diff --git a/docs/site/Extending-request-body-parsing.md b/docs/site/Extending-request-body-parsing.md new file mode 100644 index 000000000000..3cab6e6d0020 --- /dev/null +++ b/docs/site/Extending-request-body-parsing.md @@ -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; +} +``` + +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); +``` diff --git a/docs/site/Parsing-requests.md b/docs/site/Parsing-requests.md index 5f4f584162d2..9db1b4238fbc 100644 --- a/docs/site/Parsing-requests.md +++ b/docs/site/Parsing-requests.md @@ -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 @@ -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: @@ -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 { + const storage = multer.memoryStorage(); + const upload = multer({storage}); + return new Promise((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 { + return new JsonBodyParser().parse(request); +} + +{ + 'x-parser': parseJson +} +``` + #### Localizing Errors A body data may break multiple validation rules, like missing required fields, diff --git a/docs/site/sidebars/lb4_sidebar.yml b/docs/site/sidebars/lb4_sidebar.yml index 8d8bdddee716..cf2c91869ad6 100644 --- a/docs/site/sidebars/lb4_sidebar.yml +++ b/docs/site/sidebars/lb4_sidebar.yml @@ -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' diff --git a/packages/context/src/binding.ts b/packages/context/src/binding.ts index 1ca87fac2d84..94db8b8fec95 100644 --- a/packages/context/src/binding.ts +++ b/packages/context/src/binding.ts @@ -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'); /** @@ -443,7 +443,7 @@ export class Binding { * easy to read. * @param key Binding key */ - static bind(key: string): Binding { - return new Binding(key); + static bind(key: BindingAddress): Binding { + return new Binding(key.toString()); } } diff --git a/packages/rest/fixtures/file-upload-test.txt b/packages/rest/fixtures/file-upload-test.txt new file mode 100644 index 000000000000..8c4b34ef1bd8 --- /dev/null +++ b/packages/rest/fixtures/file-upload-test.txt @@ -0,0 +1 @@ +This file is used for file-upload acceptance test. diff --git a/packages/rest/package.json b/packages/rest/package.json index 48634b9c5250..d13d20685654 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -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", diff --git a/packages/rest/src/body-parser.ts b/packages/rest/src/body-parser.ts deleted file mode 100644 index 4b4703fafc78..000000000000 --- a/packages/rest/src/body-parser.ts +++ /dev/null @@ -1,236 +0,0 @@ -// Copyright IBM Corp. 2017,2018. All Rights Reserved. -// Node module: @loopback/rest -// This file is licensed under the MIT License. -// License text available at https://opensource.org/licenses/MIT - -import {OperationObject} from '@loopback/openapi-v3-types'; -import * as debugModule from 'debug'; -import * as HttpErrors from 'http-errors'; -import {Request, Response, RequestBodyParserOptions} from './types'; - -import { - json, - urlencoded, - text, - OptionsJson, - OptionsUrlencoded, - OptionsText, - Options, -} from 'body-parser'; -import {inject} from '@loopback/context'; -import {SchemaObject, ReferenceObject, isReferenceObject} from '..'; -import {RestBindings} from './keys'; -import {is} from 'type-is'; - -type HttpError = HttpErrors.HttpError; - -const debug = debugModule('loopback:rest:body-parser'); - -export type RequestBody = { - // tslint:disable:no-any - value: any | undefined; - coercionRequired?: boolean; - mediaType?: string; - schema?: SchemaObject | ReferenceObject; -}; - -/** - * Get the content-type header value from the request - * @param req Http request - */ -function getContentType(req: Request): string | undefined { - return req.get('content-type'); -} - -/** - * Express body parser function type - */ -type BodyParserWithCallback = ( - request: Request, - response: Response, - callback: (err: HttpError) => void, -) => void; - -function normalizeParsingError(err: HttpError) { - debug('Cannot parse request body %j', err); - if (!err.statusCode || err.statusCode >= 500) { - err.statusCode = 400; - } - return err; -} - -/** - * Parse the body asynchronously - * @param handle The express middleware handler - * @param request Http request - */ -function parse( - handle: BodyParserWithCallback, - request: Request, -): Promise { - // A hack to fool TypeScript as we don't need `response` - const response = ({} as any) as Response; - return new Promise((resolve, reject) => { - handle(request, response, err => { - if (err) { - reject(normalizeParsingError(err)); - return; - } - resolve(); - }); - }); -} - -// Default limit of the body length -const DEFAULT_LIMIT = '1mb'; - -type ParserOption = T extends 'json' - ? OptionsJson - : T extends 'urlencoded' - ? OptionsUrlencoded - : T extends 'text' ? OptionsText : Options; - -function getParserOptions( - type: T, - options: RequestBodyParserOptions, -): ParserOption { - const opts: {[name: string]: any} = {}; - Object.assign(opts, options[type], options); - for (const k of ['json', 'urlencoded', 'text']) { - delete opts[k]; - } - return opts as ParserOption; -} - -export class RequestBodyParser { - private jsonParser: BodyParserWithCallback; - private urlencodedParser: BodyParserWithCallback; - private textParser: BodyParserWithCallback; - - constructor( - @inject(RestBindings.REQUEST_BODY_PARSER_OPTIONS, {optional: true}) - options: RequestBodyParserOptions = {}, - ) { - const jsonOptions = Object.assign( - {type: 'json', limit: DEFAULT_LIMIT}, - getParserOptions('json', options), - ); - this.jsonParser = json(jsonOptions); - - const urlencodedOptions = Object.assign( - { - type: 'urlencoded', - extended: true, - limit: DEFAULT_LIMIT, - }, - getParserOptions('urlencoded', options), - ); - this.urlencodedParser = urlencoded(urlencodedOptions); - - const textOptions = Object.assign( - {type: 'text/*', limit: DEFAULT_LIMIT}, - getParserOptions('text', options), - ); - this.textParser = text(textOptions); - } - - async parseJsonBody(request: Request, mediaType: string = 'json') { - if (is(mediaType, 'json')) { - await parse(this.jsonParser, request); - return {value: request.body}; - } - return undefined; - } - - async parseUrlencodedBody( - request: Request, - mediaType: string = 'urlencoded', - ) { - if (is(mediaType, 'urlencoded')) { - await parse(this.urlencodedParser, request); - return {value: request.body, coercionRequired: true}; - } - return undefined; - } - - async parseTextBody(request: Request, mediaType: string = 'text/*') { - if (is(mediaType, 'text/*')) { - await parse(this.textParser, request); - return {value: request.body}; - } - return undefined; - } - - private normalizeParsingError(err: HttpError) { - debug('Cannot parse request body %j', err); - if (!err.statusCode || err.statusCode >= 500) { - err.statusCode = 400; - } - return err; - } - - async loadRequestBodyIfNeeded( - operationSpec: OperationObject, - request: Request, - options: RequestBodyParserOptions = {}, - ): Promise { - const requestBody: RequestBody = { - value: undefined, - }; - if (!operationSpec.requestBody) return Promise.resolve(requestBody); - - debug('Request body parser options: %j', options); - - const contentType = getContentType(request) || 'application/json'; - debug('Loading request body with content type %j', contentType); - - // 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 new HttpErrors.UnsupportedMediaType( - `Content-type ${contentType} does not match [${Object.keys(content)}].`, - ); - } - - try { - let body = await this.parseJsonBody(request, matchedMediaType); - if (body !== undefined) return Object.assign(requestBody, body); - body = await this.parseUrlencodedBody(request, matchedMediaType); - if (body !== undefined) return Object.assign(requestBody, body); - body = await this.parseTextBody(request, matchedMediaType); - if (body !== undefined) return Object.assign(requestBody, body); - } catch (err) { - throw this.normalizeParsingError(err); - } - - throw new HttpErrors.UnsupportedMediaType( - `Content-type ${matchedMediaType} is not supported.`, - ); - } -} diff --git a/packages/rest/src/body-parsers/body-parser.helpers.ts b/packages/rest/src/body-parsers/body-parser.helpers.ts new file mode 100644 index 000000000000..ccd27b4421cf --- /dev/null +++ b/packages/rest/src/body-parsers/body-parser.helpers.ts @@ -0,0 +1,148 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + Options, + OptionsJson, + OptionsText, + OptionsUrlencoded, +} from 'body-parser'; +import * as debugModule from 'debug'; +import {HttpError} from 'http-errors'; +import {Request, RequestBodyParserOptions, Response} from '../types'; + +const debug = debugModule('loopback:rest:body-parser'); + +/** + * Get the content-type header value from the request + * @param req Http request + */ +export function getContentType(req: Request): string | undefined { + return req.get('content-type'); +} + +/** + * Express body parser function type + */ +export type BodyParserMiddleware = ( + request: Request, + response: Response, + next: (err: HttpError) => void, +) => void; + +/** + * Normalize parsing errors as `4xx` + * @param err + */ +export function normalizeParsingError(err: HttpError) { + debug('Cannot parse request body %j', err); + if (!err.statusCode || err.statusCode >= 500) { + err.statusCode = 400; + } + return err; +} + +// tslint:disable:no-any + +/** + * Parse the request body asynchronously + * @param handle The express middleware handler + * @param request Http request + */ +export function invokeBodyParserMiddleware( + handle: BodyParserMiddleware, + request: Request, +): Promise { + // A hack to fool TypeScript as we don't need `response` + const response = ({} as any) as Response; + return new Promise((resolve, reject) => { + handle(request, response, err => { + if (err) { + reject(err); + return; + } + resolve(request.body); + }); + }); +} + +// Default limit of the body length +export const DEFAULT_LIMIT = '1mb'; + +/** + * Extract parser options based on the parser type + * @param type json|urlencoded|text + * @param options + */ +export function getParserOptions( + type: 'json', + options: RequestBodyParserOptions, +): OptionsJson; +export function getParserOptions( + type: 'urlencoded', + options: RequestBodyParserOptions, +): OptionsUrlencoded; +export function getParserOptions( + type: 'text', + options: RequestBodyParserOptions, +): OptionsText; +export function getParserOptions( + type: 'raw', + options: RequestBodyParserOptions, +): Options; + +export function getParserOptions( + type: 'json' | 'urlencoded' | 'text' | 'raw', + options: RequestBodyParserOptions, +) { + const opts: {[name: string]: any} = {limit: DEFAULT_LIMIT}; + switch (type) { + case 'json': + // Allow */json and */*+json + opts.type = ['*/json', '*/*+json']; + opts.strict = false; + break; + case 'urlencoded': + opts.type = type; + opts.extended = true; + break; + case 'text': + // Set media type to `text/*` to match `text/plain` or `text/html` + opts.type = 'text/*'; + break; + case 'raw': + opts.type = ['application/octet-stream', '*/*']; + break; + } + Object.assign(opts, options[type], options); + for (const k of ['json', 'urlencoded', 'text', 'raw']) { + delete opts[k]; + } + return opts; +} + +export namespace builtinParsers { + export const json = Symbol('json'); + export const urlencoded = Symbol('urlencoded'); + export const text = Symbol('text'); + export const raw = Symbol('raw'); + export const stream = Symbol('stream'); + + export const names: (string | symbol)[] = [ + json, + urlencoded, + text, + raw, + stream, + ]; + + export const mapping: {[name: string]: symbol} = { + json, + urlencoded, + text, + raw, + stream, + }; +} diff --git a/packages/rest/src/body-parsers/body-parser.json.ts b/packages/rest/src/body-parsers/body-parser.json.ts new file mode 100644 index 000000000000..937ac9e8ef61 --- /dev/null +++ b/packages/rest/src/body-parsers/body-parser.json.ts @@ -0,0 +1,44 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {inject} from '@loopback/context'; +import {json} from 'body-parser'; +import {is} from 'type-is'; +import {RestBindings} from '../keys'; +import {Request, RequestBodyParserOptions} from '../types'; +import { + BodyParserMiddleware, + getParserOptions, + invokeBodyParserMiddleware, + builtinParsers, +} from './body-parser.helpers'; +import {BodyParser, RequestBody} from './types'; + +export class JsonBodyParser implements BodyParser { + name = builtinParsers.json; + private jsonParser: BodyParserMiddleware; + + constructor( + @inject(RestBindings.REQUEST_BODY_PARSER_OPTIONS, {optional: true}) + options: RequestBodyParserOptions = {}, + ) { + const jsonOptions = getParserOptions('json', options); + this.jsonParser = json(jsonOptions); + } + + supports(mediaType: string) { + return !!is(mediaType, '*/json', '*/*+json'); + } + + async parse(request: Request): Promise { + let body = await invokeBodyParserMiddleware(this.jsonParser, request); + // https://github.com/expressjs/body-parser/blob/master/lib/types/json.js#L71-L76 + const contentLength = request.get('content-length'); + if (contentLength != null && +contentLength === 0) { + body = undefined; + } + return {value: body}; + } +} diff --git a/packages/rest/src/body-parsers/body-parser.raw.ts b/packages/rest/src/body-parsers/body-parser.raw.ts new file mode 100644 index 000000000000..94a64fbd4ba5 --- /dev/null +++ b/packages/rest/src/body-parsers/body-parser.raw.ts @@ -0,0 +1,42 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {inject} from '@loopback/context'; +import {raw} from 'body-parser'; +import {is} from 'type-is'; +import {RestBindings} from '../keys'; +import {Request, RequestBodyParserOptions} from '../types'; +import { + BodyParserMiddleware, + getParserOptions, + invokeBodyParserMiddleware, + builtinParsers, +} from './body-parser.helpers'; +import {BodyParser, RequestBody} from './types'; + +/** + * Parsing the request body into Buffer + */ +export class RawBodyParser implements BodyParser { + name = builtinParsers.raw; + private rawParser: BodyParserMiddleware; + + constructor( + @inject(RestBindings.REQUEST_BODY_PARSER_OPTIONS, {optional: true}) + options: RequestBodyParserOptions = {}, + ) { + const rawOptions = getParserOptions('raw', options); + this.rawParser = raw(rawOptions); + } + + supports(mediaType: string) { + return !!is(mediaType, 'application/octet-stream'); + } + + async parse(request: Request): Promise { + const body = await invokeBodyParserMiddleware(this.rawParser, request); + return {value: body}; + } +} diff --git a/packages/rest/src/body-parsers/body-parser.stream.ts b/packages/rest/src/body-parsers/body-parser.stream.ts new file mode 100644 index 000000000000..2cf8aee4b318 --- /dev/null +++ b/packages/rest/src/body-parsers/body-parser.stream.ts @@ -0,0 +1,27 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Request} from '../types'; +import {BodyParser, RequestBody} from './types'; +import {builtinParsers} from './body-parser.helpers'; + +/** + * A special body parser to retain request stream as is. + * It will be used by explicitly setting `x-parser` to `'stream'` in the request + * body spec. + */ +export class StreamBodyParser implements BodyParser { + name = builtinParsers.stream; + + supports(mediaType: string) { + // Return `false` so that this parser can only be trigged by the + // `{x-parser: 'stream'}` extension in the request body spec + return false; + } + + async parse(request: Request): Promise { + return {value: request}; + } +} diff --git a/packages/rest/src/body-parsers/body-parser.text.ts b/packages/rest/src/body-parsers/body-parser.text.ts new file mode 100644 index 000000000000..ed540e8bd839 --- /dev/null +++ b/packages/rest/src/body-parsers/body-parser.text.ts @@ -0,0 +1,44 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {inject} from '@loopback/context'; +import {text} from 'body-parser'; +import {is} from 'type-is'; +import {RestBindings} from '../keys'; +import {Request, RequestBodyParserOptions} from '../types'; +import { + BodyParserMiddleware, + getParserOptions, + invokeBodyParserMiddleware, + builtinParsers, +} from './body-parser.helpers'; +import {BodyParser, RequestBody} from './types'; + +export class TextBodyParser implements BodyParser { + name = builtinParsers.text; + private textParser: BodyParserMiddleware; + + constructor( + @inject(RestBindings.REQUEST_BODY_PARSER_OPTIONS, {optional: true}) + options: RequestBodyParserOptions = {}, + ) { + const textOptions = Object.assign( + {type: 'text/*'}, + getParserOptions('text', options), + ); + this.textParser = text(textOptions); + } + + supports(mediaType: string) { + // Please note that `text/*` matches `text/plain` and `text/html` but`text` + // does not. + return !!is(mediaType, 'text/*'); + } + + async parse(request: Request): Promise { + const body = await invokeBodyParserMiddleware(this.textParser, request); + return {value: body}; + } +} diff --git a/packages/rest/src/body-parsers/body-parser.ts b/packages/rest/src/body-parsers/body-parser.ts new file mode 100644 index 000000000000..0d31b79ce2eb --- /dev/null +++ b/packages/rest/src/body-parsers/body-parser.ts @@ -0,0 +1,205 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + Constructor, + Context, + inject, + instantiateClass, +} from '@loopback/context'; +import {isReferenceObject, OperationObject} from '@loopback/openapi-v3-types'; +import * as debugModule from 'debug'; +import {is} from 'type-is'; +import {RestHttpErrors} from '../rest-http-error'; +import {Request} from '../types'; +import { + builtinParsers, + getContentType, + normalizeParsingError, +} from './body-parser.helpers'; +import { + BodyParser, + BodyParserFunction, + RequestBody, + REQUEST_BODY_PARSER_TAG, +} from './types'; + +const debug = debugModule('loopback:rest:body-parser'); + +export class RequestBodyParser { + readonly parsers: BodyParser[]; + + constructor( + @inject.tag(REQUEST_BODY_PARSER_TAG, {optional: true}) + parsers?: BodyParser[], + @inject.context() private readonly ctx?: Context, + ) { + this.parsers = sortParsers(parsers || []); + if (debug.enabled) { + debug('Body parsers: ', this.parsers.map(p => p.name)); + } + } + + async loadRequestBodyIfNeeded( + operationSpec: OperationObject, + request: Request, + ): Promise { + const {requestBody, customParser} = await this._matchRequestBodySpec( + operationSpec, + request, + ); + if (!operationSpec.requestBody) return requestBody; + const matchedMediaType = requestBody.mediaType!; + try { + if (customParser) { + // Invoke the custom parser + const body = await this._invokeCustomParser(customParser, request); + return Object.assign(requestBody, body); + } else { + const parser = this._findParser(matchedMediaType); + if (parser) { + const body = await parser.parse(request); + return Object.assign(requestBody, body); + } + } + } catch (err) { + throw normalizeParsingError(err); + } + + throw RestHttpErrors.unsupportedMediaType(matchedMediaType); + } + + /** + * Match the http request to a given media type of the request body spec + */ + private async _matchRequestBodySpec( + operationSpec: OperationObject, + request: Request, + ) { + const requestBody: RequestBody = { + value: undefined, + }; + if (!operationSpec.requestBody) return {requestBody}; + + const contentType = getContentType(request) || 'application/json'; + debug('Loading request body with content type %j', contentType); + + // 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; + let customParser = undefined; + for (const type in content) { + matchedMediaType = is(contentType, type); + if (matchedMediaType) { + debug('Matched media type: %s -> %s', type, contentType); + requestBody.mediaType = contentType; + requestBody.schema = content[type].schema; + customParser = content[type]['x-parser']; + break; + } + } + + if (!matchedMediaType) { + // No matching media type found, fail fast + throw RestHttpErrors.unsupportedMediaType( + contentType, + Object.keys(content), + ); + } + + return {requestBody, customParser}; + } + + /** + * Find a body parser that supports the media type + * @param matchedMediaType Media type + */ + private _findParser(matchedMediaType: string) { + for (const parser of this.parsers) { + if (!parser.supports(matchedMediaType)) { + debug( + 'Body parser %s does not support %s', + parser.name, + matchedMediaType, + ); + continue; + } + debug('Body parser %s found for %s', parser.name, matchedMediaType); + return parser; + } + } + + /** + * Resolve and invoke a custom parser + * @param customParser The parser name, class or function + * @param request Http request + */ + private async _invokeCustomParser( + customParser: string | Constructor | BodyParserFunction, + request: Request, + ) { + if (typeof customParser === 'string') { + const parser = this.parsers.find( + p => + p.name === customParser || + p.name === builtinParsers.mapping[customParser], + ); + if (parser) { + debug('Using custom parser %s', customParser); + return parser.parse(request); + } + } else if (typeof customParser === 'function') { + if (isBodyParserClass(customParser)) { + debug('Using custom parser class %s', customParser.name); + const parser = await instantiateClass( + customParser as Constructor, + this.ctx!, + ); + return parser.parse(request); + } else { + debug('Using custom parser function %s', customParser.name); + return customParser(request); + } + } + throw new Error('Custom parser not found: ' + customParser); + } +} + +/** + * Test if a function is a body parser class or plain function + * @param fn + */ +function isBodyParserClass( + fn: Constructor | BodyParserFunction, +): fn is Constructor { + return fn.toString().startsWith('class '); +} + +/** + * Sort body parsers so that built-in ones are used after extensions + * @param parsers + */ +function sortParsers(parsers: BodyParser[]) { + return parsers.sort((a, b) => { + const i1 = builtinParsers.names.indexOf(a.name); + const i2 = builtinParsers.names.indexOf(b.name); + return i1 - i2; + }); +} diff --git a/packages/rest/src/body-parsers/body-parser.urlencoded.ts b/packages/rest/src/body-parsers/body-parser.urlencoded.ts new file mode 100644 index 000000000000..c264e4ba5bd0 --- /dev/null +++ b/packages/rest/src/body-parsers/body-parser.urlencoded.ts @@ -0,0 +1,42 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {inject} from '@loopback/context'; +import {urlencoded} from 'body-parser'; +import {is} from 'type-is'; +import {RestBindings} from '../keys'; +import {Request, RequestBodyParserOptions} from '../types'; +import { + BodyParserMiddleware, + getParserOptions, + invokeBodyParserMiddleware, + builtinParsers, +} from './body-parser.helpers'; +import {BodyParser, RequestBody} from './types'; + +export class UrlEncodedBodyParser implements BodyParser { + name = builtinParsers.urlencoded; + private urlencodedParser: BodyParserMiddleware; + + constructor( + @inject(RestBindings.REQUEST_BODY_PARSER_OPTIONS, {optional: true}) + options: RequestBodyParserOptions = {}, + ) { + const urlencodedOptions = getParserOptions('urlencoded', options); + this.urlencodedParser = urlencoded(urlencodedOptions); + } + + supports(mediaType: string) { + return !!is(mediaType, 'urlencoded'); + } + + async parse(request: Request): Promise { + const body = await invokeBodyParserMiddleware( + this.urlencodedParser, + request, + ); + return {value: body, coercionRequired: true}; + } +} diff --git a/packages/rest/src/body-parsers/index.ts b/packages/rest/src/body-parsers/index.ts new file mode 100644 index 000000000000..e9fb47a1f9d4 --- /dev/null +++ b/packages/rest/src/body-parsers/index.ts @@ -0,0 +1,12 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './types'; +export * from './body-parser'; +export * from './body-parser.json'; +export * from './body-parser.text'; +export * from './body-parser.urlencoded'; +export * from './body-parser.stream'; +export * from './body-parser.raw'; diff --git a/packages/rest/src/body-parsers/types.ts b/packages/rest/src/body-parsers/types.ts new file mode 100644 index 000000000000..aa6c2d74349f --- /dev/null +++ b/packages/rest/src/body-parsers/types.ts @@ -0,0 +1,60 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {ReferenceObject, SchemaObject} from '@loopback/openapi-v3-types'; +import {Request} from '../types'; +/** + * Request body with metadata + */ +export type RequestBody = { + /** + * Parsed value of the request body + */ + // tslint:disable-next-line:no-any + value: any | undefined; + /** + * Is coercion required? Some forms of request such as urlencoded don't + * have rich types such as number or boolean. + */ + coercionRequired?: boolean; + /** + * Resolved media type + */ + mediaType?: string; + /** + * Corresponding schema for the request body + */ + schema?: SchemaObject | ReferenceObject; +}; + +/** + * 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; +} + +/** + * Plain function for body parsing + */ +export type BodyParserFunction = (request: Request) => Promise; + +/** + * Binding tag for request body parser extensions + */ +export const REQUEST_BODY_PARSER_TAG = 'rest.requestBodyParser'; diff --git a/packages/rest/src/index.ts b/packages/rest/src/index.ts index e644149e7196..059b664dd14b 100644 --- a/packages/rest/src/index.ts +++ b/packages/rest/src/index.ts @@ -8,7 +8,7 @@ export * from './router'; export * from './providers'; export * from './parser'; -export * from './body-parser'; +export * from './body-parsers'; export * from './writer'; export * from './http-handler'; export * from './request-context'; diff --git a/packages/rest/src/keys.ts b/packages/rest/src/keys.ts index 688fbc3bb710..1319b350d07a 100644 --- a/packages/rest/src/keys.ts +++ b/packages/rest/src/keys.ts @@ -32,6 +32,7 @@ import {HttpProtocol} from '@loopback/http-server'; import * as https from 'https'; import {ErrorWriterOptions} from 'strong-error-handler'; import {RestRouter} from './router'; +import {RequestBodyParser, BodyParser} from './body-parsers'; /** * RestServer-specific bindings @@ -85,10 +86,59 @@ export namespace RestBindings { 'rest.errorWriterOptions', ); + /** + * Binding key for request body parser options + */ export const REQUEST_BODY_PARSER_OPTIONS = BindingKey.create< RequestBodyParserOptions >('rest.requestBodyParserOptions'); + /** + * Binding key for request body parser + */ + export const REQUEST_BODY_PARSER = BindingKey.create( + 'rest.requestBodyParser', + ); + + function bodyParserBindingKey(parser: string) { + return `${REQUEST_BODY_PARSER}.${parser}`; + } + + /** + * Binding key for request json body parser + */ + export const REQUEST_BODY_PARSER_JSON = BindingKey.create( + bodyParserBindingKey('JsonBodyParser'), + ); + + /** + * Binding key for request urlencoded body parser + */ + export const REQUEST_BODY_PARSER_URLENCODED = BindingKey.create( + bodyParserBindingKey('UrlEncodedBodyParser'), + ); + + /** + * Binding key for request text body parser + */ + export const REQUEST_BODY_PARSER_TEXT = BindingKey.create( + bodyParserBindingKey('TextBodyParser'), + ); + + /** + * Binding key for request raw body parser + */ + export const REQUEST_BODY_PARSER_RAW = BindingKey.create( + bodyParserBindingKey('RawBodyParser'), + ); + + /** + * Binding key for request raw body parser + */ + export const REQUEST_BODY_PARSER_STREAM = BindingKey.create( + bodyParserBindingKey('StreamBodyParser'), + ); + /** * Binding key for setting and injecting an OpenAPI spec */ diff --git a/packages/rest/src/parser.ts b/packages/rest/src/parser.ts index ea5a36b811e6..d64698c860f8 100644 --- a/packages/rest/src/parser.ts +++ b/packages/rest/src/parser.ts @@ -11,17 +11,13 @@ import { SchemasObject, } from '@loopback/openapi-v3-types'; import * as debugModule from 'debug'; -import * as HttpErrors from 'http-errors'; import * as parseUrl from 'parseurl'; import {parse as parseQuery} from 'qs'; +import {RequestBody, RequestBodyParser} from './body-parsers'; import {coerceParameter} from './coercion/coerce-parameter'; import {RestHttpErrors} from './rest-http-error'; import {ResolvedRoute} from './router'; -import { - OperationArgs, - PathParameterValues, - Request, -} from './types'; +import {OperationArgs, PathParameterValues, Request} from './types'; import {validateRequestBody} from './validation/request-body.validator'; const debug = debugModule('loopback:rest:parser'); @@ -39,7 +35,7 @@ Object.freeze(QUERY_NOT_PARSED); export async function parseOperationArgs( request: Request, route: ResolvedRoute, - requestBodyParser: RequestBodyParser = new RequestBodyParser({}), + requestBodyParser: RequestBodyParser = new RequestBodyParser(), ): Promise { debug('Parsing operation arguments for route %s', route.describe()); const operationSpec = route.spec; diff --git a/packages/rest/src/providers/parse-params.provider.ts b/packages/rest/src/providers/parse-params.provider.ts index 0d9621a28ba3..fc3517e9e095 100644 --- a/packages/rest/src/providers/parse-params.provider.ts +++ b/packages/rest/src/providers/parse-params.provider.ts @@ -7,8 +7,8 @@ import {Provider, inject} from '@loopback/context'; import {parseOperationArgs} from '../parser'; import {RestBindings} from '../keys'; import {ResolvedRoute} from '../router'; -import {Request, ParseParams, RequestBodyParserOptions} from '../types'; -import {RequestBodyParser} from '../body-parser'; +import {Request, ParseParams} from '../types'; +import {RequestBodyParser} from '../body-parsers'; /** * Provides the function for parsing args in requests at runtime. * @@ -18,14 +18,10 @@ import {RequestBodyParser} from '../body-parser'; * @returns {ParseParams} The handler function that will parse request args. */ export class ParseParamsProvider implements Provider { - private requestBodyParser: RequestBodyParser; - constructor( - @inject(RestBindings.REQUEST_BODY_PARSER_OPTIONS, {optional: true}) - private options?: RequestBodyParserOptions, - ) { - this.requestBodyParser = new RequestBodyParser(options); - } + @inject(RestBindings.REQUEST_BODY_PARSER) + private requestBodyParser: RequestBodyParser, + ) {} value() { return (request: Request, route: ResolvedRoute) => parseOperationArgs(request, route, this.requestBodyParser); diff --git a/packages/rest/src/rest.application.ts b/packages/rest/src/rest.application.ts index 404de1d1a345..fe4e2b57918c 100644 --- a/packages/rest/src/rest.application.ts +++ b/packages/rest/src/rest.application.ts @@ -3,7 +3,7 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Binding, Constructor} from '@loopback/context'; +import {Binding, Constructor, BindingAddress} from '@loopback/context'; import {Application, ApplicationConfig, Server} from '@loopback/core'; import {OpenApiSpec, OperationObject} from '@loopback/openapi-v3-types'; import {PathParams} from 'express-serve-static-core'; @@ -14,6 +14,7 @@ import {RestComponent} from './rest.component'; import {HttpRequestListener, HttpServerLike, RestServer} from './rest.server'; import {ControllerClass, ControllerFactory, RouteEntry} from './router'; import {SequenceFunction, SequenceHandler} from './sequence'; +import {BodyParser} from './body-parsers'; export const ERR_NO_MULTI_SERVER = format( 'RestApplication does not support multiple servers!', @@ -94,6 +95,18 @@ export class RestApplication extends Application implements HttpServerLike { this.restServer.static(path, rootDir, options); } + /** + * Bind a body parser to the server context + * @param parserClass Body parser class + * @param address Optional binding address + */ + bodyParser( + bodyParserClass: Constructor, + address?: BindingAddress, + ): Binding { + return this.restServer.bodyParser(bodyParserClass, address); + } + /** * Register a new Controller-based route. * diff --git a/packages/rest/src/rest.component.ts b/packages/rest/src/rest.component.ts index 03d0273cd53c..4596e6cddc4c 100644 --- a/packages/rest/src/rest.component.ts +++ b/packages/rest/src/rest.component.ts @@ -3,14 +3,23 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT +import {Binding, BindingScope, Constructor, inject} from '@loopback/context'; import { + Application, Component, CoreBindings, ProviderMap, Server, - Application, } from '@loopback/core'; -import {inject, Constructor} from '@loopback/context'; +import {createEmptyApiSpec} from '@loopback/openapi-v3-types'; +import { + JsonBodyParser, + RequestBodyParser, + StreamBodyParser, + TextBodyParser, + UrlEncodedBodyParser, +} from './body-parsers'; +import {RawBodyParser} from './body-parsers/body-parser.raw'; import {RestBindings} from './keys'; import { BindElementProvider, @@ -18,13 +27,16 @@ import { GetFromContextProvider, InvokeMethodProvider, LogErrorProvider, - RejectProvider, ParseParamsProvider, + RejectProvider, SendProvider, } from './providers'; -import {RestServer, RestServerConfig} from './rest.server'; +import { + createBodyParserBinding, + RestServer, + RestServerConfig, +} from './rest.server'; import {DefaultSequence} from './sequence'; -import {createEmptyApiSpec} from '@loopback/openapi-v3-types'; export class RestComponent implements Component { providers: ProviderMap = { @@ -37,6 +49,34 @@ export class RestComponent implements Component { [RestBindings.SequenceActions.PARSE_PARAMS.key]: ParseParamsProvider, [RestBindings.SequenceActions.SEND.key]: SendProvider, }; + /** + * Add built-in body parsers + */ + bindings = [ + Binding.bind(RestBindings.REQUEST_BODY_PARSER) + .toClass(RequestBodyParser) + .inScope(BindingScope.SINGLETON), + createBodyParserBinding( + JsonBodyParser, + RestBindings.REQUEST_BODY_PARSER_JSON, + ), + createBodyParserBinding( + TextBodyParser, + RestBindings.REQUEST_BODY_PARSER_TEXT, + ), + createBodyParserBinding( + UrlEncodedBodyParser, + RestBindings.REQUEST_BODY_PARSER_URLENCODED, + ), + createBodyParserBinding( + RawBodyParser, + RestBindings.REQUEST_BODY_PARSER_RAW, + ), + createBodyParserBinding( + StreamBodyParser, + RestBindings.REQUEST_BODY_PARSER_STREAM, + ), + ]; servers: { [name: string]: Constructor; } = { diff --git a/packages/rest/src/rest.server.ts b/packages/rest/src/rest.server.ts index 7c125b1c8961..8c389c5aa3df 100644 --- a/packages/rest/src/rest.server.ts +++ b/packages/rest/src/rest.server.ts @@ -3,7 +3,14 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Binding, Constructor, Context, inject} from '@loopback/context'; +import { + Binding, + Constructor, + Context, + inject, + BindingScope, + BindingAddress, +} from '@loopback/context'; import {Application, CoreBindings, Server} from '@loopback/core'; import {HttpServer, HttpServerOptions} from '@loopback/http-server'; import {getControllerSpec} from '@loopback/openapi-v3'; @@ -21,6 +28,7 @@ import {IncomingMessage, ServerResponse} from 'http'; import {ServerOptions} from 'https'; import {safeDump} from 'js-yaml'; import {ServeStaticOptions} from 'serve-static'; +import {BodyParser, REQUEST_BODY_PARSER_TAG} from './body-parsers'; import {HttpHandler} from './http-handler'; import {RestBindings} from './keys'; import {QUERY_NOT_PARSED} from './parser'; @@ -719,6 +727,20 @@ export class RestServer extends Context implements Server, HttpServerLike { this.sequence(SequenceFromFunction); } + /** + * Bind a body parser to the server context + * @param parserClass Body parser class + * @param address Optional binding address + */ + bodyParser( + bodyParserClass: Constructor, + address?: BindingAddress, + ): Binding { + const binding = createBodyParserBinding(bodyParserClass, address); + this.add(binding); + return binding; + } + /** * Start this REST API's HTTP/HTTPS server. * @@ -777,6 +799,23 @@ export class RestServer extends Context implements Server, HttpServerLike { } } +/** + * Create a binding for the given body parser class + * @param parserClass Body parser class + * @param key Optional binding address + */ +export function createBodyParserBinding( + parserClass: Constructor, + key?: BindingAddress, +): Binding { + const address = + key || `${RestBindings.REQUEST_BODY_PARSER}.${parserClass.name}`; + return Binding.bind(address) + .toClass(parserClass) + .inScope(BindingScope.SINGLETON) + .tag(REQUEST_BODY_PARSER_TAG); +} + /** * The form of OpenAPI specs to be served * diff --git a/packages/rest/src/types.ts b/packages/rest/src/types.ts index 6ad8acfe554d..63993ccf3891 100644 --- a/packages/rest/src/types.ts +++ b/packages/rest/src/types.ts @@ -92,6 +92,8 @@ export interface RequestBodyParserOptions extends Options { json?: OptionsJson; urlencoded?: OptionsUrlencoded; text?: OptionsText; + raw?: Options; + [name: string]: unknown; } export type PathParameterValues = {[key: string]: any}; diff --git a/packages/rest/src/validation/request-body.validator.ts b/packages/rest/src/validation/request-body.validator.ts index cabcd45e9a40..6e1be22fd3be 100644 --- a/packages/rest/src/validation/request-body.validator.ts +++ b/packages/rest/src/validation/request-body.validator.ts @@ -110,7 +110,11 @@ function validateValueAgainstSchema( /* istanbul ignore if */ if (debug.enabled) { - debug('Invalid request body: %s', util.inspect(validationErrors)); + debug( + 'Invalid request body: %s. Errors: %s', + util.inspect(body, {depth: null}), + util.inspect(validationErrors), + ); } const error = RestHttpErrors.invalidRequestBody(); diff --git a/packages/rest/test/acceptance/file-upload/file-upload-with-parser.acceptance.ts b/packages/rest/test/acceptance/file-upload/file-upload-with-parser.acceptance.ts new file mode 100644 index 000000000000..18ba8bfb9bf1 --- /dev/null +++ b/packages/rest/test/acceptance/file-upload/file-upload-with-parser.acceptance.ts @@ -0,0 +1,119 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + Client, + createRestAppClient, + expect, + givenHttpServerConfig, +} from '@loopback/testlab'; +import * as multer from 'multer'; +import * as path from 'path'; +import { + BodyParser, + post, + Request, + requestBody, + RequestBody, + RestApplication, +} from '../../..'; + +const FORM_DATA = 'multipart/form-data'; + +describe('multipart/form-data parser', () => { + let client: Client; + let app: RestApplication; + before(givenAClient); + after(async () => { + await app.stop(); + }); + + it('supports file uploads', async () => { + const FIXTURES = path.resolve(__dirname, '../../../../fixtures'); + const res = await client + .post('/show-body') + .field('user', 'john') + .field('email', 'john@example.com') + .attach('testFile', path.resolve(FIXTURES, 'file-upload-test.txt'), { + filename: 'file-upload-test.txt', + contentType: FORM_DATA, + }) + .expect(200); + expect(res.body.files[0]).containEql({ + fieldname: 'testFile', + originalname: 'file-upload-test.txt', + mimetype: FORM_DATA, + }); + }); + + class FileUploadController { + @post('/show-body', { + responses: { + 200: { + content: { + 'application/json': { + schema: { + type: 'object', + }, + }, + }, + description: '', + }, + }, + }) + async showBody( + @requestBody({ + description: 'multipart/form-data value.', + required: true, + content: { + [FORM_DATA]: { + schema: {type: 'object'}, + }, + }, + }) + body: unknown, + ) { + return body; + } + } + + async function givenAClient() { + app = new RestApplication({rest: givenHttpServerConfig()}); + app.bodyParser(MultipartFormDataBodyParser); + app.controller(FileUploadController); + await app.start(); + client = createRestAppClient(app); + } +}); + +class MultipartFormDataBodyParser implements BodyParser { + name = FORM_DATA; + + supports(mediaType: string) { + // The mediaType can be + // `multipart/form-data; boundary=--------------------------979177593423179356726653` + return mediaType.startsWith(FORM_DATA); + } + + async parse(request: Request): Promise { + const storage = multer.memoryStorage(); + const upload = multer({storage}); + return new Promise((resolve, reject) => { + // tslint:disable-next-line:no-any + upload.any()(request, {} as any, err => { + if (err) reject(err); + else { + resolve({ + value: { + files: request.files, + // tslint:disable-next-line:no-any + fields: (request as any).fields, + }, + }); + } + }); + }); + } +} diff --git a/packages/rest/test/acceptance/file-upload/file-upload.acceptance.ts b/packages/rest/test/acceptance/file-upload/file-upload.acceptance.ts new file mode 100644 index 000000000000..08858e8900ec --- /dev/null +++ b/packages/rest/test/acceptance/file-upload/file-upload.acceptance.ts @@ -0,0 +1,103 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {inject} from '@loopback/context'; +import { + Client, + createRestAppClient, + expect, + givenHttpServerConfig, +} from '@loopback/testlab'; +import * as multer from 'multer'; +import * as path from 'path'; +import { + post, + Request, + requestBody, + Response, + RestApplication, + RestBindings, +} from '../../..'; + +describe('multipart/form-data', () => { + let client: Client; + let app: RestApplication; + before(givenAClient); + after(async () => { + await app.stop(); + }); + + it('supports file uploads', async () => { + const FIXTURES = path.resolve(__dirname, '../../../../fixtures'); + const res = await client + .post('/show-body') + .field('user', 'john') + .field('email', 'john@example.com') + .attach('testFile', path.resolve(FIXTURES, 'file-upload-test.txt'), { + filename: 'file-upload-test.txt', + contentType: 'multipart/form-data', + }) + .expect(200); + expect(res.body.files[0]).containEql({ + fieldname: 'testFile', + originalname: 'file-upload-test.txt', + mimetype: 'multipart/form-data', + }); + }); + + class FileUploadController { + @post('/show-body', { + responses: { + 200: { + content: { + 'application/json': { + schema: { + type: 'object', + }, + }, + }, + description: '', + }, + }, + }) + async showBody( + @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 { + const storage = multer.memoryStorage(); + const upload = multer({storage}); + return new Promise((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, + }); + } + }); + }); + } + } + + async function givenAClient() { + app = new RestApplication({rest: givenHttpServerConfig()}); + app.controller(FileUploadController); + await app.start(); + client = createRestAppClient(app); + } +}); diff --git a/packages/rest/test/acceptance/request-parsing/request-parsing.acceptance.ts b/packages/rest/test/acceptance/request-parsing/request-parsing.acceptance.ts new file mode 100644 index 000000000000..ac85eeae9982 --- /dev/null +++ b/packages/rest/test/acceptance/request-parsing/request-parsing.acceptance.ts @@ -0,0 +1,133 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + Client, + createRestAppClient, + expect, + givenHttpServerConfig, +} from '@loopback/testlab'; +import { + JsonBodyParser, + post, + Request, + requestBody, + RestApplication, + RestBindings, +} from '../../..'; + +describe('request parsing', () => { + let client: Client; + let app: RestApplication; + // tslint:disable-next-line:no-any + let parsedRequestBodyValue: any; + + beforeEach(givenAClient); + afterEach(async () => { + await app.stop(); + }); + + it('supports x-parser extension', async () => { + await postRequest('/show-body-json'); + }); + + it('allows built-in body parsers to be overridden', async () => { + class MyJsonBodyParser extends JsonBodyParser { + supports(mediaType: string) { + return false; + } + } + app.bodyParser(MyJsonBodyParser, RestBindings.REQUEST_BODY_PARSER_JSON); + await postRequest('/show-body', 415); + await postRequest('/show-body-json'); + }); + + it('invokes custom body parsers before built-in ones', async () => { + let invoked = false; + class MyJsonBodyParser extends JsonBodyParser { + name = Symbol('my-json'); + async parse(request: Request) { + const body = await super.parse(request); + invoked = true; + return body; + } + } + app.bodyParser(MyJsonBodyParser); + await client + .post('/show-body') + .set('Content-Type', 'application/json') + .send({key: 'value'}) + .expect(200, {key: 'new-value'}); + expect(invoked).to.be.true(); + }); + + it('allows built-in body parsers to be removed', async () => { + app.unbind(RestBindings.REQUEST_BODY_PARSER_JSON); + await postRequest('/show-body', 415); + }); + + async function givenAClient() { + app = new RestApplication({rest: givenHttpServerConfig()}); + app.controller( + givenBodyParamController('/show-body-json', 'json'), + 'Controller1', + ); + app.controller(givenBodyParamController('/show-body'), 'Controller2'); + await app.start(); + client = createRestAppClient(app); + } + + async function postRequest(url = '/show-body', expectedStatusCode = 200) { + const res = await client + .post(url) + .set('Content-Type', 'application/json') + .send({key: 'value'}) + .expect(expectedStatusCode); + expect(parsedRequestBodyValue).to.eql({key: 'value'}); + return res; + } + + function givenBodyParamController(url: string, parser?: string | Function) { + class RouteParamController { + @post(url, { + responses: { + 200: { + content: { + 'application/json': { + schema: { + type: 'object', + }, + }, + }, + description: '', + }, + }, + }) + async showBody( + @requestBody({ + required: true, + content: { + 'application/json': { + // Customize body parsing + 'x-parser': parser, + schema: {type: 'object'}, + }, + }, + }) + request: // tslint:disable-next-line:no-any + any, + ): Promise { + parsedRequestBodyValue = request; + if (parser === 'stream') { + parsedRequestBodyValue = request.body; + } + const parserName = + typeof parser === 'string' ? parser : parser && parser.name; + return {key: 'new-value', parser: parserName}; + } + } + return RouteParamController; + } +}); diff --git a/packages/rest/test/acceptance/validation/validation.acceptance.ts b/packages/rest/test/acceptance/validation/validation.acceptance.ts index 45a983d69a9c..c537a02096ce 100644 --- a/packages/rest/test/acceptance/validation/validation.acceptance.ts +++ b/packages/rest/test/acceptance/validation/validation.acceptance.ts @@ -62,12 +62,19 @@ describe('Validation at REST level', () => { it('rejects missing required properties', () => serverRejectsRequestWithMissingRequiredValues()); - it('rejects requests with no (empty) body', async () => { - // NOTE(bajtos) An empty body cannot be parsed as a JSON, - // therefore this test request does not even reach the validation logic. + it('rejects requests with no body', async () => { + // An empty body is now parsed as `undefined` await client .post('/products') .type('json') + .expect(400); + }); + + it('rejects requests with empty json body', async () => { + await client + .post('/products') + .type('json') + .send('{}') .expect(422); }); diff --git a/packages/rest/test/integration/http-handler.integration.ts b/packages/rest/test/integration/http-handler.integration.ts index b9a7da7a4e60..3512db23e886 100644 --- a/packages/rest/test/integration/http-handler.integration.ts +++ b/packages/rest/test/integration/http-handler.integration.ts @@ -3,24 +3,34 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT +import {Context} from '@loopback/context'; +import {anOpenApiSpec, anOperationSpec} from '@loopback/openapi-spec-builder'; +import {ControllerSpec, get} from '@loopback/openapi-v3'; +import {ParameterObject, RequestBodyObject} from '@loopback/openapi-v3-types'; +import {Client, createClientForHandler, expect} from '@loopback/testlab'; +import * as express from 'express'; +import * as HttpErrors from 'http-errors'; +import {is} from 'type-is'; import { - HttpHandler, + BodyParser, + createBodyParserBinding, DefaultSequence, - writeResultToResponse, - RestBindings, FindRouteProvider, + HttpHandler, InvokeMethodProvider, + JsonBodyParser, + ParseParamsProvider, + RawBodyParser, RejectProvider, + Request, + RequestBodyParser, + RestBindings, + StreamBodyParser, + TextBodyParser, + UrlEncodedBodyParser, + writeResultToResponse, } from '../..'; -import {ControllerSpec, get} from '@loopback/openapi-v3'; -import {Context} from '@loopback/context'; -import {Client, createClientForHandler, expect} from '@loopback/testlab'; -import * as HttpErrors from 'http-errors'; -import {ParameterObject, RequestBodyObject} from '@loopback/openapi-v3-types'; -import {anOpenApiSpec, anOperationSpec} from '@loopback/openapi-spec-builder'; import {createUnexpectedHttpErrorLogger} from '../helpers'; -import * as express from 'express'; -import {ParseParamsProvider} from '../../src'; const SequenceActions = RestBindings.SequenceActions; @@ -274,12 +284,28 @@ describe('HttpHandler', () => { .post('/show-body') .set('content-type', 'application/xml') .send('value') + .expect(415, { + error: { + code: 'UNSUPPORTED_MEDIA_TYPE', + message: 'Content-type application/xml is not supported.', + name: 'UnsupportedMediaTypeError', + statusCode: 415, + }, + }); + }); + + it('rejects unmatched request body', () => { + logErrorsExcept(415); + return client + .post('/show-body') + .set('content-type', 'text/plain') + .send('value') .expect(415, { error: { code: 'UNSUPPORTED_MEDIA_TYPE', message: - 'Content-type application/xml does not match ' + - '[application/json,application/x-www-form-urlencoded].', + 'Content-type text/plain does not match [application/json' + + ',application/x-www-form-urlencoded,application/xml].', name: 'UnsupportedMediaTypeError', statusCode: 415, }, @@ -332,6 +358,33 @@ describe('HttpHandler', () => { .expect(200, body); }); + it('allows request body parser extensions', () => { + const body = 'value'; + + /** + * A mock-up xml parser + */ + class XmlBodyParser implements BodyParser { + name: string = 'xml'; + supports(mediaType: string) { + return !!is(mediaType, 'xml'); + } + + async parse(request: Request) { + return {value: {key: 'value'}}; + } + } + + // Register a request body parser for xml + rootContext.add(createBodyParserBinding(XmlBodyParser)); + + return client + .post('/show-body') + .set('content-type', 'application/xml') + .send(body) + .expect(200, {key: 'value'}); + }); + /** * Ignore the EPIPE and ECONNRESET errors. * See https://github.com/nodejs/node/issues/12339 @@ -345,18 +398,6 @@ describe('HttpHandler', () => { // On Windows, ECONNRESET is sometimes emitted instead of EPIPE. if (err && err.code !== 'EPIPE' && err.code !== 'ECONNRESET') throw err; } - - it('allows customization of request body parser options', () => { - const body = {key: givenLargeRequest()}; - rootContext - .bind(RestBindings.REQUEST_BODY_PARSER_OPTIONS) - .to({limit: 4 * 1024 * 1024}); // Set limit to 4MB - return client - .post('/show-body') - .set('content-type', 'application/json') - .send(body) - .expect(200, body); - }); function givenLargeRequest() { const data = Buffer.alloc(2 * 1024 * 1024, 'A', 'utf-8'); @@ -377,6 +418,9 @@ describe('HttpHandler', () => { 'application/x-www-form-urlencoded': { schema: {type: 'object'}, }, + 'application/xml': { + schema: {type: 'object'}, + }, }, }, responses: { @@ -581,6 +625,33 @@ describe('HttpHandler', () => { rootContext.bind(RestBindings.SEQUENCE).toClass(DefaultSequence); + [ + createBodyParserBinding( + JsonBodyParser, + RestBindings.REQUEST_BODY_PARSER_JSON, + ), + createBodyParserBinding( + TextBodyParser, + RestBindings.REQUEST_BODY_PARSER_TEXT, + ), + createBodyParserBinding( + UrlEncodedBodyParser, + RestBindings.REQUEST_BODY_PARSER_URLENCODED, + ), + createBodyParserBinding( + RawBodyParser, + RestBindings.REQUEST_BODY_PARSER_RAW, + ), + createBodyParserBinding( + StreamBodyParser, + RestBindings.REQUEST_BODY_PARSER_STREAM, + ), + ].forEach(binding => rootContext.add(binding)); + + rootContext + .bind(RestBindings.REQUEST_BODY_PARSER) + .toClass(RequestBodyParser); + handler = new HttpHandler(rootContext); rootContext.bind(RestBindings.HANDLER).to(handler); } diff --git a/packages/rest/test/integration/rest.server.integration.ts b/packages/rest/test/integration/rest.server.integration.ts index 4fdfb539ddfc..f2748aefdf36 100644 --- a/packages/rest/test/integration/rest.server.integration.ts +++ b/packages/rest/test/integration/rest.server.integration.ts @@ -12,7 +12,15 @@ import { httpsGetAsync, givenHttpServerConfig, } from '@loopback/testlab'; -import {RestBindings, RestServer, RestComponent, get} from '../..'; +import { + RestBindings, + RestServer, + RestComponent, + get, + Request, + RestServerConfig, + BodyParser, +} from '../..'; import {IncomingMessage, ServerResponse} from 'http'; import * as yaml from 'js-yaml'; import * as path from 'path'; @@ -20,7 +28,8 @@ import * as fs from 'fs'; import * as util from 'util'; const readFileAsync = util.promisify(fs.readFile); -import {RestServerConfig} from '../..'; +import {is} from 'type-is'; +import {requestBody, post} from '../../src'; const FIXTURES = path.resolve(__dirname, '../../../fixtures'); const ASSETS = path.resolve(FIXTURES, 'assets'); @@ -230,6 +239,35 @@ describe('RestServer (integration)', () => { .expect(200, 'Hi'); }); + it('allows request body parser extensions', async () => { + const body = 'value'; + + /** + * A mock-up xml parser + */ + class XmlBodyParser implements BodyParser { + name: string = 'xml'; + supports(mediaType: string) { + return !!is(mediaType, 'xml'); + } + + async parse(request: Request) { + return {value: {key: 'value'}}; + } + } + + const server = await givenAServer({rest: {port: 0}}); + // Register a request body parser for xml + server.bodyParser(XmlBodyParser); + server.controller(DummyXmlController); + + await createClientForHandler(server.requestHandler) + .post('/') + .set('Content-Type', 'application/xml') + .send(body) + .expect(200, {key: 'value'}); + }); + it('allows cors', async () => { const server = await givenAServer({rest: {port: 0}}); server.handler(dummyRequestHandler); @@ -688,6 +726,25 @@ paths: } } + class DummyXmlController { + constructor() {} + @post('/') + echo( + @requestBody({ + content: { + 'application/xml': { + schema: { + type: 'object', + }, + }, + }, + }) + body: object, + ): object { + return body; + } + } + function readFileFromDirectory( dirname: string, filename: string, diff --git a/packages/rest/test/unit/body-parser.unit.ts b/packages/rest/test/unit/body-parser.unit.ts new file mode 100644 index 000000000000..8d760c1610dc --- /dev/null +++ b/packages/rest/test/unit/body-parser.unit.ts @@ -0,0 +1,365 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Context} from '@loopback/core'; +import {OperationObject, RequestBodyObject} from '@loopback/openapi-v3-types'; +import { + expect, + ShotRequestOptions, + stubExpressContext, +} from '@loopback/testlab'; +import { + JsonBodyParser, + RawBodyParser, + Request, + RequestBody, + RequestBodyParser, + RequestBodyParserOptions, + StreamBodyParser, + TextBodyParser, + UrlEncodedBodyParser, +} from '../..'; +import {builtinParsers} from '../../src/body-parsers/body-parser.helpers'; + +describe('body parser', () => { + const defaultSchema = { + type: 'object', + }; + let requestBodyParser: RequestBodyParser; + before(givenRequestBodyParser); + + it('parses body parameter with multiple media types', async () => { + const req = givenRequest({ + url: '/', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + payload: 'key=value', + }); + + const urlencodedSchema = { + type: 'object', + properties: { + key: {type: 'string'}, + }, + }; + const spec = givenOperationWithRequestBody({ + description: 'data', + content: { + 'application/json': {schema: {type: 'object'}}, + 'application/x-www-form-urlencoded': { + schema: urlencodedSchema, + }, + }, + }); + const requestBody = await requestBodyParser.loadRequestBodyIfNeeded( + spec, + req, + ); + expect(requestBody).to.eql({ + value: {key: 'value'}, + coercionRequired: true, + mediaType: 'application/x-www-form-urlencoded', + schema: urlencodedSchema, + }); + }); + + it('allows application/json to be default', async () => { + const req = givenRequest({ + url: '/', + headers: { + 'Content-Type': 'application/json', + }, + payload: {key: 'value'}, + }); + + const spec = givenOperationWithRequestBody({ + description: 'data', + content: {}, + }); + const requestBody = await requestBodyParser.loadRequestBodyIfNeeded( + spec, + req, + ); + expect(requestBody).to.eql({ + value: {key: 'value'}, + mediaType: 'application/json', + schema: defaultSchema, + }); + }); + + it('allows text/json to be parsed', async () => { + const req = givenRequest({ + url: '/', + headers: { + 'Content-Type': 'text/json', + }, + payload: {key: 'value'}, + }); + + const spec = givenOperationWithRequestBody({ + description: 'data', + content: {'text/json': {schema: defaultSchema}}, + }); + const requestBody = await requestBodyParser.loadRequestBodyIfNeeded( + spec, + req, + ); + expect(requestBody).to.eql({ + value: {key: 'value'}, + mediaType: 'text/json', + schema: defaultSchema, + }); + }); + + it('allows */*.+json to be parsed', async () => { + const req = givenRequest({ + url: '/', + headers: { + 'Content-Type': 'application/x-xyz+json', + }, + payload: {key: 'value'}, + }); + + const spec = givenOperationWithRequestBody({ + description: 'data', + content: {'application/x-xyz+json': {schema: defaultSchema}}, + }); + const requestBody = await requestBodyParser.loadRequestBodyIfNeeded( + spec, + req, + ); + expect(requestBody).to.eql({ + value: {key: 'value'}, + mediaType: 'application/x-xyz+json', + schema: defaultSchema, + }); + }); + + it('parses body string as json', async () => { + const req = givenRequest({ + url: '/', + payload: '"value"', + headers: { + 'Content-Type': 'application/json', + }, + }); + + const spec = givenOperationWithRequestBody({ + description: 'data', + content: {}, + }); + const requestBody = await requestBodyParser.loadRequestBodyIfNeeded( + spec, + req, + ); + expect(requestBody).to.eql({ + value: 'value', + mediaType: 'application/json', + schema: defaultSchema, + }); + }); + + it('parses body number as json', async () => { + const req = givenRequest({ + url: '/', + payload: '123', + headers: { + 'Content-Type': 'application/json', + }, + }); + + const spec = givenOperationWithRequestBody({ + description: 'data', + content: {}, + }); + const requestBody = await requestBodyParser.loadRequestBodyIfNeeded( + spec, + req, + ); + expect(requestBody).to.eql({ + value: 123, + mediaType: 'application/json', + schema: defaultSchema, + }); + }); + + it('sorts body parsers', () => { + const options: RequestBodyParserOptions = {}; + const bodyParser = new RequestBodyParser([ + new TextBodyParser(options), + new StreamBodyParser(), + new JsonBodyParser(options), + new UrlEncodedBodyParser(options), + new RawBodyParser(options), + { + name: 'xml', + supports: mediaType => true, + parse: async request => { + return {value: 'xml'}; + }, + }, + ]); + const names = bodyParser.parsers.map(p => p.name); + expect(names).to.eql(['xml', ...builtinParsers.names]); + }); + + it('normalizes parsing errors', async () => { + const bodyParser = new RequestBodyParser([ + { + name: 'xml', + supports: mediaType => true, + parse: async request => { + throw new Error('Not implemented'); + }, + }, + ]); + const req = givenRequest({ + url: '/', + payload: '123', + headers: { + 'Content-Type': 'application/xml', + }, + }); + + const spec = givenOperationWithRequestBody({ + description: 'data', + content: {'application/xml': {schema: defaultSchema}}, + }); + return expect( + bodyParser.loadRequestBodyIfNeeded(spec, req), + ).to.be.rejectedWith({statusCode: 400, message: 'Not implemented'}); + }); + + describe('x-parser extension', () => { + let spec: OperationObject; + let req: Request; + let requestBody: RequestBody; + + it('skips body parsing', async () => { + await loadRequestBodyWithXStream('stream'); + expect(requestBody).to.eql({ + value: req, + mediaType: 'application/json', + schema: defaultSchema, + }); + }); + + it('allows custom parser by name', async () => { + await loadRequestBodyWithXStream('json'); + expect(requestBody).to.eql({ + value: {key: 'value'}, + mediaType: 'application/json', + schema: defaultSchema, + }); + }); + + it('allows raw parser', async () => { + await loadRequestBodyWithXStream('raw'); + expect(requestBody).to.eql({ + value: Buffer.from(JSON.stringify({key: 'value'})), + mediaType: 'application/json', + schema: defaultSchema, + }); + }); + + it('allows custom parser by class', async () => { + await loadRequestBodyWithXStream(JsonBodyParser); + expect(requestBody).to.eql({ + value: {key: 'value'}, + mediaType: 'application/json', + schema: defaultSchema, + }); + }); + + it('allows custom parser by function', async () => { + function parseJson(request: Request) { + return new JsonBodyParser().parse(request); + } + await loadRequestBodyWithXStream(parseJson); + expect(requestBody).to.eql({ + value: {key: 'value'}, + mediaType: 'application/json', + schema: defaultSchema, + }); + }); + + it('reports error if custom parser not found', async () => { + return expect(loadRequestBodyWithXStream('xml')) + .to.be.rejectedWith(/Custom parser not found\: xml/) + .catch(e => {}); + }); + + async function loadRequestBodyWithXStream(parser: string | Function) { + spec = givenSpecWithXStream(parser); + req = givenShowBodyRequest(); + requestBody = await requestBodyParser.loadRequestBodyIfNeeded(spec, req); + return requestBody; + } + + function givenShowBodyRequest() { + return givenRequest({ + url: '/show-body', + method: 'post', + payload: {key: 'value'}, + headers: { + 'Content-Type': 'application/json', + }, + }); + } + + function givenSpecWithXStream(parser: string | Function) { + return { + 'x-operation-name': 'showBody', + requestBody: { + required: true, + content: { + 'application/json': { + // Skip body parsing + 'x-parser': parser, + schema: {type: 'object'}, + }, + }, + }, + responses: { + 200: { + content: { + 'application/json': { + schema: { + type: 'object', + }, + }, + }, + description: '', + }, + }, + }; + } + }); + + function givenRequestBodyParser() { + const options: RequestBodyParserOptions = {}; + const parsers = [ + new JsonBodyParser(options), + new UrlEncodedBodyParser(options), + new TextBodyParser(options), + new StreamBodyParser(), + new RawBodyParser(options), + ]; + requestBodyParser = new RequestBodyParser(parsers, new Context()); + } + + function givenOperationWithRequestBody(requestBody?: RequestBodyObject) { + return { + 'x-operation-name': 'testOp', + requestBody: requestBody, + responses: {}, + }; + } + + function givenRequest(options?: ShotRequestOptions): Request { + return stubExpressContext(options).request; + } +}); diff --git a/packages/rest/test/unit/coercion/utils.ts b/packages/rest/test/unit/coercion/utils.ts index dcabfebed4aa..77e5b7afb06a 100644 --- a/packages/rest/test/unit/coercion/utils.ts +++ b/packages/rest/test/unit/coercion/utils.ts @@ -12,6 +12,7 @@ import { parseOperationArgs, PathParameterValues, Request, + RequestBodyParser, ResolvedRoute, Route, } from '../../..'; @@ -80,12 +81,13 @@ export async function testCoercion(config: TestArgs) { break; } + const requestBodyParser = new RequestBodyParser(); if (config.expectError) { - await expect(parseOperationArgs(req, route)).to.be.rejectedWith( - config.expectedResult, - ); + await expect( + parseOperationArgs(req, route, requestBodyParser), + ).to.be.rejectedWith(config.expectedResult); } else { - const args = await parseOperationArgs(req, route); + const args = await parseOperationArgs(req, route, requestBodyParser); expect(args).to.eql([config.expectedResult]); } } catch (err) { diff --git a/packages/rest/test/unit/parser.unit.ts b/packages/rest/test/unit/parser.unit.ts index fef0f65c7592..358d5f0e1f5b 100644 --- a/packages/rest/test/unit/parser.unit.ts +++ b/packages/rest/test/unit/parser.unit.ts @@ -3,6 +3,7 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT +import {Context} from '@loopback/core'; import { OperationObject, ParameterObject, @@ -16,15 +17,24 @@ import { } from '@loopback/testlab'; import { createResolvedRoute, + JsonBodyParser, parseOperationArgs, - RequestBodyParser, PathParameterValues, + RawBodyParser, Request, + RequestBodyParser, + RequestBodyParserOptions, RestHttpErrors, Route, + StreamBodyParser, + TextBodyParser, + UrlEncodedBodyParser, } from '../..'; describe('operationArgsParser', () => { + let requestBodyParser: RequestBodyParser; + before(givenRequestBodyParser); + it('parses path parameters', async () => { const req = givenRequest(); const spec = givenOperationWithParameters([ @@ -37,7 +47,7 @@ describe('operationArgsParser', () => { ]); const route = givenResolvedRoute(spec, {id: 1}); - const args = await parseOperationArgs(req, route); + const args = await parseOperationArgs(req, route, requestBodyParser); expect(args).to.eql([1]); }); @@ -54,7 +64,7 @@ describe('operationArgsParser', () => { }); const route = givenResolvedRoute(spec); - const args = await parseOperationArgs(req, route); + const args = await parseOperationArgs(req, route, requestBodyParser); expect(args).to.eql([{key: 'value'}]); }); @@ -76,7 +86,7 @@ describe('operationArgsParser', () => { }); const route = givenResolvedRoute(spec); - const args = await parseOperationArgs(req, route); + const args = await parseOperationArgs(req, route, requestBodyParser); expect(args).to.eql([{key: 'value'}]); }); @@ -107,7 +117,7 @@ describe('operationArgsParser', () => { }); const route = givenResolvedRoute(spec); - const args = await parseOperationArgs(req, route); + const args = await parseOperationArgs(req, route, requestBodyParser); expect(args).to.eql([{key1: 'value', key2: 1, key3: true}]); }); @@ -136,7 +146,7 @@ describe('operationArgsParser', () => { }); const route = givenResolvedRoute(spec); - const args = await parseOperationArgs(req, route); + const args = await parseOperationArgs(req, route, requestBodyParser); expect(args).to.eql([{key: [1, 2]}]); }); @@ -177,7 +187,7 @@ describe('operationArgsParser', () => { }); const route = givenResolvedRoute(spec); - const args = await parseOperationArgs(req, route); + const args = await parseOperationArgs(req, route, requestBodyParser); expect(args).to.eql([ { @@ -212,7 +222,7 @@ describe('operationArgsParser', () => { }); const route = givenResolvedRoute(spec); - const args = await parseOperationArgs(req, route); + const args = await parseOperationArgs(req, route, requestBodyParser); expect(args).to.eql([{key1: ['value1', 'value2']}]); }); @@ -234,7 +244,7 @@ describe('operationArgsParser', () => { }); const route = givenResolvedRoute(spec); - const args = await parseOperationArgs(req, route); + const args = await parseOperationArgs(req, route, requestBodyParser); expect(args).to.eql(['plain-text']); }); @@ -256,7 +266,7 @@ describe('operationArgsParser', () => { }); const route = givenResolvedRoute(spec); - const args = await parseOperationArgs(req, route); + const args = await parseOperationArgs(req, route, requestBodyParser); expect(args).to.eql(['

Hello

']); }); @@ -270,7 +280,7 @@ describe('operationArgsParser', () => { const spec = givenOperationWithObjectParameter('value'); const route = givenResolvedRoute(spec); - const args = await parseOperationArgs(req, route); + const args = await parseOperationArgs(req, route, requestBodyParser); expect(args).to.eql([{key: 'value'}]); }); @@ -283,7 +293,7 @@ describe('operationArgsParser', () => { const spec = givenOperationWithObjectParameter('value'); const route = givenResolvedRoute(spec); - const args = await parseOperationArgs(req, route); + const args = await parseOperationArgs(req, route, requestBodyParser); expect(args).to.eql([{key: 'value'}]); }); @@ -296,7 +306,9 @@ describe('operationArgsParser', () => { const spec = givenOperationWithObjectParameter('value'); const route = givenResolvedRoute(spec); - await expect(parseOperationArgs(req, route)).to.be.rejectedWith( + await expect( + parseOperationArgs(req, route, requestBodyParser), + ).to.be.rejectedWith( RestHttpErrors.invalidData('{"malformed-JSON"}', 'value', { details: { syntaxError: 'Unexpected token } in JSON at position 17', @@ -313,9 +325,9 @@ describe('operationArgsParser', () => { const spec = givenOperationWithObjectParameter('value'); const route = givenResolvedRoute(spec); - await expect(parseOperationArgs(req, route)).to.be.rejectedWith( - RestHttpErrors.invalidData('[1,2]', 'value'), - ); + await expect( + parseOperationArgs(req, route, requestBodyParser), + ).to.be.rejectedWith(RestHttpErrors.invalidData('[1,2]', 'value')); }); it('rejects array values provided via nested keys', async () => { @@ -326,9 +338,9 @@ describe('operationArgsParser', () => { const spec = givenOperationWithObjectParameter('value'); const route = givenResolvedRoute(spec); - await expect(parseOperationArgs(req, route)).to.be.rejectedWith( - RestHttpErrors.invalidData(['1', '2'], 'value'), - ); + await expect( + parseOperationArgs(req, route, requestBodyParser), + ).to.be.rejectedWith(RestHttpErrors.invalidData(['1', '2'], 'value')); }); function givenOperationWithObjectParameter( @@ -348,78 +360,17 @@ describe('operationArgsParser', () => { } }); - describe('body parser', () => { - let requestBodyParser: RequestBodyParser; - - before(givenRequestBodyParser); - - it('parses body parameter with multiple media types', async () => { - const req = givenRequest({ - url: '/', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - payload: 'key=value', - }); - - const urlencodedSchema = { - type: 'object', - properties: { - key: {type: 'string'}, - }, - }; - const spec = givenOperationWithRequestBody({ - description: 'data', - content: { - 'application/json': {schema: {type: 'object'}}, - 'application/x-www-form-urlencoded': { - schema: urlencodedSchema, - }, - }, - }); - const requestBody = await requestBodyParser.loadRequestBodyIfNeeded( - spec, - req, - ); - expect(requestBody).to.eql({ - value: {key: 'value'}, - coercionRequired: true, - mediaType: 'application/x-www-form-urlencoded', - schema: urlencodedSchema, - }); - }); - - it('allows application/json to be default', async () => { - const req = givenRequest({ - url: '/', - headers: { - 'Content-Type': 'application/json', - }, - payload: {key: 'value'}, - }); - - const defaultSchema = { - type: 'object', - }; - const spec = givenOperationWithRequestBody({ - description: 'data', - content: {}, - }); - const requestBody = await requestBodyParser.loadRequestBodyIfNeeded( - spec, - req, - ); - expect(requestBody).to.eql({ - value: {key: 'value'}, - mediaType: 'application/json', - schema: defaultSchema, - }); - }); - - function givenRequestBodyParser() { - requestBodyParser = new RequestBodyParser(); - } - }); + function givenRequestBodyParser() { + const options: RequestBodyParserOptions = {}; + const parsers = [ + new JsonBodyParser(options), + new UrlEncodedBodyParser(options), + new TextBodyParser(options), + new StreamBodyParser(), + new RawBodyParser(options), + ]; + requestBodyParser = new RequestBodyParser(parsers, new Context()); + } function givenOperationWithParameters(params?: ParameterObject[]) { return {