2bad3e2b509373d22279d6a866f1dbd1e57a11c8
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');
12 // Internal identifiers for route entries.
13 const METHODS
= Symbol('METHODS');
14 const PARAM
= Symbol('PARAM');
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
33 * @property {Symbol} METHODS key to method:handler map on search paths
34 * @property {Symbol} PARAM key to parameter name in search path parts
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: ':')
42 constructor(options
= {}) {
43 common
.setOptions(this, defaultOptions
, options
);
45 // Keep lists of paths to match, indexed by path length.
46 this.pathsByLength
= {
50 this.METHODS
= METHODS
;
55 * @typedef {Object} Router~ParamPart
56 * @property {String} PARAM (symbol key)
59 * @typedef {Array<String|Router~ParamPart>} Router~SearchPath
60 * @property {Object} METHODS (symbol key)
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}
69 _pathDefinitionToPathMatch(pathDefinition
) {
70 const pathMatch
= pathDefinition
72 .map((p
) => p
.startsWith(this.paramPrefix
) ? { [PARAM
]: p
.slice(this.paramPrefix
.length
) } : p
);
73 if (this.ignoreTrailingSlash
74 && pathMatch
[pathMatch
.length
- 1] === '') {
77 pathMatch
[METHODS
] = {};
78 pathMatch
.forEach((p
) => Object
.freeze(p
));
79 Object
.freeze(pathMatch
);
85 * Compare checkPath to fixedPath, no param substitution, params must match.
86 * @param {Router~SearchPath} fixedPath
87 * @param {Router~SearchPath} checkPath
91 static _pathCompareExact(fixedPath
, checkPath
) {
92 if (fixedPath
.length
!== checkPath
.length
) {
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
]) {
102 } else if (fixedPart
!== checkPart
) {
111 * Compare checkPath to fixedPath, populating params.
112 * @param {Router~SearchPath} fixedPath
113 * @param {Array<String>} checkPath
114 * @param {Object} returnParams
118 static _pathCompareParam(fixedPath
, checkPath
, returnParams
= {}) {
121 if (fixedPath
.length
!== checkPath
.length
) {
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
) {
133 Object
.assign(returnParams
, params
);
139 * @typedef Router~MatchedPath
140 * @property {Object} pathParams populated param fields
141 * @property {Router~SearchPath} matchedPath
144 * Search for an existing path, return matched path and path parameters.
145 * @param {Array<String>} matchParts
146 * @returns {Router~MatchedPath}
149 _pathFind(matchParts
) {
152 matchedPath: undefined,
154 const pathsByLength
= this.pathsByLength
[matchParts
.length
];
156 for (const p
of pathsByLength
) {
157 if (Router
._pathCompareParam(p
, matchParts
, result
.pathParams
)) {
158 result
.matchedPath
= p
;
168 * Return a matching path, no param substitution, params must match
169 * @param {Router~SearchPath} matchParts
170 * @returns {Router~SearchPath=}
173 _pathFindExact(matchParts
) {
174 const pathsByLength
= this.pathsByLength
[matchParts
.length
];
176 for (const p
of pathsByLength
) {
177 if (Router
._pathCompareExact(p
, matchParts
)) {
187 * @callback Router~HandlerFn
190 * @typedef {Object} Router~PathHandler
191 * @property {Router~HandlerFn} handler
192 * @property {any[]} handlerArgs
195 * Insert a new path handler.
196 * @param {string|string[]} methods
197 * @param {string} urlPath
198 * @param {Router~HandlerFn} handler
199 * @param {any[]} handlerArgs
201 on(methods
, urlPath
, handler
, handlerArgs
= []) {
202 const matchParts
= this._pathDefinitionToPathMatch(urlPath
);
203 let existingPath
= this._pathFindExact(matchParts
);
205 existingPath
= matchParts
;
206 if (!(matchParts
.length
in this.pathsByLength
)) {
207 this.pathsByLength
[matchParts
.length
] = [];
209 this.pathsByLength
[matchParts
.length
].push(existingPath
);
211 if (!Array
.isArray(methods
)) {
214 if (!Array
.isArray(handlerArgs
)) {
215 throw new TypeError(`handlerArgs must be an Array, not '${typeof handlerArgs}'`);
217 methods
.forEach((method
) => {
218 if (!httpMethods
.includes(method
) && method
!== '*') {
219 throw new DingusError(`invalid method '${method}'`);
221 existingPath
[METHODS
][method
] = { handler
, handlerArgs
};
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}
236 lookup(method
, urlPath
, ctx
= {}) {
237 const pathParts
= urlPath
.split('/').map((part
) => decodeURIComponent(part
));
238 if (this.ignoreTrailingSlash
239 && pathParts
[pathParts
.length
- 1] === '') {
242 const { matchedPath
, pathParams
} = this._pathFind(pathParts
);
243 ctx
.params
= pathParams
;
245 ctx
.matchedPath
= matchedPath
;
246 if (method
in matchedPath
[METHODS
]) {
247 return matchedPath
[METHODS
][method
];
249 if ('*' in matchedPath
[METHODS
]) {
250 return matchedPath
[METHODS
]['*'];
252 throw new DingusError('NoMethod');
254 ctx
.unmatchedPath
= pathParts
;
255 throw new DingusError('NoPath');
261 module
.exports
= Router
;