minor documentation updates
[squeep-api-dingus] / lib / router.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
12 // Internal identifiers for route entries.
13 const METHODS = Symbol('METHODS');
14 const PARAM = Symbol('PARAM');
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 * @property {Symbol} METHODS key to method:handler map on search paths
34 * @property {Symbol} PARAM key to parameter name in search path parts
35 */
36 class Router {
37 /**
38 * @param {Object} options
39 * @param {Boolean} options.ignoreTrailingSlash discard any trailing slashes when registering and comparing paths (default: false)
40 * @param {String} options.paramPrefix prefix of a path part denoting a named parameter when registering paths (default: ':')
41 */
42 constructor(options = {}) {
43 common.setOptions(this, defaultOptions, options);
44
45 // Keep lists of paths to match, indexed by path length.
46 this.pathsByLength = {
47 1: [],
48 };
49
50 this.METHODS = METHODS;
51 this.PARAM = PARAM;
52 }
53
54 /**
55 * @typedef {Object} Router~ParamPart
56 * @property {String} PARAM (symbol key)
57 */
58 /**
59 * @typedef {Array<String|Router~ParamPart>} Router~SearchPath
60 * @property {Object} METHODS (symbol key)
61 */
62 /**
63 * Prepare a path for insertion into search list.
64 * A searchable path is a list of path parts, with a property of an object of method handlers.
65 * @param {String} pathDefinition
66 * @returns {Router~SearchPath}
67 * @private
68 */
69 _pathDefinitionToPathMatch(pathDefinition) {
70 const pathMatch = pathDefinition
71 .split('/')
72 .map((p) => p.startsWith(this.paramPrefix) ? { [PARAM]: p.slice(this.paramPrefix.length) } : p);
73 if (this.ignoreTrailingSlash
74 && pathMatch[pathMatch.length - 1] === '') {
75 pathMatch.pop();
76 }
77 pathMatch[METHODS] = {};
78 pathMatch.forEach((p) => Object.freeze(p));
79 Object.freeze(pathMatch);
80 return pathMatch;
81 }
82
83
84 /**
85 * Compare checkPath to fixedPath, no param substitution, params must match.
86 * @param {Router~SearchPath} fixedPath
87 * @param {Router~SearchPath} checkPath
88 * @returns {Boolean}
89 * @private
90 */
91 static _pathCompareExact(fixedPath, checkPath) {
92 if (fixedPath.length !== checkPath.length) {
93 return false;
94 }
95 for (let i = 0; i < fixedPath.length; i++) {
96 const fixedPart = fixedPath[i];
97 const checkPart = checkPath[i];
98 if (typeof fixedPart === 'object' && typeof checkPart === 'object') {
99 if (fixedPart[PARAM] !== checkPart[PARAM]) {
100 return false;
101 }
102 } else if (fixedPart !== checkPart) {
103 return false;
104 }
105 }
106 return true;
107 }
108
109
110 /**
111 * Compare checkPath to fixedPath, populating params.
112 * @param {Router~SearchPath} fixedPath
113 * @param {Array<String>} checkPath
114 * @param {Object} returnParams
115 * @returns {Boolean}
116 * @private
117 */
118 static _pathCompareParam(fixedPath, checkPath, returnParams = {}) {
119 const params = {};
120
121 if (fixedPath.length !== checkPath.length) {
122 return false;
123 }
124 for (let i = 0; i < fixedPath.length; i++) {
125 const fixedPart = fixedPath[i];
126 const checkPart = checkPath[i];
127 if (typeof fixedPart === 'object') {
128 params[fixedPart[PARAM]] = checkPart;
129 } else if (fixedPart !== checkPart) {
130 return false;
131 }
132 }
133 Object.assign(returnParams, params);
134 return true;
135 }
136
137
138 /**
139 * @typedef Router~MatchedPath
140 * @property {Object} pathParams populated param fields
141 * @property {Router~SearchPath} matchedPath
142 */
143 /**
144 * Search for an existing path, return matched path and path parameters.
145 * @param {Array<String>} matchParts
146 * @returns {Router~MatchedPath}
147 * @private
148 */
149 _pathFind(matchParts) {
150 const result = {
151 pathParams: {},
152 matchedPath: undefined,
153 };
154 const pathsByLength = this.pathsByLength[matchParts.length];
155 if (pathsByLength) {
156 for (const p of pathsByLength) {
157 if (Router._pathCompareParam(p, matchParts, result.pathParams)) {
158 result.matchedPath = p;
159 break;
160 }
161 }
162 }
163 return result;
164 }
165
166
167 /**
168 * Return a matching path, no param substitution, params must match
169 * @param {Router~SearchPath} matchParts
170 * @returns {Router~SearchPath=}
171 * @private
172 */
173 _pathFindExact(matchParts) {
174 const pathsByLength = this.pathsByLength[matchParts.length];
175 if (pathsByLength) {
176 for (const p of pathsByLength) {
177 if (Router._pathCompareExact(p, matchParts)) {
178 return p;
179 }
180 }
181 }
182 return undefined;
183 }
184
185
186 /**
187 * @callback Router~HandlerFn
188 */
189 /**
190 * @typedef {Object} Router~PathHandler
191 * @property {Router~HandlerFn} handler
192 * @property {any[]} handlerArgs
193 */
194 /**
195 * Insert a new path handler.
196 * @param {string|string[]} methods
197 * @param {string} urlPath
198 * @param {Router~HandlerFn} handler
199 * @param {any[]} handlerArgs
200 */
201 on(methods, urlPath, handler, handlerArgs = []) {
202 const matchParts = this._pathDefinitionToPathMatch(urlPath);
203 let existingPath = this._pathFindExact(matchParts);
204 if (!existingPath) {
205 existingPath = matchParts;
206 if (!(matchParts.length in this.pathsByLength)) {
207 this.pathsByLength[matchParts.length] = [];
208 }
209 this.pathsByLength[matchParts.length].push(existingPath);
210 }
211 if (!Array.isArray(methods)) {
212 methods = [methods];
213 }
214 if (!Array.isArray(handlerArgs)) {
215 throw new TypeError(`handlerArgs must be an Array, not '${typeof handlerArgs}'`);
216 }
217 methods.forEach((method) => {
218 if (!httpMethods.includes(method) && method !== '*') {
219 throw new DingusError(`invalid method '${method}'`);
220 }
221 existingPath[METHODS][method] = { handler, handlerArgs };
222 });
223 }
224
225
226 /**
227 * Return an object, which contains a matching handler and any extra
228 * arguments, for a requested url.
229 * Also sets path named-parameters as #params and the matched path as
230 * #matchedPath on the context.
231 * @param {string} method
232 * @param {string[]} urlPath
233 * @param {object} ctx
234 * @returns {Router~PathHandler}
235 */
236 lookup(method, urlPath, ctx = {}) {
237 const pathParts = urlPath.split('/').map((part) => decodeURIComponent(part));
238 if (this.ignoreTrailingSlash
239 && pathParts[pathParts.length - 1] === '') {
240 pathParts.pop();
241 }
242 const { matchedPath, pathParams } = this._pathFind(pathParts);
243 ctx.params = pathParams;
244 if (matchedPath) {
245 ctx.matchedPath = matchedPath;
246 if (method in matchedPath[METHODS]) {
247 return matchedPath[METHODS][method];
248 }
249 if ('*' in matchedPath[METHODS]) {
250 return matchedPath[METHODS]['*'];
251 }
252 throw new DingusError('NoMethod');
253 }
254 ctx.unmatchedPath = pathParts;
255 throw new DingusError('NoPath');
256 }
257
258
259 }
260
261 module.exports = Router;