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