UNPKG

chanfana

Version:

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

1,460 lines (1,439 loc) 73.5 kB
// src/index.ts import { extendZodWithOpenApi as extendZodWithOpenApi3 } from "@asteasolutions/zod-to-openapi"; // src/openapi.ts import { OpenApiGeneratorV3, OpenApiGeneratorV31 } from "@asteasolutions/zod-to-openapi"; import yaml from "js-yaml"; import { z as z2 } from "zod"; // src/ui.ts function getSwaggerUI(schemaUrl) { schemaUrl = schemaUrl.replace(/\/+(\/|$)/g, "$1"); return `<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"/> <meta name="viewport" content="width=device-width, initial-scale=1"/> <meta name="description" content="SwaggerIU"/> <title>SwaggerUI</title> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.17.14/swagger-ui.css" integrity="sha256-QBcPDuhZ0X+SExunBzKaiKBw5PZodNETZemnfSMvYRc=" crossorigin="anonymous"> <link rel="shortcut icon" href="data:image/x-icon;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlMb//2ux//9or///ZKz//wlv5f8JcOf/CnXv/why7/8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB2vi/wZo3/9ytf//b7P//2uw//+BvP//DHbp/w568P8Md+//CnXv/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAApv4/8HbOH/lMf//3W3//9ytf//brL//w946v8SfvH/EHzw/w558P8AAAAAAAAAAAAAAAAAAAAAAAAAABF56f8Ndef/C3Dj/whs4f98u///eLn//3W3//+Evv//FoPx/xSA8f8SfvD/EHvw/wAAAAAAAAAAAAAAAA1EeF0WgOz/EXrp/w515v8LceT/lsn//3+9//97u///eLj//xaB7f8YhfL/FoLx/xSA8f8JP/deAAAAAAAAAAAgjfH/HIjw/xeB7P8Te+n/AAAAAAAAAACGwf//gr///369//+Iwf//HIny/xqH8v8YhfL/FYLx/wAAAAAnlfPlJJLy/yGO8v8cifD/GILt/wAAAAAAAAAAmMz//4nD//+Fwf//gb///xyJ8P8ejPP/HIny/xmH8v8XhPLnK5r0/yiW8/8lk/P/IpDy/wAAAAAAAAAAAAAAAAAAAACPx///jMX//4jD//+MxP//IpD0/yCO8/8di/P/G4ny/y6e9f8sm/T/KZj0/yaV8/8AAAAAAAAAAAAAAAAAAAAAlsz//5LJ//+Px///lMn//yaV9P8kkvT/IZD0/x+O8/8yo/blMKD1/y2d9f8qmfT/KJbz/wAAAAAAAAAAqdb//53Q//+Zzv//lsv//yiY8/8qmvX/KJf1/yWV9P8jkvTQAAAAADSl9v8xofX/Lp71/yyb9P8AAAAAAAAAAKfW//+k1P//oNL//6rW//8wofb/Lp72/yuc9f8pmfX/AAAAAAAAAAAcVHtcNab2/zKj9v8voPX/LZz0/7vh//+u2///qtj//6fW//8wofT/NKX3/zKj9/8voPb/F8/6XgAAAAAAAAAAAAAAADmr9/82qPf/M6T2/zCg9f+44f//td///7Hd//++4v//Oqz4/ziq+P81p/f/M6X3/wAAAAAAAAAAAAAAAAAAAAAAAAAAOqz4/zep9//M6///v+X//7vj//+44f//OKn1/z6x+f88rvn/Oaz4/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD6x+f8qmfP/yOv//8bq///C5///z+z//0O3+v9Ctfr/QLP5/z2x+f8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0u///8jr///I6///yOv//zmq9f9Dt/r/Q7f6/0O3+v8AAAAAAAAAAAAAAAAAAAAA8A8AAOAHAADgBwAAwAMAAMADAACGAQAABgAAAA8AAAAPAAAABgAAAIYBAADAAwAAwAMAAOAHAADgBwAA8A8AAA==" /> </head> <body> <div id="swagger-ui"></div> <script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.17.14/swagger-ui-bundle.js" integrity="sha256-wuSp7wgUSDn/R8FCAgY+z+TlnnCk5xVKJr1Q2IDIi6E=" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.17.14/swagger-ui-standalone-preset.js" integrity="sha256-M7em9a/KxJAv35MoG+LS4S2xXyQdOEYG5ubRd0W3+G8=" crossorigin="anonymous"></script> <script> window.onload = () => { window.ui = SwaggerUIBundle({ url: '${schemaUrl}', dom_id: '#swagger-ui', deepLinking: true, presets: [ SwaggerUIBundle.presets.apis ] }); }; </script> </body> </html>`; } function getReDocUI(schemaUrl) { schemaUrl = schemaUrl.replace(/\/+(\/|$)/g, "$1"); return `<!DOCTYPE html> <html> <head> <title>ReDocUI</title> <!-- needed for adaptive design --> <meta charset="utf-8"/> <meta name="viewport" content="width=device-width, initial-scale=1"> <link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet"> <link rel="shortcut icon" href="data:image/x-icon;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlMb//2ux//9or///ZKz//wlv5f8JcOf/CnXv/why7/8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB2vi/wZo3/9ytf//b7P//2uw//+BvP//DHbp/w568P8Md+//CnXv/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAApv4/8HbOH/lMf//3W3//9ytf//brL//w946v8SfvH/EHzw/w558P8AAAAAAAAAAAAAAAAAAAAAAAAAABF56f8Ndef/C3Dj/whs4f98u///eLn//3W3//+Evv//FoPx/xSA8f8SfvD/EHvw/wAAAAAAAAAAAAAAAA1EeF0WgOz/EXrp/w515v8LceT/lsn//3+9//97u///eLj//xaB7f8YhfL/FoLx/xSA8f8JP/deAAAAAAAAAAAgjfH/HIjw/xeB7P8Te+n/AAAAAAAAAACGwf//gr///369//+Iwf//HIny/xqH8v8YhfL/FYLx/wAAAAAnlfPlJJLy/yGO8v8cifD/GILt/wAAAAAAAAAAmMz//4nD//+Fwf//gb///xyJ8P8ejPP/HIny/xmH8v8XhPLnK5r0/yiW8/8lk/P/IpDy/wAAAAAAAAAAAAAAAAAAAACPx///jMX//4jD//+MxP//IpD0/yCO8/8di/P/G4ny/y6e9f8sm/T/KZj0/yaV8/8AAAAAAAAAAAAAAAAAAAAAlsz//5LJ//+Px///lMn//yaV9P8kkvT/IZD0/x+O8/8yo/blMKD1/y2d9f8qmfT/KJbz/wAAAAAAAAAAqdb//53Q//+Zzv//lsv//yiY8/8qmvX/KJf1/yWV9P8jkvTQAAAAADSl9v8xofX/Lp71/yyb9P8AAAAAAAAAAKfW//+k1P//oNL//6rW//8wofb/Lp72/yuc9f8pmfX/AAAAAAAAAAAcVHtcNab2/zKj9v8voPX/LZz0/7vh//+u2///qtj//6fW//8wofT/NKX3/zKj9/8voPb/F8/6XgAAAAAAAAAAAAAAADmr9/82qPf/M6T2/zCg9f+44f//td///7Hd//++4v//Oqz4/ziq+P81p/f/M6X3/wAAAAAAAAAAAAAAAAAAAAAAAAAAOqz4/zep9//M6///v+X//7vj//+44f//OKn1/z6x+f88rvn/Oaz4/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD6x+f8qmfP/yOv//8bq///C5///z+z//0O3+v9Ctfr/QLP5/z2x+f8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0u///8jr///I6///yOv//zmq9f9Dt/r/Q7f6/0O3+v8AAAAAAAAAAAAAAAAAAAAA8A8AAOAHAADgBwAAwAMAAMADAACGAQAABgAAAA8AAAAPAAAABgAAAIYBAADAAwAAwAMAAOAHAADgBwAA8A8AAA==" /> <!-- ReDoc doesn't change outer page styles --> <style> body { margin: 0; padding: 0; } </style> </head> <body> <redoc spec-url="${schemaUrl}"></redoc> <script src="https://cdn.jsdelivr.net/npm/redoc@2.1.5/bundles/redoc.standalone.js" integrity="sha256-vlwzMMjDW4/OsppbdVKtRb/8L9lJT+LhqC+pQXnrX48=" crossorigin="anonymous"></script> </body> </html>`; } // src/utils.ts import { z } from "zod"; function validateBasePath(base) { if (!base.startsWith("/")) { throw new Error(`base must start with "/", got "${base}"`); } if (base.endsWith("/")) { throw new Error(`base must not end with "/", got "${base}"`); } } function jsonResp(data, params) { return new Response(JSON.stringify(data), { headers: { "content-type": "application/json;charset=UTF-8" }, // @ts-expect-error status: params?.status ? params.status : 200, ...params }); } function formatChanfanaError(e) { if (e instanceof z.ZodError) { return jsonResp( { errors: e.issues.map((issue) => ({ code: 7001, message: issue.message, path: issue.path.map(String) })), success: false, result: {} }, { status: 400 } ); } if (e instanceof Error && "buildResponse" in e && typeof e.buildResponse === "function") { const apiError = e; const headers = { "content-type": "application/json;charset=UTF-8" }; if (apiError.retryAfter !== void 0) { headers["Retry-After"] = String(apiError.retryAfter); } return new Response( JSON.stringify({ success: false, errors: apiError.buildResponse(), result: {} }), { status: apiError.status, headers } ); } return null; } // src/zod/registry.ts import { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi"; var OpenAPIRegistryMerger = class extends OpenAPIRegistry { _definitions = []; merge(registry, basePath) { if (!registry || !registry._definitions) return; for (const definition of registry._definitions) { if (basePath) { this._definitions.push({ ...definition, route: { ...definition.route, path: `${basePath}${definition.route.path}` } }); } else { this._definitions.push({ ...definition }); } } } }; // src/openapi.ts var OpenAPIHandler = class { router; options; registry; allowedMethods = ["get", "head", "post", "put", "delete", "patch"]; /** * When true, the underlying router handles base path prefixing for route * registration (e.g. Hono's basePath()). Doc route paths will be registered * without the base prefix since the router adds it automatically. * The base is still used for schema generation and HTML references. * * This is a getter (not a field) so that subclass overrides take effect * even when accessed during the base class constructor (createDocsRoutes). */ get routerHandlesBasePrefix() { return false; } /** * Hook for adapters to wrap route handler functions. * Called for each OpenAPIRoute handler during route registration. * The base implementation returns the handler as-is. * Subclasses (e.g. HonoOpenAPIHandler) can override this to add * error conversion or other adapter-specific behavior. */ wrapHandler(handler) { return handler; } constructor(router, options) { if (!router) { throw new Error("Router is required"); } if (options?.base) { validateBasePath(options.base); } this.router = router; this.options = options || {}; this.registry = new OpenAPIRegistryMerger(); this.createDocsRoutes(); } /** * Creates the documentation routes for Swagger UI, ReDoc, and OpenAPI JSON/YAML. * Respects the base path configuration for consistent URL generation. */ createDocsRoutes() { const base = this.options?.base || ""; const openapiUrl = this.options?.openapi_url || "/openapi.json"; const routeBase = this.routerHandlesBasePrefix ? "" : base; const docsDisabled = this.options?.docs_url === null; const redocDisabled = this.options?.redoc_url === null; const openapiDisabled = this.options?.openapi_url === null; if (!docsDisabled && !openapiDisabled) { const docsPath = this.options?.docs_url || "/docs"; this.router.get(docsPath, () => { return new Response(getSwaggerUI(base + openapiUrl), { headers: { "content-type": "text/html; charset=UTF-8" }, status: 200 }); }); } if (!redocDisabled && !openapiDisabled) { const redocPath = this.options?.redoc_url || "/redocs"; this.router.get(redocPath, () => { return new Response(getReDocUI(base + openapiUrl), { headers: { "content-type": "text/html; charset=UTF-8" }, status: 200 }); }); } if (!openapiDisabled) { this.router.get(routeBase + openapiUrl, () => { return new Response(JSON.stringify(this.getGeneratedSchema()), { headers: { "content-type": "application/json;charset=UTF-8" }, status: 200 }); }); const yamlUrl = openapiUrl.replace(/\.json$/, ".yaml"); this.router.get(routeBase + yamlUrl, () => { return new Response(yaml.dump(this.getGeneratedSchema()), { headers: { "content-type": "text/yaml;charset=UTF-8" }, status: 200 }); }); } } /** * Generates the OpenAPI schema document from registered routes. * @returns The complete OpenAPI specification object */ getGeneratedSchema() { const GeneratorClass = this.options?.openapiVersion === "3" ? OpenApiGeneratorV3 : OpenApiGeneratorV31; const generator = new GeneratorClass(this.registry.definitions); return generator.generateDocument({ openapi: this.options?.openapiVersion === "3" ? "3.0.3" : "3.1.0", info: { version: this.options?.schema?.info?.version || "1.0.0", title: this.options?.schema?.info?.title || "OpenAPI", ...this.options?.schema?.info }, ...this.options?.schema }); } /** * Registers a nested router and merges its OpenAPI registry. * @param params - Nested router parameters * @returns Array containing the nested router's fetch handler */ registerNestedRouter(params) { const path = params.nestedRouter.options?.base ? void 0 : params.path ? ((this.options.base || "") + params.path).replaceAll(/\/+(\/|$)/g, "$1").replaceAll(/:(\w+)/g, "{$1}") : void 0; this.registry.merge(params.nestedRouter.registry, path); return [params.nestedRouter.fetch]; } /** * Parses a route path, applying base path and converting to OpenAPI format. * @param path - The route path to parse * @returns The parsed and formatted path */ parseRoute(path) { return ((this.options.base || "") + path).replaceAll(/\/+(\/|$)/g, "$1").replaceAll(/:(\w+)/g, "{$1}"); } /** * Sanitizes an operationId to ensure it's valid for OpenAPI. * @param operationId - The raw operationId * @returns A sanitized operationId */ sanitizeOperationId(operationId) { return operationId.replace(/[{}]/g, "").replace(/\/+/g, "_").replace(/^_+|_+$/g, "").replace(/_+/g, "_") || // Collapse multiple underscores "root"; } /** * Registers a route with the OpenAPI registry. * @param params - Route registration parameters * @returns Array of wrapped handlers */ registerRoute(params) { const parsedRoute = this.parseRoute(params.path); const parsedParams = ((this.options.base || "") + params.path).match(/:(\w+)/g); let urlParams = []; if (parsedParams) { urlParams = parsedParams.map((obj) => obj.replace(":", "")); } let schema; let operationId; for (const handler of params.handlers) { if (handler.name) { operationId = this.sanitizeOperationId(`${params.method}_${handler.name}`); } if (handler.isRoute === true) { schema = new handler({ route: parsedRoute, urlParams }).getSchemaZod(); break; } } if (operationId === void 0) { operationId = this.sanitizeOperationId(`${params.method}_${parsedRoute}`); } if (schema === void 0) { schema = { operationId, responses: { 200: { description: "Successful response." } } }; if (urlParams.length > 0) { schema.request = { params: z2.object( urlParams.reduce( (obj, item) => Object.assign(obj, { [item]: z2.string() }), {} ) ) }; } } else { if (!schema.operationId) { if (this.options?.generateOperationIds === false) { throw new Error(`Route ${params.path} doesn't have operationId set!`); } schema.operationId = operationId; } } if (params.doRegister === void 0 || params.doRegister) { this.registry.registerPath({ ...schema, // @ts-expect-error - method type is more restrictive in the library method: params.method, path: parsedRoute }); } return params.handlers.map((handler) => { if (handler.isRoute) { const fn = (...params2) => new handler({ router: this, route: parsedRoute, urlParams, raiseOnError: this.options?.raiseOnError, validateResponse: this.options?.validateResponse, raiseUnknownParameters: this.options?.raiseUnknownParameters, passthroughErrors: this.options?.passthroughErrors }).execute(...params2); return this.wrapHandler(fn); } return handler; }); } /** * Handles common proxy properties for the wrapped router. * Provides access to isChanfana flag, original router, schema, and registry. */ handleCommonProxy(_target, prop, ..._args) { if (prop === "middleware") { return []; } if (prop === "isChanfana") { return true; } if (prop === "original") { return this.router; } if (prop === "schema") { return this.getGeneratedSchema(); } if (prop === "registry") { return this.registry; } if (prop === "options") { return this.options; } return void 0; } /** * Gets the Request object from handler arguments. * Must be implemented by subclasses. * @param _args - Handler arguments */ getRequest(_args) { throw new Error("getRequest not implemented"); } /** * Gets URL parameters from handler arguments. * Must be implemented by subclasses. * @param _args - Handler arguments */ getUrlParams(_args) { throw new Error("getUrlParams not implemented"); } /** * Gets environment bindings from handler arguments. * Must be implemented by subclasses. * @param _args - Handler arguments */ getBindings(_args) { throw new Error("getBindings not implemented"); } }; // src/adapters/hono.ts var HIJACKED_METHODS = /* @__PURE__ */ new Set(["basePath", "on", "route", "delete", "get", "patch", "post", "put", "all"]); function getHonoBasePath(router) { const bp = router?._basePath; if (typeof bp !== "string" || bp === "/") { return void 0; } if (bp.endsWith("/")) { const normalized = bp.replace(/\/+$/, ""); console.warn( `Hono basePath has a trailing slash ("${bp}"). Use basePath("${normalized}") instead of basePath("${bp}") to avoid issues.` ); return normalized; } return bp; } var HonoOpenAPIHandler = class extends OpenAPIHandler { get routerHandlesBasePrefix() { return true; } /** * Wraps route handlers to catch chanfana errors (ZodError, ApiException) * and convert them to Hono HTTPException instances. This allows errors to * flow through Hono's onError handler while preserving chanfana's default * error response format via HTTPException.getResponse(). */ wrapHandler(handler) { if (this.options?.passthroughErrors) return handler; return async (...args) => { try { return await handler(...args); } catch (e) { const response = formatChanfanaError(e); if (response) { const { HTTPException } = await import("hono/http-exception"); throw new HTTPException(response.status, { res: response }); } throw e; } }; } getRequest(args) { return args[0].req.raw; } getUrlParams(args) { return args[0].req.param(); } getBindings(args) { return args[0].env; } }; function fromHono(router, options) { if (options?.base) { validateBasePath(options.base); } const existingBase = getHonoBasePath(router); if (existingBase && options?.base) { throw new Error( `Detected Hono basePath "${existingBase}" and chanfana base option "${options.base}". As of chanfana 3.1, the base option is no longer needed when using Hono's basePath() \u2014 the base path "${existingBase}" is detected automatically. Please remove the base option from fromHono().` ); } const basedRouter = options?.base ? router.basePath(options.base) : router; const effectiveBase = getHonoBasePath(basedRouter); const effectiveOptions = { ...effectiveBase ? { ...options, base: effectiveBase } : options, raiseOnError: true }; const openapiRouter = new HonoOpenAPIHandler(basedRouter, effectiveOptions); const proxy = new Proxy(basedRouter, { get: (target, prop, ...args) => { const _result = openapiRouter.handleCommonProxy(target, prop, ...args); if (_result !== void 0) { return _result; } if (typeof target[prop] !== "function") { return target[prop]; } return (route, ...handlers) => { if (prop !== "fetch") { if (prop === "route" && handlers.length === 1 && handlers[0].isChanfana === true) { openapiRouter.registerNestedRouter({ method: "", nestedRouter: handlers[0], path: route }); const subApp = handlers[0].original.basePath(""); const excludePath = /* @__PURE__ */ new Set(["/openapi.json", "/openapi.yaml", "/docs", "/redocs"]); subApp.routes = subApp.routes.filter((obj) => { return !excludePath.has(obj.path); }); basedRouter.route(route, subApp); return proxy; } if (prop === "all" && handlers.length === 1 && handlers[0].isRoute) { handlers = openapiRouter.registerRoute({ method: prop, path: route, handlers, doRegister: false }); } else if (openapiRouter.allowedMethods.includes(prop)) { handlers = openapiRouter.registerRoute({ method: prop, path: route, handlers }); } else if (prop === "on") { const methods = route; const paths = handlers.shift(); if (Array.isArray(methods) || Array.isArray(paths)) { throw new Error("chanfana only supports single method+path on hono.on('method', 'path', EndpointClass)"); } handlers = openapiRouter.registerRoute({ method: methods.toLowerCase(), path: paths, handlers }); handlers = [paths, ...handlers]; } } const resp = Reflect.get(target, prop, ...args)(route, ...handlers); if (HIJACKED_METHODS.has(prop)) { return proxy; } return resp; }; } }); return proxy; } // src/adapters/ittyRouter.ts var IttyRouterOpenAPIHandler = class extends OpenAPIHandler { getRequest(args) { return args[0]; } getUrlParams(args) { return args[0].params; } getBindings(args) { return args[1]; } }; function fromIttyRouter(router, options) { const { raiseOnError: _ignored, ...safeOptions } = options || {}; const openapiRouter = new IttyRouterOpenAPIHandler(router, safeOptions); return new Proxy(router, { get: (target, prop, ...args) => { const _result = openapiRouter.handleCommonProxy(target, prop, ...args); if (_result !== void 0) { return _result; } return (route, ...handlers) => { if (prop !== "fetch") { if (handlers.length === 1 && handlers[0].isChanfana === true) { handlers = openapiRouter.registerNestedRouter({ method: prop, nestedRouter: handlers[0], path: void 0 }); } else if (openapiRouter.allowedMethods.includes(prop)) { handlers = openapiRouter.registerRoute({ method: prop, path: route, handlers }); } } return Reflect.get(target, prop, ...args)(route, ...handlers); }; } }); } // src/contentTypes.ts var contentJson = (schema) => ({ content: { "application/json": { schema } } }); // src/endpoints/create.ts import { z as z6 } from "zod"; // src/exceptions.ts import { z as z3 } from "zod"; var ApiException = class extends Error { isVisible = true; message; default_message = "Internal Error"; status = 500; code = 7e3; includesPath = false; constructor(message = "") { super(message); this.message = message; } buildResponse() { return [ { code: this.code, message: this.isVisible ? this.message || this.default_message : "Internal Error" } ]; } static schema() { const inst = new this(); const errorSchema = inst.includesPath ? z3.object({ code: z3.number(), message: z3.string(), path: z3.array(z3.string()) }) : z3.object({ code: z3.number(), message: z3.string() }); return { [inst.status]: { description: inst.default_message, ...contentJson( z3.object({ success: z3.literal(false), errors: z3.array(errorSchema) }) ) } }; } }; var InputValidationException = class extends ApiException { isVisible = true; default_message = "Input Validation Error"; status = 400; code = 7001; path = null; includesPath = true; constructor(message, path) { super(message); this.path = path; } buildResponse() { return [ { code: this.code, message: this.isVisible ? this.message : "Internal Error", path: this.path } ]; } }; var MultiException = class extends ApiException { isVisible = true; errors; status = 400; constructor(errors) { super("Multiple Exceptions"); this.errors = errors; for (const err of errors) { if (err.status > this.status) { this.status = err.status; } if (!err.isVisible && this.isVisible) { this.isVisible = false; } } } buildResponse() { return this.errors.flatMap((err) => err.buildResponse()); } }; var NotFoundException = class extends ApiException { isVisible = true; default_message = "Not Found"; status = 404; code = 7002; }; var UnauthorizedException = class extends ApiException { isVisible = true; default_message = "Unauthorized"; status = 401; code = 7003; }; var ForbiddenException = class extends ApiException { isVisible = true; default_message = "Forbidden"; status = 403; code = 7004; }; var MethodNotAllowedException = class extends ApiException { isVisible = true; default_message = "Method Not Allowed"; status = 405; code = 7005; }; var ConflictException = class extends ApiException { isVisible = true; default_message = "Conflict"; status = 409; code = 7006; }; var UnprocessableEntityException = class extends ApiException { isVisible = true; default_message = "Unprocessable Entity"; status = 422; code = 7007; includesPath = true; path = null; constructor(message, path) { super(message); this.path = path; } buildResponse() { return [ { code: this.code, message: this.isVisible ? this.message || this.default_message : "Internal Error", path: this.path } ]; } }; var TooManyRequestsException = class extends ApiException { isVisible = true; default_message = "Too Many Requests"; status = 429; code = 7008; retryAfter; constructor(message, retryAfter) { super(message); this.retryAfter = retryAfter; } }; var InternalServerErrorException = class extends ApiException { isVisible = false; default_message = "Internal Server Error"; status = 500; code = 7009; }; var BadGatewayException = class extends ApiException { isVisible = true; default_message = "Bad Gateway"; status = 502; code = 7010; }; var ServiceUnavailableException = class extends ApiException { isVisible = true; default_message = "Service Unavailable"; status = 503; code = 7011; retryAfter; constructor(message, retryAfter) { super(message); this.retryAfter = retryAfter; } }; var GatewayTimeoutException = class extends ApiException { isVisible = true; default_message = "Gateway Timeout"; status = 504; code = 7012; }; var ResponseValidationException = class extends ApiException { isVisible = false; default_message = "Response Validation Error"; status = 500; code = 7013; constructor(message, options) { super(message ?? ""); if (message) this.message = message; if (options?.cause) this.cause = options.cause; } }; // src/route.ts import { extendZodWithOpenApi as extendZodWithOpenApi2 } from "@asteasolutions/zod-to-openapi"; import { z as z5 } from "zod"; // src/parameters.ts import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; import { z as z4 } from "zod"; extendZodWithOpenApi(z4); function unwrapAndCheck(schema, ZodClass) { let current = schema; if (current instanceof ZodClass) { return true; } while (current && typeof current.unwrap === "function") { current = current.unwrap(); if (current instanceof ZodClass) { return true; } } return false; } function coerceInputs(data, schema) { if (data.size === 0 || data.size === void 0 && typeof data === "object" && Object.keys(data).length === 0) { return null; } const params = {}; const entries = data.entries ? data.entries() : Object.entries(data); for (let [key, value] of entries) { if (value === "") { value = null; } if (params[key] === void 0) { params[key] = value; } else if (!Array.isArray(params[key])) { params[key] = [params[key], value]; } else { params[key].push(value); } let innerType; if (schema && schema.shape && schema.shape[key]) { innerType = schema.shape[key]; } else if (schema) { innerType = schema; } if (innerType && params[key] !== null) { if (unwrapAndCheck(innerType, z4.ZodArray) && !Array.isArray(params[key])) { params[key] = [params[key]]; } else if (unwrapAndCheck(innerType, z4.ZodBoolean) && typeof params[key] === "string") { const _val = params[key].toLowerCase().trim(); if (_val === "true" || _val === "false") { params[key] = _val === "true"; } } else if (unwrapAndCheck(innerType, z4.ZodNumber) && typeof params[key] === "string") { params[key] = Number.parseFloat(params[key]); } else if (unwrapAndCheck(innerType, z4.ZodBigInt) && typeof params[key] === "string") { try { params[key] = BigInt(params[key]); } catch { } } else if (unwrapAndCheck(innerType, z4.ZodDate) && typeof params[key] === "string") { params[key] = new Date(params[key]); } } } return params; } // src/route.ts extendZodWithOpenApi2(z5); var OpenAPIRoute = class { /** * 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) { throw new Error("Method not implemented."); } static isRoute = true; /** Args the execute() was called with */ args; /** Cache for validated data - prevents re-validation on multiple calls */ validatedData = void 0; /** Cache for raw request data before Zod applies defaults/transformations */ unvalidatedData = void 0; /** Route configuration options */ params; /** OpenAPI schema definition for this route */ schema = {}; constructor(params) { this.params = params; this.args = []; } /** * 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() { const request = this.params.router.getRequest(this.args); if (this.validatedData !== void 0) 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() { if (this.unvalidatedData !== void 0) return this.unvalidatedData; const request = this.params.router.getRequest(this.args); const schema = this.getSchemaZod(); const unvalidatedData = {}; 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 = {}; const rHeaders = new Headers(request.headers); for (const header of Object.keys(schema.request.headers.shape)) { tmpHeaders[header] = rHeaders.get(header); } unvalidatedData.headers = coerceInputs(tmpHeaders, schema.request.headers) ?? {}; } if (!["get", "head"].includes(request.method.toLowerCase()) && schema.request?.body?.content?.["application/json"]?.schema) { try { unvalidatedData.body = await request.json(); } catch (_e) { unvalidatedData.body = {}; } } this.unvalidatedData = unvalidatedData; return unvalidatedData; } /** * Returns the OpenAPI schema for this route. * Override this method to customize schema properties. */ getSchema() { 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() { const schema = { ...this.getSchema() }; if (!schema.responses) { 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. */ handleError(error) { 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) { this.validatedData = void 0; this.unvalidatedData = void 0; 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; } if (resp !== null && resp !== void 0 && !(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) { const schema = this.getSchemaZod(); const responses = schema.responses; if (!responses) return void 0; const responseConfig = responses[String(statusCode)] ?? responses.default; if (!responseConfig) return void 0; const jsonContent = responseConfig.content?.["application/json"]; if (!jsonContent?.schema) return void 0; const zodSchema = jsonContent.schema; if (!(zodSchema instanceof z5.ZodType)) return void 0; 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) { if (resp === null || resp === void 0) 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 cloned = resp.clone(); let body; 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); 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") { 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) { const schema = this.getSchemaZod(); const unvalidatedData = await this.getUnvalidatedData(); const rawSchema = {}; 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; if (this.params?.raiseUnknownParameters === void 0 || this.params?.raiseUnknownParameters === true) { validationSchema = z5.strictObject(rawSchema); } else { validationSchema = z5.object(rawSchema); } try { return await validationSchema.parseAsync(unvalidatedData); } catch (e) { if (e instanceof z5.ZodError) { throw new MultiException( e.issues.map((issue) => new InputValidationException(issue.message, issue.path.map(String))) ); } throw e; } } }; // src/endpoints/types.ts function MetaGenerator(meta) { return { fields: meta.fields ?? meta.model.schema, model: { serializer: (obj, _context) => obj, serializerSchema: meta.model.schema, ...meta.model }, pathParameters: meta.pathParameters ?? null, tags: meta.tags }; } function metaSchemaProps(meta) { return { ...meta.tags?.length ? { tags: meta.tags } : {} }; } // src/endpoints/create.ts var CreateEndpoint = class extends OpenAPIRoute { // @ts-expect-error _meta; get meta() { return MetaGenerator(this._meta); } getSchema() { const bodyParameters = this.meta.fields.omit( (this.params.urlParams || []).reduce((a, v) => ({ ...a, [v]: true }), {}) ); const pathParameters = this.meta.fields.pick( (this.params.urlParams || []).reduce((a, v) => ({ ...a, [v]: true }), {}) ); return { request: { body: contentJson(bodyParameters), params: Object.keys(pathParameters.shape).length ? pathParameters : void 0, ...this.schema?.request }, responses: { "201": { description: "Returns the created Object", ...contentJson( z6.object({ success: z6.boolean(), result: this.meta.model.serializerSchema }) ), ...this.schema?.responses?.[201] }, ...InputValidationException.schema(), ...this.schema?.responses }, ...metaSchemaProps(this._meta), ...this.schema }; } async getObject() { const data = await this.getValidatedData(); const newData = { ...data.body }; for (const param of this.params.urlParams) { newData[param] = data.params[param]; } return newData; } async before(data) { return data; } async after(data) { return data; } async create(data) { return data; } async handle(..._args) { let obj = await this.getObject(); obj = await this.before(obj); obj = await this.create(obj); obj = await this.after(obj); return Response.json( { success: true, result: this.meta.model.serializer(obj, { filters: [] }) }, { status: 201 } ); } }; // src/endpoints/d1/base.ts var SQL_IDENTIFIER_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/; var VALID_ORDER_DIRECTIONS = /* @__PURE__ */ new Set(["asc", "desc"]); function validateSqlIdentifier(identifier, type) { if (!identifier || typeof identifier !== "string") { throw new ApiException(`Invalid ${type} name: must be a non-empty string`); } if (!SQL_IDENTIFIER_REGEX.test(identifier)) { throw new ApiException( `Invalid ${type} name "${identifier}": must start with a letter or underscore and contain only alphanumeric characters and underscores` ); } if (identifier.length > 128) { throw new ApiException(`Invalid ${type} name "${identifier}": exceeds maximum length of 128 characters`); } return identifier; } function validateTableName(tableName) { return validateSqlIdentifier(tableName, "table"); } function validateColumnName(columnName, validColumns) { const validated = validateSqlIdentifier(columnName, "column"); if (validColumns && validColumns.length > 0 && !validColumns.includes(validated)) { throw new ApiException(`Invalid column name "${columnName}": not found in schema`); } return validated; } function validateOrderDirection(direction) { const normalized = (direction || "asc").toLowerCase().trim(); return VALID_ORDER_DIRECTIONS.has(normalized) ? normalized : "asc"; } function validateOrderByColumn(column, allowedColumns, fallbackColumn) { if (!column || typeof column !== "string" || column === "undefined") { return validateColumnName(fallbackColumn); } if (allowedColumns.includes(column)) { return validateColumnName(column); } return validateColumnName(fallbackColumn); } function buildSafeFilters(filters, validColumns, startParamIndex = 1) { const conditions = []; const conditionsParams = []; for (const f of filters) { const validatedColumn = validateColumnName(f.field, validColumns); if (f.operator === "EQ") { conditions.push(`${validatedColumn} = ?${startParamIndex + conditionsParams.length}`); conditionsParams.push(f.value); } else { throw new ApiException(`Operator "${f.operator}" is not implemented`); } } return { conditions, conditionsParams }; } function buildPrimaryKeyFilters(filters, primaryKeys, validColumns, startParamIndex = 1) { const primaryKeyFilters = filters.filters.filter((f) => primaryKeys.includes(f.field)); if (primaryKeyFilters.length === 0) { throw new ApiException("No primary key filters provided \u2014 refusing to execute unscoped query"); } return buildSafeFilters(primaryKeyFilters, validColumns, startParamIndex); } function getD1Binding(getBindings, args, dbName) { const env = getBindings(args); if (env[dbName] === void 0) { throw new ApiException(`Binding "${dbName}" is not defined in worker`); } if (env[dbName].prepare === void 0) { throw new ApiException(`Binding "${dbName}" is not a D1 binding`); } return env[dbName]; } function handleDbError(error, constraintsMessages, logger, operation) { if (logger && operation) { logger.error(`Database error during ${operation}: ${error.message}`); } if (error.message.includes("UNIQUE constraint failed")) { const match = error.message.match(/UNIQUE constraint failed:\s*([^:]+)/); if (match?.[1]) { const constraintName = match[1].trim(); if (constraintsMessages[constraintName]) { const template = constraintsMessages[constraintName]; throw new InputValidationException(template.message, template.path); } } } throw new ApiException("Database operation failed"); } function buildWhereClause(conditions) { if (conditions.length === 0) { return ""; } return `WHERE ${conditions.join(" AND ")}`; } function buildOrderByClause(column, direction) { return `ORDER BY ${column} ${direction}`; } // src/endpoints/d1/create.ts var D1CreateEndpoint = class extends CreateEndpoint { /** Name of the D1 database binding in the worker environment. Defaults to "DB" */ dbName = "DB"; /** Optional logger for debugging and error tracking */ logger; /** Custom error messages for UNIQUE constraint violations. Keys are constraint names (e.g., "users.email") */ constraintsMessages = {}; /** * Gets the D1 database binding from the worker environment. * @returns D1Database instance * @throws ApiException if binding is not defined or is not a D1 binding */ getDBBinding() { return getD1Binding((args) => this.params.router.getBindings(args), this.args, this.dbName); } /** * Gets the list of valid column names from the model schema. * @returns Array of valid column names */ getValidColumns() { return Object.keys(this.meta.model.schema.shape); } /** * Creates a new record in the database. * @param data - The validated data to insert * @returns The created record * @throws ApiException on database errors */ async create(data) { const tableName = validateTableName(this.meta.model.tableName); const validColumns = this.getValidColumns(); const columns = Object.keys(data).map((col) => validateColumnName(col, validColumns)); const values = Object.values(data); const columnList = columns.join(", "); const placeholders = values.map((_, i) => `?${i + 1}`).join(", "); const sql = `INSERT INTO ${tableName} (${columnList}) VALUES (${placeholders}) RETURNING *`; try { const result = await this.getDBBinding().prepare(sql).bind(...values).all(); const inserted = result.results[0]; if (this.logger) { this.logger.log(`Successfully created record in ${tableName}`); } return inserted; } catch (e) { handleDbError(e, this.constraintsMessages, this.logger, `create ${tableName}`); } } }; // src/endpoints/delete.ts import { z as z7 } from "zod"; var DeleteEndpoint = class extends OpenAPIRoute { // @ts-expect-error _meta; get meta() { return MetaGenerator(this._meta); } getSchema() { const urlParams = this.meta.pathParameters ?? this.params.urlParams ?? []; const bodyParameters = this.meta.fields.pick((this.meta.model.primaryKeys || []).reduce((a, v) => ({ ...a, [v]: true }), {})).omit(urlParams.reduce((a, v) => ({ ...a, [v]: true }), {})); const pathParameters = this.meta.fields.pick((this.meta.model.primaryKeys || []).reduce((a, v) => ({ ...a, [v]: true }), {})).pick(urlParams.reduce((a, v) => ({ ...a, [v]: true }), {})); return { request: { body: Object.keys(bodyParameters.shape).length ? contentJson(bodyParameters) : void 0, params: Object.keys(pathParameters.shape).length ? pathParameters : void 0, ...this.schema?.request }, responses: { "200": { description: "Returns the Object if it was successfully deleted", ...contentJson( z7.object({ success: z7.boolean(), result: this.meta.model.serializerSchema }) ), ...this.schema?.responses?.[200] }, ...NotFoundException.schema(), ...this.schema?.responses }, ...metaSchemaProps(this._meta), ...this.schema }; } async getFilters() { const data = await this.getValidatedData(); const filters = []; for (const part of [data.params, data.body]) { if (part) { for (const [key, value] of Object.entries(part)) { filters.push({ field: key, operator: "EQ", value }); } } } return { filters }; } async before(_oldObj, filters) { return filters; } async after(data) { return data; } async delete(_oldObj, _filters) { return null; } async getObject(_filters) { return null; } async handle(..._args) { let filters = await this.getFilters(); const oldObj = await this.getObject(filters); if (oldObj === null) { throw new NotFoundException(); } filters = await this.before(oldObj, filters); let obj = await this.delete(oldObj, filters); if (obj === null) { throw new NotFoundException(); }