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

feat(rest): add support for form request body #1838

Merged
merged 1 commit into from
Oct 29, 2018
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
71 changes: 71 additions & 0 deletions docs/site/Parsing-requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,77 @@ in/by the `@requestBody` decorator. Please refer to the documentation on
[@requestBody decorator](Decorators.md#requestbody-decorator) to get a
comprehensive idea of defining custom validation rules for your models.

We support `json` and `urlencoded` content types. The client should set
`Content-Type` http header to `application/json` or
`application/x-www-form-urlencoded`. Its value is matched against the list of
media types defined in the `requestBody.content` object of the OpenAPI operation
spec. If no matching media types is found or the type is not supported yet, an
UnsupportedMediaTypeError (http statusCode 415) will be reported.

Please note that `urlencoded` media type does not support data typing. For
example, `key=3` is parsed as `{key: '3'}`. The raw result is then coerced by
AJV based on the matching content schema. The coercion rules are described in
[AJV type coercion rules](https://github.com/epoberezkin/ajv/blob/master/COERCION.md).

The [qs](https://github.com/ljharb/qs) is used to parse complex strings. For
example, given the following request body definition:

```ts
const requestBodyObject = {
description: 'data',
content: {
'application/x-www-form-urlencoded': {
schema: {
type: 'object',
properties: {
name: {type: 'string'},
location: {
type: 'object',
properties: {
lat: {type: 'number'},
lng: {type: 'number'},
},
},
tags: {
type: 'array',
items: {type: 'string'},
},
},
},
},
},
};
```

The encoded value
`'name=IBM%20HQ&location[lat]=0.741895&location[lng]=-73.989308&tags[0]=IT&tags[1]=NY'`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great example 👍

is parsed and coerced as:

```ts
{
name: 'IBM HQ',
location: {lat: 0.741895, lng: -73.989308},
tags: ['IT', 'NY'],
}
```

The request body parser options (such as `limit`) can now be configured by
binding the value to `RestBindings.REQUEST_BODY_PARSER_OPTIONS`
('rest.requestBodyParserOptions'). For example,

```ts
server
.bind(RestBindings.REQUEST_BODY_PARSER_OPTIONS)
.to({limit: 4 * 1024 * 1024}); // Set limit to 4MB
```

The list of options can be found in the [body](https://github.com/Raynos/body)
module.

By default, the `limit` is `1024 * 1024` (1MB). Any request with a body length
exceeding the limit will be rejected with http status code 413 (request entity
too large).

A few tips worth mentioning:

- If a model property's type refers to another model, make sure it is also
Expand Down
12 changes: 9 additions & 3 deletions packages/openapi-v3/src/decorators/request-body.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@ export function requestBody(requestBodySpec?: Partial<RequestBodyObject>) {
return function(target: Object, member: string, index: number) {
debug('@requestBody() on %s.%s', target.constructor.name, member);
debug(' parameter index: %s', index);
debug(' options: %s', inspect(requestBodySpec, {depth: null}));
/* istanbul ignore if */
if (debug.enabled)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a comment please to tell our code coverage tool to ignore the fact that this block is intentionally skipped by tests and thus should be not be counted towards code coverage?

debug(' options: %s', inspect(requestBodySpec, {depth: null}));

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

const paramType = paramTypes[index];
const schema = resolveSchema(paramType);
debug(' inferred schema: %s', inspect(schema, {depth: null}));
/* istanbul ignore if */
if (debug.enabled)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a comment please to tell our code coverage tool to ignore the fact that this block is intentionally skipped by tests and thus should be not be counted towards code coverage?

debug(' inferred schema: %s', inspect(schema, {depth: null}));
requestBodySpec.content = _.mapValues(requestBodySpec.content, c => {
if (!c.schema) {
c.schema = schema;
Expand All @@ -109,7 +113,9 @@ export function requestBody(requestBodySpec?: Partial<RequestBodyObject>) {
requestBodySpec[REQUEST_BODY_INDEX] = index;
}

debug(' final spec: ', inspect(requestBodySpec, {depth: null}));
/* istanbul ignore if */
if (debug.enabled)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a comment please to tell our code coverage tool to ignore the fact that this block is intentionally skipped by tests and thus should be not be counted towards code coverage?

debug(' final spec: ', inspect(requestBodySpec, {depth: null}));
ParameterDecoratorFactory.createDecorator<RequestBodyObject>(
OAI3Keys.REQUEST_BODY_KEY,
requestBodySpec as RequestBodyObject,
Expand Down
6 changes: 4 additions & 2 deletions packages/rest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
"@types/express": "^4.11.1",
"@types/http-errors": "^1.6.1",
"@types/parseurl": "^1.3.1",
"@types/qs": "^6.5.1",
"ajv": "^6.5.1",
"body": "^5.1.0",
"cors": "^2.8.4",
Expand All @@ -44,6 +43,7 @@
"path-to-regexp": "^2.2.0",
"qs": "^6.5.2",
"strong-error-handler": "^3.2.0",
"type-is": "^1.6.16",
"validator": "^10.4.0"
},
"devDependencies": {
Expand All @@ -56,7 +56,9 @@
"@types/js-yaml": "^3.11.1",
"@types/lodash": "^4.14.106",
"@types/node": "^10.11.2",
"@types/serve-static": "1.13.2"
"@types/serve-static": "1.13.2",
"@types/qs": "^6.5.1",
"@types/type-is": "^1.6.2"
},
"files": [
"README.md",
Expand Down
5 changes: 5 additions & 0 deletions packages/rest/src/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
ParseParams,
Reject,
Send,
RequestBodyParserOptions,
} from './types';

import {HttpProtocol} from '@loopback/http-server';
Expand Down Expand Up @@ -84,6 +85,10 @@ export namespace RestBindings {
'rest.errorWriterOptions',
);

export const REQUEST_BODY_PARSER_OPTIONS = BindingKey.create<
RequestBodyParserOptions
>('rest.requestBodyParserOptions');

/**
* Binding key for setting and injecting an OpenAPI spec
*/
Expand Down
151 changes: 122 additions & 29 deletions packages/rest/src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
isReferenceObject,
OperationObject,
ParameterObject,
ReferenceObject,
SchemaObject,
SchemasObject,
} from '@loopback/openapi-v3-types';
import * as debugModule from 'debug';
Expand All @@ -19,8 +21,15 @@ import {promisify} from 'util';
import {coerceParameter} from './coercion/coerce-parameter';
import {RestHttpErrors} from './index';
import {ResolvedRoute} from './router/routing-table';
import {OperationArgs, PathParameterValues, Request} from './types';
import {
OperationArgs,
PathParameterValues,
Request,
RequestBodyParserOptions,
} from './types';
import {validateRequestBody} from './validation/request-body.validator';
import {is} from 'type-is';
import * as qs from 'qs';

type HttpError = HttpErrors.HttpError;

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

// tslint:disable-next-line:no-any
type MaybeBody = any | undefined;
// tslint:disable:no-any
export type RequestBody = {
value: any | undefined;
coercionRequired?: boolean;
mediaType?: string;
schema?: SchemaObject | ReferenceObject;
};

const parseJsonBody: (
req: IncomingMessage,
options: {},
) => Promise<any> = promisify(require('body/json'));

const parseJsonBody: (req: IncomingMessage) => Promise<MaybeBody> = promisify(
require('body/json'),
);
const parseFormBody: (
req: IncomingMessage,
options: {},
) => Promise<any> = promisify(require('body/form'));

/**
* Get the content-type header value from the request
* @param req Http request
*/
function getContentType(req: Request): string | undefined {
const val = req.headers['content-type'];
if (typeof val === 'string') {
return val;
} else if (Array.isArray(val)) {
// Assume only one value is present
return val[0];
}
return undefined;
return req.get('content-type');
}

/**
Expand All @@ -61,11 +74,12 @@ function getContentType(req: Request): string | undefined {
export async function parseOperationArgs(
request: Request,
route: ResolvedRoute,
options: RequestBodyParserOptions = {},
): Promise<OperationArgs> {
debug('Parsing operation arguments for route %s', route.describe());
const operationSpec = route.spec;
const pathParams = route.pathParams;
const body = await loadRequestBodyIfNeeded(operationSpec, request);
const body = await loadRequestBodyIfNeeded(operationSpec, request, options);
return buildOperationArguments(
operationSpec,
request,
Expand All @@ -75,32 +89,111 @@ export async function parseOperationArgs(
);
}

async function loadRequestBodyIfNeeded(
function normalizeParsingError(err: HttpError) {
debug('Cannot parse request body %j', err);
if (!err.statusCode || err.statusCode >= 500) {
err.statusCode = 400;
}
return err;
}

export async function loadRequestBodyIfNeeded(
operationSpec: OperationObject,
request: Request,
): Promise<MaybeBody> {
if (!operationSpec.requestBody) return Promise.resolve();
options: RequestBodyParserOptions = {},
): Promise<RequestBody> {
const requestBody: RequestBody = {
value: undefined,
};
if (!operationSpec.requestBody) return Promise.resolve(requestBody);

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

const contentType = getContentType(request);
const contentType = getContentType(request) || 'application/json';
debug('Loading request body with content type %j', contentType);
if (contentType && !/json/.test(contentType)) {
throw new HttpErrors.UnsupportedMediaType(
`Content-type ${contentType} is not supported.`,

// the type of `operationSpec.requestBody` could be `RequestBodyObject`
// or `ReferenceObject`, resolving a `$ref` value is not supported yet.
if (isReferenceObject(operationSpec.requestBody)) {
throw new Error('$ref requestBody is not supported yet.');
}

let content = operationSpec.requestBody.content || {};
if (!Object.keys(content).length) {
content = {
// default to allow json and urlencoded
'application/json': {schema: {type: 'object'}},
'application/x-www-form-urlencoded': {schema: {type: 'object'}},
};
}

// Check of the request content type matches one of the expected media
// types in the request body spec
let matchedMediaType: string | false = false;
for (const type in content) {
matchedMediaType = is(contentType, type);
if (matchedMediaType) {
requestBody.mediaType = type;
requestBody.schema = content[type].schema;
break;
}
}

if (!matchedMediaType) {
// No matching media type found, fail fast
throw RestHttpErrors.unsupportedMediaType(
contentType,
Object.keys(content),
);
}

return await parseJsonBody(request).catch((err: HttpError) => {
debug('Cannot parse request body %j', err);
err.statusCode = 400;
throw err;
});
if (is(matchedMediaType, 'urlencoded')) {
try {
const body = await parseFormBody(
request,
// use `qs` modules to handle complex objects
Object.assign(
{
querystring: {
parse: (str: string, cb: Function) => {
cb(null, qs.parse(str));
},
},
},
options,
),
);
return Object.assign(requestBody, {
// form parser returns an object without prototype
// create a new copy to simplify shouldjs assertions
value: Object.assign({}, body),
// urlencoded body only provide string values
// set the flag so that AJV can coerce them based on the schema
coercionRequired: true,
});
} catch (err) {
throw normalizeParsingError(err);
}
}

if (is(matchedMediaType, 'json')) {
try {
const jsonBody = await parseJsonBody(request, options);
requestBody.value = jsonBody;
return requestBody;
} catch (err) {
throw normalizeParsingError(err);
}
}

throw RestHttpErrors.unsupportedMediaType(matchedMediaType);
}

function buildOperationArguments(
operationSpec: OperationObject,
request: Request,
pathParams: PathParameterValues,
body: MaybeBody,
body: RequestBody,
globalSchemas: SchemasObject,
): OperationArgs {
let requestBodyIndex: number = -1;
Expand Down Expand Up @@ -131,7 +224,7 @@ function buildOperationArguments(
debug('Validating request body - value %j', body);
validateRequestBody(body, operationSpec.requestBody, globalSchemas);

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

Expand Down
Loading