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
JavaScript
// 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();
}