allow additional arguments to be passed to handler functions
[squeep-api-dingus] / lib / router.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
12 // Internal identifiers for route entries.
13 const METHODS = Symbol('METHODS');
14 const PARAM = Symbol('PARAM');
15
16 const defaultOptions = {
17 ignoreTrailingSlash: false,
18 paramPrefix: ':',
19 };
20
21 class Router {
22 /**
23 * @param {Object} options
24 * @param {Boolean} options.ignoreTrailingSlash
25 * @param {Boolean} options.paramPrefix
26 */
27 constructor(options = {}) {
28 common.setOptions(this, defaultOptions, options);
29
30 // Keep lists of paths to match, indexed by path length.
31 this.pathsByLength = {
32 1: [],
33 };
34
35 this.METHODS = METHODS;
36 this.PARAM = PARAM;
37 }
38
39
40 /**
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
44 */
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] === '') {
49 pathMatch.pop();
50 }
51 pathMatch[METHODS] = {};
52 pathMatch.forEach((p) => Object.freeze(p));
53 Object.freeze(pathMatch);
54 return pathMatch;
55 }
56
57
58 /**
59 * Compare checkPath to fixedPath, no param substitution, params must match.
60 * @param {*} fixedPath
61 * @param {*} checkPath
62 */
63 static _pathCompareExact(fixedPath, checkPath) {
64 if (fixedPath.length !== checkPath.length) {
65 return false;
66 }
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]) {
72 return false;
73 }
74 } else if (fixedPart !== checkPart) {
75 return false;
76 }
77 }
78 return true;
79 }
80
81
82 /**
83 * Compare checkPath to fixedPath, populating params.
84 * @param {*} fixedPath
85 * @param {*} checkPath
86 * @param {*} returnParams
87 */
88 static _pathCompareParam(fixedPath, checkPath, returnParams = {}) {
89 const params = {};
90
91 if (fixedPath.length !== checkPath.length) {
92 return false;
93 }
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) {
100 return false;
101 }
102 }
103 Object.assign(returnParams, params);
104 return true;
105 }
106
107
108 /**
109 * Search for an existing path, return matched path and path parameters.
110 * @param {Array} matchParts
111 */
112 _pathFind(matchParts) {
113 const result = {
114 pathParams: {},
115 matchedPath: undefined,
116 };
117 const pathsByLength = this.pathsByLength[matchParts.length];
118 if (pathsByLength) {
119 for (const p of pathsByLength) {
120 if (Router._pathCompareParam(p, matchParts, result.pathParams)) {
121 result.matchedPath = p;
122 break;
123 }
124 }
125 }
126 return result;
127 }
128
129
130 /**
131 * Return a matching path, no param substitution, params must match
132 * @param {*} matchParts
133 */
134 _pathFindExact(matchParts) {
135 const pathsByLength = this.pathsByLength[matchParts.length];
136 if (pathsByLength) {
137 for (const p of pathsByLength) {
138 if (Router._pathCompareExact(p, matchParts)) {
139 return p;
140 }
141 }
142 }
143 return undefined;
144 }
145
146
147 /**
148 * Insert a new path handler.
149 * @param {string|string[]} methods
150 * @param {string} urlPath
151 * @param {fn} handler
152 * @param {*[]} handlerArgs
153 */
154 on(methods, urlPath, handler, handlerArgs = []) {
155 const matchParts = this._pathDefinitionToPathMatch(urlPath);
156 let existingPath = this._pathFindExact(matchParts);
157 if (!existingPath) {
158 existingPath = matchParts;
159 if (!(matchParts.length in this.pathsByLength)) {
160 this.pathsByLength[matchParts.length] = [];
161 }
162 this.pathsByLength[matchParts.length].push(existingPath);
163 }
164 if (!Array.isArray(methods)) {
165 methods = [methods];
166 }
167 if (!Array.isArray(handlerArgs)) {
168 throw new TypeError(`handlerArgs must be an Array, not '${typeof handlerArgs}'`);
169 }
170 methods.forEach((method) => {
171 if (!httpMethods.includes(method) && method !== '*') {
172 throw new DingusError(`invalid method '${method}'`);
173 }
174 existingPath[METHODS][method] = { handler, handlerArgs };
175 });
176 }
177
178
179 /**
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
186 * @returns {object}
187 */
188 lookup(method, urlPath, ctx = {}) {
189 const pathParts = urlPath.split('/').map((part) => decodeURIComponent(part));
190 if (this.ignoreTrailingSlash
191 && pathParts[pathParts.length - 1] === '') {
192 pathParts.pop();
193 }
194 const { matchedPath, pathParams } = this._pathFind(pathParts);
195 ctx.params = pathParams;
196 if (matchedPath) {
197 ctx.matchedPath = matchedPath;
198 if (method in matchedPath[METHODS]) {
199 return matchedPath[METHODS][method];
200 }
201 if ('*' in matchedPath[METHODS]) {
202 return matchedPath[METHODS]['*'];
203 }
204 throw new DingusError('NoMethod');
205 }
206 ctx.unmatchedPath = pathParts;
207 throw new DingusError('NoPath');
208 }
209
210
211 }
212
213 module.exports = Router;