Skip to content

Commit

Permalink
feat(server): implementing hono adapter (#1739)
Browse files Browse the repository at this point in the history
  • Loading branch information
svetch authored Sep 30, 2024
1 parent 46d6a63 commit b4418ac
Show file tree
Hide file tree
Showing 5 changed files with 268 additions and 4 deletions.
2 changes: 2 additions & 0 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"fastify": "^4.14.1",
"fastify-plugin": "^4.5.0",
"h3": "^1.8.2",
"hono": "^4.6.3",
"isomorphic-fetch": "^3.0.0",
"next": "14.2.4",
"nuxt": "^3.7.4",
Expand All @@ -71,6 +72,7 @@
"./sveltekit": "./sveltekit/index.js",
"./nuxt": "./nuxt/index.js",
"./nestjs": "./nestjs/index.js",
"./hono": "./hono/index.js",
"./types": "./types.js"
}
}
61 changes: 61 additions & 0 deletions packages/server/src/hono/handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { DbClientContract } from '@zenstackhq/runtime';
import { Context, MiddlewareHandler } from 'hono';
import { StatusCode } from 'hono/utils/http-status';
import { RPCApiHandler } from '../api';
import { loadAssets } from '../shared';
import { AdapterBaseOptions } from '../types';

/**
* Options for initializing a Hono middleware.
*/
export interface HonoOptions extends AdapterBaseOptions {
/**
* Callback method for getting a Prisma instance for the given request.
*/
getPrisma: (ctx: Context) => Promise<unknown> | unknown;
}

export function createHonoHandler(options: HonoOptions): MiddlewareHandler {
const { modelMeta, zodSchemas } = loadAssets(options);
const requestHandler = options.handler ?? RPCApiHandler();

return async (ctx) => {
const prisma = (await options.getPrisma(ctx)) as DbClientContract;
if (!prisma) {
return ctx.json({ message: 'unable to get prisma from request context' }, 500);
}

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

const path = ctx.req.path.substring(ctx.req.routePath.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 requestHandler({
method: ctx.req.method,
path,
query,
requestBody,
prisma,
modelMeta,
zodSchemas,
});
return ctx.json(r.body as object, r.status as StatusCode);
} catch (err) {
return ctx.json({ message: `An unhandled error occurred: ${err}` }, 500);
}
};
}
1 change: 1 addition & 0 deletions packages/server/src/hono/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './handler';
191 changes: 191 additions & 0 deletions packages/server/tests/adapter/hono.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
/* eslint-disable @typescript-eslint/no-var-requires */
/* eslint-disable @typescript-eslint/no-explicit-any */
/// <reference types="@types/jest" />
import { loadSchema } from '@zenstackhq/testtools';
import 'isomorphic-fetch';
import path from 'path';
import superjson from 'superjson';
import Rest from '../../src/api/rest';
import { createHonoHandler } from '../../src/hono';
import { makeUrl, schema } from '../utils';
import { Hono, MiddlewareHandler } from 'hono';

describe('Hono adapter tests - rpc handler', () => {
it('run hooks regular json', async () => {
const { prisma, zodSchemas } = await loadSchema(schema);

const handler = await createHonoApp(createHonoHandler({ getPrisma: () => prisma, zodSchemas }));

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: 'user1@abc.com',
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: 'user1@abc.com',
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: 'user1@def.com' } })
);
expect(r.status).toBe(200);
expect((await unmarshal(r)).data.email).toBe('user1@def.com');

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);
});

it('custom load path', async () => {
const { prisma, projectDir } = await loadSchema(schema, { output: './zen' });

const handler = await createHonoApp(
createHonoHandler({
getPrisma: () => prisma,
modelMeta: require(path.join(projectDir, './zen/model-meta')).default,
zodSchemas: require(path.join(projectDir, './zen/zod')),
})
);

const r = await handler(
makeRequest('POST', '/api/user/create', {
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.status).toBe(201);
});
});

describe('Hono adapter tests - rest handler', () => {
it('run hooks', async () => {
const { prisma, modelMeta, zodSchemas } = await loadSchema(schema);

const handler = await createHonoApp(
createHonoHandler({
getPrisma: () => prisma,
handler: Rest({ endpoint: 'http://localhost/api' }),
modelMeta,
zodSchemas,
})
);

let 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: 'user1@abc.com' },
},
})
);
expect(r.status).toBe(201);
expect(await unmarshal(r)).toMatchObject({
data: {
id: 'user1',
attributes: {
email: 'user1@abc.com',
},
},
});

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: 'user1@def.com' } },
})
);
expect(r.status).toBe(200);
expect((await unmarshal(r)).data.attributes.email).toBe('user1@def.com');

r = await handler(makeRequest('DELETE', makeUrl(makeUrl('/api/user/user1'))));
expect(r.status).toBe(204);
expect(await prisma.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;
}
17 changes: 13 additions & 4 deletions pnpm-lock.yaml

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

0 comments on commit b4418ac

Please sign in to comment.