/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-unused-vars */ import type { RouteConfig as RouteConfigBase, ZodContentObject, ZodMediaTypeObject, ZodRequestBody, } from '@asteasolutions/zod-to-openapi' import { OpenAPIRegistry, OpenApiGeneratorV3, OpenApiGeneratorV31, extendZodWithOpenApi, } from '@asteasolutions/zod-to-openapi' import { zValidator } from '@hono/zod-validator' import { Hono } from 'hono' import type { Context, Env, Handler, Input, MiddlewareHandler, Schema, ToSchema, TypedResponse, ValidationTargets, } from 'hono' import type { MergePath, MergeSchemaPath } from 'hono/types' import type { ClientErrorStatusCode, InfoStatusCode, RedirectStatusCode, ServerErrorStatusCode, StatusCode, SuccessStatusCode, } from 'hono/utils/http-status' import type { JSONParsed, JSONValue, RemoveBlankRecord, SimplifyDeepArray } from 'hono/utils/types' import { mergePath } from 'hono/utils/url' import type { ZodError, ZodSchema } from 'zod' import { ZodType, z } from 'zod' type MaybePromise = Promise | T export type RouteConfig = RouteConfigBase & { middleware?: MiddlewareHandler | MiddlewareHandler[] hide?: boolean } type RequestTypes = { body?: ZodRequestBody params?: ZodType query?: ZodType cookies?: ZodType headers?: ZodType | ZodType[] } type IsJson = T extends string ? T extends `application/${infer Start}json${infer _End}` ? Start extends '' | `${string}+` | `vnd.${string}+` ? 'json' : never : never : never type IsForm = T extends string ? T extends | `multipart/form-data${infer _Rest}` | `application/x-www-form-urlencoded${infer _Rest}` ? 'form' : never : never type ReturnJsonOrTextOrResponse< ContentType, Content, Status extends keyof StatusCodeRangeDefinitions | StatusCode, > = ContentType extends string ? ContentType extends `application/${infer Start}json${infer _End}` ? Start extends '' | `${string}+` | `vnd.${string}+` ? TypedResponse< SimplifyDeepArray extends JSONValue ? JSONValue extends SimplifyDeepArray ? never : JSONParsed : never, ExtractStatusCode, 'json' > : never : ContentType extends `text/plain${infer _Rest}` ? TypedResponse, 'text'> : Response : never type RequestPart = Part extends keyof R['request'] ? R['request'][Part] : {} type HasUndefined = undefined extends T ? true : false type InputTypeBase< R extends RouteConfig, Part extends string, Type extends keyof ValidationTargets, > = R['request'] extends RequestTypes ? RequestPart extends ZodType ? { in: { [K in Type]: HasUndefined extends true ? { [K2 in keyof z.input>]?: z.input>[K2] } : { [K2 in keyof z.input>]: z.input>[K2] } } out: { [K in Type]: z.output> } } : {} : {} type InputTypeJson = R['request'] extends RequestTypes ? R['request']['body'] extends ZodRequestBody ? R['request']['body']['content'] extends ZodContentObject ? IsJson extends never ? {} : R['request']['body']['content'][keyof R['request']['body']['content']] extends Record< 'schema', ZodSchema > ? { in: { json: z.input< R['request']['body']['content'][keyof R['request']['body']['content']]['schema'] > } out: { json: z.output< R['request']['body']['content'][keyof R['request']['body']['content']]['schema'] > } } : {} : {} : {} : {} type InputTypeForm = R['request'] extends RequestTypes ? R['request']['body'] extends ZodRequestBody ? R['request']['body']['content'] extends ZodContentObject ? IsForm extends never ? {} : R['request']['body']['content'][keyof R['request']['body']['content']] extends Record< 'schema', ZodSchema > ? { in: { form: z.input< R['request']['body']['content'][keyof R['request']['body']['content']]['schema'] > } out: { form: z.output< R['request']['body']['content'][keyof R['request']['body']['content']]['schema'] > } } : {} : {} : {} : {} type InputTypeParam = InputTypeBase type InputTypeQuery = InputTypeBase type InputTypeHeader = InputTypeBase type InputTypeCookie = InputTypeBase type ExtractContent = T extends { [K in keyof T]: infer A } ? A extends Record<'schema', ZodSchema> ? z.infer : never : never type StatusCodeRangeDefinitions = { '1XX': InfoStatusCode '2XX': SuccessStatusCode '3XX': RedirectStatusCode '4XX': ClientErrorStatusCode '5XX': ServerErrorStatusCode } type RouteConfigStatusCode = keyof StatusCodeRangeDefinitions | StatusCode type ExtractStatusCode = T extends keyof StatusCodeRangeDefinitions ? StatusCodeRangeDefinitions[T] : T type DefinedStatusCodes = keyof R['responses'] & RouteConfigStatusCode export type RouteConfigToTypedResponse = | { [Status in DefinedStatusCodes]: R['responses'][Status] extends { content: infer Content } ? undefined extends Content ? never : ReturnJsonOrTextOrResponse< keyof R['responses'][Status]['content'], ExtractContent, Status > : TypedResponse<{}, ExtractStatusCode, string> }[DefinedStatusCodes] | ('default' extends keyof R['responses'] ? R['responses']['default'] extends { content: infer Content } ? undefined extends Content ? never : ReturnJsonOrTextOrResponse< keyof Content, ExtractContent, Exclude>> > : TypedResponse<{}, Exclude>>, string> : never) export type Hook = ( result: { target: keyof ValidationTargets } & ( | { success: true data: T } | { success: false error: ZodError } ), c: Context ) => R type ConvertPathType = T extends `${infer Start}/{${infer Param}}${infer Rest}` ? `${Start}/:${Param}${ConvertPathType}` : T export type OpenAPIHonoOptions = { defaultHook?: Hook } type HonoInit = ConstructorParameters[0] & OpenAPIHonoOptions /** * Turns `T | T[] | undefined` into `T[]` */ type AsArray = T extends undefined // TODO move to utils? ? [] : T extends any[] ? T : [T] /** * Like simplify but recursive */ export type DeepSimplify = { // TODO move to utils? [KeyType in keyof T]: T[KeyType] extends Record ? DeepSimplify : T[KeyType] } & {} /** * Helper to infer generics from {@link MiddlewareHandler} */ export type OfHandlerType = T extends MiddlewareHandler ? { env: E path: P input: I } : never /** * Reduce a tuple of middleware handlers into a single * handler representing the composition of all * handlers. */ export type MiddlewareToHandlerType[]> = M extends [ infer First, infer Second, ...infer Rest, ] ? First extends MiddlewareHandler ? Second extends MiddlewareHandler ? Rest extends MiddlewareHandler[] // Ensure Rest is an array of MiddlewareHandler ? MiddlewareToHandlerType< [ MiddlewareHandler< DeepSimplify['env'] & OfHandlerType['env']>, // Combine envs OfHandlerType['path'], // Keep path from First OfHandlerType['input'] // Keep input from First >, ...Rest, ] > : never : never : never : M extends [infer Last] ? Last // Return the last remaining handler in the array : MiddlewareHandler type RouteMiddlewareParams = OfHandlerType< MiddlewareToHandlerType> > export type RouteConfigToEnv = RouteMiddlewareParams extends never ? Env : RouteMiddlewareParams['env'] export type RouteHandler< R extends RouteConfig, E extends Env = RouteConfigToEnv, I extends Input = InputTypeParam & InputTypeQuery & InputTypeHeader & InputTypeCookie & InputTypeForm & InputTypeJson, P extends string = ConvertPathType, > = Handler< E, P, I, // If response type is defined, only TypedResponse is allowed. R extends { responses: { [statusCode: number]: { content: { [mediaType: string]: ZodMediaTypeObject } } } } ? MaybePromise> : MaybePromise> | MaybePromise > export type RouteHook< R extends RouteConfig, E extends Env = RouteConfigToEnv, I extends Input = InputTypeParam & InputTypeQuery & InputTypeHeader & InputTypeCookie & InputTypeForm & InputTypeJson, P extends string = ConvertPathType, > = Hook< I, E, P, RouteConfigToTypedResponse | Response | Promise | void | Promise > type OpenAPIObjectConfig = Parameters< InstanceType['generateDocument'] >[0] export type OpenAPIObjectConfigure = | OpenAPIObjectConfig | ((context: Context) => OpenAPIObjectConfig) export class OpenAPIHono< E extends Env = Env, S extends Schema = {}, BasePath extends string = '/', > extends Hono { openAPIRegistry: OpenAPIRegistry defaultHook?: OpenAPIHonoOptions['defaultHook'] constructor(init?: HonoInit) { super(init) this.openAPIRegistry = new OpenAPIRegistry() this.defaultHook = init?.defaultHook } /** * * @param {RouteConfig} route - The route definition which you create with `createRoute()`. * @param {Handler} handler - The handler. If you want to return a JSON object, you should specify the status code with `c.json()`. * @param {Hook} hook - Optional. The hook method defines what it should do after validation. * @example * app.openapi( * route, * (c) => { * // ... * return c.json( * { * age: 20, * name: 'Young man', * }, * 200 // You should specify the status code even if it's 200. * ) * }, * (result, c) => { * if (!result.success) { * return c.json( * { * code: 400, * message: 'Custom Message', * }, * 400 * ) * } * } *) */ openapi = < R extends RouteConfig, I extends Input = InputTypeParam & InputTypeQuery & InputTypeHeader & InputTypeCookie & InputTypeForm & InputTypeJson, P extends string = ConvertPathType, >( { middleware: routeMiddleware, hide, ...route }: R, handler: Handler< // use the env from the middleware if it's defined R['middleware'] extends MiddlewareHandler[] | MiddlewareHandler ? RouteMiddlewareParams['env'] & E : E, P, I, // If response type is defined, only TypedResponse is allowed. R extends { responses: { [statusCode: number]: { content: { [mediaType: string]: ZodMediaTypeObject } } } } ? MaybePromise> : MaybePromise> | MaybePromise >, hook: | Hook< I, E, P, R extends { responses: { [statusCode: number]: { content: { [mediaType: string]: ZodMediaTypeObject } } } } ? MaybePromise> | undefined : MaybePromise> | MaybePromise | undefined > | undefined = this.defaultHook ): OpenAPIHono< E, S & ToSchema, I, RouteConfigToTypedResponse>, BasePath > => { if (!hide) { this.openAPIRegistry.registerPath(route) } const validators: MiddlewareHandler[] = [] if (route.request?.query) { const validator = zValidator('query', route.request.query as any, hook as any) validators.push(validator as any) } if (route.request?.params) { const validator = zValidator('param', route.request.params as any, hook as any) validators.push(validator as any) } if (route.request?.headers) { const validator = zValidator('header', route.request.headers as any, hook as any) validators.push(validator as any) } if (route.request?.cookies) { const validator = zValidator('cookie', route.request.cookies as any, hook as any) validators.push(validator as any) } const bodyContent = route.request?.body?.content if (bodyContent) { for (const mediaType of Object.keys(bodyContent)) { if (!bodyContent[mediaType]) { continue } const schema = (bodyContent[mediaType] as ZodMediaTypeObject)['schema'] if (!(schema instanceof ZodType)) { continue } if (isJSONContentType(mediaType)) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore we can ignore the type error since Zod Validator's types are not used const validator = zValidator('json', schema, hook) if (route.request?.body?.required) { validators.push(validator) } else { const mw: MiddlewareHandler = async (c, next) => { if (c.req.header('content-type')) { if (isJSONContentType(c.req.header('content-type')!)) { return await validator(c, next) } } c.req.addValidatedData('json', {}) await next() } validators.push(mw) } } if (isFormContentType(mediaType)) { const validator = zValidator('form', schema, hook as any) if (route.request?.body?.required) { validators.push(validator) } else { const mw: MiddlewareHandler = async (c, next) => { if (c.req.header('content-type')) { if (isFormContentType(c.req.header('content-type')!)) { return await validator(c, next) } } c.req.addValidatedData('form', {}) await next() } validators.push(mw) } } } } const middleware = routeMiddleware ? Array.isArray(routeMiddleware) ? routeMiddleware : [routeMiddleware] : [] this.on( [route.method], route.path.replaceAll(/\/{(.+?)}/g, '/:$1'), ...middleware, ...validators, handler ) return this } getOpenAPIDocument = ( config: OpenAPIObjectConfig ): ReturnType => { const generator = new OpenApiGeneratorV3(this.openAPIRegistry.definitions) const document = generator.generateDocument(config) // @ts-expect-error the _basePath is a private property return this._basePath ? addBasePathToDocument(document, this._basePath) : document } getOpenAPI31Document = ( config: OpenAPIObjectConfig ): ReturnType => { const generator = new OpenApiGeneratorV31(this.openAPIRegistry.definitions) const document = generator.generateDocument(config) // @ts-expect-error the _basePath is a private property return this._basePath ? addBasePathToDocument(document, this._basePath) : document } doc =

