Skip to content

Commit 8119298

Browse files
authored
feat(server): migrate hono adapter (#341)
* feat(server): migrate hono adapter * fix test
1 parent 2525ef3 commit 8119298

File tree

11 files changed

+244
-15
lines changed

11 files changed

+244
-15
lines changed

packages/server/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
"fastify": "^5.6.1",
7575
"fastify-plugin": "^5.1.0",
7676
"elysia": "^1.3.1",
77+
"hono": "^4.6.3",
7778
"supertest": "^7.1.4",
7879
"zod": "~3.25.0"
7980
},
@@ -83,6 +84,7 @@
8384
"fastify": "^5.0.0",
8485
"fastify-plugin": "^5.0.0",
8586
"elysia": "^1.3.0",
87+
"hono": "^4.6.0",
8688
"zod": "catalog:"
8789
},
8890
"peerDependenciesMeta": {
@@ -100,6 +102,9 @@
100102
},
101103
"elysia": {
102104
"optional": true
105+
},
106+
"hono": {
107+
"optional": true
103108
}
104109
}
105110
}

packages/server/src/adapter/common.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { SchemaDef } from "@zenstackhq/orm/schema";
2-
import type { ApiHandler } from "../types";
2+
import { log } from "../api/utils";
3+
import type { ApiHandler, LogConfig } from "../types";
34

45
/**
56
* Options common to all adapters
@@ -9,4 +10,8 @@ export interface CommonAdapterOptions<Schema extends SchemaDef> {
910
* The API handler to process requests
1011
*/
1112
apiHandler: ApiHandler<Schema>;
13+
}
14+
15+
export function logInternalError(logger: LogConfig | undefined, err: unknown) {
16+
log(logger, 'error', `An unhandled error occurred while processing the request: ${err}${err instanceof Error ? '\n' + err.stack : ''}`);
1217
}

packages/server/src/adapter/elysia/handler.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import type { ClientContract } from '@zenstackhq/orm';
22
import type { SchemaDef } from '@zenstackhq/orm/schema';
33
import { Elysia, type Context as ElysiaContext } from 'elysia';
4-
import { log } from '../../api/utils';
5-
import type { CommonAdapterOptions } from '../common';
4+
import { logInternalError, type CommonAdapterOptions } from '../common';
65

