UNPKG

express-swagger-autodoc

Version:

Auto-generate Swagger UI for Express routes without annotations

248 lines (206 loc) 7.45 kB
import swaggerUi from 'swagger-ui-express' import { capitalizeFirstLetter } from './utils.js' import routeMetadata from 'express-swagger-autodoc/swaggerMetadata.js' function extractPathParams(path) { const pathParams = [] const paramRegex = /:([^/]+)/g let match while ((match = paramRegex.exec(path)) !== null) { pathParams.push({ name: match[1], in: 'path', required: true, schema: { type: 'string' }, }) } return pathParams } function extractRoutesExpress(app, routeMetadata, middlewareFunctions = []) { const paths = {} const secureMiddlewareNames = middlewareFunctions function addRoute(method, path, prefix = '', middlewares = []) { const pathParams = extractPathParams(path) const swaggerPath = path.replace(/:([^/]+)/g, '{$1}') if (!paths[swaggerPath]) paths[swaggerPath] = {} const key = `${method.toLowerCase()} ${swaggerPath}` const inputModel = routeMetadata[key] || {} const parameters = [...pathParams] if (inputModel.params) { for (const [name, type] of Object.entries(inputModel.params)) { if (!parameters.find(p => p.name === name && p.in === 'path')) { parameters.push({ name, in: 'path', required: true, schema: { type }, }) } } } if (inputModel.query) { for (const [name, type] of Object.entries(inputModel.query)) { parameters.push({ name, in: 'query', required: false, schema: { type }, }) } } let requestBody if (inputModel.body) { let schema if (Object.keys(inputModel.body).length === 0) { schema = { type: 'object', additionalProperties: true } } else if ( Object.values(inputModel.body).every( v => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || Array.isArray(v) || typeof v === 'object' ) ) { schema = { type: 'object', properties: Object.fromEntries( Object.entries(inputModel.body).map(([k, v]) => { const type = Array.isArray(v) ? 'array' : typeof v === 'object' ? 'object' : typeof v return [k, { type, example: v }] }) ), } } else { schema = inputModel.body } requestBody = { content: { 'application/json': { schema } } } } const usesSecureMiddleware = middlewares.some( fn => secureMiddlewareNames.includes(fn?.name) ) paths[swaggerPath][method.toLowerCase()] = { tags: [generateTagName(prefix)], summary: `Auto-generated ${method.toUpperCase()} ${swaggerPath}`, parameters: parameters.length ? parameters : undefined, ...(requestBody && { requestBody }), ...(usesSecureMiddleware && { security: [{ AccessKeyAuth: [] }] }), responses: { 200: { description: 'OK' }, }, } } function getMountPathFromRegexp(regexp) { const match = regexp?.source ?.replace(/^\\\//, '/') ?.replace(/\\\/\?\(\?=\\\/\|\$\)/, '') ?.replace(/\\\//g, '/') ?.replace(/\(\?:\(\.\*\)\)\?/, '') ?.replace(/\$$/, '') ?.trim() return match === '/' ? '' : match || '' } function traverseLayers(layers, basePath = '') { for (const layer of layers) { if (layer.route && layer.route.path) { const routePath = basePath + layer.route.path const middlewares = layer.route.stack.map(m => m.handle) for (const method of Object.keys(layer.route.methods)) { addRoute(method, routePath, basePath, middlewares) } } else if (layer.name === 'router' && layer.handle?.stack) { let mountPath = '' if (typeof layer.handle[PREFIX_SYMBOL] === 'string') { mountPath = layer.handle[PREFIX_SYMBOL] } else if (typeof layer.regexp?.source === 'string') { mountPath = getMountPathFromRegexp(layer.regexp) } let effectivePrefix if (mountPath.includes(basePath)) { effectivePrefix = mountPath } else { effectivePrefix = basePath + mountPath } traverseLayers(layer.handle.stack, effectivePrefix) } } } const stack = app._router?.stack || app.router?.stack if (!stack) throw new Error('Cannot find router stack') traverseLayers(stack) return paths } function extractRoutes(app, routeMetadata, middlewareFunctions = []) { if ((app.router && app.router.stack) || (app._router && Array.isArray(app._router.stack))) { return extractRoutesExpress(app, routeMetadata, middlewareFunctions) } else { throw new Error('Express router not initialized. Make sure to define routes before calling setupSwagger().') } } function generateTagName(prefix) { if (!prefix) return 'Default' const words = prefix.replace(/^\//, '').replace(/\//g, ' ').split(' ') return words.map(capitalizeFirstLetter).join(' ') } const PREFIX_SYMBOL = Symbol('swaggerPrefix') export function overrideAppMethods(appOrRouter, routeMetadata, prefix = '') { const methods = ['get', 'post', 'put', 'delete', 'patch', 'options', 'head'] const originalMethods = {} appOrRouter[PREFIX_SYMBOL] = prefix const isRouter = !!appOrRouter.stack && typeof appOrRouter.use === 'function' const isApp = !!appOrRouter._router && typeof appOrRouter.listen === 'function' const type = isApp ? 'Express App' : isRouter ? 'Express Router' : 'Unknown' methods.forEach(method => { originalMethods[method] = appOrRouter[method].bind(appOrRouter) appOrRouter[method] = (path, ...handlers) => { let inputModel = null if ( handlers.length > 0 && typeof handlers[handlers.length - 1] === 'object' && !Array.isArray(handlers[handlers.length - 1]) && typeof handlers[handlers.length - 1] !== 'function' ) { inputModel = handlers.pop() } const fullPath = `${prefix}${path}`.replace(/\/+/g, '/') const swaggerPath = fullPath.replace(/:([^/]+)/g, '{$1}') routeMetadata[`${method.toLowerCase()} ${swaggerPath}`] = inputModel || {} return originalMethods[method](path, ...handlers) } }) } export function registerSwagger(app, routeMetadata, options = {}) { const { route = '/docs', title = 'API Documentation', version = '1.0.0', description = 'Auto-generated Swagger UI', securitySchemes = {} } = options const { middlewareFunctions = [] } = securitySchemes const paths = extractRoutes(app, routeMetadata, middlewareFunctions) const openapiSpec = { openapi: '3.0.0', info: { title, version, description }, paths, ...(Object.keys(securitySchemes).length && { components: { securitySchemes: Object.fromEntries( Object.entries(securitySchemes).filter(([key]) => key !== 'middlewareFunctions') ) } }) } app.use(route, swaggerUi.serve, swaggerUi.setup(openapiSpec)) } export { routeMetadata }