diff --git a/README.md b/README.md index 156c380..347f179 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,54 @@ router.param('user_id', function (req, res, next, id) { }) ``` +### router.listRoutes() + +Returns an array with one `{ path, methods, router }` object per path +registered on this router, in registration order. + +- `path` is the path the route was registered with (a string or a `RegExp`). + Routes registered with an array of paths produce one entry per path. +- `methods` is an array of the uppercase HTTP method names the route responds + to, including the automatic `HEAD` for `GET` routes. It is `undefined` when + the entry matches all methods (routes registered only with `.all()`, and + mounted routers). Routes that combine `.all()` with specific methods list + those methods. An empty array means the route was created with + `router.route(path)` but has no handlers yet, so it matches no method. +- `router` is the mounted router instance for `.use(path, router)` entries, + otherwise `undefined`. Nested routes are not resolved recursively; consumers + can recurse themselves by calling `router.listRoutes()` when the mounted + router implements it. Note that `.use()` + entries match their path as a prefix, while route entries match the full + path. + +Plain middleware functions registered with `.use()` are not listed. + +```js +const router = new Router() +const admin = new Router() + +admin.get('/', (req, res) => { + res.end('Hello') +}) + +router.use('/admin', admin) + +router.all('/:id', function (req, res) { + res.end('Hello') +}) + +console.log(router.listRoutes()) +// [ +// { path: '/admin', methods: undefined, router: admin }, +// { path: '/:id', methods: undefined, router: undefined } +// ] + +console.log(admin.listRoutes()) +// [ +// { path: '/', methods: ['GET', 'HEAD'], router: undefined } +// ] +``` + ### router.route(path) Creates an instance of a single `Route` for the given `path`. diff --git a/index.js b/index.js index 4358aeb..a0bd2a2 100644 --- a/index.js +++ b/index.js @@ -441,6 +441,54 @@ Router.prototype.route = function route (path) { return route } +/** + * List the routes and mounted routers registered on this router. + * + * Returns one `{ path, methods, router }` object per registered path. + * `methods` lists the methods the route responds to, including the + * automatic `HEAD` for `GET` routes, and is `undefined` when the layer + * matches all methods (routes registered only with `.all()`, and + * mounted routers). Plain middleware is not listed. `router` is the + * mounted router instance for + * `.use(path, router)` layers, so consumers can recurse by calling + * `router.listRoutes()` themselves when it is available. + * + * @return {Array<{path: string|RegExp, methods: Array|undefined, router: Router|undefined}>} + * @public + */ +Router.prototype.listRoutes = function listRoutes () { + const routes = [] + + for (const layer of this.stack) { + const route = layer.route + const router = !route && Array.isArray(layer.handle.stack) + ? layer.handle + : undefined + + if (!route && !router) { + continue + } + + let methods + if (route) { + methods = route._methods() + if (methods.length === 0 && route.methods._all) { + methods = undefined + } + } + + if (Array.isArray(layer.rawPath)) { + for (const path of layer.rawPath) { + routes.push({ path, methods: methods && methods.slice(), router }) + } + } else { + routes.push({ path: layer.rawPath, methods, router }) + } + } + + return routes +} + // create Router#VERB functions methods.concat('all').forEach(function (method) { Router.prototype[method] = function (path) { diff --git a/lib/layer.js b/lib/layer.js index 6a4408f..4370e5a 100644 --- a/lib/layer.js +++ b/lib/layer.js @@ -44,6 +44,8 @@ function Layer (path, options, fn) { this.name = fn.name || '' this.params = undefined this.path = undefined + // flat unnests nested path arrays and snapshots the caller's array + this.rawPath = Array.isArray(path) ? path.flat(Infinity) : path this.slash = path === '/' && opts.end === false function matcher (_path) { diff --git a/lib/route.js b/lib/route.js index 1887d78..5e26960 100644 --- a/lib/route.js +++ b/lib/route.js @@ -74,7 +74,7 @@ Route.prototype._handlesMethod = function _handlesMethod (method) { */ Route.prototype._methods = function _methods () { - const methods = Object.keys(this.methods) + const methods = Object.keys(this.methods).filter((method) => method !== '_all') // append automatic head if (this.methods.get && !this.methods.head) { diff --git a/package.json b/package.json index e465e6c..e6be557 100644 --- a/package.json +++ b/package.json @@ -39,8 +39,8 @@ }, "scripts": { "lint": "standard", - "test": "mocha --reporter spec --bail --check-leaks test/", - "test:debug": "mocha --reporter spec --bail --check-leaks test/ --inspect --inspect-brk", + "test": "mocha --reporter spec --check-leaks test/", + "test:debug": "mocha --reporter spec --check-leaks test/ --inspect --inspect-brk", "test-ci": "nyc --reporter=lcov --reporter=text npm test", "test-cov": "nyc --reporter=text npm test", "version": "node scripts/version-history.js && git add HISTORY.md" diff --git a/test/listRoutes.js b/test/listRoutes.js new file mode 100644 index 0000000..509957e --- /dev/null +++ b/test/listRoutes.js @@ -0,0 +1,249 @@ +const { it, describe } = require('mocha') +const Router = require('..') +const utils = require('./support/utils') + +const assert = utils.assert + +describe('listRoutes', function () { + it('should return an empty array when no routes are registered', function () { + const router = new Router() + + assert.deepStrictEqual(router.listRoutes(), []) + }) + + it('should list routes for strings, regexps, arrays, and parameterized paths', function () { + const router = new Router() + + router.get('/foo', noop) + router.post('/:id/setting/:thing', noop) + router.all(/^\/[a-z]oo$/, noop) + router.get(['/bar', '/baz'], noop) + + assert.deepStrictEqual(router.listRoutes(), [ + { path: '/foo', methods: ['GET', 'HEAD'], router: undefined }, + { path: '/:id/setting/:thing', methods: ['POST'], router: undefined }, + { path: /^\/[a-z]oo$/, methods: undefined, router: undefined }, + { path: '/bar', methods: ['GET', 'HEAD'], router: undefined }, + { path: '/baz', methods: ['GET', 'HEAD'], router: undefined } + ]) + }) + + it('should include the automatic HEAD method for GET routes, but not duplicate an explicit one', function () { + const router = new Router() + + router.get('/implicit', noop) + router.route('/explicit').get(noop).head(noop) + + assert.deepStrictEqual(router.listRoutes(), [ + { path: '/implicit', methods: ['GET', 'HEAD'], router: undefined }, + { path: '/explicit', methods: ['GET', 'HEAD'], router: undefined } + ]) + }) + + it('should list all methods registered on a route', function () { + const router = new Router() + + router.route('/test') + .get(noop) + .post(noop) + .put(noop) + + assert.deepStrictEqual(router.listRoutes(), [ + { path: '/test', methods: ['GET', 'POST', 'PUT', 'HEAD'], router: undefined } + ]) + }) + + it('should return empty methods for routes created without handlers', function () { + const router = new Router() + + router.route('/draft') + + assert.deepStrictEqual(router.listRoutes(), [ + { path: '/draft', methods: [], router: undefined } + ]) + }) + + it('should return undefined methods for .all() routes', function () { + const router = new Router() + + router.all('/test', noop) + + assert.deepStrictEqual(router.listRoutes(), [ + { path: '/test', methods: undefined, router: undefined } + ]) + }) + + it('should keep the specific methods of routes that combine .all() with verbs', function () { + const router = new Router() + + router.route('/users') + .all(noop) + .get(noop) + .post(noop) + + assert.deepStrictEqual(router.listRoutes(), [ + { path: '/users', methods: ['GET', 'POST', 'HEAD'], router: undefined } + ]) + }) + + it('should repeat routes registered multiple times', function () { + const router = new Router() + + router.get('/test', noop) + router.get('/test', noop) + router.post('/test', noop) + + assert.deepStrictEqual(router.listRoutes(), [ + { path: '/test', methods: ['GET', 'HEAD'], router: undefined }, + { path: '/test', methods: ['GET', 'HEAD'], router: undefined }, + { path: '/test', methods: ['POST'], router: undefined } + ]) + }) + + it('should not share the methods array between entries of an array path', function () { + const router = new Router() + + router.get(['/bar', '/baz'], noop) + + const routes = router.listRoutes() + + routes[0].methods.push('POST') + + assert.deepStrictEqual(routes[1].methods, ['GET', 'HEAD']) + }) + + it('should not list plain middleware', function () { + const router = new Router() + + router.use(noop) + router.use('/admin', noop) + router.get('/test', noop) + + assert.deepStrictEqual(router.listRoutes(), [ + { path: '/test', methods: ['GET', 'HEAD'], router: undefined } + ]) + }) + + it('should flatten nested arrays of paths', function () { + const router = new Router() + const nested = ['/b'] + + router.get(['/a', nested], noop) + nested.push('/c') + + assert.deepStrictEqual(router.listRoutes(), [ + { path: '/a', methods: ['GET', 'HEAD'], router: undefined }, + { path: '/b', methods: ['GET', 'HEAD'], router: undefined } + ]) + }) + + it('should not reflect later mutations of a registered path array', function () { + const router = new Router() + const paths = ['/a'] + + router.get(paths, noop) + paths.push('/b') + + assert.deepStrictEqual(router.listRoutes(), [ + { path: '/a', methods: ['GET', 'HEAD'], router: undefined } + ]) + }) + + it('should not list middleware that merely has a listRoutes property', function () { + const router = new Router() + + function metrics (req, res, next) { next() } + metrics.listRoutes = () => 'not routes' + + router.use('/status', metrics) + + assert.deepStrictEqual(router.listRoutes(), []) + }) + + it('should list mounted routers that do not implement listRoutes', function () { + const router = new Router() + + function legacy (req, res, next) { next() } + legacy.stack = [] + + router.use('/legacy', legacy) + + assert.deepStrictEqual(router.listRoutes(), [ + { path: '/legacy', methods: undefined, router: legacy } + ]) + }) + + it('should expose mounted routers without recursing', function () { + const router = new Router() + const inner = new Router() + + inner.get('/api', noop) + router.use('/inner', inner) + router.get('/test', noop) + + assert.deepStrictEqual(router.listRoutes(), [ + { path: '/inner', methods: undefined, router: inner }, + { path: '/test', methods: ['GET', 'HEAD'], router: undefined } + ]) + + assert.deepStrictEqual(inner.listRoutes(), [ + { path: '/api', methods: ['GET', 'HEAD'], router: undefined } + ]) + }) + + it('should use the default path when mounting a router without a path', function () { + const router = new Router() + const inner = new Router() + + inner.get('/api', noop) + router.use(inner) + + assert.deepStrictEqual(router.listRoutes(), [ + { path: '/', methods: undefined, router: inner } + ]) + }) + + it('should list routers mounted at a RegExp path', function () { + const router = new Router() + const inner = new Router() + + inner.get('/api', noop) + router.use(/^\/page_([0-9]+)/, inner) + + assert.deepStrictEqual(router.listRoutes(), [ + { path: /^\/page_([0-9]+)/, methods: undefined, router: inner } + ]) + }) + + it('should list a mounted router once per path in an array', function () { + const router = new Router() + const inner = new Router() + + router.use(['/foo', '/bar'], inner) + + assert.deepStrictEqual(router.listRoutes(), [ + { path: '/foo', methods: undefined, router: inner }, + { path: '/bar', methods: undefined, router: inner } + ]) + }) + + it('should allow consumers to recurse into cyclic routers without blowing the stack', function () { + const router = new Router() + const inner = new Router() + + inner.get('/api', noop) + router.use('/inner', inner) + inner.use('/loop', router) + + assert.deepStrictEqual(router.listRoutes(), [ + { path: '/inner', methods: undefined, router: inner } + ]) + + assert.deepStrictEqual(inner.listRoutes(), [ + { path: '/api', methods: ['GET', 'HEAD'], router: undefined }, + { path: '/loop', methods: undefined, router } + ]) + }) +}) + +function noop () {}