Skip to content

Commit 5699e5b

Browse files
authored
feat(server): migrate fastify adapter (#339)
* feat(server): migrate fastify adapter * addressing pr comments
1 parent 95da8d2 commit 5699e5b

File tree

8 files changed

+596
-15
lines changed

8 files changed

+596
-15
lines changed

packages/server/package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,16 @@
7171
"body-parser": "^2.2.0",
7272
"express": "^5.0.0",
7373
"next": "^15.0.0",
74+
"fastify": "^5.6.1",
75+
"fastify-plugin": "^5.1.0",
7476
"supertest": "^7.1.4",
7577
"zod": "~3.25.0"
7678
},
7779
"peerDependencies": {
7880
"express": "^5.0.0",
7981
"next": "^15.0.0",
82+
"fastify": "^5.0.0",
83+
"fastify-plugin": "^5.0.0",
8084
"zod": "catalog:"
8185
},
8286
"peerDependenciesMeta": {
@@ -85,6 +89,12 @@
8589
},
8690
"next": {
8791
"optional": true
92+
},
93+
"fastify": {
94+
"optional": true
95+
},
96+
"fastify-plugin": {
97+
"optional": true
8898
}
8999
}
90100
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { SchemaDef } from "@zenstackhq/orm/schema";
2+
import type { ApiHandler } from "../types";
3+
4+
/**
5+
* Options common to all adapters
6+
*/
7+
export interface CommonAdapterOptions<Schema extends SchemaDef> {
8+
/**
9+
* The API handler to process requests
10+
*/
11+
apiHandler: ApiHandler<Schema>;
12+
}

packages/server/src/adapter/express/middleware.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,12 @@
11
import type { ClientContract } from '@zenstackhq/orm';
22
import type { SchemaDef } from '@zenstackhq/orm/schema';
33
import type { Handler, Request, Response } from 'express';
4-
import type { ApiHandler } from '../../types';
4+
import type { CommonAdapterOptions } from '../common';
55

