Skip to content
This repository was archived by the owner on Mar 1, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,16 @@
"body-parser": "^2.2.0",
"express": "^5.0.0",
"next": "^15.0.0",
"fastify": "^5.6.1",
"fastify-plugin": "^5.1.0",
"supertest": "^7.1.4",
"zod": "~3.25.0"
},
"peerDependencies": {
"express": "^5.0.0",
"next": "^15.0.0",
"fastify": "^5.0.0",
"fastify-plugin": "^5.0.0",
"zod": "catalog:"
},
"peerDependenciesMeta": {
Expand All @@ -85,6 +89,12 @@
},
"next": {
"optional": true
},
"fastify": {
"optional": true
},
"fastify-plugin": {
"optional": true
}
}
}
12 changes: 12 additions & 0 deletions packages/server/src/adapter/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { SchemaDef } from "@zenstackhq/orm/schema";
import type { ApiHandler } from "../types";

/**
* Options common to all adapters
*/
export interface CommonAdapterOptions<Schema extends SchemaDef> {
/**
* The API handler to process requests
*/
apiHandler: ApiHandler<Schema>;
}
9 changes: 2 additions & 7 deletions packages/server/src/adapter/express/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
import type { ClientContract } from '@zenstackhq/orm';
import type { SchemaDef } from '@zenstackhq/orm/schema';
import type { Handler, Request, Response } from 'express';
import type { ApiHandler } from '../../types';
import type { CommonAdapterOptions } from '../common';

