Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add parameters coercing #16

Merged
merged 1 commit into from
Sep 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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