From: Justin Wind Date: Fri, 28 Mar 2025 19:21:14 +0000 (-0700) Subject: support naming and rendering router paths X-Git-Url: https://git.squeep.com/?a=commitdiff_plain;h=88cb5d475c9216898d3f17c44e578a26a3fef6f7;p=squeep-api-dingus support naming and rendering router paths --- diff --git a/CHANGELOG.md b/CHANGELOG.md index f33a549..cf32abe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ Releases and notable changes to this project are documented here. +## [Unreleased] + +- support naming route paths, and rendering those named paths with parameter substitution + ## [v2.1.3] - 2025-03-28 - updated dependencies and devDependencies diff --git a/lib/dingus.js b/lib/dingus.js index c53e469..2c521db 100644 --- a/lib/dingus.js +++ b/lib/dingus.js @@ -16,7 +16,7 @@ const querystring = require('node:querystring'); const common = require('./common'); const ContentNegotiation = require('./content-negotiation'); const Enum = require('./enum'); -const { ResponseError, RouterNoPathError, RouterNoMethodError } = require('./errors'); +const { DingusError, ResponseError, RouterNoPathError, RouterNoMethodError } = require('./errors'); const { extensionToMime } = require('./mime-helper'); const Router = require('./router'); const Template = require('./template'); @@ -116,17 +116,49 @@ class Dingus { /** - * Insert a new path handler + * Insert a new path handler. + * Prior to v2.2.0 there was no name parameter, so we jankily handle both method signatures. * @param {string|string[]} methods methods to match for this path, '*' allowed * @param {string} urlPath request path to match + * @param {string=} name name of path, may be elided from arguments * @param {Function} handler handler to invoke on match * @param {any[]} handlerArgs additional arguments for handler */ - on(methods, urlPath, handler, ...handlerArgs) { - this.router.on(methods, urlPath, handler, handlerArgs); + on(methods, urlPath, name, handler, ...handlerArgs) { + if (typeof name === 'string') { + // Named path + this.router.on(methods, urlPath, handler, handlerArgs, name); + } else { + // Unnamed path, shift parameters: name contains handler, handler contains first handlerArg + this.router.on(methods, urlPath, name, [handler, ...handlerArgs]); + } } + /** + * Render a named path with any parameters. + * @param {string} name path name + * @param {object} params parameter substitutions + * @returns {string} rendered path + */ + routePath(name, params) { + const p = this.router.getNamedPath(name); + if (!p) { + throw new DingusError(`undefined route path '${name}'`); + } + return p + .map((part) => { + if (typeof part === 'string') { + return part; + } + if (part in params) { + return params[part]; // eslint-disable-line security/detect-object-injection + } + throw new DingusError(`missing route parameter '${part}'`); + }) + .join('/'); + } + /** * Common header tagging for all requests. * Add our own identifier, and persist any external transit identifiers. diff --git a/lib/router/index.js b/lib/router/index.js index 975187f..af17a4d 100644 --- a/lib/router/index.js +++ b/lib/router/index.js @@ -12,6 +12,7 @@ const PathParameter = require('./path-parameter'); // Internal identifiers for route entries. const kPathMethods = Symbol('kSqueepDingusRouterPathMethods'); +const kPathName = Symbol('kSqueepDingusRouterPathName'); const defaultOptions = { ignoreTrailingSlash: false, @@ -49,6 +50,7 @@ const defaultOptions = { */ class Router { static kPathMethods = kPathMethods; + static kPathName = kPathName; pathsByLength; /** @@ -66,6 +68,11 @@ class Router { this.pathsByLength = { 1: [], }; + + /** + * Keep index to named paths. + */ + this.pathsByName = new Map(); } @@ -73,10 +80,11 @@ class Router { * Prepare a path for insertion into search list. * A route path is an Array of path parts, with a symbolic property of an object mapping method handlers. * @param {string} rawPath path string + * @param {string=} name name of path * @returns {RoutePath} route path * @private */ - _pathToRoutePath(rawPath) { + _pathToRoutePath(rawPath, name) { const routePath = rawPath .split('/') .map((p) => this._pathPartMunge(p)); @@ -87,6 +95,9 @@ class Router { } routePath[kPathMethods] = {}; + if (name) { + routePath[kPathName] = name; + } Object.defineProperty(routePath, 'path', { enumerable: false, @@ -220,9 +231,10 @@ class Router { * @param {string} urlPath request path to match * @param {HandlerFn} handler handler to invoke on match * @param {any[]} handlerArgs additional arguments for handler + * @param {string=} name name of this path */ - on(methods, urlPath, handler, handlerArgs = []) { - const matchParts = this._pathToRoutePath(urlPath); + on(methods, urlPath, handler, handlerArgs, name) { + const matchParts = this._pathToRoutePath(urlPath, name); let existingPath = this._pathFindExact(matchParts); if (!existingPath) { existingPath = matchParts; @@ -230,18 +242,24 @@ class Router { this.pathsByLength[matchParts.length] = []; } this.pathsByLength[matchParts.length].push(existingPath); + if (name) { + if (this.pathsByName.has(name)) { + throw new DingusError(`path name '${name}' already declared`); + } + this.pathsByName.set(name, existingPath); + } } if (!Array.isArray(methods)) { methods = [methods]; } - if (!Array.isArray(handlerArgs)) { + if (typeof handlerArgs !== 'undefined' && !Array.isArray(handlerArgs)) { throw new TypeError(`handlerArgs must be an Array, not '${typeof handlerArgs}'`); } methods.forEach((method) => { if (!httpMethods.includes(method) && method !== '*') { throw new DingusError(`invalid method '${method}'`); } - existingPath[kPathMethods][method] = { handler, handlerArgs }; + existingPath[kPathMethods][method] = { handler, handlerArgs: handlerArgs ?? [] }; }); } @@ -279,6 +297,15 @@ class Router { } + /** + * Get a named route path entry. + * @param {string} name name of path + * @returns {RoutePath} route path array + */ + getNamedPath(name) { + return this.pathsByName.get(name); + } + } module.exports = Router; diff --git a/test/lib/dingus.js b/test/lib/dingus.js index ae3d746..8023436 100644 --- a/test/lib/dingus.js +++ b/test/lib/dingus.js @@ -293,12 +293,36 @@ describe('Dingus', function () { beforeEach(function () { stubOn = sinon.stub(dingus.router, 'on'); }); - it('covers', function () { + it('covers unnamed route', function () { dingus.on('GET', '/', () => {}); assert(stubOn.called); }); + it('covers named route', function () { + dingus.on('GET', '/', 'root', () => {}); + assert(stubOn.called); + }); }); // on + describe('routePath', function () { + it('renders plain path', function () { + dingus.on('GET', '/a/b/c', 'pathName', () => {}); + const result = dingus.routePath('pathName'); + assert.strictEqual(result, '/a/b/c'); + }); + it('renders parameterized path', function () { + dingus.on('GET', '/a/:b/c', 'pathName', () => {}); + const result = dingus.routePath('pathName', { b: 'foo' }); + assert.strictEqual(result, '/a/foo/c'); + }); + it('requires parameters', function () { + dingus.on('GET', '/a/:b/c', 'pathName', () => {}); + assert.throws(() => dingus.routePath('pathName', { a: 'foo' }), DingusError); + }); + it('requires path exists', function () { + assert.throws(() => dingus.routePath('pathName'), DingusError); + }); + }); + describe('setEndBodyHandler', function () { let req, res, ctx, handler, origEnd, origWrite; beforeEach(function () { diff --git a/test/lib/router.js b/test/lib/router.js index e5bba38..c5a5130 100644 --- a/test/lib/router.js +++ b/test/lib/router.js @@ -9,10 +9,11 @@ const { DingusError, RouterNoPathError, RouterNoMethodError } = require('../../l const noExpectedException = 'did not get expected exception'; describe('Router', function () { - const router = new Router(); + let router; let _its; // Save and restore ignoreTrailingSlash beforeEach(function () { + router = new Router(); _its = router.ignoreTrailingSlash; }); @@ -51,6 +52,14 @@ describe('Router', function () { const r = router._pathToRoutePath(p); assert.deepStrictEqual(r, expected); }); + it('defines a named path', function () { + const p = '/a/b/c'; + const expected = ['', 'a', 'b', 'c']; + expected[Router.kPathMethods] = {}; + expected[Router.kPathName] = 'name'; + const r = router._pathToRoutePath(p, 'name'); + assert.deepStrictEqual(r, expected); + }); }); // _pathToRoutePath describe('_pathCompareExact', function () { @@ -187,7 +196,6 @@ describe('Router', function () { }); // _pathFind / _pathFindExact describe('on', function () { - let pathsByLengthOrig; const stubHandler = () => {}; const stubEntry = { handler: stubHandler, @@ -195,14 +203,10 @@ describe('Router', function () { }; beforeEach(function () { - pathsByLengthOrig = router.pathsByLength; router.pathsByLength = { 2: [ router._pathToRoutePath('/:id') ], }; }); - afterEach(function () { - router.pathsByLength = pathsByLengthOrig; - }); it('adds new path', function () { const urlPath = '/a/:id'; @@ -256,7 +260,7 @@ describe('Router', function () { const urlPath = '/a/:id'; try { router.on('FLARP', urlPath, stubHandler); - assert.fail('expected an exception'); + assert.fail(noExpectedException); } catch (e) { assert(e instanceof DingusError); assert.strictEqual(e.message, 'invalid method \'FLARP\''); @@ -267,26 +271,42 @@ describe('Router', function () { const urlPath = '/a'; try { router.on('GET', urlPath, stubHandler, {}); - assert.fail('expected an exception'); + assert.fail(noExpectedException); } catch (e) { assert(e instanceof TypeError); } }); + + it('adds named path', function () { + const urlPath = '/a/:id'; + const name = 'pathName'; + router.on('GET', urlPath, stubHandler, undefined, name); + assert(router.pathsByName.has(name)); + }); + + it('fails to add duplicate named path', function () { + const urlPath1 = '/a/:id'; + const urlPath2 = '/b/:id'; + const name = 'pathName'; + router.on('GET', urlPath1, stubHandler, undefined, name); + try { + router.on('GET', urlPath2, stubHandler, undefined, name); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof DingusError); + assert.strictEqual(e.message, 'path name \'pathName\' already declared'); + } + }); }); // on describe('lookup', function () { - let pathsByLengthOrig; let ctx; let stubHandler; beforeEach(function () { ctx = {}; - pathsByLengthOrig = router.pathsByLength; stubHandler = sinon.stub(); }); - afterEach(function () { - router.pathsByLength = pathsByLengthOrig; - }); it('finds handler', function () { const urlPath = '/:id'; @@ -367,4 +387,22 @@ describe('Router', function () { }); // lookup + describe('getNamedPath', function () { + const stubHandler = () => {}; + beforeEach(function () { + router.on('GET', '/a/:id', stubHandler, undefined, 'aId'); + }); + it('returns named path', function () { + const expected = router._pathToRoutePath('/a/:id', 'aId'); + expected[Router.kPathMethods]['GET'] = { handler: stubHandler, handlerArgs: [] }; + const result = router.getNamedPath('aId'); + assert.deepStrictEqual(result, expected); + }); + it('returns nothing for nonexistent name', function () { + const expected = undefined; + const result = router.getNamedPath('nope'); + assert.deepStrictEqual(result, expected); + }); + }); // getNamedPath + }); // Router