├── .gitignore ├── .github ├── CODEOWNERS └── workflows │ └── test.yml ├── .npmrc ├── lib ├── minimum-doc.js ├── layer-schema.js ├── convert-yaml.js ├── ui.js ├── validate.js └── generate-doc.js ├── test ├── _moreRoutes.js ├── _routes.js ├── _validate.js ├── _regexRoutes.js └── index.js ├── LICENSE ├── package.json ├── index.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @wesleytodd -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /lib/minimum-doc.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = { 4 | openapi: '3.0.0', 5 | info: { 6 | title: 'Express App', 7 | version: '1.0.0' 8 | }, 9 | paths: {} 10 | } 11 | -------------------------------------------------------------------------------- /lib/layer-schema.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const schemas = new Map() 3 | 4 | module.exports = { 5 | set: (handler, schema) => { 6 | schemas.set(handler, schema) 7 | }, 8 | get: (handler) => { 9 | return schemas.get(handler) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /lib/convert-yaml.js: -------------------------------------------------------------------------------- 1 | const YAML = require('yaml') 2 | 3 | /** 4 | * Converts a json to yaml 5 | * @param {object} jsonObject 6 | * @returns {string} yamlString 7 | */ 8 | module.exports = function (jsonObject) { 9 | const doc = YAML.stringify(jsonObject) 10 | return doc 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | node-version: [16.x, 18.x, 20.x, 22.x] 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - name: npm install and test 20 | run: npm it 21 | -------------------------------------------------------------------------------- /test/_moreRoutes.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router({ mergeParams: true }) 2 | const openapi = require('..') 3 | 4 | const oapi = openapi() 5 | router.use(oapi) 6 | 7 | router.get( 8 | '/', 9 | oapi.validPath({ 10 | summary: 'Get a user.', 11 | parameters: [ 12 | { 13 | in: 'path', 14 | imageId: 'id', 15 | schema: { 16 | type: 'integer' 17 | } 18 | } 19 | ], 20 | responses: { 21 | 200: { 22 | content: { 23 | 'application/json': { 24 | schema: { 25 | type: 'string' 26 | } 27 | } 28 | } 29 | } 30 | } 31 | }), 32 | async (req, res) => { 33 | res.send('done') 34 | } 35 | ) 36 | 37 | module.exports = router 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Wes Todd 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY 10 | SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION 12 | OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN 13 | CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@wesleytodd/openapi", 3 | "version": "1.1.0", 4 | "description": "Middleware for generating OpenAPI/Swagger documentation for your Express app", 5 | "author": "Wes Todd ", 6 | "keywords": [ 7 | "express", 8 | "openapi", 9 | "swagger", 10 | "documentation" 11 | ], 12 | "license": "ISC", 13 | "main": "index.js", 14 | "repository": { 15 | "type": "git", 16 | "url": "wesleytodd/express-openapi" 17 | }, 18 | "scripts": { 19 | "test": "standard && mocha", 20 | "prepublishOnly": "EXPRESS_MAJOR=4 mocha && EXPRESS_MAJOR=5 mocha", 21 | "postpublish": "git push origin && git push origin --tags" 22 | }, 23 | "devDependencies": { 24 | "express": "^4.18.2", 25 | "express4": "github:expressjs/express#4.19.2", 26 | "express5": "npm:express@^5.0.0-beta.3", 27 | "mocha": "^10.3.0", 28 | "standard": "^17.1.0", 29 | "supertest": "^6.3.4" 30 | }, 31 | "dependencies": { 32 | "ajv": "^8.12.0", 33 | "ajv-formats": "^2.1.1", 34 | "ajv-keywords": "^5.1.0", 35 | "http-errors": "^2.0.0", 36 | "path-to-regexp": "^6.2.1", 37 | "router": "^1.3.8", 38 | "serve-static": "^1.15.0", 39 | "swagger-parser": "^10.0.3", 40 | "swagger-ui-dist": "^5.11.8", 41 | "yaml": "^2.4.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/ui.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const path = require('path') 3 | const serve = require('serve-static') 4 | 5 | module.exports.serveSwaggerUI = function serveSwaggerUI (documentUrl, opts = {}) { 6 | const { plugins, ...options } = opts 7 | 8 | return [serve(path.resolve(require.resolve('swagger-ui-dist'), '..'), { index: false }), 9 | function returnUiInit (req, res, next) { 10 | if (req.path.endsWith('/swagger-ui-init.js')) { 11 | res.type('.js') 12 | res.send(`window.onload = function () { 13 | window.ui = SwaggerUIBundle({ 14 | url: '${documentUrl}', 15 | dom_id: '#swagger-ui', 16 | ${plugins?.length ? `plugins: [${plugins}],` : ''} 17 | ...${JSON.stringify(options)} 18 | }) 19 | }` 20 | ) 21 | } else { 22 | next() 23 | } 24 | }, 25 | function renderSwaggerHtml (req, res) { 26 | res.type('html').send(renderHtmlPage('Swagger UI', ` 27 | 28 | `, ` 29 |
30 | 31 | 32 | 33 | `)) 34 | } 35 | ] 36 | } 37 | 38 | function renderHtmlPage (title, head, body) { 39 | return ` 40 | 41 | 42 | ${title} 43 | 44 | 45 | 62 | ${head} 63 | 64 | 65 | ${body} 66 | 67 | 68 | ` 69 | } 70 | -------------------------------------------------------------------------------- /test/_routes.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { suite, test } = require('mocha') 3 | const assert = require('assert') 4 | const supertest = require('supertest') 5 | const express = require('express') 6 | const SwaggerParser = require('swagger-parser') 7 | const openapi = require('..') 8 | const _moreRoutes = require('./_moreRoutes') 9 | 10 | module.exports = function () { 11 | suite('routes', function () { 12 | test('serve OpenAPI document', function (done) { 13 | const app = express() 14 | app.use(openapi()) 15 | supertest(app) 16 | .get(`${openapi.defaultRoutePrefix}.json`) 17 | .expect(200, (err, res) => { 18 | assert(!err, err) 19 | SwaggerParser.validate(res.body, (err, api) => { 20 | assert(!err, err) 21 | assert.deepStrictEqual(api, openapi.minimumViableDocument) 22 | done() 23 | }) 24 | }) 25 | }) 26 | 27 | test('serve components as separate routes', function (done) { 28 | const app = express() 29 | const schema = { 30 | type: 'object', 31 | properties: { 32 | hello: { 33 | type: 'string', 34 | enum: ['world'] 35 | } 36 | } 37 | } 38 | 39 | app.use(openapi({ 40 | components: { 41 | schema: { 42 | HelloWorld: schema 43 | } 44 | } 45 | })) 46 | supertest(app) 47 | .get(`${openapi.defaultRoutePrefix}/components/schema/HelloWorld.json`) 48 | .expect(200, (err, res) => { 49 | assert(!err, err) 50 | assert.deepStrictEqual(res.body, schema) 51 | done() 52 | }) 53 | }) 54 | 55 | test('validate and return any errors', function (done) { 56 | const app = express() 57 | 58 | const oapi = openapi() 59 | app.use(oapi) 60 | app.get('/bad-document', oapi.path({ 61 | responses: { 62 | 200: { 63 | content: { 64 | 'application/json': { 65 | schema: { type: 'object' } 66 | } 67 | } 68 | } 69 | } 70 | })) 71 | 72 | supertest(app) 73 | .get(`${openapi.defaultRoutePrefix}/validate`) 74 | .expect(200, (err, res) => { 75 | assert(!err, err) 76 | assert.deepStrictEqual(res.body.details[0].inner[0].path, ['paths', '/bad-document', 'get', 'responses', '200']) 77 | assert.strictEqual(res.body.details[0].inner[0].params[0], 'description') 78 | done() 79 | }) 80 | }) 81 | 82 | test('serve routes in a different file', function (done) { 83 | const app = express() 84 | 85 | const oapi = openapi() 86 | app.use(oapi) 87 | app.use('/:id', _moreRoutes) 88 | 89 | supertest(app) 90 | .get(`${openapi.defaultRoutePrefix}.json`) 91 | .expect(200, (err, res) => { 92 | assert(!err, err) 93 | assert.strictEqual(Object.keys((res.body.paths))[0], '/{id}/') 94 | done() 95 | }) 96 | }) 97 | 98 | test('serve routes in an array as different routes', function (done) { 99 | const app = express() 100 | 101 | const oapi = openapi() 102 | app.use(oapi) 103 | app.get(['/route/:a', '/route/b', '/routeC'], oapi.path({ 104 | summary: 'Test route.', 105 | responses: { 106 | 200: { 107 | content: { 108 | 'application/json': { 109 | schema: { 110 | type: 'string' 111 | } 112 | } 113 | } 114 | } 115 | } 116 | })) 117 | 118 | supertest(app) 119 | .get(`${openapi.defaultRoutePrefix}.json`) 120 | .expect(200, (err, res) => { 121 | assert(!err, err) 122 | assert.strictEqual(Object.keys((res.body.paths))[0], '/route/{a}') 123 | assert.strictEqual(Object.keys((res.body.paths))[1], '/route/b') 124 | assert.strictEqual(Object.keys((res.body.paths))[2], '/routeC') 125 | done() 126 | }) 127 | }) 128 | }) 129 | } 130 | -------------------------------------------------------------------------------- /lib/validate.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const Ajv = require('ajv') 3 | const addFormats = require('ajv-formats') 4 | const addKeywords = require('ajv-keywords') 5 | const httpErrors = require('http-errors') 6 | 7 | const BASE_REQ_SCHEMA = { 8 | type: 'object', 9 | required: ['headers', 'params', 'query'], 10 | properties: { 11 | headers: { 12 | type: 'object', 13 | required: [], 14 | properties: {} 15 | }, 16 | params: { 17 | type: 'object', 18 | required: [], 19 | properties: {} 20 | }, 21 | query: { 22 | type: 'object', 23 | required: [], 24 | properties: {} 25 | }, 26 | body: { 27 | type: 'object', 28 | required: [], 29 | properties: {} 30 | } 31 | } 32 | } 33 | 34 | module.exports = function makeValidatorMiddleware (middleware, schema, opts) { 35 | let ajv 36 | let validate 37 | 38 | function makeValidator () { 39 | const reqSchema = structuredClone(BASE_REQ_SCHEMA) 40 | 41 | // Compile req schema on first request 42 | // Build param validation 43 | schema.parameters && schema.parameters.forEach((p) => { 44 | switch (p.in) { 45 | case 'path': 46 | reqSchema.properties.params.properties[p.name] = p.schema 47 | p.required && !reqSchema.properties.params.required.includes(p.name) && reqSchema.properties.params.required.push(p.name) 48 | break 49 | case 'query': 50 | reqSchema.properties.query.properties[p.name] = p.schema 51 | p.required && !reqSchema.properties.query.required.includes(p.name) && reqSchema.properties.query.required.push(p.name) 52 | break 53 | case 'header': { 54 | const name = p.name.toLowerCase() 55 | reqSchema.properties.headers.properties[name] = p.schema 56 | p.required && !reqSchema.properties.headers.required.includes(p.name) && reqSchema.properties.headers.required.push(name) 57 | break 58 | } 59 | } 60 | }) 61 | 62 | // Compile req body schema 63 | schema.requestBody && Object.entries(schema.requestBody.content) 64 | .forEach(([contentType, { schema }]) => { 65 | switch (contentType) { 66 | case 'application/json': 67 | reqSchema.properties.body = schema 68 | break 69 | default: 70 | throw new TypeError(`Validation of content type not supported: ${contentType}`) 71 | } 72 | }) 73 | 74 | // Add components for references 75 | reqSchema.components = middleware.document && middleware.document.components 76 | 77 | return ajv.compile(reqSchema) 78 | } 79 | 80 | return function validateMiddleware (req, res, next) { 81 | // Restrict validation to only "route" layers 82 | // This prevents running any validation 83 | // if we are in a .use call which could 84 | // be a non-routable request thus 85 | if (!req.route) { 86 | return next() 87 | } 88 | 89 | // Create ajv instance on first request 90 | if (!ajv) { 91 | ajv = new Ajv({ 92 | coerceTypes: opts.coerce === 'false' ? opts.coerce : true, 93 | strict: opts.strict === true ? opts.strict : false 94 | }) 95 | addFormats(ajv) 96 | 97 | if (opts.keywords) { addKeywords(ajv, opts.keywords) } 98 | } 99 | 100 | if (!validate) { 101 | validate = makeValidator() 102 | } 103 | 104 | // Validate request 105 | let r = req 106 | if (opts.coerce !== true) { 107 | r = makeReqCopy(req) 108 | } 109 | const validationStatus = validate(r) 110 | if (validationStatus === true) { 111 | return next() 112 | } 113 | 114 | // build error? 115 | const err = new Error('Request validation failed') 116 | err.validationErrors = validate.errors 117 | err.validationSchema = validate.schema 118 | next(httpErrors(400, err)) 119 | } 120 | } 121 | 122 | // This is because ajv modifies the original data, 123 | // preventing this requires that we dont pass the 124 | // actual req. An issue has been opened (@TODO open the issue) 125 | function makeReqCopy (req) { 126 | return JSON.parse(JSON.stringify({ 127 | headers: req.headers, 128 | params: req.params, 129 | query: req.query, 130 | body: req.body 131 | })) 132 | } 133 | -------------------------------------------------------------------------------- /lib/generate-doc.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const pathToRegexp = require('path-to-regexp') 3 | const minimumViableDocument = require('./minimum-doc') 4 | const { get: getSchema, set: setSchema } = require('./layer-schema') 5 | 6 | module.exports = function generateDocument (baseDocument, router, basePath) { 7 | // Merge document with select minimum defaults 8 | const doc = Object.assign({ 9 | openapi: minimumViableDocument.openapi 10 | }, baseDocument, { 11 | info: Object.assign({}, minimumViableDocument.info, baseDocument.info), 12 | paths: Object.assign({}, minimumViableDocument.paths, baseDocument.paths) 13 | }) 14 | 15 | // Iterate the middleware stack and add any paths and schemas, etc 16 | router && router.stack.forEach((_layer) => { 17 | iterateStack('', null, _layer, (path, routeLayer, layer) => { 18 | if (basePath && path.startsWith(basePath)) { 19 | path = path.replace(basePath, '') 20 | } 21 | const schema = getSchema(layer.handle) 22 | if (!schema || !layer.method) { 23 | return 24 | } 25 | 26 | const operation = Object.assign({}, schema) 27 | 28 | // Add route params to schema 29 | if (routeLayer && routeLayer.keys && routeLayer.keys.length) { 30 | const keys = {} 31 | 32 | const params = routeLayer.keys.map((k, i) => { 33 | const prev = i > 0 && routeLayer.keys[i - 1] 34 | // do not count parameters without a name if they are next to a named parameter 35 | if (typeof k.name === 'number' && prev && prev.offset + prev.name.length + 1 >= k.offset) { 36 | return null 37 | } 38 | let param 39 | if (schema.parameters) { 40 | param = schema.parameters.find((p) => p.name === k.name && p.in === 'path') 41 | } 42 | 43 | // Reformat the path 44 | keys[k.name] = '{' + k.name + '}' 45 | 46 | return Object.assign({ 47 | name: k.name, 48 | in: 'path', 49 | required: !k.optional, 50 | schema: k.schema || { type: 'string' } 51 | }, param || {}) 52 | }) 53 | .filter((e) => e) 54 | 55 | if (schema.parameters) { 56 | schema.parameters.forEach((p) => { 57 | if (!params.find((pp) => p.name === pp.name)) { 58 | params.push(p) 59 | } 60 | }) 61 | } 62 | 63 | operation.parameters = params 64 | path = pathToRegexp.compile(path.replace(/\*|\(\*\)/g, '(.*)'))(keys, { encode: (value) => value }) 65 | } 66 | 67 | doc.paths[path] = doc.paths[path] || {} 68 | doc.paths[path][layer.method] = operation 69 | setSchema(layer.handle, operation) 70 | }) 71 | }) 72 | 73 | return doc 74 | } 75 | 76 | function iterateStack (path, routeLayer, layer, cb) { 77 | cb(path, routeLayer, layer) 78 | if (layer.name === 'router') { 79 | layer.handle.stack.forEach(l => { 80 | path = path || '' 81 | iterateStack(path + split(layer.regexp, layer.keys).join('/'), layer, l, cb) 82 | }) 83 | } 84 | if (!layer.route) { 85 | return 86 | } 87 | if (Array.isArray(layer.route.path)) { 88 | const r = layer.regexp.toString() 89 | layer.route.path.forEach((p, i) => iterateStack(path + p, layer, { 90 | ...layer, 91 | // Chacking if p is a string here since p may be a regex expression 92 | keys: layer.keys.filter((k) => typeof p === 'string' ? p.includes(`/:${k.name}`) : false), 93 | // There may be an issue here if the regex has a '|', but that seems to only be the case with user defined regex 94 | regexp: new RegExp(`(${r.substring(2, r.length - 3).split('|')[i]})`), 95 | route: { ...layer.route, path: '' } 96 | }, cb)) 97 | return 98 | } 99 | layer.route.stack.forEach((l) => iterateStack(path + layer.route.path, layer, l, cb)) 100 | } 101 | 102 | function processComplexMatch (thing, keys) { 103 | let i = 0 104 | 105 | return thing 106 | .toString() 107 | // The replace below replaces the regex used by Express to match dynamic parameters 108 | // (i.e. /:id, /:name, etc...) with the name(s) of those parameter(s) 109 | // This could have been accomplished with replaceAll for Node version 15 and above 110 | // no-useless-escape is disabled since we need three backslashes 111 | .replace(/\(\?\:\(\[\^\\\/\]\+\?\)\)/g, () => `{${keys[i++].name}}`) // eslint-disable-line no-useless-escape 112 | .replace(/\\(.)/g, '$1') 113 | // The replace below removes the regex used at the start of the string and 114 | // the regex used to match the query parameters 115 | .replace(/\/\^|\/\?(.*)/g, '') 116 | .split('/') 117 | } 118 | 119 | // https://github.com/expressjs/express/issues/3308#issuecomment-300957572 120 | function split (thing, keys) { 121 | // In express v5 the router layers regexp (path-to-regexp@3.2.0) 122 | // has some additional handling for end of lines, remove those 123 | // 124 | // layer.regexp 125 | // v4 ^\\/sub-route\\/?(?=\\/|$) 126 | // v5 ^\\/sub-route(?:\\/(?=$))?(?=\\/|$) 127 | // 128 | // l.regexp 129 | // v4 ^\\/endpoint\\/?$ 130 | // v5 ^\\/endpoint(?:\\/)?$ 131 | if (typeof thing === 'string') { 132 | return thing.split('/') 133 | } else if (thing.fast_slash) { 134 | return [] 135 | } else { 136 | const match = thing 137 | .toString() 138 | .replace('\\/?', '') 139 | .replace('(?=\\/|$)', '$') 140 | // Added this line to catch the express v5 case after the v4 part is stripped off 141 | .replace('(?:\\/(?=$))?$', '$') 142 | .match(/^\/\^((?:\\[.*+?^${}()|[\]\\/]|[^.*+?^${}()|[\]\\/])*)\$\//) 143 | return match 144 | ? match[1].replace(/\\(.)/g, '$1').split('/') 145 | : processComplexMatch(thing, keys) 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const httpErrors = require('http-errors') 3 | const Router = require('router') 4 | const SwaggerParser = require('swagger-parser') 5 | const ui = require('./lib/ui') 6 | const makeValidator = require('./lib/validate') 7 | const { get: getSchema, set: setSchema } = require('./lib/layer-schema') 8 | const minimumViableDocument = require('./lib/minimum-doc') 9 | const generateDocument = require('./lib/generate-doc') 10 | const defaultRoutePrefix = '/openapi' 11 | const YAML = require('yaml') 12 | 13 | module.exports = function ExpressOpenApi (_routePrefix, _doc, _opts) { 14 | // Acceptable arguments: 15 | // oapi() 16 | // oapi('/path') 17 | // oapi('/path', doc) 18 | // oapi('/path', doc, opts) 19 | // oapi(doc) 20 | // oapi(doc, opts) 21 | // 22 | // The below logic is correct, but very hard to reason about 23 | let routePrefix = _routePrefix || defaultRoutePrefix 24 | let doc = _doc || minimumViableDocument 25 | let opts = _opts || {} 26 | if (typeof _routePrefix !== 'string') { 27 | routePrefix = defaultRoutePrefix 28 | doc = _routePrefix || minimumViableDocument 29 | opts = _doc || {} 30 | } 31 | 32 | // We need to route a bit, seems a safe addition 33 | // to use the express router in an express middleware 34 | const router = new Router() 35 | 36 | // Fully generate the doc on the first request 37 | let isFirstRequest = true 38 | 39 | // Where the magic happens 40 | const middleware = function OpenApiMiddleware (req, res, next) { 41 | if (isFirstRequest) { 42 | middleware.document = generateDocument(middleware.document, req.app._router || req.app.router, opts.basePath) 43 | isFirstRequest = false 44 | } 45 | 46 | router.handle(req, res, next) 47 | } 48 | 49 | // Expose the current document and prefix 50 | middleware.routePrefix = routePrefix 51 | middleware.document = generateDocument(doc, undefined, opts.basePath) 52 | middleware.generateDocument = generateDocument 53 | middleware.options = opts 54 | 55 | // Add a path schema to the document 56 | middleware.path = function (schema = {}) { 57 | function schemaMiddleware (req, res, next) { 58 | next() 59 | } 60 | 61 | setSchema(schemaMiddleware, schema) 62 | return schemaMiddleware 63 | } 64 | 65 | // Validate path middleware 66 | middleware.validPath = function (schema = {}, pathOpts = {}) { 67 | let validate 68 | function validSchemaMiddleware (req, res, next) { 69 | if (!validate) { 70 | validate = makeValidator(middleware, getSchema(validSchemaMiddleware), { ...pathOpts, ...opts }) 71 | } 72 | return validate(req, res, next) 73 | } 74 | 75 | setSchema(validSchemaMiddleware, schema) 76 | return validSchemaMiddleware 77 | } 78 | 79 | // Component definitions 80 | middleware.component = function (type, name, description) { 81 | if (!type) { 82 | throw new TypeError('Component type is required') 83 | } 84 | 85 | // Return whole component type 86 | if (!name && !description) { 87 | return middleware.document.components && middleware.document.components[type] 88 | } 89 | 90 | // Return ref to type 91 | if (name && !description) { 92 | if (!middleware.document.components || !middleware.document.components[type] || !middleware.document.components[type][name]) { 93 | throw new Error(`Unknown ${type} ref: ${name}`) 94 | } 95 | return { $ref: `#/components/${type}/${name}` } 96 | } 97 | 98 | // @TODO create id 99 | // Is this necessary? The point of this was to provide canonical component ref urls 100 | // But now I think that might not be necessary. 101 | // if (!description || !description['$id']) { 102 | // const server = middleware.document.servers && middleware.document.servers[0] && middleware.document.servers[0].url 103 | // console.log(`${server || '/'}{routePrefix}/components/${type}/${name}.json`) 104 | // description['$id'] = `${middleware.document.servers[0].url}/${routePrefix}/components/${type}/${name}.json` 105 | // } 106 | 107 | // Set name on parameter if not passed 108 | if (type === 'parameters') { 109 | description.name = description.name || name 110 | } 111 | 112 | // Define a new component 113 | middleware.document.components = middleware.document.components || {} 114 | middleware.document.components[type] = middleware.document.components[type] || {} 115 | middleware.document.components[type][name] = description 116 | 117 | return middleware 118 | } 119 | middleware.schema = middleware.component.bind(null, 'schemas') 120 | middleware.response = middleware.component.bind(null, 'responses') 121 | middleware.parameters = middleware.component.bind(null, 'parameters') 122 | middleware.examples = middleware.component.bind(null, 'examples') 123 | middleware.requestBodies = middleware.component.bind(null, 'requestBodies') 124 | middleware.headers = middleware.component.bind(null, 'headers') 125 | middleware.securitySchemes = middleware.component.bind(null, 'securitySchemes') 126 | middleware.links = middleware.component.bind(null, 'links') 127 | middleware.callbacks = middleware.component.bind(null, 'callbacks') 128 | 129 | // Expose ui middleware 130 | middleware.swaggerui = (options) => ui.serveSwaggerUI(`${routePrefix}.json`, options) 131 | 132 | // OpenAPI document as json 133 | router.get(`${routePrefix}.json`, (req, res) => { 134 | middleware.document = generateDocument(middleware.document, req.app._router || req.app.router, opts.basePath) 135 | res.json(middleware.document) 136 | }) 137 | 138 | // OpenAPI document as yaml 139 | router.get([`${routePrefix}.yaml`, `${routePrefix}.yml`], (req, res) => { 140 | const jsonSpec = generateDocument(middleware.document, req.app._router || req.app.router, opts.basePath) 141 | const yamlSpec = YAML.stringify(jsonSpec) 142 | 143 | res.type('yaml') 144 | res.send(yamlSpec) 145 | }) 146 | 147 | router.get(`${routePrefix}/components/:type/:name.json`, (req, res, next) => { 148 | const { type, name } = req.params 149 | middleware.document = generateDocument(middleware.document, req.app._router || req.app.router, opts.basePath) 150 | 151 | // No component by that identifer 152 | if (!middleware.document.components[type] || !middleware.document.components[type][name]) { 153 | return next(httpErrors(404, `Component does not exist: ${type}/${name}`)) 154 | } 155 | 156 | // Return component 157 | res.json(middleware.document.components[type][name]) 158 | }) 159 | 160 | // Validate full open api document 161 | router.get(`${routePrefix}/validate`, (req, res) => { 162 | middleware.document = generateDocument(middleware.document, req.app._router || req.app.router, opts.basePath) 163 | SwaggerParser.validate(middleware.document, (err, api) => { 164 | if (err) { 165 | return res.json({ 166 | valid: false, 167 | details: err.details, 168 | document: middleware.document 169 | }) 170 | } 171 | res.json({ 172 | valid: true, 173 | document: middleware.document 174 | }) 175 | }) 176 | }) 177 | 178 | // Serve up the for exploring the document 179 | if (opts.htmlui) { 180 | let ui = opts.htmlui 181 | if (!Array.isArray(opts.htmlui)) { 182 | ui = [opts.htmlui] 183 | } 184 | if (ui.includes('swagger-ui')) { 185 | router.get(`${routePrefix}`, (req, res) => { res.redirect(`${routePrefix}/swagger-ui`) }) 186 | router.use(`${routePrefix}/swagger-ui`, middleware.swaggerui) 187 | } 188 | } 189 | 190 | return middleware 191 | } 192 | 193 | module.exports.minimumViableDocument = minimumViableDocument 194 | module.exports.defaultRoutePrefix = defaultRoutePrefix 195 | -------------------------------------------------------------------------------- /test/_validate.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { suite, test } = require('mocha') 3 | const assert = require('assert') 4 | const supertest = require('supertest') 5 | const express = require('express') 6 | const openapi = require('..') 7 | 8 | module.exports = function () { 9 | suite('validate', function () { 10 | test('validate incoming requests', async function () { 11 | const app = express() 12 | const oapi = openapi() 13 | 14 | app.use(express.json(), oapi) 15 | app.post('/:foo', oapi.validPath({ 16 | parameters: [{ 17 | name: 'num', 18 | in: 'query', 19 | schema: { type: 'number' } 20 | }, { 21 | name: 'x-Custom-Header', 22 | in: 'header', 23 | required: true, 24 | schema: { type: 'string' } 25 | }], 26 | requestBody: { 27 | required: true, 28 | content: { 29 | 'application/json': { 30 | schema: { 31 | type: 'object', 32 | properties: { 33 | hello: { type: 'string', enum: ['world'] }, 34 | birthday: { type: 'string', format: 'date' } 35 | } 36 | } 37 | } 38 | } 39 | }, 40 | responses: { 41 | 200: { 42 | description: 'Successful response', 43 | content: { 44 | 'application/json': { 45 | schema: { 46 | type: 'object', 47 | properties: { 48 | goodbye: { type: 'string', enum: ['moon'] } 49 | } 50 | } 51 | } 52 | } 53 | } 54 | } 55 | }), (req, res) => { 56 | res.status(200).json({ 57 | goodbye: 'moon', 58 | num: req.query.num 59 | }) 60 | }, (err, req, res, next) => { 61 | assert(err) 62 | res.status(err.statusCode).json(err) 63 | }) 64 | 65 | const res1 = await supertest(app) 66 | .post('/bar?num=123') 67 | .set('X-Custom-Header', 'value') 68 | .send({ 69 | hello: 'world', 70 | foo: 'bar' 71 | }) 72 | 73 | assert.strictEqual(res1.statusCode, 200) 74 | assert.strictEqual(res1.body.goodbye, 'moon') 75 | assert.strictEqual(res1.body.num, '123') 76 | 77 | const res2 = await supertest(app) 78 | .post('/bar') 79 | .set('X-Custom-Header', 'value') 80 | .send({ 81 | hello: 'bad boy', 82 | foo: 'bar' 83 | }) 84 | 85 | assert.strictEqual(res2.statusCode, 400) 86 | assert.strictEqual(res2.body.validationErrors[0].instancePath, '/body/hello') 87 | 88 | const res3 = await supertest(app) 89 | .post('/bar') 90 | .send({ 91 | hello: 'world', 92 | foo: 'bar' 93 | }) 94 | 95 | assert.strictEqual(res3.statusCode, 400) 96 | assert.strictEqual(res3.body.validationErrors[0].instancePath, '/headers') 97 | assert.strictEqual(res3.body.validationErrors[0].params.missingProperty, 'x-custom-header') 98 | 99 | const res4 = await supertest(app) 100 | .post('/bar?num=123') 101 | .set('X-Custom-Header', 'value') 102 | .send({ 103 | hello: 'world', 104 | birthday: 'bad date', 105 | foo: 'bar' 106 | }) 107 | 108 | assert.strictEqual(res4.statusCode, 400) 109 | assert.strictEqual(res4.body.validationErrors[0].instancePath, '/body/birthday') 110 | 111 | app.put('/zoom', oapi.validPath({ 112 | requestBody: { 113 | required: true, 114 | content: { 115 | 'application/json': { 116 | schema: { 117 | type: 'object', 118 | properties: { 119 | name: { type: 'string', not: { regexp: '/^[A-Z]/' } } 120 | } 121 | } 122 | } 123 | } 124 | }, 125 | responses: { 126 | 200: { 127 | description: 'Successful response', 128 | content: { 129 | 'application/json': { 130 | schema: { 131 | type: 'object', 132 | properties: { 133 | goodbye: { type: 'string', enum: ['moon'] } 134 | } 135 | } 136 | } 137 | } 138 | } 139 | } 140 | }, { keywords: ['regexp'] }), (req, res) => { 141 | res.status(200).json({ 142 | goodbye: 'moon', 143 | num: req.query.num 144 | }) 145 | }, (err, req, res, next) => { 146 | assert(err) 147 | res.status(err.statusCode).json(err) 148 | }) 149 | 150 | const res5 = await supertest(app) 151 | .put('/zoom') 152 | .send({ 153 | hello: 'world', 154 | foo: 'bar', 155 | name: 'abc' 156 | }) 157 | 158 | assert.strictEqual(res5.statusCode, 200) 159 | 160 | const res6 = await supertest(app) 161 | .put('/zoom') 162 | .send({ 163 | hello: 'world', 164 | foo: 'bar', 165 | name: 'Abc' 166 | }) 167 | 168 | assert.strictEqual(res6.statusCode, 400) 169 | assert.strictEqual(res6.body.validationErrors[0].instancePath, '/body/name') 170 | 171 | app.get('/me', oapi.validPath({ 172 | parameters: [{ 173 | name: 'q', 174 | in: 'query', 175 | schema: { 176 | type: 'string', 177 | regexp: { 178 | pattern: '^o', 179 | flags: 'i' 180 | } 181 | } 182 | }], 183 | responses: { 184 | 200: { 185 | description: 'Successful response', 186 | content: { 187 | 'application/json': { 188 | schema: { 189 | type: 'object', 190 | properties: { 191 | goodbye: { type: 'string', enum: ['moon'] } 192 | } 193 | } 194 | } 195 | } 196 | } 197 | } 198 | }, { keywords: ['regexp'] }), (req, res) => { 199 | res.status(200).json({ 200 | goodbye: 'moon' 201 | }) 202 | }, (err, req, res, next) => { 203 | assert(err) 204 | res.status(err.statusCode).json(err) 205 | }) 206 | 207 | const res7 = await supertest(app) 208 | .get('/me?q=123') 209 | 210 | assert.strictEqual(res7.statusCode, 400) 211 | assert.strictEqual(res7.body.validationErrors[0].instancePath, '/query/q') 212 | 213 | const res8 = await supertest(app) 214 | .get('/me?q=oops') 215 | 216 | assert.strictEqual(res8.statusCode, 200) 217 | assert.strictEqual(res8.body.goodbye, 'moon') 218 | }) 219 | 220 | test('coerce types on req', async function () { 221 | const app = express() 222 | const oapi = openapi(null, { 223 | coerce: true 224 | }) 225 | 226 | app.use(oapi) 227 | app.post('/', oapi.validPath({ 228 | parameters: [{ 229 | name: 'num', 230 | in: 'query', 231 | schema: { type: 'number' } 232 | }] 233 | }), (req, res) => { 234 | res.status(200).json({ 235 | num: req.query.num, 236 | numType: typeof req.query.num 237 | }) 238 | }, (err, req, res, next) => { 239 | assert(err) 240 | res.status(err.statusCode).json(err) 241 | }) 242 | 243 | const res1 = await supertest(app) 244 | .post('/?num=123') 245 | .send() 246 | 247 | assert.strictEqual(res1.statusCode, 200) 248 | assert.strictEqual(res1.body.num, 123) 249 | assert.strictEqual(res1.body.numType, 'number') 250 | }) 251 | }) 252 | } 253 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Express OpenAPI 2 | 3 | [![NPM Version](https://badgen.net/npm/v/@wesleytodd/openapi)](https://npmjs.org/package/@wesleytodd/openapi) 4 | [![NPM Downloads](https://badgen.net/npm/dm/@wesleytodd/openapi)](https://npmjs.org/package/@wesleytodd/openapi) 5 | [![js-standard-style](https://badgen.net/badge/style/standard/green)](https://github.com/standard/standard) 6 | 7 | A middleware for generating and validating OpenAPI documentation from an Express app. 8 | 9 | This middleware will look at the routes defined in your app and fill in as much as it can about them 10 | into an OpenAPI document. Optionally you can also flesh out request and response schemas, parameters, and 11 | other parts of your api spec with path specific middleware. The final document will be exposed as json 12 | served by the main middleware (along with component specific documents). 13 | 14 | ## Philosophy 15 | 16 | It is common in the OpenAPI community to talk about generating code from documentation. There is value 17 | in this approach, as often it is easier for devs to let someone else make the implementation decisions 18 | for them. For me, I feel the opposite. I am an engineer whose job it is to make good decisions about 19 | writing quality code. I want control of my application, and I want to write code. With this module I can 20 | both write great code, as well as have great documentation! 21 | 22 | 23 | ## Installation 24 | 25 | ``` 26 | $ npm install --save @wesleytodd/openapi 27 | ``` 28 | 29 | ## Usage 30 | 31 | ```javascript 32 | const openapi = require('@wesleytodd/openapi') 33 | const app = require('express')() 34 | 35 | const oapi = openapi({ 36 | openapi: '3.0.0', 37 | info: { 38 | title: 'Express Application', 39 | description: 'Generated docs from an Express api', 40 | version: '1.0.0', 41 | } 42 | }) 43 | 44 | // This will serve the generated json document(s) 45 | // (as well as the swagger-ui if configured) 46 | app.use(oapi) 47 | 48 | // To add path specific schema you can use the .path middleware 49 | app.get('/', oapi.path({ 50 | responses: { 51 | 200: { 52 | description: 'Successful response', 53 | content: { 54 | 'application/json': { 55 | schema: { 56 | type: 'object', 57 | properties: { 58 | hello: { type: 'string' } 59 | } 60 | } 61 | } 62 | } 63 | } 64 | } 65 | }), (req, res) => { 66 | res.json({ 67 | hello: 'world' 68 | }) 69 | }) 70 | 71 | app.listen(8080) 72 | ``` 73 | 74 | In the above example you can see the output of the OpenAPI spec by requesting `/openapi.json`. 75 | 76 | ```shell 77 | $ curl -s http://localhost:8080/openapi.json | jq . 78 | { 79 | "openapi": "3.0.0", 80 | "info": { 81 | "title": "Express Application", 82 | "version": "1.0.0", 83 | "description": "Generated docs from an Express api" 84 | }, 85 | "paths": { 86 | "/": { 87 | "get": { 88 | "responses": { 89 | "200": { 90 | "description": "Successful response", 91 | "content": { 92 | "application/json": { 93 | "schema": { 94 | "type": "object", 95 | "properties": { 96 | "hello": { 97 | "type": "string" 98 | } 99 | } 100 | } 101 | } 102 | } 103 | } 104 | } 105 | } 106 | } 107 | } 108 | } 109 | ``` 110 | 111 | ## Api Docs 112 | 113 | ### `openapi([route [, document[, options]]])` 114 | 115 | Creates an instance of the documentation middleware. The function that is returned 116 | is a middleware function decorated with helper methods for setting up the api documentation. 117 | 118 | Options: 119 | 120 | - `route `: A route for which the documentation will be served at 121 | - `document `: Base document on top of which the paths will be added 122 | - `options `: Options object 123 | - `options.coerce`: Enable data type [`coercion`](https://www.npmjs.com/package/ajv#coercing-data-types) 124 | - `options.htmlui`: Turn on serving `swagger-ui` html ui 125 | - `options.basePath`: When set, will strip the value of `basePath` from the start of every path. 126 | 127 | ##### Coerce 128 | 129 | By default `coerceTypes` is set to `true` for AJV, but a copy of the `req` data 130 | is passed to prevent modifying the `req` in an unexpected way. This is because 131 | the `coerceTypes` option in (AJV modifies the input)[https://github.com/epoberezkin/ajv/issues/549]. 132 | If this is the behavior you want, you can pass `true` for this and a copy will not be made. 133 | This will result in params in the path or query with type `number` will be converted 134 | to numbers [based on the rules from AJV](https://github.com/epoberezkin/ajv/blob/master/COERCION.md). 135 | 136 | ### `OpenApiMiddleware.path([definition])` 137 | 138 | Registers a path with the OpenAPI document. The path `definition` is an 139 | [`OperationObject`](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#operationObject) 140 | with all of the information about the requests and responses on that route. It returns 141 | a middleware function which can be used in an express app. 142 | 143 | **Example:** 144 | 145 | ```javascript 146 | app.get('/:foo', oapi.path({ 147 | description: 'Get a foo', 148 | responses: { 149 | 200: { 150 | content: { 151 | 'application/json': { 152 | schema: { 153 | type: 'object', 154 | properties: { 155 | foo: { type: 'string' } 156 | } 157 | } 158 | } 159 | } 160 | } 161 | } 162 | }), (req, res) => { 163 | res.json({ 164 | foo: req.params.foo 165 | }) 166 | }) 167 | ``` 168 | 169 | ### `OpenApiMiddleware.validPath([definition [, pathOpts]])` 170 | 171 | Registers a path with the OpenAPI document, also ensures incoming requests are valid against the schema. The path 172 | `definition` is an [`OperationObject`](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#operationObject) 173 | with all of the information about the requests and responses on that route. It returns a middleware function which 174 | can be used in an express app and will call `next(err) if the incoming request is invalid. 175 | 176 | The error is created with (`http-errors`)[https://www.npmjs.com/package/http-errors], and then is augmented with 177 | information about the schema and validation errors. Validation uses (`avj`)[https://www.npmjs.com/package/ajv], 178 | and `err.validationErrors` is the format exposed by that package. Pass { keywords: [] } as pathOpts to support custom validation based on [ajv-keywords](https://www.npmjs.com/package/ajv-keywords). 179 | 180 | **Example:** 181 | 182 | ```javascript 183 | app.get('/:foo', oapi.validPath({ 184 | description: 'Get a foo', 185 | responses: { 186 | 200: { 187 | content: { 188 | 'application/json': { 189 | schema: { 190 | type: 'object', 191 | properties: { 192 | foo: { type: 'string' } 193 | } 194 | } 195 | } 196 | } 197 | }, 198 | 400: { 199 | content: { 200 | 'application/json': { 201 | schema: { 202 | type: 'object', 203 | properties: { 204 | error: { type: 'string' } 205 | } 206 | } 207 | } 208 | } 209 | } 210 | } 211 | }), (err, req, res, next) => { 212 | res.status(err.status).json({ 213 | error: err.message, 214 | validation: err.validationErrors, 215 | schema: err.validationSchema 216 | }) 217 | }) 218 | 219 | app.get('/zoom', oapi.validPath({ 220 | ... 221 | requestBody: { 222 | required: true, 223 | content: { 224 | 'application/json': { 225 | schema: { 226 | type: 'object', 227 | properties: { 228 | name: { type: 'string', not: { regexp: '/^[A-Z]/' } } 229 | } 230 | } 231 | } 232 | } 233 | }, 234 | ... 235 | }, { keywords: ['regexp'] }), (err, req, res, next) => { 236 | res.status(err.status).json({ 237 | error: err.message, 238 | validation: err.validationErrors, 239 | schema: err.validationSchema 240 | }) 241 | }) 242 | ``` 243 | 244 | ### `OpenApiMiddleware.component(type[, name[, definition]])` 245 | 246 | Defines a new [`Component`](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#components-object) 247 | on the document. 248 | 249 | **Example:** 250 | 251 | ```javascript 252 | oapi.component('examples', 'FooExample', { 253 | summary: 'An example of foo', 254 | value: 'bar' 255 | }) 256 | ``` 257 | 258 | If neither `definition` nor `name` are passed, the function will return the full `components` json. 259 | 260 | **Example:** 261 | 262 | ```javascript 263 | oapi.component('examples', FooExample) 264 | // { '$ref': '#/components/examples/FooExample' } 265 | ``` 266 | 267 | If `name` is defined but `definition` is not, it will return a 268 | [`Reference Object`](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#referenceObject) 269 | pointing to the component by that name. 270 | 271 | **Example:** 272 | 273 | ```javascript 274 | oapi.component('examples') 275 | // { summary: 'An example of foo', value: 'bar' } 276 | ``` 277 | 278 | #### `OpenApiMiddleware.schema(name[, definition])` 279 | #### `OpenApiMiddleware.response(name[, definition])` 280 | #### `OpenApiMiddleware.parameters(name[, definition])` 281 | #### `OpenApiMiddleware.examples(name[, definition])` 282 | #### `OpenApiMiddleware.requestBodies(name[, definition])` 283 | #### `OpenApiMiddleware.headers(name[, definition])` 284 | #### `OpenApiMiddleware.securitySchemes(name[, definition])` 285 | #### `OpenApiMiddleware.links(name[, definition])` 286 | #### `OpenApiMiddleware.callbacks(name[, definition])` 287 | 288 | There are special component middleware for all of the types of component defined in the 289 | [OpenAPI spec](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#fixed-fields-6). 290 | Each of which is just the `component` method with a bound type, and behave with the same variadic behavior. 291 | 292 | ### `OpenApiMiddleware.swaggerui()` 293 | 294 | Serve an interactive UI for exploring the OpenAPI document. 295 | 296 | [SwaggerUI](https://www.npmjs.com/package/swagger-ui) is one of the most popular tools for viewing OpenAPI documents and are bundled with the middleware. 297 | The UI is not turned on by default but can be with the option mentioned above or by using one 298 | of these middleware. Both interactive UIs also accept an optional object as a function argument which accepts configuration parameters for Swagger and Redoc. The full list of Swagger and Redoc configuration options can be found here: https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/ and here: https://redocly.com/docs/redoc/config/ respectively. 299 | 300 | **Example:** 301 | 302 | ```javascript 303 | app.use('/swaggerui', oapi.swaggerui()) 304 | ``` 305 | -------------------------------------------------------------------------------- /test/_regexRoutes.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { suite, test } = require('mocha') 3 | const assert = require('assert') 4 | const supertest = require('supertest') 5 | const express = require('express') 6 | const openapi = require('..') 7 | 8 | module.exports = function () { 9 | suite('regex routes', function () { 10 | test('serve routes with a * wildcard', function (done) { 11 | const app = express() 12 | 13 | const oapi = openapi() 14 | app.use(oapi) 15 | app.get('/route/:param*', oapi.path({ 16 | summary: 'Test route.', 17 | responses: { 18 | 200: { 19 | content: { 20 | 'application/json': { 21 | schema: { 22 | type: 'string' 23 | } 24 | } 25 | } 26 | } 27 | } 28 | })) 29 | 30 | supertest(app) 31 | .get(`${openapi.defaultRoutePrefix}.json`) 32 | .expect(200, (err, res) => { 33 | assert(!err, err) 34 | assert.strictEqual(Object.keys((res.body.paths))[0], '/route/{param}') 35 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters.length, 2) 36 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters[0].in, 'path') 37 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters[0].name, 'param') 38 | done() 39 | }) 40 | }) 41 | 42 | test('serve routes with a * wildcard in parentheses', function (done) { 43 | const app = express() 44 | 45 | const oapi = openapi() 46 | app.use(oapi) 47 | app.get('/route/:param(*)', oapi.path({ 48 | summary: 'Test route.', 49 | responses: { 50 | 200: { 51 | content: { 52 | 'application/json': { 53 | schema: { 54 | type: 'string' 55 | } 56 | } 57 | } 58 | } 59 | } 60 | })) 61 | 62 | supertest(app) 63 | .get(`${openapi.defaultRoutePrefix}.json`) 64 | .expect(200, (err, res) => { 65 | assert(!err, err) 66 | assert.strictEqual(Object.keys((res.body.paths))[0], '/route/{param}') 67 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters.length, 1) 68 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters[0].in, 'path') 69 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters[0].name, 'param') 70 | done() 71 | }) 72 | }) 73 | 74 | test('serve routes in an array as different routes when one route has a * wildcard', function (done) { 75 | const app = express() 76 | 77 | const oapi = openapi() 78 | app.use(oapi) 79 | app.get(['/route/:param*', '/route/b', '/routeC'], oapi.path({ 80 | summary: 'Test route.', 81 | responses: { 82 | 200: { 83 | content: { 84 | 'application/json': { 85 | schema: { 86 | type: 'string' 87 | } 88 | } 89 | } 90 | } 91 | } 92 | })) 93 | 94 | supertest(app) 95 | .get(`${openapi.defaultRoutePrefix}.json`) 96 | .expect(200, (err, res) => { 97 | assert(!err, err) 98 | assert.strictEqual(Object.keys((res.body.paths))[0], '/route/{param}') 99 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters.length, 1) 100 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters[0].in, 'path') 101 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters[0].name, 'param') 102 | assert.strictEqual(Object.keys((res.body.paths))[1], '/route/b') 103 | assert.strictEqual(Object.keys((res.body.paths))[2], '/routeC') 104 | done() 105 | }) 106 | }) 107 | 108 | test('serve route with param and a * wildcard', function (done) { 109 | const app = express() 110 | 111 | const oapi = openapi() 112 | app.use(oapi) 113 | app.get('/route/:param/desc/*', oapi.path({ 114 | summary: 'Test route.', 115 | responses: { 116 | 200: { 117 | content: { 118 | 'application/json': { 119 | schema: { 120 | type: 'string' 121 | } 122 | } 123 | } 124 | } 125 | } 126 | })) 127 | 128 | supertest(app) 129 | .get(`${openapi.defaultRoutePrefix}.json`) 130 | .expect(200, (err, res) => { 131 | assert(!err, err) 132 | assert.strictEqual(Object.keys((res.body.paths))[0], '/route/{param}/desc/{0}') 133 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters.length, 2) 134 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters[0].name, 'param') 135 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters[1].name, 0) 136 | done() 137 | }) 138 | }) 139 | 140 | test('serve routes with only a * wildcard', function (done) { 141 | const app = express() 142 | 143 | const oapi = openapi() 144 | app.use(oapi) 145 | app.get('/*', oapi.path({ 146 | summary: 'Test route.', 147 | responses: { 148 | 200: { 149 | content: { 150 | 'application/json': { 151 | schema: { 152 | type: 'string' 153 | } 154 | } 155 | } 156 | } 157 | } 158 | })) 159 | 160 | supertest(app) 161 | .get(`${openapi.defaultRoutePrefix}.json`) 162 | .expect(200, (err, res) => { 163 | assert(!err, err) 164 | assert.strictEqual(Object.keys((res.body.paths))[0], '/{0}') 165 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters.length, 1) 166 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters[0].name, 0) 167 | done() 168 | }) 169 | }) 170 | 171 | test('serve routes with a * wildcard parameter and a named parameter', function (done) { 172 | const app = express() 173 | 174 | const oapi = openapi() 175 | app.use(oapi) 176 | app.get('/route/*/:param', oapi.path({ 177 | summary: 'Test route.', 178 | responses: { 179 | 200: { 180 | content: { 181 | 'application/json': { 182 | schema: { 183 | type: 'string' 184 | } 185 | } 186 | } 187 | } 188 | } 189 | })) 190 | 191 | supertest(app) 192 | .get(`${openapi.defaultRoutePrefix}.json`) 193 | .expect(200, (err, res) => { 194 | assert(!err, err) 195 | assert.strictEqual(Object.keys((res.body.paths))[0], '/route/{0}/{param}') 196 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters.length, 2) 197 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters[0].name, 0) 198 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters[1].name, 'param') 199 | done() 200 | }) 201 | }) 202 | 203 | test('serve routes with two parameters and a hardcoded desc then a wildcard parameter', function (done) { 204 | const app = express() 205 | 206 | const oapi = openapi() 207 | app.use(oapi) 208 | app.get('/route/:paramA/:paramB/desc/*', oapi.path({ 209 | summary: 'Test route.', 210 | responses: { 211 | 200: { 212 | content: { 213 | 'application/json': { 214 | schema: { 215 | type: 'string' 216 | } 217 | } 218 | } 219 | } 220 | } 221 | })) 222 | 223 | supertest(app) 224 | .get(`${openapi.defaultRoutePrefix}.json`) 225 | .expect(200, (err, res) => { 226 | assert(!err, err) 227 | assert.strictEqual(Object.keys((res.body.paths))[0], '/route/{paramA}/{paramB}/desc/{0}') 228 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters.length, 3) 229 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters[0].name, 'paramA') 230 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters[1].name, 'paramB') 231 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters[2].name, 0) 232 | done() 233 | }) 234 | }) 235 | 236 | test('serve routes with two parameters and a wildcard parameter', function (done) { 237 | const app = express() 238 | 239 | const oapi = openapi() 240 | app.use(oapi) 241 | app.get('/route/:paramA/:paramB/*', oapi.path({ 242 | summary: 'Test route.', 243 | responses: { 244 | 200: { 245 | content: { 246 | 'application/json': { 247 | schema: { 248 | type: 'string' 249 | } 250 | } 251 | } 252 | } 253 | } 254 | })) 255 | 256 | supertest(app) 257 | .get(`${openapi.defaultRoutePrefix}.json`) 258 | .expect(200, (err, res) => { 259 | assert(!err, err) 260 | assert.strictEqual(Object.keys((res.body.paths))[0], '/route/{paramA}/{paramB}/{0}') 261 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters.length, 3) 262 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters[0].name, 'paramA') 263 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters[1].name, 'paramB') 264 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters[2].name, 0) 265 | done() 266 | }) 267 | }) 268 | 269 | test('serve routes with a parameter and another named, grouped, wildcard parameter', function (done) { 270 | const app = express() 271 | 272 | const oapi = openapi() 273 | app.use(oapi) 274 | app.get('/route/:paramA/:paramB(*)', oapi.path({ 275 | summary: 'Test route.', 276 | responses: { 277 | 200: { 278 | content: { 279 | 'application/json': { 280 | schema: { 281 | type: 'string' 282 | } 283 | } 284 | } 285 | } 286 | } 287 | })) 288 | 289 | supertest(app) 290 | .get(`${openapi.defaultRoutePrefix}.json`) 291 | .expect(200, (err, res) => { 292 | assert(!err, err) 293 | assert.strictEqual(Object.keys((res.body.paths))[0], '/route/{paramA}/{paramB}') 294 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters.length, 2) 295 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters[0].name, 'paramA') 296 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters[1].name, 'paramB') 297 | done() 298 | }) 299 | }) 300 | 301 | test('serve multiple routes with a parameter and another named, grouped, wildcard parameter', function (done) { 302 | const app = express() 303 | 304 | const oapi = openapi() 305 | app.use(oapi) 306 | app.get(['/route/:paramA/:paramB(*)', '/cars/:paramA/:paramB(*)'], oapi.path({ 307 | summary: 'Test route.', 308 | responses: { 309 | 200: { 310 | content: { 311 | 'application/json': { 312 | schema: { 313 | type: 'string' 314 | } 315 | } 316 | } 317 | } 318 | } 319 | })) 320 | 321 | supertest(app) 322 | .get(`${openapi.defaultRoutePrefix}.json`) 323 | .expect(200, (err, res) => { 324 | assert(!err, err) 325 | assert.strictEqual(Object.keys((res.body.paths))[0], '/route/{paramA}/{paramB}') 326 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters.length, 4) 327 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters[0].name, 'paramA') 328 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters[1].name, 'paramB') 329 | done() 330 | }) 331 | }) 332 | 333 | test('serve routes with a parameter and another named wildcard parameter', function (done) { 334 | const app = express() 335 | 336 | const oapi = openapi() 337 | app.use(oapi) 338 | app.get('/route/:paramA/:paramB*', oapi.path({ 339 | summary: 'Test route.', 340 | responses: { 341 | 200: { 342 | content: { 343 | 'application/json': { 344 | schema: { 345 | type: 'string' 346 | } 347 | } 348 | } 349 | } 350 | } 351 | })) 352 | 353 | supertest(app) 354 | .get(`${openapi.defaultRoutePrefix}.json`) 355 | .expect(200, (err, res) => { 356 | assert(!err, err) 357 | assert.strictEqual(Object.keys((res.body.paths))[0], '/route/{paramA}/{paramB}') 358 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters.length, 3) 359 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters[0].name, 'paramA') 360 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters[1].name, 'paramB') 361 | done() 362 | }) 363 | }) 364 | 365 | test('serve routes with a parameter and a * wildcard', function (done) { 366 | const app = express() 367 | 368 | const oapi = openapi() 369 | app.use(oapi) 370 | app.get('/route/:param/*', oapi.path({ 371 | summary: 'Test route.', 372 | responses: { 373 | 200: { 374 | content: { 375 | 'application/json': { 376 | schema: { 377 | type: 'string' 378 | } 379 | } 380 | } 381 | } 382 | } 383 | })) 384 | 385 | supertest(app) 386 | .get(`${openapi.defaultRoutePrefix}.json`) 387 | .expect(200, (err, res) => { 388 | assert(!err, err) 389 | assert.strictEqual(Object.keys((res.body.paths))[0], '/route/{param}/{0}') 390 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters.length, 2) 391 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters[1].name, 0) 392 | assert.strictEqual(res.body.paths[Object.keys((res.body.paths))[0]].get.parameters[0].name, 'param') 393 | done() 394 | }) 395 | }) 396 | }) 397 | } 398 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { suite, test } = require('mocha') 3 | const assert = require('assert') 4 | const util = require('util') 5 | const supertest = require('supertest') 6 | const SwaggerParser = require('swagger-parser') 7 | const openapi = require('../') 8 | const { name } = require('../package.json') 9 | const YAML = require('yaml') 10 | 11 | // We support testing with different major versions of express 12 | let spec = 'express' 13 | if (process.env.EXPRESS_MAJOR) { 14 | if (!['4', '5'].includes(process.env.EXPRESS_MAJOR)) { 15 | throw new Error('EXPRESS_MAJOR contained an invalid value') 16 | } 17 | spec += process.env.EXPRESS_MAJOR 18 | } 19 | const express = require(spec) 20 | 21 | function logDocument (doc) { 22 | console.log(util.inspect(doc, { depth: null })) 23 | } 24 | 25 | suite(name, function () { 26 | test('accept no options', function () { 27 | const oapi = openapi() 28 | assert.strictEqual(oapi.routePrefix, openapi.defaultRoutePrefix) 29 | assert.deepStrictEqual(oapi.document, openapi.minimumViableDocument) 30 | }) 31 | 32 | test('accept no document option', function () { 33 | const oapi = openapi('/test') 34 | assert.strictEqual(oapi.routePrefix, '/test') 35 | assert.deepStrictEqual(oapi.document, openapi.minimumViableDocument) 36 | }) 37 | 38 | test('accept doc w/o path or opts', function () { 39 | const oapi = openapi({ 40 | info: { 41 | version: '1.0.0', 42 | title: '@express/openapi' 43 | } 44 | }) 45 | assert.strictEqual(oapi.routePrefix, '/openapi') 46 | assert.deepStrictEqual(oapi.document.info.title, '@express/openapi') 47 | assert.deepStrictEqual(oapi.options, {}) 48 | }) 49 | 50 | test('accept both a routePrefix and a document', function () { 51 | const oapi = openapi('/test', { 52 | info: { 53 | title: 'Test App' 54 | } 55 | }) 56 | assert.strictEqual(oapi.routePrefix, '/test') 57 | assert.deepStrictEqual(oapi.document, { 58 | openapi: '3.0.0', 59 | info: { 60 | title: 'Test App', 61 | version: '1.0.0' 62 | }, 63 | paths: {} 64 | }) 65 | }) 66 | 67 | test('create a basic valid OpenAPI document and serve test json on an express app', function (done) { 68 | const app = express() 69 | app.use(openapi()) 70 | supertest(app) 71 | .get(`${openapi.defaultRoutePrefix}.json`) 72 | .expect(200, (err, res) => { 73 | assert(!err, err) 74 | SwaggerParser.validate(res.body, (err, api) => { 75 | assert(!err, err) 76 | assert.deepStrictEqual(api, openapi.minimumViableDocument) 77 | done() 78 | }) 79 | }) 80 | }) 81 | 82 | test('create a basic valid OpenAPI document and serve test yaml on an express app', function (done) { 83 | const app = express() 84 | app.use(openapi()) 85 | 86 | supertest(app) 87 | .get(`${openapi.defaultRoutePrefix}.yaml`) 88 | .expect(200, (err, res) => { 89 | assert(!err, err) 90 | const output = YAML.parse(res.text) 91 | SwaggerParser.validate(output, (err, api) => { 92 | assert(!err, err) 93 | assert.deepStrictEqual(api, openapi.minimumViableDocument) 94 | done() 95 | }) 96 | }) 97 | }) 98 | 99 | test('create a basic valid OpenAPI document and serve test yml on an express app', function (done) { 100 | const app = express() 101 | app.use(openapi()) 102 | 103 | supertest(app) 104 | .get(`${openapi.defaultRoutePrefix}.yml`) 105 | .expect(200, (err, res) => { 106 | assert(!err, err) 107 | const output = YAML.parse(res.text) 108 | SwaggerParser.validate(output, (err, api) => { 109 | assert(!err, err) 110 | assert.deepStrictEqual(api, openapi.minimumViableDocument) 111 | done() 112 | }) 113 | }) 114 | }) 115 | 116 | test('create a basic valid Swagger UI document and check the HTML title', function (done) { 117 | const app = express() 118 | app.use(openapi().swaggerui()) 119 | supertest(app) 120 | .get(`${openapi.defaultRoutePrefix}.json`) 121 | .end((err, res) => { 122 | assert(!err, err) 123 | assert(res.text.includes('Swagger UI')) 124 | done() 125 | }) 126 | }) 127 | 128 | test('serves onload function in swagger-ui-init.js file', function (done) { 129 | const app = express() 130 | app.use(openapi().swaggerui()) 131 | supertest(app) 132 | .get(`${openapi.defaultRoutePrefix}/swagger-ui-init.js`) 133 | .end((err, res) => { 134 | assert(!err, err) 135 | assert(res.text.includes('window.onload = function () {')) 136 | done() 137 | }) 138 | }) 139 | 140 | test('load routes from the express app', function (done) { 141 | const app = express() 142 | const oapi = openapi() 143 | 144 | const helloWorldSchema = oapi.path({ 145 | responses: { 146 | 200: { 147 | description: 'Successful response', 148 | content: { 149 | 'application/json': { 150 | schema: { 151 | type: 'object', 152 | properties: { 153 | hello: { type: 'string' } 154 | } 155 | } 156 | } 157 | } 158 | } 159 | } 160 | }) 161 | 162 | app.use(oapi) 163 | app.get('/foo', helloWorldSchema, (req, res) => { 164 | res.json({ 165 | hello: 'world' 166 | }) 167 | }) 168 | 169 | supertest(app) 170 | .get(`${openapi.defaultRoutePrefix}.json`) 171 | .expect(200, (err, res) => { 172 | assert(!err, err) 173 | SwaggerParser.validate(res.body, (err, api) => { 174 | assert(!err, err) 175 | done() 176 | }) 177 | }) 178 | }) 179 | 180 | test('support express array formats', (done) => { 181 | const app = express() 182 | const oapi = openapi() 183 | 184 | const emptySchema = oapi.path({ 185 | responses: { 186 | 204: { 187 | description: 'Successful response', 188 | content: { 189 | 'application/json': { } 190 | } 191 | } 192 | } 193 | }) 194 | 195 | app.use(oapi) 196 | app.get('/undocumented', (req, res) => { 197 | res.status(204).send() 198 | }) 199 | app.get('/array', [emptySchema, (req, res) => { 200 | res.status(204).send() 201 | }]) 202 | app.get('/array-of-arrays', [[emptySchema, (req, res) => { 203 | res.status(204).send() 204 | }]]) 205 | 206 | supertest(app) 207 | .get(`${openapi.defaultRoutePrefix}.json`) 208 | .expect(200, (err, res) => { 209 | assert(!err, err) 210 | SwaggerParser.validate(res.body, (err, api) => { 211 | if (err) { 212 | logDocument(api) 213 | done(err) 214 | } 215 | 216 | assert(api.paths['/array']) 217 | assert(api.paths['/array'].get) 218 | assert(api.paths['/array'].get.responses[204]) 219 | assert.strictEqual(api.paths['/array'].get.responses[204].description, 'Successful response') 220 | 221 | assert(api.paths['/array-of-arrays']) 222 | assert(api.paths['/array-of-arrays'].get) 223 | assert(api.paths['/array-of-arrays'].get.responses[204]) 224 | assert.strictEqual(api.paths['/array-of-arrays'].get.responses[204].description, 'Successful response') 225 | 226 | assert(!api.paths['/undocumented']) 227 | 228 | done() 229 | }) 230 | }) 231 | }) 232 | 233 | test('support express route syntax', (done) => { 234 | const app = express() 235 | const oapi = openapi() 236 | 237 | const emptySchema = oapi.path({ 238 | responses: { 239 | 204: { 240 | description: 'Successful response', 241 | content: { 242 | 'application/json': { } 243 | } 244 | } 245 | } 246 | }) 247 | 248 | app.use(oapi) 249 | app.route('/route') 250 | .get(emptySchema, (req, res) => { 251 | res.status(204).send() 252 | }) 253 | .put(emptySchema, (req, res) => { 254 | res.status(204).send() 255 | }) 256 | 257 | app.route('/route-all') 258 | .all(emptySchema) 259 | .all((req, res) => { 260 | res.status(204).send() 261 | }) 262 | 263 | supertest(app) 264 | .get(`${openapi.defaultRoutePrefix}.json`) 265 | .expect(200, (err, res) => { 266 | assert(!err, err) 267 | SwaggerParser.validate(res.body, (err, api) => { 268 | if (err) { 269 | logDocument(api) 270 | done(err) 271 | } 272 | 273 | assert(api.paths['/route']) 274 | assert(api.paths['/route'].get) 275 | assert(api.paths['/route'].get.responses[204]) 276 | assert.strictEqual(api.paths['/route'].get.responses[204].description, 'Successful response') 277 | 278 | assert(api.paths['/route'].put) 279 | assert(api.paths['/route'].put.responses[204]) 280 | assert.strictEqual(api.paths['/route'].put.responses[204].description, 'Successful response') 281 | 282 | done() 283 | }) 284 | }) 285 | }) 286 | 287 | test('support named path params', (done) => { 288 | const app = express() 289 | const oapi = openapi() 290 | 291 | const emptySchema = oapi.path({ 292 | responses: { 293 | 204: { 294 | description: 'Successful response', 295 | content: { 296 | 'application/json': { } 297 | } 298 | } 299 | } 300 | }) 301 | 302 | app.use(oapi) 303 | app.get('/:foo', emptySchema, (req, res) => { 304 | res.status(204).send() 305 | }) 306 | 307 | supertest(app) 308 | .get(`${openapi.defaultRoutePrefix}.json`) 309 | .expect(200, (err, res) => { 310 | assert(!err, err) 311 | SwaggerParser.validate(res.body, (err, api) => { 312 | if (err) { 313 | logDocument(api) 314 | done(err) 315 | } 316 | 317 | assert(api.paths['/{foo}']) 318 | assert(api.paths['/{foo}'].get) 319 | assert(api.paths['/{foo}'].get.parameters[0]) 320 | assert.strictEqual(api.paths['/{foo}'].get.parameters[0].name, 'foo') 321 | 322 | done() 323 | }) 324 | }) 325 | }) 326 | 327 | test('support parameter components', (done) => { 328 | const app = express() 329 | const oapi = openapi() 330 | 331 | oapi.parameters('id', { 332 | in: 'path', 333 | required: true, 334 | description: 'The entity id', 335 | schema: { type: 'string' } 336 | }) 337 | 338 | app.use(oapi) 339 | app.get('/:id', oapi.path({ 340 | description: 'Get thing by id', 341 | parameters: [oapi.parameters('id')], 342 | responses: { 343 | 204: { 344 | description: 'Successful response', 345 | content: { 346 | 'application/json': { } 347 | } 348 | } 349 | } 350 | }), (req, res) => { 351 | res.status(204).send() 352 | }) 353 | 354 | supertest(app) 355 | .get(`${openapi.defaultRoutePrefix}/validate`) 356 | .expect(200, (err, res) => { 357 | assert(!err, err) 358 | assert.strictEqual(res.body.valid, true) 359 | assert.strictEqual(res.body.document.components.parameters.id.name, 'id') 360 | assert.strictEqual(res.body.document.components.parameters.id.description, 'The entity id') 361 | assert.strictEqual(res.body.document.components.parameters.id.schema.type, 'string') 362 | assert.strictEqual(res.status, 200) 363 | done() 364 | }) 365 | }) 366 | 367 | test('support a non-string path parameter', (done) => { 368 | const app = express() 369 | const oapi = openapi() 370 | 371 | oapi.parameters('id', { 372 | in: 'path', 373 | required: true, 374 | description: 'The numeric User ID', 375 | schema: { type: 'integer' } 376 | }) 377 | 378 | app.use(oapi) 379 | app.get('/:id', oapi.path({ 380 | description: 'Get a user by ID', 381 | parameters: [oapi.parameters('id')], 382 | responses: { 383 | 204: { 384 | description: 'Successful response', 385 | content: { 386 | 'application/json': { } 387 | } 388 | } 389 | } 390 | }), (req, res) => { 391 | res.status(204).send() 392 | }) 393 | 394 | supertest(app) 395 | .get(`${openapi.defaultRoutePrefix}/validate`) 396 | .expect(200, (err, res) => { 397 | assert(!err, err) 398 | assert.strictEqual(res.body.valid, true) 399 | assert.strictEqual(res.body.document.components.parameters.id.name, 'id') 400 | assert.strictEqual(res.body.document.components.parameters.id.description, 'The numeric User ID') 401 | assert.strictEqual(res.body.document.components.parameters.id.schema.type, 'integer') 402 | assert.strictEqual(res.status, 200) 403 | done() 404 | }) 405 | }) 406 | 407 | test('support express sub-routes with Router', (done) => { 408 | const app = express() 409 | const router = express.Router() 410 | const oapi = openapi() 411 | 412 | const emptySchema = oapi.path({ 413 | responses: { 414 | 204: { 415 | description: 'Successful response', 416 | content: { 417 | 'application/json': {} 418 | } 419 | } 420 | } 421 | }) 422 | 423 | router.get('/endpoint', emptySchema, (req, res) => { 424 | res.status(204).send() 425 | }) 426 | 427 | app.use(oapi) 428 | app.use('/sub-route', router) 429 | 430 | supertest(app) 431 | .get(`${openapi.defaultRoutePrefix}.json`) 432 | .expect(200, (err, res) => { 433 | assert(!err, err) 434 | SwaggerParser.validate(res.body, (err, api) => { 435 | if (err) { 436 | logDocument(api) 437 | 438 | done(err) 439 | } 440 | 441 | assert(api.paths['/sub-route/endpoint']) 442 | assert(api.paths['/sub-route/endpoint'].get) 443 | assert(api.paths['/sub-route/endpoint'].get.responses[204]) 444 | assert.strictEqual( 445 | api.paths['/sub-route/endpoint'].get.responses[204].description, 446 | 'Successful response' 447 | ) 448 | 449 | done() 450 | }) 451 | }) 452 | }) 453 | 454 | test('support express nested sub-routes with Router', (done) => { 455 | const app = express() 456 | const router = express.Router() 457 | const subrouter = express.Router() 458 | const oapi = openapi() 459 | 460 | const emptySchema = oapi.path({ 461 | responses: { 462 | 204: { 463 | description: 'Successful response', 464 | content: { 465 | 'application/json': {} 466 | } 467 | } 468 | } 469 | }) 470 | 471 | subrouter.get('/endpoint', emptySchema, (req, res) => { 472 | res.status(204).send() 473 | }) 474 | 475 | app.use(oapi) 476 | app.use('/sub-route', router) 477 | router.use('/sub-sub-route', subrouter) 478 | 479 | supertest(app) 480 | .get(`${openapi.defaultRoutePrefix}.json`) 481 | .expect(200, (err, res) => { 482 | assert(!err, err) 483 | SwaggerParser.validate(res.body, (err, api) => { 484 | if (err) { 485 | logDocument(api) 486 | 487 | done(err) 488 | } 489 | 490 | assert(api.paths['/sub-route/sub-sub-route/endpoint']) 491 | assert(api.paths['/sub-route/sub-sub-route/endpoint'].get) 492 | assert(api.paths['/sub-route/sub-sub-route/endpoint'].get.responses[204]) 493 | assert.strictEqual( 494 | api.paths['/sub-route/sub-sub-route/endpoint'].get.responses[204].description, 495 | 'Successful response' 496 | ) 497 | 498 | done() 499 | }) 500 | }) 501 | }) 502 | 503 | test('support express nested sub-routes with base path', (done) => { 504 | const app = express() 505 | const router = express.Router() 506 | const subrouter = express.Router() 507 | const oapi = openapi(undefined, { basePath: '/sub-route' }) 508 | 509 | const emptySchema = oapi.path({ 510 | responses: { 511 | 204: { 512 | description: 'Successful response', 513 | content: { 514 | 'application/json': {} 515 | } 516 | } 517 | } 518 | }) 519 | 520 | subrouter.get('/endpoint', emptySchema, (req, res) => { 521 | res.status(204).send() 522 | }) 523 | 524 | app.use(oapi) 525 | app.use('/sub-route', router) 526 | router.use('/sub-sub-route', subrouter) 527 | 528 | supertest(app) 529 | .get(`${openapi.defaultRoutePrefix}.json`) 530 | .expect(200, (err, res) => { 531 | assert(!err, err) 532 | SwaggerParser.validate(res.body, (err, api) => { 533 | if (err) { 534 | logDocument(api) 535 | 536 | done(err) 537 | } 538 | 539 | assert(Object.keys(api.paths).length === 1) 540 | 541 | assert(api.paths['/sub-sub-route/endpoint']) 542 | assert(api.paths['/sub-sub-route/endpoint'].get) 543 | assert(api.paths['/sub-sub-route/endpoint'].get.responses[204]) 544 | assert.strictEqual( 545 | api.paths['/sub-sub-route/endpoint'].get.responses[204].description, 546 | 'Successful response' 547 | ) 548 | 549 | done() 550 | }) 551 | }) 552 | }) 553 | 554 | test('sub-routes should only be stripped once', (done) => { 555 | const app = express() 556 | const router = express.Router() 557 | const subrouter = express.Router() 558 | const oapi = openapi(undefined, { basePath: '/sub-route' }) 559 | 560 | const emptySchema = oapi.path({ 561 | responses: { 562 | 204: { 563 | description: 'Successful response', 564 | content: { 565 | 'application/json': {} 566 | } 567 | } 568 | } 569 | }) 570 | 571 | subrouter.get('/endpoint', emptySchema, (req, res) => { 572 | res.status(204).send() 573 | }) 574 | 575 | app.use(oapi) 576 | app.use('/sub-route', router) 577 | router.use('/sub-route', subrouter) 578 | 579 | supertest(app) 580 | .get(`${openapi.defaultRoutePrefix}.json`) 581 | .expect(200, (err, res) => { 582 | assert(!err, err) 583 | SwaggerParser.validate(res.body, (err, api) => { 584 | if (err) { 585 | logDocument(api) 586 | 587 | done(err) 588 | } 589 | 590 | assert(Object.keys(api.paths).length === 1) 591 | 592 | assert(api.paths['/sub-route/endpoint']) 593 | assert(api.paths['/sub-route/endpoint'].get) 594 | assert(api.paths['/sub-route/endpoint'].get.responses[204]) 595 | assert.strictEqual( 596 | api.paths['/sub-route/endpoint'].get.responses[204].description, 597 | 'Successful response' 598 | ) 599 | 600 | done() 601 | }) 602 | }) 603 | }) 604 | 605 | test('the basePath option should only strip the base path from the start of paths', (done) => { 606 | const app = express() 607 | const router = express.Router() 608 | const subrouter = express.Router() 609 | const oapi = openapi(undefined, { basePath: '/base-path' }) 610 | 611 | const emptySchema = oapi.path({ 612 | responses: { 613 | 204: { 614 | description: 'Successful response', 615 | content: { 616 | 'application/json': {} 617 | } 618 | } 619 | } 620 | }) 621 | 622 | subrouter.get('/endpoint', emptySchema, (req, res) => { 623 | res.status(204).send() 624 | }) 625 | 626 | app.use(oapi) 627 | app.use('/route', router) 628 | router.use('/base-path', subrouter) 629 | 630 | supertest(app) 631 | .get(`${openapi.defaultRoutePrefix}.json`) 632 | .expect(200, (err, res) => { 633 | assert(!err, err) 634 | SwaggerParser.validate(res.body, (err, api) => { 635 | if (err) { 636 | logDocument(api) 637 | 638 | done(err) 639 | } 640 | 641 | assert(Object.keys(api.paths).length === 1) 642 | 643 | assert(api.paths['/route/base-path/endpoint']) 644 | assert(api.paths['/route/base-path/endpoint'].get) 645 | assert(api.paths['/route/base-path/endpoint'].get.responses[204]) 646 | assert.strictEqual( 647 | api.paths['/route/base-path/endpoint'].get.responses[204].description, 648 | 'Successful response' 649 | ) 650 | 651 | done() 652 | }) 653 | }) 654 | }) 655 | 656 | // Other tests 657 | require('./_validate')() 658 | require('./_regexRoutes')() 659 | require('./_routes')() 660 | }) 661 | --------------------------------------------------------------------------------