Skip to content

Commit bb7b7cd

Browse files
committed
feat(server): implementing hono adapter
1 parent 46d6a63 commit bb7b7cd

File tree

5 files changed

+268
-4
lines changed

5 files changed

+268
-4
lines changed

packages/server/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"fastify": "^4.14.1",
5353
"fastify-plugin": "^4.5.0",
5454
"h3": "^1.8.2",
55+
"hono": "^4.6.3",
5556
"isomorphic-fetch": "^3.0.0",
5657
"next": "14.2.4",
5758
"nuxt": "^3.7.4",
@@ -71,6 +72,7 @@
7172
"./sveltekit": "./sveltekit/index.js",
7273
"./nuxt": "./nuxt/index.js",
7374
"./nestjs": "./nestjs/index.js",
75+
"./hono": "./hono/index.js",
7476
"./types": "./types.js"
7577
}
7678
}

packages/server/src/hono/handler.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { DbClientContract } from '@zenstackhq/runtime';
2+
import { Context, MiddlewareHandler } from 'hono';
3+
import { StatusCode } from 'hono/utils/http-status';
4+
import { RPCApiHandler } from '../api';
5+
import { loadAssets } from '../shared';
6+
import { AdapterBaseOptions } from '../types';
7+
8+
/**
9+
* Options for initializing a Hono middleware.
10+
*/
11+
export interface HonoOptions extends AdapterBaseOptions {
12+
/**
13+
* Callback method for getting a Prisma instance for the given request.
14+
*/
15+
getPrisma: (ctx: Context) => Promise<unknown> | unknown;
16+
}
17+
18+
export function createHonoHandler(options: HonoOptions): MiddlewareHandler {
19+
const { modelMeta, zodSchemas } = loadAssets(options);
20+
const requestHandler = options.handler ?? RPCApiHandler();
21+
22+
return async (ctx) => {
23+
const prisma = (await options.getPrisma(ctx)) as DbClientContract;
24+
if (!prisma) {
25+
return ctx.json({ message: 'unable to get prisma from request context' }, 500);
26+
}
27+
28+
const url = new URL(ctx.req.url);
29+
const query = Object.fromEntries(url.searchParams);
30+
31+
const path = ctx.req.path.substring(ctx.req.routePath.length - 1);
32+
33+
if (!path) {
34+
return ctx.json({ message: 'missing path parameter' }, 400);
35+
}
36+
37+
let requestBody: unknown;
38+
if (ctx.req.raw.body) {
39+
try {
40+
requestBody = await ctx.req.json();
41+
} catch {
42+
// noop
43+
}
44+
}
45+
46+
try {
47+
const r = await requestHandler({
48+
method: ctx.req.method,
49+
path,
50+
query,
51+
requestBody,
52+
prisma,
53+
modelMeta,
54+
zodSchemas,
55+
});
56+
return ctx.json(r.body as object, r.status as StatusCode);
57+
} catch (err) {
58+
return ctx.json({ message: `An unhandled error occurred: ${err}` }, 500);
59+
}
60+
};
61+
}

