92a85006458d4544a0336e532c11649e1b596946
[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 */
153 on(methods, urlPath, handler) {
154 const matchParts = this._pathDefinitionToPathMatch(urlPath);
155 let existingPath = this._pathFindExact(matchParts);
156 if (!existingPath) {
157 existingPath = matchParts;
158 if (!(matchParts.length in this.pathsByLength)) {
159 this.pathsByLength[matchParts.length] = [];
160 }
161 this.pathsByLength[matchParts.length].push(existingPath);
162 }
163 if (!Array.isArray(methods)) {
164 methods = [methods];
165 }
166 methods.forEach((method) => {
167 if (!httpMethods.includes(method) && method !== '*') {
168 throw new DingusError(`invalid method '${method}'`);
169 }
170 existingPath[METHODS][method] = handler;
171 });
172 }
173
174
175 /**
176 * Return a matching handler for a request, sets path parameters on context.
177 * @param {string} method
178 * @param {string[]} urlPath
179 * @param {object} ctx
180 */
181 lookup(method, urlPath, ctx = {}) {
182 const pathParts = urlPath.split('/').map((part) => decodeURIComponent(part));
183 if (this.ignoreTrailingSlash
184 && pathParts[pathParts.length - 1] === '') {
185 pathParts.pop();
186 }
187 const { matchedPath, pathParams } = this._pathFind(pathParts);
188 ctx.params = pathParams;
189 if (matchedPath) {
190 ctx.matchedPath = matchedPath;
191 if (method in matchedPath[METHODS]) {
192 return matchedPath[METHODS][method];
193 }
194 if ('*' in matchedPath[METHODS]) {
195 return matchedPath[METHODS]['*'];
196 }
197 throw new DingusError('NoMethod');
198 }
199 ctx.unmatchedPath = pathParts;
200 throw new DingusError('NoPath');
201 }
202
203
204 }
205
206 module.exports = Router;