chanfana
Version:
OpenAPI 3 and 3.1 schema generator and validator for Hono, itty-router and more!
371 lines (328 loc) • 12.1 kB
text/typescript
import { OpenApiGeneratorV3, OpenApiGeneratorV31 } from "@asteasolutions/zod-to-openapi";
import yaml from "js-yaml";
import { z } from "zod";
import type { OpenAPIRouteSchema, RouterOptions } from "./types";
import { getReDocUI, getSwaggerUI } from "./ui";
import { validateBasePath } from "./utils";
import { OpenAPIRegistryMerger } from "./zod/registry";
export type OpenAPIRouterType<M> = {
original: M;
options: RouterOptions;
registry: OpenAPIRegistryMerger;
schema: any;
};
/**
* Valid HTTP methods for OpenAPI routes.
* These are the standard methods supported by the OpenAPI specification.
*/
export type HttpMethod = "get" | "head" | "post" | "put" | "delete" | "patch";
/**
* Handles the generation of OpenAPI schema and serves the documentation UI.
*
* Paths defined with `x-ignore: true` in their `OpenAPIRouteSchema`
* will be excluded from the generated OpenAPI specification by the CLI tool.
*/
export class OpenAPIHandler {
router: any;
options: RouterOptions;
registry: OpenAPIRegistryMerger;
allowedMethods: string[] = ["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).
*/
protected get routerHandlesBasePrefix(): boolean {
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.
*/
protected wrapHandler(handler: (...args: any[]) => Promise<Response>): (...args: any[]) => Promise<Response> {
return handler;
}
constructor(router: any, options?: RouterOptions) {
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";
// When routerHandlesBasePrefix is true (e.g. Hono with basePath()),
// the router adds the base prefix to routes automatically, so we skip it
// in route registration paths. The full base+url is still used in HTML
// content so the browser can resolve the correct URL.
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;
// Note: /docs and /redocs routes intentionally don't use routeBase.
// When routerHandlesBasePrefix is true (Hono), the router's basePath()
// already prefixes all registered routes automatically. When false
// (itty-router), doc routes have never been prefixed with base — this
// is pre-existing behavior. Only openapi.json/yaml use routeBase because
// itty-router needs the explicit prefix for schema endpoints.
// Swagger UI docs route
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,
});
});
}
// ReDoc docs route
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,
});
});
}
// OpenAPI JSON and YAML endpoints
if (!openapiDisabled) {
// JSON endpoint
this.router.get(routeBase + openapiUrl, () => {
return new Response(JSON.stringify(this.getGeneratedSchema()), {
headers: {
"content-type": "application/json;charset=UTF-8",
},
status: 200,
});
});
// YAML endpoint - use proper regex to only replace trailing .json
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: { method: string; nestedRouter: any; path?: string }) {
// Only overwrite the path if the nested router doesn't have a base already
const path = params.nestedRouter.options?.base
? undefined
: params.path
? ((this.options.base || "") + params.path)
.replaceAll(/\/+(\/|$)/g, "$1") // strip double & trailing slash
.replaceAll(/:(\w+)/g, "{$1}") // convert parameters into openapi compliant
: undefined;
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: string): string {
return ((this.options.base || "") + path)
.replaceAll(/\/+(\/|$)/g, "$1") // strip double & trailing slash
.replaceAll(/:(\w+)/g, "{$1}"); // convert parameters into openapi compliant
}
/**
* Sanitizes an operationId to ensure it's valid for OpenAPI.
* @param operationId - The raw operationId
* @returns A sanitized operationId
*/
private sanitizeOperationId(operationId: string): string {
return (
operationId
.replace(/[{}]/g, "") // Remove curly braces
.replace(/\/+/g, "_") // Replace slashes with underscores
.replace(/^_+|_+$/g, "") // Trim leading/trailing underscores
.replace(/_+/g, "_") || // Collapse multiple underscores
"root"
); // Fallback for empty result
}
/**
* Registers a route with the OpenAPI registry.
* @param params - Route registration parameters
* @returns Array of wrapped handlers
*/
registerRoute(params: { method: string; path: string; handlers: any[]; doRegister?: boolean }) {
const parsedRoute = this.parseRoute(params.path);
const parsedParams = ((this.options.base || "") + params.path).match(/:(\w+)/g);
let urlParams: string[] = [];
if (parsedParams) {
urlParams = parsedParams.map((obj) => obj.replace(":", ""));
}
let schema: OpenAPIRouteSchema | undefined;
let operationId: string | undefined;
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: urlParams,
}).getSchemaZod();
break;
}
}
if (operationId === undefined) {
operationId = this.sanitizeOperationId(`${params.method}_${parsedRoute}`);
}
if (schema === undefined) {
// No schema for this route, try to guess the parameters
schema = {
operationId: operationId,
responses: {
200: {
description: "Successful response.",
},
},
};
if (urlParams.length > 0) {
schema.request = {
params: z.object(
urlParams.reduce(
(obj, item) =>
Object.assign(obj, {
[item]: z.string(),
}),
{},
),
),
};
}
} else {
// Schema was provided in the endpoint
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 === undefined || 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: any) => {
if (handler.isRoute) {
const fn = (...params: any[]) =>
new handler({
router: this,
route: parsedRoute,
urlParams: urlParams,
raiseOnError: this.options?.raiseOnError,
validateResponse: this.options?.validateResponse,
raiseUnknownParameters: this.options?.raiseUnknownParameters,
passthroughErrors: this.options?.passthroughErrors,
}).execute(...params);
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: any, prop: string, ..._args: any[]) {
// This is a hack to allow older versions of wrangler to use this library
// https://github.com/cloudflare/workers-sdk/issues/5420
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 undefined;
}
/**
* Gets the Request object from handler arguments.
* Must be implemented by subclasses.
* @param _args - Handler arguments
*/
getRequest(_args: any[]): Request {
throw new Error("getRequest not implemented");
}
/**
* Gets URL parameters from handler arguments.
* Must be implemented by subclasses.
* @param _args - Handler arguments
*/
getUrlParams(_args: any[]): Record<string, any> {
throw new Error("getUrlParams not implemented");
}
/**
* Gets environment bindings from handler arguments.
* Must be implemented by subclasses.
* @param _args - Handler arguments
*/
getBindings(_args: any[]): Record<string, any> {
throw new Error("getBindings not implemented");
}
}