packages/server/src/hono/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './handler';
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
/* eslint-disable @typescript-eslint/no-var-requires */
2+
/* eslint-disable @typescript-eslint/no-explicit-any */
3+
/// <reference types="@types/jest" />
4+
import { loadSchema } from '@zenstackhq/testtools';
5+
import 'isomorphic-fetch';
6+
import path from 'path';
7+
import superjson from 'superjson';
8+
import Rest from '../../src/api/rest';
9+
import { createHonoHandler } from '../../src/hono';
10+
import { makeUrl, schema } from '../utils';
11+
import { Hono, MiddlewareHandler } from 'hono';
12+
13+
describe('Hono adapter tests - rpc handler', () => {
14+
it('run hooks regular json', async () => {
15+
const { prisma, zodSchemas } = await loadSchema(schema);
16+
17+
const handler = await createHonoApp(createHonoHandler({ getPrisma: () => prisma, zodSchemas }));
18+
19+
let r = await handler(makeRequest('GET', makeUrl('/api/post/findMany', { where: { id: { equals: '1' } } })));
20+
expect(r.status).toBe(200);
21+
expect((await unmarshal(r)).data).toHaveLength(0);
22+
23+
r = await handler(
24+
makeRequest('POST', '/api/user/create', {
25+
include: { posts: true },
26+
data: {
27+
id: 'user1',
28+
email: 'user1@abc.com',
29+
posts: {
30+
create: [
31+
{ title: 'post1', published: true, viewCount: 1 },
32+
{ title: 'post2', published: false, viewCount: 2 },
33+
],
34+
},
35+
},
36+
})
37+
);
38+
expect(r.status).toBe(201);
39+
expect((await unmarshal(r)).data).toMatchObject({
40+
email: 'user1@abc.com',
41+
posts: expect.arrayContaining([
42+
expect.objectContaining({ title: 'post1' }),
43+
expect.objectContaining({ title: 'post2' }),
44+
]),
45+
});
46+
47+
r = await handler(makeRequest('GET', makeUrl('/api/post/findMany')));
48+
expect(r.status).toBe(200);
49+
expect((await unmarshal(r)).data).toHaveLength(2);
50+
51+
r = await handler(makeRequest('GET', makeUrl('/api/post/findMany', { where: { viewCount: { gt: 1 } } })));
52+
expect(r.status).toBe(200);
53+
expect((await unmarshal(r)).data).toHaveLength(1);
54+
55+
r = await handler(
56+
makeRequest('PUT', '/api/user/update', { where: { id: 'user1' }, data: { email: 'user1@def.com' } })
57+
);
58+
expect(r.status).toBe(200);
59+
expect((await unmarshal(r)).data.email).toBe('user1@def.com');
60+
61+
r = await handler(makeRequest('GET', makeUrl('/api/post/count', { where: { viewCount: { gt: 1 } } })));
62+
expect(r.status).toBe(200);
63+
expect((await unmarshal(r)).data).toBe(1);
64+
65+
r = await handler(makeRequest('GET', makeUrl('/api/post/aggregate', { _sum: { viewCount: true } })));
66+
expect(r.status).toBe(200);
67+
expect((await unmarshal(r)).data._sum.viewCount).toBe(3);
68+
69+
r = await handler(
70+
makeRequest('GET', makeUrl('/api/post/groupBy', { by: ['published'], _sum: { viewCount: true } }))
71+
);
72+
expect(r.status).toBe(200);
73+
expect((await unmarshal(r)).data).toEqual(
74+
expect.arrayContaining([
75+
expect.objectContaining({ published: true, _sum: { viewCount: 1 } }),
76+
expect.objectContaining({ published: false, _sum: { viewCount: 2 } }),
77+
])
78+
);
79+
80+
r = await handler(makeRequest('DELETE', makeUrl('/api/user/deleteMany', { where: { id: 'user1' } })));
81+
expect(r.status).toBe(200);
82+
expect((await unmarshal(r)).data.count).toBe(1);
83+
});
84+
85+
it('custom load path', async () => {
86+
const { prisma, projectDir } = await loadSchema(schema, { output: './zen' });
87+
88+
const handler = await createHonoApp(
89+
createHonoHandler({
90+
getPrisma: () => prisma,
91+
modelMeta: require(path.join(projectDir, './zen/model-meta')).default,
92+
zodSchemas: require(path.join(projectDir, './zen/zod')),
93+
})
94+
);
95+
96+
const r = await handler(
97+
makeRequest('POST', '/api/user/create', {
98+
include: { posts: true },
99+
data: {
100+
id: 'user1',
101+
email: 'user1@abc.com',
102+
posts: {
103+
create: [
104+
{ title: 'post1', published: true, viewCount: 1 },
105+
{ title: 'post2', published: false, viewCount: 2 },
106+
],
107+
},
108+
},
109+
})
110+
);
111+
expect(r.status).toBe(201);
112+
});
113+
});
114+
115+
describe('Hono adapter tests - rest handler', () => {
116+
it('run hooks', async () => {
117+
const { prisma, modelMeta, zodSchemas } = await loadSchema(schema);
118+
119+
const handler = await createHonoApp(
120+
createHonoHandler({
121+
getPrisma: () => prisma,
122+
handler: Rest({ endpoint: 'http://localhost/api' }),
123+
modelMeta,
124+
zodSchemas,
125+
})
126+
);
127+
128+
let r = await handler(makeRequest('GET', makeUrl('/api/post/1')));
129+
expect(r.status).toBe(404);
130+
131+
r = await handler(
132+
makeRequest('POST', '/api/user', {
133+
data: {
134+
type: 'user',
135+
attributes: { id: 'user1', email: 'user1@abc.com' },
136+
},
137+
})
138+
);
139+
expect(r.status).toBe(201);
140+
expect(await unmarshal(r)).toMatchObject({
141+
data: {
142+
id: 'user1',
143+
attributes: {
144+
email: 'user1@abc.com',
145+
},
146+
},
147+
});
148+
149+
r = await handler(makeRequest('GET', makeUrl('/api/user?filter[id]=user1')));
150+
expect(r.status).toBe(200);
151+
expect((await unmarshal(r)).data).toHaveLength(1);
152+
153+
r = await handler(makeRequest('GET', makeUrl('/api/user?filter[id]=user2')));
154+
expect(r.status).toBe(200);
155+
expect((await unmarshal(r)).data).toHaveLength(0);
156+
157+
r = await handler(makeRequest('GET', makeUrl('/api/user?filter[id]=user1&filter[email]=xyz')));
158+
expect(r.status).toBe(200);
159+
expect((await unmarshal(r)).data).toHaveLength(0);
160+
161+
r = await handler(
162+
makeRequest('PUT', makeUrl('/api/user/user1'), {
163+
data: { type: 'user', attributes: { email: 'user1@def.com' } },
164+
})
165+
);
166+
expect(r.status).toBe(200);
167+
expect((await unmarshal(r)).data.attributes.email).toBe('user1@def.com');
168+
169+
r = await handler(makeRequest('DELETE', makeUrl(makeUrl('/api/user/user1'))));
170+
expect(r.status).toBe(204);
171+
expect(await prisma.user.findMany()).toHaveLength(0);
172+
});
173+
});
174+
175+
function makeRequest(method: string, path: string, body?: any) {
176+
const payload = body ? JSON.stringify(body) : undefined;
177+
return new Request(`http://localhost${path}`, { method, body: payload });
178+
}
179+
180+
async function unmarshal(r: Response, useSuperJson = false) {
181+
const text = await r.text();
182+
return (useSuperJson ? superjson.parse(text) : JSON.parse(text)) as any;
183+
}
184+
185+
async function createHonoApp(middleware: MiddlewareHandler) {
186+
const app = new Hono();
187+
188+
app.use('/api/*', middleware);
189+
190+
return app.fetch;
191+
}

pnpm-lock.yaml

Lines changed: 13 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)