Skip to content

Commit d44f033

Browse files
committed
feat(rest): match content type to request body spec
1 parent 7f7b015 commit d44f033

File tree

5 files changed

+152
-44
lines changed

5 files changed

+152
-44
lines changed

packages/rest/package.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"path-to-regexp": "^2.2.0",
4545
"qs": "^6.5.2",
4646
"strong-error-handler": "^3.2.0",
47+
"type-is": "^1.6.16",
4748
"validator": "^10.4.0"
4849
},
4950
"devDependencies": {
@@ -54,7 +55,8 @@
5455
"@types/debug": "0.0.30",
5556
"@types/js-yaml": "^3.11.1",
5657
"@types/lodash": "^4.14.106",
57-
"@types/node": "^10.11.2"
58+
"@types/node": "^10.11.2",
59+
"@types/type-is": "^1.6.2"
5860
},
5961
"files": [
6062
"README.md",

packages/rest/src/parser.ts

+75-39
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
isReferenceObject,
99
OperationObject,
1010
ParameterObject,
11+
ReferenceObject,
1112
SchemasObject,
1213
} from '@loopback/openapi-v3-types';
1314
import * as debugModule from 'debug';
@@ -26,6 +27,7 @@ import {
2627
RequestBodyParserOptions,
2728
} from './types';
2829
import {validateRequestBody} from './validation/request-body.validator';
30+
import {is} from 'type-is';
2931

3032
type HttpError = HttpErrors.HttpError;
3133

@@ -38,6 +40,8 @@ Object.freeze(QUERY_NOT_PARSED);
3840
type RequestBody = {
3941
value: any | undefined;
4042
coercionRequired?: boolean;
43+
mediaType?: string;
44+
schema?: SchemasObject | ReferenceObject;
4145
};
4246

