Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support multipart form data #1880

Closed
wants to merge 9 commits into from
2 changes: 2 additions & 0 deletions packages/rest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"http-errors": "^1.6.3",
"js-yaml": "^3.11.0",
"lodash": "^4.17.5",
"multiparty": "^4.2.1",
"openapi-schema-to-json-schema": "^2.1.0",
"openapi3-ts": "^1.0.0",
"parseurl": "^1.3.2",
Expand All @@ -54,6 +55,7 @@
"@types/debug": "0.0.30",
"@types/js-yaml": "^3.11.1",
"@types/lodash": "^4.14.106",
"@types/multiparty": "0.0.31",
"@types/node": "^10.11.2"
},
"files": [
Expand Down
5 changes: 5 additions & 0 deletions packages/rest/src/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
ParseParams,
Reject,
Send,
RequestBodyParserOptions,
} from './types';

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

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

/**
* Binding key for setting and injecting an OpenAPI spec
*/
Expand Down
114 changes: 97 additions & 17 deletions packages/rest/src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,14 @@ import {promisify} from 'util';
import {coerceParameter} from './coercion/coerce-parameter';
import {RestHttpErrors} from './index';
import {ResolvedRoute} from './router/routing-table';
import {OperationArgs, PathParameterValues, Request} from './types';
import {
OperationArgs,
PathParameterValues,
Request,
RequestBodyParserOptions,
} from './types';
import {validateRequestBody} from './validation/request-body.validator';
import {Form} from 'multiparty';

type HttpError = HttpErrors.HttpError;

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

// tslint:disable-next-line:no-any
type MaybeBody = any | undefined;
// tslint:disable:no-any
type RequestBody = {
value: any | undefined;
coercionRequired?: boolean;
};

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

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

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

async function parseMultiParty(request: IncomingMessage, options: any) {
return new Promise((resolve, reject) => {
const form = new Form(options);

form.parse(request, function (error: Error, fields: any, files: any) {
if (error) {
return reject(error);
}
resolve({...fields, files: files});
});
});
}

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

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

const contentType = getContentType(request);
debug('Loading request body with content type %j', contentType);

if (
contentType &&
contentType.startsWith('multipart/form-data')
) {
const body = await parseMultiParty(request, options).catch(
(err: HttpError) => {
debug('Cannot parse request body %j', err);
if (!err.statusCode || err.statusCode >= 500) {
err.statusCode = 400;
}
throw err;
},
);
// form parser returns an object with prototype
return {
value: Object.assign({}, body),
coercionRequired: true,
};
}

if (
contentType &&
contentType.startsWith('application/x-www-form-urlencoded')
) {
const body = await parseFormBody(request, options).catch(
(err: HttpError) => {
debug('Cannot parse request body %j', err);
if (!err.statusCode || err.statusCode >= 500) {
err.statusCode = 400;
}
throw err;
},
);
// form parser returns an object with prototype
return {
value: Object.assign({}, body),
coercionRequired: true,
};
}

if (contentType && !/json/.test(contentType)) {
throw new HttpErrors.UnsupportedMediaType(
`Content-type ${contentType} is not supported.`,
);
}

return await parseJsonBody(request).catch((err: HttpError) => {
debug('Cannot parse request body %j', err);
err.statusCode = 400;
throw err;
});
const jsonBody = await parseJsonBody(request, options).catch(
(err: HttpError) => {
debug('Cannot parse request body %j', err);
if (!err.statusCode || err.statusCode >= 500) {
err.statusCode = 400;
}
throw err;
},
);
return {value: jsonBody};
}

