diff --git a/packages/server/package.json b/packages/server/package.json index 6f35baad3..68d05d74c 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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", @@ -71,6 +72,7 @@ "./sveltekit": "./sveltekit/index.js", "./nuxt": "./nuxt/index.js", "./nestjs": "./nestjs/index.js", + "./hono": "./hono/index.js", "./types": "./types.js" } } diff --git a/packages/server/src/hono/handler.ts b/packages/server/src/hono/handler.ts new file mode 100644 index 000000000..bddf88cdb --- /dev/null +++ b/packages/server/src/hono/handler.ts @@ -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; +} + +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); + } + }; +} diff --git a/packages/server/src/hono/index.ts b/packages/server/src/hono/index.ts new file mode 100644 index 000000000..68ae53f6c --- /dev/null +++ b/packages/server/src/hono/index.ts @@ -0,0 +1 @@ +export * from './handler'; diff --git a/packages/server/tests/adapter/hono.test.ts b/packages/server/tests/adapter/hono.test.ts new file mode 100644 index 000000000..3fc1bb9da --- /dev/null +++ b/packages/server/tests/adapter/hono.test.ts @@ -0,0 +1,191 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/// +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; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d95f0b79..286f2a3fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -737,6 +737,9 @@ importers: h3: specifier: ^1.8.2 version: 1.12.0 + hono: + specifier: ^4.6.3 + version: 4.6.3 isomorphic-fetch: specifier: ^3.0.0 version: 3.0.0(encoding@0.1.13) @@ -3980,7 +3983,7 @@ packages: engines: {node: '>= 14'} concat-map@0.0.1: - resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} concat-stream@1.6.2: resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} @@ -4418,7 +4421,7 @@ packages: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} ee-first@1.1.1: - resolution: {integrity: sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=} + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} electron-to-chromium@1.4.814: resolution: {integrity: sha512-GVulpHjFu1Y9ZvikvbArHmAhZXtm3wHlpjTMcXNGKl4IQ4jMQjlnz8yMQYYqdLHKi/jEL2+CBC2akWVCoIGUdw==} @@ -5173,6 +5176,10 @@ packages: highlight.js@10.4.1: resolution: {integrity: sha512-yR5lWvNz7c85OhVAEAeFhVCc/GV4C30Fjzc/rCP0aCWzc1UUOPUk55dK/qdwTZHBvMZo+eZ2jpk62ndX/xMFlg==} + hono@4.6.3: + resolution: {integrity: sha512-0LeEuBNFeSHGqZ9sNVVgZjB1V5fmhkBSB0hZrpqStSMLOWgfLy0dHOvrjbJh0H2khsjet6rbHfWTHY0kpYThKQ==} + engines: {node: '>=16.9.0'} + hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} @@ -6090,7 +6097,7 @@ packages: resolution: {integrity: sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==} media-typer@0.3.0: - resolution: {integrity: sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=} + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} merge-descriptors@1.0.1: @@ -8250,7 +8257,7 @@ packages: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} utils-merge@1.0.1: - resolution: {integrity: sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=} + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} uuid@10.0.0: @@ -13891,6 +13898,8 @@ snapshots: highlight.js@10.4.1: {} + hono@4.6.3: {} + hookable@5.5.3: {} hosted-git-info@4.1.0: