Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Release/v1.0.0 #54

Merged
merged 8 commits into from
Feb 28, 2024
Merged
Show file tree
Hide file tree
Changes from 5 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
29 changes: 8 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,6 @@ into an OpenAPI document. Optionally you can also flesh out request and respons
other parts of your api spec with path specific middleware. The final document will be exposed as json
served by the main middleware (along with component specific documents).

## Note on package name

This package documents itself as `@express/openapi`. This is because we (the Express TC) have been discussing
adopting the npm scope for publishing "core maintained" middleware modules. This is one such middleware.
While we are working out the details of this I am publishing this module under my personal scope. When
that is resolved we will move it over to the main scope and I will deprecate this module.

Install & usage step for now: `$ npm i @wesleytodd/openapi` & `const openapi = require('@wesleytodd/openapi')`

## Philosophy

It is common in the OpenAPI community to talk about generating code from documentation. There is value
Expand All @@ -32,13 +23,13 @@ both write great code, as well as have great documentation!
## Installation

```
$ npm install --save @express/openapi
$ npm install --save @wesleytodd/openapi
```

## Usage

```javascript
const openapi = require('@express/openapi')
const openapi = require('@wesleytodd/openapi')
const app = require('express')()

const oapi = openapi({
Expand All @@ -51,7 +42,7 @@ const oapi = openapi({
})

// This will serve the generated json document(s)
// (as well as swagger-ui or redoc if configured)
// (as well as the swagger-ui if configured)
app.use(oapi)

// To add path specific schema you can use the .path middleware
Expand Down Expand Up @@ -130,9 +121,8 @@ Options:
- `document <object>`: Base document on top of which the paths will be added
- `options <object>`: Options object
- `options.coerce`: Enable data type [`coercion`](https://www.npmjs.com/package/ajv#coercing-data-types)
- `options.htmlui`: Turn on serving `redoc` or `swagger-ui` html ui
- `options.htmlui`: Turn on serving `swagger-ui` html ui
- `options.basePath`: When set, will strip the value of `basePath` from the start of every path.
The options object can also accept 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.

##### Coerce

Expand Down Expand Up @@ -275,19 +265,16 @@ There are special component middleware for all of the types of component defined
[OpenAPI spec](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#fixed-fields-6).
Each of which is just the `component` method with a bound type, and behave with the same variadic behavior.

### `OpenApiMiddleware.redoc()`
### `OpenApiMiddleware.swaggerui()`

Serve an interactive UI for exploring the OpenAPI document.

[Redoc](https://github.com/Rebilly/ReDoc/) and [SwaggerUI](https://www.npmjs.com/package/swagger-ui) are
two of the most popular tools for viewing OpenAPI documents and are bundled with the middleware.
They are not turned on by default but can be with the option mentioned above or by using one
of these middleware.
[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.
The UI is not turned on by default but can be with the option mentioned above or by using one
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.

**Example:**

```javascript
app.use('/redoc', oapi.redoc)
app.use('/swaggerui', oapi.swaggerui)
app.use('/swaggerui', oapi.swaggerui())
```
9 changes: 2 additions & 7 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,7 @@ module.exports = function ExpressOpenApi (_routePrefix, _doc, _opts) {
middleware.callbacks = middleware.component.bind(null, 'callbacks')

// Expose ui middleware
middleware.redoc = ui.serveRedoc(`${routePrefix}.json`, opts)
middleware.swaggerui = ui.serveSwaggerUI(`${routePrefix}.json`, opts)
middleware.swaggerui = (options) => ui.serveSwaggerUI(`${routePrefix}.json`, options)

// OpenAPI document as json
router.get(`${routePrefix}.json`, (req, res) => {
Expand Down Expand Up @@ -180,11 +179,7 @@ module.exports = function ExpressOpenApi (_routePrefix, _doc, _opts) {
if (opts.htmlui) {
let ui = opts.htmlui
if (!Array.isArray(opts.htmlui)) {
ui = [opts.htmlui || 'redoc']
}
if (ui.includes('redoc')) {
router.get(`${routePrefix}`, (req, res) => { res.redirect(`${routePrefix}/redoc`) })
router.use(`${routePrefix}/redoc`, middleware.redoc)
ui = [opts.htmlui]
}
if (ui.includes('swagger-ui')) {
router.get(`${routePrefix}`, (req, res) => { res.redirect(`${routePrefix}/swagger-ui`) })
Expand Down
35 changes: 32 additions & 3 deletions lib/generate-doc.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,17 +73,46 @@ function iterateStack (path, routeLayer, layer, cb) {
if (layer.name === 'router') {
layer.handle.stack.forEach(l => {
path = path || ''
iterateStack(path + split(layer.regexp).join('/'), layer, l, cb)
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
}
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('/')
}

// https://github.com/expressjs/express/issues/3308#issuecomment-300957572
function split (thing) {
function split (thing, keys) {
if (typeof thing === 'string') {
return thing.split('/')
} else if (thing.fast_slash) {
Expand All @@ -96,6 +125,6 @@ function split (thing) {
.match(/^\/\^((?:\\[.*+?^${}()|[\]\\/]|[^.*+?^${}()|[\]\\/])*)\$\//)
return match
? match[1].replace(/\\(.)/g, '$1').split('/')
: '<complex:' + thing.toString() + '>'
: processComplexMatch(thing, keys)
}
}
46 changes: 10 additions & 36 deletions lib/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,48 +2,22 @@
const path = require('path')
const serve = require('serve-static')

module.exports.serveRedoc = function serveRedoc (documentUrl, opts) {
const toKebabCase = (string) =>
string
.replace(/([a-z])([A-Z])/g, '$1-$2')
.replace(/[\s_]+/g, '-')
.toLowerCase()
const options = {}
Object.keys(opts).forEach((key) => {
if (!['coerce', 'htmlui', 'basePath'].includes(key)) {
options[toKebabCase(key)] = opts[key]
}
})

return [serve(path.resolve(require.resolve('redoc'), '..')), function renderRedocHtml (req, res) {
res.type('html').send(renderHtmlPage('ReDoc', `
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
`, `
<redoc spec-url="${documentUrl}" ${Object.keys(options).join(' ')}></redoc>
<script src="./redoc.standalone.js"></script>
`))
}]
}

module.exports.serveSwaggerUI = function serveSwaggerUI (documentUrl, opts) {
const options = {
url: documentUrl,
dom_id: '#swagger-ui'
}
Object.keys(opts).forEach((key) => {
if (!['coerce', 'htmlui', 'basePath'].includes(key)) {
options[key] = opts[key]
}
})
module.exports.serveSwaggerUI = function serveSwaggerUI (documentUrl, opts = {}) {
const { plugins, ...options } = opts

return [serve(path.resolve(require.resolve('swagger-ui-dist'), '..'), { index: false }),
function returnUiInit (req, res, next) {
if (req.path.endsWith('/swagger-ui-init.js')) {
res.type('.js')
res.send(`window.onload = function () {
window.ui = SwaggerUIBundle(${JSON.stringify(options, null, 2)})
}
`)
window.ui = SwaggerUIBundle({
url: '${documentUrl}',
dom_id: '#swagger-ui',
${plugins?.length ? `plugins: [${plugins}],` : ''}
...${JSON.stringify(options)}
})
}`
)
} else {
next()
}
Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@
"http-errors": "^2.0.0",
"merge-deep": "^3.0.2",
"path-to-regexp": "^6.2.1",
"redoc": "^2.0.0-alpha.41",
"router": "^1.3.3",
"serve-static": "^1.13.2",
"swagger-parser": "^10.0.3",
Expand Down
37 changes: 37 additions & 0 deletions test/_moreRoutes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
const router = require('express').Router({ mergeParams: true })
const openapi = require('..')

