@uphold/fastify-openapi-router-plugin 0.3.0 → 0.4.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.
package/README.md CHANGED
@@ -246,6 +246,22 @@ fastify.oas.route({
246
246
  });
247
247
  ```
248
248
 
249
+ ### Caveats
250
+
251
+ #### Coercing of `parameters`
252
+
253
+ This plugin configures Fastify to coerce `parameters` to the correct type based on the schema, [style and explode](https://swagger.io/docs/specification/serialization/) keywords defined in the OpenAPI specification. However, there are limitations. Here's an overview:
254
+
255
+ - Coercing of all primitive types is supported, like `number` and `boolean`.
256
+ - Coercing of `array` types are supported, albeit with limited styles:
257
+ - Path: simple.
258
+ - Query: form with exploded enabled or disabled.
259
+ - Headers: simple.
260
+ - Cookies: no support.
261
+ - Coercing of `object` types is not supported.
262
+
263
+ If your API needs improved coercion support, like `object` types or `cookie` parameters, please [fill an issue](https://github.com/uphold/fastify-openapi-router-plugin/issues/new) to discuss the implementation.
264
+
249
265
  ## License
250
266
 
251
267
  [MIT](./LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uphold/fastify-openapi-router-plugin",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "A plugin for Fastify to connect routes with a OpenAPI 3.x specification",
5
5
  "main": "./src/index.js",
6
6
  "types": "types/index.d.ts",
@@ -1,8 +1,8 @@
1
1
  import { DECORATOR_NAME } from '../utils/constants.js';
2
+ import { applyParamsCoercing, parseParams } from './params.js';
3
+ import { applySecurity, validateSecurity } from './security.js';
2
4
  import { parseBody } from './body.js';
3
- import { parseParams } from './params.js';
4
5
  import { parseResponse } from './response.js';
5
- import { parseSecurity, validateSecurity } from './security.js';
6
6
  import { parseUrl } from './url.js';
7
7
  import { validateSpec } from './spec.js';
8
8
 
@@ -28,7 +28,8 @@ export const parse = async options => {
28
28
  async function (request) {
29
29
  request[DECORATOR_NAME].operation = operation;
30
30
  },
31
- parseSecurity(operation, spec, options.securityHandlers, options.securityErrorMapper)
31
+ applySecurity(operation, spec, options.securityHandlers, options.securityErrorMapper),
32
+ applyParamsCoercing(operation)
32
33
  ].filter(Boolean),
33
34
  schema: {
34
35
  headers: parseParams(operation.parameters, 'header'),
@@ -22,3 +22,72 @@ export const parseParams = (parameters, location) => {
22
22
 
23
23
  return schema;
24
24
  };
25
+
26
+ export const applyParamsCoercing = operation => {
27
+ // Skip if operation has no parameters.
28
+ if (!operation.parameters) {
29
+ return;
30
+ }
31
+
32
+ const coerceArrayParametersFns = operation.parameters
33
+ .filter(param => param.schema.type === 'array')
34
+ .map(param => {
35
+ switch (param.in) {
36
+ case 'header':
37
+ if (!param.style || param.style == 'simple') {
38
+ const lowercaseName = param.name.toLowerCase();
39
+
40
+ return request => {
41
+ const value = request.header[lowercaseName];
42
+
43
+ if (value && !Array.isArray(value)) {
44
+ request.header[lowercaseName] = value.split(',');
45
+ }
46
+ };
47
+ }
48
+
49
+ break;
50
+
51
+ case 'path':
52
+ if (!param.style || param.style === 'simple') {
53
+ return request => {
54
+ const value = request.params[param.name];
55
+
56
+ if (value && !Array.isArray(value)) {
57
+ request.params[param.name] = value.split(',');
58
+ }
59
+ };
60
+ }
61
+
62
+ break;
63
+
64
+ case 'query':
65
+ if (!param.style || param.style === 'form') {
66
+ if (param.explode === false) {
67
+ return request => {
68
+ const value = request.query[param.name];
69
+
70
+ if (value && !Array.isArray(value)) {
71
+ request.query[param.name] = value.split(',');
72
+ }
73
+ };
74
+ } else {
75
+ return request => {
76
+ const value = request.query[param.name];
77
+
78
+ if (value && !Array.isArray(value)) {
79
+ request.query[param.name] = [value];
80
+ }
81
+ };
82
+ }
83
+ }
84
+
85
+ break;
86
+ }
87
+ })
88
+ .filter(Boolean);
89
+
90
+ return async request => {
91
+ coerceArrayParametersFns.forEach(fn => fn(request));
92
+ };
93
+ };
@@ -1,5 +1,5 @@
1
+ import { applyParamsCoercing, parseParams } from './params.js';
1
2
  import { describe, expect, it } from 'vitest';
2
- import { parseParams } from './params.js';
3
3
 
4
4
  describe('parseParams()', () => {
5
5
  it('should return an empty schema when passing invalid arguments', () => {
@@ -70,3 +70,273 @@ describe('parseParams()', () => {
70
70
  expect(parseParams(params, 'query')).toStrictEqual(queryParamsSchema);
71
71
  });
72
72
  });
73
+
74
+ describe('applyParamsCoercing()', () => {
75
+ it('should return undefined when operation has no parameters', () => {
76
+ expect(applyParamsCoercing({})).toBeUndefined();
77
+ });
78
+
79
+ describe('header', () => {
80
+ it('should ignore if value is not set', () => {
81
+ const request = {
82
+ header: {}
83
+ };
84
+ const operation = {
85
+ parameters: [
86
+ {
87
+ in: 'header',
88
+ name: 'foo',
89
+ schema: { type: 'array' }
90
+ }
91
+ ]
92
+ };
93
+
94
+ applyParamsCoercing(operation)(request);
95
+
96
+ expect(request.header).toStrictEqual({});
97
+ });
98
+
99
+ [
100
+ // Default.
101
+ {
102
+ expected: { foo: ['a', 'b'], foz: 'c,d' },
103
+ input: { foo: 'a,b', foz: 'c,d' },
104
+ spec: {}
105
+ },
106
+ // Simple style.
107
+ {
108
+ expected: { foo: ['a', 'b'], foz: 'c,d' },
109
+ input: { foo: 'a,b', foz: 'c,d' },
110
+ spec: { style: 'simple' }
111
+ },
112
+ // Simple style with explode explicitly set to true.
113
+ {
114
+ expected: { foo: ['a', 'b'], foz: 'c,d' },
115
+ input: { foo: 'a,b', foz: 'c,d' },
116
+ spec: { explode: true, style: 'simple' }
117
+ },
118
+ // Simple style with explode set to false.
119
+ {
120
+ expected: { foo: ['a', 'b'], foz: 'c,d' },
121
+ input: { foo: 'a,b', foz: 'c,d' },
122
+ spec: { explode: false, style: 'simple' }
123
+ },
124
+ // Ignore if already an array.
125
+ {
126
+ expected: { foo: ['a', 'b'], foz: 'c,d' },
127
+ input: { foo: ['a', 'b'], foz: 'c,d' },
128
+ spec: { style: 'simple' }
129
+ },
130
+ // Unknown style.
131
+ {
132
+ expected: { foo: 'a,b', foz: 'c,d' },
133
+ input: { foo: 'a,b', foz: 'c,d' },
134
+ spec: { style: 'foobar' }
135
+ }
136
+ ].forEach(({ expected, input, spec: { explode, style } }) => {
137
+ it(`should coerce arrays when style is '${style}' and explode is '${explode}'`, () => {
138
+ const request = {
139
+ header: input
140
+ };
141
+ const operation = {
142
+ parameters: [
143
+ {
144
+ explode,
145
+ in: 'header',
146
+ name: 'Foo',
147
+ schema: { type: 'array' },
148
+ style
149
+ },
150
+ {
151
+ explode,
152
+ in: 'header',
153
+ name: 'Foz',
154
+ schema: { type: 'string' },
155
+ style
156
+ }
157
+ ]
158
+ };
159
+
160
+ applyParamsCoercing(operation)(request);
161
+
162
+ expect(request.header).toStrictEqual(expected);
163
+ });
164
+ });
165
+ });
166
+
167
+ describe('path', () => {
168
+ it('should ignore if value is not set', () => {
169
+ const request = {
170
+ params: {}
171
+ };
172
+ const operation = {
173
+ parameters: [
174
+ {
175
+ in: 'path',
176
+ name: 'foo',
177
+ schema: { type: 'array' }
178
+ }
179
+ ]
180
+ };
181
+
182
+ applyParamsCoercing(operation)(request);
183
+
184
+ expect(request.params).toStrictEqual({});
185
+ });
186
+
187
+ [
188
+ // Default.
189
+ {
190
+ expected: { foo: ['a', 'b'], foz: 'c,d' },
191
+ input: { foo: 'a,b', foz: 'c,d' },
192
+ spec: {}
193
+ },
194
+ // Simple style.
195
+ {
196
+ expected: { foo: ['a', 'b'], foz: 'c,d' },
197
+ input: { foo: 'a,b', foz: 'c,d' },
198
+ spec: { style: 'simple' }
199
+ },
200
+ // Simple style with explode explicitly set to true.
201
+ {
202
+ expected: { foo: ['a', 'b'], foz: 'c,d' },
203
+ input: { foo: 'a,b', foz: 'c,d' },
204
+ spec: { explode: true, style: 'simple' }
205
+ },
206
+ // Simple style with explode set to false.
207
+ {
208
+ expected: { foo: ['a', 'b'], foz: 'c,d' },
209
+ input: { foo: 'a,b', foz: 'c,d' },
210
+ spec: { explode: false, style: 'simple' }
211
+ },
212
+ // Ignore if already an array.
213
+ {
214
+ expected: { foo: ['a', 'b'], foz: 'c,d' },
215
+ input: { foo: ['a', 'b'], foz: 'c,d' },
216
+ spec: { style: 'simple' }
217
+ },
218
+ // Unknown style.
219
+ {
220
+ expected: { foo: 'a,b', foz: 'c,d' },
221
+ input: { foo: 'a,b', foz: 'c,d' },
222
+ spec: { style: 'foobar' }
223
+ }
224
+ ].forEach(({ expected, input, spec: { explode, style } }) => {
225
+ it(`should coerce arrays when style is '${style}' and explode is '${explode}'`, () => {
226
+ const request = {
227
+ params: input
228
+ };
229
+ const operation = {
230
+ parameters: [
231
+ {
232
+ explode,
233
+ in: 'path',
234
+ name: 'foo',
235
+ schema: { type: 'array' },
236
+ style
237
+ },
238
+ {
239
+ explode,
240
+ in: 'path',
241
+ name: 'foz',
242
+ schema: { type: 'string' },
243
+ style
244
+ }
245
+ ]
246
+ };
247
+
248
+ applyParamsCoercing(operation)(request);
249
+
250
+ expect(request.params).toStrictEqual(expected);
251
+ });
252
+ });
253
+ });
254
+
255
+ describe('query', () => {
256
+ it('should ignore if value is not set', () => {
257
+ const request = {
258
+ query: {}
259
+ };
260
+ const operation = {
261
+ parameters: [
262
+ {
263
+ in: 'query',
264
+ name: 'foo',
265
+ schema: { type: 'array' }
266
+ }
267
+ ]
268
+ };
269
+
270
+ applyParamsCoercing(operation)(request);
271
+
272
+ expect(request.query).toStrictEqual({});
273
+ });
274
+
275
+ [
276
+ // Default.
277
+ {
278
+ expected: { foo: ['a,b'], foz: 'c,d' },
279
+ input: { foo: 'a,b', foz: 'c,d' },
280
+ spec: {}
281
+ },
282
+ // Form style.
283
+ {
284
+ expected: { foo: ['a,b'], foz: 'c,d' },
285
+ input: { foo: 'a,b', foz: 'c,d' },
286
+ spec: { style: 'form' }
287
+ },
288
+ // Form style with explode explicitly set to true.
289
+ {
290
+ expected: { foo: ['a,b'], foz: 'c,d' },
291
+ input: { foo: 'a,b', foz: 'c,d' },
292
+ spec: { explode: true, style: 'form' }
293
+ },
294
+ // Form style with explode set to false.
295
+ {
296
+ expected: { foo: ['a', 'b'], foz: 'c,d' },
297
+ input: { foo: 'a,b', foz: 'c,d' },
298
+ spec: { explode: false, style: 'form' }
299
+ },
300
+ // Ignore if already an array.
301
+ {
302
+ expected: { foo: ['a', 'b'], foz: 'c,d' },
303
+ input: { foo: ['a', 'b'], foz: 'c,d' },
304
+ spec: { style: 'form' }
305
+ },
306
+ // Ignore if already an array.
307
+ {
308
+ expected: { foo: 'a,b', foz: 'c,d' },
309
+ input: { foo: 'a,b', foz: 'c,d' },
310
+ spec: { style: 'foobar' }
311
+ }
312
+ ].forEach(({ expected, input, spec: { explode, style } }) => {
313
+ it(`should coerce arrays when style is '${style}' and explode is '${explode}'`, () => {
314
+ const request = {
315
+ query: input
316
+ };
317
+ const operation = {
318
+ parameters: [
319
+ {
320
+ explode,
321
+ in: 'query',
322
+ name: 'foo',
323
+ schema: { type: 'array' },
324
+ style
325
+ },
326
+ {
327
+ explode,
328
+ in: 'query',
329
+ name: 'foz',
330
+ schema: { type: 'string' },
331
+ style
332
+ }
333
+ ]
334
+ };
335
+
336
+ applyParamsCoercing(operation)(request);
337
+
338
+ expect(request.query).toStrictEqual(expected);
339
+ });
340
+ });
341
+ });
342
+ });
@@ -4,7 +4,7 @@ import { extractSecuritySchemeValueFromRequest, verifyScopes } from '../utils/se
4
4
  import _ from 'lodash-es';
5
5
  import pProps from 'p-props';
6
6
 
7
- export const parseSecurity = (operation, spec, securityHandlers, securityErrorMapper) => {
7
+ export const applySecurity = (operation, spec, securityHandlers, securityErrorMapper) => {
8
8
  // Use the operation security if it's defined, otherwise fallback to the spec global security.
9
9
  const operationSecurity = operation.security ?? spec.security ?? [];
10
10
 
@@ -1,7 +1,7 @@
1
1
  import { DECORATOR_NAME } from '../utils/constants.js';
2
+ import { applySecurity, validateSecurity } from './security.js';
2
3
  import { describe, expect, it, vi } from 'vitest';
3
4
  import { errors } from '../errors/index.js';
4
- import { parseSecurity, validateSecurity } from './security.js';
5
5
 
6
6
  describe('validateSecurity()', () => {
7
7
  it('should throw on invalid security handler option', () => {
@@ -85,15 +85,15 @@ describe('validateSecurity()', () => {
85
85
  });
86
86
  });
87
87
 
88
- describe('parseSecurity()', () => {
88
+ describe('applySecurity()', () => {
89
89
  it('should return undefined if no security', async () => {
90
- expect(parseSecurity({}, {}, {})).toBeUndefined();
91
- expect(parseSecurity({ security: [] }, {}, {})).toBeUndefined();
92
- expect(parseSecurity({}, { security: [] }, {})).toBeUndefined();
90
+ expect(applySecurity({}, {}, {})).toBeUndefined();
91
+ expect(applySecurity({ security: [] }, {}, {})).toBeUndefined();
92
+ expect(applySecurity({}, { security: [] }, {})).toBeUndefined();
93
93
  });
94
94
 
95
95
  it('should return undefined if `security` is disabled in operation', async () => {
96
- const onRequest = parseSecurity({ security: [] }, { security: [{ OAuth2: [] }] }, {});
96
+ const onRequest = applySecurity({ security: [] }, { security: [{ OAuth2: [] }] }, {});
97
97
 
98
98
  expect(onRequest).toBeUndefined();
99
99
  });
@@ -125,7 +125,7 @@ describe('parseSecurity()', () => {
125
125
  OAuth2: vi.fn(async () => ({ data: 'OAuth2 data', scopes: [] }))
126
126
  };
127
127
 
128
- const onRequest = parseSecurity(operation, spec, securityHandlers);
128
+ const onRequest = applySecurity(operation, spec, securityHandlers);
129
129
 
130
130
  await onRequest(request);
131
131
 
@@ -180,7 +180,7 @@ describe('parseSecurity()', () => {
180
180
  OAuth2: vi.fn(async () => ({ data: 'OAuth2 data', scopes: [] }))
181
181
  };
182
182
 
183
- const onRequest = parseSecurity(operation, spec, securityHandlers);
183
+ const onRequest = applySecurity(operation, spec, securityHandlers);
184
184
 
185
185
  await onRequest(request);
186
186
 
@@ -241,7 +241,7 @@ describe('parseSecurity()', () => {
241
241
  })
242
242
  };
243
243
 
244
- const onRequest = parseSecurity(operation, spec, securityHandlers);
244
+ const onRequest = applySecurity(operation, spec, securityHandlers);
245
245
 
246
246
  expect.assertions(2);
247
247
 
@@ -296,7 +296,7 @@ describe('parseSecurity()', () => {
296
296
  OAuth2: vi.fn(async () => ({ data: 'OAuth2 data', scopes: ['write'] }))
297
297
  };
298
298
 
299
- const onRequest = parseSecurity(operation, spec, securityHandlers);
299
+ const onRequest = applySecurity(operation, spec, securityHandlers);
300
300
 
301
301
  await onRequest(request);
302
302
 
@@ -327,7 +327,7 @@ describe('parseSecurity()', () => {
327
327
  })
328
328
  };
329
329
 
330
- const onRequest = parseSecurity(operation, spec, securityHandlers);
330
+ const onRequest = applySecurity(operation, spec, securityHandlers);
331
331
 
332
332
  expect.assertions(2);
333
333
 
@@ -362,7 +362,7 @@ describe('parseSecurity()', () => {
362
362
  OAuth2: vi.fn(async () => ({ data: 'OAuth2 data', scopes: [] }))
363
363
  };
364
364
 
365
- const onRequest = parseSecurity(operation, spec, securityHandlers);
365
+ const onRequest = applySecurity(operation, spec, securityHandlers);
366
366
 
367
367
  expect.assertions(3);
368
368
 
@@ -410,7 +410,7 @@ describe('parseSecurity()', () => {
410
410
  OAuth2: vi.fn(() => ({ data: 'OAuth2 data', scopes: ['read'] }))
411
411
  };
412
412
 
413
- const onRequest = parseSecurity(operation, spec, securityHandlers);
413
+ const onRequest = applySecurity(operation, spec, securityHandlers);
414
414
 
415
415
  expect.assertions(2);
416
416
 
@@ -455,7 +455,7 @@ describe('parseSecurity()', () => {
455
455
  OAuth2: vi.fn(() => {})
456
456
  };
457
457
 
458
- const onRequest = parseSecurity(operation, spec, securityHandlers);
458
+ const onRequest = applySecurity(operation, spec, securityHandlers);
459
459
 
460
460
  await onRequest(request);
461
461
 
@@ -496,7 +496,7 @@ describe('parseSecurity()', () => {
496
496
  OAuth2: vi.fn(() => {})
497
497
  };
498
498
 
499
- const onRequest = parseSecurity(operation, spec, securityHandlers);
499
+ const onRequest = applySecurity(operation, spec, securityHandlers);
500
500
 
501
501
  expect.assertions(2);
502
502
 
@@ -545,7 +545,7 @@ describe('parseSecurity()', () => {
545
545
  const customError = new Error('Mapped error');
546
546
  const securityErrorMapper = vi.fn(() => customError);
547
547
 
548
- const onRequest = parseSecurity(operation, spec, securityHandlers, securityErrorMapper);
548
+ const onRequest = applySecurity(operation, spec, securityHandlers, securityErrorMapper);
549
549
 
550
550
  expect.assertions(3);
551
551