UNPKG

chanfana

Version:

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

371 lines (328 loc) 12.1 kB
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"); } }