diff --git a/README.md b/README.md index e971ca3..440e15d 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,22 @@ fastify.oas.route({ }); ``` +### Caveats + +#### Coercing of `parameters` + +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: + +- Coercing of all primitive types is supported, like `number` and `boolean`. +- Coercing of `array` types are supported, albeit with limited styles: + - Path: simple. + - Query: form with exploded enabled or disabled. + - Headers: simple. + - Cookies: no support. +- Coercing of `object` types is not supported. + +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. + ## License [MIT](./LICENSE) diff --git a/src/parser/index.js b/src/parser/index.js index 61f40f5..5711217 100644 --- a/src/parser/index.js +++ b/src/parser/index.js @@ -1,8 +1,8 @@ import { DECORATOR_NAME } from '../utils/constants.js'; +import { applyParamsCoercing, parseParams } from './params.js'; +import { applySecurity, validateSecurity } from './security.js'; import { parseBody } from './body.js'; -import { parseParams } from './params.js'; import { parseResponse } from './response.js'; -import { parseSecurity, validateSecurity } from './security.js'; import { parseUrl } from './url.js'; import { validateSpec } from './spec.js'; @@ -28,7 +28,8 @@ export const parse = async options => { async function (request) { request[DECORATOR_NAME].operation = operation; }, - parseSecurity(operation, spec, options.securityHandlers, options.securityErrorMapper) + applySecurity(operation, spec, options.securityHandlers, options.securityErrorMapper), + applyParamsCoercing(operation) ].filter(Boolean), schema: { headers: parseParams(operation.parameters, 'header'), diff --git a/src/parser/params.js b/src/parser/params.js index 38201d0..b2f4ba3 100644 --- a/src/parser/params.js +++ b/src/parser/params.js @@ -22,3 +22,72 @@ export const parseParams = (parameters, location) => { return schema; }; + +export const applyParamsCoercing = operation => { + // Skip if operation has no parameters. + if (!operation.parameters) { + return; + } + + const coerceArrayParametersFns = operation.parameters + .filter(param => param.schema.type === 'array') + .map(param => { + switch (param.in) { + case 'header': + if (!param.style || param.style == 'simple') { + const lowercaseName = param.name.toLowerCase(); + + return request => { + const value = request.header[lowercaseName]; + + if (value && !Array.isArray(value)) { + request.header[lowercaseName] = value.split(','); + } + }; + } + + break; + + case 'path': + if (!param.style || param.style === 'simple') { + return request => { + const value = request.params[param.name]; + + if (value && !Array.isArray(value)) { + request.params[param.name] = value.split(','); + } + }; + } + + break; + + case 'query': + if (!param.style || param.style === 'form') { + if (param.explode === false) { + return request => { + const value = request.query[param.name]; + + if (value && !Array.isArray(value)) { + request.query[param.name] = value.split(','); + } + }; + } else { + return request => { + const value = request.query[param.name]; + + if (value && !Array.isArray(value)) { + request.query[param.name] = [value]; + } + }; + } + } + + break; + } + }) + .filter(Boolean); + + return async request => { + coerceArrayParametersFns.forEach(fn => fn(request)); + }; +}; diff --git a/src/parser/params.test.js b/src/parser/params.test.js index e9c52f9..3132086 100644 --- a/src/parser/params.test.js +++ b/src/parser/params.test.js @@ -1,5 +1,5 @@ +import { applyParamsCoercing, parseParams } from './params.js'; import { describe, expect, it } from 'vitest'; -import { parseParams } from './params.js'; describe('parseParams()', () => { it('should return an empty schema when passing invalid arguments', () => { @@ -70,3 +70,273 @@ describe('parseParams()', () => { expect(parseParams(params, 'query')).toStrictEqual(queryParamsSchema); }); }); + +describe('applyParamsCoercing()', () => { + it('should return undefined when operation has no parameters', () => { + expect(applyParamsCoercing({})).toBeUndefined(); + }); + + describe('header', () => { + it('should ignore if value is not set', () => { + const request = { + header: {} + }; + const operation = { + parameters: [ + { + in: 'header', + name: 'foo', + schema: { type: 'array' } + } + ] + }; + + applyParamsCoercing(operation)(request); + + expect(request.header).toStrictEqual({}); + }); + + [ + // Default. + { + expected: { foo: ['a', 'b'], foz: 'c,d' }, + input: { foo: 'a,b', foz: 'c,d' }, + spec: {} + }, + // Simple style. + { + expected: { foo: ['a', 'b'], foz: 'c,d' }, + input: { foo: 'a,b', foz: 'c,d' }, + spec: { style: 'simple' } + }, + // Simple style with explode explicitly set to true. + { + expected: { foo: ['a', 'b'], foz: 'c,d' }, + input: { foo: 'a,b', foz: 'c,d' }, + spec: { explode: true, style: 'simple' } + }, + // Simple style with explode set to false. + { + expected: { foo: ['a', 'b'], foz: 'c,d' }, + input: { foo: 'a,b', foz: 'c,d' }, + spec: { explode: false, style: 'simple' } + }, + // Ignore if already an array. + { + expected: { foo: ['a', 'b'], foz: 'c,d' }, + input: { foo: ['a', 'b'], foz: 'c,d' }, + spec: { style: 'simple' } + }, + // Unknown style. + { + expected: { foo: 'a,b', foz: 'c,d' }, + input: { foo: 'a,b', foz: 'c,d' }, + spec: { style: 'foobar' } + } + ].forEach(({ expected, input, spec: { explode, style } }) => { + it(`should coerce arrays when style is '${style}' and explode is '${explode}'`, () => { + const request = { + header: input + }; + const operation = { + parameters: [ + { + explode, + in: 'header', + name: 'Foo', + schema: { type: 'array' }, + style + }, + { + explode, + in: 'header', + name: 'Foz', + schema: { type: 'string' }, + style + } + ] + }; + + applyParamsCoercing(operation)(request); + + expect(request.header).toStrictEqual(expected); + }); + }); + }); + + describe('path', () => { + it('should ignore if value is not set', () => { + const request = { + params: {} + }; + const operation = { + parameters: [ + { + in: 'path', + name: 'foo', + schema: { type: 'array' } + } + ] + }; + + applyParamsCoercing(operation)(request); + + expect(request.params).toStrictEqual({}); + }); + + [ + // Default. + { + expected: { foo: ['a', 'b'], foz: 'c,d' }, + input: { foo: 'a,b', foz: 'c,d' }, + spec: {} + }, + // Simple style. + { + expected: { foo: ['a', 'b'], foz: 'c,d' }, + input: { foo: 'a,b', foz: 'c,d' }, + spec: { style: 'simple' } + }, + // Simple style with explode explicitly set to true. + { + expected: { foo: ['a', 'b'], foz: 'c,d' }, + input: { foo: 'a,b', foz: 'c,d' }, + spec: { explode: true, style: 'simple' } + }, + // Simple style with explode set to false. + { + expected: { foo: ['a', 'b'], foz: 'c,d' }, + input: { foo: 'a,b', foz: 'c,d' }, + spec: { explode: false, style: 'simple' } + }, + // Ignore if already an array. + { + expected: { foo: ['a', 'b'], foz: 'c,d' }, + input: { foo: ['a', 'b'], foz: 'c,d' }, + spec: { style: 'simple' } + }, + // Unknown style. + { + expected: { foo: 'a,b', foz: 'c,d' }, + input: { foo: 'a,b', foz: 'c,d' }, + spec: { style: 'foobar' } + } + ].forEach(({ expected, input, spec: { explode, style } }) => { + it(`should coerce arrays when style is '${style}' and explode is '${explode}'`, () => { + const request = { + params: input + }; + const operation = { + parameters: [ + { + explode, + in: 'path', + name: 'foo', + schema: { type: 'array' }, + style + }, + { + explode, + in: 'path', + name: 'foz', + schema: { type: 'string' }, + style + } + ] + }; + + applyParamsCoercing(operation)(request); + + expect(request.params).toStrictEqual(expected); + }); + }); + }); + + describe('query', () => { + it('should ignore if value is not set', () => { + const request = { + query: {} + }; + const operation = { + parameters: [ + { + in: 'query', + name: 'foo', + schema: { type: 'array' } + } + ] + }; + + applyParamsCoercing(operation)(request); + + expect(request.query).toStrictEqual({}); + }); + + [ + // Default. + { + expected: { foo: ['a,b'], foz: 'c,d' }, + input: { foo: 'a,b', foz: 'c,d' }, + spec: {} + }, + // Form style. + { + expected: { foo: ['a,b'], foz: 'c,d' }, + input: { foo: 'a,b', foz: 'c,d' }, + spec: { style: 'form' } + }, + // Form style with explode explicitly set to true. + { + expected: { foo: ['a,b'], foz: 'c,d' }, + input: { foo: 'a,b', foz: 'c,d' }, + spec: { explode: true, style: 'form' } + }, + // Form style with explode set to false. + { + expected: { foo: ['a', 'b'], foz: 'c,d' }, + input: { foo: 'a,b', foz: 'c,d' }, + spec: { explode: false, style: 'form' } + }, + // Ignore if already an array. + { + expected: { foo: ['a', 'b'], foz: 'c,d' }, + input: { foo: ['a', 'b'], foz: 'c,d' }, + spec: { style: 'form' } + }, + // Ignore if already an array. + { + expected: { foo: 'a,b', foz: 'c,d' }, + input: { foo: 'a,b', foz: 'c,d' }, + spec: { style: 'foobar' } + } + ].forEach(({ expected, input, spec: { explode, style } }) => { + it(`should coerce arrays when style is '${style}' and explode is '${explode}'`, () => { + const request = { + query: input + }; + const operation = { + parameters: [ + { + explode, + in: 'query', + name: 'foo', + schema: { type: 'array' }, + style + }, + { + explode, + in: 'query', + name: 'foz', + schema: { type: 'string' }, + style + } + ] + }; + + applyParamsCoercing(operation)(request); + + expect(request.query).toStrictEqual(expected); + }); + }); + }); +}); diff --git a/src/parser/security.js b/src/parser/security.js index 7c008eb..ea84c0c 100644 --- a/src/parser/security.js +++ b/src/parser/security.js @@ -4,7 +4,7 @@ import { extractSecuritySchemeValueFromRequest, verifyScopes } from '../utils/se import _ from 'lodash-es'; import pProps from 'p-props'; -export const parseSecurity = (operation, spec, securityHandlers, securityErrorMapper) => { +export const applySecurity = (operation, spec, securityHandlers, securityErrorMapper) => { // Use the operation security if it's defined, otherwise fallback to the spec global security. const operationSecurity = operation.security ?? spec.security ?? []; diff --git a/src/parser/security.test.js b/src/parser/security.test.js index b21de9b..deb3c7c 100644 --- a/src/parser/security.test.js +++ b/src/parser/security.test.js @@ -1,7 +1,7 @@ import { DECORATOR_NAME } from '../utils/constants.js'; +import { applySecurity, validateSecurity } from './security.js'; import { describe, expect, it, vi } from 'vitest'; import { errors } from '../errors/index.js'; -import { parseSecurity, validateSecurity } from './security.js'; describe('validateSecurity()', () => { it('should throw on invalid security handler option', () => { @@ -85,15 +85,15 @@ describe('validateSecurity()', () => { }); }); -describe('parseSecurity()', () => { +describe('applySecurity()', () => { it('should return undefined if no security', async () => { - expect(parseSecurity({}, {}, {})).toBeUndefined(); - expect(parseSecurity({ security: [] }, {}, {})).toBeUndefined(); - expect(parseSecurity({}, { security: [] }, {})).toBeUndefined(); + expect(applySecurity({}, {}, {})).toBeUndefined(); + expect(applySecurity({ security: [] }, {}, {})).toBeUndefined(); + expect(applySecurity({}, { security: [] }, {})).toBeUndefined(); }); it('should return undefined if `security` is disabled in operation', async () => { - const onRequest = parseSecurity({ security: [] }, { security: [{ OAuth2: [] }] }, {}); + const onRequest = applySecurity({ security: [] }, { security: [{ OAuth2: [] }] }, {}); expect(onRequest).toBeUndefined(); }); @@ -125,7 +125,7 @@ describe('parseSecurity()', () => { OAuth2: vi.fn(async () => ({ data: 'OAuth2 data', scopes: [] })) }; - const onRequest = parseSecurity(operation, spec, securityHandlers); + const onRequest = applySecurity(operation, spec, securityHandlers); await onRequest(request); @@ -180,7 +180,7 @@ describe('parseSecurity()', () => { OAuth2: vi.fn(async () => ({ data: 'OAuth2 data', scopes: [] })) }; - const onRequest = parseSecurity(operation, spec, securityHandlers); + const onRequest = applySecurity(operation, spec, securityHandlers); await onRequest(request); @@ -241,7 +241,7 @@ describe('parseSecurity()', () => { }) }; - const onRequest = parseSecurity(operation, spec, securityHandlers); + const onRequest = applySecurity(operation, spec, securityHandlers); expect.assertions(2); @@ -296,7 +296,7 @@ describe('parseSecurity()', () => { OAuth2: vi.fn(async () => ({ data: 'OAuth2 data', scopes: ['write'] })) }; - const onRequest = parseSecurity(operation, spec, securityHandlers); + const onRequest = applySecurity(operation, spec, securityHandlers); await onRequest(request); @@ -327,7 +327,7 @@ describe('parseSecurity()', () => { }) }; - const onRequest = parseSecurity(operation, spec, securityHandlers); + const onRequest = applySecurity(operation, spec, securityHandlers); expect.assertions(2); @@ -362,7 +362,7 @@ describe('parseSecurity()', () => { OAuth2: vi.fn(async () => ({ data: 'OAuth2 data', scopes: [] })) }; - const onRequest = parseSecurity(operation, spec, securityHandlers); + const onRequest = applySecurity(operation, spec, securityHandlers); expect.assertions(3); @@ -410,7 +410,7 @@ describe('parseSecurity()', () => { OAuth2: vi.fn(() => ({ data: 'OAuth2 data', scopes: ['read'] })) }; - const onRequest = parseSecurity(operation, spec, securityHandlers); + const onRequest = applySecurity(operation, spec, securityHandlers); expect.assertions(2); @@ -455,7 +455,7 @@ describe('parseSecurity()', () => { OAuth2: vi.fn(() => {}) }; - const onRequest = parseSecurity(operation, spec, securityHandlers); + const onRequest = applySecurity(operation, spec, securityHandlers); await onRequest(request); @@ -496,7 +496,7 @@ describe('parseSecurity()', () => { OAuth2: vi.fn(() => {}) }; - const onRequest = parseSecurity(operation, spec, securityHandlers); + const onRequest = applySecurity(operation, spec, securityHandlers); expect.assertions(2); @@ -545,7 +545,7 @@ describe('parseSecurity()', () => { const customError = new Error('Mapped error'); const securityErrorMapper = vi.fn(() => customError); - const onRequest = parseSecurity(operation, spec, securityHandlers, securityErrorMapper); + const onRequest = applySecurity(operation, spec, securityHandlers, securityErrorMapper); expect.assertions(3);