4347
const parseJsonBody: (
@@ -55,14 +59,7 @@ const parseFormBody: (
5559
* @param req Http request
5660
*/
5761
function getContentType(req: Request): string | undefined {
58-
const val = req.headers['content-type'];
59-
if (typeof val === 'string') {
60-
return val;
61-
} else if (Array.isArray(val)) {
62-
// Assume only one value is present
63-
return val[0];
64-
}
65-
return undefined;
62+
return req.get('content-type');
6663
}
6764

6865
/**
@@ -90,54 +87,92 @@ export async function parseOperationArgs(
9087
);
9188
}
9289

93-
async function loadRequestBodyIfNeeded(
90+
function normalizeParsingError(err: HttpError) {
91+
debug('Cannot parse request body %j', err);
92+
if (!err.statusCode || err.statusCode >= 500) {
93+
err.statusCode = 400;
94+
}
95+
return err;
96+
}
97+
98+
export async function loadRequestBodyIfNeeded(
9499
operationSpec: OperationObject,
95100
request: Request,
96101
options: RequestBodyParserOptions = {},
97102
): Promise<RequestBody> {
98-
if (!operationSpec.requestBody) return Promise.resolve({value: undefined});
103+
const requestBody: RequestBody = {
104+
value: undefined,
105+
};
106+
if (!operationSpec.requestBody) return Promise.resolve(requestBody);
99107

100108
debug('Request body parser options: %j', options);
101109

102-
const contentType = getContentType(request);
110+
const contentType = getContentType(request) || 'application/json';
103111
debug('Loading request body with content type %j', contentType);
104112

105-
if (
106-
contentType &&
107-
contentType.startsWith('application/x-www-form-urlencoded')
108-
) {
109-
const body = await parseFormBody(request, options).catch(
110-
(err: HttpError) => {
111-
debug('Cannot parse request body %j', err);
112-
if (!err.statusCode || err.statusCode >= 500) {
113-
err.statusCode = 400;
114-
}
115-
throw err;
116-
},
117-
);
118-
// form parser returns an object with prototype
119-
return {
120-
value: Object.assign({}, body),
121-
coercionRequired: true,
113+
// the type of `operationSpec.requestBody` could be `RequestBodyObject`
114+
// or `ReferenceObject`, resolving a `$ref` value is not supported yet.
115+
if (isReferenceObject(operationSpec.requestBody)) {
116+
throw new Error('$ref requestBody is not supported yet.');
117+
}
118+
119+
let content = operationSpec.requestBody.content || {};
120+
if (!Object.keys(content).length) {
121+
content = {
122+
// default to allow json and urlencoded
123+
'application/json': {schema: {type: 'object'}},
124+
'application/x-www-form-urlencoded': {schema: {type: 'object'}},
122125
};
123126
}
124127

125-
if (contentType && !/json/.test(contentType)) {
128+
// Check of the request content type matches one of the expected media
129+
// types in the request body spec
130+
let matchedMediaType: string | false = false;
131+
for (const type in content) {
132+
matchedMediaType = is(contentType, type);
133+
if (matchedMediaType) {
134+
requestBody.mediaType = type;
135+
requestBody.schema = content[type].schema;
136+
break;
137+
}
138+
}
139+
140+
if (!matchedMediaType) {
141+
// No matching media type found, fail fast
126142
throw new HttpErrors.UnsupportedMediaType(
127-
`Content-type ${contentType} is not supported.`,
143+
`Content-type ${contentType} does not match [${Object.keys(content)}].`,
128144
);
129145
}
130146

131-
const jsonBody = await parseJsonBody(request, options).catch(
132-
(err: HttpError) => {
133-
debug('Cannot parse request body %j', err);
134-
if (!err.statusCode || err.statusCode >= 500) {
135-
err.statusCode = 400;
136-
}
137-
throw err;
138-
},
147+
if (is(matchedMediaType, 'urlencoded')) {
148+
try {
149+
const body = await parseFormBody(request, options);
150+
return Object.assign(requestBody, {
151+
// form parser returns an object without prototype
152+
// create a new copy to simplify shouldjs assertions
153+
value: Object.assign({}, body),
154+
// urlencoded body only provide string values
155+
// set the flag so that AJV can coerce them based on the schema
156+
coercionRequired: true,
157+
});
158+
} catch (err) {
159+
throw normalizeParsingError(err);
160+
}
161+
}
162+
163+
if (is(matchedMediaType, 'json')) {
164+
try {
165+
const jsonBody = await parseJsonBody(request, options);
166+
requestBody.value = jsonBody;
167+
return requestBody;
168+
} catch (err) {
169+
throw normalizeParsingError(err);
170+
}
171+
}
172+
173+
throw new HttpErrors.UnsupportedMediaType(
174+
`Content-type ${matchedMediaType} is not supported.`,
139175
);
140-
return {value: jsonBody};
141176
}
142177

143178
function buildOperationArguments(
@@ -175,6 +210,7 @@ function buildOperationArguments(
175210
debug('Validating request body - value %j', body);
176211
validateRequestBody(body.value, operationSpec.requestBody, globalSchemas, {
177212
coerceTypes: body.coercionRequired,
213+
schema: body.schema,
178214
});
179215

180216
if (requestBodyIndex > -1) paramArgs.splice(requestBodyIndex, 0, body.value);

packages/rest/src/validation/request-body.validator.ts

+8-3
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import {
77
RequestBodyObject,
88
SchemaObject,
9+
ReferenceObject,
910
SchemasObject,
1011
} from '@loopback/openapi-v3-types';
1112
import * as AJV from 'ajv';
@@ -18,11 +19,15 @@ import * as _ from 'lodash';
1819
const toJsonSchema = require('openapi-schema-to-json-schema');
1920
const debug = debugModule('loopback:rest:validation');
2021

22+
export interface RequestBodyValidationOptions extends AJV.Options {
23+
schema?: SchemaObject | ReferenceObject;
24+
}
25+
2126
/**
2227
* Check whether the request body is valid according to the provided OpenAPI schema.
2328
* The JSON schema is generated from the OpenAPI schema which is typically defined
2429
* by `@requestBody()`.
25-
* The validation leverages AJS shema validator.
30+
* The validation leverages AJS schema validator.
2631
* @param body The body data from an HTTP request.
2732
* @param requestBodySpec The OpenAPI requestBody specification defined in `@requestBody()`.
2833
* @param globalSchemas The referenced schemas generated from `OpenAPISpec.components.schemas`.
@@ -32,7 +37,7 @@ export function validateRequestBody(
3237
body: any,
3338
requestBodySpec: RequestBodyObject | undefined,
3439
globalSchemas?: SchemasObject,
35-
options?: AJV.Options,
40+
options: RequestBodyValidationOptions = {},
3641
) {
3742
if (!requestBodySpec) return;
3843

@@ -47,7 +52,7 @@ export function validateRequestBody(
4752
throw err;
4853
}
4954

50-
const schema = getRequestBodySchema(requestBodySpec);
55+
const schema = options.schema || getRequestBodySchema(requestBodySpec);
5156
debug('Request body schema: %j', util.inspect(schema, {depth: null}));
5257
if (!schema) return;
5358

packages/rest/test/integration/http-handler.integration.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,9 @@ describe('HttpHandler', () => {
272272
.send('<key>value</key>')
273273
.expect(415, {
274274
error: {
275-
message: 'Content-type application/xml is not supported.',
275+
message:
276+
'Content-type application/xml does not match ' +
277+
'[application/json,application/x-www-form-urlencoded].',
276278
name: 'UnsupportedMediaTypeError',
277279
statusCode: 415,
278280
},
@@ -351,6 +353,9 @@ describe('HttpHandler', () => {
351353
'application/json': {
352354
schema: {type: 'object'},
353355
},
356+
'application/x-www-form-urlencoded': {
357+
schema: {type: 'object'},
358+
},
354359
},
355360
},
356361
responses: {

packages/rest/test/unit/parser.unit.ts

+60
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
import {
1818
createResolvedRoute,
1919
parseOperationArgs,
20+
loadRequestBodyIfNeeded,
2021
PathParameterValues,
2122
Request,
2223
RestHttpErrors,
@@ -169,6 +170,65 @@ describe('operationArgsParser', () => {
169170
expect(args).to.eql([{key1: ['value1', 'value2']}]);
170171
});
171172

173+
describe('body parser', () => {
174+
it('parses body parameter with multiple media types', async () => {
175+
const req = givenRequest({
176+
url: '/',
177+
headers: {
178+
'Content-Type': 'application/x-www-form-urlencoded',
179+
},
180+
payload: 'key=value',
181+
});
182+
183+
const urlencodedSchema = {
184+
type: 'object',
185+
properties: {
186+
key: {type: 'string'},
187+
},
188+
};
189+
const spec = givenOperationWithRequestBody({
190+
description: 'data',
191+
content: {
192+
'application/json': {schema: {type: 'object'}},
193+
'application/x-www-form-urlencoded': {
194+
schema: urlencodedSchema,
195+
},
196+
},
197+
});
198+
const requestBody = await loadRequestBodyIfNeeded(spec, req);
199+
expect(requestBody).to.eql({
200+
value: {key: 'value'},
201+
coercionRequired: true,
202+
mediaType: 'application/x-www-form-urlencoded',
203+
schema: urlencodedSchema,
204+
});
205+
});
206+
207+
it('allows application/json to be default', async () => {
208+
const req = givenRequest({
209+
url: '/',
210+
headers: {
211+
'Content-Type': 'application/json',
212+
},
213+
payload: {key: 'value'},
214+
});
215+
216+
const defaultSchema = {
217+
type: 'object',
218+
};
219+
const spec = givenOperationWithRequestBody({
220+
description: 'data',
221+
content: {},
222+
});
223+
const requestBody = await loadRequestBodyIfNeeded(spec, req);
224+
expect(requestBody).to.eql({
225+
value: {key: 'value'},
226+
mediaType: 'application/json',
227+
schema: defaultSchema,
228+
});
229+
});
230+
});
231+
172232
context('in:query style:deepObject', () => {
173233
it('parses JSON-encoded string value', async () => {
174234
const req = givenRequest({

0 commit comments

Comments
 (0)