Skip to content

Commit 00ec6df

Browse files
committed
feat(rest): expose request body validation options to be configurable
1 parent 73ad6ad commit 00ec6df

File tree

8 files changed

+131
-27
lines changed

8 files changed

+131
-27
lines changed

docs/site/Extending-request-body-parsing.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,10 @@ the following configuration:
122122
},
123123
text: {
124124
limit: '2MB'
125-
}
125+
},
126+
// Validation options for AJV, see https://github.com/epoberezkin/ajv#options
127+
// This setting is global for all request body parsers.
128+
validation: {nullable: true},
126129
}
127130
```
128131

packages/rest/src/__tests__/acceptance/validation/validation.acceptance.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {model, property} from '@loopback/repository';
88
import {
99
Client,
1010
createRestAppClient,
11+
expect,
1112
givenHttpServerConfig,
1213
} from '@loopback/testlab';
1314
import {
@@ -16,7 +17,9 @@ import {
1617
jsonToSchemaObject,
1718
post,
1819
requestBody,
20+
RequestBodyValidationOptions,
1921
RestApplication,
22+
RestBindings,
2023
SchemaObject,
2124
} from '../../..';
2225
import {aBodySpec} from '../../helpers';
@@ -98,6 +101,57 @@ describe('Validation at REST level', () => {
98101
});
99102
});
100103

104+
context('with request body validation options', () => {
105+
class ProductController {
106+
@post('/products')
107+
async create(
108+
@requestBody({required: true}) data: Product,
109+
): Promise<Product> {
110+
return new Product(data);
111+
}
112+
}
113+
114+
before(() =>
115+
givenAnAppAndAClient(ProductController, {
116+
nullable: false,
117+
compiledSchemaCache: new WeakMap(),
118+
}),
119+
);
120+
after(() => app.stop());
121+
122+
it('rejects requests with `null` with {nullable: false}', async () => {
123+
const DATA = {
124+
name: 'iPhone',
125+
description: null,
126+
price: 10,
127+
};
128+
const res = await client
129+
.post('/products')
130+
.send(DATA)
131+
.expect(422);
132+
133+
expect(res.body).to.eql({
134+
error: {
135+
code: 'VALIDATION_FAILED',
136+
details: [
137+
{
138+
code: 'type',
139+
info: {
140+
type: 'string',
141+
},
142+
message: 'should be string',
143+
path: '.description',
144+
},
145+
],
146+
message:
147+
'The request body is invalid. See error object `details` property for more info.',
148+
name: 'UnprocessableEntityError',
149+
statusCode: 422,
150+
},
151+
});
152+
});
153+
});
154+
101155
// A request body schema can be provided explicitly by the user
102156
// as an inlined content[type].schema property.
103157
context('for fully-specified request body', () => {
@@ -245,8 +299,15 @@ describe('Validation at REST level', () => {
245299
.expect(422);
246300
}
247301

248-
async function givenAnAppAndAClient(controller: ControllerClass) {
302+
async function givenAnAppAndAClient(
303+
controller: ControllerClass,
304+
validationOptions?: RequestBodyValidationOptions,
305+
) {
249306
app = new RestApplication({rest: givenHttpServerConfig()});
307+
if (validationOptions)
308+
app
309+
.bind(RestBindings.REQUEST_BODY_PARSER_OPTIONS)
310+
.to({validation: validationOptions});
250311
app.controller(controller);
251312
await app.start();
252313

packages/rest/src/__tests__/unit/request-body.validator.test.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,14 @@
33
// This file is licensed under the MIT License.
44
// License text available at https://opensource.org/licenses/MIT
55

6-
import {expect} from '@loopback/testlab';
7-
import {validateRequestBody} from '../../validation/request-body.validator';
8-
import {RestHttpErrors} from '../../';
9-
import {aBodySpec} from '../helpers';
106
import {
117
ReferenceObject,
128
SchemaObject,
139
SchemasObject,
1410
} from '@loopback/openapi-v3-types';
11+
import {expect} from '@loopback/testlab';
12+
import {RestHttpErrors, validateRequestBody} from '../../';
13+
import {aBodySpec} from '../helpers';
1514

1615
const INVALID_MSG = RestHttpErrors.INVALID_REQUEST_BODY_MESSAGE;
1716

packages/rest/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export * from './rest.server';
2020
export * from './sequence';
2121
export * from './rest-http-error';
2222
export * from './parse-json';
23+
export * from './validation/request-body.validator';
2324

2425
// export all errors from external http-errors package
2526
import * as HttpErrors from 'http-errors';

packages/rest/src/parser.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@ import {RequestBody, RequestBodyParser} from './body-parsers';
1515
import {coerceParameter} from './coercion/coerce-parameter';
1616
import {RestHttpErrors} from './rest-http-error';
1717
import {ResolvedRoute} from './router';
18-
import {OperationArgs, PathParameterValues, Request} from './types';
18+
import {
19+
OperationArgs,
20+
PathParameterValues,
21+
Request,
22+
RequestBodyValidationOptions,
23+
} from './types';
1924
import {validateRequestBody} from './validation/request-body.validator';
2025
const debug = debugFactory('loopback:rest:parser');
2126

@@ -30,6 +35,7 @@ export async function parseOperationArgs(
3035
request: Request,
3136
route: ResolvedRoute,
3237
requestBodyParser: RequestBodyParser = new RequestBodyParser(),
38+
options: RequestBodyValidationOptions = {},
3339
): Promise<OperationArgs> {
3440
debug('Parsing operation arguments for route %s', route.describe());
3541
const operationSpec = route.spec;
@@ -44,6 +50,7 @@ export async function parseOperationArgs(
4450
pathParams,
4551
body,
4652
route.schemas,
53+
options,
4754
);
4855
}
4956

@@ -53,6 +60,7 @@ function buildOperationArguments(
5360
pathParams: PathParameterValues,
5461
body: RequestBody,
5562
globalSchemas: SchemasObject,
63+
options: RequestBodyValidationOptions = {},
5664
): OperationArgs {
5765
let requestBodyIndex = -1;
5866
if (operationSpec.requestBody) {
@@ -80,7 +88,7 @@ function buildOperationArguments(
8088
}
8189

8290
debug('Validating request body - value %j', body);
83-
validateRequestBody(body, operationSpec.requestBody, globalSchemas);
91+
validateRequestBody(body, operationSpec.requestBody, globalSchemas, options);
8492

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

packages/rest/src/providers/parse-params.provider.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {RequestBodyParser} from '../body-parsers';
88
import {RestBindings} from '../keys';
99
import {parseOperationArgs} from '../parser';
1010
import {ResolvedRoute} from '../router';
11-
import {ParseParams, Request} from '../types';
11+
import {ParseParams, Request, RequestBodyParserOptions} from '../types';
1212
/**
1313
* Provides the function for parsing args in requests at runtime.
1414
*
@@ -18,9 +18,16 @@ export class ParseParamsProvider implements Provider<ParseParams> {
1818
constructor(
1919
@inject(RestBindings.REQUEST_BODY_PARSER)
2020
private requestBodyParser: RequestBodyParser,
21+
@inject(RestBindings.REQUEST_BODY_PARSER_OPTIONS, {optional: true})
22+
private options: RequestBodyParserOptions = {},
2123
) {}
2224
value() {
2325
return (request: Request, route: ResolvedRoute) =>
24-
parseOperationArgs(request, route, this.requestBodyParser);
26+
parseOperationArgs(
27+
request,
28+
route,
29+
this.requestBodyParser,
30+
this.options.validation,
31+
);
2532
}
2633
}

packages/rest/src/types.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@
44
// License text available at https://opensource.org/licenses/MIT
55

66
import {Binding, BoundValue} from '@loopback/context';
7-
import {ResolvedRoute, RouteEntry} from './router';
8-
import {Request, Response} from 'express';
7+
import {ReferenceObject, SchemaObject} from '@loopback/openapi-v3-types';
8+
import * as ajv from 'ajv';
99
import {
10+
Options,
1011
OptionsJson,
11-
OptionsUrlencoded,
1212
OptionsText,
13-
Options,
13+
OptionsUrlencoded,
1414
} from 'body-parser';
15+
import {Request, Response} from 'express';
16+
import {ResolvedRoute, RouteEntry} from './router';
1517

1618
export {Request, Response};
1719

@@ -82,6 +84,20 @@ export type LogError = (
8284
request: Request,
8385
) => void;
8486

87+
/**
88+
* Options for request body validation using AJV
89+
*/
90+
export interface RequestBodyValidationOptions extends ajv.Options {
91+
/**
92+
* Custom cache for compiled schemas by AJV. This setting makes it possible
93+
* to skip the default cache.
94+
*/
95+
compiledSchemaCache?: WeakMap<
96+
SchemaObject | ReferenceObject,
97+
ajv.ValidateFunction
98+
>;
99+
}
100+
85101
/* eslint-disable @typescript-eslint/no-explicit-any */
86102

87103
/**
@@ -93,6 +109,7 @@ export interface RequestBodyParserOptions extends Options {
93109
urlencoded?: OptionsUrlencoded;
94110
text?: OptionsText;
95111
raw?: Options;
112+
validation?: RequestBodyValidationOptions;
96113
[name: string]: unknown;
97114
}
98115

packages/rest/src/validation/request-body.validator.ts

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,11 @@ import * as debugModule from 'debug';
1414
import * as _ from 'lodash';
1515
import * as util from 'util';
1616
import {HttpErrors, RequestBody, RestHttpErrors} from '..';
17+
import {RequestBodyValidationOptions} from '../types';
1718

1819
const toJsonSchema = require('openapi-schema-to-json-schema');
1920
const debug = debugModule('loopback:rest:validation');
2021

21-
export type RequestBodyValidationOptions = AJV.Options;
22-
2322
/**
2423
* Check whether the request body is valid according to the provided OpenAPI schema.
2524
* The JSON schema is generated from the OpenAPI schema which is typically defined
@@ -28,6 +27,7 @@ export type RequestBodyValidationOptions = AJV.Options;
2827
* @param body - The request body parsed from an HTTP request.
2928
* @param requestBodySpec - The OpenAPI requestBody specification defined in `@requestBody()`.
3029
* @param globalSchemas - The referenced schemas generated from `OpenAPISpec.components.schemas`.
30+
* @param options - Request body validation options for AJV
3131
*/
3232
export function validateRequestBody(
3333
body: RequestBody,
@@ -55,7 +55,7 @@ export function validateRequestBody(
5555
}
5656
if (!schema) return;
5757

58-
options = Object.assign({coerceTypes: body.coercionRequired}, options);
58+
options = Object.assign({coerceTypes: !!body.coercionRequired}, options);
5959
validateValueAgainstSchema(body.value, schema, globalSchemas, options);
6060
}
6161

@@ -76,29 +76,37 @@ function convertToJsonSchema(openapiSchema: SchemaObject) {
7676
return jsonSchema;
7777
}
7878

79+
/**
80+
* Built-in cache for complied schemas by AJV
81+
*/
82+
const DEFAULT_COMPILED_SCHEMA_CACHE = new WeakMap<
83+
SchemaObject | ReferenceObject,
84+
AJV.ValidateFunction
85+
>();
86+
7987
/**
8088
* Validate the request body data against JSON schema.
8189
* @param body - The request body data.
8290
* @param schema - The JSON schema used to perform the validation.
8391
* @param globalSchemas - Schema references.
92+
* @param options - Request body validation options.
8493
*/
85-
86-
const compiledSchemaCache = new WeakMap();
87-
8894
function validateValueAgainstSchema(
8995
// eslint-disable-next-line @typescript-eslint/no-explicit-any
9096
body: any,
9197
schema: SchemaObject | ReferenceObject,
92-
globalSchemas?: SchemasObject,
93-
options?: RequestBodyValidationOptions,
98+
globalSchemas: SchemasObject = {},
99+
options: RequestBodyValidationOptions = {},
94100
) {
95-
let validate;
101+
let validate: AJV.ValidateFunction;
102+
103+
const cache = options.compiledSchemaCache || DEFAULT_COMPILED_SCHEMA_CACHE;
96104

97-
if (compiledSchemaCache.has(schema)) {
98-
validate = compiledSchemaCache.get(schema);
105+
if (cache.has(schema)) {
106+
validate = DEFAULT_COMPILED_SCHEMA_CACHE.get(schema)!;
99107
} else {
100108
validate = createValidator(schema, globalSchemas, options);
101-
compiledSchemaCache.set(schema, validate);
109+
cache.set(schema, validate);
102110
}
103111

104112
if (validate(body)) {
@@ -133,7 +141,7 @@ function createValidator(
133141
schema: SchemaObject,
134142
globalSchemas?: SchemasObject,
135143
options?: RequestBodyValidationOptions,
136-
): Function {
144+
): AJV.ValidateFunction {
137145
const jsonSchema = convertToJsonSchema(schema);
138146

139147
const schemaWithRef = Object.assign({components: {}}, jsonSchema);

0 commit comments

Comments
 (0)