UNPKG

chanfana

Version:

OpenAPI 3 and 3.1 schema generator and validator for Hono, itty-router and more!

371 lines (317 loc) 12.7 kB
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; } } }