Merge branch 'v2.1-dev'
[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, 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 * 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) => this._pathPartMunge(p));
65
66 if (this.ignoreTrailingSlash
67 && routePath[routePath.length - 1] === '') {
68 routePath.pop();
69 }
70
71 routePath[kPathMethods] = {};
72
73 Object.defineProperty(routePath, 'path', {
74 enumerable: false,
75 value: rawPath,
76 });
77
78 Object.freeze(routePath);
79 return routePath;
80 }
81
82
83 /*
84 * Convert a path part string to parameter if needed.
85 * Remove escape from an escaped parameter.
86 * @param {String} part
87 * @returns {PathParameter|String}
88 * @private
89 */
90 _pathPartMunge(part) {
91 if (part.startsWith(this.paramPrefix)) {
92 return new PathParameter(part.slice(this.paramPrefix.length));
93 }
94 if (part.startsWith('\\' + this.paramPrefix)) {
95 return part.slice(1);
96 }
97 return part;
98 }
99
100
101 /**
102 * Compare checkPath to fixedPath, no param substitution, params must match.
103 * @param {Router~RoutePath} routePath
104 * @param {Router~RoutePath} checkPath
105 * @returns {Boolean}
106 * @private
107 */
108 static _pathCompareExact(routePath, checkPath) {
109 if (routePath.length !== checkPath.length) {
110 return false;
111 }
112 for (let i = 0; i < routePath.length; i++) {
113 const fixedPart = routePath[i];
114 const checkPart = checkPath[i];
115 if (fixedPart instanceof PathParameter && checkPart instanceof PathParameter) {
116 if (fixedPart.parameter !== checkPart.parameter) {
117 return false;
118 }
119 } else if (fixedPart !== checkPart) {
120 return false;
121 }
122 }
123 return true;
124 }
125
126
127 /**
128 * Compare routePath to fixedPath, populating params.
129 * @param {Router~RoutePath} routePath
130 * @param {Array<String>} checkPath
131 * @param {Object} returnParams
132 * @returns {Boolean}
133 * @private
134 */
135 static _pathCompareParam(routePath, checkPath, returnParams = {}) {
136 const params = {};
137
138 if (routePath.length !== checkPath.length) {
139 return false;
140 }
141 for (let i = 0; i < routePath.length; i++) {
142 const fixedPart = routePath[i];
143 const checkPart = checkPath[i];
144 if (fixedPart instanceof PathParameter) {
145 params[fixedPart.parameter] = checkPart;
146 } else if (fixedPart !== checkPart) {
147 return false;
148 }
149 }
150 Object.assign(returnParams, params);
151 return true;
152 }
153
154
155 /**
156 * @typedef Router~MatchedPath
157 * @property {Object} pathParams populated param fields
158 * @property {Router~RoutePath} matchedPath
159 */
160 /**
161 * Search for an existing path, return matched path and path parameters.
162 * @param {Array<String>} matchParts
163 * @returns {Router~MatchedPath}
164 * @private
165 */
166 _pathFind(matchParts) {
167 const result = {
168 pathParams: {},
169 matchedPath: undefined,
170 };
171 const pathsByLength = this.pathsByLength[matchParts.length];
172 if (pathsByLength) {
173 for (const p of pathsByLength) {
174 if (Router._pathCompareParam(p, matchParts, result.pathParams)) {
175 result.matchedPath = p;
176 break;
177 }
178 }
179 }
180 return result;
181 }
182
183
184 /**
185 * Return a matching path, no param substitution, params must match
186 * @param {Router~RoutePath} routePath
187 * @returns {Router~RoutePath=}
188 * @private
189 */
190 _pathFindExact(routePath) {
191 const pathsByLength = this.pathsByLength[routePath.length];
192 if (pathsByLength) {
193 for (const p of pathsByLength) {
194 if (Router._pathCompareExact(p, routePath)) {
195 return p;
196 }
197 }
198 }
199 return undefined;
200 }
201
202
203 /**
204 * @callback Router~HandlerFn
205 */
206 /**
207 * @typedef {Object} Router~PathHandler
208 * @property {Router~HandlerFn} handler
209 * @property {any[]} handlerArgs
210 */
211 /**
212 * Insert a new path handler.
213 * @param {string|string[]} methods
214 * @param {string} urlPath
215 * @param {Router~HandlerFn} handler
216 * @param {any[]} handlerArgs
217 */
218 on(methods, urlPath, handler, handlerArgs = []) {
219 const matchParts = this._pathToRoutePath(urlPath);
220 let existingPath = this._pathFindExact(matchParts);
221 if (!existingPath) {
222 existingPath = matchParts;
223 if (!(matchParts.length in this.pathsByLength)) {
224 this.pathsByLength[matchParts.length] = [];
225 }
226 this.pathsByLength[matchParts.length].push(existingPath);
227 }
228 if (!Array.isArray(methods)) {
229 methods = [methods];
230 }
231 if (!Array.isArray(handlerArgs)) {
232 throw new TypeError(`handlerArgs must be an Array, not '${typeof handlerArgs}'`);
233 }
234 methods.forEach((method) => {
235 if (!httpMethods.includes(method) && method !== '*') {
236 throw new DingusError(`invalid method '${method}'`);
237 }
238 existingPath[kPathMethods][method] = { handler, handlerArgs };
239 });
240 }
241
242
243 /**
244 * Return an object, which contains a matching handler and any extra
245 * arguments, for a requested url.
246 * Also sets path named-parameters as #params and the matched path as
247 * #matchedPath on the context.
248 * @param {string} method
249 * @param {string[]} urlPath
250 * @param {object} ctx
251 * @returns {Router~PathHandler}
252 */
253 lookup(method, urlPath, ctx = {}) {
254 const pathParts = urlPath.split('/').map((part) => decodeURIComponent(part));
255 if (this.ignoreTrailingSlash
256 && pathParts[pathParts.length - 1] === '') {
257 pathParts.pop();
258 }
259 const { matchedPath, pathParams } = this._pathFind(pathParts);
260 ctx.params = pathParams;
261 if (matchedPath) {
262 ctx.matchedPath = matchedPath.path;
263 if (method in matchedPath[kPathMethods]) {
264 return matchedPath[kPathMethods][method];
265 }
266 if ('*' in matchedPath[kPathMethods]) {
267 return matchedPath[kPathMethods]['*'];
268 }
269 throw new RouterNoMethodError();
270 }
271 ctx.unmatchedPath = pathParts;
272 throw new RouterNoPathError();
273 }
274
275
276 }
277 Router.kPathMethods = kPathMethods;
278
279 module.exports = Router;