( path: P, configure: OpenAPIObjectConfigure ): OpenAPIHono, BasePath> => { return this.get(path, (c) => { const config = typeof configure === 'function' ? configure(c) : configure try { const document = this.getOpenAPIDocument(config) return c.json(document) } catch (e: any) { return c.json(e, 500) } }) as any } doc31 =

( path: P, configure: OpenAPIObjectConfigure ): OpenAPIHono, BasePath> => { return this.get(path, (c) => { const config = typeof configure === 'function' ? configure(c) : configure try { const document = this.getOpenAPI31Document(config) return c.json(document) } catch (e: any) { return c.json(e, 500) } }) as any } route< SubPath extends string, SubEnv extends Env, SubSchema extends Schema, SubBasePath extends string, >( path: SubPath, app: Hono ): OpenAPIHono> & S, BasePath> route(path: SubPath): Hono, BasePath> route< SubPath extends string, SubEnv extends Env, SubSchema extends Schema, SubBasePath extends string, >( path: SubPath, app?: Hono ): OpenAPIHono> & S, BasePath> { const pathForOpenAPI = path.replaceAll(/:([^\/]+)/g, '{$1}') super.route(path, app as any) if (!(app instanceof OpenAPIHono)) { return this as any } app.openAPIRegistry.definitions.forEach((def) => { switch (def.type) { case 'component': return this.openAPIRegistry.registerComponent(def.componentType, def.name, def.component) case 'route': { this.openAPIRegistry.registerPath({ ...def.route, path: mergePath( pathForOpenAPI, // @ts-expect-error _basePath is private app._basePath.replaceAll(/:([^\/]+)/g, '{$1}'), def.route.path ), }) return } case 'webhook': { this.openAPIRegistry.registerWebhook({ ...def.webhook, path: mergePath( pathForOpenAPI, // @ts-expect-error _basePath is private app._basePath.replaceAll(/:([^\/]+)/g, '{$1}'), def.webhook.path ), }) return } case 'schema': return this.openAPIRegistry.register(def.schema._def.openapi._internal.refId, def.schema) case 'parameter': return this.openAPIRegistry.registerParameter( def.schema._def.openapi._internal.refId, def.schema ) default: { const errorIfNotExhaustive: never = def throw new Error(`Unknown registry type: ${errorIfNotExhaustive}`) } } }) return this as any } basePath(path: SubPath): OpenAPIHono> { return new OpenAPIHono({ ...(super.basePath(path) as any), defaultHook: this.defaultHook }) } } type RoutingPath

= P extends `${infer Head}/{${infer Param}}${infer Tail}` ? `${Head}/:${Param}${RoutingPath}` : P export const createRoute =

& { path: P }>( routeConfig: R ) => { const route = { ...routeConfig, getRoutingPath(): RoutingPath { return routeConfig.path.replaceAll(/\/{(.+?)}/g, '/:$1') as RoutingPath

}, } return Object.defineProperty(route, 'getRoutingPath', { enumerable: false }) } extendZodWithOpenApi(z) export { extendZodWithOpenApi, z } function addBasePathToDocument(document: Record, basePath: string) { const updatedPaths: Record = {} Object.keys(document.paths).forEach((path) => { updatedPaths[mergePath(basePath.replaceAll(/:([^\/]+)/g, '{$1}'), path)] = document.paths[path] }) return { ...document, paths: updatedPaths, } } function isJSONContentType(contentType: string) { return /^application\/([a-z-\.]+\+)?json/.test(contentType) } function isFormContentType(contentType: string) { return ( contentType.startsWith('multipart/form-data') || contentType.startsWith('application/x-www-form-urlencoded') ) }