Skip to content

Commit

Permalink
feat(rest): allow body parsers to be extended
Browse files Browse the repository at this point in the history
- add `bodyParser` sugar method
- use `x-parser` to control custom body parsing
- add docs for body parser extensions
- add raw body parser
  • Loading branch information
raymondfeng committed Nov 26, 2018
1 parent 084837f commit 86bfcbc
Show file tree
Hide file tree
Showing 34 changed files with 2,008 additions and 409 deletions.
169 changes: 169 additions & 0 deletions docs/site/Extending-request-body-parsing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
---
lang: en
title: 'Extending request body parsing'
keywords: LoopBack 4.0, LoopBack 4
sidebar: lb4_sidebar
permalink: /doc/en/lb4/Extending-request-body-parsing.html
---

## Parsing requests

LoopBack 4 uses the `Content-Type` header and `requestBody` of the OpenAPI spec
to parse the body of http requests. Please see
[Parsing requests](./Parsing-requests.md) for more details.

The `@loopback/rest` module ships a set of built-in body parsers:

- `json`: parses the http request body as a json value (object, array, string,
number, boolean, null)
- `urlencoded`: decodes the http request body from
'application/x-www-form-urlencoded'
- `text`: parses the http request body as a `string`
- `stream`: keeps the http request body as a stream without parsing
- `raw`: parses the http request body as a `Buffer`

To support more media types, LoopBack defines extension points to plug in body
parsers to parse the request body. LoopBack's request body parsing capability
can be extended in the following ways:

## Adding a new parser

To add a new body parser, follow the steps below:

1. Define a class that implements the `BodyParser` interface:

```ts
/**
* Interface to be implemented by body parser extensions
*/
export interface BodyParser {
/**
* Name of the parser
*/
name: string | symbol;
/**
* Indicate if the given media type is supported
* @param mediaType Media type
*/
supports(mediaType: string): boolean;
/**
* Parse the request body
* @param request http request
*/
parse(request: Request): Promise<RequestBody>;
}
```

A body parser implementation class will be instantiated by the LoopBack runtime
within the context and it can leverage dependency injections. For example:

```ts
export class JsonBodyParser implements BodyParser {
name = 'json';
private jsonParser: BodyParserMiddleware;

constructor(
@inject(RestBindings.REQUEST_BODY_PARSER_OPTIONS, {optional: true})
options: RequestBodyParserOptions = {},
) {
const jsonOptions = getParserOptions('json', options);
this.jsonParser = json(jsonOptions);
}
// ...
}
```

See the complete code at
https://github.com/strongloop/loopback-next/blob/master/packages/rest/src/body-parsers/body-parser.json.ts.

2. Bind the body parser class to your REST server/application:

For example,

```ts
server.bodyParser(XmlBodyParser);
```

The `bodyParser` api binds `XmlBodyParser` to the context with:

- key: `request.bodyParser.XmlBodyParser`
- tag: `request.bodyParser`

Please note that newly added body parsers are always invoked before the built-in
ones.

### Contribute a body parser from a component

A component can add one or more body parsers via its bindings property:

```ts
import {createBodyParserBinding} from '@loopback/rest';

export class XmlComponent implements Component {
bindings = [createBodyParserBinding(XmlBodyParser)];
}
```

### Customize parser options

The request body parser options is bound to
`RestBindings.REQUEST_BODY_PARSER_OPTIONS`. To customize request body parser
options, you can simply bind a new value to its key.

Built-in parsers retrieve their own options from the request body parser
options. The parser specific properties override common ones. For example, given
the following configuration:

```ts
{
limit: '1MB'
json: {
strict: false
},
text: {
limit: '2MB'
}
}
```

The json parser will be created with `{limit: '1MB', strict: false}` and the
text parser with `{limit: '2MB'}`.

Custom parsers can choose to have its own `options` from the context by
dependency injection, for example:

```ts
export class XmlBodyParser implements BodyParser {
name = 'xml';

constructor(
@inject('request.bodyParsers.xml.options', {optional: true})
options: XmlBodyParserOptions = {},
) {
...
}
// ...
}
```

## Replace an existing parser

An existing parser can be replaced by binding a different value to the
application context.

```ts
class MyJsonBodyParser implements BodyParser {
// ...
}
app.bodyParser(MyJsonBodyParser, RestBindings.REQUEST_BODY_PARSER_JSON);
```

## Remove an existing parser

An existing parser can be removed from the application by unbinding the
corresponding key. For example, the following code removes the built-in JSON
body parser.

