minor refactoring in router, clarifying names and complicating parameter objects
[squeep-api-dingus] / lib / router / index.js
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;