Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/response-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"chanfana": minor
---

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`)
25 changes: 25 additions & 0 deletions docs/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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

Expand Down
76 changes: 76 additions & 0 deletions docs/openapi-configuration-customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions src/exceptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
1 change: 1 addition & 0 deletions src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
84 changes: 83 additions & 1 deletion src/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -191,6 +191,15 @@ export class OpenAPIRoute<HandleArgs extends Array<object> = any> {
let resp;
try {
resp = await this.handle(...args);

if (this.params?.validateResponse) {
try {
resp = await this.validateResponse(resp);
} catch (validationError) {
console.error("[chanfana] Response validation failed:", validationError);
throw new ResponseValidationException();
}
}
} catch (rawError) {
if (this.params?.passthroughErrors) {
throw rawError;
Expand Down Expand Up @@ -218,6 +227,79 @@ export class OpenAPIRoute<HandleArgs extends Array<object> = 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 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<any> {
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;

// 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.
// 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,
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
Expand Down
8 changes: 8 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,21 @@ export interface RouterOptions {
openapiVersion?: "3" | "3.1";
raiseOnError?: boolean;
passthroughErrors?: boolean;
/**
* 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;
}

export interface RouteOptions {
router: any;
raiseUnknownParameters: boolean;
raiseOnError?: boolean;
passthroughErrors?: boolean;
validateResponse?: boolean;
route: string;
urlParams: Array<string>;
}
Expand Down
Loading
Loading