/**
* Express middleware options
*/
export interface MiddlewareOptions<Schema extends SchemaDef> {
/**
* The API handler to process requests
*/
apiHandler: ApiHandler<Schema>;

export interface MiddlewareOptions<Schema extends SchemaDef> extends CommonAdapterOptions<Schema> {
Comment thread
ymc9 marked this conversation as resolved.
Outdated
/**
* Callback for getting a ZenStackClient for the given request
*/
Expand Down
2 changes: 2 additions & 0 deletions packages/server/src/adapter/fastify/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { ZenStackFastifyPlugin, type PluginOptions } from './plugin';

57 changes: 57 additions & 0 deletions packages/server/src/adapter/fastify/plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { ClientContract } from '@zenstackhq/orm';
import type { SchemaDef } from '@zenstackhq/orm/schema';
import type { FastifyPluginCallback, FastifyReply, FastifyRequest } from 'fastify';
import fp from 'fastify-plugin';
import type { CommonAdapterOptions } from '../common';

/**
* Fastify plugin options
*/
export interface PluginOptions<Schema extends SchemaDef> extends CommonAdapterOptions<Schema> {

/**
* Url prefix, e.g.: /api
*/
prefix: string;

/**
* Callback for getting a PrismaClient for the given request
Comment thread
ymc9 marked this conversation as resolved.
Outdated
*/
getClient: (request: FastifyRequest, reply: FastifyReply) => ClientContract<Schema> | Promise<ClientContract<Schema>>;
}
Comment thread
ymc9 marked this conversation as resolved.

/**
* Fastify plugin for handling CRUD requests.
*/
const pluginHandler: FastifyPluginCallback<PluginOptions<SchemaDef>> = (fastify, options, done) => {
const prefix = options.prefix ?? '';

fastify.all(`${prefix}/*`, async (request, reply) => {
const client = await options.getClient(request, reply);
if (!client) {
reply.status(500).send({ message: 'unable to get prisma from request context' });
Comment thread
ymc9 marked this conversation as resolved.
Outdated
return reply;
}
Comment thread
ymc9 marked this conversation as resolved.

try {
const response = await options.apiHandler.handleRequest({
method: request.method,
path: (request.params as any)['*'],
query: request.query as Record<string, string | string[]>,
requestBody: request.body,
client,
});
reply.status(response.status).send(response.body);
} catch (err) {
reply.status(500).send({ message: `An unhandled error occurred: ${err}` });
}

return reply;
});

done();
};

const plugin = fp(pluginHandler);

export { plugin as ZenStackFastifyPlugin };
9 changes: 1 addition & 8 deletions packages/server/src/adapter/next/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,10 @@ import type { ClientContract } from '@zenstackhq/orm';
import type { SchemaDef } from '@zenstackhq/orm/schema';
import type { NextApiRequest, NextApiResponse } from 'next';
import type { NextRequest } from 'next/server';
import type { ApiHandler } from '../../types';
import type { CommonAdapterOptions } from '../common';
import { default as AppRouteHandler } from './app-route-handler';
import { default as PagesRouteHandler } from './pages-route-handler';

interface CommonAdapterOptions<Schema extends SchemaDef> {
/**
* The API handler to process requests
*/
apiHandler: ApiHandler<Schema>;
}

/**
* Options for initializing a Next.js API endpoint request handler.
*/
Expand Down
198 changes: 198 additions & 0 deletions packages/server/test/adapter/fastify.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { createTestClient } from '@zenstackhq/testtools';
import fastify from 'fastify';
import { describe, expect, it } from 'vitest';
import { ZenStackFastifyPlugin } from '../../src/adapter/fastify';
import { RestApiHandler, RPCApiHandler } from '../../src/api';
import { makeUrl, schema } from '../utils';

describe('Fastify adapter tests - rpc handler', () => {
it('run plugin regular json', async () => {
const client = await createTestClient(schema);

const app = fastify();
app.register(ZenStackFastifyPlugin, {
prefix: '/api',
getClient: () => client,
apiHandler: new RPCApiHandler({ schema: client.schema })
});

let r = await app.inject({
method: 'GET',
url: makeUrl('/api/post/findMany', { where: { id: { equals: '1' } } }),
});
expect(r.statusCode).toBe(200);
expect(r.json().data).toHaveLength(0);

r = await app.inject({
method: 'POST',
url: '/api/user/create',
payload: {
include: { posts: true },
data: {
id: 'user1',
email: 'user1@abc.com',
posts: {
create: [
{ title: 'post1', published: true, viewCount: 1 },
{ title: 'post2', published: false, viewCount: 2 },
],
},
},
},
});
expect(r.statusCode).toBe(201);
const data = r.json().data;
expect(data).toEqual(
expect.objectContaining({
email: 'user1@abc.com',
posts: expect.arrayContaining([
expect.objectContaining({ title: 'post1' }),
expect.objectContaining({ title: 'post2' }),
]),
})
);

r = await app.inject({
method: 'GET',
url: makeUrl('/api/post/findMany'),
});
expect(r.statusCode).toBe(200);
expect(r.json().data).toHaveLength(2);

r = await app.inject({
method: 'GET',
url: makeUrl('/api/post/findMany', { where: { viewCount: { gt: 1 } } }),
});
expect(r.statusCode).toBe(200);
expect(r.json().data).toHaveLength(1);

r = await app.inject({
method: 'PUT',
url: '/api/user/update',
payload: { where: { id: 'user1' }, data: { email: 'user1@def.com' } },
});
expect(r.statusCode).toBe(200);
expect(r.json().data.email).toBe('user1@def.com');

r = await app.inject({
method: 'GET',
url: makeUrl('/api/post/count', { where: { viewCount: { gt: 1 } } }),
});
expect(r.statusCode).toBe(200);
expect(r.json().data).toBe(1);

r = await app.inject({
method: 'GET',
url: makeUrl('/api/post/aggregate', { _sum: { viewCount: true } }),
});
expect(r.statusCode).toBe(200);
expect(r.json().data._sum.viewCount).toBe(3);

r = await app.inject({
method: 'GET',
url: makeUrl('/api/post/groupBy', { by: ['published'], _sum: { viewCount: true } }),
});
expect(r.statusCode).toBe(200);
expect(r.json().data).toEqual(
expect.arrayContaining([
expect.objectContaining({ published: true, _sum: { viewCount: 1 } }),
expect.objectContaining({ published: false, _sum: { viewCount: 2 } }),
])
);

r = await app.inject({
method: 'DELETE',
url: makeUrl('/api/user/deleteMany', { where: { id: 'user1' } }),
});
expect(r.statusCode).toBe(200);
expect(r.json().data.count).toBe(1);
});

it('invalid path or args', async () => {
const client = await createTestClient(schema);

const app = fastify();
app.register(ZenStackFastifyPlugin, {
prefix: '/api',
getClient: () => client,
apiHandler: new RPCApiHandler({ schema: client.schema }),
});

let r = await app.inject({
method: 'GET',
url: '/api/post/',
});
expect(r.statusCode).toBe(400);

r = await app.inject({
method: 'GET',
url: '/api/post/findMany/abc',
});
expect(r.statusCode).toBe(400);

r = await app.inject({
method: 'GET',
url: '/api/post/findMany?q=abc',
});
expect(r.statusCode).toBe(400);
});
});

describe('Fastify adapter tests - rest handler', () => {
it('run plugin regular json', async () => {
const client = await createTestClient(schema);

const app = fastify();
app.register(ZenStackFastifyPlugin, {
prefix: '/api',
getClient: () => client,
apiHandler: new RestApiHandler({ schema: client.schema, endpoint: 'http://localhost/api' }),
});

let r = await app.inject({
method: 'GET',
url: '/api/post/1',
});
expect(r.statusCode).toBe(404);

r = await app.inject({
method: 'POST',
url: '/api/user',
payload: {
data: {
type: 'User',
attributes: {
id: 'user1',
email: 'user1@abc.com',
},
},
},
});
expect(r.statusCode).toBe(201);
expect(r.json()).toMatchObject({
jsonapi: { version: '1.1' },
data: { type: 'User', id: 'user1', attributes: { email: 'user1@abc.com' } },
});

r = await app.inject({ method: 'GET', url: '/api/user?filter[id]=user1' });
expect(r.json().data).toHaveLength(1);

r = await app.inject({ method: 'GET', url: '/api/user?filter[id]=user2' });
expect(r.json().data).toHaveLength(0);

r = await app.inject({ method: 'GET', url: '/api/user?filter[id]=user1&filter[email]=xyz' });
expect(r.json().data).toHaveLength(0);

r = await app.inject({
method: 'PUT',
url: '/api/user/user1',
payload: { data: { type: 'User', attributes: { email: 'user1@def.com' } } },
});
expect(r.statusCode).toBe(200);
expect(r.json().data.attributes.email).toBe('user1@def.com');

r = await app.inject({ method: 'DELETE', url: '/api/user/user1' });
expect(r.statusCode).toBe(200);
expect(await client.user.findMany()).toHaveLength(0);
});
});
Loading
Loading