Merge branch 'v1.2-dev' as v1.2.6 v1.2.6
authorJustin Wind <justin.wind+git@gmail.com>
Fri, 29 Apr 2022 16:21:51 +0000 (09:21 -0700)
committerJustin Wind <justin.wind+git@gmail.com>
Fri, 29 Apr 2022 16:21:51 +0000 (09:21 -0700)
12 files changed:
lib/common.js
lib/dingus.js
lib/patches/incoming-message.js
lib/router.js [deleted file]
lib/router/index.js [new file with mode: 0644]
lib/router/path-parameter.js [new file with mode: 0644]
package-lock.json
package.json
test/lib/common.js
test/lib/dingus.js
test/lib/router.js
test/lib/router/path-parameter.js [new file with mode: 0644]

index 21070b8782918f267bf32e3adf96537df08e615a..40a9f554f9ee4f687fe63402e3fed0908162408b 100644 (file)
@@ -11,8 +11,15 @@ const uuid = require('uuid');
 const Enum = require('./enum');
 
 /**
- * Return a function which combines a part of the filename with a scope, for use in logging.
- * @param {string} filename
+ * @callback ScopeFn
+ * @param {String} scope
+ * @returns {String}
+ */
+/**
+ * Return a function which prefixes a provided scope with the most-
+ * relevant part of the filename, for use in logging.
+ * @param {String} filename
+ * @returns {ScopeFn}
  */
 const fileScope = (filename) => {
   let fScope = path.basename(filename, '.js');
@@ -24,9 +31,10 @@ const fileScope = (filename) => {
 
 /**
  * Simple ETag from data.
- * @param {string} filePath 
- * @param {object} fileStat 
- * @param {*} fileData 
+ * @param {String} filePath (currently unused)
+ * @param {fs.Stats} fileStat
+ * @param {crypto.BinaryLike} fileData content
+ * @returns {String}
  */
 const generateETag = (_filePath, fileStat, fileData) => {
   const hash = crypto.createHash('sha256');
@@ -39,16 +47,19 @@ const generateETag = (_filePath, fileStat, fileData) => {
 };
 
 /**
- * @param {object} obj 
- * @param {string} prop 
- * @param {*} def 
+ * Access property with default.
+ * @param {Object} obj
+ * @param {String} prop
+ * @param {*} def default value if prop does not exist for obj
+ * @return {*}
  */
 const get = (obj, prop, def) => obj && prop && (prop in obj) ? obj[prop] : def;
 
 /**
  * @param {http.ClientRequest} req 
  * @param {http.ServerResponse} res 
- * @param {object} ctx 
+ * @param {Object} ctx 
+ * @deprecated after v1.2.5 (integrated into logger module)
  */
 const handlerLogData = (req, res, ctx) => ({
   req: requestLogData(req),
@@ -57,10 +68,12 @@ const handlerLogData = (req, res, ctx) => ({
 });
 
 /**
- * 
+ * Determine whether a client has already requested a resource,
+ * based on If-Modified-Since and If-None-Match headers.
  * @param {http.ClientRequest} req 
  * @param {Number} modifiedTimeMs 
- * @param {string} eTag 
+ * @param {String} eTag 
+ * @returns {Boolean}
  */
 const isClientCached = (req, modifiedTimeMs, eTag) => {
   let clientCached = false;
@@ -93,6 +106,7 @@ const isClientCached = (req, modifiedTimeMs, eTag) => {
  * Expects only one-level deep, is not recursive!
  * @param {Object} origEnum
  * @param {Object} additionalEnum
+ * @returns {Object}
  */
 const mergeEnum = (origEnum, additionalEnum) => {
   for (const e of Object.keys(additionalEnum)) {
@@ -118,9 +132,9 @@ const httpStatusCodeClass = (statusCode) => Math.floor(statusCode / 100);
 const _isObject = (obj) => obj && typeof obj === 'object';
 const _isArray = (obj) => Array.isArray(obj);
 /**
- * Return a new object with all objects combined.
- * @param  {...any} objects
- * @returns
+ * Return a new object with all objects combined, later properties taking precedence.
+ * @param  {...Object} objects
+ * @returns {Object}
  */
 const mergeDeep = (...objects) => {
   return objects.reduce((acc, obj) => {
@@ -144,7 +158,8 @@ const mergeDeep = (...objects) => {
 /**
  * Return a new object with selected props.
  * @param {Object} obj
- * @param {string[]} props
+ * @param {String[]} props
+ * @returns {Object}
  */
 const pick = (obj, props) => {
   const picked = {};
@@ -160,6 +175,7 @@ const pick = (obj, props) => {
  * Return a subset of a request object, suitable for logging.
  * Obscures sensitive header values.
  * @param {http.ClientRequest} req 
+ * @deprecated after v1.2.5 (integrated into logger module)
  */
 const requestLogData = (req) => {
   const data = pick(req, [
@@ -178,6 +194,7 @@ const requestLogData = (req) => {
  * Remove sensitive header data.
  * @param {Object} data
  * @param {Object} data.headers
+ * @deprecated after v1.2.5 (integrated into logger module)
  */
 const scrubHeaderObject = (data) => {
   if (data && data.headers && 'authorization' in data.headers) {
@@ -192,6 +209,7 @@ const scrubHeaderObject = (data) => {
  * Hide sensitive part of an Authorization header.
  * @param {String} authHeader
  * @returns {String}
+ * @deprecated after v1.2.5 (integrated into logger module)
  */
 const obscureAuthorizationHeader = (authHeader) => {
   if (!authHeader) {
@@ -206,6 +224,7 @@ const obscureAuthorizationHeader = (authHeader) => {
 /**
  * Return a subset of a response object, suitable for logging.
  * @param {http.ServerResponse} res 
+ * @deprecated after v1.2.5 (integrated into logger module)
  */
 const responseLogData = (res) => {
   const response = pick(res, [
@@ -218,7 +237,7 @@ const responseLogData = (res) => {
 
 
 /**
- * Store updates to defaultOptions, but no new properties.
+ * Store all properties in defaultOptions on target from either options or defaultOptions.
  * @param {Object} target 
  * @param {Object} defaultOptions 
  * @param {Object} options 
@@ -228,10 +247,10 @@ const setOptions = (target, defaultOptions, options) => {
 };
 
 /**
- * Return a list of source split at first delimiter.
- * @param {string} src 
- * @param {string} delimiter 
- * @param {string} fill trailing stand-in if no delimiter in src
+ * Return a two-item list of src, split at first delimiter encountered.
+ * @param {String} src 
+ * @param {String} delimiter 
+ * @param {String} fill trailing stand-in if no delimiter in src
  */
 const splitFirst = (src, delimiter, fill) => {
   const idx = src.indexOf(delimiter);
@@ -243,14 +262,21 @@ const splitFirst = (src, delimiter, fill) => {
 };
 
 /**
- * Generate a new request identifier.
+ * Generate a new request identifier, a time/host-based uuid.
  * @returns {String}
  */
 const requestId = () => {
   return uuid.v1();
 };
 
+/**
+ * Do nothing.
+ */
 const nop = () => { /**/ };
+
+/**
+ * A logger object which does nothing.
+ */
 const nullLogger = {
   error: nop,
   warn: nop,
@@ -260,8 +286,9 @@ const nullLogger = {
 };
 
 /**
- * Populates any absent logger levels.
+ * Populates any absent logger level functions on a logger object.
  * @param {Object} logger
+ * @returns {Object}
  */
 const ensureLoggerLevels = (logger = {}) => {
   for (const level in nullLogger) {
@@ -275,6 +302,7 @@ const ensureLoggerLevels = (logger = {}) => {
 /**
  * Merges folded header lines
  * @param {String[]} lines
+ * @returns {String}
  */
 const unfoldHeaderLines = (lines) => {
   const foldedLineRE = /^(\t| +)(.*)$/;
index 51da6fab6b2185680cace1f8d1ad48921283d59d..943aa2f91e7bfc141960b35182bbbc23ef816f16 100644 (file)
@@ -27,7 +27,7 @@ const defaultOptions = {
   strictAccept: true,
   selfBaseUrl: '',
   staticMetadata: true,
-  staticPath: undefined, // no reasonable default
+  staticPath: undefined, // No reasonable default
   trustProxy: true,
   querystring,
 };
@@ -349,8 +349,9 @@ class Dingus {
    * Return all body data from a request.
    * @param {http.ClientRequest} req
    * @param {Number=} maximumBodySize
+   * @param {Boolean=} toString
    */
-  async bodyData(req, maximumBodySize) {
+  async bodyData(req, maximumBodySize, toString = true) {
     const _scope = _fileScope('bodyData');
     return new Promise((resolve, reject) => {
       const body = [];
@@ -363,7 +364,10 @@ class Dingus {
           reject(new ResponseError(Enum.ErrorResponse.RequestEntityTooLarge));
         }
       });
-      req.on('end', () => resolve(Buffer.concat(body).toString()));
+      req.on('end', () => {
+        const bodyBuffer = Buffer.concat(body);
+        resolve(toString ? bodyBuffer.toString() : bodyBuffer);
+      });
       req.on('error', (e) => {
         this.logger.error(_scope, 'failed', { error: e });
         reject(e);
@@ -521,7 +525,7 @@ class Dingus {
    */
   async serveFile(req, res, ctx, directory, fileName) {
     const _scope = _fileScope('serveFile');
-    this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
+    this.logger.debug(_scope, 'called', { req, ctx });
 
     // Require a directory field.
     if (!directory) {
@@ -561,18 +565,20 @@ class Dingus {
         break;
       }
       const suffix = Enum.EncodingTypeSuffix[encoding];
-      if (suffix) {
-        const encodedFilePath = `${filePath}${suffix}`;
-        const [ encodedStat, encodedData ] = await this._readFileInfo(encodedFilePath);
-        if (encodedStat) {
-          ([ stat, data ] = [ encodedStat, encodedData ]);
-          ctx.selectedEncoding = encoding;
-          Dingus.addEncodingHeader(res, encoding);
-          res.setHeader(Enum.Header.Vary, Enum.Header.AcceptEncoding);
-          this.logger.debug(_scope, 'serving encoded version', { ctx, encodedFilePath });
-        }
-        break;
+      if (!suffix) {
+        this.logger.error(_scope, 'supported encoding missing mapped suffix', { ctx, encoding });
+        continue;
+      }
+      const encodedFilePath = `${filePath}${suffix}`;
+      const [ encodedStat, encodedData ] = await this._readFileInfo(encodedFilePath);
+      if (encodedStat) {
+        ([ stat, data ] = [ encodedStat, encodedData ]);
+        ctx.selectedEncoding = encoding;
+        Dingus.addEncodingHeader(res, encoding);
+        res.setHeader(Enum.Header.Vary, Enum.Header.AcceptEncoding);
+        this.logger.debug(_scope, 'serving encoded version', { ctx, encodedFilePath });
       }
+      break;
     }
 
     const lastModifiedDate = new Date(stat.mtimeMs);
@@ -649,11 +655,11 @@ class Dingus {
     if (err && err.statusCode) {
       res.statusCode = err.statusCode;
       body = this.renderError(res.getHeader(Enum.Header.ContentType), err);
-      this.logger.debug(_scope, 'handler error', { err, ...common.handlerLogData(req, res, ctx) });
+      this.logger.debug(_scope, 'handler error', { err, req, res, ctx });
     } else {
       res.statusCode = 500;
       body = this.renderError(res.getHeader(Enum.Header.ContentType), Enum.ErrorResponse.InternalServerError);
-      this.logger.error(_scope, 'handler exception', { err, ...common.handlerLogData(req, res, ctx) });
+      this.logger.error(_scope, 'handler exception', { err, req, res, ctx });
     }
     res.end(body);
   }
index 894ca65428d79e9ff2ba7decd844d2573c7944d8..0b2e259c499ec932708c783dfbd44391b9e93564 100644 (file)
@@ -3,9 +3,9 @@ const { IncomingMessage } = require('http');
 
 /**
  * Welp, here we are, already into the crazy.
- * Per documentation (https://nodejs.org/docs/latest-v12.x/api/http.html#http_request_getheader_name)
- * this should exist, yet (as of 12.18.4) it does not.  So let us change this pitch up, and patch
-*/
+ * This originally existed due to a misinterpretation of the documentation,
+ * but it's really quite useful, so it stays in our world.
+ */
 /* istanbul ignore else */
 if (typeof IncomingMessage.getHeader !== 'function') {
   IncomingMessage.prototype.getHeader = function (name) {
diff --git a/lib/router.js b/lib/router.js
deleted file mode 100644 (file)
index 61a8d11..0000000
+++ /dev/null
@@ -1,213 +0,0 @@
-/* eslint-disable security/detect-object-injection */
-'use strict';
-
-/**
- * A very simple router.
- */
-
-const { METHODS: httpMethods } = require('http');
-const common = require('./common');
-const { DingusError } = require('./errors');
-
-// Internal identifiers for route entries.
-const METHODS = Symbol('METHODS');
-const PARAM = Symbol('PARAM');
-
-const defaultOptions = {
-  ignoreTrailingSlash: false,
-  paramPrefix: ':',
-};
-
-class Router {
-  /**
-   * @param {Object} options
-   * @param {Boolean} options.ignoreTrailingSlash 
-   * @param {Boolean} options.paramPrefix 
-   */
-  constructor(options = {}) {
-    common.setOptions(this, defaultOptions, options);
-
-    // Keep lists of paths to match, indexed by path length.
-    this.pathsByLength = {
-      1: [],
-    };
-
-    this.METHODS = METHODS;
-    this.PARAM = PARAM;
-  }
-
-
-  /**
-   * Prepare a path for insertion into search list.
-   * A searchable path is a list of path parts, with a property of method handlers.
-   * @param {string} pathDefinition 
-   */
-  _pathDefinitionToPathMatch(pathDefinition) {
-    const pathMatch = pathDefinition.split('/').map((p) => p.startsWith(this.paramPrefix) ? { [PARAM]: p.slice(this.paramPrefix.length) } : p);
-    if (this.ignoreTrailingSlash
-    &&  pathMatch[pathMatch.length - 1] === '') {
-      pathMatch.pop();
-    }
-    pathMatch[METHODS] = {};
-    pathMatch.forEach((p) => Object.freeze(p));
-    Object.freeze(pathMatch);
-    return pathMatch;
-  }
-
-
-  /**
-   * Compare checkPath to fixedPath, no param substitution, params must match.
-   * @param {*} fixedPath 
-   * @param {*} checkPath 
-   */
-  static _pathCompareExact(fixedPath, checkPath) {
-    if (fixedPath.length !== checkPath.length) {
-      return false;
-    }
-    for (let i = 0; i < fixedPath.length; i++) {
-      const fixedPart = fixedPath[i];
-      const checkPart = checkPath[i];
-      if (typeof fixedPart === 'object' && typeof checkPart === 'object') {
-        if (fixedPart[PARAM] !== checkPart[PARAM]) {
-          return false;
-        }
-      } else if (fixedPart !== checkPart) {
-        return false;
-      }
-    }
-    return true;
-  }
-
-
-  /**
-   * Compare checkPath to fixedPath, populating params.
-   * @param {*} fixedPath 
-   * @param {*} checkPath 
-   * @param {*} returnParams 
-   */
-  static _pathCompareParam(fixedPath, checkPath, returnParams = {}) {
-    const params = {};
-
-    if (fixedPath.length !== checkPath.length) {
-      return false;
-    }
-    for (let i = 0; i < fixedPath.length; i++) {
-      const fixedPart = fixedPath[i];
-      const checkPart = checkPath[i];
-      if (typeof fixedPart === 'object') {
-        params[fixedPart[PARAM]] = checkPart;
-      } else if (fixedPart !== checkPart) {
-        return false;
-      }
-    }
-    Object.assign(returnParams, params);
-    return true;
-  }
-
-
-  /**
-   * Search for an existing path, return matched path and path parameters.
-   * @param {Array} matchParts 
-   */
-  _pathFind(matchParts) {
-    const result = {
-      pathParams: {},
-      matchedPath: undefined,
-    };
-    const pathsByLength = this.pathsByLength[matchParts.length];
-    if (pathsByLength) {
-      for (const p of pathsByLength) {
-        if (Router._pathCompareParam(p, matchParts, result.pathParams)) {
-          result.matchedPath = p;
-          break;
-        }
-      }
-    }
-    return result;
-  }
-
-
-  /**
-   * Return a matching path, no param substitution, params must match
-   * @param {*} matchParts 
-   */
-  _pathFindExact(matchParts) {
-    const pathsByLength = this.pathsByLength[matchParts.length];
-    if (pathsByLength) {
-      for (const p of pathsByLength) {
-        if (Router._pathCompareExact(p, matchParts)) {
-          return p;
-        }
-      }
-    }
-    return undefined;
-  }
-
-
-  /**
-   * Insert a new path handler.
-   * @param {string|string[]} methods
-   * @param {string} urlPath 
-   * @param {fn} handler 
-   * @param {*[]} handlerArgs
-   */
-  on(methods, urlPath, handler, handlerArgs = []) {
-    const matchParts = this._pathDefinitionToPathMatch(urlPath);
-    let existingPath = this._pathFindExact(matchParts);
-    if (!existingPath) {
-      existingPath = matchParts;
-      if (!(matchParts.length in this.pathsByLength)) {
-        this.pathsByLength[matchParts.length] = [];
-      }
-      this.pathsByLength[matchParts.length].push(existingPath);
-    }
-    if (!Array.isArray(methods)) {
-      methods = [methods];
-    }
-    if (!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[METHODS][method] = { handler, handlerArgs };
-    });
-  }
-
-
-  /**
-   * Return an object, which contains a matching handler and any extra
-   * arguments, for a requested url.
-   * Also sets path parameters on context.
-   * @param {string} method 
-   * @param {string[]} urlPath
-   * @param {object} ctx 
-   * @returns {object}
-   */
-  lookup(method, urlPath, ctx = {}) {
-    const pathParts = urlPath.split('/').map((part) => decodeURIComponent(part));
-    if (this.ignoreTrailingSlash
-    &&  pathParts[pathParts.length - 1] === '') {
-      pathParts.pop();
-    }  
-    const { matchedPath, pathParams } = this._pathFind(pathParts);
-    ctx.params = pathParams;
-    if (matchedPath) {
-      ctx.matchedPath = matchedPath;
-      if (method in matchedPath[METHODS]) {
-        return matchedPath[METHODS][method];
-      }
-      if ('*' in matchedPath[METHODS]) {
-        return matchedPath[METHODS]['*'];
-      }
-      throw new DingusError('NoMethod');
-    }
-    ctx.unmatchedPath = pathParts;
-    throw new DingusError('NoPath');
-  }
-
-
-}
-
-module.exports = Router;
diff --git a/lib/router/index.js b/lib/router/index.js
new file mode 100644 (file)
index 0000000..a13ac47
--- /dev/null
@@ -0,0 +1,253 @@
+/* eslint-disable security/detect-object-injection */
+'use strict';
+
+/**
+ * A very simple router.
+ */
+
+const { METHODS: httpMethods } = require('http');
+const common = require('../common');
+const { DingusError } = require('../errors');
+const PathParameter = require('./path-parameter');
+
+// Internal identifiers for route entries.
+const kPathMethods = Symbol('kSqueepDingusRouterPathMethods');
+
+const defaultOptions = {
+  ignoreTrailingSlash: false,
+  paramPrefix: ':',
+};
+
+/**
+ * A naïve router which maps incoming requests to handler functions
+ * by way of url path and request method.
+ * 
+ * Regex parsing of paths was eschewed, as a design decision.
+ * 
+ * Instead, each path to be searched for is deconstructed into a list
+ * of its constituent parts as strings or objects, for invariant or
+ * parameterized parts respectively.  Each search path is assigned a
+ * mapping of methods to handler functions.
+ * 
+ * @property {Object} pathsByLength index to registered paths by number of parts
+ */
+class Router {
+  /**
+   * @param {Object} options
+   * @param {Boolean} options.ignoreTrailingSlash discard any trailing slashes when registering and comparing paths (default: false)
+   * @param {String} options.paramPrefix prefix of a path part denoting a named parameter when registering paths (default: ':')
+   */
+  constructor(options = {}) {
+    common.setOptions(this, defaultOptions, options);
+
+    // Keep lists of paths to match, indexed by path length.
+    this.pathsByLength = {
+      1: [],
+    };
+  }
+
+
+  /**
+   * @typedef {Array<String|PathParameter>} Router~RoutePath
+   * @property {Object} kPathMethods (symbol key)
+   */
+  /**
+   * 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
+   * @returns {Router~RoutePath}
+   * @private
+   */
+  _pathToRoutePath(rawPath) {
+    const routePath = rawPath
+      .split('/')
+      .map((p) => p.startsWith(this.paramPrefix) ? new PathParameter(p.slice(this.paramPrefix.length)) : p);
+    if (this.ignoreTrailingSlash
+    &&  routePath[routePath.length - 1] === '') {
+      routePath.pop();
+    }
+    routePath[kPathMethods] = {};
+    Object.freeze(routePath);
+    return routePath;
+  }
+
+
+  /**
+   * Compare checkPath to fixedPath, no param substitution, params must match.
+   * @param {Router~RoutePath} routePath 
+   * @param {Router~RoutePath} checkPath 
+   * @returns {Boolean}
+   * @private
+   */
+  static _pathCompareExact(routePath, checkPath) {
+    if (routePath.length !== checkPath.length) {
+      return false;
+    }
+    for (let i = 0; i < routePath.length; i++) {
+      const fixedPart = routePath[i];
+      const checkPart = checkPath[i];
+      if (fixedPart instanceof PathParameter && checkPart instanceof PathParameter) {
+        if (fixedPart.parameter !== checkPart.parameter) {
+          return false;
+        }
+      } else if (fixedPart !== checkPart) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+
+  /**
+   * Compare routePath to fixedPath, populating params.
+   * @param {Router~RoutePath} routePath 
+   * @param {Array<String>} checkPath 
+   * @param {Object} returnParams 
+   * @returns {Boolean}
+   * @private
+   */
+  static _pathCompareParam(routePath, checkPath, returnParams = {}) {
+    const params = {};
+
+    if (routePath.length !== checkPath.length) {
+      return false;
+    }
+    for (let i = 0; i < routePath.length; i++) {
+      const fixedPart = routePath[i];
+      const checkPart = checkPath[i];
+      if (fixedPart instanceof PathParameter) {
+        params[fixedPart.parameter] = checkPart;
+      } else if (fixedPart !== checkPart) {
+        return false;
+      }
+    }
+    Object.assign(returnParams, params);
+    return true;
+  }
+
+
+  /**
+   * @typedef Router~MatchedPath
+   * @property {Object} pathParams populated param fields
+   * @property {Router~RoutePath} matchedPath
+   */
+  /**
+   * Search for an existing path, return matched path and path parameters.
+   * @param {Array<String>} matchParts 
+   * @returns {Router~MatchedPath}
+   * @private
+   */
+  _pathFind(matchParts) {
+    const result = {
+      pathParams: {},
+      matchedPath: undefined,
+    };
+    const pathsByLength = this.pathsByLength[matchParts.length];
+    if (pathsByLength) {
+      for (const p of pathsByLength) {
+        if (Router._pathCompareParam(p, matchParts, result.pathParams)) {
+          result.matchedPath = p;
+          break;
+        }
+      }
+    }
+    return result;
+  }
+
+
+  /**
+   * Return a matching path, no param substitution, params must match
+   * @param {Router~RoutePath} routePath 
+   * @returns {Router~RoutePath=}
+   * @private
+   */
+  _pathFindExact(routePath) {
+    const pathsByLength = this.pathsByLength[routePath.length];
+    if (pathsByLength) {
+      for (const p of pathsByLength) {
+        if (Router._pathCompareExact(p, routePath)) {
+          return p;
+        }
+      }
+    }
+    return undefined;
+  }
+
+
+  /**
+   * @callback Router~HandlerFn
+   */
+  /**
+   * @typedef {Object} Router~PathHandler
+   * @property {Router~HandlerFn} handler
+   * @property {any[]} handlerArgs
+   */
+  /**
+   * Insert a new path handler.
+   * @param {string|string[]} methods
+   * @param {string} urlPath 
+   * @param {Router~HandlerFn} handler
+   * @param {any[]} handlerArgs
+   */
+  on(methods, urlPath, handler, handlerArgs = []) {
+    const matchParts = this._pathToRoutePath(urlPath);
+    let existingPath = this._pathFindExact(matchParts);
+    if (!existingPath) {
+      existingPath = matchParts;
+      if (!(matchParts.length in this.pathsByLength)) {
+        this.pathsByLength[matchParts.length] = [];
+      }
+      this.pathsByLength[matchParts.length].push(existingPath);
+    }
+    if (!Array.isArray(methods)) {
+      methods = [methods];
+    }
+    if (!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 };
+    });
+  }
+
+
+  /**
+   * Return an object, which contains a matching handler and any extra
+   * arguments, for a requested url.
+   * Also sets path named-parameters as #params and the matched path as
+   * #matchedPath on the context.
+   * @param {string} method 
+   * @param {string[]} urlPath
+   * @param {object} ctx 
+   * @returns {Router~PathHandler}
+   */
+  lookup(method, urlPath, ctx = {}) {
+    const pathParts = urlPath.split('/').map((part) => decodeURIComponent(part));
+    if (this.ignoreTrailingSlash
+    &&  pathParts[pathParts.length - 1] === '') {
+      pathParts.pop();
+    }  
+    const { matchedPath, pathParams } = this._pathFind(pathParts);
+    ctx.params = pathParams;
+    if (matchedPath) {
+      ctx.matchedPath = matchedPath;
+      if (method in matchedPath[kPathMethods]) {
+        return matchedPath[kPathMethods][method];
+      }
+      if ('*' in matchedPath[kPathMethods]) {
+        return matchedPath[kPathMethods]['*'];
+      }
+      throw new DingusError('NoMethod');
+    }
+    ctx.unmatchedPath = pathParts;
+    throw new DingusError('NoPath');
+  }
+
+
+}
+Router.kPathMethods = kPathMethods;
+
+module.exports = Router;
diff --git a/lib/router/path-parameter.js b/lib/router/path-parameter.js
new file mode 100644 (file)
index 0000000..3c6f1cb
--- /dev/null
@@ -0,0 +1,40 @@
+'use strict';
+
+const parameters = new Map();
+/**
+ * De-duplicating factory of minimal-objects to track the named-parameter parts of path definitions.
+ *
+ * @property {String} parameter
+ */
+class PathParameter extends null {
+  constructor(parameter) {
+    if (!parameter || typeof(parameter) !== 'string') {
+      throw new RangeError('parameter must be string');
+    }
+    if (parameters.has(parameter)) {
+      return parameters.get(parameter);
+    }
+    const pathParameter = Object.create(PathParameter.prototype);
+    pathParameter.parameter = parameter;
+    parameters.set(parameter, pathParameter);
+    Object.freeze(pathParameter);
+    return pathParameter;
+  }
+
+  /**
+   * @returns {String}
+   */
+  toString() {
+    return `{${this.constructor.name} ${this.parameter}}`;
+  }
+
+  /**
+   * Clear the de-duplication table, for tests.
+   */
+  static _flush() {
+    this.parameters.clear();
+  }
+}
+PathParameter.parameters = parameters;
+
+module.exports = PathParameter;
\ No newline at end of file
index 8d270e06e28a4a62e2228abf60412150a4007298..d98036a6df141f94b54d6d760be9128dad71a553 100644 (file)
@@ -1,6 +1,6 @@
 {
   "name": "@squeep/api-dingus",
-  "version": "1.2.5",
+  "version": "1.2.6",
   "lockfileVersion": 1,
   "requires": true,
   "dependencies": {
       }
     },
     "@eslint/eslintrc": {
-      "version": "1.2.1",
-      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.2.1.tgz",
-      "integrity": "sha512-bxvbYnBPN1Gibwyp6NrpnFzA3YtRL3BBAyEAFVIpNTm2Rn4Vy87GA5M4aSn3InRrlsbX5N0GW7XIx+U4SAEKdQ==",
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.2.2.tgz",
+      "integrity": "sha512-lTVWHs7O2hjBFZunXTZYnYqtB9GakA1lnxIf+gKq2nY5gxkkNi/lQvveW6t8gFdOHTg6nG50Xs95PrLqVpcaLg==",
       "dev": true,
       "requires": {
         "ajv": "^6.12.4",
           "dev": true
         },
         "debug": {
-          "version": "4.3.3",
-          "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz",
-          "integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==",
+          "version": "4.3.4",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+          "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
           "dev": true,
           "requires": {
             "ms": "2.1.2"
       }
     },
     "@sinonjs/fake-timers": {
-      "version": "9.0.0",
-      "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-9.0.0.tgz",
-      "integrity": "sha512-+shXA2X7KNP7H7qNbQTJ3SA+NQc0pZDSBrdvFSRwF8sAo/ohw+ZQFD8Moc+gnz51+1eRXtEQBpKWPiQ4jsRC/w==",
+      "version": "9.1.2",
+      "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-9.1.2.tgz",
+      "integrity": "sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw==",
       "dev": true,
       "requires": {
         "@sinonjs/commons": "^1.7.0"
       "dev": true
     },
     "acorn": {
-      "version": "8.7.0",
-      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz",
-      "integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==",
+      "version": "8.7.1",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.1.tgz",
+      "integrity": "sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==",
       "dev": true
     },
     "acorn-jsx": {
       "dev": true
     },
     "eslint": {
-      "version": "8.11.0",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.11.0.tgz",
-      "integrity": "sha512-/KRpd9mIRg2raGxHRGwW9ZywYNAClZrHjdueHcrVDuO3a6bj83eoTirCCk0M0yPwOjWYKHwRVRid+xK4F/GHgA==",
+      "version": "8.14.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.14.0.tgz",
+      "integrity": "sha512-3/CE4aJX7LNEiE3i6FeodHmI/38GZtWCsAtsymScmzYapx8q1nVVb+eLcLSzATmCPXw5pT4TqVs1E0OmxAd9tw==",
       "dev": true,
       "requires": {
-        "@eslint/eslintrc": "^1.2.1",
+        "@eslint/eslintrc": "^1.2.2",
         "@humanwhocodes/config-array": "^0.9.2",
         "ajv": "^6.10.0",
         "chalk": "^4.0.0",
           "dev": true
         },
         "debug": {
-          "version": "4.3.3",
-          "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz",
-          "integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==",
+          "version": "4.3.4",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+          "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
           "dev": true,
           "requires": {
             "ms": "2.1.2"
       }
     },
     "eslint-plugin-security": {
-      "version": "1.4.0",
-      "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-1.4.0.tgz",
-      "integrity": "sha512-xlS7P2PLMXeqfhyf3NpqbvbnW04kN8M9NtmhpR3XGyOvt/vNKS7XPXT5EDbwKW9vCjWH4PpfQvgD/+JgN0VJKA==",
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-1.5.0.tgz",
+      "integrity": "sha512-hAFVwLZ/UeXrlyVD2TDarv/x00CoFVpaY0IUZhKjPjiFxqkuQVixsK4f2rxngeQOqSxi6OUjzJM/jMwKEVjJ8g==",
       "dev": true,
       "requires": {
-        "safe-regex": "^1.1.0"
+        "safe-regex": "^2.1.1"
       }
     },
     "eslint-plugin-sonarjs": {
-      "version": "0.12.0",
-      "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-0.12.0.tgz",
-      "integrity": "sha512-soxjK67hoYxO8hesKqXWN50GSM+oG2r35N5WnAMehetahO6zoVpv3HZbdziP0jYWNopEe6te/BFUZOYAZI+qhg==",
+      "version": "0.13.0",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-0.13.0.tgz",
+      "integrity": "sha512-t3m7ta0EspzDxSOZh3cEOJIJVZgN/TlJYaBGnQlK6W/PZNbWep8q4RQskkJkA7/zwNpX0BaoEOSUUrqaADVoqA==",
       "dev": true
     },
     "eslint-scope": {
       }
     },
     "globals": {
-      "version": "13.12.1",
-      "resolved": "https://registry.npmjs.org/globals/-/globals-13.12.1.tgz",
-      "integrity": "sha512-317dFlgY2pdJZ9rspXDks7073GpDmXdfbM3vYYp0HAMKGDh1FfWPleI2ljVNLQX5M5lXcAslTcPTrOrMEFOjyw==",
+      "version": "13.13.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-13.13.0.tgz",
+      "integrity": "sha512-EQ7Q18AJlPwp3vUDL4mKA0KXrXyNIQyWon6T6XQiBQF0XHvRsiCSrWmmeATpUzdJN2HhWZU6Pdl0a9zdep5p6A==",
       "dev": true,
       "requires": {
         "type-fest": "^0.20.2"
       }
     },
     "mime-db": {
-      "version": "1.51.0",
-      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.51.0.tgz",
-      "integrity": "sha512-5y8A56jg7XVQx2mbv1lu49NR4dokRnhZYTtL+KGfaa27uq4pSTXkwQkFJl4pkRMyNFz/EtYDSkiiEHx3F7UN6g=="
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
     },
     "minimatch": {
       "version": "3.0.4",
       }
     },
     "minimist": {
-      "version": "1.2.5",
-      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
-      "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
+      "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
       "dev": true
     },
     "mocha": {
         "picomatch": "^2.2.1"
       }
     },
+    "regexp-tree": {
+      "version": "0.1.24",
+      "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.24.tgz",
+      "integrity": "sha512-s2aEVuLhvnVJW6s/iPgEGK6R+/xngd2jNQ+xy4bXNDKxZKJH6jpPHY6kVeVv1IeLCHgswRj+Kl3ELaDjG6V1iw==",
+      "dev": true
+    },
     "regexpp": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.1.0.tgz",
       "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
       "dev": true
     },
-    "ret": {
-      "version": "0.1.15",
-      "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
-      "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==",
-      "dev": true
-    },
     "rimraf": {
       "version": "3.0.2",
       "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
       "dev": true
     },
     "safe-regex": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz",
-      "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=",
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz",
+      "integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==",
       "dev": true,
       "requires": {
-        "ret": "~0.1.10"
+        "regexp-tree": "~0.1.1"
       }
     },
     "semver": {
       "dev": true
     },
     "sinon": {
-      "version": "13.0.1",
-      "resolved": "https://registry.npmjs.org/sinon/-/sinon-13.0.1.tgz",
-      "integrity": "sha512-8yx2wIvkBjIq/MGY1D9h1LMraYW+z1X0mb648KZnKSdvLasvDu7maa0dFaNYdTDczFgbjNw2tOmWdTk9saVfwQ==",
+      "version": "13.0.2",
+      "resolved": "https://registry.npmjs.org/sinon/-/sinon-13.0.2.tgz",
+      "integrity": "sha512-KvOrztAVqzSJWMDoxM4vM+GPys1df2VBoXm+YciyB/OLMamfS3VXh3oGh5WtrAGSzrgczNWFFY22oKb7Fi5eeA==",
       "dev": true,
       "requires": {
         "@sinonjs/commons": "^1.8.3",
-        "@sinonjs/fake-timers": "^9.0.0",
+        "@sinonjs/fake-timers": "^9.1.2",
         "@sinonjs/samsam": "^6.1.1",
         "diff": "^5.0.0",
         "nise": "^5.1.1",
index 84e5763d7f8bdea78e538a39c2ba084d3d352ad4..6bff130c4a934b1efe48a29633c4ce5b79c3b79f 100644 (file)
@@ -1,6 +1,6 @@
 {
   "name": "@squeep/api-dingus",
-  "version": "1.2.5",
+  "version": "1.2.6",
   "description": "A minimal API server framework",
   "main": "index.js",
   "scripts": {
   "author": "Justin Wind <jwind-npm@squeep.com>",
   "license": "ISC",
   "dependencies": {
-    "mime-db": "^1.51.0",
+    "mime-db": "^1.52.0",
     "uuid": "^8.3.2"
   },
   "devDependencies": {
-    "eslint": "^8.11.0",
+    "eslint": "^8.14.0",
     "eslint-plugin-node": "^11.1.0",
-    "eslint-plugin-security": "^1.4.0",
-    "eslint-plugin-sonarjs": "^0.12.0",
+    "eslint-plugin-security": "^1.5.0",
+    "eslint-plugin-sonarjs": "^0.13.0",
     "mocha": "^9.2.2",
     "nyc": "^15.1.0",
     "pre-commit": "^1.2.2",
-    "sinon": "^13.0.1"
+    "sinon": "^13.0.2"
   }
 }
index bacd6be3d54d2aaa7e6b019c98e0e664050392dc..57c4113a9db986dbee499942e6e11ea0a78964b9 100644 (file)
@@ -196,6 +196,32 @@ describe('common', function () {
     });
   }); // responseLogData
 
+  describe('handlerLogData', function () {
+    it('covers', function () {
+      const req = {
+        method: 'GET',
+        somethingElse: 'blah',
+      };
+      const res = {
+        getHeaders: () => ({}),
+        statusCode: 200,
+        blah: 'blah',
+      };
+      const ctx = {};
+      const result = common.handlerLogData(req, res, ctx);
+      assert.deepStrictEqual(result, {
+        req: {
+          method: 'GET',
+        },
+        res: {
+          headers: {},
+          statusCode: 200,
+        },
+        ctx: {},
+      });
+    });
+  }); // handlerLogData
+
   describe('setOptions', function () {
     it('sets options', function () {
       const expected = {
index 356974fee0f48342dd80cfd710ccbd91bb64b210..ab61256b3a89162c17bfb002a0569f7975786efc 100644 (file)
@@ -535,6 +535,14 @@ describe('Dingus', function () {
         assert.strictEqual(e.statusCode, 413);
       }
     });
+    it('provides buffer', async function () {
+      const p = dingus.bodyData(res, 0, false);
+      const expected = Buffer.from('bleat');
+      resEvents['data'](expected);
+      resEvents['end']();
+      const result = await p;
+      assert.deepStrictEqual(result, expected);
+    });
   }); // bodyData
 
   describe('ingestBody', function () {
@@ -813,6 +821,13 @@ Content-Type: image/sgi
       await dingus.serveFile(req, res, ctx, directory, fileName);
       assert(res.end.called);
     });
+    it('handles misconfigured encoding', async function () {
+      Enum.EncodingType.Flarp = 'flarp';
+      req._headers[Enum.Header.AcceptEncoding] = 'flarp, gzip';
+      await dingus.serveFile(req, res, ctx, directory, fileName);
+      delete Enum.EncodingType.Flarp;
+      assert(res.end.called);
+    });
   }); // serveFile
 
   describe('renderError', function () {
index 7cc92a04d984fb9cdac009b9ce672e55acb24691..d63917087d1490021d5441564669008c60f1676c 100644 (file)
@@ -5,6 +5,7 @@
 const assert = require('assert');
 const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require
 const Router = require('../../lib/router');
+const PathParameter = require('../../lib/router/path-parameter')
 const { DingusError } = require('../../lib/errors');
 
 const noExpectedException = 'did not get expected exception';
@@ -22,61 +23,61 @@ describe('Router', function () {
     router.ignoreTrailingSlash = _its;
   });
 
-  describe('_pathDefinitionToPathMatch', function () {
+  describe('_pathToRoutePath', function () {
     it('defines a simple path', function () {
       const p = '/a/b/c';
       const expected = ['', 'a', 'b', 'c'];
-      expected[router.METHODS] = {};
-      const r = router._pathDefinitionToPathMatch(p);
+      expected[Router.kPathMethods] = {};
+      const r = router._pathToRoutePath(p);
       assert.deepStrictEqual(r, expected);
     });
     it('defines a path with parameter', function () {
       const p = '/a/b/:c';
-      const expected = ['', 'a', 'b', { [router.PARAM]: 'c' }];
-      expected[router.METHODS] = {};
-      const r = router._pathDefinitionToPathMatch(p);
+      const expected = ['', 'a', 'b', new PathParameter('c')];
+      expected[Router.kPathMethods] = {};
+      const r = router._pathToRoutePath(p);
       assert.deepStrictEqual(r, expected);
     });
     it('defines a path with trailing slash', function () {
       router.ignoreTrailingSlash = true;
       const p = '/a/b/:c/';
-      const expected = ['', 'a', 'b', { [router.PARAM]: 'c' }];
-      expected[router.METHODS] = {};
-      const r = router._pathDefinitionToPathMatch(p);
+      const expected = ['', 'a', 'b', new PathParameter('c')];
+      expected[Router.kPathMethods] = {};
+      const r = router._pathToRoutePath(p);
       assert.deepStrictEqual(r, expected);
     });
-  }); // _pathDefinitionToPathMatch
+  }); // _pathToRoutePath
 
   describe('_pathCompareExact', function () {
     let fixedPath, checkPath;
 
     it('compares static paths which match', function () {
-      fixedPath = router._pathDefinitionToPathMatch('/a/b/c');
-      checkPath = router._pathDefinitionToPathMatch('/a/b/c');
+      fixedPath = router._pathToRoutePath('/a/b/c');
+      checkPath = router._pathToRoutePath('/a/b/c');
       const r = Router._pathCompareExact(fixedPath, checkPath);
       assert.strictEqual(r, true);
     });
     it('compares static paths which do not match', function () {
-      fixedPath = router._pathDefinitionToPathMatch('/a/b/c');
-      checkPath = router._pathDefinitionToPathMatch('/a/b/d');
+      fixedPath = router._pathToRoutePath('/a/b/c');
+      checkPath = router._pathToRoutePath('/a/b/d');
       const r = Router._pathCompareExact(fixedPath, checkPath);
       assert.strictEqual(r, false);
     });
     it('compares unequal static paths', function () {
-      fixedPath = router._pathDefinitionToPathMatch('/a/b/c');
-      checkPath = router._pathDefinitionToPathMatch('/a/b');
+      fixedPath = router._pathToRoutePath('/a/b/c');
+      checkPath = router._pathToRoutePath('/a/b');
       const r = Router._pathCompareExact(fixedPath, checkPath);
       assert.strictEqual(r, false);
     });
     it('compares param paths which match', function () {
-      fixedPath = router._pathDefinitionToPathMatch('/a/:b/c');
-      checkPath = router._pathDefinitionToPathMatch('/a/:b/c');
+      fixedPath = router._pathToRoutePath('/a/:b/c');
+      checkPath = router._pathToRoutePath('/a/:b/c');
       const r = Router._pathCompareExact(fixedPath, checkPath);
       assert.strictEqual(r, true);
     });
     it('compares param paths which do not match', function () {
-      fixedPath = router._pathDefinitionToPathMatch('/a/:b/c');
-      checkPath = router._pathDefinitionToPathMatch('/a/:g/c');
+      fixedPath = router._pathToRoutePath('/a/:b/c');
+      checkPath = router._pathToRoutePath('/a/:g/c');
       const r = Router._pathCompareExact(fixedPath, checkPath);
       assert.strictEqual(r, false);
     });
@@ -88,8 +89,8 @@ describe('Router', function () {
     it('compares static paths which match', function () {
       const params = {};
       const expectedParams = {};
-      fixedPath = router._pathDefinitionToPathMatch('/a/b/c');
-      checkPath = router._pathDefinitionToPathMatch('/a/b/c');
+      fixedPath = router._pathToRoutePath('/a/b/c');
+      checkPath = router._pathToRoutePath('/a/b/c');
       const r = Router._pathCompareParam(fixedPath, checkPath);
       assert.strictEqual(r, true);
       assert.deepStrictEqual(params, expectedParams);
@@ -97,8 +98,8 @@ describe('Router', function () {
     it('compares static paths which do not match', function () {
       const params = {};
       const expectedParams = {};
-      fixedPath = router._pathDefinitionToPathMatch('/a/b/c');
-      checkPath = router._pathDefinitionToPathMatch('/a/b/d');
+      fixedPath = router._pathToRoutePath('/a/b/c');
+      checkPath = router._pathToRoutePath('/a/b/d');
       const r = Router._pathCompareParam(fixedPath, checkPath, params);
       assert.strictEqual(r, false);
       assert.deepStrictEqual(params, expectedParams);
@@ -106,8 +107,8 @@ describe('Router', function () {
     it('compares unequal static paths', function () {
       const params = {};
       const expectedParams = {};
-      fixedPath = router._pathDefinitionToPathMatch('/a/b/c');
-      checkPath = router._pathDefinitionToPathMatch('/a/b');
+      fixedPath = router._pathToRoutePath('/a/b/c');
+      checkPath = router._pathToRoutePath('/a/b');
       const r = Router._pathCompareParam(fixedPath, checkPath, params);
       assert.strictEqual(r, false);
       assert.deepStrictEqual(params, expectedParams);
@@ -117,8 +118,8 @@ describe('Router', function () {
       const expectedParams = {
         b: 'bar',
       };
-      fixedPath = router._pathDefinitionToPathMatch('/a/:b/c');
-      checkPath = router._pathDefinitionToPathMatch('/a/bar/c');
+      fixedPath = router._pathToRoutePath('/a/:b/c');
+      checkPath = router._pathToRoutePath('/a/bar/c');
       const r = Router._pathCompareParam(fixedPath, checkPath, params);
       assert.strictEqual(r, true);
       assert.deepStrictEqual(params, expectedParams);
@@ -129,8 +130,8 @@ describe('Router', function () {
         b: 'gaz',
         c: '123',
       };
-      fixedPath = router._pathDefinitionToPathMatch('/a/:b/:c');
-      checkPath = router._pathDefinitionToPathMatch('/a/gaz/123');
+      fixedPath = router._pathToRoutePath('/a/:b/:c');
+      checkPath = router._pathToRoutePath('/a/gaz/123');
       const r = Router._pathCompareParam(fixedPath, checkPath, params);
       assert.strictEqual(r, true);
       assert.deepStrictEqual(params, expectedParams);
@@ -143,8 +144,8 @@ describe('Router', function () {
     beforeEach(function () {
       pathsByLengthOrig = router.pathsByLength;
       router.pathsByLength = {
-        2: [ router._pathDefinitionToPathMatch('/:id') ],
-        3: [ router._pathDefinitionToPathMatch('/a/b') ],
+        2: [ router._pathToRoutePath('/:id') ],
+        3: [ router._pathToRoutePath('/a/b') ],
       };
     });
     afterEach(function () {
@@ -167,7 +168,7 @@ describe('Router', function () {
 
     describe('_pathFindExact', function () {
       it('finds a path', function () {
-        const pathParts = ['', { [router.PARAM]: 'id' }];
+        const pathParts = ['', new PathParameter('id')];
         const r = router._pathFindExact(pathParts);
         assert.strictEqual(r, router.pathsByLength[2][0]);
       });
@@ -191,7 +192,7 @@ describe('Router', function () {
     beforeEach(function () {
       pathsByLengthOrig = router.pathsByLength;
       router.pathsByLength = {
-        2: [ router._pathDefinitionToPathMatch('/:id') ],
+        2: [ router._pathToRoutePath('/:id') ],
       };
     });
     afterEach(function () {
@@ -200,17 +201,17 @@ describe('Router', function () {
   
     it('adds new path', function () {
       const urlPath = '/a/:id';
-      const expected = router._pathDefinitionToPathMatch(urlPath);
-      expected[router.METHODS]['GET'] = stubEntry;
+      const expected = router._pathToRoutePath(urlPath);
+      expected[Router.kPathMethods]['GET'] = stubEntry;
       router.on('GET', urlPath, stubHandler);
       assert.deepStrictEqual(router.pathsByLength[3][0], expected);
     });
 
     it('adds new method to path', function () {
       const urlPath = '/a/:id';
-      const expected = router._pathDefinitionToPathMatch(urlPath);
-      expected[router.METHODS]['GET'] = stubEntry;
-      expected[router.METHODS]['POST'] = stubEntry;
+      const expected = router._pathToRoutePath(urlPath);
+      expected[Router.kPathMethods]['GET'] = stubEntry;
+      expected[Router.kPathMethods]['POST'] = stubEntry;
       router.on('GET', urlPath, stubHandler);
 
       router.on('POST', urlPath, stubHandler);
@@ -219,8 +220,8 @@ describe('Router', function () {
 
     it('add some more paths', function () {
       let urlPath = '/a/b/c/d';
-      const expected = router._pathDefinitionToPathMatch(urlPath);
-      expected[router.METHODS]['GET'] = stubEntry;
+      const expected = router._pathToRoutePath(urlPath);
+      expected[Router.kPathMethods]['GET'] = stubEntry;
       router.on('GET', urlPath, stubHandler);
       urlPath = '/a/b/x/y';
       router.on('GET', urlPath, stubHandler);
@@ -230,9 +231,9 @@ describe('Router', function () {
 
     it('adds multiple methods', function () {
       const urlPath = '/:id';
-      const expected = router._pathDefinitionToPathMatch(urlPath);
-      expected[router.METHODS]['GET'] = stubEntry;
-      expected[router.METHODS]['HEAD'] = stubEntry;
+      const expected = router._pathToRoutePath(urlPath);
+      expected[Router.kPathMethods]['GET'] = stubEntry;
+      expected[Router.kPathMethods]['HEAD'] = stubEntry;
 
       router.on(['GET', 'HEAD'], urlPath, stubHandler);
       assert.deepStrictEqual(router.pathsByLength[2][0], expected);
@@ -240,8 +241,8 @@ describe('Router', function () {
 
     it('adds new wildcard path', function () {
       const urlPath = '/a/:id';
-      const expected = router._pathDefinitionToPathMatch(urlPath);
-      expected[router.METHODS]['*'] = stubEntry;
+      const expected = router._pathToRoutePath(urlPath);
+      expected[Router.kPathMethods]['*'] = stubEntry;
       router.on('*', urlPath, stubHandler);
       assert.deepStrictEqual(router.pathsByLength[3][0], expected);
     });
diff --git a/test/lib/router/path-parameter.js b/test/lib/router/path-parameter.js
new file mode 100644 (file)
index 0000000..9b2fb1e
--- /dev/null
@@ -0,0 +1,51 @@
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const PathParameter = require('../../../lib/router/path-parameter');
+
+const noExpectedException = 'did not receive expected exception';
+describe('PathParameter', function () {
+  beforeEach(function () {
+    PathParameter._flush();
+  });
+  it('requires a parameter', function () {
+    try {
+      new PathParameter();
+      assert.fail(noExpectedException);
+    } catch (e) {
+      assert(e instanceof RangeError, noExpectedException);
+    }
+  });
+  it('requires parameter be a string', function () {
+    try {
+      new PathParameter({});
+      assert.fail(noExpectedException);
+    } catch (e) {
+      assert(e instanceof RangeError, noExpectedException);
+    }
+  });
+  it('creates a parameter object', function () {
+    const p = new PathParameter('foo');
+    assert(p instanceof PathParameter);
+    assert.strictEqual(p.parameter, 'foo');
+  });
+  it('duplicate parameters are the same object', function () {
+    const p1 = new PathParameter('foo');
+    const p2 = new PathParameter('foo');
+    assert.strictEqual(p1, p2);
+  });
+  it('shows itself', function () {
+    const p = new PathParameter('foo');
+    assert(p.toString().includes('foo'));
+  });
+  it('parameters are immutable', function () {
+    const p = new PathParameter('foo');
+    try {
+      p[PathParameter.kPathParameter] = 'bar';
+      assert.fail(noExpectedException);
+    } catch (e) {
+      assert(e instanceof TypeError, noExpectedException);
+    }
+  });
+}); // PathParameter
\ No newline at end of file