diff --git a/.changeset/witty-dolls-raise.md b/.changeset/witty-dolls-raise.md new file mode 100644 index 00000000..ea0ae78d --- /dev/null +++ b/.changeset/witty-dolls-raise.md @@ -0,0 +1,5 @@ +--- +'@hono/zod-openapi': minor +--- + +Add defaultHook as an option for OpenAPIHono diff --git a/packages/zod-openapi/README.md b/packages/zod-openapi/README.md index 1968240e..b0223b12 100644 --- a/packages/zod-openapi/README.md +++ b/packages/zod-openapi/README.md @@ -174,6 +174,57 @@ app.openapi( ) ``` +### A DRY approach to handling validation errors + +In the case that you have a common error formatter, you can initialize the `OpenAPIHono` instance with a `defaultHook`. + +```ts +const app = new OpenAPIHono({ + defaultHook: (result, c) => { + if (!result.success) { + return c.jsonT( + { + ok: false, + errors: formatZodErrors(result), + source: 'custom_error_handler', + }, + 422 + ) + } + }, +}) +``` + +You can still override the `defaultHook` by providing the hook at the call site when appropriate. + +```ts +// uses the defaultHook +app.openapi(createPostRoute, (c) => { + const { title } = c.req.valid('json') + return c.jsonT({ title }) +}) + +// override the defaultHook by passing in a hook +app.openapi( + createBookRoute, + (c) => { + const { title } = c.req.valid('json') + return c.jsonT({ title }) + }, + (result, c) => { + if (!result.success) { + return c.jsonT( + { + ok: false, + source: 'routeHook' as const, + }, + 400 + ) + } + } +) +``` + ### OpenAPI v3.1 You can generate OpenAPI v3.1 spec using the following methods: @@ -211,7 +262,7 @@ You can configure middleware for each endpoint from a route created by `createRo import { prettyJSON } from 'hono/pretty-json' import { cache } from 'honoc/cache' -app.use(route.getRoutingPath(), prettyJSON(), cache({ cacheName: "my-cache" })) +app.use(route.getRoutingPath(), prettyJSON(), cache({ cacheName: 'my-cache' })) app.openapi(route, handler) ``` diff --git a/packages/zod-openapi/src/index.ts b/packages/zod-openapi/src/index.ts index 063ab46f..4c7f1a12 100644 --- a/packages/zod-openapi/src/index.ts +++ b/packages/zod-openapi/src/index.ts @@ -151,7 +151,10 @@ type ConvertPathType = T extends `${infer _}/{${infer Param}}$ type HandlerResponse = TypedResponse | Promise> -type HonoInit = ConstructorParameters[0] +export type OpenAPIHonoOptions = { + defaultHook?: Hook +} +type HonoInit = ConstructorParameters[0] & OpenAPIHonoOptions export type RouteHandler< R extends RouteConfig, @@ -183,10 +186,12 @@ export class OpenAPIHono< BasePath extends string = '/' > extends Hono { openAPIRegistry: OpenAPIRegistry + defaultHook?: OpenAPIHonoOptions['defaultHook'] - constructor(init?: HonoInit) { + constructor(init?: HonoInit) { super(init) this.openAPIRegistry = new OpenAPIRegistry() + this.defaultHook = init?.defaultHook } openapi = < @@ -201,7 +206,7 @@ export class OpenAPIHono< >( route: R, handler: Handler>>, - hook?: Hook> + hook: Hook> | undefined = this.defaultHook ): OpenAPIHono>, BasePath> => { this.openAPIRegistry.registerPath(route) diff --git a/packages/zod-openapi/test/index.test.ts b/packages/zod-openapi/test/index.test.ts index b9ec374b..65c0fa78 100644 --- a/packages/zod-openapi/test/index.test.ts +++ b/packages/zod-openapi/test/index.test.ts @@ -1,6 +1,7 @@ /* eslint-disable node/no-extraneous-import */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { Hono, Env, ToSchema } from 'hono' +import type { RouteConfig } from '@asteasolutions/zod-to-openapi' +import type { Hono, Env, ToSchema, Context } from 'hono' import { hc } from 'hono/client' import { describe, it, expect, expectTypeOf } from 'vitest' import { OpenAPIHono, createRoute, z } from '../src' @@ -15,6 +16,17 @@ describe('Constructor', () => { const app = new OpenAPIHono({ getPath }) expect(app.getPath).toBe(getPath) }) + + it('Should accept a defaultHook', () => { + type FakeEnv = { Variables: { fake: string }; Bindings: { other: number } } + const app = new OpenAPIHono({ + defaultHook: (result, c) => { + // Make sure we're passing context types through + expectTypeOf(c).toMatchTypeOf>() + }, + }) + expect(app.defaultHook).toBeDefined() + }) }) describe('Basic - params', () => { @@ -757,4 +769,143 @@ describe('With hc', () => { expect(client.books.$url().pathname).toBe('/books') }) }) + + describe('defaultHook', () => { + const app = new OpenAPIHono({ + defaultHook: (result, c) => { + if (!result.success) { + const res = c.jsonT( + { + ok: false, + source: 'defaultHook', + }, + 400 + ) + return res + } + }, + }) + + const TitleSchema = z.object({ + title: z.string().openapi({}), + }) + + function errorResponse() { + return { + 400: { + content: { + 'application/json': { + schema: z.object({ + ok: z.boolean().openapi({}), + source: z.enum(['routeHook', 'defaultHook']).openapi({}), + }), + }, + }, + description: 'A validation error', + }, + } satisfies RouteConfig['responses'] + } + + const createPostRoute = createRoute({ + method: 'post', + path: '/posts', + operationId: 'createPost', + request: { + body: { + content: { + 'application/json': { + schema: TitleSchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + 'application/json': { + schema: TitleSchema, + }, + }, + description: 'A post', + }, + ...errorResponse(), + }, + }) + const createBookRoute = createRoute({ + method: 'post', + path: '/books', + operationId: 'createBook', + request: { + body: { + content: { + 'application/json': { + schema: TitleSchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + 'application/json': { + schema: TitleSchema, + }, + }, + description: 'A book', + }, + ...errorResponse(), + }, + }) + + // use the defaultHook + app.openapi(createPostRoute, (c) => { + const { title } = c.req.valid('json') + return c.jsonT({ title }) + }) + + // use a routeHook + app.openapi( + createBookRoute, + (c) => { + const { title } = c.req.valid('json') + return c.jsonT({ title }) + }, + (result, c) => { + if (!result.success) { + const res = c.jsonT( + { + ok: false, + source: 'routeHook' as const, + }, + 400 + ) + return res + } + } + ) + + it('uses the defaultHook', async () => { + const res = await app.request('/posts', { + method: 'POST', + body: JSON.stringify({ bad: 'property' }), + }) + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ + ok: false, + source: 'defaultHook', + }) + }) + + it('it uses the route hook instead of the defaultHook', async () => { + const res = await app.request('/books', { + method: 'POST', + body: JSON.stringify({ bad: 'property' }), + }) + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ + ok: false, + source: 'routeHook', + }) + }) + }) })