f0d2d945a53909c327ab1661c54e83e9a6cfe097
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
] = {};
72 Object
.freeze(routePath
);
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}
84 _pathPartMunge(part
) {
85 if (part
.startsWith(this.paramPrefix
)) {
86 return new PathParameter(part
.slice(this.paramPrefix
.length
));
88 if (part
.startsWith('\\' + this.paramPrefix
)) {
96 * Compare checkPath to fixedPath, no param substitution, params must match.
97 * @param {Router~RoutePath} routePath
98 * @param {Router~RoutePath} checkPath
102 static _pathCompareExact(routePath
, checkPath
) {
103 if (routePath
.length
!== checkPath
.length
) {
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
) {
113 } else if (fixedPart
!== checkPart
) {
122 * Compare routePath to fixedPath, populating params.
123 * @param {Router~RoutePath} routePath
124 * @param {Array<String>} checkPath
125 * @param {Object} returnParams
129 static _pathCompareParam(routePath
, checkPath
, returnParams
= {}) {
132 if (routePath
.length
!== checkPath
.length
) {
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
) {
144 Object
.assign(returnParams
, params
);
150 * @typedef Router~MatchedPath
151 * @property {Object} pathParams populated param fields
152 * @property {Router~RoutePath} matchedPath
155 * Search for an existing path, return matched path and path parameters.
156 * @param {Array<String>} matchParts
157 * @returns {Router~MatchedPath}
160 _pathFind(matchParts
) {
163 matchedPath: undefined,
165 const pathsByLength
= this.pathsByLength
[matchParts
.length
];
167 for (const p
of pathsByLength
) {
168 if (Router
._pathCompareParam(p
, matchParts
, result
.pathParams
)) {
169 result
.matchedPath
= p
;
179 * Return a matching path, no param substitution, params must match
180 * @param {Router~RoutePath} routePath
181 * @returns {Router~RoutePath=}
184 _pathFindExact(routePath
) {
185 const pathsByLength
= this.pathsByLength
[routePath
.length
];
187 for (const p
of pathsByLength
) {
188 if (Router
._pathCompareExact(p
, routePath
)) {
198 * @callback Router~HandlerFn
201 * @typedef {Object} Router~PathHandler
202 * @property {Router~HandlerFn} handler
203 * @property {any[]} handlerArgs
206 * Insert a new path handler.
207 * @param {string|string[]} methods
208 * @param {string} urlPath
209 * @param {Router~HandlerFn} handler
210 * @param {any[]} handlerArgs
212 on(methods
, urlPath
, handler
, handlerArgs
= []) {
213 const matchParts
= this._pathToRoutePath(urlPath
);
214 let existingPath
= this._pathFindExact(matchParts
);
216 existingPath
= matchParts
;
217 if (!(matchParts
.length
in this.pathsByLength
)) {
218 this.pathsByLength
[matchParts
.length
] = [];
220 this.pathsByLength
[matchParts
.length
].push(existingPath
);
222 if (!Array
.isArray(methods
)) {
225 if (!Array
.isArray(handlerArgs
)) {
226 throw new TypeError(`handlerArgs must be an Array, not '${typeof handlerArgs}'`);
228 methods
.forEach((method
) => {
229 if (!httpMethods
.includes(method
) && method
!== '*') {
230 throw new DingusError(`invalid method '${method}'`);
232 existingPath
[kPathMethods
][method
] = { handler
, handlerArgs
};
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}
247 lookup(method
, urlPath
, ctx
= {}) {
248 const pathParts
= urlPath
.split('/').map((part
) => decodeURIComponent(part
));
249 if (this.ignoreTrailingSlash
250 && pathParts
[pathParts
.length
- 1] === '') {
253 const { matchedPath
, pathParams
} = this._pathFind(pathParts
);
254 ctx
.params
= pathParams
;
256 ctx
.matchedPath
= matchedPath
;
257 if (method
in matchedPath
[kPathMethods
]) {
258 return matchedPath
[kPathMethods
][method
];
260 if ('*' in matchedPath
[kPathMethods
]) {
261 return matchedPath
[kPathMethods
]['*'];
263 throw new DingusError('NoMethod');
265 ctx
.unmatchedPath
= pathParts
;
266 throw new DingusError('NoPath');
271 Router
.kPathMethods
= kPathMethods
;
273 module
.exports
= Router
;