1 /* eslint-disable security/detect-object-injection */
5 * A very simple router.
8 const { METHODS: httpMethods
} = require('http');
9 const common
= require('../common');
10 const { DingusError
} = require('../errors');
11 const PathParameter
= require('./path-parameter');
13 // Internal identifiers for route entries.
14 const kPathMethods
= Symbol('kSqueepDingusRouterPathMethods');
16 const defaultOptions
= {
17 ignoreTrailingSlash: false,
22 * A naïve router which maps incoming requests to handler functions
23 * by way of url path and request method.
25 * Regex parsing of paths was eschewed, as a design decision.
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.
32 * @property {Object} pathsByLength index to registered paths by number of parts
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: ':')
40 constructor(options
= {}) {
41 common
.setOptions(this, defaultOptions
, options
);
43 // Keep lists of paths to match, indexed by path length.
44 this.pathsByLength
= {
51 * @typedef {Array<String|PathParameter>} Router~RoutePath
52 * @property {Object} kPathMethods (symbol key)
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}
61 _pathToRoutePath(rawPath
) {
62 const routePath
= rawPath
64 .map((p
) => this._pathPartMunge(p
));
66 if (this.ignoreTrailingSlash
67 && routePath
[routePath
.length
- 1] === '') {
71 routePath
[kPathMethods
] = {};
73 Object
.defineProperty(routePath
, 'path', {
78 Object
.freeze(routePath
);
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}
90 _pathPartMunge(part
) {
91 if (part
.startsWith(this.paramPrefix
)) {
92 return new PathParameter(part
.slice(this.paramPrefix
.length
));
94 if (part
.startsWith('\\' + this.paramPrefix
)) {
102 * Compare checkPath to fixedPath, no param substitution, params must match.
103 * @param {Router~RoutePath} routePath
104 * @param {Router~RoutePath} checkPath
108 static _pathCompareExact(routePath
, checkPath
) {
109 if (routePath
.length
!== checkPath
.length
) {
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
) {
119 } else if (fixedPart
!== checkPart
) {
128 * Compare routePath to fixedPath, populating params.
129 * @param {Router~RoutePath} routePath
130 * @param {Array<String>} checkPath
131 * @param {Object} returnParams
135 static _pathCompareParam(routePath
, checkPath
, returnParams
= {}) {
138 if (routePath
.length
!== checkPath
.length
) {
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
) {
150 Object
.assign(returnParams
, params
);
156 * @typedef Router~MatchedPath
157 * @property {Object} pathParams populated param fields
158 * @property {Router~RoutePath} matchedPath
161 * Search for an existing path, return matched path and path parameters.
162 * @param {Array<String>} matchParts
163 * @returns {Router~MatchedPath}
166 _pathFind(matchParts
) {
169 matchedPath: undefined,
171 const pathsByLength
= this.pathsByLength
[matchParts
.length
];
173 for (const p
of pathsByLength
) {
174 if (Router
._pathCompareParam(p
, matchParts
, result
.pathParams
)) {
175 result
.matchedPath
= p
;
185 * Return a matching path, no param substitution, params must match
186 * @param {Router~RoutePath} routePath
187 * @returns {Router~RoutePath=}
190 _pathFindExact(routePath
) {
191 const pathsByLength
= this.pathsByLength
[routePath
.length
];
193 for (const p
of pathsByLength
) {
194 if (Router
._pathCompareExact(p
, routePath
)) {
204 * @callback Router~HandlerFn
207 * @typedef {Object} Router~PathHandler
208 * @property {Router~HandlerFn} handler
209 * @property {any[]} handlerArgs
212 * Insert a new path handler.
213 * @param {string|string[]} methods
214 * @param {string} urlPath
215 * @param {Router~HandlerFn} handler
216 * @param {any[]} handlerArgs
218 on(methods
, urlPath
, handler
, handlerArgs
= []) {
219 const matchParts
= this._pathToRoutePath(urlPath
);
220 let existingPath
= this._pathFindExact(matchParts
);
222 existingPath
= matchParts
;
223 if (!(matchParts
.length
in this.pathsByLength
)) {
224 this.pathsByLength
[matchParts
.length
] = [];
226 this.pathsByLength
[matchParts
.length
].push(existingPath
);
228 if (!Array
.isArray(methods
)) {
231 if (!Array
.isArray(handlerArgs
)) {
232 throw new TypeError(`handlerArgs must be an Array, not '${typeof handlerArgs}'`);
234 methods
.forEach((method
) => {
235 if (!httpMethods
.includes(method
) && method
!== '*') {
236 throw new DingusError(`invalid method '${method}'`);
238 existingPath
[kPathMethods
][method
] = { handler
, handlerArgs
};
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}
253 lookup(method
, urlPath
, ctx
= {}) {
254 const pathParts
= urlPath
.split('/').map((part
) => decodeURIComponent(part
));
255 if (this.ignoreTrailingSlash
256 && pathParts
[pathParts
.length
- 1] === '') {
259 const { matchedPath
, pathParams
} = this._pathFind(pathParts
);
260 ctx
.params
= pathParams
;
262 ctx
.matchedPath
= matchedPath
.path
;
263 if (method
in matchedPath
[kPathMethods
]) {
264 return matchedPath
[kPathMethods
][method
];
266 if ('*' in matchedPath
[kPathMethods
]) {
267 return matchedPath
[kPathMethods
]['*'];
269 throw new DingusError('NoMethod');
271 ctx
.unmatchedPath
= pathParts
;
272 throw new DingusError('NoPath');
277 Router
.kPathMethods
= kPathMethods
;
279 module
.exports
= Router
;