UNPKG

chanfana

Version:

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

251 lines (217 loc) 9.79 kB
import type { Hono, Input } from "hono"; import type { BlankInput, Env, H, HandlerResponse, MergePath, MergeSchemaPath, Schema, ToSchema, TypedResponse, } from "hono/types"; import { OpenAPIHandler, type OpenAPIRouterType } from "../openapi"; import type { OpenAPIRoute } from "../route"; import type { RouterOptions } from "../types"; import { formatChanfanaError, validateBasePath } from "../utils"; type MergeTypedResponse<T> = T extends Promise<infer T2> ? T2 extends TypedResponse ? T2 : TypedResponse : T extends TypedResponse ? T : TypedResponse; const HIJACKED_METHODS = new Set(["basePath", "on", "route", "delete", "get", "patch", "post", "put", "all"]); export type HonoOpenAPIRouterType< E extends Env = Env, S extends Schema = {}, BasePath extends string = "/", > = OpenAPIRouterType<Hono<E, S, BasePath>> & { on(method: string, path: string, endpoint: typeof OpenAPIRoute<any>): Hono<E, S, BasePath>["on"]; on(method: string, path: string, router: Hono<E, S, BasePath>): Hono<E, S, BasePath>["on"]; route<SubPath extends string, SubEnv extends Env, SubSchema extends Schema, SubBasePath extends string>( path: SubPath, app: HonoOpenAPIRouterType<SubEnv, SubSchema, SubBasePath>, ): HonoOpenAPIRouterType<E, MergeSchemaPath<SubSchema, MergePath<BasePath, SubPath>> | S, BasePath>; all<P extends string, I extends Input = BlankInput, R extends HandlerResponse<any> = any>( path: P, endpoint: typeof OpenAPIRoute<any> | H, ): HonoOpenAPIRouterType<E, S & ToSchema<"all", MergePath<BasePath, P>, I, MergeTypedResponse<R>>, BasePath>; delete<P extends string, I extends Input = BlankInput, R extends HandlerResponse<any> = any>( path: P, endpoint: typeof OpenAPIRoute<any> | H, ): HonoOpenAPIRouterType<E, S & ToSchema<"delete", MergePath<BasePath, P>, I, MergeTypedResponse<R>>, BasePath>; delete(path: string, router: Hono<E, S, BasePath>): Hono<E, S, BasePath>["delete"]; get<P extends string, I extends Input = BlankInput, R extends HandlerResponse<any> = any>( path: P, endpoint: typeof OpenAPIRoute<any> | H, ): HonoOpenAPIRouterType<E, S & ToSchema<"get", MergePath<BasePath, P>, I, MergeTypedResponse<R>>, BasePath>; get(path: string, router: Hono<E, S, BasePath>): Hono<E, S, BasePath>["get"]; patch<P extends string, I extends Input = BlankInput, R extends HandlerResponse<any> = any>( path: P, endpoint: typeof OpenAPIRoute<any> | H, ): HonoOpenAPIRouterType<E, S & ToSchema<"patch", MergePath<BasePath, P>, I, MergeTypedResponse<R>>, BasePath>; patch(path: string, router: Hono<E, S, BasePath>): Hono<E, S, BasePath>["patch"]; post<P extends string, I extends Input = BlankInput, R extends HandlerResponse<any> = any>( path: P, endpoint: typeof OpenAPIRoute<any> | H, ): HonoOpenAPIRouterType<E, S & ToSchema<"post", MergePath<BasePath, P>, I, MergeTypedResponse<R>>, BasePath>; post(path: string, router: Hono<E, S, BasePath>): Hono<E, S, BasePath>["post"]; put<P extends string, I extends Input = BlankInput, R extends HandlerResponse<any> = any>( path: P, endpoint: typeof OpenAPIRoute<any> | H, ): HonoOpenAPIRouterType<E, S & ToSchema<"put", MergePath<BasePath, P>, I, MergeTypedResponse<R>>, BasePath>; put(path: string, router: Hono<E, S, BasePath>): Hono<E, S, BasePath>["put"]; // Hono must be defined last, for the overwrite method to have priority! } & Hono<E, S, BasePath>; /** * Defensively reads Hono's base path from its internal `_basePath` property. * This is not part of Hono's public API — if Hono changes this internal, * the fallback is that users must pass the `base` option to fromHono() explicitly. * * @returns The detected base path, or undefined if not available or "/" */ function getHonoBasePath(router: any): string | undefined { const bp = router?._basePath; if (typeof bp !== "string" || bp === "/") { return undefined; } 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; } export class HonoOpenAPIHandler extends OpenAPIHandler { protected get routerHandlesBasePrefix(): boolean { 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(). */ protected wrapHandler(handler: (...args: any[]) => Promise<Response>): (...args: any[]) => Promise<Response> { if (this.options?.passthroughErrors) return handler; return async (...args: any[]) => { 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 as any, { res: response }); } throw e; } }; } getRequest(args: any[]) { return args[0].req.raw; } getUrlParams(args: any[]): Record<string, any> { return args[0].req.param(); } getBindings(args: any[]): Record<string, any> { return args[0].env; } } export function fromHono< M extends Hono<E, S, BasePath>, E extends Env = M extends Hono<infer E, any, any> ? E : never, S extends Schema = M extends Hono<any, infer S, any> ? S : never, BasePath extends string = M extends Hono<any, any, infer BP> ? BP : never, >(router: M, options?: RouterOptions): HonoOpenAPIRouterType<E, S, BasePath> { // Validate base format early, before Hono's basePath() is called. // The OpenAPIHandler constructor also validates, but basePath() runs first here. if (options?.base) { validateBasePath(options.base); } // Detect pre-existing basePath on the Hono instance (e.g. new Hono().basePath("/api")) 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() — ` + `the base path "${existingBase}" is detected automatically. ` + `Please remove the base option from fromHono().`, ); } // If chanfana base option is provided (and no pre-existing basePath), apply it via Hono's basePath() // so that both route matching and schema generation use the same prefix. // If the router already has a basePath, use it as-is — routes already match at the prefixed path. const basedRouter = options?.base ? router.basePath(options.base) : router; // Read the effective base from the (possibly based) router for schema generation. // This covers both cases: chanfana's base applied via basePath(), or pre-existing basePath. 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: any, prop: string, ...args: any[]) => { const _result = openapiRouter.handleCommonProxy(target, prop, ...args); if (_result !== undefined) { return _result; } if (typeof target[prop] !== "function") { return target[prop]; } return (route: string, ...handlers: any[]) => { if (prop !== "fetch") { if (prop === "route" && handlers.length === 1 && handlers[0].isChanfana === true) { openapiRouter.registerNestedRouter({ method: "", nestedRouter: handlers[0], path: route, }); // Hacky clone const subApp = handlers[0].original.basePath(""); const excludePath = new Set(["/openapi.json", "/openapi.yaml", "/docs", "/redocs"]); subApp.routes = subApp.routes.filter((obj: any) => { 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: handlers, doRegister: false, }); } else if (openapiRouter.allowedMethods.includes(prop)) { handlers = openapiRouter.registerRoute({ method: prop, path: route, handlers: handlers, }); } else if (prop === "on") { const methods: string | string[] = route; const paths: string | string[] = 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, }); handlers = [paths, ...handlers]; } } const resp = Reflect.get(target, prop, ...args)(route, ...handlers); if (HIJACKED_METHODS.has(prop)) { return proxy; } return resp; }; }, }); return proxy as HonoOpenAPIRouterType<E, S, BasePath>; }