Skip to content

Commit 6d95802

Browse files
committed
feat(rest): switch to express body-parser
1 parent ceff03f commit 6d95802

File tree

6 files changed

+153
-72
lines changed

6 files changed

+153
-72
lines changed

packages/rest/package.json

+4-1
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,15 @@
2525
"@loopback/http-server": "^1.0.0",
2626
"@loopback/openapi-v3": "^1.0.1",
2727
"@loopback/openapi-v3-types": "^1.0.0",
28+
"@types/body-parser": "^1.17.0",
2829
"@types/cors": "^2.8.3",
2930
"@types/express": "^4.11.1",
3031
"@types/http-errors": "^1.6.1",
3132
"@types/parseurl": "^1.3.1",
3233
"@types/qs": "^6.5.1",
34+
"@types/type-is": "^1.6.2",
3335
"ajv": "^6.5.1",
34-
"body": "^5.1.0",
36+
"body-parser": "^1.18.3",
3537
"cors": "^2.8.4",
3638
"debug": "^4.0.1",
3739
"express": "^4.16.3",
@@ -44,6 +46,7 @@
4446
"path-to-regexp": "^2.2.0",
4547
"qs": "^6.5.2",
4648
"strong-error-handler": "^3.2.0",
49+
"type-is": "^1.6.16",
4750
"validator": "^10.4.0"
4851
},
4952
"devDependencies": {

packages/rest/src/parser.ts

+92-60
Original file line numberDiff line numberDiff line change
@@ -11,22 +11,24 @@ import {
1111
SchemasObject,
1212
} from '@loopback/openapi-v3-types';
1313
import * as debugModule from 'debug';
14-
import {IncomingMessage} from 'http';
1514
import * as HttpErrors from 'http-errors';
1615
import * as parseUrl from 'parseurl';
1716
import {parse as parseQuery} from 'qs';
18-
import {promisify} from 'util';
1917
import {coerceParameter} from './coercion/coerce-parameter';
2018
import {RestHttpErrors} from './index';
2119
import {ResolvedRoute} from './router/routing-table';
2220
import {
2321
OperationArgs,
2422
PathParameterValues,
2523
Request,
24+
Response,
2625
RequestBodyParserOptions,
2726
} from './types';
2827
import {validateRequestBody} from './validation/request-body.validator';
2928

29+
import {json, urlencoded, text} from 'body-parser';
30+
import * as typeis from 'type-is';
31+
3032
type HttpError = HttpErrors.HttpError;
3133

3234
const debug = debugModule('loopback:rest:parser');
@@ -40,31 +42,6 @@ type RequestBody = {
4042
coercionRequired?: boolean;
4143
};
4244

43-
const parseJsonBody: (
44-
req: IncomingMessage,
45-
options: {},
46-
) => Promise<any> = promisify(require('body/json'));
47-
48-
const parseFormBody: (
49-
req: IncomingMessage,
50-
options: {},
51-
) => Promise<any> = promisify(require('body/form'));
52-
53-
/**
54-
* Get the content-type header value from the request
55-
* @param req Http request
56-
*/
57-
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;
66-
}
67-
6845
/**
6946
* Parses the request to derive arguments to be passed in for the Application
7047
* controller method
@@ -90,6 +67,41 @@ export async function parseOperationArgs(
9067
);
9168
}
9269

70+
/**
71+
* Express body parser function type
72+
*/
73+
type BodyParser = (
74+
request: Request,
75+
response: Response,
76+
callback: (err: HttpError) => void,
77+
) => void;
78+
79+
/**
80+
* Parse the body asynchronously
81+
* @param handle The express middleware handler
82+
* @param request Http request
83+
*/
84+
function parse(handle: BodyParser, request: Request): Promise<void> {
85+
// A hack to fool TypeScript as we don't need `response`
86+
const response = ({} as any) as Response;
87+
return new Promise<void>((resolve, reject) => {
88+
handle(request, response, err => {
89+
if (err) {
90+
debug('Cannot parse request body %j', err);
91+
if (!err.statusCode || err.statusCode >= 500) {
92+
err.statusCode = 400;
93+
}
94+
reject(err);
95+
return;
96+
}
97+
resolve();
98+
});
99+
});
100+
}
101+
102+
// Default limit of the body length
103+
const DEFAULT_LIMIT = '1mb';
104+
93105
async function loadRequestBodyIfNeeded(
94106
operationSpec: OperationObject,
95107
request: Request,
@@ -99,45 +111,65 @@ async function loadRequestBodyIfNeeded(
99111

100112
debug('Request body parser options: %j', options);
101113

102-
const contentType = getContentType(request);
103-
debug('Loading request body with content type %j', contentType);
114+
let body = await parseJsonBody(request, options);
115+
if (body) return body;
116+
body = await parseUrlencodedBody(request, options);
117+
if (body) return body;
118+
body = await parseTextBody(request, options);
119+
if (body) return body;
104120

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,
122-
};
123-
}
121+
throw new HttpErrors.UnsupportedMediaType(
122+
`Content-type ${request.get('content-type')} is not supported.`,
123+
);
124+
}
124125