76
/**
87
* Options for initializing an Elysia middleware.
@@ -66,7 +65,7 @@ export function createElysiaHandler<Schema extends SchemaDef>(options: ElysiaOpt
6665
return r.body;
6766
} catch (err) {
6867
set.status = 500;
69-
log(options.apiHandler.log, 'error', `An unhandled error occurred while processing the request: ${err}${err instanceof Error ? '\n' + err.stack : ''}`);
68+
logInternalError(options.apiHandler.log, err);
7069
return {
7170
message: 'An internal server error occurred',
7271
};

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
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 { log } from '../../api/utils';
5-
import type { CommonAdapterOptions } from '../common';
4+
import { logInternalError, type CommonAdapterOptions } from '../common';
65

76
/**
87
* Express middleware options
@@ -71,7 +70,7 @@ const factory = <Schema extends SchemaDef>(options: MiddlewareOptions<Schema>):
7170
if (sendResponse === false) {
7271
throw err;
7372
}
74-
log(options.apiHandler.log, 'error', `An unhandled error occurred while processing the request: ${err}${err instanceof Error ? '\n' + err.stack : ''}`);
73+
logInternalError(options.apiHandler.log, err);
7574
return response.status(500).json({ message: `An internal server error occurred` });
7675
}
7776
};

packages/server/src/adapter/fastify/plugin.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import type { ClientContract } from '@zenstackhq/orm';
22
import type { SchemaDef } from '@zenstackhq/orm/schema';
33
import type { FastifyPluginCallback, FastifyReply, FastifyRequest } from 'fastify';
44
import fp from 'fastify-plugin';
5-
import { log } from '../../api/utils';
6-
import type { CommonAdapterOptions } from '../common';
5+
import { logInternalError, type CommonAdapterOptions } from '../common';
76

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

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { ClientContract } from '@zenstackhq/orm';
2+
import type { SchemaDef } from '@zenstackhq/orm/schema';
3+
import type { Context, MiddlewareHandler } from 'hono';
4+
import { routePath } from 'hono/route';
5+
import type { ContentfulStatusCode } from 'hono/utils/http-status';
6+
import { logInternalError, type CommonAdapterOptions } from '../common';
7+
8+
/**
9+
* Options for initializing a Hono middleware.
10+
*/
11+
export interface HonoOptions<Schema extends SchemaDef> extends CommonAdapterOptions<Schema> {
12+
/**
13+
* Callback method for getting a ZenStackClient instance for the given request.
14+
*/
15+
getClient: (ctx: Context) => Promise<ClientContract<Schema>> | ClientContract<Schema>;
16+
}
17+
18+
export function createHonoHandler<Schema extends SchemaDef>(options: HonoOptions<Schema>): MiddlewareHandler {
19+
return async (ctx) => {
20+
const client = await options.getClient(ctx);
21+
if (!client) {
22+
return ctx.json({ message: 'unable to get ZenStackClient from request context' }, 500);
23+
}
24+
25+
const url = new URL(ctx.req.url);
26+
const query = Object.fromEntries(url.searchParams);
27+
28+
const path = ctx.req.path.substring(routePath(ctx).length - 1);
29+
if (!path) {
30+
return ctx.json({ message: 'missing path parameter' }, 400);
31+
}
32+
33+
let requestBody: unknown;
34+
if (ctx.req.raw.body) {
35+
try {
36+
requestBody = await ctx.req.json();
37+
} catch {
38+
// noop
39+
}
40+
}
41+
42+
try {
43+
const r = await options.apiHandler.handleRequest({
44+
method: ctx.req.method,
45+
path,
46+
query,
47+
requestBody,
48+
client,
49+
});
50+
return ctx.json(r.body as object, r.status as ContentfulStatusCode);
51+
} catch (err) {
52+
logInternalError(options.apiHandler.log, err);
53+
return ctx.json({ message: `An internal server error occurred` }, 500);
54+
}
55+
};
56+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './handler';

packages/server/test/adapter/express.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { RestApiHandler } from '../../src/api/rest';
99
import { makeUrl, schema } from '../utils';
1010

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

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

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

154154
const app = express();

packages/server/test/adapter/fastify.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { RestApiHandler, RPCApiHandler } from '../../src/api';
66
import { makeUrl, schema } from '../utils';
77

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

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

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

114114
const app = fastify();
@@ -139,7 +139,7 @@ describe('Fastify adapter tests - rpc handler', () => {
139139
});
140140

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

145145
const app = fastify();
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { createTestClient } from '@zenstackhq/testtools';
2+
import { Hono, MiddlewareHandler } from 'hono';
3+
import superjson from 'superjson';
4+
import { describe, expect, it } from 'vitest';
5+
import { createHonoHandler } from '../../src/adapter/hono';
6+
import { RestApiHandler, RPCApiHandler } from '../../src/api';
7+
import { makeUrl, schema } from '../utils';
8+
9+
describe('Hono adapter tests - rpc handler', () => {
10+
it('properly handles requests', async () => {
11+
const client = await createTestClient(schema);
12+
13+
const handler = await createHonoApp(createHonoHandler({ getClient: () => client, apiHandler: new RPCApiHandler({schema: client.$schema}) }));
14+
15+
let r = await handler(makeRequest('GET', makeUrl('/api/post/findMany', { where: { id: { equals: '1' } } })));
16+
expect(r.status).toBe(200);
17+
expect((await unmarshal(r)).data).toHaveLength(0);
18+
19+
r = await handler(
20+
makeRequest('POST', '/api/user/create', {
21+
include: { posts: true },
22+
data: {
23+
id: 'user1',
24+
25+
posts: {
26+
create: [
27+
{ title: 'post1', published: true, viewCount: 1 },
28+
{ title: 'post2', published: false, viewCount: 2 },
29+
],
30+
},
31+
},
32+
})
33+
);
34+
expect(r.status).toBe(201);
35+
expect((await unmarshal(r)).data).toMatchObject({
36+
37+
posts: expect.arrayContaining([
38+
expect.objectContaining({ title: 'post1' }),
39+
expect.objectContaining({ title: 'post2' }),
40+
]),
41+
});
42+
43+
r = await handler(makeRequest('GET', makeUrl('/api/post/findMany')));
44+
expect(r.status).toBe(200);
45+
expect((await unmarshal(r)).data).toHaveLength(2);
46+
47+
r = await handler(makeRequest('GET', makeUrl('/api/post/findMany', { where: { viewCount: { gt: 1 } } })));
48+
expect(r.status).toBe(200);
49+
expect((await unmarshal(r)).data).toHaveLength(1);
50+
51+
r = await handler(
52+
makeRequest('PUT', '/api/user/update', { where: { id: 'user1' }, data: { email: '[email protected]' } })
53+
);
54+
expect(r.status).toBe(200);
55+
expect((await unmarshal(r)).data.email).toBe('[email protected]');
56+
57+
r = await handler(makeRequest('GET', makeUrl('/api/post/count', { where: { viewCount: { gt: 1 } } })));
58+
expect(r.status).toBe(200);
59+
expect((await unmarshal(r)).data).toBe(1);
60+
61+
r = await handler(makeRequest('GET', makeUrl('/api/post/aggregate', { _sum: { viewCount: true } })));
62+
expect(r.status).toBe(200);
63+
expect((await unmarshal(r)).data._sum.viewCount).toBe(3);
64+
65+
r = await handler(
66+
makeRequest('GET', makeUrl('/api/post/groupBy', { by: ['published'], _sum: { viewCount: true } }))
67+
);
68+
expect(r.status).toBe(200);
69+
expect((await unmarshal(r)).data).toEqual(
70+
expect.arrayContaining([
71+
expect.objectContaining({ published: true, _sum: { viewCount: 1 } }),
72+
expect.objectContaining({ published: false, _sum: { viewCount: 2 } }),
73+
])
74+
);
75+
76+
r = await handler(makeRequest('DELETE', makeUrl('/api/user/deleteMany', { where: { id: 'user1' } })));
77+
expect(r.status).toBe(200);
78+
expect((await unmarshal(r)).data.count).toBe(1);
79+
});
80+
});
81+
82+
describe('Hono adapter tests - rest handler', () => {
83+
it('properly handles requests', async () => {
84+
const client = await createTestClient(schema);
85+
86+
const handler = await createHonoApp(
87+
createHonoHandler({
88+
getClient: () => client,
89+
apiHandler: new RestApiHandler({ endpoint: 'http://localhost/api', schema: client.$schema }),
90+
})
91+
);
92+
93+
let r = await handler(makeRequest('GET', makeUrl('/api/post/1')));
94+
expect(r.status).toBe(404);
95+
96+
r = await handler(
97+
makeRequest('POST', '/api/user', {
98+
data: {
99+
type: 'user',
100+
attributes: { id: 'user1', email: '[email protected]' },
101+
},
102+
})
103+
);
104+
expect(r.status).toBe(201);
105+
expect(await unmarshal(r)).toMatchObject({
106+
data: {
107+
id: 'user1',
108+
attributes: {
109+
110+
},
111+
},
112+
});
113+
114+
r = await handler(makeRequest('GET', makeUrl('/api/user?filter[id]=user1')));
115+
expect(r.status).toBe(200);
116+
expect((await unmarshal(r)).data).toHaveLength(1);
117+
118+
r = await handler(makeRequest('GET', makeUrl('/api/user?filter[id]=user2')));
119+
expect(r.status).toBe(200);
120+
expect((await unmarshal(r)).data).toHaveLength(0);
121+
122+
r = await handler(makeRequest('GET', makeUrl('/api/user?filter[id]=user1&filter[email]=xyz')));
123+
expect(r.status).toBe(200);
124+
expect((await unmarshal(r)).data).toHaveLength(0);
125+
126+
r = await handler(
127+
makeRequest('PUT', makeUrl('/api/user/user1'), {
128+
data: { type: 'user', attributes: { email: '[email protected]' } },
129+
})
130+
);
131+
expect(r.status).toBe(200);
132+
expect((await unmarshal(r)).data.attributes.email).toBe('[email protected]');
133+
134+
r = await handler(makeRequest('DELETE', makeUrl('/api/user/user1')));
135+
expect(r.status).toBe(200);
136+
expect(await client.user.findMany()).toHaveLength(0);
137+
});
138+
});
139+
140+
function makeRequest(method: string, path: string, body?: any) {
141+
const payload = body ? JSON.stringify(body) : undefined;
142+
return new Request(`http://localhost${path}`, { method, body: payload });
143+
}
144+
145+
async function unmarshal(r: Response, useSuperJson = false) {
146+
const text = await r.text();
147+
return (useSuperJson ? superjson.parse(text) : JSON.parse(text)) as any;
148+
}
149+
150+
async function createHonoApp(middleware: MiddlewareHandler) {
151+
const app = new Hono();
152+
153+
app.use('/api/*', middleware);
154+
155+
return app.fetch;
156+
}

0 commit comments

Comments
 (0)