From 4a41c13b13c2a29e9eb2d9a67f511e784f0b9b50 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Mon, 12 Aug 2019 11:21:47 -0700 Subject: [PATCH] feat(rest): add support for ajv-keywords --- packages/rest/package-lock.json | 5 + packages/rest/package.json | 1 + .../validation/validation.acceptance.ts | 96 ++++++++++++++++++- packages/rest/src/types.ts | 6 ++ .../src/validation/request-body.validator.ts | 8 ++ 5 files changed, 115 insertions(+), 1 deletion(-) diff --git a/packages/rest/package-lock.json b/packages/rest/package-lock.json index 0d4f93f21fe9..90d66ea3a81a 100644 --- a/packages/rest/package-lock.json +++ b/packages/rest/package-lock.json @@ -191,6 +191,11 @@ "uri-js": "^4.2.2" } }, + "ajv-keywords": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.4.1.tgz", + "integrity": "sha512-RO1ibKvd27e6FEShVFfPALuHI3WjSVNeK5FIsmme/LYRNxjKuNj+Dt7bucLa6NdSv3JcVTyMlm9kGR84z1XpaQ==" + }, "append-field": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", diff --git a/packages/rest/package.json b/packages/rest/package.json index ca5c44854bf2..ba739100989d 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -32,6 +32,7 @@ "@types/serve-static": "1.13.2", "@types/type-is": "^1.6.2", "ajv": "^6.10.2", + "ajv-keywords": "^3.4.1", "body-parser": "^1.19.0", "cors": "^2.8.5", "debug": "^4.1.1", diff --git a/packages/rest/src/__tests__/acceptance/validation/validation.acceptance.ts b/packages/rest/src/__tests__/acceptance/validation/validation.acceptance.ts index d8e9b68f2538..c81c555b990e 100644 --- a/packages/rest/src/__tests__/acceptance/validation/validation.acceptance.ts +++ b/packages/rest/src/__tests__/acceptance/validation/validation.acceptance.ts @@ -38,7 +38,7 @@ describe('Validation at REST level', () => { @property({required: false, type: 'string', jsonSchema: {nullable: true}}) description?: string | null; - @property({required: true}) + @property({required: true, jsonSchema: {range: [0, 100]}}) price: number; constructor(data: Partial) { @@ -115,6 +115,7 @@ describe('Validation at REST level', () => { givenAnAppAndAClient(ProductController, { nullable: false, compiledSchemaCache: new WeakMap(), + ajvKeywords: ['range'], }), ); after(() => app.stop()); @@ -150,6 +151,99 @@ describe('Validation at REST level', () => { }, }); }); + + it('rejects requests with price out of range', async () => { + const DATA = { + name: 'iPhone', + description: 'iPhone', + price: 200, + }; + const res = await client + .post('/products') + .send(DATA) + .expect(422); + + expect(res.body).to.eql({ + error: { + statusCode: 422, + name: 'UnprocessableEntityError', + message: + 'The request body is invalid. See error object `details` property for more info.', + code: 'VALIDATION_FAILED', + details: [ + { + path: '.price', + code: 'maximum', + message: 'should be <= 100', + info: {comparison: '<=', limit: 100, exclusive: false}, + }, + { + path: '.price', + code: 'range', + message: 'should pass "range" keyword validation', + info: {keyword: 'range'}, + }, + ], + }, + }); + }); + }); + + context('with request body validation options - {ajvKeywords: true}', () => { + class ProductController { + @post('/products') + async create( + @requestBody({required: true}) data: Product, + ): Promise { + return new Product(data); + } + } + + before(() => + givenAnAppAndAClient(ProductController, { + nullable: false, + compiledSchemaCache: new WeakMap(), + $data: true, + ajvKeywords: true, + }), + ); + after(() => app.stop()); + + it('rejects requests with price out of range', async () => { + const DATA = { + name: 'iPhone', + description: 'iPhone', + price: 200, + }; + const res = await client + .post('/products') + .send(DATA) + .expect(422); + + expect(res.body).to.eql({ + error: { + statusCode: 422, + name: 'UnprocessableEntityError', + message: + 'The request body is invalid. See error object `details` property for more info.', + code: 'VALIDATION_FAILED', + details: [ + { + path: '.price', + code: 'maximum', + message: 'should be <= 100', + info: {comparison: '<=', limit: 100, exclusive: false}, + }, + { + path: '.price', + code: 'range', + message: 'should pass "range" keyword validation', + info: {keyword: 'range'}, + }, + ], + }, + }); + }); }); // A request body schema can be provided explicitly by the user diff --git a/packages/rest/src/types.ts b/packages/rest/src/types.ts index fd394d288ade..416fe500c63c 100644 --- a/packages/rest/src/types.ts +++ b/packages/rest/src/types.ts @@ -101,6 +101,12 @@ export interface RequestBodyValidationOptions extends ajv.Options { * to skip the default cache. */ compiledSchemaCache?: SchemaValidatorCache; + /** + * Enable additional AJV keywords from https://github.com/epoberezkin/ajv-keywords + * - `true`: Add all keywords from `ajv-keywords` + * - `string[]`: Add an array of keywords from `ajv-keywords` + */ + ajvKeywords?: true | string[]; } /* eslint-disable @typescript-eslint/no-explicit-any */ diff --git a/packages/rest/src/validation/request-body.validator.ts b/packages/rest/src/validation/request-body.validator.ts index 63f3841b3c60..9763ce7c6576 100644 --- a/packages/rest/src/validation/request-body.validator.ts +++ b/packages/rest/src/validation/request-body.validator.ts @@ -19,6 +19,8 @@ import {RequestBodyValidationOptions, SchemaValidatorCache} from '../types'; const toJsonSchema = require('openapi-schema-to-json-schema'); const debug = debugModule('loopback:rest:validation'); +const ajvKeywords = require('ajv-keywords'); + /** * Check whether the request body is valid according to the provided OpenAPI schema. * The JSON schema is generated from the OpenAPI schema which is typically defined @@ -183,5 +185,11 @@ function createValidator( debug('AJV options', options); const ajv = new AJV(options); + if (options.ajvKeywords === true) { + ajvKeywords(ajv); + } else if (Array.isArray(options.ajvKeywords)) { + ajvKeywords(ajv, options.ajvKeywords); + } + return ajv.compile(schemaWithRef); }