const oapi = openapi()
router.use(oapi)

router.get(
'/',
oapi.validPath({
summary: 'Get a user.',
parameters: [
{
in: 'path',
imageId: 'id',
schema: {
type: 'integer'
}
}
],
responses: {
200: {
content: {
'application/json': {
schema: {
type: 'string'
}
}
}
}
}
}),
async (req, res) => {
res.send('done')
}
)

module.exports = router
48 changes: 48 additions & 0 deletions test/_routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const supertest = require('supertest')
const express = require('express')
const SwaggerParser = require('swagger-parser')
const openapi = require('..')
const _moreRoutes = require('./_moreRoutes')

module.exports = function () {
suite('routes', function () {
Expand Down Expand Up @@ -77,5 +78,52 @@ module.exports = function () {
done()
})
})

test('serve routes in a different file', function (done) {
const app = express()

const oapi = openapi()
app.use(oapi)
app.use('/:id', _moreRoutes)

supertest(app)
.get(`${openapi.defaultRoutePrefix}.json`)
.expect(200, (err, res) => {
assert(!err, err)
assert.strictEqual(Object.keys((res.body.paths))[0], '/{id}/')
done()
})
})

test('serve routes in an array as different routes', function (done) {
const app = express()

const oapi = openapi()
app.use(oapi)
app.get(['/route/:a', '/route/b', '/routeC'], oapi.path({
summary: 'Test route.',
responses: {
200: {
content: {
'application/json': {
schema: {
type: 'string'
}
}
}
}
}
}))

supertest(app)
.get(`${openapi.defaultRoutePrefix}.json`)
.expect(200, (err, res) => {
assert(!err, err)
assert.strictEqual(Object.keys((res.body.paths))[0], '/route/{a}')
assert.strictEqual(Object.keys((res.body.paths))[1], '/route/b')
assert.strictEqual(Object.keys((res.body.paths))[2], '/routeC')
done()
})
})
})
}
16 changes: 2 additions & 14 deletions test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ suite(name, function () {

test('create a basic valid Swagger UI document and check the HTML title', function (done) {
const app = express()
app.use(openapi().swaggerui)
app.use(openapi().swaggerui())
supertest(app)
.get(`${openapi.defaultRoutePrefix}.json`)
.end((err, res) => {
Expand All @@ -118,7 +118,7 @@ suite(name, function () {

test('serves onload function in swagger-ui-init.js file', function (done) {
const app = express()
app.use(openapi().swaggerui)
app.use(openapi().swaggerui())
supertest(app)
.get(`${openapi.defaultRoutePrefix}/swagger-ui-init.js`)
.end((err, res) => {
Expand All @@ -128,18 +128,6 @@ suite(name, function () {
})
})

test('create a basic valid ReDoc document and check the HTML title', function (done) {
const app = express()
app.use(openapi().redoc)
supertest(app)
.get(`${openapi.defaultRoutePrefix}.json`)
.end((err, res) => {
assert(!err, err)
assert(res.text.includes('<title>ReDoc</title>'))
done()
})
})

test('load routes from the express app', function (done) {
const app = express()
const oapi = openapi()
Expand Down