express-swagger-autodoc
Version:
Auto-generate Swagger UI for Express routes without annotations
248 lines (206 loc) • 7.45 kB
JavaScript
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 }