add static route helper, named route path renderers
authorJustin Wind <justin.wind+git@gmail.com>
Fri, 4 Apr 2025 21:32:21 +0000 (14:32 -0700)
committerJustin Wind <justin.wind+git@gmail.com>
Fri, 4 Apr 2025 21:32:21 +0000 (14:32 -0700)
CHANGELOG.md
lib/dingus.js
test/lib/dingus.js

index a3b048b9d256f31e56cf7a3ba950d36afdfff283..d5e92b63c135baf399677537355ffe1ce1437f37 100644 (file)
@@ -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
index 31c3a101e1a7e30701fdba2ba1db1296fcce77d7..1e0c1a22abf909ef1e9555507c562fd653dc2827 100644 (file)
@@ -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 = /^(?<name>[^:]+): +(?<value>.*)$/;
 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<boolean>} 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);
index e68878ae3383d5e041604b60748abb97b5138f96..26f003cb8329b2a0622fde8b03012c012dd11da6 100644 (file)
@@ -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