-
-
Notifications
You must be signed in to change notification settings - Fork 84
/
Copy pathrouter.ts
358 lines (318 loc) · 10.8 KB
/
router.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
import * as _ from 'lodash';
import type { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types';
import bath from 'bath-es5';
import * as cookie from 'cookie';
import { parse as parseQuery } from 'qs';
import { Parameters } from 'bath-es5/_/types';
import { PickVersionElement } from './backend';
// alias Document to OpenAPIV3_1.Document
type Document = OpenAPIV3_1.Document | OpenAPIV3.Document;
/**
* OperationObject
* @typedef {(OpenAPIV3_1.OperationObject | OpenAPIV3.OperationObject)} OperationObject
*/
/**
* OAS Operation Object containing the path and method so it can be placed in a flat array of operations
*
* @export
* @interface Operation
* @extends {OperationObject}
*/
export type Operation<D extends Document = Document> = PickVersionElement<
D,
OpenAPIV3.OperationObject,
OpenAPIV3_1.OperationObject
> & {
path: string;
method: string;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AnyRequestBody = any;
export interface Request {
method: string;
path: string;
headers: {
[key: string]: string | string[];
};
query?:
| {
[key: string]: string | string[];
}
| string;
body?: AnyRequestBody;
}
export type UnknownParams = {
[key: string]: string | string[];
};
export interface ParsedRequest<
RequestBody = AnyRequestBody,
Params = UnknownParams,
Query = UnknownParams,
Headers = UnknownParams,
Cookies = UnknownParams,
> {
method: string;
path: string;
requestBody: RequestBody;
params: Params;
query: Query;
headers: Headers;
cookies: Cookies;
body?: AnyRequestBody;
}
/**
* Class that handles routing
*
* @export
* @class OpenAPIRouter
*/
export class OpenAPIRouter<D extends Document = Document> {
public definition: D;
public apiRoot: string;
private ignoreTrailingSlashes: boolean;
/**
* Creates an instance of OpenAPIRouter
*
* @param opts - constructor options
* @param {D} opts.definition - the OpenAPI definition, file path or Document object
* @param {string} opts.apiRoot - the root URI of the api. all paths are matched relative to apiRoot
* @memberof OpenAPIRouter
*/
constructor(opts: { definition: D; apiRoot?: string; ignoreTrailingSlashes?: boolean }) {
this.definition = opts.definition;
this.apiRoot = opts.apiRoot || '/';
this.ignoreTrailingSlashes = opts.ignoreTrailingSlashes ?? true;
}
/**
* Matches a request to an API operation (router)
*
* @param {Request} req
* @param {boolean} [strict] strict mode, throw error if operation is not found
* @returns {Operation<D>}
* @memberof OpenAPIRouter
*/
public matchOperation(req: Request): Operation<D> | undefined;
public matchOperation(req: Request, strict: boolean): Operation<D>;
public matchOperation(req: Request, strict?: boolean) {
// normalize request for matching
req = this.normalizeRequest(req);
// if request doesn't match apiRoot, throw 404
if (!req.path.startsWith(this.apiRoot)) {
if (strict) {
throw Error('404-notFound: no route matches request');
} else {
return undefined;
}
}
// get relative path
const normalizedPath = this.normalizePath(req.path);
// get all operations matching exact path
const exactPathMatches = this.getOperations().filter(({ path }) => path === normalizedPath);
// check if there's one with correct method and return if found
const exactMatch = exactPathMatches.find(({ method }) => method === req.method);
if (exactMatch) {
return exactMatch;
}
// check with path templates
const templatePathMatches = this.getOperations().filter(({ path }) => {
// convert openapi path template to a regex pattern i.e. /{id}/ becomes /[^/]+/
const pathPattern = `^${path.replace(/\{.*?\}/g, '[^/]+')}$`;
return Boolean(normalizedPath.match(new RegExp(pathPattern, 'g')));
});
// if no operations match the path, throw 404
if (!templatePathMatches.length) {
if (strict) {
throw Error('404-notFound: no route matches request');
} else {
return undefined;
}
}
// find matching operation
const match = _.chain(templatePathMatches)
// order matches by length (specificity)
.orderBy((op) => op.path.replace(RegExp(/\{.*?\}/g), '').length, 'desc')
// then check if one of the matched operations matches the method
.find(({ method }) => method === req.method)
.value();
if (!match) {
if (strict) {
throw Error('405-methodNotAllowed: this method is not registered for the route');
} else {
return undefined;
}
}
return match;
}
/**
* Flattens operations into a simple array of Operation objects easy to work with
*
* @returns {Operation<D>[]}
* @memberof OpenAPIRouter
*/
public getOperations(): Operation<D>[] {
const paths = this.definition?.paths || {};
return _.chain(paths)
.entries()
.flatMap(([path, pathBaseObject]) => {
const methods = _.pick(pathBaseObject, ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']);
return _.entries(methods).map(([method, operation]) => {
const op = operation as Operation<D>;
return {
...op,
path,
method,
// append the path base object's parameters to the operation's parameters
parameters: [
...((op.parameters as PickVersionElement<D, OpenAPIV3.ParameterObject, OpenAPIV3_1.ParameterObject>[]) ||
[]),
...((pathBaseObject?.parameters as PickVersionElement<
D,
OpenAPIV3.ParameterObject,
OpenAPIV3_1.ParameterObject
>[]) || []), // path base object parameters
],
// operation-specific security requirement override global requirements
security: op.security || this.definition.security || [],
};
});
})
.value();
}
/**
* Gets a single operation based on operationId
*
* @param {string} operationId
* @returns {Operation<D>}
* @memberof OpenAPIRouter
*/
public getOperation(operationId: string): Operation<D> | undefined {
return this.getOperations().find((op) => op.operationId === operationId);
}
/**
* Normalises request:
* - http method to lowercase
* - remove path leading slash
* - remove path query string
*
* @export
* @param {Request} req
* @returns {Request}
*/
public normalizeRequest(req: Request): Request {
let path = req.path?.trim() || '';
// add leading prefix to path
if (!path.startsWith('/')) {
path = `/${path}`;
}
// remove query string from path
path = path.split('?')[0];
// normalize method to lowercase
const method = req.method.trim().toLowerCase();
return { ...req, path, method };
}
/**
* Normalises path for matching: strips apiRoot prefix from the path
*
* Also depending on configuration, will remove trailing slashes
*
* @export
* @param {string} path
* @returns {string}
*/
public normalizePath(pathInput: string) {
let path = pathInput.trim();
// strip apiRoot from path
if (path.startsWith(this.apiRoot)) {
path = path.replace(new RegExp(`^${this.apiRoot}/?`), '/');
}
// remove trailing slashes from path if ignoreTrailingSlashes = true
while (this.ignoreTrailingSlashes && path.length > 1 && path.endsWith('/')) {
path = path.substr(0, path.length - 1);
}
return path;
}
/**
* Parses and normalizes a request
* - parse json body
* - parse query string
* - parse cookies from headers
* - parse path params based on uri template
*
* @export
* @param {Request} req
* @param {Operation<D>} [operation]
* @param {string} [patbh]
* @returns {ParsedRequest}
*/
public parseRequest(req: Request, operation?: Operation<D>): ParsedRequest {
let requestBody = req.body;
if (req.body && typeof req.body !== 'object') {
try {
// attempt to parse json
requestBody = JSON.parse(req.body.toString());
} catch {
// suppress json parsing errors
// we will emit error if validation requires it later
}
}
// header keys are converted to lowercase, so Content-Type becomes content-type
const headers = _.mapKeys(req.headers, (val, header) => header.toLowerCase());
// parse cookie from headers
const cookieHeader = headers['cookie'];
const cookies = cookie.parse(_.flatten([cookieHeader]).join('; '));
// parse query
const queryString = typeof req.query === 'string' ? req.query.replace('?', '') : req.path.split('?')[1];
const query = typeof req.query === 'object' ? _.cloneDeep(req.query) : parseQuery(queryString);
// normalize
req = this.normalizeRequest(req);
let params: Parameters = {};
if (operation) {
// get relative path
const normalizedPath = this.normalizePath(req.path);
// parse path params if path is given
const pathParams = bath(operation.path);
params = pathParams.params(normalizedPath) || {};
// parse query parameters with specified style for parameter
for (const queryParam in query) {
if (query[queryParam]) {
const parameter = operation.parameters?.find(
(param) => !('$ref' in param) && param?.in === 'query' && param?.name === queryParam,
) as PickVersionElement<D, OpenAPIV3.ParameterObject, OpenAPIV3_1.ParameterObject>;
if (parameter) {
if (parameter.content && parameter.content['application/json']) {
query[queryParam] = JSON.parse(query[queryParam]);
} else if (parameter.explode === false && queryString) {
let commaQueryString = queryString.replace(/%2C/g, ',');
if (parameter.style === 'spaceDelimited') {
commaQueryString = commaQueryString.replace(/ /g, ',').replace(/%20/g, ',');
}
if (parameter.style === 'pipeDelimited') {
commaQueryString = commaQueryString.replace(/\|/g, ',').replace(/%7C/g, ',');
}
// use comma parsing e.g. &a=1,2,3
const commaParsed = parseQuery(commaQueryString, { comma: true });
query[queryParam] = commaParsed[queryParam];
} else if (parameter.explode === false) {
let decoded = query[queryParam].replace(/%2C/g, ',');
if (parameter.style === 'spaceDelimited') {
decoded = decoded.replace(/ /g, ',').replace(/%20/g, ',');
}
if (parameter.style === 'pipeDelimited') {
decoded = decoded.replace(/\|/g, ',').replace(/%7C/g, ',');
}
query[queryParam] = decoded.split(',');
}
}
}
}
}
return {
...req,
params,
headers,
query,
cookies,
requestBody,
};
}
}