Skip to content
5 changes: 5 additions & 0 deletions .changeset/zod-openapi-response-validation.md
Original file line number Diff line number Diff line change
@@ -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).
49 changes: 49 additions & 0 deletions packages/zod-openapi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
314 changes: 313 additions & 1 deletion packages/zod-openapi/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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<typeof ItemSchema>, 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<typeof ItemSchema>, 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<typeof ItemSchema>, 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<typeof multiRoute> = (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<typeof multiRoute> = (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 = $(
Expand Down
Loading
Loading