chanfana
Version:
OpenAPI 3 and 3.1 schema generator and validator for Hono, itty-router and more!
371 lines (317 loc) • 12.7 kB
text/typescript
import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";
import { z } from "zod";
import { InputValidationException, MultiException, ResponseValidationException } from "./exceptions";
import { coerceInputs } from "./parameters";
import type { AnyZodObject, OpenAPIRouteSchema, RouteOptions, ValidatedData } from "./types";
import { formatChanfanaError, jsonResp } from "./utils";
extendZodWithOpenApi(z);
/**
* Base class for all OpenAPI route handlers.
* Provides request validation, error handling, and response formatting.
*
* @template HandleArgs - Router handler arguments type
*/
export class OpenAPIRoute<HandleArgs extends Array<object> = any> {
/**
* The main handler method to be implemented by subclasses.
* @param _args - Handler arguments (context, request, etc. depending on router)
* @returns Response object or plain object (will be auto-converted to JSON)
*/
handle(..._args: any[]): Response | Promise<Response> | object | Promise<object> {
throw new Error("Method not implemented.");
}
static isRoute = true;
/** Args the execute() was called with */
args: HandleArgs;
/** Cache for validated data - prevents re-validation on multiple calls */
validatedData: any = undefined;
/** Cache for raw request data before Zod applies defaults/transformations */
unvalidatedData: any = undefined;
/** Route configuration options */
params: RouteOptions;
/** OpenAPI schema definition for this route */
schema: OpenAPIRouteSchema = {};
constructor(params: RouteOptions) {
this.params = params;
this.args = [] as any;
}
/**
* Gets validated request data, validating the request if not already done.
* Results are cached for subsequent calls.
*
* @returns Validated data including params, query, headers, and body
*/
async getValidatedData<S = any>(): Promise<ValidatedData<S>> {
const request = this.params.router.getRequest(this.args);
if (this.validatedData !== undefined) return this.validatedData;
const data = await this.validateRequest(request);
this.validatedData = data;
return data;
}
/**
* Gets raw request data before Zod validation/transformation.
* Useful for checking which fields were actually sent in the request,
* especially when using Zod 4 with optional fields that have defaults.
*
* @returns Raw request data object
*/
async getUnvalidatedData(): Promise<any> {
if (this.unvalidatedData !== undefined) return this.unvalidatedData;
const request = this.params.router.getRequest(this.args);
const schema: OpenAPIRouteSchema = this.getSchemaZod();
const unvalidatedData: any = {};
if (schema.request?.params) {
unvalidatedData.params = coerceInputs(this.params.router.getUrlParams(this.args), schema.request?.params);
}
const { searchParams } = new URL(request.url);
if (schema.request?.query) {
const queryParams = coerceInputs(searchParams, schema.request.query);
unvalidatedData.query = queryParams ?? {};
}
if (schema.request?.headers) {
const tmpHeaders: Record<string, string | null> = {};
const rHeaders = new Headers(request.headers);
for (const header of Object.keys((schema.request.headers as AnyZodObject).shape)) {
tmpHeaders[header] = rHeaders.get(header);
}
unvalidatedData.headers = coerceInputs(tmpHeaders, schema.request.headers as AnyZodObject) ?? {};
}
// Only parse body for non-GET/HEAD requests with JSON content
if (
!["get", "head"].includes(request.method.toLowerCase()) &&
schema.request?.body?.content?.["application/json"]?.schema
) {
try {
unvalidatedData.body = await request.json();
} catch (_e) {
// JSON parse error - store empty body and let Zod validation handle required fields
unvalidatedData.body = {};
}
}
this.unvalidatedData = unvalidatedData;
return unvalidatedData;
}
/**
* Returns the OpenAPI schema for this route.
* Override this method to customize schema properties.
*/
getSchema(): OpenAPIRouteSchema {
return this.schema;
}
/**
* Returns the schema with Zod types, adding default response if not provided.
* Note: This creates a shallow copy - nested objects are still references.
*/
getSchemaZod(): OpenAPIRouteSchema {
// Shallow copy - nested objects are still references
const schema = { ...this.getSchema() };
if (!schema.responses) {
// No response was provided in the schema, default to a blank one.
// The `schema: {}` (plain object, not a ZodType) is intentional:
// getResponseSchema() relies on the `instanceof z.ZodType` check to
// skip this default and return undefined, so validateResponse() passes
// through responses when no real schema is defined.
schema.responses = {
"200": {
description: "Successful response",
content: {
"application/json": {
schema: {},
},
},
},
};
}
return schema;
}
/**
* Hook to transform errors thrown during handle().
* Override this method to wrap, replace, or re-classify errors before
* chanfana's default error formatting runs.
*
* The returned value is used for all subsequent error handling:
* - If `raiseOnError` is true, the returned error is re-thrown (e.g. to Hono's onError).
* - Otherwise, chanfana's `formatChanfanaError` is called on the returned error.
*
* @example
* ```typescript
* class MyRoute extends OpenAPIRoute {
* protected handleError(error: unknown): unknown {
* // Wrap ApiExceptions so they bypass chanfana's formatter
* // and reach Hono's onError handler directly
* if (error instanceof ApiException) {
* return new MyCustomError(error);
* }
* return error;
* }
* }
* ```
*
* @param error - The caught error
* @returns The error (possibly transformed) to be handled by chanfana.
* Should be an Error instance. Returning non-Error values (null, strings, etc.)
* may produce confusing stack traces if the error is ultimately re-thrown.
*/
protected handleError(error: unknown): unknown {
return error;
}
/**
* Main execution method called by the router.
* Handles validation, error catching, and response formatting.
*
* Caches are reset on each execution to ensure request isolation.
*
* @param args - Handler arguments from the router
* @returns Response object
*/
async execute(...args: HandleArgs) {
// Reset caches for request isolation
this.validatedData = undefined;
this.unvalidatedData = undefined;
this.args = args;
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("Response body does not match schema", { cause: validationError });
}
}
} catch (rawError) {
if (this.params?.passthroughErrors) {
throw rawError;
}
const e = this.handleError(rawError) ?? rawError;
if (this.params?.raiseOnError) {
throw e;
}
const errorResponse = formatChanfanaError(e);
if (errorResponse) {
return errorResponse;
}
throw e;
}
// Auto-convert plain objects to JSON responses
if (resp !== null && resp !== undefined && !(resp instanceof Response) && typeof resp === "object") {
return jsonResp(resp);
}
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.
*
* Note: Body-dependent headers such as ETag or Content-MD5 are preserved from the
* original response and may become stale after fields are stripped or defaults applied.
*
* @param resp - The response from handle()
* @returns The validated/stripped response
* @throws ZodError if the response body fails schema validation
* @throws SyntaxError if a Response claims application/json but the body is not valid JSON
*/
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();
let body: unknown;
try {
body = await cloned.json();
} catch (parseError) {
console.error("[chanfana] Response body is not valid JSON despite content-type header:", parseError);
throw parseError;
}
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
* @returns Validated and typed request data
* @throws ZodError if validation fails
*/
async validateRequest(request: Request) {
const schema: OpenAPIRouteSchema = this.getSchemaZod();
// Get unvalidated data (this also stores it in this.unvalidatedData)
const unvalidatedData = await this.getUnvalidatedData();
const rawSchema: any = {};
if (schema.request?.params) {
rawSchema.params = schema.request.params;
}
if (schema.request?.query) {
rawSchema.query = schema.request.query;
}
if (schema.request?.headers) {
rawSchema.headers = schema.request.headers;
}
if (
!["get", "head"].includes(request.method.toLowerCase()) &&
schema.request?.body?.content?.["application/json"]?.schema
) {
rawSchema.body = schema.request.body.content["application/json"].schema;
}
let validationSchema: any;
if (this.params?.raiseUnknownParameters === undefined || this.params?.raiseUnknownParameters === true) {
validationSchema = z.strictObject(rawSchema);
} else {
validationSchema = z.object(rawSchema);
}
try {
return await validationSchema.parseAsync(unvalidatedData);
} catch (e) {
if (e instanceof z.ZodError) {
throw new MultiException(
e.issues.map((issue) => new InputValidationException(issue.message, issue.path.map(String))),
);
}
throw e;
}
}
}