Skip to content

Commit 2d9e0a8

Browse files
committed
feat(rest): add support for form request body
See #1797 - allow applications to configure body parser options - match content type to request body spec - use RequestBody for validation - extract unsupported media type error
1 parent 2e099eb commit 2d9e0a8

File tree

14 files changed

+691
-104
lines changed

14 files changed

+691
-104
lines changed

docs/site/Parsing-requests.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,77 @@ in/by the `@requestBody` decorator. Please refer to the documentation on
179179
[@requestBody decorator](Decorators.md#requestbody-decorator) to get a
180180
comprehensive idea of defining custom validation rules for your models.
181181

182+
We support `json` and `urlencoded` content types. The client should set
183+
`Content-Type` http header to `application/json` or
184+
`application/x-www-form-urlencoded`. Its value is matched against the list of
185+
media types defined in the `requestBody.content` object of the OpenAPI operation
186+
spec. If no matching media types is found or the type is not supported yet, an
187+
UnsupportedMediaTypeError (http statusCode 415) will be reported.
188+
189+
Please note that `urlencoded` media type does not support data typing. For
190+
example, `key=3` is parsed as `{key: '3'}`. The raw result is then coerced by
191+
AJV based on the matching content schema. The coercion rules are described in
192+
[AJV type coercion rules](https://github.com/epoberezkin/ajv/blob/master/COERCION.md).
193+
194+
The [qs](https://github.com/ljharb/qs) is used to parse complex strings. For
195+
example, given the following request body definition:
196+
197+
```ts
198+
const requestBodyObject = {
199+
description: 'data',
200+
content: {
201+
'application/x-www-form-urlencoded': {
202+
schema: {
203+
type: 'object',
204+
properties: {
205+
name: {type: 'string'},
206+
location: {
207+
type: 'object',
208+
properties: {
209+
lat: {type: 'number'},
210+
lng: {type: 'number'},
211+
},
212+
},
213+
tags: {
214+
type: 'array',
215+
items: {type: 'string'},
216+
},
217+
},
218+
},
219+
},
220+
},
221+
};
222+
```
223+
224+
The encoded value
225+
`'name=IBM%20HQ&location[lat]=0.741895&location[lng]=-73.989308&tags[0]=IT&tags[1]=NY'`
226+
is parsed and coerced as:
227+
228+
```ts
229+
{
230+
name: 'IBM HQ',
231+
location: {lat: 0.741895, lng: -73.989308},
232+
tags: ['IT', 'NY'],
233+
}
234+
```
235+
236+
The request body parser options (such as `limit`) can now be configured by
237+
binding the value to `RestBindings.REQUEST_BODY_PARSER_OPTIONS`
238+
('rest.requestBodyParserOptions'). For example,
239+
240+
```ts
241+
server
242+
.bind(RestBindings.REQUEST_BODY_PARSER_OPTIONS)
243+
.to({limit: 4 * 1024 * 1024}); // Set limit to 4MB
244+
```
245+
246+
The list of options can be found in the [body](https://github.com/Raynos/body)
247+
module.
248+
249+
By default, the `limit` is `1024 * 1024` (1MB). Any request with a body length
250+
exceeding the limit will be rejected with http status code 413 (request entity
251+
too large).
252+
182253
A few tips worth mentioning:
183254

184255
- If a model property's type refers to another model, make sure it is also

packages/openapi-v3/src/decorators/request-body.decorator.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,9 @@ export function requestBody(requestBodySpec?: Partial<RequestBodyObject>) {
8181
return function(target: Object, member: string, index: number) {
8282
debug('@requestBody() on %s.%s', target.constructor.name, member);
8383
debug(' parameter index: %s', index);
84-
debug(' options: %s', inspect(requestBodySpec, {depth: null}));
84+
/* istanbul ignore if */
85+
if (debug.enabled)
86+
debug(' options: %s', inspect(requestBodySpec, {depth: null}));
8587

8688
// Use 'application/json' as default content if `requestBody` is undefined
8789
requestBodySpec = requestBodySpec || {content: {}};
@@ -95,7 +97,9 @@ export function requestBody(requestBodySpec?: Partial<RequestBodyObject>) {
9597

9698
const paramType = paramTypes[index];
9799
const schema = resolveSchema(paramType);
98-
debug(' inferred schema: %s', inspect(schema, {depth: null}));
100+
/* istanbul ignore if */
101+
if (debug.enabled)
102+
debug(' inferred schema: %s', inspect(schema, {depth: null}));
99103
requestBodySpec.content = _.mapValues(requestBodySpec.content, c => {
100104
if (!c.schema) {
101105
c.schema = schema;
@@ -109,7 +113,9 @@ export function requestBody(requestBodySpec?: Partial<RequestBodyObject>) {
109113
requestBodySpec[REQUEST_BODY_INDEX] = index;
110114
}
111115

112-
debug(' final spec: ', inspect(requestBodySpec, {depth: null}));
116+
/* istanbul ignore if */
117+
if (debug.enabled)
118+
debug(' final spec: ', inspect(requestBodySpec, {depth: null}));
113119
ParameterDecoratorFactory.createDecorator<RequestBodyObject>(
114120
OAI3Keys.REQUEST_BODY_KEY,
115121
requestBodySpec as RequestBodyObject,

packages/rest/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
"@types/express": "^4.11.1",
3030
"@types/http-errors": "^1.6.1",
3131
"@types/parseurl": "^1.3.1",
32-
"@types/qs": "^6.5.1",
3332
"ajv": "^6.5.1",
3433
"body": "^5.1.0",
3534
"cors": "^2.8.4",
@@ -44,6 +43,7 @@
4443
"path-to-regexp": "^2.2.0",
4544
"qs": "^6.5.2",
4645
"strong-error-handler": "^3.2.0",
46+
"type-is": "^1.6.16",
4747
"validator": "^10.4.0"
4848
},
4949
"devDependencies": {
@@ -56,7 +56,9 @@
5656
"@types/js-yaml": "^3.11.1",
5757
"@types/lodash": "^4.14.106",
5858
"@types/node": "^10.11.2",
59-
"@types/serve-static": "1.13.2"
59+
"@types/serve-static": "1.13.2",
60+
"@types/qs": "^6.5.1",
61+
"@types/type-is": "^1.6.2"
6062
},
6163
"files": [
6264
"README.md",

packages/rest/src/keys.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
ParseParams,
2626
Reject,
2727
Send,
28+
RequestBodyParserOptions,
2829
} from './types';
2930

3031
import {HttpProtocol} from '@loopback/http-server';
@@ -84,6 +85,10 @@ export namespace RestBindings {
8485
'rest.errorWriterOptions',
8586
);
8687

88+
export const REQUEST_BODY_PARSER_OPTIONS = BindingKey.create<
89+
RequestBodyParserOptions
90+
>('rest.requestBodyParserOptions');
91+
8792
/**
8893
* Binding key for setting and injecting an OpenAPI spec
8994
*/

packages/rest/src/parser.ts

Lines changed: 122 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
isReferenceObject,
99
OperationObject,
1010
ParameterObject,
11+
ReferenceObject,
12+
SchemaObject,
1113
SchemasObject,
1214
} from '@loopback/openapi-v3-types';
1315
import * as debugModule from 'debug';
@@ -19,8 +21,15 @@ import {promisify} from 'util';
1921
import {coerceParameter} from './coercion/coerce-parameter';
2022
import {RestHttpErrors} from './index';
2123
import {ResolvedRoute} from './router/routing-table';
22-
import {OperationArgs, PathParameterValues, Request} from './types';
24+
import {
25+
OperationArgs,
26+
PathParameterValues,
27+
Request,
28+
RequestBodyParserOptions,
29+
} from './types';
2330
import {validateRequestBody} from './validation/request-body.validator';
31+
import {is} from 'type-is';
32+
import * as qs from 'qs';
2433

2534
type HttpError = HttpErrors.HttpError;
2635

@@ -29,26 +38,30 @@ const debug = debugModule('loopback:rest:parser');
2938
export const QUERY_NOT_PARSED = {};
3039
Object.freeze(QUERY_NOT_PARSED);
3140

32-
// tslint:disable-next-line:no-any
33-
type MaybeBody = any | undefined;
41+
// tslint:disable:no-any
42+
export type RequestBody = {
43+
value: any | undefined;
44+
coercionRequired?: boolean;
45+
mediaType?: string;
46+
schema?: SchemaObject | ReferenceObject;
47+
};
48+
49+
const parseJsonBody: (
50+
req: IncomingMessage,
51+
options: {},
52+
) => Promise<any> = promisify(require('body/json'));
3453

35-
const parseJsonBody: (req: IncomingMessage) => Promise<MaybeBody> = promisify(
36-
require('body/json'),
37-
);
54+
const parseFormBody: (
55+
req: IncomingMessage,
56+
options: {},
57+
) => Promise<any> = promisify(require('body/form'));
3858

3959
/**
4060
* Get the content-type header value from the request
4161
* @param req Http request
4262
*/
4363
function getContentType(req: Request): string | undefined {
44-
const val = req.headers['content-type'];
45-
if (typeof val === 'string') {
46-
return val;
47-
} else if (Array.isArray(val)) {
48-
// Assume only one value is present
49-
return val[0];
50-
}
51-
return undefined;
64+
return req.get('content-type');
5265
}
5366

5467
/**
@@ -61,11 +74,12 @@ function getContentType(req: Request): string | undefined {
6174
export async function parseOperationArgs(
6275
request: Request,
6376
route: ResolvedRoute,
77+
options: RequestBodyParserOptions = {},
6478
): Promise<OperationArgs> {
6579
debug('Parsing operation arguments for route %s', route.describe());
6680
const operationSpec = route.spec;
6781
const pathParams = route.pathParams;
68-
const body = await loadRequestBodyIfNeeded(operationSpec, request);
82+
const body = await loadRequestBodyIfNeeded(operationSpec, request, options);
6983
return buildOperationArguments(
7084
operationSpec,
7185
request,
@@ -75,32 +89,111 @@ export async function parseOperationArgs(
7589
);
7690
}
7791

78-
async function loadRequestBodyIfNeeded(
92+
function normalizeParsingError(err: HttpError) {
93+
debug('Cannot parse request body %j', err);
94+
if (!err.statusCode || err.statusCode >= 500) {
95+
err.statusCode = 400;
96+
}
97+
return err;
98+
}
99+
100+
export async function loadRequestBodyIfNeeded(
79101
operationSpec: OperationObject,
80102
request: Request,
81-
): Promise<MaybeBody> {
82-
if (!operationSpec.requestBody) return Promise.resolve();
103+
options: RequestBodyParserOptions = {},
104+
): Promise<RequestBody> {
105+
const requestBody: RequestBody = {
106+
value: undefined,
107+
};
108+
if (!operationSpec.requestBody) return Promise.resolve(requestBody);
109+
110+
debug('Request body parser options: %j', options);
83111

84-
const contentType = getContentType(request);
112+
const contentType = getContentType(request) || 'application/json';
85113
debug('Loading request body with content type %j', contentType);
86-
if (contentType && !/json/.test(contentType)) {
87-
throw new HttpErrors.UnsupportedMediaType(
88-
`Content-type ${contentType} is not supported.`,
114+
115+
// the type of `operationSpec.requestBody` could be `RequestBodyObject`
116+
// or `ReferenceObject`, resolving a `$ref` value is not supported yet.
117+
if (isReferenceObject(operationSpec.requestBody)) {
118+
throw new Error('$ref requestBody is not supported yet.');
119+
}
120+
121+
let content = operationSpec.requestBody.content || {};
122+
if (!Object.keys(content).length) {
123+
content = {
124+
// default to allow json and urlencoded
125+
'application/json': {schema: {type: 'object'}},
126+
'application/x-www-form-urlencoded': {schema: {type: 'object'}},
127+
};
128+
}
129+
130+
// Check of the request content type matches one of the expected media
131+
// types in the request body spec
132+
let matchedMediaType: string | false = false;
133+
for (const type in content) {
134+
matchedMediaType = is(contentType, type);
135+
if (matchedMediaType) {
136+
requestBody.mediaType = type;
137+
requestBody.schema = content[type].schema;
138+
break;
139+
}
140+
}
141+
142+
if (!matchedMediaType) {
143+
// No matching media type found, fail fast
144+
throw RestHttpErrors.unsupportedMediaType(
145+
contentType,
146+
Object.keys(content),
89147
);
90148
}
91149

92-
return await parseJsonBody(request).catch((err: HttpError) => {
93-
debug('Cannot parse request body %j', err);
94-
err.statusCode = 400;
95-
throw err;
96-
});
150+
if (is(matchedMediaType, 'urlencoded')) {
151+
try {
152+
const body = await parseFormBody(
153+
request,
154+
// use `qs` modules to handle complex objects
155+
Object.assign(
156+
{
157+
querystring: {
158+
parse: (str: string, cb: Function) => {
159+
cb(null, qs.parse(str));
160+
},
161+
},
162+
},
163+
options,
164+
),
165+
);
166+
return Object.assign(requestBody, {
167+
// form parser returns an object without prototype
168+
// create a new copy to simplify shouldjs assertions
169+
value: Object.assign({}, body),
170+
// urlencoded body only provide string values
171+
// set the flag so that AJV can coerce them based on the schema
172+
coercionRequired: true,
173+
});
174+
} catch (err) {
175+
throw normalizeParsingError(err);
176+
}
177+
}
178+
179+
if (is(matchedMediaType, 'json')) {
180+
try {
181+
const jsonBody = await parseJsonBody(request, options);
182+
requestBody.value = jsonBody;
183+
return requestBody;
184+
} catch (err) {
185+
throw normalizeParsingError(err);
186+
}
187+
}
188+
189+
throw RestHttpErrors.unsupportedMediaType(matchedMediaType);
97190
}
98191

99192
function buildOperationArguments(
100193
operationSpec: OperationObject,
101194
request: Request,
102195
pathParams: PathParameterValues,
103-
body: MaybeBody,
196+
body: RequestBody,
104197
globalSchemas: SchemasObject,
105198
): OperationArgs {
106199
let requestBodyIndex: number = -1;
@@ -131,7 +224,7 @@ function buildOperationArguments(
131224
debug('Validating request body - value %j', body);
132225
validateRequestBody(body, operationSpec.requestBody, globalSchemas);
133226

134-
if (requestBodyIndex > -1) paramArgs.splice(requestBodyIndex, 0, body);
227+
if (requestBodyIndex > -1) paramArgs.splice(requestBodyIndex, 0, body.value);
135228
return paramArgs;
136229
}
137230

0 commit comments

Comments
 (0)