update devDependencies, add jsdoc lint, fix lint issues
[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('node:http');
9 const common = require('../common');
10 const { DingusError, RouterNoPathError, RouterNoMethodError } = 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 * @typedef {Array<string|PathParameter>} RoutePath
23 * @property {{[method: string]: PathHandler}} kPathMethods (symbol key)
24 */
25 /**
26 * @callback HandlerFn
27 */
28 /**
29 * @typedef {object} PathHandler
30 * @property {HandlerFn} handler invoked on path match
31 * @property {any[]} handlerArgs passed to handler
32 */
33 /**
34 * @typedef MatchedPath
35 * @property {object} pathParams populated param fields
36 * @property {RoutePath=} matchedPath matched path
37 */
38
39 /**
40 * A naïve router which maps incoming requests to handler functions
41 * by way of url path and request method.
42 *
43 * Regex parsing of paths was eschewed, as a design decision.
44 *
45 * Instead, each path to be searched for is deconstructed into a list
46 * of its constituent parts as strings or objects, for invariant or
47 * parameterized parts respectively. Each search path is assigned a
48 * mapping of methods to handler functions.
49 */
50 class Router {
51 static kPathMethods = kPathMethods;
52 pathsByLength;
53
54 /**
55 * @param {object} options options object
56 * @param {boolean} options.ignoreTrailingSlash discard any trailing slashes when registering and comparing paths (default: false)
57 * @param {string} options.paramPrefix prefix of a path part denoting a named parameter when registering paths (default: ':')
58 */
59 constructor(options = {}) {
60 common.setOptions(this, defaultOptions, options);
61
62 /**
63 * Keep lists of registered paths to match, indexed by number of path parts.
64 * @type {{[length: number]: RoutePath[]}}
65 */
66 this.pathsByLength = {
67 1: [],
68 };
69 }
70
71
72 /**
73 * Prepare a path for insertion into search list.
74 * A route path is an Array of path parts, with a symbolic property of an object mapping method handlers.
75 * @param {string} rawPath path string
76 * @returns {RoutePath} route path
77 * @private
78 */
79 _pathToRoutePath(rawPath) {
80 const routePath = rawPath
81 .split('/')
82 .map((p) => this._pathPartMunge(p));
83
84 if (this.ignoreTrailingSlash
85 && routePath[routePath.length - 1] === '') {
86 routePath.pop();
87 }
88
89 routePath[kPathMethods] = {};
90
91 Object.defineProperty(routePath, 'path', {
92 enumerable: false,
93 value: rawPath,
94 });
95
96 Object.freeze(routePath);
97 return routePath;
98 }
99
100
101 /**
102 * Convert a path part string to parameter if needed.
103 * Remove escape from an escaped parameter.
104 * @param {string} part component of a path
105 * @returns {PathParameter|string} component or parameter object
106 * @private
107 */
108 _pathPartMunge(part) {
109 if (part.startsWith(this.paramPrefix)) {
110 return new PathParameter(part.slice(this.paramPrefix.length));
111 }
112 if (part.startsWith('\\' + this.paramPrefix)) {
113 return part.slice(1);
114 }
115 return part;
116 }
117
118
119 /**
120 * Compare checkPath to fixedPath, no param substitution, params must match.
121 * @param {RoutePath} routePath route path
122 * @param {RoutePath} checkPath path to match
123 * @returns {boolean} matched
124 * @private
125 */
126 static _pathCompareExact(routePath, checkPath) {
127 if (routePath.length !== checkPath.length) {
128 return false;
129 }
130 for (let i = 0; i < routePath.length; i++) {
131 const fixedPart = routePath[i];
132 const checkPart = checkPath[i];
133 if (fixedPart instanceof PathParameter && checkPart instanceof PathParameter) {
134 if (fixedPart.parameter !== checkPart.parameter) {
135 return false;
136 }
137 } else if (fixedPart !== checkPart) {
138 return false;
139 }
140 }
141 return true;
142 }
143
144
145 /**
146 * Compare routePath to fixedPath, populating params.
147 * @param {RoutePath} routePath route path
148 * @param {Array<string>} checkPath request path to check
149 * @param {object} returnParams populated param components on match
150 * @returns {boolean} matched
151 * @private
152 */
153 static _pathCompareParam(routePath, checkPath, returnParams = {}) {
154 const params = {};
155
156 if (routePath.length !== checkPath.length) {
157 return false;
158 }
159 for (let i = 0; i < routePath.length; i++) {
160 const fixedPart = routePath[i];
161 const checkPart = checkPath[i];
162 if (fixedPart instanceof PathParameter) {
163 params[fixedPart.parameter] = checkPart;
164 } else if (fixedPart !== checkPart) {
165 return false;
166 }
167 }
168 Object.assign(returnParams, params);
169 return true;
170 }
171
172
173 /**
174 * Search for an existing path, return matched path and path parameters.
175 * @param {Array<string>} matchParts path components
176 * @returns {MatchedPath} matched or not
177 * @private
178 */
179 _pathFind(matchParts) {
180 const result = {
181 pathParams: {},
182 matchedPath: undefined,
183 };
184 const pathsByLength = this.pathsByLength[matchParts.length];
185 if (pathsByLength) {
186 for (const p of pathsByLength) {
187 if (Router._pathCompareParam(p, matchParts, result.pathParams)) {
188 result.matchedPath = p;
189 break;
190 }
191 result.pathParams = {}; // Reset after potential population from failed match.
192 }
193 }
194 return result;
195 }
196
197
198 /**
199 * Return a matching path, no param substitution, params must match
200 * @param {RoutePath} routePath path
201 * @returns {RoutePath=} matched
202 * @private
203 */
204 _pathFindExact(routePath) {
205 const pathsByLength = this.pathsByLength[routePath.length];
206 if (pathsByLength) {
207 for (const p of pathsByLength) {
208 if (Router._pathCompareExact(p, routePath)) {
209 return p;
210 }
211 }
212 }
213 return undefined;
214 }
215
216
217 /**
218 * Insert a new path handler.
219 * @param {string|string[]} methods methods to match for this handler, '*' allowed
220 * @param {string} urlPath request path to match
221 * @param {HandlerFn} handler handler to invoke on match
222 * @param {any[]} handlerArgs additional arguments for handler
223 */
224 on(methods, urlPath, handler, handlerArgs = []) {
225 const matchParts = this._pathToRoutePath(urlPath);
226 let existingPath = this._pathFindExact(matchParts);
227 if (!existingPath) {
228 existingPath = matchParts;
229 if (!(matchParts.length in this.pathsByLength)) {
230 this.pathsByLength[matchParts.length] = [];
231 }
232 this.pathsByLength[matchParts.length].push(existingPath);
233 }
234 if (!Array.isArray(methods)) {
235 methods = [methods];
236 }
237 if (!Array.isArray(handlerArgs)) {
238 throw new TypeError(`handlerArgs must be an Array, not '${typeof handlerArgs}'`);
239 }
240 methods.forEach((method) => {
241 if (!httpMethods.includes(method) && method !== '*') {
242 throw new DingusError(`invalid method '${method}'`);
243 }
244 existingPath[kPathMethods][method] = { handler, handlerArgs };
245 });
246 }
247
248
249 /**
250 * Return an object, which contains a matching handler and any extra
251 * arguments, for a requested url.
252 * Also sets path named-parameters as #params and the matched path as
253 * #matchedPath on the context.
254 * @param {string} method request method
255 * @param {string[]} urlPath request path
256 * @param {object} ctx request context, updated with matched metadata
257 * @returns {PathHandler} matched path
258 */
259 lookup(method, urlPath, ctx = {}) {
260 const pathParts = urlPath.split('/').map((part) => decodeURIComponent(part));
261 if (this.ignoreTrailingSlash
262 && pathParts[pathParts.length - 1] === '') {
263 pathParts.pop();
264 }
265 const { matchedPath, pathParams } = this._pathFind(pathParts);
266 ctx.params = pathParams;
267 if (matchedPath) {
268 ctx.matchedPath = matchedPath.path;
269 if (method in matchedPath[kPathMethods]) {
270 return matchedPath[kPathMethods][method];
271 }
272 if ('*' in matchedPath[kPathMethods]) {
273 return matchedPath[kPathMethods]['*'];
274 }
275 throw new RouterNoMethodError();
276 }
277 ctx.unmatchedPath = pathParts;
278 throw new RouterNoPathError();
279 }
280
281
282 }
283
284 module.exports = Router;