```ts
app.unbind(RestBindings.REQUEST_BODY_PARSER_JSON);
```
117 changes: 103 additions & 14 deletions docs/site/Parsing-requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,12 +179,13 @@ 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.
We support `json`, `urlencoded`, and `text` content types. The client should set
`Content-Type` http header to `application/json`,
`application/x-www-form-urlencoded`, or `text/plain`. 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
Expand Down Expand Up @@ -238,17 +239,25 @@ 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
server.bind(RestBindings.REQUEST_BODY_PARSER_OPTIONS).to({
limit: '4MB',
});
```

The list of options can be found in the [body](https://github.com/Raynos/body)
module.
The options can be media type specific, for example:

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).
```ts
server.bind(RestBindings.REQUEST_BODY_PARSER_OPTIONS).to({
json: {limit: '4MB'},
text: {limit: '1MB'},
});
```

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

By default, the `limit` is `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:

Expand All @@ -260,6 +269,86 @@ A few tips worth mentioning:
[`api()`](Decorators.md#api-decorator), this requires you to provide a
completed request body specification.

#### Extend Request Body Parsing

See [Extending request body parsing](./Extending-request-body-parsing.md) for
more details.

#### Specify Custom Parser by Controller Methods

In some cases, a controller method wants to handle request body parsing by
itself, such as, to accept `multipart/form-data` for file uploads or stream-line
a large json document. To bypass body parsing, the `'x-parser'` extension can be
set to `'stream'` for a media type of the request body content. For example,

```ts
class FileUploadController {
async upload(
@requestBody({
description: 'multipart/form-data value.',
required: true,
content: {
'multipart/form-data': {
// Skip body parsing
'x-parser': 'stream',
schema: {type: 'object'},
},
},
})
request: Request,
@inject(RestBindings.Http.RESPONSE) response: Response,
): Promise<object> {
const storage = multer.memoryStorage();
const upload = multer({storage});
return new Promise<object>((resolve, reject) => {
upload.any()(request, response, err => {
if (err) reject(err);
else {
resolve({
files: request.files,
// tslint:disable-next-line:no-any
fields: (request as any).fields,
});
}
});
});
}
}
```

The `x-parser` value can be one of the following:

1. Name of the parser, such as `json`, `raw`, or `stream`

- `stream`: keeps the http request body as a stream without parsing
- `raw`: parses the http request body as a `Buffer`

```ts
{
'x-parser': 'stream'
}
```

2. A body parser class

```ts
{
'x-parser': JsonBodyParser
}
```

3. A body parser function, for example:

```ts
function parseJson(request: Request): Promise<RequestBody> {
return new JsonBodyParser().parse(request);
}

{
'x-parser': parseJson
}
```

#### Localizing Errors

A body data may break multiple validation rules, like missing required fields,
Expand Down
4 changes: 4 additions & 0 deletions docs/site/sidebars/lb4_sidebar.yml
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,10 @@ children:
url: Creating-servers.html
output: 'web, pdf'

- title: 'Extending Request Body Parsing'
url: Extending-request-body-parsing.html
output: 'web, pdf'

- title: 'Testing your extension'
url: Testing-your-extension.html
output: 'web, pdf'
Expand Down
14 changes: 7 additions & 7 deletions packages/context/src/binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,21 @@
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import * as debugModule from 'debug';
import {BindingAddress, BindingKey} from './binding-key';
import {Context} from './context';
import {BindingKey} from './binding-key';
import {Provider} from './provider';
import {ResolutionSession} from './resolution-session';
import {instantiateClass} from './resolver';
import {
BoundValue,
Constructor,
isPromiseLike,
BoundValue,
ValueOrPromise,
MapObject,
transformValueOrPromise,
ValueOrPromise,
} from './value-promise';
import {Provider} from './provider';

import * as debugModule from 'debug';
const debug = debugModule('loopback:context:binding');

/**
Expand Down Expand Up @@ -443,7 +443,7 @@ export class Binding<T = BoundValue> {
* easy to read.
* @param key Binding key
*/
static bind(key: string): Binding {
return new Binding(key);
static bind<T = unknown>(key: BindingAddress<T>): Binding {
return new Binding(key.toString());
}
}
1 change: 1 addition & 0 deletions packages/rest/fixtures/file-upload-test.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
This file is used for file-upload acceptance test.
4 changes: 2 additions & 2 deletions packages/rest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,10 @@
"@types/debug": "0.0.30",
"@types/js-yaml": "^3.11.1",
"@types/lodash": "^4.14.106",
"@types/multer": "^1.3.7",
"@types/node": "^10.11.2",
"@types/qs": "^6.5.1",
"@types/serve-static": "1.13.2",
"@types/type-is": "^1.6.2"
"multer": "^1.4.1"
},
"files": [
"README.md",
Expand Down
Loading

0 comments on commit 86bfcbc

Please sign in to comment.