Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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> {
/**
* 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 ZenStackClient for the given request
*/
getClient: (request: FastifyRequest, reply: FastifyReply) => ClientContract<Schema> | Promise<ClientContract<Schema>>;
}

/**
* 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 ZenStackClient from request context' });
return reply;
}

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