Skip to content
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
5 changes: 5 additions & 0 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
"fastify": "^5.6.1",
"fastify-plugin": "^5.1.0",
"elysia": "^1.3.1",
"hono": "^4.6.3",
"supertest": "^7.1.4",
"zod": "~3.25.0"
},
Expand All @@ -83,6 +84,7 @@
"fastify": "^5.0.0",
"fastify-plugin": "^5.0.0",
"elysia": "^1.3.0",
"hono": "^4.6.0",
"zod": "catalog:"
},
"peerDependenciesMeta": {
Expand All @@ -100,6 +102,9 @@
},
"elysia": {
"optional": true
},
"hono": {
"optional": true
}
}
}
7 changes: 6 additions & 1 deletion packages/server/src/adapter/common.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { SchemaDef } from "@zenstackhq/orm/schema";
import type { ApiHandler } from "../types";
import { log } from "../api/utils";
import type { ApiHandler, LogConfig } from "../types";

/**
* Options common to all adapters
Expand All @@ -9,4 +10,8 @@ export interface CommonAdapterOptions<Schema extends SchemaDef> {
* The API handler to process requests
*/
apiHandler: ApiHandler<Schema>;
}

export function logInternalError(logger: LogConfig | undefined, err: unknown) {
log(logger, 'error', `An unhandled error occurred while processing the request: ${err}${err instanceof Error ? '\n' + err.stack : ''}`);
}
5 changes: 2 additions & 3 deletions packages/server/src/adapter/elysia/handler.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import type { ClientContract } from '@zenstackhq/orm';
import type { SchemaDef } from '@zenstackhq/orm/schema';
import { Elysia, type Context as ElysiaContext } from 'elysia';
import { log } from '../../api/utils';
import type { CommonAdapterOptions } from '../common';
import { logInternalError, type CommonAdapterOptions } from '../common';

/**
* Options for initializing an Elysia middleware.
Expand Down Expand Up @@ -66,7 +65,7 @@ export function createElysiaHandler<Schema extends SchemaDef>(options: ElysiaOpt
return r.body;
} catch (err) {
set.status = 500;
log(options.apiHandler.log, 'error', `An unhandled error occurred while processing the request: ${err}${err instanceof Error ? '\n' + err.stack : ''}`);
logInternalError(options.apiHandler.log, err);
return {
message: 'An internal server error occurred',
};
Expand Down
5 changes: 2 additions & 3 deletions packages/server/src/adapter/express/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import type { ClientContract } from '@zenstackhq/orm';
import type { SchemaDef } from '@zenstackhq/orm/schema';
import type { Handler, Request, Response } from 'express';
import { log } from '../../api/utils';
import type { CommonAdapterOptions } from '../common';
import { logInternalError, type CommonAdapterOptions } from '../common';

/**
* Express middleware options
Expand Down Expand Up @@ -71,7 +70,7 @@ const factory = <Schema extends SchemaDef>(options: MiddlewareOptions<Schema>):
if (sendResponse === false) {
throw err;
}
log(options.apiHandler.log, 'error', `An unhandled error occurred while processing the request: ${err}${err instanceof Error ? '\n' + err.stack : ''}`);
logInternalError(options.apiHandler.log, err);
return response.status(500).json({ message: `An internal server error occurred` });
}
};
Expand Down
5 changes: 2 additions & 3 deletions packages/server/src/adapter/fastify/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ 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 { log } from '../../api/utils';
import type { CommonAdapterOptions } from '../common';
import { logInternalError, type CommonAdapterOptions } from '../common';

/**
* Fastify plugin options
Expand Down Expand Up @@ -44,7 +43,7 @@ const pluginHandler: FastifyPluginCallback<PluginOptions<SchemaDef>> = (fastify,
});
reply.status(response.status).send(response.body);
} catch (err) {
log(options.apiHandler.log, 'error', `An unhandled error occurred while processing the request: ${err}${err instanceof Error ? '\n' + err.stack : ''}`);
logInternalError(options.apiHandler.log, err);
reply.status(500).send({ message: `An internal server error occurred` });
}

Expand Down
56 changes: 56 additions & 0 deletions packages/server/src/adapter/hono/handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { ClientContract } from '@zenstackhq/orm';
import type { SchemaDef } from '@zenstackhq/orm/schema';
import type { Context, MiddlewareHandler } from 'hono';
import { routePath } from 'hono/route';
import type { ContentfulStatusCode } from 'hono/utils/http-status';
import { logInternalError, type CommonAdapterOptions } from '../common';

/**
* Options for initializing a Hono middleware.
*/
export interface HonoOptions<Schema extends SchemaDef> extends CommonAdapterOptions<Schema> {
/**
* Callback method for getting a ZenStackClient instance for the given request.
*/
getClient: (ctx: Context) => Promise<ClientContract<Schema>> | ClientContract<Schema>;
}

