diff --git a/.changeset/zod-openapi-response-validation.md b/.changeset/zod-openapi-response-validation.md new file mode 100644 index 000000000..8643f5787 --- /dev/null +++ b/.changeset/zod-openapi-response-validation.md @@ -0,0 +1,5 @@ +--- +"@hono/zod-openapi": minor +--- + +Add optional outgoing response validation via `strictStatusCode` and `strictResponse` on `OpenAPIHono`, with `defaultResponseHook` and per-route `responseHook` for failures. Validation runs when handlers use `c.json` (see README for scope). diff --git a/packages/zod-openapi/README.md b/packages/zod-openapi/README.md index 1c3d5df3e..41ee26385 100644 --- a/packages/zod-openapi/README.md +++ b/packages/zod-openapi/README.md @@ -303,6 +303,55 @@ app.openapi( ) ``` +### Response validation (optional) + +You can opt in to validating outgoing JSON against the route `responses` schemas. Validation runs when the handler calls `c.json(payload, status)` (or `c.json(payload, { status })`), so the payload is checked **before** a `Response` is built—not by re-reading the body afterward. + +Enable flags on `OpenAPIHono`: + +- `strictStatusCode` — the status passed to `c.json` must match the route’s `responses` (numeric keys, range keys like `2XX`, or `default`). +- `strictResponse` — when the resolved response entry has an `application/json` schema, the JSON value is validated with that Zod schema. If there is no JSON schema for that status (e.g. `204`), validation is skipped. + +When validation fails, `defaultResponseHook` (or the route-level `responseHook`) runs. Return a `Response` from the hook to control the error payload; if you return nothing, a small JSON error with status `500` is sent. + +The default body for body-validation failures includes Zod **`issues`**, which can be detailed. For production APIs, use **`defaultResponseHook` / `responseHook`** to return a smaller or redacted error shape. + +```ts +const app = new OpenAPIHono({ + strictStatusCode: true, + strictResponse: true, + defaultResponseHook: (result, c) => { + if (result.kind === 'status_mismatch') { + return c.json({ error: 'Unexpected status', status: result.status }, 500) + } + return c.json({ error: 'Invalid response body', issues: result.error.issues }, 500) + }, +}) +``` + +Pass a **hooks object** as the third argument to `app.openapi` when you need both request validation (`hook`) and per-route response errors (`responseHook`): + +```ts +app.openapi(route, handler, { + hook: (result, c) => { + /* request validation — same as before */ + }, + responseHook: (result, c) => { + if (result.kind === 'body') { + return c.json({ message: 'Bad handler output' }, 500) + } + }, +}) +``` + +#### Scope and limitations + +- Only **`c.json(...)`** is validated (together with **`c.status`** when inferring the status). **`c.text`**, **`c.html`**, **`c.body`**, and **`return new Response(...)`** are not checked. +- If one response defines **several** JSON-compatible media types under `content`, the **first** such entry (object key order) is used for `strictResponse`. +- Wrapping runs for the **OpenAPI route handler** (after built-in validators). If **route `middleware` returns a response without calling `next()`**, that response bypasses strict checks. Likewise, calling **`c.json` only after the handler has returned** does not go through validation. +- With `strictStatusCode` or `strictResponse` enabled, the handler is executed inside an **async** wrapper (usually negligible; it can show up in stack traces). +- Status range keys must follow **OpenAPI spelling** (`1XX` … `5XX` with uppercase `X`); other strings are not treated as ranges. + ### OpenAPI v3.1 You can generate OpenAPI v3.1 spec using the following methods: diff --git a/packages/zod-openapi/src/index.test.ts b/packages/zod-openapi/src/index.test.ts index 6bb81f544..8aa84be41 100644 --- a/packages/zod-openapi/src/index.test.ts +++ b/packages/zod-openapi/src/index.test.ts @@ -7,7 +7,7 @@ import type { ServerErrorStatusCode } from 'hono/utils/http-status' import type { JSONValue } from 'hono/utils/types' import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { stringify } from 'yaml' -import type { RouteConfigToTypedResponse } from './index' +import type { RouteConfigToTypedResponse, RouteHandler } from './index' import { $, OpenAPIHono, createRoute, z } from './index' describe('Constructor', () => { @@ -32,6 +32,18 @@ describe('Constructor', () => { }) expect(app.defaultHook).toBeDefined() }) + + it('Should accept strict response options', () => { + const defaultResponseHook = () => new Response() + const app = new OpenAPIHono({ + strictStatusCode: true, + strictResponse: true, + defaultResponseHook, + }) + expect(app.strictStatusCode).toBe(true) + expect(app.strictResponse).toBe(true) + expect(app.defaultResponseHook).toBe(defaultResponseHook) + }) }) describe('Basic - params', () => { @@ -2212,6 +2224,306 @@ describe('Hide Routes', () => { }) }) +describe('Response validation (strictStatusCode / strictResponse)', () => { + type BuiltinStrictValidationJson = { + success: boolean + error: string + status?: number + issues?: unknown[] + } + + const ItemSchema = z + .object({ + id: z.string(), + n: z.number(), + }) + .openapi('Item') + + const itemRoute = createRoute({ + method: 'get', + path: '/item', + responses: { + 200: { + description: 'ok', + content: { 'application/json': { schema: ItemSchema } }, + }, + }, + }) + + it('Should not validate when strict flags are off', async () => { + const app = new OpenAPIHono() + app.openapi(itemRoute, (c) => + c.json({ id: 'x', n: 'bad' } as unknown as z.infer, 200) + ) + const res = await app.request('/item') + expect(res.status).toBe(200) + }) + + it('Should not apply strict response when route middleware returns c.json without next', async () => { + const app = new OpenAPIHono({ strictResponse: true }) + app.openapi( + { + ...itemRoute, + middleware: [ + (c) => c.json({ id: 'x', n: 'bad' } as unknown as z.infer, 200), + ], + }, + () => { + throw new Error('handler should not run') + } + ) + const res = await app.request('/item') + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ id: 'x', n: 'bad' }) + }) + + it('Should reject invalid JSON body when strictResponse is on', async () => { + const app = new OpenAPIHono({ strictResponse: true }) + app.openapi(itemRoute, (c) => + c.json({ id: 'x', n: 'bad' } as unknown as z.infer, 200) + ) + const res = await app.request('/item') + expect(res.status).toBe(500) + const body = (await res.json()) as BuiltinStrictValidationJson + expect(body.success).toBe(false) + expect(body.error).toBe('Response body validation failed.') + expect(Array.isArray(body.issues)).toBe(true) + }) + + it('Should accept valid JSON body when strictResponse is on', async () => { + const app = new OpenAPIHono({ strictResponse: true }) + app.openapi(itemRoute, (c) => c.json({ id: 'x', n: 1 }, 200)) + const res = await app.request('/item') + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ id: 'x', n: 1 }) + }) + + it('Should use c.status when c.json omits status (success)', async () => { + const app = new OpenAPIHono({ strictStatusCode: true, strictResponse: true }) + app.openapi(itemRoute, (c) => { + c.status(200) + return c.json({ id: 'x', n: 1 }) + }) + const res = await app.request('/item') + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ id: 'x', n: 1 }) + }) + + it('Should reject undeclared status when only c.status is set and c.json omits status', async () => { + const app = new OpenAPIHono({ strictStatusCode: true, strictResponse: true }) + app.openapi(itemRoute, (c) => { + c.status(404) + return c.json({ id: 'x', n: 1 }) + }) + const res = await app.request('/item') + expect(res.status).toBe(500) + const body = (await res.json()) as BuiltinStrictValidationJson + expect(body.success).toBe(false) + expect(body.error).toBe('Response status does not match any of the defined responses.') + expect(body.status).toBe(404) + }) + + it('Should reject undeclared status when strictStatusCode is on', async () => { + const app = new OpenAPIHono({ strictStatusCode: true }) + // @ts-expect-error status 201 not in route responses + app.openapi(itemRoute, (c) => c.json({ id: 'x', n: 1 }, 201)) + const res = await app.request('/item') + expect(res.status).toBe(500) + const body = (await res.json()) as BuiltinStrictValidationJson + expect(body.success).toBe(false) + expect(body.error).toBe('Response status does not match any of the defined responses.') + expect(body.status).toBe(201) + }) + + it('Should validate body per declared status when multiple responses exist', async () => { + const multiRoute = createRoute({ + method: 'get', + path: '/multi-status-code', + request: { + query: z.object({ + returnCode: z.string().optional(), + }), + }, + responses: { + 200: { + description: 'ok', + content: { + 'application/json': { + schema: z.object({ + name: z.string(), + age: z.number(), + }), + }, + }, + }, + 404: { + description: 'not found', + content: { + 'application/json': { + schema: z.object({ not: z.literal('found') }), + }, + }, + }, + 500: { + description: 'error', + content: { + 'application/json': { + schema: z.object({ error: z.string() }), + }, + }, + }, + }, + }) + const app = new OpenAPIHono({ strictStatusCode: true, strictResponse: true }) + const handleMulti: RouteHandler = (c) => { + const { returnCode } = c.req.valid('query') + if (returnCode === '500') { + return c.json({ error: returnCode }, 500) + } + if (returnCode === '404') { + return c.json({ not: 'found' as const }, 404) + } + return c.json({ name: 'John Doe', age: 20 }, 200) + } + app.openapi(multiRoute, handleMulti) + + const r200 = await app.request('/multi-status-code') + expect(r200.status).toBe(200) + expect(await r200.json()).toEqual({ name: 'John Doe', age: 20 }) + + const r404 = await app.request('/multi-status-code?returnCode=404') + expect(r404.status).toBe(404) + expect(await r404.json()).toEqual({ not: 'found' }) + + const r500 = await app.request('/multi-status-code?returnCode=500') + expect(r500.status).toBe(500) + expect(await r500.json()).toEqual({ error: '500' }) + }) + + it('Should reject invalid body for one of multiple declared response schemas', async () => { + const multiRoute = createRoute({ + method: 'get', + path: '/multi-status-bad-body', + request: { + query: z.object({ returnCode: z.string().optional() }), + }, + responses: { + 200: { + description: 'ok', + content: { + 'application/json': { + schema: z.object({ name: z.string(), age: z.number() }), + }, + }, + }, + 500: { + description: 'error', + content: { + 'application/json': { + schema: z.object({ error: z.string() }), + }, + }, + }, + }, + }) + const app = new OpenAPIHono({ strictStatusCode: true, strictResponse: true }) + const handleBadBody: RouteHandler = (c) => { + const { returnCode } = c.req.valid('query') + if (returnCode === '500') { + return c.json({ wrong: true } as never, 500) + } + return c.json({ name: 'a', age: 1 }, 200) + } + app.openapi(multiRoute, handleBadBody) + const res = await app.request('/multi-status-bad-body?returnCode=500') + expect(res.status).toBe(500) + const body = (await res.json()) as BuiltinStrictValidationJson + expect(body.success).toBe(false) + expect(body.error).toBe('Response body validation failed.') + expect(Array.isArray(body.issues)).toBe(true) + }) + + it('Should use defaultResponseHook for body failures', async () => { + const app = new OpenAPIHono({ + strictResponse: true, + defaultResponseHook: (result, c) => { + if (result.kind === 'body') { + return c.json({ custom: true }, 502) + } + }, + }) + app.openapi(itemRoute, (c) => c.json({ wrong: true } as never, 200)) + const res = await app.request('/item') + expect(res.status).toBe(502) + expect(await res.json()).toEqual({ custom: true }) + }) + + it('Should prefer route responseHook over defaultResponseHook', async () => { + const app = new OpenAPIHono({ + strictResponse: true, + defaultResponseHook: () => new Response('default', { status: 503 }), + }) + app.openapi(itemRoute, (c) => c.json({ wrong: true } as never, 200), { + responseHook: () => new Response('route', { status: 504 }), + }) + const res = await app.request('/item') + expect(res.status).toBe(504) + expect(await res.text()).toBe('route') + }) + + it('Should match 2XX range to status code', async () => { + const rangeRoute = createRoute({ + method: 'get', + path: '/range', + responses: { + '2XX': { + description: 'ok', + content: { 'application/json': { schema: ItemSchema } }, + }, + }, + }) + const app = new OpenAPIHono({ strictStatusCode: true, strictResponse: true }) + app.openapi(rangeRoute, (c) => c.json({ id: 'a', n: 2 }, 200)) + const res = await app.request('/range') + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ id: 'a', n: 2 }) + }) + + it('Should keep request hook when using hooks object', async () => { + const ParamsSchema = z.object({ id: z.string().min(3) }).openapi({ + param: { name: 'id', in: 'path' }, + }) + const r = createRoute({ + method: 'get', + path: '/req-hook/{id}', + request: { params: ParamsSchema }, + responses: { + 200: { + description: 'ok', + content: { 'application/json': { schema: z.object({ ok: z.boolean() }) } }, + }, + 400: { + description: 'param validation', + content: { 'application/json': { schema: z.object({ reqHook: z.boolean() }) } }, + }, + }, + }) + const app = new OpenAPIHono({ strictResponse: true }) + app.openapi(r, (c) => c.json({ ok: true }, 200), { + hook: (result, c) => { + if (!result.success) { + return c.json({ reqHook: true }, 400) + } + }, + }) + const bad = await app.request('/req-hook/x') + expect(bad.status).toBe(400) + expect(await bad.json()).toEqual({ reqHook: true }) + const good = await app.request('/req-hook/abcd') + expect(good.status).toBe(200) + }) +}) + describe('$', () => { it('Should convert Hono instance to OpenAPIHono type', async () => { const app = $( diff --git a/packages/zod-openapi/src/index.ts b/packages/zod-openapi/src/index.ts index 5e5cc4f0e..9559515b4 100644 --- a/packages/zod-openapi/src/index.ts +++ b/packages/zod-openapi/src/index.ts @@ -231,15 +231,55 @@ export type Hook = ( c: Context ) => R +export type ResponseHookFailure = + | { kind: 'status_mismatch'; status: number } + | { kind: 'body'; error: ZodError } + +/** When `strictStatusCode` or `strictResponse` fails; return a `Response` or `undefined` for the default 500 JSON. */ +export type ResponseHook = ( + result: ResponseHookFailure, + c: Context +) => Response | undefined + type ConvertPathType = T extends `${infer Start}/{${infer Param}}${infer Rest}` ? `${Start}/:${Param}${ConvertPathType}` : T export type OpenAPIHonoOptions = { defaultHook?: Hook + /** Require `c.json` status to match a key in route `responses`. */ + strictStatusCode?: boolean + /** Validate JSON bodies with Zod schemas from `responses` for the resolved status. */ + strictResponse?: boolean + defaultResponseHook?: ResponseHook } type HonoInit = ConstructorParameters[0] & OpenAPIHonoOptions +export type OpenAPIRouteHooks< + R extends RouteConfig, + E extends Env, + I extends Input, + P extends string, +> = { + hook?: Hook< + I, + E, + P, + R extends { + responses: { + [statusCode: number]: { + content: { + [mediaType: string]: ZodMediaTypeObject + } + } + } + } + ? MaybePromise> | undefined + : MaybePromise> | MaybePromise | undefined + > + responseHook?: ResponseHook +} + /** * Turns `T | T[] | undefined` into `T[]` */ @@ -396,6 +436,212 @@ export const $ = >(app: T): HonoToOpenAPIHono = return app as HonoToOpenAPIHono } +const OPENAPI_RESPONSE_RANGE_KEYS = ['1XX', '2XX', '3XX', '4XX', '5XX'] as const + +function statusMatchesOpenAPIRangeKey(status: number, key: string): boolean { + if (!(OPENAPI_RESPONSE_RANGE_KEYS as readonly string[]).includes(key)) { + return false + } + const family = Number(key[0]) + return Math.floor(status / 100) === family +} + +function resolveOpenAPIResponseKey( + responses: RouteConfig['responses'], + status: number +): string | undefined { + const keys = Object.keys(responses) + const exact = String(status) + if (keys.includes(exact)) { + return exact + } + for (const k of keys) { + if (statusMatchesOpenAPIRangeKey(status, k)) { + return k + } + } + if (keys.includes('default')) { + return 'default' + } + return undefined +} + +/** Uses the first JSON-compatible `content` entry (key order) when several are present. */ +function getJsonZodSchemaForOpenAPIResponse( + responses: RouteConfig['responses'], + responseKey: string +): ZodType | undefined { + const entry = responses[responseKey as keyof typeof responses] as + | { content?: ZodContentObject } + | undefined + const content = entry?.content + if (!content) { + return undefined + } + for (const mediaType of Object.keys(content)) { + if (!isJSONContentType(mediaType)) { + continue + } + const media = content[mediaType] as ZodMediaTypeObject | undefined + const schema = media?.schema + if (isZod(schema)) { + return schema as ZodType + } + } + return undefined +} + +function resolveStatusFromJsonArgs( + arg: number | Parameters[1] | undefined, + contextStatus: number | undefined +): number { + if (typeof arg === 'number') { + return arg + } + if (arg && typeof arg === 'object' && 'status' in arg && arg.status != null) { + return arg.status as number + } + return contextStatus ?? 200 +} + +function defaultOpenAPIResponseValidationFailureResponse( + c: Context, + failure: ResponseHookFailure +): Response { + if (failure.kind === 'status_mismatch') { + return c.json( + { + success: false, + error: 'Response status does not match any of the defined responses.', + status: failure.status, + }, + 500 + ) + } + return c.json( + { + success: false, + error: 'Response body validation failed.', + issues: failure.error.issues, + }, + 500 + ) +} + +function installOpenAPIResponseValidation( + c: Context, + responses: RouteConfig['responses'], + options: { + strictStatusCode: boolean + strictResponse: boolean + routeResponseHook?: ResponseHook + defaultResponseHook?: ResponseHook + } +): () => void { + const origJson = c.json.bind(c) + const origStatus = c.status.bind(c) + let contextStatus: number | undefined + + const restore = () => { + c.json = origJson + c.status = origStatus + } + + c.status = ((code: StatusCode) => { + contextStatus = code + origStatus(code) + }) as typeof c.status + + c.json = ((object: unknown, arg?: never, headers?: never) => { + const status = resolveStatusFromJsonArgs(arg, contextStatus) + const responseKey = resolveOpenAPIResponseKey(responses, status) + + if (options.strictStatusCode && responseKey === undefined) { + c.json = origJson + try { + const failure: ResponseHookFailure = { kind: 'status_mismatch', status } + const custom = + options.routeResponseHook?.(failure, c) ?? options.defaultResponseHook?.(failure, c) + if (custom) { + return custom + } + return defaultOpenAPIResponseValidationFailureResponse(c, failure) + } finally { + c.json = origJson + } + } + + if (options.strictResponse && responseKey !== undefined) { + const schema = getJsonZodSchemaForOpenAPIResponse(responses, responseKey) + if (schema) { + const parsed = schema.safeParse(object) + if (!parsed.success) { + c.json = origJson + try { + const failure: ResponseHookFailure = { kind: 'body', error: parsed.error } + const custom = + options.routeResponseHook?.(failure, c) ?? options.defaultResponseHook?.(failure, c) + if (custom) { + return custom + } + return defaultOpenAPIResponseValidationFailureResponse(c, failure) + } finally { + c.json = origJson + } + } + } + } + + return origJson(object as never, arg as never, headers as never) + }) as typeof c.json + + return restore +} + +/** When strict flags are on, wraps the handler in `async` so validation can wrap `c.json` / `c.status` for the request. */ +function wrapOpenAPIRouteHandler( + handler: Handler, + responses: RouteConfig['responses'], + options: { + strictStatusCode: boolean + strictResponse: boolean + routeResponseHook?: ResponseHook + defaultResponseHook?: ResponseHook + } +): Handler { + if (!options.strictStatusCode && !options.strictResponse) { + return handler + } + return (async (c, next) => { + const restore = installOpenAPIResponseValidation(c, responses, options) + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return -- composed handler output is intentionally generic + return await Promise.resolve(handler(c, next)) + } finally { + restore() + } + }) as Handler +} + +function normalizeOpenAPIRouteHooks( + hookOrObj: + | Hook + | { hook?: Hook; responseHook?: ResponseHook } + | undefined, + defaultHook: Hook | undefined +): { requestHook: Hook | undefined; responseHook?: ResponseHook } { + if (hookOrObj === undefined) { + return { requestHook: defaultHook } + } + if (typeof hookOrObj === 'function') { + return { requestHook: hookOrObj } + } + return { + requestHook: hookOrObj.hook ?? defaultHook, + responseHook: hookOrObj.responseHook, + } +} + export class OpenAPIHono< E extends Env = Env, S extends Schema = {}, @@ -403,43 +649,43 @@ export class OpenAPIHono< > extends Hono { openAPIRegistry: OpenAPIRegistry defaultHook?: OpenAPIHonoOptions['defaultHook'] + strictStatusCode: boolean + strictResponse: boolean + defaultResponseHook?: ResponseHook constructor(init?: HonoInit) { - super(init) + const { defaultHook, strictStatusCode, strictResponse, defaultResponseHook, ...honoInit } = + init ?? {} + super(honoInit as ConstructorParameters[0]) this.openAPIRegistry = new OpenAPIRegistry() - this.defaultHook = init?.defaultHook + this.defaultHook = defaultHook + this.strictStatusCode = strictStatusCode ?? false + this.strictResponse = strictResponse ?? false + this.defaultResponseHook = defaultResponseHook } /** - * - * @param {RouteConfig} route - The route definition which you create with `createRoute()`. - * @param {Handler} handler - The handler. If you want to return a JSON object, you should specify the status code with `c.json()`. - * @param {Hook} hook - Optional. The hook method defines what it should do after validation. + * @param route - From `createRoute()`. + * @param handler - Route handler; use `c.json(payload, status)` for JSON responses. + * @param hookOrHooks - Optional request-validation hook, or `{ hook?, responseHook? }`. + * With `strictStatusCode` / `strictResponse` on the app, `responseHook` runs on response validation failure (overrides `defaultResponseHook` for that route). See README for scope. * @example * app.openapi( * route, * (c) => { - * // ... - * return c.json( - * { - * age: 20, - * name: 'Young man', - * }, - * 200 // You should specify the status code even if it's 200. - * ) + * return c.json({ age: 20, name: 'Young man' }, 200) * }, - * (result, c) => { - * if (!result.success) { - * return c.json( - * { - * code: 400, - * message: 'Custom Message', - * }, - * 400 - * ) - * } - * } - *) + * (result, c) => { + * if (!result.success) { + * return c.json({ code: 400, message: 'Custom Message' }, 400) + * } + * } + * ) + * @example + * app.openapi(route, handler, { + * hook: (result, c) => { ... }, + * responseHook: (result, c) => { ... }, + * }) */ openapi = < R extends RouteConfig, @@ -472,7 +718,7 @@ export class OpenAPIHono< ? MaybePromise> : MaybePromise> | MaybePromise >, - hook: + hookOrHooks: | Hook< I, E, @@ -489,6 +735,7 @@ export class OpenAPIHono< ? MaybePromise> | undefined : MaybePromise> | MaybePromise | undefined > + | OpenAPIRouteHooks | undefined = this.defaultHook ): OpenAPIHono< E, @@ -499,25 +746,27 @@ export class OpenAPIHono< this.openAPIRegistry.registerPath(route) } + const { requestHook, responseHook } = normalizeOpenAPIRouteHooks(hookOrHooks, this.defaultHook) + const validators: MiddlewareHandler[] = [] if (route.request?.query) { - const validator = zValidator('query', route.request.query as any, hook as any) + const validator = zValidator('query', route.request.query as any, requestHook as any) validators.push(validator as any) } if (route.request?.params) { - const validator = zValidator('param', route.request.params as any, hook as any) + const validator = zValidator('param', route.request.params as any, requestHook as any) validators.push(validator as any) } if (route.request?.headers) { - const validator = zValidator('header', route.request.headers as any, hook as any) + const validator = zValidator('header', route.request.headers as any, requestHook as any) validators.push(validator as any) } if (route.request?.cookies) { - const validator = zValidator('cookie', route.request.cookies as any, hook as any) + const validator = zValidator('cookie', route.request.cookies as any, requestHook as any) validators.push(validator as any) } @@ -535,7 +784,7 @@ export class OpenAPIHono< if (isJSONContentType(mediaType)) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore we can ignore the type error since Zod Validator's types are not used - const validator = zValidator('json', schema, hook) as MiddlewareHandler + const validator = zValidator('json', schema, requestHook) as MiddlewareHandler if (route.request?.body?.required) { validators.push(validator) } else { @@ -554,7 +803,7 @@ export class OpenAPIHono< if (isFormContentType(mediaType)) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore we can ignore the type error since Zod Validator's types are not used - const validator = zValidator('form', schema, hook) as MiddlewareHandler + const validator = zValidator('form', schema, requestHook) as MiddlewareHandler if (route.request?.body?.required) { validators.push(validator) } else { @@ -579,12 +828,23 @@ export class OpenAPIHono< : [routeMiddleware] : [] + const wrappedHandler = wrapOpenAPIRouteHandler( + handler as Handler, + route.responses, + { + strictStatusCode: this.strictStatusCode, + strictResponse: this.strictResponse, + routeResponseHook: responseHook, + defaultResponseHook: this.defaultResponseHook, + } + ) as typeof handler + this.on( [route.method], [route.path.replaceAll(/\/{(.+?)}/g, '/:$1')], ...middleware, ...validators, - handler + wrappedHandler ) return this } @@ -729,7 +989,13 @@ export class OpenAPIHono< override basePath( path: SubPath ): OpenAPIHono> { - return new OpenAPIHono({ ...(super.basePath(path) as any), defaultHook: this.defaultHook }) + return new OpenAPIHono({ + ...(super.basePath(path) as any), + defaultHook: this.defaultHook, + strictStatusCode: this.strictStatusCode, + strictResponse: this.strictResponse, + defaultResponseHook: this.defaultResponseHook, + }) } // Type overrides to return OpenAPIHono instead of Hono