update dependencies and devDependencies
[squeep-api-dingus] / lib / router / index.js
1 /* eslint-disable security/detect-object-injection */
2 'use strict';
3
4 /**
5 * A very simple router.
6 */
7
8 const { METHODS: httpMethods } = require('http');
9 const common = require('../common');
10 const { DingusError } = require('../errors');
11 const PathParameter = require('./path-parameter');
12
13 // Internal identifiers for route entries.
14 const kPathMethods = Symbol('kSqueepDingusRouterPathMethods');
15
16 const defaultOptions = {
17 ignoreTrailingSlash: false,
18 paramPrefix: ':',
19 };
20
21 /**
22 * A naïve router which maps incoming requests to handler functions
23 * by way of url path and request method.
24 *
25 * Regex parsing of paths was eschewed, as a design decision.
26 *
27 * Instead, each path to be searched for is deconstructed into a list
28 * of its constituent parts as strings or objects, for invariant or
29 * parameterized parts respectively. Each search path is assigned a
30 * mapping of methods to handler functions.
31 *
32 * @property {Object} pathsByLength index to registered paths by number of parts
33 */
34 class Router {
35 /**
36 * @param {Object} options
37 * @param {Boolean} options.ignoreTrailingSlash discard any trailing slashes when registering and comparing paths (default: false)
38 * @param {String} options.paramPrefix prefix of a path part denoting a named parameter when registering paths (default: ':')
39 */
40 constructor(options = {}) {
41 common.setOptions(this, defaultOptions, options);
42
43 // Keep lists of paths to match, indexed by path length.
44 this.pathsByLength = {
45 1: [],
46 };
47 }
48
49
50 /**
51 * @typedef {Array<String|PathParameter>} Router~RoutePath
52 * @property {Object} kPathMethods (symbol key)
53 */
54 /**
55 * Prepare a path for insertion into search list.
56 * A route path is an Array of path parts, with a symbolic property of an object mapping method handlers.
57 * @param {String} rawPath
58 * @returns {Router~RoutePath}
59 * @private
60 */
61 _pathToRoutePath(rawPath) {
62 const routePath = rawPath
63 .split('/')
64 .map((p) => p.startsWith(this.paramPrefix) ? new PathParameter(p.slice(this.paramPrefix.length)) : p);
65 if (this.ignoreTrailingSlash
66 && routePath[routePath.length - 1] === '') {
67 routePath.pop();
68 }
69 routePath[kPathMethods] = {};
70 Object.freeze(routePath);
71 return routePath;
72 }
73
74
75 /**
76 * Compare checkPath to fixedPath, no param substitution, params must match.
77 * @param {Router~RoutePath} routePath
78 * @param {Router~RoutePath} checkPath
79 * @returns {Boolean}
80 * @private
81 */
82 static _pathCompareExact(routePath, checkPath) {
83 if (routePath.length !== checkPath.length) {
84 return false;
85 }
86 for (let i = 0; i < routePath.length; i++) {
87 const fixedPart = routePath[i];
88 const checkPart = checkPath[i];
89 if (fixedPart instanceof PathParameter && checkPart instanceof PathParameter) {
90 if (fixedPart.parameter !== checkPart.parameter) {
91 return false;
92 }
93 } else if (fixedPart !== checkPart) {
94 return false;
95 }
96 }
97 return true;
98 }
99
100
101 /**
102 * Compare routePath to fixedPath, populating params.
103 * @param {Router~RoutePath} routePath
104 * @param {Array<String>} checkPath
105 * @param {Object} returnParams
106 * @returns {Boolean}
107 * @private
108 */
109 static _pathCompareParam(routePath, checkPath, returnParams = {}) {
110 const params = {};
111
112 if (routePath.length !== checkPath.length) {
113 return false;
114 }
115 for (let i = 0; i < routePath.length; i++) {
116 const fixedPart = routePath[i];
117 const checkPart = checkPath[i];
118 if (fixedPart instanceof PathParameter) {
119 params[fixedPart.parameter] = checkPart;
120 } else if (fixedPart !== checkPart) {
121 return false;
122 }
123 }
124 Object.assign(returnParams, params);
125 return true;
126 }
127
128
129 /**
130 * @typedef Router~MatchedPath
131 * @property {Object} pathParams populated param fields
132 * @property {Router~RoutePath} matchedPath
133 */
134 /**
135 * Search for an existing path, return matched path and path parameters.
136 * @param {Array<String>} matchParts
137 * @returns {Router~MatchedPath}
138 * @private
139 */
140 _pathFind(matchParts) {
141 const result = {
142 pathParams: {},
143 matchedPath: undefined,
144 };
145 const pathsByLength = this.pathsByLength[matchParts.length];
146 if (pathsByLength) {
147 for (const p of pathsByLength) {
148 if (Router._pathCompareParam(p, matchParts, result.pathParams)) {
149 result.matchedPath = p;
150 break;
151 }
152 }
153 }
154 return result;
155 }
156
157
158 /**
159 * Return a matching path, no param substitution, params must match
160 * @param {Router~RoutePath} routePath
161 * @returns {Router~RoutePath=}
162 * @private
163 */
164 _pathFindExact(routePath) {
165 const pathsByLength = this.pathsByLength[routePath.length];
166 if (pathsByLength) {
167 for (const p of pathsByLength) {
168 if (Router._pathCompareExact(p, routePath)) {
169 return p;
170 }
171 }
172 }
173 return undefined;
174 }
175
176
177 /**
178 * @callback Router~HandlerFn
179 */
180 /**
181 * @typedef {Object} Router~PathHandler
182 * @property {Router~HandlerFn} handler
183 * @property {any[]} handlerArgs
184 */
185 /**
186 * Insert a new path handler.
187 * @param {string|string[]} methods
188 * @param {string} urlPath
189 * @param {Router~HandlerFn} handler
190 * @param {any[]} handlerArgs
191 */
192 on(methods, urlPath, handler, handlerArgs = []) {
193 const matchParts = this._pathToRoutePath(urlPath);
194 let existingPath = this._pathFindExact(matchParts);
195 if (!existingPath) {
196 existingPath = matchParts;
197 if (!(matchParts.length in this.pathsByLength)) {
198 this.pathsByLength[matchParts.length] = [];
199 }
200 this.pathsByLength[matchParts.length].push(existingPath);
201 }
202 if (!Array.isArray(methods)) {
203 methods = [methods];
204 }
205 if (!Array.isArray(handlerArgs)) {
206 throw new TypeError(`handlerArgs must be an Array, not '${typeof handlerArgs}'`);
207 }
208 methods.forEach((method) => {
209 if (!httpMethods.includes(method) && method !== '*') {
210 throw new DingusError(`invalid method '${method}'`);
211 }
212 existingPath[kPathMethods][method] = { handler, handlerArgs };
213 });
214 }
215
216
217 /**
218 * Return an object, which contains a matching handler and any extra
219 * arguments, for a requested url.
220 * Also sets path named-parameters as #params and the matched path as
221 * #matchedPath on the context.
222 * @param {string} method
223 * @param {string[]} urlPath
224 * @param {object} ctx
225 * @returns {Router~PathHandler}
226 */
227 lookup(method, urlPath, ctx = {}) {
228 const pathParts = urlPath.split('/').map((part) => decodeURIComponent(part));
229 if (this.ignoreTrailingSlash
230 && pathParts[pathParts.length - 1] === '') {
231 pathParts.pop();
232 }
233 const { matchedPath, pathParams } = this._pathFind(pathParts);
234 ctx.params = pathParams;
235 if (matchedPath) {
236 ctx.matchedPath = matchedPath;
237 if (method in matchedPath[kPathMethods]) {
238 return matchedPath[kPathMethods][method];
239 }
240 if ('*' in matchedPath[kPathMethods]) {
241 return matchedPath[kPathMethods]['*'];
242 }
243 throw new DingusError('NoMethod');
244 }
245 ctx.unmatchedPath = pathParts;
246 throw new DingusError('NoPath');
247 }
248
249
250 }
251 Router.kPathMethods = kPathMethods;
252
253 module.exports = Router;