From 3dd1ec744932f6af4292a09a40bd69a68d28e14e Mon Sep 17 00:00:00 2001 From: mikan3rd Date: Wed, 25 Mar 2026 12:29:13 +0900 Subject: [PATCH 1/7] feat(zod-openapi): add optional response validation (strict flags, hooks) Made-with: Cursor --- packages/zod-openapi/README.md | 41 ++++ packages/zod-openapi/src/index.test.ts | 162 +++++++++++++ packages/zod-openapi/src/index.ts | 301 ++++++++++++++++++++++++- 3 files changed, 493 insertions(+), 11 deletions(-) diff --git a/packages/zod-openapi/README.md b/packages/zod-openapi/README.md index 1c3d5df3e..be2b3964e 100644 --- a/packages/zod-openapi/README.md +++ b/packages/zod-openapi/README.md @@ -303,6 +303,47 @@ 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. + +```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) + } + }, +}) +``` + +Responses built only with `return new Response(JSON.stringify(...))` (without going through `c.json`) are **not** validated. + ### 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..84c44da0d 100644 --- a/packages/zod-openapi/src/index.test.ts +++ b/packages/zod-openapi/src/index.test.ts @@ -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,156 @@ describe('Hide Routes', () => { }) }) +describe('Response validation (strictStatusCode / strictResponse)', () => { + 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 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 { + success: boolean + error: string + issues: unknown[] + } + 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 reject undeclared status when strictStatusCode is on', async () => { + const app = new OpenAPIHono({ strictStatusCode: true }) + // @ts-expect-error deliberate 201 vs declared 200 (strictStatusCode test) + 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 { + success: boolean + error: string + status: number + } + 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 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 a199e5bbd..c4438a77b 100644 --- a/packages/zod-openapi/src/index.ts +++ b/packages/zod-openapi/src/index.ts @@ -231,15 +231,60 @@ export type Hook = ( c: Context ) => R +/** Failure cases passed to {@link ResponseHook} (hook is only invoked on failure). */ +export type ResponseHookFailure = + | { kind: 'status_mismatch'; status: number } + | { kind: 'body'; error: ZodError } + +/** + * Called when response validation fails (`strictStatusCode` / `strictResponse`). + * Return a `Response` to send it; return `undefined` to use the built-in JSON error body (500). + * Use a synchronous hook; `c.json` / `c.text` run on the real context methods while the hook runs. + */ +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 + /** When true, `c.json` status must match a key in the route `responses` (numeric, range like `2XX`, or `default`). */ + strictStatusCode?: boolean + /** When true, JSON response bodies are validated with the Zod schema from `responses` for the resolved status (if present). */ + 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[]` */ @@ -350,6 +395,7 @@ export type RouteHook< I, E, P, + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- Hono-compatible handler may return void RouteConfigToTypedResponse | Response | Promise | void | Promise > @@ -396,6 +442,211 @@ 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 +} + +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 +} + +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 { + // OpenAPI handler return type is preserved via `as typeof handler` at the call site. + // 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,11 +654,19 @@ 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 } /** @@ -472,7 +731,7 @@ export class OpenAPIHono< ? MaybePromise> : MaybePromise> | MaybePromise >, - hook: + hookOrHooks: | Hook< I, E, @@ -489,6 +748,7 @@ export class OpenAPIHono< ? MaybePromise> | undefined : MaybePromise> | MaybePromise | undefined > + | OpenAPIRouteHooks | undefined = this.defaultHook ): OpenAPIHono< E, @@ -499,25 +759,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 +797,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 +816,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 +841,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 } @@ -727,7 +1000,13 @@ export class OpenAPIHono< } 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 From 16e94c283fcf1c535d6616a3996f611e02be862f Mon Sep 17 00:00:00 2001 From: mikan3rd Date: Wed, 25 Mar 2026 14:10:28 +0900 Subject: [PATCH 2/7] feat(zod-openapi): enhance response validation with new test cases Added tests to validate response behavior for various status codes and response schemas, ensuring strict adherence to defined responses. This includes handling cases where status is set but not declared, and validating response bodies against multiple schemas. --- packages/zod-openapi/src/index.test.ts | 143 ++++++++++++++++++++++++- packages/zod-openapi/src/index.ts | 1 - 2 files changed, 142 insertions(+), 2 deletions(-) diff --git a/packages/zod-openapi/src/index.test.ts b/packages/zod-openapi/src/index.test.ts index 84c44da0d..11e902c8f 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', () => { @@ -2277,6 +2277,35 @@ describe('Response validation (strictStatusCode / strictResponse)', () => { 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 { + success: boolean + error: string + status: number + } + 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 deliberate 201 vs declared 200 (strictStatusCode test) @@ -2293,6 +2322,118 @@ describe('Response validation (strictStatusCode / strictResponse)', () => { 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 { + success: boolean + error: string + issues?: unknown[] + } + 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, diff --git a/packages/zod-openapi/src/index.ts b/packages/zod-openapi/src/index.ts index c4438a77b..50d2cc052 100644 --- a/packages/zod-openapi/src/index.ts +++ b/packages/zod-openapi/src/index.ts @@ -395,7 +395,6 @@ export type RouteHook< I, E, P, - // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- Hono-compatible handler may return void RouteConfigToTypedResponse | Response | Promise | void | Promise > From 88ec7bb07990f9193d128864ca0ad361beff5750 Mon Sep 17 00:00:00 2001 From: mikan3rd Date: Wed, 25 Mar 2026 14:24:38 +0900 Subject: [PATCH 3/7] refactor(zod-openapi): improve comments and documentation for response validation Updated comments in the response validation code to enhance clarity, including more descriptive error messages and streamlined documentation for the `ResponseHook` type. This change aims to improve code readability and maintainability. --- packages/zod-openapi/src/index.test.ts | 2 +- packages/zod-openapi/src/index.ts | 12 +++--------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/zod-openapi/src/index.test.ts b/packages/zod-openapi/src/index.test.ts index 11e902c8f..ae0254f00 100644 --- a/packages/zod-openapi/src/index.test.ts +++ b/packages/zod-openapi/src/index.test.ts @@ -2308,7 +2308,7 @@ describe('Response validation (strictStatusCode / strictResponse)', () => { it('Should reject undeclared status when strictStatusCode is on', async () => { const app = new OpenAPIHono({ strictStatusCode: true }) - // @ts-expect-error deliberate 201 vs declared 200 (strictStatusCode test) + // @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) diff --git a/packages/zod-openapi/src/index.ts b/packages/zod-openapi/src/index.ts index 50d2cc052..0d6292b9b 100644 --- a/packages/zod-openapi/src/index.ts +++ b/packages/zod-openapi/src/index.ts @@ -231,16 +231,11 @@ export type Hook = ( c: Context ) => R -/** Failure cases passed to {@link ResponseHook} (hook is only invoked on failure). */ export type ResponseHookFailure = | { kind: 'status_mismatch'; status: number } | { kind: 'body'; error: ZodError } -/** - * Called when response validation fails (`strictStatusCode` / `strictResponse`). - * Return a `Response` to send it; return `undefined` to use the built-in JSON error body (500). - * Use a synchronous hook; `c.json` / `c.text` run on the real context methods while the hook runs. - */ +/** When `strictStatusCode` or `strictResponse` fails; return a `Response` or `undefined` for the default 500 JSON. */ export type ResponseHook = ( result: ResponseHookFailure, c: Context @@ -252,9 +247,9 @@ type ConvertPathType = T extends `${infer Start}/{${infer Para export type OpenAPIHonoOptions = { defaultHook?: Hook - /** When true, `c.json` status must match a key in the route `responses` (numeric, range like `2XX`, or `default`). */ + /** Require `c.json` status to match a key in route `responses`. */ strictStatusCode?: boolean - /** When true, JSON response bodies are validated with the Zod schema from `responses` for the resolved status (if present). */ + /** Validate JSON bodies with Zod schemas from `responses` for the resolved status. */ strictResponse?: boolean defaultResponseHook?: ResponseHook } @@ -618,7 +613,6 @@ function wrapOpenAPIRouteHandler { const restore = installOpenAPIResponseValidation(c, responses, options) try { - // OpenAPI handler return type is preserved via `as typeof handler` at the call site. // eslint-disable-next-line @typescript-eslint/no-unsafe-return -- composed handler output is intentionally generic return await Promise.resolve(handler(c, next)) } finally { From 339be94a29a6499cab5f87698e6916f3023e94b7 Mon Sep 17 00:00:00 2001 From: mikan3rd Date: Wed, 25 Mar 2026 14:36:39 +0900 Subject: [PATCH 4/7] refactor(zod-openapi): simplify response validation type definitions Introduced a new type, BuiltinStrictValidationJson, to streamline response body type definitions in tests. This change enhances code clarity and reduces redundancy in type assertions for response validation scenarios. --- packages/zod-openapi/src/index.test.ts | 31 +++++++++----------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/packages/zod-openapi/src/index.test.ts b/packages/zod-openapi/src/index.test.ts index ae0254f00..70eca7f08 100644 --- a/packages/zod-openapi/src/index.test.ts +++ b/packages/zod-openapi/src/index.test.ts @@ -2225,6 +2225,13 @@ 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(), @@ -2259,11 +2266,7 @@ describe('Response validation (strictStatusCode / strictResponse)', () => { ) const res = await app.request('/item') expect(res.status).toBe(500) - const body = (await res.json()) as { - success: boolean - error: string - issues: unknown[] - } + 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) @@ -2296,11 +2299,7 @@ describe('Response validation (strictStatusCode / strictResponse)', () => { }) const res = await app.request('/item') expect(res.status).toBe(500) - const body = (await res.json()) as { - success: boolean - error: string - status: number - } + 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) @@ -2312,11 +2311,7 @@ describe('Response validation (strictStatusCode / strictResponse)', () => { 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 { - success: boolean - error: string - status: number - } + 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) @@ -2424,11 +2419,7 @@ describe('Response validation (strictStatusCode / strictResponse)', () => { 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 { - success: boolean - error: string - issues?: unknown[] - } + 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) From bc157bcf5dd3a243670d093103937ba53bca36fa Mon Sep 17 00:00:00 2001 From: mikan3rd Date: Wed, 25 Mar 2026 15:37:54 +0900 Subject: [PATCH 5/7] docs(zod-openapi): update README and tests for response validation improvements Enhanced the README to clarify the behavior of body-validation failures and the scope of strict response validation. Added a new test case to verify that strict response validation does not apply when middleware returns a response without calling `next()`. This improves documentation and ensures better understanding of response handling in the OpenAPIHono framework. --- packages/zod-openapi/README.md | 10 +++++- packages/zod-openapi/src/index.test.ts | 19 ++++++++++++ packages/zod-openapi/src/index.ts | 42 +++++++++++--------------- 3 files changed, 46 insertions(+), 25 deletions(-) diff --git a/packages/zod-openapi/README.md b/packages/zod-openapi/README.md index be2b3964e..41ee26385 100644 --- a/packages/zod-openapi/README.md +++ b/packages/zod-openapi/README.md @@ -314,6 +314,8 @@ Enable flags on `OpenAPIHono`: 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, @@ -342,7 +344,13 @@ app.openapi(route, handler, { }) ``` -Responses built only with `return new Response(JSON.stringify(...))` (without going through `c.json`) are **not** validated. +#### 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 diff --git a/packages/zod-openapi/src/index.test.ts b/packages/zod-openapi/src/index.test.ts index 70eca7f08..71e58a18d 100644 --- a/packages/zod-openapi/src/index.test.ts +++ b/packages/zod-openapi/src/index.test.ts @@ -2259,6 +2259,25 @@ describe('Response validation (strictStatusCode / strictResponse)', () => { 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) => diff --git a/packages/zod-openapi/src/index.ts b/packages/zod-openapi/src/index.ts index 0d6292b9b..cbc7a5096 100644 --- a/packages/zod-openapi/src/index.ts +++ b/packages/zod-openapi/src/index.ts @@ -466,6 +466,7 @@ function resolveOpenAPIResponseKey( return undefined } +/** Uses the first JSON-compatible `content` entry (key order) when several are present. */ function getJsonZodSchemaForOpenAPIResponse( responses: RouteConfig['responses'], responseKey: string @@ -597,6 +598,7 @@ function installOpenAPIResponseValidation( 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'], @@ -663,35 +665,27 @@ export class OpenAPIHono< } /** - * - * @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, From 97ce3f427f403e5a680d533c25f662d60ae91b93 Mon Sep 17 00:00:00 2001 From: mikan3rd Date: Fri, 27 Mar 2026 11:40:37 +0900 Subject: [PATCH 6/7] fix(zod-openapi): address edge cases in response validation tests Refined existing tests to cover additional edge cases in response validation, ensuring comprehensive coverage for various response scenarios. This update enhances the robustness of the validation logic and improves overall test reliability. --- .changeset/zod-openapi-response-validation.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/zod-openapi-response-validation.md 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). From ad3c514a99f25f4aa596ce2c2a7a68fbf7d7190f Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 5 Apr 2026 06:45:20 +0000 Subject: [PATCH 7/7] ci: apply automated fixes --- packages/zod-openapi/src/index.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/zod-openapi/src/index.test.ts b/packages/zod-openapi/src/index.test.ts index 71e58a18d..8aa84be41 100644 --- a/packages/zod-openapi/src/index.test.ts +++ b/packages/zod-openapi/src/index.test.ts @@ -2265,8 +2265,7 @@ describe('Response validation (strictStatusCode / strictResponse)', () => { { ...itemRoute, middleware: [ - (c) => - c.json({ id: 'x', n: 'bad' } as unknown as z.infer, 200), + (c) => c.json({ id: 'x', n: 'bad' } as unknown as z.infer, 200), ], }, () => {