function buildOperationArguments(
operationSpec: OperationObject,
request: Request,
pathParams: PathParameterValues,
body: MaybeBody,
body: RequestBody,
globalSchemas: SchemasObject,
): OperationArgs {
let requestBodyIndex: number = -1;
Expand Down Expand Up @@ -129,9 +207,11 @@ function buildOperationArguments(
}

debug('Validating request body - value %j', body);
validateRequestBody(body, operationSpec.requestBody, globalSchemas);
validateRequestBody(body.value, operationSpec.requestBody, globalSchemas, {
coerceTypes: body.coercionRequired,
});

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

Expand Down
18 changes: 13 additions & 5 deletions packages/rest/src/providers/parse-params.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,26 @@
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {Provider, BoundValue} from '@loopback/context';
import {Provider, inject} from '@loopback/context';
import {parseOperationArgs} from '../parser';
import {RestBindings} from '../keys';
import {ResolvedRoute} from '../router';
import {Request, ParseParams, RequestBodyParserOptions} from '../types';
/**
* Provides the function for parsing args in requests at runtime.
*
* @export
* @class ParseParamsProvider
* @implements {Provider<BoundValue>}
* @returns {BoundValue} The handler function that will parse request args.
* @implements {Provider<ParseParams>}
* @returns {ParseParams} The handler function that will parse request args.
*/
export class ParseParamsProvider implements Provider<BoundValue> {
export class ParseParamsProvider implements Provider<ParseParams> {
constructor(
@inject(RestBindings.REQUEST_BODY_PARSER_OPTIONS, {optional: true})
private options?: RequestBodyParserOptions,
) {}
value() {
return parseOperationArgs;
return (request: Request, route: ResolvedRoute) =>
parseOperationArgs(request, route, this.options);
}
}
19 changes: 19 additions & 0 deletions packages/rest/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import {Binding, BoundValue} from '@loopback/context';
import {ResolvedRoute, RouteEntry} from './router/routing-table';
import {Request, Response} from 'express';
import {File} from 'multiparty';

export {Request, Response};

Expand Down Expand Up @@ -77,6 +78,16 @@ export type LogError = (
) => void;

// tslint:disable:no-any

/**
* Options for request body parsing
*/
export type RequestBodyParserOptions = {
limit?: number;
encoding?: string;
[property: string]: any;
};

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

Expand All @@ -90,3 +101,11 @@ export type OperationRetval = any;

export type GetFromContext = (key: string) => Promise<BoundValue>;
export type BindElement = (key: string) => Binding;

export {File};
export interface FileArray {
/*
* the file arrays
*/
file?: File[];
}
19 changes: 14 additions & 5 deletions packages/rest/src/validation/request-body.validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export function validateRequestBody(
body: any,
requestBodySpec: RequestBodyObject | undefined,
globalSchemas?: SchemasObject,
options?: AJV.Options,
) {
if (!requestBodySpec) return;

Expand All @@ -50,7 +51,7 @@ export function validateRequestBody(
debug('Request body schema: %j', util.inspect(schema, {depth: null}));
if (!schema) return;

validateValueAgainstSchema(body, schema, globalSchemas);
validateValueAgainstSchema(body, schema, globalSchemas, options);
}

/**
Expand Down Expand Up @@ -94,13 +95,14 @@ function validateValueAgainstSchema(
body: any,
schema: SchemaObject,
globalSchemas?: SchemasObject,
options?: AJV.Options,
) {
let validate;

if (compiledSchemaCache.has(schema)) {
validate = compiledSchemaCache.get(schema);
} else {
validate = createValidator(schema, globalSchemas);
validate = createValidator(schema, globalSchemas, options);
compiledSchemaCache.set(schema, validate);
}

Expand Down Expand Up @@ -128,6 +130,7 @@ function validateValueAgainstSchema(
function createValidator(
schema: SchemaObject,
globalSchemas?: SchemasObject,
options?: AJV.Options,
): Function {
const jsonSchema = convertToJsonSchema(schema);

Expand All @@ -136,9 +139,15 @@ function createValidator(
schemas: globalSchemas,
};

const ajv = new AJV({
allErrors: true,
});
const ajv = new AJV(
Object.assign(
{},
{
allErrors: true,
},
options,
),
);

return ajv.compile(schemaWithRef);
}
Loading