From: Justin Wind Date: Fri, 4 Apr 2025 21:32:21 +0000 (-0700) Subject: add static route helper, named route path renderers X-Git-Url: http://git.squeep.com/?a=commitdiff_plain;h=b7fdee9f74f2ce614589b71798f8a868274c0c3d;p=squeep-api-dingus add static route helper, named route path renderers --- diff --git a/CHANGELOG.md b/CHANGELOG.md index a3b048b..d5e92b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,8 @@ Releases and notable changes to this project are documented here. ## [Unreleased] -- support naming route paths, and rendering those named paths with parameter substitution +- support naming route paths, and rendering those named paths with parameter substitution as relative, absolute, or external links +- helper for defining routes for static files (opinionated) - send Allow header on 405 Method Not Allowed responses ## [v2.1.3] - 2025-03-28 diff --git a/lib/dingus.js b/lib/dingus.js index 31c3a10..1e0c1a2 100644 --- a/lib/dingus.js +++ b/lib/dingus.js @@ -25,6 +25,9 @@ const Template = require('./template'); const { fileScope } = require('@squeep/log-helper'); const _fileScope = fileScope(__filename); +/** + * @type {DingusOptions} + */ const defaultOptions = { ignoreTrailingSlash: true, proxyPrefix: '', @@ -43,19 +46,25 @@ const headerParseRE = /^(?[^:]+): +(?.*)$/; const lineBreakRE = /\r\n|\n|\r/; const proxySplitRE = /\s*,\s*/u; +/** + * @typedef {object} DingusOptions + * @property {boolean=} ignoreTrailingSlash requests for '/foo/' will match a '/foo' route (default: true) + * @property {string=} proxyPrefix leading part of url path to strip before processing, should not have trailing slash (default: '') + * @property {boolean=} strictAccept whether to error on unsupported Accept type (default: true) + * @property {string=} selfBaseUrl root url including proxyPrefix, for constructing external links (default: '') + * @property {boolean=} staticMetadata serve static headers with static files (default: true) + * @property {string=} staticPath local filepath to static files, e.g. path.join(__dirname, 'static') + * @property {string=} staticRoute route base path to serve static files (default: '/static) + * @property {boolean=} trustProxy trust some header data to be provided by proxy (default: true) + * @property {boolean=} intrinsicHeadMethod handle HEAD requests automatically if not specified as a route method for routes with a GET handler (default: true) + * @property {boolean=} intrinsicHeadPersistBody include un-sent body on ctx for automatic HEAD requests (default: false) + * @property {object=} querystring alternate qs parser to use, must provide .parse() function (default: node:querystring) + */ + class Dingus { /** * @param {object} logger console-like object which implements logging methods - * @param {object} options options - * @param {boolean} options.ignoreTrailingSlash requests for '/foo/' will match a '/foo' route - * @param {string} options.proxyPrefix leading part of url path to strip - * @param {boolean} options.strictAccept whether to error on unsupported Accept type - * @param {string} options.selfBaseUrl for constructing links - * @param {boolean} options.staticMetadata serve static headers with static files - * @param {boolean} options.trustProxy trust some header data to be provided by proxy - * @param {boolean} options.intrinsicHeadMethod handle HEAD requests automatically if not specified as a route method - * @param {boolean} options.intrinsicHeadPersistBody include un-sent body on ctx for automatic HEAD requests - * @param {object} options.querystring alternate qs parser to use + * @param {DingusOptions} options options */ constructor(logger = console, options = {}) { common.setOptions(this, defaultOptions, options); @@ -76,6 +85,46 @@ class Dingus { } + /** + * @typedef {object} StandardStaticRouteOptions + * @property {string=} staticRoute url path to static content, without trailing slash (default: '/static') + * @property {string=} staticRouteFileName internal reference to name of static file route (default: 'static-file') + */ + /** + * Establish some routes for handling static files. + * @param {StandardStaticRouteOptions} options options + */ + defineStandardStaticRoutes(options = {}) { + const opt = { + staticRoute: '/static', + staticRouteFileName: 'static-file', + ...options, + }; + if (!this.staticPath || !opt.staticRoute) { + throw new DingusError('static route definition requested but missing required options'); + } + if (!opt.staticRoute.startsWith('/')) { + throw new RangeError('staticRoute parameter should have leading slash'); + } + if (opt.staticRoute.endsWith('/')) { + throw new RangeError('staticRoute parameter should not have trailing slash'); + } + + // /static/:file serves file + this.on(['GET'], `${opt.staticRoute}/:file`, opt.staticRouteFileName, this.handlerGetStaticFile.bind(this)); + + // /static forwards to /static/ + this.on(['GET'], opt.staticRoute, this.handlerRedirect.bind(this), `${this.proxyPrefix}${opt.staticRoute}/`); + + // /static/ serves /static/index.html + this.on(['GET'], `${opt.staticRoute}/`, this.handlerGetStaticFile.bind(this), 'index.html'); + + // /favicon.ico and /robots.txt serve from /static/ + this.on(['GET'], '/favicon.ico', this.handlerGetStaticFile.bind(this), 'favicon.ico'); + this.on(['GET'], '/robots.txt', this.handlerGetStaticFile.bind(this), 'robots.txt'); + } + + /** * Resolve relative and empty paths in url * @param {string} p path @@ -107,7 +156,7 @@ class Dingus { * @returns {object} path string and query params */ _splitUrl(url) { - const [ p, qs ] = common.splitFirst(url, '?'); + const [p, qs] = common.splitFirst(url, '?'); return { pathPart: this._normalizePath(p), queryParams: this.querystring.parse(qs), @@ -125,40 +174,120 @@ class Dingus { * @param {any[]} handlerArgs additional arguments for handler */ on(methods, urlPath, name, handler, ...handlerArgs) { - if (typeof name === 'string') { - // Named path - this.router.on(methods, urlPath, handler, handlerArgs, name); - } else { + if (typeof name === 'function') { // Unnamed path, shift parameters: name contains handler, handler contains first handlerArg this.router.on(methods, urlPath, name, [handler, ...handlerArgs]); + } else { + // Named path + this.router.on(methods, urlPath, handler, handlerArgs, name); } } + /** + * @typedef {import('./router').RoutePath} RoutePath + */ + /** + * Replace parameters in route path array. + * @param {RoutePath} routePath route path array + * @param {object=} params parameter substitutions + * @returns {RoutePath} parameterized route path array + */ + static _parameterizePath(routePath, params) { + return routePath.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}'`); + }); + } + + /** * Render a named path with any parameters. * @param {string} name path name - * @param {object} params parameter substitutions + * @param {object=} params parameter substitutions + * @param {boolean=} asArray disable joining parts into string * @returns {string} rendered path */ - routePath(name, params) { - const p = this.router.getNamedPath(name); - if (!p) { + routePath(name, params, asArray = false) { + const namedPath = this.router.getNamedPath(name); + if (!namedPath) { 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('/'); + + const parameterizedPath = this.constructor._parameterizePath(namedPath, params); + + return asArray ? parameterizedPath : parameterizedPath.join('/'); + } + + + /** + * Render internal path including proxyPrefix. + * @param {string} name path name + * @param {object=} params parameter substitutions + * @returns {string} rendered path + */ + absolutePath(name, params) { + return this.proxyPrefix + this.routePath(name, params); + } + + + /** + * Render internal path relative to current path. + * Exemplorum gratia: + * Current_Path_|_Target_Path__|_Result_________ + * /foo/bar/baz | /foo/bar/zoo | zoo + * /foo/bar/baz | /foo/quux | ../quux + * /foo/bar/baz | /frink | ../../frink + * @param {string} current path name + * @param {string} target path name + * @param {object=} currentParams parameter substitutions + * @param {object=} targetParams parameter substitutions + * @returns {string} rendered path + */ + relativePath(current, target, currentParams, targetParams) { + const targetPath = this.router.getNamedPath(target); + if (!targetPath) { + throw new DingusError(`undefined target route path '${target}'`); + } + const currentPath = this.router.getNamedPath(current); + if (!currentPath) { + throw new DingusError(`undefined current route path '${current}'`); + } + + const targetParameterized = this.constructor._parameterizePath(targetPath, targetParams); + const currentParameterized = this.constructor._parameterizePath(currentPath, currentParams); + + let idx = 0; + while (currentParameterized[idx] === targetParameterized[idx]) { // eslint-disable-line security/detect-object-injection + idx += 1; + } + + const upPaths = currentParameterized.slice(idx).length; + const diff = targetParameterized.slice(idx); + for (let x = 1; x < upPaths; x++) { + diff.unshift('..'); + } + + return diff.join('/'); + } + + + /** + * Render external URL for path. + * @param {string} name path name + * @param {object=} params parameter substitutions + * @returns {string} rendered url + */ + externalPath(name, params) { + return this.selfBaseUrl + this.routePath(name, params); } + /** * Common header tagging for all requests. * Add our own identifier, and persist any external transit identifiers. @@ -234,7 +363,7 @@ class Dingus { static ingestCookie(req, res, ctx) { ctx.cookie = {}; req.getHeader(Enum.Header.Cookie)?.split(cookieSplitRE).forEach((cookie) => { - const [ name, value ] = common.splitFirst(cookie, '=', null).map((x) => { + const [name, value] = common.splitFirst(cookie, '=', null).map((x) => { try { return decodeURIComponent(x.trim()); } catch (e) { // eslint-disable-line no-unused-vars @@ -603,7 +732,7 @@ class Dingus { * @param {http.ServerResponse} res response * @param {string} directory path to file * @param {string} fileName - already normalized and filtered - * @returns {boolean} if meta headers were added + * @returns {Promise} if meta headers were added */ async _serveFileMetaHeaders(res, directory, fileName) { const _scope = _fileScope('_serveFileMetaHeaders'); @@ -691,9 +820,9 @@ class Dingus { continue; } const encodedFilePath = `${filePath}${suffix}`; - const [ encodedStat, encodedData ] = await this._readFileInfo(encodedFilePath); + const [encodedStat, encodedData] = await this._readFileInfo(encodedFilePath); if (encodedStat) { - ([ stat, data ] = [ encodedStat, encodedData ]); + ([stat, data] = [encodedStat, encodedData]); ctx.selectedEncoding = encoding; Dingus.addEncodingHeader(res, encoding); res.setHeader(Enum.Header.Vary, Enum.Header.AcceptEncoding); diff --git a/test/lib/dingus.js b/test/lib/dingus.js index e68878a..26f003c 100644 --- a/test/lib/dingus.js +++ b/test/lib/dingus.js @@ -12,17 +12,21 @@ const noExpectedException = 'did not get expected exception'; const _nop = () => undefined; const _logFn = (process.env['VERBOSE_TESTS'] && console.log) || _nop; -const noLogger = { +const logger = { debug: _logFn, error: _logFn, }; -sinon.spy(noLogger, 'debug'); -sinon.spy(noLogger, 'error'); +sinon.spy(logger, 'debug'); +sinon.spy(logger, 'error'); describe('Dingus', function () { let dingus; beforeEach(function () { - dingus = new Dingus(noLogger, {}); + dingus = new Dingus(logger, { + selfBaseUrl: 'https://example.com/foo', + proxyPrefix: '/foo', + staticPath: '/static', + }); }); afterEach(function () { sinon.restore(); @@ -35,6 +39,27 @@ describe('Dingus', function () { }); }); // constructor + describe('defineStandardStaticRoutes', function () { + it('requires staticPath option', function () { + delete dingus.staticPath; + assert.throws(() => dingus.defineStandardStaticRoutes(), DingusError); + }); + it('requires staticRoute option', function () { + assert.throws(() => dingus.defineStandardStaticRoutes({ staticRoute: '' }), DingusError); + }); + it('requires staticRoute option be absolute', function () { + assert.throws(() => dingus.defineStandardStaticRoutes({ staticRoute: 'static/' }), RangeError); + }); + it('requires staticRoute option not have trailing slash', function () { + assert.throws(() => dingus.defineStandardStaticRoutes({ staticRoute: '/static/' }), RangeError); + }); + it('configures static routes', function () { + sinon.spy(dingus, 'on'); + dingus.defineStandardStaticRoutes(); + assert(dingus.on.called); + }); + }); // defineStandardStaticRoutes + describe('_normalizePath', function () { it('returns normal path', function () { const p = '/a/b/c'; @@ -47,6 +72,9 @@ describe('Dingus', function () { const r = dingus._normalizePath(p); assert.strictEqual(r, expected); }); + it('covers no proxy prefix', function () { + + }); }); // _normalizePath describe('_splitUrl', function () { @@ -314,6 +342,11 @@ describe('Dingus', function () { const result = dingus.routePath('pathName', { b: 'foo' }); assert.strictEqual(result, '/a/foo/c'); }); + it('renders parameterized path as array', function () { + dingus.on('GET', '/a/:b/c', 'pathName', () => {}); + const result = dingus.routePath('pathName', { b: 'foo' }, true); + assert.deepStrictEqual(result, ['', 'a', 'foo', 'c']); + }); it('requires parameters', function () { dingus.on('GET', '/a/:b/c', 'pathName', () => {}); assert.throws(() => dingus.routePath('pathName', { a: 'foo' }), DingusError); @@ -321,7 +354,58 @@ describe('Dingus', function () { it('requires path exists', function () { assert.throws(() => dingus.routePath('pathName'), DingusError); }); - }); + }); // routePath + + describe('absolutePath', function () { + it('renders', function () { + dingus.on('GET', '/a/b/c', 'pathName', () => {}); + const result = dingus.absolutePath('pathName'); + assert.strictEqual(result, '/foo/a/b/c'); + }); + }); // absolutePath + + describe('relativePath', function () { + let currentParams, targetParams; + beforeEach(function () { + dingus.on('GET', '/foo/:id/baz', 'baz', () => {}); + dingus.on('GET', '/foo/:id/zoo', 'zoo', () => {}); + dingus.on('GET', '/foo/quux', 'quux', () => {}); + dingus.on('GET', '/frink', 'frink', () => {}); + currentParams = { id: '123' }; + targetParams = { id: '123' }; + }); + it('requires current path exist', function () { + assert.throws(() => dingus.relativePath('nope', 'baz', currentParams, targetParams), DingusError); + }); + it('requires target path exist', function () { + assert.throws(() => dingus.relativePath('frink', 'nope', currentParams, targetParams), DingusError); + }); + it('renders at same depth', function () { + const result = dingus.relativePath('baz', 'zoo', currentParams, targetParams); + assert.strictEqual(result, 'zoo'); + }); + it('renders at depth', function () { + const result = dingus.relativePath('baz', 'quux', currentParams, targetParams); + assert.strictEqual(result, '../quux'); + }); + it('renders at more depth', function () { + const result = dingus.relativePath('baz', 'frink', currentParams, targetParams); + assert.strictEqual(result, '../../frink'); + }); + it('renders', function () { + const result = dingus.relativePath('frink', 'baz', currentParams, targetParams); + assert.strictEqual(result, 'foo/123/baz'); + }); + + }); // relativePath + + describe('externalPath', function () { + it('renders', function () { + dingus.on('GET', '/a/b/c', 'pathName', () => {}); + const result = dingus.externalPath('pathName'); + assert.strictEqual(result, 'https://example.com/foo/a/b/c'); + }); + }); // externalPath describe('setEndBodyHandler', function () { let req, res, ctx, handler, origEnd, origWrite; @@ -1066,6 +1150,7 @@ Content-Type: image/sgi beforeEach(function () { pfxDingus = new Dingus(console, { proxyPrefix: pfx }); req = { + method: 'GET', setHeader: sinon.stub(), getHeader: sinon.stub(), }; @@ -1083,29 +1168,37 @@ Content-Type: image/sgi afterEach(function () { sinon.restore(); }); + it('covers no prefix', async function () { + pfxDingus = new Dingus(console, { proxyPrefix: '' }); + sinon.stub(pfxDingus, 'handlerMethodNotAllowed'); + sinon.stub(pfxDingus, 'handlerNotFound'); + const urlPath = '/:id'; + req.url = '/abc'; + pfxDingus.on(req.method, urlPath, stubHandler); + await pfxDingus.dispatch(req, res, ctx); + assert(stubHandler.called); + assert(pfxDingus.handlerMethodNotAllowed.notCalled); + assert(pfxDingus.handlerNotFound.notCalled); + }); it('handles prefixed route', async function () { const urlPath = '/:id'; - const method = 'GET'; - pfxDingus.on(method, urlPath, stubHandler); + pfxDingus.on(req.method, urlPath, stubHandler); req.url = pfx + '/abc'; - req.method = method; await pfxDingus.dispatch(req, res, ctx); assert(stubHandler.called); - assert(!pfxDingus.handlerMethodNotAllowed.called); - assert(!pfxDingus.handlerNotFound.called); + assert(pfxDingus.handlerMethodNotAllowed.notCalled); + assert(pfxDingus.handlerNotFound.notCalled); }); it('does not handle prefixed route', async function () { const urlPath = '/:id'; - const method = 'GET'; - pfxDingus.on(method, urlPath, stubHandler); + pfxDingus.on(req.method, urlPath, stubHandler); req.url = '/wrongpfx/abc'; - req.method = method; await pfxDingus.dispatch(req, res, ctx); - assert(!stubHandler.called); - assert(!pfxDingus.handlerMethodNotAllowed.called); + assert(stubHandler.notCalled); + assert(pfxDingus.handlerMethodNotAllowed.notCalled); assert(pfxDingus.handlerNotFound.called); }); }); // proxyPrefix