66
/**
77
* Express middleware options
88
*/
9-
export interface MiddlewareOptions<Schema extends SchemaDef> {
10-
/**
11-
* The API handler to process requests
12-
*/
13-
apiHandler: ApiHandler<Schema>;
14-
9+
export interface MiddlewareOptions<Schema extends SchemaDef> extends CommonAdapterOptions<Schema> {
1510
/**
1611
* Callback for getting a ZenStackClient for the given request
1712
*/
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { ZenStackFastifyPlugin, type PluginOptions } from './plugin';
2+
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { ClientContract } from '@zenstackhq/orm';
2+
import type { SchemaDef } from '@zenstackhq/orm/schema';
3+
import type { FastifyPluginCallback, FastifyReply, FastifyRequest } from 'fastify';
4+
import fp from 'fastify-plugin';
5+
import type { CommonAdapterOptions } from '../common';
6+
7+
/**
8+
* Fastify plugin options
9+
*/
10+
export interface PluginOptions<Schema extends SchemaDef> extends CommonAdapterOptions<Schema> {
11+
12+
/**
13+
* Url prefix, e.g.: /api
14+
*/
15+
prefix: string;
16+
17+
/**
18+
* Callback for getting a ZenStackClient for the given request
19+
*/
20+
getClient: (request: FastifyRequest, reply: FastifyReply) => ClientContract<Schema> | Promise<ClientContract<Schema>>;
21+
}
22+
23+
/**
24+
* Fastify plugin for handling CRUD requests.
25+
*/
26+
const pluginHandler: FastifyPluginCallback<PluginOptions<SchemaDef>> = (fastify, options, done) => {
27+
const prefix = options.prefix ?? '';
28+
29+
fastify.all(`${prefix}/*`, async (request, reply) => {
30+
const client = await options.getClient(request, reply);
31+
if (!client) {
32+
reply.status(500).send({ message: 'unable to get ZenStackClient from request context' });
33+
return reply;
34+
}
35+
36+
try {
37+
const response = await options.apiHandler.handleRequest({
38+
method: request.method,
39+
path: (request.params as any)['*'],
40+
query: request.query as Record<string, string | string[]>,
41+
requestBody: request.body,
42+
client,
43+
});
44+
reply.status(response.status).send(response.body);
45+
} catch (err) {
46+
reply.status(500).send({ message: `An unhandled error occurred: ${err}` });
47+
}
48+
49+
return reply;
50+
});
51+
52+
done();
53+
};
54+
55+
const plugin = fp(pluginHandler);
56+
57+
export { plugin as ZenStackFastifyPlugin };

packages/server/src/adapter/next/index.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,10 @@ import type { ClientContract } from '@zenstackhq/orm';
22
import type { SchemaDef } from '@zenstackhq/orm/schema';
33
import type { NextApiRequest, NextApiResponse } from 'next';
44
import type { NextRequest } from 'next/server';
5-
import type { ApiHandler } from '../../types';
5+
import type { CommonAdapterOptions } from '../common';
66
import { default as AppRouteHandler } from './app-route-handler';
77
import { default as PagesRouteHandler } from './pages-route-handler';
88

9-
interface CommonAdapterOptions<Schema extends SchemaDef> {
10-
/**
11-
* The API handler to process requests
12-
*/
13-
apiHandler: ApiHandler<Schema>;
14-
}
15-
169
/**
1710
* Options for initializing a Next.js API endpoint request handler.
1811
*/
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import { createTestClient } from '@zenstackhq/testtools';
2+
import fastify from 'fastify';
3+
import { describe, expect, it } from 'vitest';
4+
import { ZenStackFastifyPlugin } from '../../src/adapter/fastify';
5+
import { RestApiHandler, RPCApiHandler } from '../../src/api';
6+
import { makeUrl, schema } from '../utils';
7+
8+
describe('Fastify adapter tests - rpc handler', () => {
9+
it('run plugin regular json', async () => {
10+
const client = await createTestClient(schema);
11+
12+
const app = fastify();
13+
app.register(ZenStackFastifyPlugin, {
14+
prefix: '/api',
15+
getClient: () => client,
16+
apiHandler: new RPCApiHandler({ schema: client.schema })
17+
});
18+
19+
let r = await app.inject({
20+
method: 'GET',
21+
url: makeUrl('/api/post/findMany', { where: { id: { equals: '1' } } }),
22+
});
23+
expect(r.statusCode).toBe(200);
24+
expect(r.json().data).toHaveLength(0);
25+
26+
r = await app.inject({
27+
method: 'POST',
28+
url: '/api/user/create',
29+
payload: {
30+
include: { posts: true },
31+
data: {
32+
id: 'user1',
33+
email: 'user1@abc.com',
34+
posts: {
35+
create: [
36+
{ title: 'post1', published: true, viewCount: 1 },
37+
{ title: 'post2', published: false, viewCount: 2 },
38+
],
39+
},
40+
},
41+
},
42+
});
43+
expect(r.statusCode).toBe(201);
44+
const data = r.json().data;
45+
expect(data).toEqual(
46+
expect.objectContaining({
47+
email: 'user1@abc.com',
48+
posts: expect.arrayContaining([
49+
expect.objectContaining({ title: 'post1' }),
50+
expect.objectContaining({ title: 'post2' }),
51+
]),
52+
})
53+
);
54+
55+
r = await app.inject({
56+
method: 'GET',
57+
url: makeUrl('/api/post/findMany'),
58+
});
59+
expect(r.statusCode).toBe(200);
60+
expect(r.json().data).toHaveLength(2);
61+
62+
r = await app.inject({
63+
method: 'GET',
64+
url: makeUrl('/api/post/findMany', { where: { viewCount: { gt: 1 } } }),
65+
});
66+
expect(r.statusCode).toBe(200);
67+
expect(r.json().data).toHaveLength(1);
68+
69+
r = await app.inject({
70+
method: 'PUT',
71+
url: '/api/user/update',
72+
payload: { where: { id: 'user1' }, data: { email: 'user1@def.com' } },
73+
});
74+
expect(r.statusCode).toBe(200);
75+
expect(r.json().data.email).toBe('user1@def.com');
76+
77+
r = await app.inject({
78+
method: 'GET',
79+
url: makeUrl('/api/post/count', { where: { viewCount: { gt: 1 } } }),
80+
});
81+
expect(r.statusCode).toBe(200);
82+
expect(r.json().data).toBe(1);
83+
84+
r = await app.inject({
85+
method: 'GET',
86+
url: makeUrl('/api/post/aggregate', { _sum: { viewCount: true } }),
87+
});
88+
expect(r.statusCode).toBe(200);
89+
expect(r.json().data._sum.viewCount).toBe(3);
90+
91+
r = await app.inject({
92+
method: 'GET',
93+
url: makeUrl('/api/post/groupBy', { by: ['published'], _sum: { viewCount: true } }),
94+
});
95+
expect(r.statusCode).toBe(200);
96+
expect(r.json().data).toEqual(
97+
expect.arrayContaining([
98+
expect.objectContaining({ published: true, _sum: { viewCount: 1 } }),
99+
expect.objectContaining({ published: false, _sum: { viewCount: 2 } }),
100+
])
101+
);
102+
103+
r = await app.inject({
104+
method: 'DELETE',
105+
url: makeUrl('/api/user/deleteMany', { where: { id: 'user1' } }),
106+
});
107+
expect(r.statusCode).toBe(200);
108+
expect(r.json().data.count).toBe(1);
109+
});
110+
111+
it('invalid path or args', async () => {
112+
const client = await createTestClient(schema);
113+
114+
const app = fastify();
115+
app.register(ZenStackFastifyPlugin, {
116+
prefix: '/api',
117+
getClient: () => client,
118+
apiHandler: new RPCApiHandler({ schema: client.schema }),
119+
});
120+
121+
let r = await app.inject({
122+
method: 'GET',
123+
url: '/api/post/',
124+
});
125+
expect(r.statusCode).toBe(400);
126+
127+
r = await app.inject({
128+
method: 'GET',
129+
url: '/api/post/findMany/abc',
130+
});
131+
expect(r.statusCode).toBe(400);
132+
133+
r = await app.inject({
134+
method: 'GET',
135+
url: '/api/post/findMany?q=abc',
136+
});
137+
expect(r.statusCode).toBe(400);
138+
});
139+
});
140+
141+
describe('Fastify adapter tests - rest handler', () => {
142+
it('run plugin regular json', async () => {
143+
const client = await createTestClient(schema);
144+
145+
const app = fastify();
146+
app.register(ZenStackFastifyPlugin, {
147+
prefix: '/api',
148+
getClient: () => client,
149+
apiHandler: new RestApiHandler({ schema: client.schema, endpoint: 'http://localhost/api' }),
150+
});
151+
152+
let r = await app.inject({
153+
method: 'GET',
154+
url: '/api/post/1',
155+
});
156+
expect(r.statusCode).toBe(404);
157+
158+
r = await app.inject({
159+
method: 'POST',
160+
url: '/api/user',
161+
payload: {
162+
data: {
163+
type: 'User',
164+
attributes: {
165+
id: 'user1',
166+
email: 'user1@abc.com',
167+
},
168+
},
169+
},
170+
});
171+
expect(r.statusCode).toBe(201);
172+
expect(r.json()).toMatchObject({
173+
jsonapi: { version: '1.1' },
174+
data: { type: 'User', id: 'user1', attributes: { email: 'user1@abc.com' } },
175+
});
176+
177+
r = await app.inject({ method: 'GET', url: '/api/user?filter[id]=user1' });
178+
expect(r.json().data).toHaveLength(1);
179+
180+
r = await app.inject({ method: 'GET', url: '/api/user?filter[id]=user2' });
181+
expect(r.json().data).toHaveLength(0);
182+
183+
r = await app.inject({ method: 'GET', url: '/api/user?filter[id]=user1&filter[email]=xyz' });
184+
expect(r.json().data).toHaveLength(0);
185+
186+
r = await app.inject({
187+
method: 'PUT',
188+
url: '/api/user/user1',
189+
payload: { data: { type: 'User', attributes: { email: 'user1@def.com' } } },
190+
});
191+
expect(r.statusCode).toBe(200);
192+
expect(r.json().data.attributes.email).toBe('user1@def.com');
193+
194+
r = await app.inject({ method: 'DELETE', url: '/api/user/user1' });
195+
expect(r.statusCode).toBe(200);
196+
expect(await client.user.findMany()).toHaveLength(0);
197+
});
198+
});

0 commit comments

Comments
 (0)