export function createHonoHandler<Schema extends SchemaDef>(options: HonoOptions<Schema>): MiddlewareHandler {
return async (ctx) => {
const client = await options.getClient(ctx);
if (!client) {
return ctx.json({ message: 'unable to get ZenStackClient from request context' }, 500);
}

const url = new URL(ctx.req.url);
const query = Object.fromEntries(url.searchParams);

const path = ctx.req.path.substring(routePath(ctx).length - 1);
if (!path) {
return ctx.json({ message: 'missing path parameter' }, 400);
}

let requestBody: unknown;
if (ctx.req.raw.body) {
try {
requestBody = await ctx.req.json();
} catch {
// noop
}
}

try {
const r = await options.apiHandler.handleRequest({
method: ctx.req.method,
path,
query,
requestBody,
client,
});
return ctx.json(r.body as object, r.status as ContentfulStatusCode);
} catch (err) {
logInternalError(options.apiHandler.log, err);
return ctx.json({ message: `An internal server error occurred` }, 500);
}
};
}
1 change: 1 addition & 0 deletions packages/server/src/adapter/hono/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './handler';
4 changes: 2 additions & 2 deletions packages/server/test/adapter/express.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { RestApiHandler } from '../../src/api/rest';
import { makeUrl, schema } from '../utils';

