From 2966a0b4ba523fcba1c0c43d95d8d39fd96c0ab2 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Wed, 29 Oct 2025 15:00:47 -0700 Subject: [PATCH 1/2] feat(server): migrate hono adapter --- packages/server/package.json | 5 + packages/server/src/adapter/common.ts | 7 +- packages/server/src/adapter/elysia/handler.ts | 5 +- .../server/src/adapter/express/middleware.ts | 5 +- packages/server/src/adapter/fastify/plugin.ts | 5 +- packages/server/src/adapter/hono/handler.ts | 56 ++++++ packages/server/src/adapter/hono/index.ts | 1 + packages/server/test/adapter/express.test.ts | 4 +- packages/server/test/adapter/fastify.test.ts | 6 +- packages/server/test/adapter/hono.test.ts | 163 ++++++++++++++++++ pnpm-lock.yaml | 9 + 11 files changed, 251 insertions(+), 15 deletions(-) create mode 100644 packages/server/src/adapter/hono/handler.ts create mode 100644 packages/server/src/adapter/hono/index.ts create mode 100644 packages/server/test/adapter/hono.test.ts diff --git a/packages/server/package.json b/packages/server/package.json index 98a9aad7..bdedc605 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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" }, @@ -83,6 +84,7 @@ "fastify": "^5.0.0", "fastify-plugin": "^5.0.0", "elysia": "^1.3.0", + "hono": "^4.6.0", "zod": "catalog:" }, "peerDependenciesMeta": { @@ -100,6 +102,9 @@ }, "elysia": { "optional": true + }, + "hono": { + "optional": true } } } diff --git a/packages/server/src/adapter/common.ts b/packages/server/src/adapter/common.ts index df235ac7..bc6ee7c8 100644 --- a/packages/server/src/adapter/common.ts +++ b/packages/server/src/adapter/common.ts @@ -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 @@ -9,4 +10,8 @@ export interface CommonAdapterOptions { * The API handler to process requests */ apiHandler: ApiHandler; +} + +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 : ''}`); } \ No newline at end of file diff --git a/packages/server/src/adapter/elysia/handler.ts b/packages/server/src/adapter/elysia/handler.ts index 12d79f51..330465d2 100644 --- a/packages/server/src/adapter/elysia/handler.ts +++ b/packages/server/src/adapter/elysia/handler.ts @@ -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. @@ -66,7 +65,7 @@ export function createElysiaHandler(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', }; diff --git a/packages/server/src/adapter/express/middleware.ts b/packages/server/src/adapter/express/middleware.ts index 5b727b27..0d35806a 100644 --- a/packages/server/src/adapter/express/middleware.ts +++ b/packages/server/src/adapter/express/middleware.ts @@ -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 @@ -71,7 +70,7 @@ const factory = (options: MiddlewareOptions): 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` }); } }; diff --git a/packages/server/src/adapter/fastify/plugin.ts b/packages/server/src/adapter/fastify/plugin.ts index a30e4966..e64aa27a 100644 --- a/packages/server/src/adapter/fastify/plugin.ts +++ b/packages/server/src/adapter/fastify/plugin.ts @@ -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 @@ -44,7 +43,7 @@ const pluginHandler: FastifyPluginCallback> = (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` }); } diff --git a/packages/server/src/adapter/hono/handler.ts b/packages/server/src/adapter/hono/handler.ts new file mode 100644 index 00000000..3e7d978c --- /dev/null +++ b/packages/server/src/adapter/hono/handler.ts @@ -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 extends CommonAdapterOptions { + /** + * Callback method for getting a ZenStackClient instance for the given request. + */ + getClient: (ctx: Context) => Promise> | ClientContract; +} + +export function createHonoHandler(options: HonoOptions): 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); + } + }; +} diff --git a/packages/server/src/adapter/hono/index.ts b/packages/server/src/adapter/hono/index.ts new file mode 100644 index 00000000..68ae53f6 --- /dev/null +++ b/packages/server/src/adapter/hono/index.ts @@ -0,0 +1 @@ +export * from './handler'; diff --git a/packages/server/test/adapter/express.test.ts b/packages/server/test/adapter/express.test.ts index d1448d2b..77c36d8e 100644 --- a/packages/server/test/adapter/express.test.ts +++ b/packages/server/test/adapter/express.test.ts @@ -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(); @@ -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(); diff --git a/packages/server/test/adapter/fastify.test.ts b/packages/server/test/adapter/fastify.test.ts index ce5026bc..ba487859 100644 --- a/packages/server/test/adapter/fastify.test.ts +++ b/packages/server/test/adapter/fastify.test.ts @@ -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(); @@ -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(); @@ -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(); diff --git a/packages/server/test/adapter/hono.test.ts b/packages/server/test/adapter/hono.test.ts new file mode 100644 index 00000000..036b5db8 --- /dev/null +++ b/packages/server/test/adapter/hono.test.ts @@ -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: '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); + }); +}); + +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); + + 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: '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('/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; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8fa84642..2082e30c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -475,6 +475,9 @@ importers: fastify-plugin: specifier: ^5.1.0 version: 5.1.0 + hono: + specifier: ^4.6.3 + version: 4.10.3 next: specifier: ^15.0.0 version: 15.5.6(react-dom@19.2.0(react@19.1.0))(react@19.1.0) @@ -2245,6 +2248,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hono@4.10.3: + resolution: {integrity: sha512-2LOYWUbnhdxdL8MNbNg9XZig6k+cZXm5IjHn2Aviv7honhBMOHb+jxrKIeJRZJRmn+htUCKhaicxwXuUDlchRA==} + engines: {node: '>=16.9.0'} + http-errors@2.0.0: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} @@ -5039,6 +5046,8 @@ snapshots: dependencies: function-bind: 1.1.2 + hono@4.10.3: {} + http-errors@2.0.0: dependencies: depd: 2.0.0 From 4a01e51458565174f27695cc458558747eda63c8 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Wed, 29 Oct 2025 17:02:04 -0700 Subject: [PATCH 2/2] fix test --- packages/server/test/adapter/hono.test.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/server/test/adapter/hono.test.ts b/packages/server/test/adapter/hono.test.ts index 036b5db8..e5e51c7e 100644 --- a/packages/server/test/adapter/hono.test.ts +++ b/packages/server/test/adapter/hono.test.ts @@ -3,7 +3,7 @@ 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 { RestApiHandler, RPCApiHandler } from '../../src/api'; import { makeUrl, schema } from '../utils'; describe('Hono adapter tests - rpc handler', () => { @@ -86,20 +86,13 @@ describe('Hono adapter tests - rest handler', () => { const handler = await createHonoApp( createHonoHandler({ getClient: () => client, - apiHandler: new RPCApiHandler({ schema: client.$schema }), + apiHandler: new RestApiHandler({ endpoint: 'http://localhost/api', schema: client.$schema }), }) ); let r = await handler(makeRequest('GET', makeUrl('/api/post/1'))); expect(r.status).toBe(404); - 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: {