UNPKG

chanfana

Version:

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

1,415 lines (1,396 loc) 78.1 kB
"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var index_exports = {}; __export(index_exports, { ApiException: () => ApiException, BadGatewayException: () => BadGatewayException, ConflictException: () => ConflictException, CreateEndpoint: () => CreateEndpoint, D1CreateEndpoint: () => D1CreateEndpoint, D1DeleteEndpoint: () => D1DeleteEndpoint, D1ListEndpoint: () => D1ListEndpoint, D1ReadEndpoint: () => D1ReadEndpoint, D1UpdateEndpoint: () => D1UpdateEndpoint, DeleteEndpoint: () => DeleteEndpoint, ForbiddenException: () => ForbiddenException, GatewayTimeoutException: () => GatewayTimeoutException, HonoOpenAPIHandler: () => HonoOpenAPIHandler, InputValidationException: () => InputValidationException, InternalServerErrorException: () => InternalServerErrorException, IttyRouterOpenAPIHandler: () => IttyRouterOpenAPIHandler, ListEndpoint: () => ListEndpoint, MetaGenerator: () => MetaGenerator, MethodNotAllowedException: () => MethodNotAllowedException, MultiException: () => MultiException, NotFoundException: () => NotFoundException, OpenAPIHandler: () => OpenAPIHandler, OpenAPIRegistryMerger: () => OpenAPIRegistryMerger, OpenAPIRoute: () => OpenAPIRoute, ReadEndpoint: () => ReadEndpoint, ResponseValidationException: () => ResponseValidationException, ServiceUnavailableException: () => ServiceUnavailableException, TooManyRequestsException: () => TooManyRequestsException, UnauthorizedException: () => UnauthorizedException, UnprocessableEntityException: () => UnprocessableEntityException, UpdateEndpoint: () => UpdateEndpoint, buildOrderByClause: () => buildOrderByClause, buildPrimaryKeyFilters: () => buildPrimaryKeyFilters, buildSafeFilters: () => buildSafeFilters, buildWhereClause: () => buildWhereClause, coerceInputs: () => coerceInputs, contentJson: () => contentJson, extendZodWithOpenApi: () => import_zod_to_openapi5.extendZodWithOpenApi, formatChanfanaError: () => formatChanfanaError, fromHono: () => fromHono, fromIttyRouter: () => fromIttyRouter, getD1Binding: () => getD1Binding, getReDocUI: () => getReDocUI, getSwaggerUI: () => getSwaggerUI, handleDbError: () => handleDbError, jsonResp: () => jsonResp, metaSchemaProps: () => metaSchemaProps, validateBasePath: () => validateBasePath, validateColumnName: () => validateColumnName, validateOrderByColumn: () => validateOrderByColumn, validateOrderDirection: () => validateOrderDirection, validateSqlIdentifier: () => validateSqlIdentifier, validateTableName: () => validateTableName }); module.exports = __toCommonJS(index_exports); var import_zod_to_openapi5 = require("@asteasolutions/zod-to-openapi"); // src/openapi.ts var import_zod_to_openapi2 = require("@asteasolutions/zod-to-openapi"); var import_js_yaml = __toESM(require("js-yaml")); var import_zod2 = require("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 var import_zod = require("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 import_zod.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 var import_zod_to_openapi = require("@asteasolutions/zod-to-openapi"); var OpenAPIRegistryMerger = class extends import_zod_to_openapi.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(import_js_yaml.default.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" ? import_zod_to_openapi2.OpenApiGeneratorV3 : import_zod_to_openapi2.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: import_zod2.z.object( urlParams.reduce( (obj, item) => Object.assign(obj, { [item]: import_zod2.z.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 var import_zod6 = require("zod"); // src/exceptions.ts var import_zod3 = require("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 ? import_zod3.z.object({ code: import_zod3.z.number(), message: import_zod3.z.string(), path: import_zod3.z.array(import_zod3.z.string()) }) : import_zod3.z.object({ code: import_zod3.z.number(), message: import_zod3.z.string() }); return { [inst.status]: { description: inst.default_message, ...contentJson( import_zod3.z.object({ success: import_zod3.z.literal(false), errors: import_zod3.z.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 var import_zod_to_openapi4 = require("@asteasolutions/zod-to-openapi"); var import_zod5 = require("zod"); // src/parameters.ts var import_zod_to_openapi3 = require("@asteasolutions/zod-to-openapi"); var import_zod4 = require("zod"); (0, import_zod_to_openapi3.extendZodWithOpenApi)(import_zod4.z); 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, import_zod4.z.ZodArray) && !Array.isArray(params[key])) { params[key] = [params[key]]; } else if (unwrapAndCheck(innerType, import_zod4.z.ZodBoolean) && typeof params[key] === "string") { const _val = params[key].toLowerCase().trim(); if (_val === "true" || _val === "false") { params[key] = _val === "true"; } } else if (unwrapAndCheck(innerType, import_zod4.z.ZodNumber) && typeof params[key] === "string") { params[key] = Number.parseFloat(params[key]); } else if (unwrapAndCheck(innerType, import_zod4.z.ZodBigInt) && typeof params[key] === "string") { try { params[key] = BigInt(params[key]); } catch { } } else if (unwrapAndCheck(innerType, import_zod4.z.ZodDate) && typeof params[key] === "string") { params[key] = new Date(params[key]); } } } return params; } // src/route.ts (0, import_zod_to_openapi4.extendZodWithOpenApi)(import_zod5.z); 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 import_zod5.z.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 = import_zod5.z.strictObject(rawSchema); } else { validationSchema = import_zod5.z.object(rawSchema); } try { return await validationSchema.parseAsync(unvalidatedData); } catch (e) { if (e instanceof import_zod5.z.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( import_zod6.z.object({ success: import_zod6.z.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 buil