describe('Express adapter tests - rpc handler', () => {
it('works with simple requests', async () => {
it('properly handles requests', async () => {
const client = await createPolicyTestClient(schema);
const rawClient = client.$unuseAll();

Expand Down Expand Up @@ -148,7 +148,7 @@ describe('Express adapter tests - rest handler', () => {
});

describe('Express adapter tests - rest handler with custom middleware', () => {
it('run middleware', async () => {
it('properly handles requests', async () => {
const client = await createPolicyTestClient(schema);

const app = express();
Expand Down
6 changes: 3 additions & 3 deletions packages/server/test/adapter/fastify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { RestApiHandler, RPCApiHandler } from '../../src/api';
import { makeUrl, schema } from '../utils';

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

const app = fastify();
Expand Down Expand Up @@ -108,7 +108,7 @@ describe('Fastify adapter tests - rpc handler', () => {
expect(r.json().data.count).toBe(1);
});

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

const app = fastify();
Expand Down Expand Up @@ -139,7 +139,7 @@ describe('Fastify adapter tests - rpc handler', () => {
});

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

const app = fastify();
Expand Down
163 changes: 163 additions & 0 deletions packages/server/test/adapter/hono.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { createTestClient } from '@zenstackhq/testtools';
import { Hono, MiddlewareHandler } from 'hono';
import superjson from 'superjson';
import { describe, expect, it } from 'vitest';
import { createHonoHandler } from '../../src/adapter/hono';
import { RPCApiHandler } from '../../src/api';
import { makeUrl, schema } from '../utils';

describe('Hono adapter tests - rpc handler', () => {
it('properly handles requests', async () => {
const client = await createTestClient(schema);

const handler = await createHonoApp(createHonoHandler({ getClient: () => client, apiHandler: new RPCApiHandler({schema: client.$schema}) }));

let r = await handler(makeRequest('GET', makeUrl('/api/post/findMany', { where: { id: { equals: '1' } } })));
expect(r.status).toBe(200);
expect((await unmarshal(r)).data).toHaveLength(0);

r = await handler(
makeRequest('POST', '/api/user/create', {
include: { posts: true },
data: {
id: 'user1',
email: '[email protected]',
posts: {
create: [
{ title: 'post1', published: true, viewCount: 1 },
{ title: 'post2', published: false, viewCount: 2 },
],
},
},
})
);
expect(r.status).toBe(201);
expect((await unmarshal(r)).data).toMatchObject({
email: '[email protected]',
posts: expect.arrayContaining([
expect.objectContaining({ title: 'post1' }),
expect.objectContaining({ title: 'post2' }),
]),
});

r = await handler(makeRequest('GET', makeUrl('/api/post/findMany')));
expect(r.status).toBe(200);
expect((await unmarshal(r)).data).toHaveLength(2);

r = await handler(makeRequest('GET', makeUrl('/api/post/findMany', { where: { viewCount: { gt: 1 } } })));
expect(r.status).toBe(200);
expect((await unmarshal(r)).data).toHaveLength(1);

r = await handler(
makeRequest('PUT', '/api/user/update', { where: { id: 'user1' }, data: { email: '[email protected]' } })
);
expect(r.status).toBe(200);
expect((await unmarshal(r)).data.email).toBe('[email protected]');

r = await handler(makeRequest('GET', makeUrl('/api/post/count', { where: { viewCount: { gt: 1 } } })));
expect(r.status).toBe(200);
expect((await unmarshal(r)).data).toBe(1);

r = await handler(makeRequest('GET', makeUrl('/api/post/aggregate', { _sum: { viewCount: true } })));
expect(r.status).toBe(200);
expect((await unmarshal(r)).data._sum.viewCount).toBe(3);

r = await handler(
makeRequest('GET', makeUrl('/api/post/groupBy', { by: ['published'], _sum: { viewCount: true } }))
);
expect(r.status).toBe(200);
expect((await unmarshal(r)).data).toEqual(
expect.arrayContaining([
expect.objectContaining({ published: true, _sum: { viewCount: 1 } }),
expect.objectContaining({ published: false, _sum: { viewCount: 2 } }),
])
);

r = await handler(makeRequest('DELETE', makeUrl('/api/user/deleteMany', { where: { id: 'user1' } })));
expect(r.status).toBe(200);
expect((await unmarshal(r)).data.count).toBe(1);
});
});

describe('Hono adapter tests - rest handler', () => {
it('properly handles requests', async () => {
const client = await createTestClient(schema);

const handler = await createHonoApp(
createHonoHandler({
getClient: () => client,
apiHandler: new RPCApiHandler({ schema: client.$schema }),
})
);

let r = await handler(makeRequest('GET', makeUrl('/api/post/1')));
expect(r.status).toBe(404);

Check failure on line 94 in packages/server/test/adapter/hono.test.ts

View workflow job for this annotation

GitHub Actions / build-test (20.x, sqlite)

test/adapter/hono.test.ts > Hono adapter tests - rest handler > properly handles requests

AssertionError: expected 400 to be 404 // Object.is equality - Expected + Received - 404 + 400 ❯ test/adapter/hono.test.ts:94:26

Check failure on line 94 in packages/server/test/adapter/hono.test.ts

View workflow job for this annotation

GitHub Actions / build-test (20.x, postgresql)

test/adapter/hono.test.ts > Hono adapter tests - rest handler > properly handles requests

AssertionError: expected 400 to be 404 // Object.is equality - Expected + Received - 404 + 400 ❯ test/adapter/hono.test.ts:94:26

r = await handler(
makeRequest('POST', '/api/user')
);

r = await handler(makeRequest('GET', makeUrl('/api/post/1')));
expect(r.status).toBe(404);

r = await handler(
makeRequest('POST', '/api/user', {
data: {
type: 'user',
attributes: { id: 'user1', email: '[email protected]' },
},
})
);
expect(r.status).toBe(201);
expect(await unmarshal(r)).toMatchObject({
data: {
id: 'user1',
attributes: {
email: '[email protected]',
},
},
});

r = await handler(makeRequest('GET', makeUrl('/api/user?filter[id]=user1')));
expect(r.status).toBe(200);
expect((await unmarshal(r)).data).toHaveLength(1);

r = await handler(makeRequest('GET', makeUrl('/api/user?filter[id]=user2')));
expect(r.status).toBe(200);
expect((await unmarshal(r)).data).toHaveLength(0);

r = await handler(makeRequest('GET', makeUrl('/api/user?filter[id]=user1&filter[email]=xyz')));
expect(r.status).toBe(200);
expect((await unmarshal(r)).data).toHaveLength(0);

r = await handler(
makeRequest('PUT', makeUrl('/api/user/user1'), {
data: { type: 'user', attributes: { email: '[email protected]' } },
})
);
expect(r.status).toBe(200);
expect((await unmarshal(r)).data.attributes.email).toBe('[email protected]');

r = await handler(makeRequest('DELETE', makeUrl('/api/user/user1')));
expect(r.status).toBe(200);
expect(await client.user.findMany()).toHaveLength(0);
});
});

function makeRequest(method: string, path: string, body?: any) {
const payload = body ? JSON.stringify(body) : undefined;
return new Request(`http://localhost${path}`, { method, body: payload });
}

async function unmarshal(r: Response, useSuperJson = false) {
const text = await r.text();
return (useSuperJson ? superjson.parse(text) : JSON.parse(text)) as any;
}

async function createHonoApp(middleware: MiddlewareHandler) {
const app = new Hono();

app.use('/api/*', middleware);

return app.fetch;
}
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading