--- /dev/null
+/* 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;