125-
if (contentType && !/json/.test(contentType)) {
126-
throw new HttpErrors.UnsupportedMediaType(
127-
`Content-type ${contentType} is not supported.`,
128-
);
126+
async function parseJsonBody(
127+
request: Request,
128+
options: RequestBodyParserOptions,
129+
) {
130+
const jsonOptions = Object.assign(
131+
{type: 'json', limit: DEFAULT_LIMIT},
132+
options,
133+
);
134+
if (typeis(request, jsonOptions.type)) {
135+
await parse(json(jsonOptions), request);
136+
return {value: request.body};
129137
}
138+
return undefined;
139+
}
130140

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;
141+
async function parseUrlencodedBody(
142+
request: Request,
143+
options: RequestBodyParserOptions,
144+
) {
145+
const urlencodedOptions = Object.assign(
146+
{
147+
extended: true,
148+
type: 'urlencoded',
149+
limit: DEFAULT_LIMIT,
138150
},
151+
options,
152+
);
153+
if (typeis(request, urlencodedOptions.type)) {
154+
await parse(urlencoded(urlencodedOptions), request);
155+
return {value: request.body, coercionRequired: true};
156+
}
157+
return undefined;
158+
}
159+
160+
async function parseTextBody(
161+
request: Request,
162+
options: RequestBodyParserOptions,
163+
) {
164+
const textOptions = Object.assign(
165+
{type: 'text/*', limit: DEFAULT_LIMIT},
166+
options,
139167
);
140-
return {value: jsonBody};
168+
if (typeis(request, textOptions.type)) {
169+
await parse(text(textOptions), request);
170+
return {value: request.body};
171+
}
172+
return undefined;
141173
}
142174

143175
function buildOperationArguments(

packages/rest/src/types.ts

+4-5
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import {Binding, BoundValue} from '@loopback/context';
77
import {ResolvedRoute, RouteEntry} from './router/routing-table';
88
import {Request, Response} from 'express';
9+
import {OptionsJson, OptionsUrlencoded, OptionsText} from 'body-parser';
910

1011
export {Request, Response};
1112

@@ -81,11 +82,9 @@ export type LogError = (
8182
/**
8283
* Options for request body parsing
8384
*/
84-
export type RequestBodyParserOptions = {
85-
limit?: number;
86-
encoding?: string;
87-
[property: string]: any;
88-
};
85+
export type RequestBodyParserOptions = OptionsJson &
86+
OptionsUrlencoded &
87+
OptionsText;
8988

9089
export type PathParameterValues = {[key: string]: any};
9190
export type OperationArgs = any[];

packages/rest/test/acceptance/validation/validation.acceptance.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,10 @@ describe('Validation at REST level', () => {
6565
it('rejects requests with no (empty) body', async () => {
6666
// NOTE(bajtos) An empty body cannot be parsed as a JSON,
6767
// therefore this test request does not even reach the validation logic.
68-
await client.post('/products').expect(400);
68+
await client
69+
.post('/products')
70+
.type('json')
71+
.expect(422);
6972
});
7073

7174
it('rejects requests with null body', async () => {

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

+5-5
Original file line numberDiff line numberDiff line change
@@ -287,9 +287,9 @@ describe('HttpHandler', () => {
287287
.send('key=' + givenLargeRequest())
288288
.expect(413, {
289289
error: {
290-
message: 'request entity too large',
291-
name: 'Error',
292290
statusCode: 413,
291+
name: 'PayloadTooLargeError',
292+
message: 'request entity too large',
293293
},
294294
})
295295
.catch(err => {
@@ -307,9 +307,9 @@ describe('HttpHandler', () => {
307307
.send({key: givenLargeRequest()})
308308
.expect(413, {
309309
error: {
310-
message: 'request entity too large',
311-
name: 'Error',
312310
statusCode: 413,
311+
name: 'PayloadTooLargeError',
312+
message: 'request entity too large',
313313
},
314314
})
315315
.catch(err => {
@@ -323,7 +323,7 @@ describe('HttpHandler', () => {
323323
const body = {key: givenLargeRequest()};
324324
rootContext
325325
.bind(RestBindings.REQUEST_BODY_PARSER_OPTIONS)
326-
.to({limit: 4 * 1024 * 1024}); // Set limit to 4MB
326+
.to({limit: '4mb'}); // Set limit to 4MB
327327
return client
328328
.post('/show-body')
329329
.set('content-type', 'application/json')

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

+44
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,50 @@ describe('operationArgsParser', () => {
169169
expect(args).to.eql([{key1: ['value1', 'value2']}]);
170170
});
171171

172+
it('parses body parameter for text data', async () => {
173+
const req = givenRequest({
174+
url: '/',
175+
headers: {
176+
'Content-Type': 'text/plain',
177+
},
178+
payload: 'plain-text',
179+
});
180+
181+
const spec = givenOperationWithRequestBody({
182+
description: 'data',
183+
content: {
184+
'text/plain': {schema: {type: 'string'}},
185+
},
186+
});
187+
const route = givenResolvedRoute(spec);
188+
189+
const args = await parseOperationArgs(req, route);
190+
191+
expect(args).to.eql(['plain-text']);
192+
});
193+
194+
it('parses body parameter for html data', async () => {
195+
const req = givenRequest({
196+
url: '/',
197+
headers: {
198+
'Content-Type': 'text/html',
199+
},
200+
payload: '<html><body><h1>Hello</h1></body></html>',
201+
});
202+
203+
const spec = givenOperationWithRequestBody({
204+
description: 'data',
205+
content: {
206+
'text/html': {schema: {type: 'string'}},
207+
},
208+
});
209+
const route = givenResolvedRoute(spec);
210+
211+
const args = await parseOperationArgs(req, route);
212+
213+
expect(args).to.eql(['<html><body><h1>Hello</h1></body></html>']);
214+
});
215+
172216
context('in:query style:deepObject', () => {
173217
it('parses JSON-encoded string value', async () => {
174218
const req = givenRequest({

0 commit comments

Comments
 (0)