UNPKG

hono-openapi

Version:
502 lines (496 loc) 14.8 kB
'use strict'; var standardValidator = require('@hono/standard-validator'); var standardJson = require('@standard-community/standard-json'); var standardOpenapi = require('@standard-community/standard-openapi'); const uniqueSymbol = Symbol("openapi"); const ALLOWED_METHODS = [ "GET", "PUT", "POST", "DELETE", "OPTIONS", "HEAD", "PATCH", "TRACE" ]; const toOpenAPIPath = (path) => path.split("/").map((x) => { let tmp = x; if (tmp.startsWith(":")) { const match = tmp.match(/^:([^{?]+)(?:{(.+)})?(\?)?$/); if (match) { const paramName = match[1]; tmp = `{${paramName}}`; } else { tmp = tmp.slice(1, tmp.length); if (tmp.endsWith("?")) tmp = tmp.slice(0, -1); tmp = `{${tmp}}`; } } return tmp; }).join("/"); const capitalize = (word) => word.charAt(0).toUpperCase() + word.slice(1); const generateOperationId = (route) => { let operationId = route.method.toLowerCase(); if (route.path === "/") return `${operationId}Index`; for (const segment of route.path.split("/")) { if (segment.charCodeAt(0) === 123) { operationId += `By${capitalize(segment.slice(1, -1))}`; } else { operationId += capitalize(segment); } } return operationId; }; const paramKey = (param) => "$ref" in param ? param.$ref : `${param.in} ${param.name}`; function mergeParameters(...params) { const merged = params.flatMap((x) => x ?? []).reduce((acc, param) => { acc.set(paramKey(param), param); return acc; }, /* @__PURE__ */ new Map()); return Array.from(merged.values()); } const specsByPathContext = /* @__PURE__ */ new Map(); function getPathContext(path) { const context = []; for (const [key, data] of specsByPathContext) { if (data && path.match(key)) { context.push(data); } } return context; } function mergeSpecs(route, ...specs) { return specs.reduce( (prev, spec) => { if (!spec || !prev) return prev; for (const [key, value] of Object.entries(spec)) { if (value == null) continue; if (key in prev && (typeof value === "object" || typeof value === "function" && key === "operationId")) { if (Array.isArray(value)) { const values = [...prev[key] ?? [], ...value]; if (key === "tags") { prev[key] = Array.from(new Set(values)); } else { prev[key] = values; } } else if (typeof value === "function") { prev[key] = value(route); } else { if (key === "parameters") { prev[key] = mergeParameters(prev[key], value); } else { prev[key] = { ...prev[key], ...value }; } } } else { prev[key] = value; } } return prev; }, { operationId: generateOperationId(route) } ); } function registerSchemaPath({ route, specs, paths }) { const path = toOpenAPIPath(route.path); const method = route.method.toLowerCase(); if (method === "all") { if (!specs) return; if (specsByPathContext.has(path)) { const prev = specsByPathContext.get(path) ?? {}; specsByPathContext.set(path, mergeSpecs(route, prev, specs)); } else { specsByPathContext.set(path, specs); } } else { const pathContext = getPathContext(path); if (!(path in paths)) { paths[path] = {}; } if (paths[path]) { paths[path][method] = mergeSpecs( route, ...pathContext, paths[path]?.[method], specs ); } } } function removeExcludedPaths(paths, ctx) { const { exclude, excludeStaticFile } = ctx.options; const newPaths = {}; const _exclude = Array.isArray(exclude) ? exclude : [exclude]; for (const [key, value] of Object.entries(paths)) { const isPathExcluded = !_exclude.some((x) => { if (typeof x === "string") return key === x; return x.test(key); }); const isStaticFileExcluded = excludeStaticFile ? !key.includes(".") || key.includes("{") : true; if (isPathExcluded && !(key.includes("*") && !key.includes("{")) && isStaticFileExcluded && value != null) { for (const method of Object.keys(value)) { const schema = value[method]; if (schema == null) continue; if (key.includes("{")) { if (!schema.parameters) schema.parameters = []; const pathParameters = key.split("/").filter( (x) => x.startsWith("{") && !schema.parameters.find( (params) => params.in === "path" && params.name === x.slice(1, x.length - 1) ) ); for (const param of pathParameters) { const paramName = param.slice(1, param.length - 1); const index = schema.parameters.findIndex( (x) => { if ("$ref" in x) { const pos = x.$ref.split("/").pop(); if (pos) { const param2 = ctx.components.parameters?.[pos]; if (param2 && !("$ref" in param2)) { return param2.in === "path" && param2.name === paramName; } } return false; } return x.in === "path" && x.name === paramName; } ); if (index === -1) { schema.parameters.push({ schema: { type: "string" }, in: "path", name: paramName, required: true }); } } } if (!schema.responses) { schema.responses = { 200: {} }; } } newPaths[key] = value; } } return newPaths; } const DEFAULT_OPTIONS = { documentation: {}, excludeStaticFile: true, exclude: [], excludeMethods: ["OPTIONS"], excludeTags: [] }; function openAPIRouteHandler(hono, options) { let specs; return async (c) => { if (specs) return c.json(specs); specs = await generateSpecs(hono, options, c); return c.json(specs); }; } async function generateSpecs(hono, options = DEFAULT_OPTIONS, c) { const ctx = { components: {}, // @ts-expect-error options: { ...DEFAULT_OPTIONS, ...options } }; const _documentation = ctx.options.documentation ?? {}; const paths = await generatePaths(hono, ctx); for (const path in paths) { for (const method in paths[path]) { const isHidden = getHiddenValue({ valueOrFunc: paths[path][method]?.hide, method, path, c }); if (isHidden) { paths[path][method] = void 0; } } } const components = mergeComponentsObjects( _documentation.components, ctx.components ); return { openapi: "3.1.0", ..._documentation, tags: _documentation.tags?.filter( (tag) => !ctx.options.excludeTags?.includes(tag?.name) ), info: { title: "Hono Documentation", description: "Development documentation", version: "0.0.0", ..._documentation.info }, paths: { ...removeExcludedPaths(paths, ctx), ..._documentation.paths }, components }; } async function generatePaths(hono, ctx) { const paths = {}; for (const route of hono.routes) { const middlewareHandler = route.handler[uniqueSymbol]; if (!middlewareHandler) { if (ctx.options.includeEmptyPaths) { registerSchemaPath({ route, paths }); } continue; } const routeMethod = route.method; if (routeMethod !== "ALL") { if (ctx.options.excludeMethods?.includes(routeMethod)) { continue; } if (!ALLOWED_METHODS.includes(routeMethod)) { continue; } } const defaultOptionsForThisMethod = ctx.options.defaultOptions?.[routeMethod]; const { schema: routeSpecs, components = {} } = await getSpec( middlewareHandler, defaultOptionsForThisMethod ); ctx.components = mergeComponentsObjects(ctx.components, components); registerSchemaPath({ route, specs: routeSpecs, paths }); } return paths; } function getHiddenValue(options) { const { valueOrFunc, c, method, path } = options; if (valueOrFunc != null) { if (typeof valueOrFunc === "boolean") { return valueOrFunc; } if (typeof valueOrFunc === "function") { return valueOrFunc({ c, method, path }); } } return false; } async function getSpec(middlewareHandler, defaultOptions) { if ("spec" in middlewareHandler) { let components = {}; const tmp = { ...defaultOptions, ...middlewareHandler.spec, responses: { ...defaultOptions?.responses, ...middlewareHandler.spec.responses } }; if (tmp.responses) { for (const key of Object.keys(tmp.responses)) { const response = tmp.responses[key]; if (!response || !("content" in response)) continue; for (const contentKey of Object.keys(response.content ?? {})) { const raw = response.content?.[contentKey]; if (!raw) continue; if (raw.schema && "toOpenAPISchema" in raw.schema) { const result2 = await raw.schema.toOpenAPISchema(); raw.schema = result2.schema; if (result2.components) { components = mergeComponentsObjects( components, result2.components ); } } } } } return { schema: tmp, components }; } const result = await middlewareHandler.toOpenAPISchema(); const docs = defaultOptions ?? {}; if (middlewareHandler.target === "form" || middlewareHandler.target === "json") { const media = middlewareHandler.options?.media ?? middlewareHandler.target === "json" ? "application/json" : "multipart/form-data"; if (!docs.requestBody || !("content" in docs.requestBody) || !docs.requestBody.content) { docs.requestBody = { content: { [media]: { schema: result.schema } } }; } else { docs.requestBody.content[media] = { schema: result.schema }; } } else { let parameters = []; if ("$ref" in result.schema) { const ref = result.schema.$ref; const pos = ref.split("/").pop(); if (pos && result.components?.schemas?.[pos]) { const schema = result.components.schemas[pos]; const newParameters = generateParameters( middlewareHandler.target, schema )[0]; if (!result.components.parameters) { result.components.parameters = {}; } result.components.parameters[pos] = newParameters; delete result.components.schemas[pos]; parameters.push({ $ref: `#/components/parameters/${pos}` }); } } else { parameters = generateParameters(middlewareHandler.target, result.schema); } docs.parameters = parameters; } return { schema: docs, components: result.components }; } function generateParameters(target, schema) { const parameters = []; for (const [key, value] of Object.entries(schema.properties ?? {})) { const def = { in: target === "param" ? "path" : target, name: key, // @ts-expect-error schema: value }; const isRequired = schema.required?.includes(key); if (isRequired) { def.required = true; } if (def.schema && "description" in def.schema && def.schema.description) { def.description = def.schema.description; def.schema.description = void 0; } parameters.push(def); } return parameters; } function mergeComponentsObjects(...components) { return components.reduce( (prev, component, index) => { if (component == null || index === 0) return prev; if (prev.schemas && Object.keys(prev.schemas).length > 0 || component.schemas && Object.keys(component.schemas).length > 0) { prev.schemas = { ...prev.schemas, ...component.schemas }; } if (prev.parameters && Object.keys(prev.parameters).length > 0 || component.parameters && Object.keys(component.parameters).length > 0) { prev.parameters = { ...prev.parameters, ...component.parameters }; } return prev; }, components[0] ?? {} ); } function loadVendor(vendor, fn) { if (fn.toJSONSchema) { standardJson.loadVendor(vendor, fn.toJSONSchema); } if (fn.toOpenAPISchema) { standardOpenapi.loadVendor(vendor, fn.toOpenAPISchema); } } function resolver(schema, options) { return { vendor: schema["~standard"].vendor, validate: schema["~standard"].validate, toJSONSchema: () => standardJson.toJsonSchema(schema, options), toOpenAPISchema: () => standardOpenapi.toOpenAPISchema(schema, options) }; } function validator(target, schema, hook, options) { const middleware = standardValidator.sValidator(target, schema, hook); return Object.assign(middleware, { [uniqueSymbol]: { target, ...resolver(schema, options), options } }); } function describeRoute(spec) { const middleware = async (_c, next) => { await next(); }; return Object.assign(middleware, { [uniqueSymbol]: { spec } }); } function describeResponse(handler, responses, options) { const _responses = Object.entries(responses).reduce( (acc, [statusCode, response]) => { if (response.content) { const content = Object.entries(response.content).reduce( (contentAcc, [mediaType, media]) => { if (media.vSchema) { const { vSchema, ...rest } = media; contentAcc[mediaType] = { ...rest, schema: resolver(vSchema, options) }; } else { contentAcc[mediaType] = media; } return contentAcc; }, {} ); acc[statusCode] = { ...response, content }; } else { acc[statusCode] = response; } return acc; }, {} ); return Object.assign(handler, { [uniqueSymbol]: { spec: { responses: _responses } } }); } exports.ALLOWED_METHODS = ALLOWED_METHODS; exports.describeResponse = describeResponse; exports.describeRoute = describeRoute; exports.generateSpecs = generateSpecs; exports.loadVendor = loadVendor; exports.openAPIRouteHandler = openAPIRouteHandler; exports.registerSchemaPath = registerSchemaPath; exports.removeExcludedPaths = removeExcludedPaths; exports.resolver = resolver; exports.uniqueSymbol = uniqueSymbol; exports.validator = validator;