Skip to content

Commit

Permalink
Add array parameter coercing
Browse files Browse the repository at this point in the history
  • Loading branch information
satazor committed Sep 23, 2024
1 parent f4ea769 commit 2a3fccd
Show file tree
Hide file tree
Showing 6 changed files with 377 additions and 21 deletions.
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 4 additions & 3 deletions src/parser/index.js
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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'),
Expand Down
69 changes: 69 additions & 0 deletions src/parser/params.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
};
};
272 changes: 271 additions & 1 deletion src/parser/params.test.js
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
});
});
Loading

0 comments on commit 2a3fccd

Please sign in to comment.