From 101ce6dad685169b9b9110f9cc17ac8d23188ea9 Mon Sep 17 00:00:00 2001 From: Gabriel Massadas Date: Wed, 25 Feb 2026 14:20:16 +0000 Subject: [PATCH 1/2] feat: add response body validation via validateResponse option (#159) Add a validateResponse router option that parses response bodies through their Zod schema. When enabled, unknown fields are stripped from JSON responses and missing required fields trigger validation errors. Works with both plain object returns and Response objects. --- .changeset/response-validation.md | 5 + src/openapi.ts | 1 + src/route.ts | 68 +++++++ src/types.ts | 3 + tests/integration/router-options.test.ts | 214 +++++++++++++++++++++++ 5 files changed, 291 insertions(+) create mode 100644 .changeset/response-validation.md diff --git a/.changeset/response-validation.md b/.changeset/response-validation.md new file mode 100644 index 0000000..711e5df --- /dev/null +++ b/.changeset/response-validation.md @@ -0,0 +1,5 @@ +--- +"chanfana": minor +--- + +Add `validateResponse` router option to parse response bodies through their Zod schema, stripping unknown fields and validating required ones diff --git a/src/openapi.ts b/src/openapi.ts index 3bc2341..c81f78d 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -300,6 +300,7 @@ export class OpenAPIHandler { route: parsedRoute, urlParams: urlParams, raiseOnError: this.options?.raiseOnError, + validateResponse: this.options?.validateResponse, raiseUnknownParameters: this.options?.raiseUnknownParameters, passthroughErrors: this.options?.passthroughErrors, }).execute(...params); diff --git a/src/route.ts b/src/route.ts index f94f355..acdb1ab 100644 --- a/src/route.ts +++ b/src/route.ts @@ -191,6 +191,10 @@ export class OpenAPIRoute = any> { let resp; try { resp = await this.handle(...args); + + if (this.params?.validateResponse) { + resp = await this.validateResponse(resp); + } } catch (rawError) { if (this.params?.passthroughErrors) { throw rawError; @@ -218,6 +222,70 @@ export class OpenAPIRoute = any> { return resp; } + /** + * Finds the Zod schema for a response with the given status code. + * Falls back to the "default" response if no exact match is found. + * @param statusCode - HTTP status code to look up + * @returns Zod schema for the response body, or undefined if not found + */ + getResponseSchema(statusCode: number): z.ZodType | undefined { + const schema = this.getSchemaZod(); + const responses = schema.responses; + if (!responses) return undefined; + + const responseConfig = responses[String(statusCode)] ?? responses.default; + if (!responseConfig) return undefined; + + const jsonContent = responseConfig.content?.["application/json"]; + if (!jsonContent?.schema) return undefined; + + // Skip non-Zod schemas (e.g. empty {} from default response) + const zodSchema = jsonContent.schema; + if (!(zodSchema instanceof z.ZodType)) return undefined; + + return zodSchema; + } + + /** + * Validates a response body against the response schema. + * For plain objects, parses through Zod to strip unknown fields. + * For Response objects with JSON content, reads the body, parses, and reconstructs. + * @param resp - The response from handle() + * @returns The validated/stripped response + */ + async validateResponse(resp: any): Promise { + if (resp === null || resp === undefined) return resp; + + if (resp instanceof Response) { + const contentType = resp.headers.get("content-type") || ""; + if (!contentType.includes("application/json")) return resp; + + const responseSchema = this.getResponseSchema(resp.status); + if (!responseSchema) return resp; + + const body = await resp.json(); + const parsed = await responseSchema.parseAsync(body); + + // Reconstruct the Response with validated body and original status/headers + const newHeaders = new Headers(resp.headers); + return new Response(JSON.stringify(parsed), { + status: resp.status, + statusText: resp.statusText, + headers: newHeaders, + }); + } + + if (typeof resp === "object") { + // Plain objects are auto-converted to 200 JSON responses + const responseSchema = this.getResponseSchema(200); + if (!responseSchema) return resp; + + return await responseSchema.parseAsync(resp); + } + + return resp; + } + /** * Validates the incoming request against the schema. * @param request - The incoming Request object diff --git a/src/types.ts b/src/types.ts index 3e7a722..95a6f27 100644 --- a/src/types.ts +++ b/src/types.ts @@ -76,6 +76,8 @@ export interface RouterOptions { openapiVersion?: "3" | "3.1"; raiseOnError?: boolean; passthroughErrors?: boolean; + /** When enabled, response bodies are parsed through their Zod schema to strip unknown fields */ + validateResponse?: boolean; } export interface RouteOptions { @@ -83,6 +85,7 @@ export interface RouteOptions { raiseUnknownParameters: boolean; raiseOnError?: boolean; passthroughErrors?: boolean; + validateResponse?: boolean; route: string; urlParams: Array; } diff --git a/tests/integration/router-options.test.ts b/tests/integration/router-options.test.ts index 89f9be7..cde00ea 100644 --- a/tests/integration/router-options.test.ts +++ b/tests/integration/router-options.test.ts @@ -783,3 +783,217 @@ describe("itty-router error handling", () => { expect((resp as any).errors[0].code).toEqual(7002); }); }); + +// --- validateResponse tests --- + +class EndpointWithExtraFields extends OpenAPIRoute { + schema = { + responses: { + "200": { + description: "Success", + ...contentJson( + z.object({ + id: z.number(), + name: z.string(), + }), + ), + }, + }, + }; + + async handle() { + // Return extra fields that are not in the schema + return { + id: 1, + name: "Test", + secret: "should-be-stripped", + internal_notes: "also-stripped", + }; + } +} + +class EndpointReturningResponse extends OpenAPIRoute { + schema = { + responses: { + "201": { + description: "Created", + ...contentJson( + z.object({ + success: z.boolean(), + result: z.object({ + id: z.number(), + title: z.string(), + }), + }), + ), + }, + }, + }; + + async handle() { + return Response.json( + { + success: true, + result: { + id: 42, + title: "New Item", + passwordHash: "secret123", + }, + }, + { status: 201 }, + ); + } +} + +class EndpointWithNoResponseSchema extends OpenAPIRoute { + async handle() { + return { data: "anything", extra: "allowed" }; + } +} + +class EndpointReturningInvalidData extends OpenAPIRoute { + schema = { + responses: { + "200": { + description: "Success", + ...contentJson( + z.object({ + id: z.number(), + name: z.string(), + }), + ), + }, + }, + }; + + async handle() { + // Missing required "name" field + return { id: 1 }; + } +} + +describe("validateResponse option", () => { + describe("itty-router with validateResponse enabled", () => { + it("should strip unknown fields from plain object responses", async () => { + const router = fromIttyRouter(AutoRouter(), { validateResponse: true }); + router.get("/items/:id", EndpointWithExtraFields); + + const request = await router.fetch(buildRequest({ method: "GET", path: "/items/1" })); + const resp = await request.json(); + + expect(request.status).toBe(200); + expect(resp.id).toBe(1); + expect(resp.name).toBe("Test"); + expect(resp.secret).toBeUndefined(); + expect(resp.internal_notes).toBeUndefined(); + }); + + it("should strip unknown fields from Response object bodies", async () => { + const router = fromIttyRouter(AutoRouter(), { validateResponse: true }); + router.post("/items", EndpointReturningResponse); + + const request = await router.fetch( + new Request("http://localhost/items", { + method: "POST", + body: JSON.stringify({}), + headers: { "Content-Type": "application/json" }, + }), + ); + const resp = await request.json(); + + expect(request.status).toBe(201); + expect(resp.success).toBe(true); + expect(resp.result.id).toBe(42); + expect(resp.result.title).toBe("New Item"); + expect(resp.result.passwordHash).toBeUndefined(); + }); + + it("should pass through responses when no response schema is defined", async () => { + const router = fromIttyRouter(AutoRouter(), { validateResponse: true }); + router.get("/items", EndpointWithNoResponseSchema); + + const request = await router.fetch(buildRequest({ method: "GET", path: "/items" })); + const resp = await request.json(); + + expect(request.status).toBe(200); + expect(resp.data).toBe("anything"); + expect(resp.extra).toBe("allowed"); + }); + + it("should throw validation error when response is missing required fields", async () => { + const router = fromIttyRouter(AutoRouter(), { validateResponse: true }); + router.get("/items/:id", EndpointReturningInvalidData); + + // With itty-router (raiseOnError is always false), ZodError is caught and formatted + const request = await router.fetch(buildRequest({ method: "GET", path: "/items/1" })); + const resp = await request.json(); + + expect(request.status).toBe(400); + expect(resp.success).toBe(false); + expect(resp.errors).toBeDefined(); + }); + }); + + describe("itty-router without validateResponse (default)", () => { + it("should NOT strip unknown fields when validateResponse is not set", async () => { + const router = fromIttyRouter(AutoRouter()); + router.get("/items/:id", EndpointWithExtraFields); + + const request = await router.fetch(buildRequest({ method: "GET", path: "/items/1" })); + const resp = await request.json(); + + expect(request.status).toBe(200); + expect(resp.id).toBe(1); + expect(resp.name).toBe("Test"); + // Extra fields should still be present + expect(resp.secret).toBe("should-be-stripped"); + expect(resp.internal_notes).toBe("also-stripped"); + }); + }); + + describe("Hono with validateResponse enabled", () => { + it("should strip unknown fields from plain object responses", async () => { + const app = new Hono(); + app.onError((err, c) => { + if (err instanceof HTTPException) { + return err.getResponse(); + } + return c.json({ error: "Internal Server Error" }, 500); + }); + + const router = fromHono(app, { validateResponse: true }); + router.get("/items/:id", EndpointWithExtraFields); + + const request = await router.fetch(new Request("http://localhost/items/1", { method: "GET" })); + const resp = (await request.json()) as any; + + expect(request.status).toBe(200); + expect(resp.id).toBe(1); + expect(resp.name).toBe("Test"); + expect(resp.secret).toBeUndefined(); + expect(resp.internal_notes).toBeUndefined(); + }); + + it("should raise validation error for invalid response data", async () => { + const app = new Hono(); + let caughtError: unknown = null; + + app.onError((err, c) => { + caughtError = err; + if (err instanceof HTTPException) { + return err.getResponse(); + } + return c.json({ error: "Internal Server Error" }, 500); + }); + + const router = fromHono(app, { validateResponse: true }); + router.get("/items/:id", EndpointReturningInvalidData); + + const request = await router.fetch(new Request("http://localhost/items/1", { method: "GET" })); + + // Hono has raiseOnError: true, so the ZodError is wrapped as HTTPException + expect(caughtError).toBeDefined(); + expect(request.status).toBe(400); + }); + }); +}); From 4aa1d44c24b874f7445db673c3bbaa9b8adec5f1 Mon Sep 17 00:00:00 2001 From: Gabriel Massadas Date: Thu, 26 Feb 2026 22:56:07 +0000 Subject: [PATCH 2/2] fix: harden response validation with proper error semantics, header handling, and docs - Return 500 (not 400) for response validation failures via new ResponseValidationException (code 7013, isVisible: false) - console.error full ZodError details for server-side debugging - Isolate response validation in its own try/catch (no longer flows through handleError hook as a raw ZodError) - Delete Content-Length/Transfer-Encoding headers when reconstructing Response objects after field stripping - Clone Response before consuming body for defensive recovery - Add 7 new tests: non-JSON passthrough, null responses, default schema fallback, unmatched status codes, Hono+Response, passthroughErrors interaction, Zod defaults on responses - Update existing invalid-data tests to expect 500/7013 - Document validateResponse option in router options docs - Document ResponseValidationException in error handling docs - Expand changeset with usage examples and behavior details --- .changeset/response-validation.md | 17 +- docs/error-handling.md | 25 ++ docs/openapi-configuration-customization.md | 76 ++++++ src/exceptions.ts | 13 ++ src/route.ts | 26 ++- src/types.ts | 7 +- tests/integration/router-options.test.ts | 245 +++++++++++++++++++- 7 files changed, 392 insertions(+), 17 deletions(-) diff --git a/.changeset/response-validation.md b/.changeset/response-validation.md index 711e5df..6b5a1df 100644 --- a/.changeset/response-validation.md +++ b/.changeset/response-validation.md @@ -2,4 +2,19 @@ "chanfana": minor --- -Add `validateResponse` router option to parse response bodies through their Zod schema, stripping unknown fields and validating required ones +Add `validateResponse` router option to validate and sanitize response bodies against their Zod schemas at runtime. + +When enabled, responses are parsed through `z.object().parseAsync()`, which strips unknown fields and validates required fields/types. This prevents accidental data leaks (e.g., internal fields like `passwordHash` reaching the client) and catches handler bugs where the response doesn't match the declared schema. + +```typescript +const router = fromHono(app, { validateResponse: true }); +``` + +**Behavior:** +- Plain object responses are validated against the `200` response schema +- `Response` objects with `application/json` content are cloned, validated, and reconstructed with corrected headers +- Non-JSON responses and responses without a matching Zod schema are passed through unchanged +- Validation failures return `500 Internal Server Error` (code `7013`) and log the full error via `console.error` + +**New exports:** +- `ResponseValidationException` — thrown when a handler's response doesn't match its declared schema (status 500, code 7013, `isVisible: false`) diff --git a/docs/error-handling.md b/docs/error-handling.md index 8d640d0..21b1191 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -367,6 +367,30 @@ if (maintenanceMode) { * **`code`:** `7012` * **`default_message`:** `"Gateway Timeout"` +### `ResponseValidationException`: Response Schema Mismatch (500) + +`ResponseValidationException` is thrown automatically when the [`validateResponse`](./openapi-configuration-customization.md#validateresponse-response-body-validation) option is enabled and a handler's response doesn't match its declared Zod response schema (e.g., a required field is missing or has the wrong type). + +**Key features:** +* **`status`:** `500 Internal Server Error` +* **`code`:** `7013` +* **`default_message`:** `"Response Validation Error"` +* **`isVisible`:** `false` (error details hidden from clients to avoid leaking internal schema information) + +This exception is **not intended to be thrown manually**. It is raised internally by chanfana when response validation fails. The full validation error (including Zod issue details) is logged via `console.error` for server-side debugging. + +```json +{ + "success": false, + "errors": [{ "code": 7013, "message": "Internal Error" }], + "result": {} +} +``` + +::: tip +A response validation failure indicates a **server-side bug** — the handler produced data that doesn't match its declared schema. Check your server logs (`console.error`) for the specific Zod validation issues, then fix the handler or update the response schema. +::: + ## Exception Summary Table | Exception | Status | Code | Default Message | Special Properties | @@ -384,6 +408,7 @@ if (maintenanceMode) { | `BadGatewayException` | 502 | 7010 | "Bad Gateway" | - | | `ServiceUnavailableException` | 503 | 7011 | "Service Unavailable" | `retryAfter` | | `GatewayTimeoutException` | 504 | 7012 | "Gateway Timeout" | - | +| `ResponseValidationException` | 500 | 7013 | "Response Validation Error" | `isVisible: false` | ### `MultiException`: Managing Multiple Errors diff --git a/docs/openapi-configuration-customization.md b/docs/openapi-configuration-customization.md index 42f667e..5d4faba 100644 --- a/docs/openapi-configuration-customization.md +++ b/docs/openapi-configuration-customization.md @@ -224,6 +224,82 @@ This option is most useful with Hono, where `app.onError` provides centralized e See [Error Handling - Bypassing Chanfana's Error Formatting](./error-handling.md#bypassing-chanfanas-error-formatting) for a detailed walkthrough. +### `validateResponse`: Response Body Validation + +* **Type:** `boolean` +* **Default:** `false` + +The `validateResponse` option enables runtime validation of response bodies against their declared Zod schemas. When enabled, every response from `handle()` is parsed through the matching Zod response schema before being sent to the client. + +This provides two key benefits: + +1. **Data leak prevention:** Unknown fields are stripped from responses, so internal properties (e.g., `passwordHash`, `internalNotes`) never accidentally reach the client. +2. **Handler bug detection:** If a handler returns data missing required fields or with wrong types, the mismatch is caught immediately instead of silently returning malformed responses. + +**Example:** + +```typescript +import { Hono } from 'hono'; +import { fromHono, OpenAPIRoute, contentJson } from 'chanfana'; +import { z } from 'zod'; + +class GetUserEndpoint extends OpenAPIRoute { + schema = { + responses: { + "200": { + description: "User details", + ...contentJson(z.object({ + id: z.number(), + name: z.string(), + })), + }, + }, + }; + + async handle(c) { + const user = await db.getUser(1); + // Even if `user` contains { id: 1, name: "Alice", passwordHash: "..." }, + // the response will only include { id: 1, name: "Alice" } + return user; + } +} + +const app = new Hono(); +const router = fromHono(app, { validateResponse: true }); +router.get("/user/:id", GetUserEndpoint); +``` + +**How it works:** + +| Response type | Behavior | +|---|---| +| Plain object | Validated against the `200` response schema | +| `Response` with `application/json` | Body is cloned, validated against the schema matching `resp.status`, and reconstructed with corrected headers | +| `Response` with non-JSON content type | Passed through unchanged | +| No Zod schema defined for the status code | Passed through unchanged | +| `null` or `undefined` | Passed through unchanged | + +**When validation fails** (e.g., a required field is missing from the response), chanfana: + +1. Logs the full error details via `console.error` for server-side debugging. +2. Returns a `500 Internal Server Error` response with error code `7013`. The error message is hidden from clients (`isVisible: false`) to avoid leaking internal schema details. + +```json +{ + "success": false, + "errors": [{ "code": 7013, "message": "Internal Error" }], + "result": {} +} +``` + +::: tip +Response validation failures are **server-side bugs** (the handler doesn't match its declared schema), which is why they return `500` — not `400`. Check `console.error` output for the specific Zod validation issues. +::: + +::: warning +This option adds a parsing step to every response. For most APIs the overhead is negligible, but for extremely high-throughput endpoints returning large payloads you may want to enable it selectively or only in development/staging. +::: + ## Customizing OpenAPI Schema Output While `RouterOptions` allows you to configure the overall OpenAPI document, you can also customize the schema output for individual endpoints and parameters using Zod's OpenAPI metadata features. diff --git a/src/exceptions.ts b/src/exceptions.ts index 0c403ab..635e2b6 100644 --- a/src/exceptions.ts +++ b/src/exceptions.ts @@ -288,3 +288,16 @@ export class GatewayTimeoutException extends ApiException { status = 504; code = 7012; } + +/** + * Exception for response validation errors (500). + * Used when a handler's response doesn't match its declared Zod response schema. + * This is a server-side bug (the handler produced invalid data), not a client error. + * Error details are hidden from clients (isVisible=false) and logged via console.error. + */ +export class ResponseValidationException extends ApiException { + isVisible = false; + default_message = "Response Validation Error"; + status = 500; + code = 7013; +} diff --git a/src/route.ts b/src/route.ts index acdb1ab..d5fcdc8 100644 --- a/src/route.ts +++ b/src/route.ts @@ -1,6 +1,6 @@ import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; import { z } from "zod"; -import { InputValidationException, MultiException } from "./exceptions"; +import { InputValidationException, MultiException, ResponseValidationException } from "./exceptions"; import { coerceInputs } from "./parameters"; import type { AnyZodObject, OpenAPIRouteSchema, RouteOptions, ValidatedData } from "./types"; import { formatChanfanaError, jsonResp } from "./utils"; @@ -193,7 +193,12 @@ export class OpenAPIRoute = any> { resp = await this.handle(...args); if (this.params?.validateResponse) { - resp = await this.validateResponse(resp); + try { + resp = await this.validateResponse(resp); + } catch (validationError) { + console.error("[chanfana] Response validation failed:", validationError); + throw new ResponseValidationException(); + } } } catch (rawError) { if (this.params?.passthroughErrors) { @@ -248,10 +253,13 @@ export class OpenAPIRoute = any> { /** * Validates a response body against the response schema. - * For plain objects, parses through Zod to strip unknown fields. - * For Response objects with JSON content, reads the body, parses, and reconstructs. + * For plain objects, parses through Zod to strip unknown fields and validate types. + * For Response objects with JSON content, clones the body, parses, and reconstructs + * with corrected headers (Content-Length/Transfer-Encoding are removed). + * Responses without a matching Zod schema (including non-JSON responses) are passed through unchanged. * @param resp - The response from handle() * @returns The validated/stripped response + * @throws ZodError if the response body fails schema validation */ async validateResponse(resp: any): Promise { if (resp === null || resp === undefined) return resp; @@ -263,11 +271,17 @@ export class OpenAPIRoute = any> { const responseSchema = this.getResponseSchema(resp.status); if (!responseSchema) return resp; - const body = await resp.json(); + // Clone before consuming the body stream so the original remains readable on failure + const cloned = resp.clone(); + const body = await cloned.json(); const parsed = await responseSchema.parseAsync(body); - // Reconstruct the Response with validated body and original status/headers + // Reconstruct the Response with validated body and original status/headers. + // Delete Content-Length and Transfer-Encoding since the body size may have changed + // after stripping unknown fields. const newHeaders = new Headers(resp.headers); + newHeaders.delete("content-length"); + newHeaders.delete("transfer-encoding"); return new Response(JSON.stringify(parsed), { status: resp.status, statusText: resp.statusText, diff --git a/src/types.ts b/src/types.ts index 95a6f27..6167821 100644 --- a/src/types.ts +++ b/src/types.ts @@ -76,7 +76,12 @@ export interface RouterOptions { openapiVersion?: "3" | "3.1"; raiseOnError?: boolean; passthroughErrors?: boolean; - /** When enabled, response bodies are parsed through their Zod schema to strip unknown fields */ + /** + * When enabled, response bodies are parsed through their Zod schema, + * stripping unknown fields and validating required fields/types. + * Validation failures result in a 500 error response and a console.error log. + * Responses without a Zod schema are passed through unchanged. + */ validateResponse?: boolean; } diff --git a/tests/integration/router-options.test.ts b/tests/integration/router-options.test.ts index cde00ea..21dc007 100644 --- a/tests/integration/router-options.test.ts +++ b/tests/integration/router-options.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; import { fromHono, fromIttyRouter } from "../../src"; import { contentJson } from "../../src/contentTypes"; -import { ApiException, MultiException, NotFoundException } from "../../src/exceptions"; +import { ApiException, MultiException, NotFoundException, ResponseValidationException } from "../../src/exceptions"; import { OpenAPIRoute } from "../../src/route"; import { buildRequest } from "../utils"; @@ -851,6 +851,107 @@ class EndpointWithNoResponseSchema extends OpenAPIRoute { } } +class EndpointReturningHtml extends OpenAPIRoute { + schema = { + responses: { + "200": { + description: "Success", + ...contentJson( + z.object({ + id: z.number(), + }), + ), + }, + }, + }; + + async handle() { + return new Response("

Hello

", { + status: 200, + headers: { "Content-Type": "text/html" }, + }); + } +} + +class EndpointReturningNull extends OpenAPIRoute { + schema = { + responses: { + "200": { + description: "Success", + ...contentJson( + z.object({ + id: z.number(), + }), + ), + }, + }, + }; + + async handle() { + return null; + } +} + +class EndpointWithDefaultResponseSchema extends OpenAPIRoute { + schema = { + responses: { + default: { + description: "Default response", + ...contentJson( + z.object({ + status: z.string(), + }), + ), + }, + }, + }; + + async handle() { + return { status: "ok", debug: "should-be-stripped" }; + } +} + +class EndpointReturningUnmatchedStatus extends OpenAPIRoute { + schema = { + responses: { + "200": { + description: "Success", + ...contentJson( + z.object({ + id: z.number(), + }), + ), + }, + }, + }; + + async handle() { + // Return 202 (only 200 is defined in schema) to test fallback passthrough + return Response.json({ id: 1, extra: "data" }, { status: 202 }); + } +} + +class EndpointWithResponseDefaults extends OpenAPIRoute { + schema = { + responses: { + "200": { + description: "Success", + ...contentJson( + z.object({ + id: z.number(), + status: z.string().default("active"), + }), + ), + }, + }, + }; + + async handle() { + // Only return id; the "status" field should get its default from Zod + return { id: 1 }; + } +} + class EndpointReturningInvalidData extends OpenAPIRoute { schema = { responses: { @@ -920,17 +1021,109 @@ describe("validateResponse option", () => { expect(resp.extra).toBe("allowed"); }); - it("should throw validation error when response is missing required fields", async () => { + it("should return 500 when response is missing required fields", async () => { const router = fromIttyRouter(AutoRouter(), { validateResponse: true }); router.get("/items/:id", EndpointReturningInvalidData); - // With itty-router (raiseOnError is always false), ZodError is caught and formatted const request = await router.fetch(buildRequest({ method: "GET", path: "/items/1" })); const resp = await request.json(); - expect(request.status).toBe(400); + // Response validation failure is a server-side bug → 500, not 400 + expect(request.status).toBe(500); expect(resp.success).toBe(false); - expect(resp.errors).toBeDefined(); + expect(resp.errors[0].code).toBe(7013); + expect(resp.errors[0].message).toBe("Internal Error"); + }); + }); + + it("should pass through non-JSON Response objects unchanged", async () => { + const router = fromIttyRouter(AutoRouter(), { validateResponse: true }); + router.get("/html", EndpointReturningHtml); + + const request = await router.fetch(buildRequest({ method: "GET", path: "/html" })); + + expect(request.status).toBe(200); + expect(request.headers.get("content-type")).toBe("text/html"); + const body = await request.text(); + expect(body).toBe("

Hello

"); + }); + + it("should handle null responses without errors", async () => { + const app = new Hono(); + let caughtError: unknown = null; + + app.onError((err, c) => { + caughtError = err; + return c.json({ error: "Internal Server Error" }, 500); + }); + + const router = fromHono(app, { validateResponse: true }); + router.get("/null", EndpointReturningNull); + + const request = await router.fetch(new Request("http://localhost/null", { method: "GET" })); + + // null passes through validateResponse without throwing; + // no error should reach onError (Hono may return 404 for null responses) + expect(caughtError).toBeNull(); + expect(request.status).not.toBe(500); + }); + + it("should fall back to the default response schema", async () => { + const router = fromIttyRouter(AutoRouter(), { validateResponse: true }); + router.get("/default", EndpointWithDefaultResponseSchema); + + const request = await router.fetch(buildRequest({ method: "GET", path: "/default" })); + const resp = await request.json(); + + expect(request.status).toBe(200); + expect(resp.status).toBe("ok"); + expect(resp.debug).toBeUndefined(); + }); + + it("should pass through when status code has no matching schema", async () => { + const router = fromIttyRouter(AutoRouter(), { validateResponse: true }); + router.get("/unmatched", EndpointReturningUnmatchedStatus); + + const request = await router.fetch(buildRequest({ method: "GET", path: "/unmatched" })); + const resp = await request.json(); + + // 202 has no schema and no "default" → passed through unchanged (extra not stripped) + expect(request.status).toBe(202); + expect(resp.id).toBe(1); + expect(resp.extra).toBe("data"); + }); + + it("should apply Zod defaults to response fields", async () => { + const router = fromIttyRouter(AutoRouter(), { validateResponse: true }); + router.get("/defaults", EndpointWithResponseDefaults); + + const request = await router.fetch(buildRequest({ method: "GET", path: "/defaults" })); + const resp = await request.json(); + + expect(request.status).toBe(200); + expect(resp.id).toBe(1); + expect(resp.status).toBe("active"); + }); + + describe("validateResponse with passthroughErrors", () => { + it("should propagate raw ResponseValidationException to Hono onError", async () => { + const app = new Hono(); + let caughtError: unknown = null; + + app.onError((err, c) => { + caughtError = err; + return c.json({ error: "caught" }, 500); + }); + + const router = fromHono(app, { validateResponse: true, passthroughErrors: true }); + router.get("/items/:id", EndpointReturningInvalidData); + + const request = await router.fetch(new Request("http://localhost/items/1", { method: "GET" })); + + // With passthroughErrors, the raw ResponseValidationException reaches onError (not HTTPException) + expect(caughtError).toBeInstanceOf(ResponseValidationException); + expect(caughtError).not.toBeInstanceOf(HTTPException); + expect(request.status).toBe(500); }); }); @@ -974,7 +1167,7 @@ describe("validateResponse option", () => { expect(resp.internal_notes).toBeUndefined(); }); - it("should raise validation error for invalid response data", async () => { + it("should return 500 with error code 7013 for invalid response data", async () => { const app = new Hono(); let caughtError: unknown = null; @@ -991,9 +1184,43 @@ describe("validateResponse option", () => { const request = await router.fetch(new Request("http://localhost/items/1", { method: "GET" })); - // Hono has raiseOnError: true, so the ZodError is wrapped as HTTPException - expect(caughtError).toBeDefined(); - expect(request.status).toBe(400); + // Hono adapter wraps ApiException as HTTPException via wrapHandler + expect(caughtError).toBeInstanceOf(HTTPException); + expect((caughtError as HTTPException).status).toBe(500); + + const resp = (await request.json()) as any; + expect(request.status).toBe(500); + expect(resp.success).toBe(false); + expect(resp.errors[0].code).toBe(7013); + expect(resp.errors[0].message).toBe("Internal Error"); + }); + + it("should strip unknown fields from Response object bodies", async () => { + const app = new Hono(); + app.onError((err, c) => { + if (err instanceof HTTPException) { + return err.getResponse(); + } + return c.json({ error: "Internal Server Error" }, 500); + }); + + const router = fromHono(app, { validateResponse: true }); + router.post("/items", EndpointReturningResponse); + + const request = await router.fetch( + new Request("http://localhost/items", { + method: "POST", + body: JSON.stringify({}), + headers: { "Content-Type": "application/json" }, + }), + ); + const resp = (await request.json()) as any; + + expect(request.status).toBe(201); + expect(resp.success).toBe(true); + expect(resp.result.id).toBe(42); + expect(resp.result.title).toBe("New Item"); + expect(resp.result.passwordHash).toBeUndefined(); }); }); });