Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16.x, 18.x, 20.x, 22.x]
node-version: [20.x, 22.x, 24.x]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
Expand Down
209 changes: 87 additions & 122 deletions lib/generate-doc.js
Original file line number Diff line number Diff line change
@@ -1,147 +1,112 @@
'use strict'
const pathToRegexp = require('path-to-regexp')

const minimumViableDocument = require('./minimum-doc')
const { get: getSchema, set: setSchema } = require('./layer-schema')

module.exports = function generateDocument (baseDocument, router, basePath) {
// Merge document with select minimum defaults
const doc = Object.assign({
openapi: minimumViableDocument.openapi
}, baseDocument, {
info: Object.assign({}, minimumViableDocument.info, baseDocument.info),
paths: Object.assign({}, minimumViableDocument.paths, baseDocument.paths)
})
function assignRoutes (router, doc, basePath, prefixPath = '', iter = 0, stripped = false) {
// Retrieve all instances of middleware with prefix routes
const subRoutes = router.stack.filter((r) => r.name === 'router')

// Iterate the middleware stack and add any paths and schemas, etc
router && router.stack.forEach((_layer) => {
iterateStack('', null, _layer, (path, routeLayer, layer) => {
if (basePath && path.startsWith(basePath)) {
path = path.replace(basePath, '')
}
const schema = getSchema(layer.handle)
if (!schema || !layer.method) {
return
if (subRoutes.length > 0) {
subRoutes.forEach((routeLayer) => {
const p = router.getRoutes()[0].path.split('/')[1]

if (prefixPath.replaceAll('/', '').trim() !== p || iter > 1) {
prefixPath += `/${p}` // Prevents basePath from being added twice
} else {
stripped = true // Set stripped to true for later
}

const operation = Object.assign({}, schema)
iter += 1
// Assign the routes to the OpenAPI document
doc = Object.assign(doc, assignRoutes(routeLayer.handle, doc, basePath, prefixPath, iter, stripped))
})
}

// Retrieve all instances of middleware attached to a route
const routes = router.stack.filter((e) => e.route)

// Add route params to schema
if (routeLayer && routeLayer.keys && routeLayer.keys.length) {
const keys = {}
routes.forEach((routeLayer) => {
const paths = [routeLayer.route.path].flat()
const layers = routeLayer.route.stack.filter((s) =>
['OpenApiMiddleware', 'schemaMiddleware', 'validSchemaMiddleware'].includes(s.name) && s.method
)

const params = routeLayer.keys.map((k, i) => {
const prev = i > 0 && routeLayer.keys[i - 1]
// do not count parameters without a name if they are next to a named parameter
if (typeof k.name === 'number' && prev && prev.offset + prev.name.length + 1 >= k.offset) {
return null
}
let param
if (schema.parameters) {
param = schema.parameters.find((p) => p.name === k.name && p.in === 'path')
}
if (layers.length > 0) {
layers.forEach((layer) => {
const schema = getSchema(layer.handle) // Retrieve the schema defined inside of the Openapi related middleware
if (!schema || !layer.method) return

// Reformat the path
keys[k.name] = '{' + k.name + '}'
paths.forEach((path) => {
const params = []
let paramIndex = 0

return Object.assign({
name: k.name,
in: 'path',
required: !k.optional,
schema: k.schema || { type: 'string' }
}, param || {})
})
.filter((e) => e)
// Retrieve parameters defined inside the schema
if (schema.parameters && schema.parameters.length) {
params.push(...schema.parameters)
}

if (schema.parameters) {
schema.parameters.forEach((p) => {
if (!params.find((pp) => p.name === pp.name)) {
params.push(p)
// Iterate over parts of the path to find undefined parameters
path = `${prefixPath}${path}`.split('/').map((p) => {
let name = p.slice(1)
if (p && [':', '*'].includes(p[0])) {
if (p.length === 1 && p === '*') {
name = paramIndex++
}
if (!params.some((param) => param.name === name && param.in === 'path')) {
params.push({
name,
in: 'path',
required: true,
schema: { type: 'string' }
})
}
return `{${name}}` // Format the parameter for OpenAPI
}
})
}
return p
}).join('/')

operation.parameters = params
path = pathToRegexp.compile(path.replace(/\*|\(\*\)/g, '(.*)'))(keys, { encode: (value) => value })
}
// Remove basePath if the route has not been stripped
if (!stripped && basePath && path.startsWith(basePath)) {
path = path.slice(basePath.length)
}

doc.paths[path] = doc.paths[path] || {}
doc.paths[path][layer.method] = operation
setSchema(layer.handle, operation)
})
// Assign the parameters back to the schema
schema.parameters = params

// Object.assign schema to operation
const operation = Object.assign({}, schema)

// Configure the OpenAPI documentation for the path and the operation
doc.paths[path] = doc.paths[path] || {}
doc.paths[path][layer.method] = operation

// Reconfigure the schema with the operation
setSchema(layer.handle, operation)
})
})
}
})

return doc
}

function iterateStack (path, routeLayer, layer, cb) {
cb(path, routeLayer, layer)
if (layer.name === 'router') {
layer.handle.stack.forEach(l => {
path = path || ''
iterateStack(path + split(layer.regexp, layer.keys).join('/'), layer, l, cb)
})
}
if (!layer.route) {
return
}
if (Array.isArray(layer.route.path)) {
const r = layer.regexp.toString()
layer.route.path.forEach((p, i) => iterateStack(path + p, layer, {
...layer,
// Chacking if p is a string here since p may be a regex expression
keys: layer.keys.filter((k) => typeof p === 'string' ? p.includes(`/:${k.name}`) : false),
// There may be an issue here if the regex has a '|', but that seems to only be the case with user defined regex
regexp: new RegExp(`(${r.substring(2, r.length - 3).split('|')[i]})`),
route: { ...layer.route, path: '' }
}, cb))
return
module.exports = function generateDocument (baseDocument, router, basePath) {
// Create the default OpenAPI document
let doc = {
...minimumViableDocument,
...baseDocument,
info: { ...minimumViableDocument.info, ...baseDocument.info },
paths: { ...minimumViableDocument.paths, ...baseDocument.paths }
}
layer.route.stack.forEach((l) => iterateStack(path + layer.route.path, layer, l, cb))
}

function processComplexMatch (thing, keys) {
let i = 0

return thing
.toString()
// The replace below replaces the regex used by Express to match dynamic parameters
// (i.e. /:id, /:name, etc...) with the name(s) of those parameter(s)
// This could have been accomplished with replaceAll for Node version 15 and above
// no-useless-escape is disabled since we need three backslashes
.replace(/\(\?\:\(\[\^\\\/\]\+\?\)\)/g, () => `{${keys[i++].name}}`) // eslint-disable-line no-useless-escape
.replace(/\\(.)/g, '$1')
// The replace below removes the regex used at the start of the string and
// the regex used to match the query parameters
.replace(/\/\^|\/\?(.*)/g, '')
.split('/')
}
// Configure the base path if it was assigned
const base = basePath || ''

// https://github.com/expressjs/express/issues/3308#issuecomment-300957572
function split (thing, keys) {
// In express v5 the router layers regexp (path-to-regexp@3.2.0)
// has some additional handling for end of lines, remove those
//
// layer.regexp
// v4 ^\\/sub-route\\/?(?=\\/|$)
// v5 ^\\/sub-route(?:\\/(?=$))?(?=\\/|$)
//
// l.regexp
// v4 ^\\/endpoint\\/?$
// v5 ^\\/endpoint(?:\\/)?$
if (typeof thing === 'string') {
return thing.split('/')
} else if (thing.fast_slash) {
return []
} else {
const match = thing
.toString()
.replace('\\/?', '')
.replace('(?=\\/|$)', '$')
// Added this line to catch the express v5 case after the v4 part is stripped off
.replace('(?:\\/(?=$))?$', '$')
.match(/^\/\^((?:\\[.*+?^${}()|[\]\\/]|[^.*+?^${}()|[\]\\/])*)\$\//)
return match
? match[1].replace(/\\(.)/g, '$1').split('/')
: processComplexMatch(thing, keys)
if (router) {
// When routes are defined, assign them to the final OpenAPI document
doc = Object.assign(doc, assignRoutes(router, doc, base))
}

return doc
}
18 changes: 18 additions & 0 deletions lib/validate.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ module.exports = function makeValidatorMiddleware (middleware, schema, opts) {
})
addFormats(ajv)

if (middleware.router && !middleware.router.handle) {
return next()
}
if (opts.keywords) { addKeywords(ajv, opts.keywords) }
}

Expand All @@ -105,6 +108,21 @@ module.exports = function makeValidatorMiddleware (middleware, schema, opts) {
let r = req
if (opts.coerce !== true) {
r = makeReqCopy(req)
} else {
// Redifine query and params as normal objects we can write to, so we can coerce the data
Object.defineProperty(req, 'query', {
value: { ...req.query },
writable: true,
enumerable: true,
configurable: true
})

Object.defineProperty(req, 'params', {
value: { ...req.params },
writable: true,
enumerable: true,
configurable: true
})
}
const validationStatus = validate(r)
if (validationStatus === true) {
Expand Down
11 changes: 5 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,23 @@
},
"scripts": {
"test": "standard && mocha",
"prepublishOnly": "EXPRESS_MAJOR=4 mocha && EXPRESS_MAJOR=5 mocha",
"prepublishOnly": "mocha",
"postpublish": "git push origin && git push origin --tags"
},
"devDependencies": {
"express": "^4.18.2",
"express4": "github:expressjs/express#4.19.2",
"express5": "npm:express@^5.0.0-beta.3",
"mocha": "^10.3.0",
"standard": "^17.1.0",
"supertest": "^6.3.4"
},
"peerDependencies": {
"express": "^5.1.0"
},
"dependencies": {
"ajv": "^8.12.0",
"ajv-formats": "^2.1.1",
"ajv-keywords": "^5.1.0",
"http-errors": "^2.0.0",
"path-to-regexp": "^6.2.1",
"router": "^1.3.8",
"router": "github:bjohansebas/router#maproutes",
"serve-static": "^1.15.0",
"swagger-parser": "^10.0.3",
"swagger-ui-dist": "^5.11.8",
Expand Down
2 changes: 1 addition & 1 deletion test/_moreRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const oapi = openapi()
router.use(oapi)

router.get(
'/',
'/:id',
oapi.validPath({
summary: 'Get a user.',
parameters: [
Expand Down
Loading