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,
23 * @param {Object} options
24 * @param {Boolean} options.ignoreTrailingSlash
25 * @param {Boolean} options.paramPrefix
27 constructor(options
= {}) {
28 common
.setOptions(this, defaultOptions
, options
);
30 // Keep lists of paths to match, indexed by path length.
31 this.pathsByLength
= {
35 this.METHODS
= METHODS
;
41 * Prepare a path for insertion into search list.
42 * A searchable path is a list of path parts, with a property of method handlers.
43 * @param {string} pathDefinition
45 _pathDefinitionToPathMatch(pathDefinition
) {
46 const pathMatch
= pathDefinition
.split('/').map((p
) => p
.startsWith(this.paramPrefix
) ? { [PARAM
]: p
.slice(this.paramPrefix
.length
) } : p
);
47 if (this.ignoreTrailingSlash
48 && pathMatch
[pathMatch
.length
- 1] === '') {
51 pathMatch
[METHODS
] = {};
52 pathMatch
.forEach((p
) => Object
.freeze(p
));
53 Object
.freeze(pathMatch
);
59 * Compare checkPath to fixedPath, no param substitution, params must match.
60 * @param {*} fixedPath
61 * @param {*} checkPath
63 static _pathCompareExact(fixedPath
, checkPath
) {
64 if (fixedPath
.length
!== checkPath
.length
) {
67 for (let i
= 0; i
< fixedPath
.length
; i
++) {
68 const fixedPart
= fixedPath
[i
];
69 const checkPart
= checkPath
[i
];
70 if (typeof fixedPart
=== 'object' && typeof checkPart
=== 'object') {
71 if (fixedPart
[PARAM
] !== checkPart
[PARAM
]) {
74 } else if (fixedPart
!== checkPart
) {
83 * Compare checkPath to fixedPath, populating params.
84 * @param {*} fixedPath
85 * @param {*} checkPath
86 * @param {*} returnParams
88 static _pathCompareParam(fixedPath
, checkPath
, returnParams
= {}) {
91 if (fixedPath
.length
!== checkPath
.length
) {
94 for (let i
= 0; i
< fixedPath
.length
; i
++) {
95 const fixedPart
= fixedPath
[i
];
96 const checkPart
= checkPath
[i
];
97 if (typeof fixedPart
=== 'object') {
98 params
[fixedPart
[PARAM
]] = checkPart
;
99 } else if (fixedPart
!== checkPart
) {
103 Object
.assign(returnParams
, params
);
109 * Search for an existing path, return matched path and path parameters.
110 * @param {Array} matchParts
112 _pathFind(matchParts
) {
115 matchedPath: undefined,
117 const pathsByLength
= this.pathsByLength
[matchParts
.length
];
119 for (const p
of pathsByLength
) {
120 if (Router
._pathCompareParam(p
, matchParts
, result
.pathParams
)) {
121 result
.matchedPath
= p
;
131 * Return a matching path, no param substitution, params must match
132 * @param {*} matchParts
134 _pathFindExact(matchParts
) {
135 const pathsByLength
= this.pathsByLength
[matchParts
.length
];
137 for (const p
of pathsByLength
) {
138 if (Router
._pathCompareExact(p
, matchParts
)) {
148 * Insert a new path handler.
149 * @param {string|string[]} methods
150 * @param {string} urlPath
151 * @param {fn} handler
152 * @param {*[]} handlerArgs
154 on(methods
, urlPath
, handler
, handlerArgs
= []) {
155 const matchParts
= this._pathDefinitionToPathMatch(urlPath
);
156 let existingPath
= this._pathFindExact(matchParts
);
158 existingPath
= matchParts
;
159 if (!(matchParts
.length
in this.pathsByLength
)) {
160 this.pathsByLength
[matchParts
.length
] = [];
162 this.pathsByLength
[matchParts
.length
].push(existingPath
);
164 if (!Array
.isArray(methods
)) {
167 if (!Array
.isArray(handlerArgs
)) {
168 throw new TypeError(`handlerArgs must be an Array, not '${typeof handlerArgs}'`);
170 methods
.forEach((method
) => {
171 if (!httpMethods
.includes(method
) && method
!== '*') {
172 throw new DingusError(`invalid method '${method}'`);
174 existingPath
[METHODS
][method
] = { handler
, handlerArgs
};
180 * Return an object, which contains a matching handler and any extra
181 * arguments, for a requested url.
182 * Also sets path parameters on context.
183 * @param {string} method
184 * @param {string[]} urlPath
185 * @param {object} ctx
188 lookup(method
, urlPath
, ctx
= {}) {
189 const pathParts
= urlPath
.split('/').map((part
) => decodeURIComponent(part
));
190 if (this.ignoreTrailingSlash
191 && pathParts
[pathParts
.length
- 1] === '') {
194 const { matchedPath
, pathParams
} = this._pathFind(pathParts
);
195 ctx
.params
= pathParams
;
197 ctx
.matchedPath
= matchedPath
;
198 if (method
in matchedPath
[METHODS
]) {
199 return matchedPath
[METHODS
][method
];
201 if ('*' in matchedPath
[METHODS
]) {
202 return matchedPath
[METHODS
]['*'];
204 throw new DingusError('NoMethod');
206 ctx
.unmatchedPath
= pathParts
;
207 throw new DingusError('NoPath');
213 module
.exports
= Router
;