@uphold/fastify-openapi-router-plugin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,522 @@
1
+ import { DECORATOR_NAME } from '../utils/constants.js';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { errors } from '../errors/index.js';
4
+ import { parseSecurity, validateSecurity } from './security.js';
5
+
6
+ describe('validateSecurity()', () => {
7
+ it('should throw on invalid security handler option', () => {
8
+ const spec = {
9
+ components: {
10
+ securitySchemes: {}
11
+ }
12
+ };
13
+
14
+ expect.assertions(2);
15
+
16
+ try {
17
+ validateSecurity(spec, {
18
+ securityHandlers: 'foo'
19
+ });
20
+ } catch (error) {
21
+ expect(error).toBeInstanceOf(TypeError);
22
+ expect(error.message).toBe(`Expected 'options.securitySchemes' to be an object.`);
23
+ }
24
+ });
25
+
26
+ it('should throw on missing security handlers', () => {
27
+ const spec = {
28
+ components: {
29
+ securitySchemes: {
30
+ foo: {}
31
+ }
32
+ }
33
+ };
34
+
35
+ expect.assertions(2);
36
+
37
+ try {
38
+ validateSecurity(spec, {
39
+ securityHandlers: {}
40
+ });
41
+ } catch (error) {
42
+ expect(error).toBeInstanceOf(TypeError);
43
+ expect(error.message).toBe(
44
+ `Missing or invalid 'options.securityHandlers.foo'. Please provide a function for the given security scheme.`
45
+ );
46
+ }
47
+ });
48
+
49
+ it('should throw on invalid security handlers', () => {
50
+ const spec = {
51
+ components: {
52
+ securitySchemes: {
53
+ foo: {}
54
+ }
55
+ }
56
+ };
57
+
58
+ expect.assertions(2);
59
+
60
+ try {
61
+ validateSecurity(spec, {
62
+ securityHandlers: { foo: {} }
63
+ });
64
+ } catch (error) {
65
+ expect(error).toBeInstanceOf(TypeError);
66
+ expect(error.message).toBe(
67
+ `Missing or invalid 'options.securityHandlers.foo'. Please provide a function for the given security scheme.`
68
+ );
69
+ }
70
+ });
71
+
72
+ it('should not throw an error if all security handlers are present', () => {
73
+ const spec = {
74
+ components: {
75
+ securitySchemes: {
76
+ bar: {},
77
+ foo: {}
78
+ }
79
+ }
80
+ };
81
+
82
+ validateSecurity(spec, {
83
+ securityHandlers: { bar: () => {}, foo: () => {} }
84
+ });
85
+ });
86
+ });
87
+
88
+ describe('parseSecurity()', () => {
89
+ it('should return undefined if no security', async () => {
90
+ expect(parseSecurity({}, {}, {})).toBeUndefined();
91
+ expect(parseSecurity({ security: [] }, {}, {})).toBeUndefined();
92
+ expect(parseSecurity({}, { security: [] }, {})).toBeUndefined();
93
+ });
94
+
95
+ it('should return undefined if `security` is disabled in operation', async () => {
96
+ const onRequest = parseSecurity({ security: [] }, { security: [{ OAuth2: [] }] }, {});
97
+
98
+ expect(onRequest).toBeUndefined();
99
+ });
100
+
101
+ it('should stop at the first successful security block', async () => {
102
+ const request = {
103
+ [DECORATOR_NAME]: {},
104
+ headers: {
105
+ 'X-API-KEY': 'api key',
106
+ 'X-API-KEY-2': 'api key 2',
107
+ authorization: 'Bearer bearer token'
108
+ }
109
+ };
110
+ const operation = {
111
+ security: [{ ApiKey: [], OAuth2: [] }, { ApiKey2: [] }]
112
+ };
113
+ const spec = {
114
+ components: {
115
+ securitySchemes: {
116
+ ApiKey: { in: 'header', name: 'X-API-KEY', type: 'apiKey' },
117
+ ApiKey2: { in: 'header', name: 'X-API-KEY-2', type: 'apiKey' },
118
+ OAuth2: { type: 'oauth2' }
119
+ }
120
+ }
121
+ };
122
+ const securityHandlers = {
123
+ ApiKey: vi.fn(async () => ({ data: 'ApiKey data', scopes: [] })),
124
+ ApiKey2: vi.fn(async () => ({ data: 'ApiKey2 data', scopes: [] })),
125
+ OAuth2: vi.fn(async () => ({ data: 'OAuth2 data', scopes: [] }))
126
+ };
127
+
128
+ const onRequest = parseSecurity(operation, spec, securityHandlers);
129
+
130
+ await onRequest(request);
131
+
132
+ expect(securityHandlers.ApiKey).toHaveBeenCalledTimes(1);
133
+ expect(securityHandlers.ApiKey).toHaveBeenCalledWith('api key', request);
134
+ expect(securityHandlers.OAuth2).toHaveBeenCalledTimes(1);
135
+ expect(securityHandlers.OAuth2).toHaveBeenCalledWith('bearer token', request);
136
+ expect(securityHandlers.ApiKey2).not.toHaveBeenCalled();
137
+ expect(request[DECORATOR_NAME].security).toMatchObject({ ApiKey: 'ApiKey data', OAuth2: 'OAuth2 data' });
138
+ expect(request[DECORATOR_NAME].securityReport).toMatchInlineSnapshot(`
139
+ [
140
+ {
141
+ "ok": true,
142
+ "schemes": {
143
+ "ApiKey": {
144
+ "data": "ApiKey data",
145
+ "ok": true,
146
+ },
147
+ "OAuth2": {
148
+ "data": "OAuth2 data",
149
+ "ok": true,
150
+ },
151
+ },
152
+ },
153
+ ]
154
+ `);
155
+ });
156
+
157
+ it('should try second security block if the first one fails', async () => {
158
+ const request = {
159
+ [DECORATOR_NAME]: {},
160
+ headers: {
161
+ 'X-API-KEY': 'api key',
162
+ authorization: 'Bearer bearer token'
163
+ }
164
+ };
165
+ const operation = {
166
+ security: [{ ApiKey: [] }, { OAuth2: [] }]
167
+ };
168
+ const spec = {
169
+ components: {
170
+ securitySchemes: {
171
+ ApiKey: { in: 'header', name: 'X-API-KEY', type: 'apiKey' },
172
+ OAuth2: { type: 'oauth2' }
173
+ }
174
+ }
175
+ };
176
+ const securityHandlers = {
177
+ ApiKey: vi.fn(() => {
178
+ throw new Error('ApiKey error');
179
+ }),
180
+ OAuth2: vi.fn(async () => ({ data: 'OAuth2 data', scopes: [] }))
181
+ };
182
+
183
+ const onRequest = parseSecurity(operation, spec, securityHandlers);
184
+
185
+ await onRequest(request);
186
+
187
+ expect(securityHandlers.ApiKey).toHaveBeenCalledTimes(1);
188
+ expect(securityHandlers.ApiKey).toHaveBeenCalledWith('api key', request);
189
+ expect(securityHandlers.OAuth2).toHaveBeenCalledTimes(1);
190
+ expect(securityHandlers.OAuth2).toHaveBeenCalledWith('bearer token', request);
191
+ expect(request[DECORATOR_NAME].security).toMatchObject({ OAuth2: 'OAuth2 data' });
192
+ expect(request[DECORATOR_NAME].securityReport).toMatchInlineSnapshot(`
193
+ [
194
+ {
195
+ "ok": false,
196
+ "schemes": {
197
+ "ApiKey": {
198
+ "error": [Error: ApiKey error],
199
+ "ok": false,
200
+ },
201
+ },
202
+ },
203
+ {
204
+ "ok": true,
205
+ "schemes": {
206
+ "OAuth2": {
207
+ "data": "OAuth2 data",
208
+ "ok": true,
209
+ },
210
+ },
211
+ },
212
+ ]
213
+ `);
214
+ });
215
+
216
+ it('should throw an error if all security blocks fail', async () => {
217
+ const request = {
218
+ [DECORATOR_NAME]: {},
219
+ headers: {
220
+ 'X-API-KEY': 'api key',
221
+ authorization: 'Bearer bearer token'
222
+ }
223
+ };
224
+ const operation = {
225
+ security: [{ ApiKey: [] }, { OAuth2: [] }]
226
+ };
227
+ const spec = {
228
+ components: {
229
+ securitySchemes: {
230
+ ApiKey: { in: 'header', name: 'X-API-KEY', type: 'apiKey' },
231
+ OAuth2: { type: 'oauth2' }
232
+ }
233
+ }
234
+ };
235
+ const securityHandlers = {
236
+ ApiKey: vi.fn(() => {
237
+ throw new Error('ApiKey error');
238
+ }),
239
+ OAuth2: vi.fn(() => {
240
+ throw new Error('OAuth2 error');
241
+ })
242
+ };
243
+
244
+ const onRequest = parseSecurity(operation, spec, securityHandlers);
245
+
246
+ expect.assertions(2);
247
+
248
+ try {
249
+ await onRequest(request);
250
+ } catch (err) {
251
+ expect(err).toBeInstanceOf(errors.UnauthorizedError);
252
+ expect(err.securityReport).toMatchInlineSnapshot(`
253
+ [
254
+ {
255
+ "ok": false,
256
+ "schemes": {
257
+ "ApiKey": {
258
+ "error": [Error: ApiKey error],
259
+ "ok": false,
260
+ },
261
+ },
262
+ },
263
+ {
264
+ "ok": false,
265
+ "schemes": {
266
+ "OAuth2": {
267
+ "error": [Error: OAuth2 error],
268
+ "ok": false,
269
+ },
270
+ },
271
+ },
272
+ ]
273
+ `);
274
+ }
275
+ });
276
+
277
+ it('should cache security handler calls', async () => {
278
+ const request = {
279
+ [DECORATOR_NAME]: {},
280
+ headers: {
281
+ authorization: 'Bearer bearer token'
282
+ }
283
+ };
284
+ const operation = {
285
+ security: [{ OAuth2: ['read'] }, { OAuth2: ['write'] }]
286
+ };
287
+ const spec = {
288
+ components: {
289
+ securitySchemes: {
290
+ ApiKey: { in: 'header', name: 'X-API-KEY', type: 'apiKey' },
291
+ OAuth2: { type: 'oauth2' }
292
+ }
293
+ }
294
+ };
295
+ const securityHandlers = {
296
+ OAuth2: vi.fn(async () => ({ data: 'OAuth2 data', scopes: ['write'] }))
297
+ };
298
+
299
+ const onRequest = parseSecurity(operation, spec, securityHandlers);
300
+
301
+ await onRequest(request);
302
+
303
+ expect(securityHandlers.OAuth2).toHaveBeenCalledTimes(1);
304
+ });
305
+
306
+ it('should cache security handler calls, even if they throw', async () => {
307
+ const request = {
308
+ [DECORATOR_NAME]: {},
309
+ headers: {
310
+ authorization: 'Bearer bearer token'
311
+ }
312
+ };
313
+ const operation = {
314
+ security: [{ OAuth2: ['read'] }, { OAuth2: ['write'] }]
315
+ };
316
+ const spec = {
317
+ components: {
318
+ securitySchemes: {
319
+ ApiKey: { in: 'header', name: 'X-API-KEY', type: 'apiKey' },
320
+ OAuth2: { type: 'oauth2' }
321
+ }
322
+ }
323
+ };
324
+ const securityHandlers = {
325
+ OAuth2: vi.fn(() => {
326
+ throw new Error('OAuth2 error');
327
+ })
328
+ };
329
+
330
+ const onRequest = parseSecurity(operation, spec, securityHandlers);
331
+
332
+ expect.assertions(2);
333
+
334
+ try {
335
+ await onRequest(request);
336
+ } catch (err) {
337
+ expect(err).toBeInstanceOf(errors.UnauthorizedError);
338
+ expect(securityHandlers.OAuth2).toHaveBeenCalledTimes(1);
339
+ }
340
+ });
341
+
342
+ it('should skip security blocks that have at least one value missing', async () => {
343
+ const request = {
344
+ [DECORATOR_NAME]: {},
345
+ headers: {
346
+ authorization: 'Bearer bearer token'
347
+ }
348
+ };
349
+ const operation = {
350
+ security: [{ ApiKey: [] }, { OAuth2: [] }]
351
+ };
352
+ const spec = {
353
+ components: {
354
+ securitySchemes: {
355
+ ApiKey: { in: 'header', name: 'X-API-KEY', type: 'apiKey' },
356
+ OAuth2: { type: 'oauth2' }
357
+ }
358
+ }
359
+ };
360
+ const securityHandlers = {
361
+ ApiKey: vi.fn(async () => ({ data: 'ApiKey data', scopes: [] })),
362
+ OAuth2: vi.fn(async () => ({ data: 'OAuth2 data', scopes: [] }))
363
+ };
364
+
365
+ const onRequest = parseSecurity(operation, spec, securityHandlers);
366
+
367
+ expect.assertions(3);
368
+
369
+ await onRequest(request);
370
+
371
+ expect(securityHandlers.ApiKey).not.toHaveBeenCalled();
372
+ expect(securityHandlers.OAuth2).toHaveBeenCalledTimes(1);
373
+ expect(request[DECORATOR_NAME].securityReport).toMatchInlineSnapshot(`
374
+ [
375
+ {
376
+ "ok": false,
377
+ "schemes": {},
378
+ },
379
+ {
380
+ "ok": true,
381
+ "schemes": {
382
+ "OAuth2": {
383
+ "data": "OAuth2 data",
384
+ "ok": true,
385
+ },
386
+ },
387
+ },
388
+ ]
389
+ `);
390
+ });
391
+
392
+ it('should validate scopes', async () => {
393
+ const request = {
394
+ [DECORATOR_NAME]: {},
395
+ headers: {
396
+ authorization: 'Bearer bearer token'
397
+ }
398
+ };
399
+ const operation = {
400
+ security: [{ OAuth2: ['write'] }]
401
+ };
402
+ const spec = {
403
+ components: {
404
+ securitySchemes: {
405
+ OAuth2: { type: 'oauth2' }
406
+ }
407
+ }
408
+ };
409
+ const securityHandlers = {
410
+ OAuth2: vi.fn(() => ({ data: 'OAuth2 data', scopes: ['read'] }))
411
+ };
412
+
413
+ const onRequest = parseSecurity(operation, spec, securityHandlers);
414
+
415
+ expect.assertions(2);
416
+
417
+ try {
418
+ await onRequest(request);
419
+ } catch (err) {
420
+ expect(err).toBeInstanceOf(errors.UnauthorizedError);
421
+ expect(err.securityReport).toMatchInlineSnapshot(`
422
+ [
423
+ {
424
+ "ok": false,
425
+ "schemes": {
426
+ "OAuth2": {
427
+ "error": [FastifyError: Scopes do not match required scopes],
428
+ "ok": false,
429
+ },
430
+ },
431
+ },
432
+ ]
433
+ `);
434
+ }
435
+ });
436
+
437
+ it('should allow security handler to return undefined', async () => {
438
+ const request = {
439
+ [DECORATOR_NAME]: {},
440
+ headers: {
441
+ authorization: 'Bearer bearer token'
442
+ }
443
+ };
444
+ const operation = {
445
+ security: [{ OAuth2: [] }]
446
+ };
447
+ const spec = {
448
+ components: {
449
+ securitySchemes: {
450
+ OAuth2: { type: 'oauth2' }
451
+ }
452
+ }
453
+ };
454
+ const securityHandlers = {
455
+ OAuth2: vi.fn(() => {})
456
+ };
457
+
458
+ const onRequest = parseSecurity(operation, spec, securityHandlers);
459
+
460
+ await onRequest(request);
461
+
462
+ expect(request[DECORATOR_NAME].security).toMatchObject({ OAuth2: undefined });
463
+ expect(request[DECORATOR_NAME].securityReport).toMatchInlineSnapshot(`
464
+ [
465
+ {
466
+ "ok": true,
467
+ "schemes": {
468
+ "OAuth2": {
469
+ "data": undefined,
470
+ "ok": true,
471
+ },
472
+ },
473
+ },
474
+ ]
475
+ `);
476
+ });
477
+
478
+ it('should allow security handler to return undefined and still check scopes', async () => {
479
+ const request = {
480
+ [DECORATOR_NAME]: {},
481
+ headers: {
482
+ authorization: 'Bearer bearer token'
483
+ }
484
+ };
485
+ const operation = {
486
+ security: [{ OAuth2: ['read'] }]
487
+ };
488
+ const spec = {
489
+ components: {
490
+ securitySchemes: {
491
+ OAuth2: { type: 'oauth2' }
492
+ }
493
+ }
494
+ };
495
+ const securityHandlers = {
496
+ OAuth2: vi.fn(() => {})
497
+ };
498
+
499
+ const onRequest = parseSecurity(operation, spec, securityHandlers);
500
+
501
+ expect.assertions(2);
502
+
503
+ try {
504
+ await onRequest(request);
505
+ } catch (err) {
506
+ expect(err).toBeInstanceOf(errors.UnauthorizedError);
507
+ expect(err.securityReport).toMatchInlineSnapshot(`
508
+ [
509
+ {
510
+ "ok": false,
511
+ "schemes": {
512
+ "OAuth2": {
513
+ "error": [FastifyError: Scopes do not match required scopes],
514
+ "ok": false,
515
+ },
516
+ },
517
+ },
518
+ ]
519
+ `);
520
+ }
521
+ });
522
+ });
@@ -0,0 +1,14 @@
1
+ import OpenAPIParser from '@readme/openapi-parser';
2
+
3
+ export const validateSpec = async options => {
4
+ const spec = await OpenAPIParser.validate(options.spec);
5
+
6
+ const version = spec.openapi ?? spec.swagger;
7
+ const majorVersion = Number(version?.split?.('.')[0]);
8
+
9
+ if (isNaN(majorVersion) || majorVersion < 3 || majorVersion >= 4) {
10
+ throw new TypeError(`Unsupported OpenAPI version: ${version ?? 'unknown'}`);
11
+ }
12
+
13
+ return spec;
14
+ };
@@ -0,0 +1,91 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { validateSpec } from './spec.js';
3
+
4
+ describe('validateSpec()', () => {
5
+ it('should throw for invalid options', async () => {
6
+ await expect(validateSpec({})).rejects.toThrowError('Expected a file path, URL, or object.');
7
+ await expect(validateSpec({ spec: { foo: 'bar' } })).rejects.toThrowError(
8
+ 'Supplied schema is not a valid OpenAPI definition.'
9
+ );
10
+ });
11
+
12
+ it('should throw an error on unsupported OpenAPI versions', async () => {
13
+ const specSwagger2 = {
14
+ info: {
15
+ title: 'Title',
16
+ version: '1'
17
+ },
18
+ paths: {},
19
+ swagger: '2.0'
20
+ };
21
+ const specOpenApi2 = {
22
+ info: {},
23
+ openapi: '2.0.0',
24
+ paths: {}
25
+ };
26
+ const specOpenApi4 = {
27
+ info: {},
28
+ openapi: '4.0.0',
29
+ paths: {}
30
+ };
31
+
32
+ await expect(validateSpec({ spec: specSwagger2 })).rejects.toThrowError(/Unsupported OpenAPI version: 2\.0/);
33
+ await expect(validateSpec({ spec: specOpenApi2 })).rejects.toThrowError(/Unsupported OpenAPI version: 2\.0\.0/);
34
+ await expect(validateSpec({ spec: specOpenApi4 })).rejects.toThrowError(/Unsupported OpenAPI version: 4\.0\.0/);
35
+ });
36
+
37
+ it('should return a parsed spec', async () => {
38
+ const spec = {
39
+ info: {
40
+ title: 'Title',
41
+ version: '1'
42
+ },
43
+ openapi: '3.1.0',
44
+ paths: {}
45
+ };
46
+
47
+ expect(await validateSpec({ spec })).toMatchObject(spec);
48
+ });
49
+
50
+ it("should resolve '$ref' in spec", async () => {
51
+ const Pet = {
52
+ type: 'object'
53
+ };
54
+ const spec = {
55
+ components: {
56
+ schemas: {
57
+ Pet
58
+ }
59
+ },
60
+ info: {
61
+ title: 'Title',
62
+ version: '1'
63
+ },
64
+ openapi: '3.1.0',
65
+ paths: {
66
+ '/pet': {
67
+ get: {
68
+ responses: {
69
+ 200: {
70
+ content: {
71
+ 'application/json': {
72
+ schema: {
73
+ $ref: '#/components/schemas/Pet'
74
+ }
75
+ }
76
+ },
77
+ description: 'successful operation'
78
+ }
79
+ }
80
+ }
81
+ }
82
+ }
83
+ };
84
+
85
+ const parsedSpec = await validateSpec({ spec });
86
+ const { schema } = parsedSpec.paths['/pet'].get.responses['200'].content['application/json'];
87
+
88
+ expect(parsedSpec).toMatchObject(spec);
89
+ expect(schema).toMatchObject(Pet);
90
+ });
91
+ });
@@ -0,0 +1,4 @@
1
+ export const parseUrl = url => {
2
+ // fastify looks for a format 'resource/:param' but OpenAPI describes routes as 'resource/{param}'.
3
+ return url.replace(/{(\w+)}/g, ':$1');
4
+ };
@@ -0,0 +1,11 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseUrl } from './url.js';
3
+
4
+ describe('parseUrl()', () => {
5
+ it('should transform OpenAPI path templates to Fastify patterns', () => {
6
+ expect(parseUrl('/path')).toBe('/path');
7
+ expect(parseUrl('/path/{id}')).toBe('/path/:id');
8
+ expect(parseUrl('/path/{id}/resource/{another_id}')).toBe('/path/:id/resource/:another_id');
9
+ expect(parseUrl('/path/{id}/{another_id}')).toBe('/path/:id/:another_id');
10
+ });
11
+ });
package/src/plugin.js ADDED
@@ -0,0 +1,51 @@
1
+ import { DECORATOR_NAME } from './utils/constants.js';
2
+ import { errors } from './errors/index.js';
3
+ import { parse } from './parser/index.js';
4
+
5
+ const createRoute = (fastify, routes) => {
6
+ return ({ method, onRequest, operationId, schema, url, ...routeOptions }) => {
7
+ const route = routes[operationId];
8
+
9
+ // Throw an error if the operation is unknown.
10
+ if (!route) {
11
+ throw new TypeError(`Missing '${operationId}' in OpenAPI spec.`);
12
+ }
13
+
14
+ // Not allowed to override options inferred by the spec.
15
+ if (method || schema || url) {
16
+ throw new TypeError(`Not allowed to override 'method', 'schema' or 'url' for operation '${operationId}'.`);
17
+ }
18
+
19
+ // Check if there is a routeOptions.onRequest hook.
20
+ if (typeof onRequest === 'function') {
21
+ route.onRequest.push(onRequest);
22
+ }
23
+
24
+ // Register a new route.
25
+ fastify.route({
26
+ ...routes[operationId],
27
+ ...routeOptions
28
+ });
29
+ };
30
+ };
31
+
32
+ const plugin = async (fastify, options) => {
33
+ options = options ?? {};
34
+
35
+ const routes = await parse(options);
36
+
37
+ // Decorate fastify object.
38
+ fastify.decorate(DECORATOR_NAME, {
39
+ errors,
40
+ route: createRoute(fastify, routes)
41
+ });
42
+
43
+ // Decorate request.
44
+ fastify.decorateRequest(DECORATOR_NAME, {
45
+ operation: {},
46
+ security: {},
47
+ securityReport: []
48
+ });
49
+ };
50
+
51
+ export default plugin;
@@ -0,0 +1,3 @@
1
+ export const PLUGIN_NAME = '@uphold/fastify-openapi-router';
2
+
3
+ export const DECORATOR_NAME = 'oas';