+++ /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');
-
-// Internal identifiers for route entries.
-const METHODS = Symbol('METHODS');
-const PARAM = Symbol('PARAM');
-
-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
- * @property {Symbol} METHODS key to method:handler map on search paths
- * @property {Symbol} PARAM key to parameter name in search path 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: [],
- };
-
- this.METHODS = METHODS;
- this.PARAM = PARAM;
- }
-
- /**
- * @typedef {Object} Router~ParamPart
- * @property {String} PARAM (symbol key)
- */
- /**
- * @typedef {Array<String|Router~ParamPart>} Router~SearchPath
- * @property {Object} METHODS (symbol key)
- */
- /**
- * Prepare a path for insertion into search list.
- * A searchable path is a list of path parts, with a property of an object of method handlers.
- * @param {String} pathDefinition
- * @returns {Router~SearchPath}
- * @private
- */
- _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 {Router~SearchPath} fixedPath
- * @param {Router~SearchPath} checkPath
- * @returns {Boolean}
- * @private
- */
- 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 {Router~SearchPath} fixedPath
- * @param {Array<String>} checkPath
- * @param {Object} returnParams
- * @returns {Boolean}
- * @private
- */
- 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;
- }
-
-
- /**
- * @typedef Router~MatchedPath
- * @property {Object} pathParams populated param fields
- * @property {Router~SearchPath} 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~SearchPath} matchParts
- * @returns {Router~SearchPath=}
- * @private
- */
- _pathFindExact(matchParts) {
- const pathsByLength = this.pathsByLength[matchParts.length];
- if (pathsByLength) {
- for (const p of pathsByLength) {
- if (Router._pathCompareExact(p, matchParts)) {
- 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._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 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[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;