Skip to content

Commit

Permalink
feat(rest): improve Ajv validation to allow extensions of keywords an…
Browse files Browse the repository at this point in the history
…d formats
  • Loading branch information
raymondfeng committed Mar 30, 2020
1 parent 38be23f commit afdee34
Show file tree
Hide file tree
Showing 9 changed files with 350 additions and 215 deletions.
190 changes: 190 additions & 0 deletions packages/rest/src/__tests__/unit/ajv-factory.provider.unit.ts
@@ -0,0 +1,190 @@
// Copyright IBM Corp. 2020. 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 {expect} from '@loopback/testlab';
import {RestBindings} from '../..';
import {RestTags} from '../../keys';
import {AjvFormat, AjvKeyword} from '../../types';
import {AjvFactoryProvider} from '../../validation/ajv-factory.provider';

describe('Ajv factory', () => {
let ctx: Context;

beforeEach(givenContext);

it('allows binary format by default', async () => {
const ajvFactory = await ctx.get(RestBindings.AJV_FACTORY);
const validator = ajvFactory().compile({type: 'string', format: 'binary'});
const result = await validator('ABC123');
expect(result).to.be.true();
});

it('honors request body parser options', async () => {
ctx
.bind(RestBindings.REQUEST_BODY_PARSER_OPTIONS)
.to({validation: {unknownFormats: ['gmail']}});
const ajvFactory = await ctx.get(RestBindings.AJV_FACTORY);
const validator = ajvFactory().compile({type: 'string', format: 'gmail'});
const result = await validator('example@gmail.com');
expect(result).to.be.true();
});

it('honors extra options', async () => {
ctx.bind(RestBindings.REQUEST_BODY_PARSER_OPTIONS).to({validation: {}});
const ajvFactory = await ctx.get(RestBindings.AJV_FACTORY);
const validator = ajvFactory({coerceTypes: true}).compile({type: 'number'});
const result = await validator('123');
expect(result).to.be.true();
});

it('accepts request body parser options via constructor', async () => {
const ajvFactory = new AjvFactoryProvider({
unknownFormats: ['gmail'],
}).value();
const validator = ajvFactory().compile({type: 'string', format: 'gmail'});
const result = await validator('example@gmail.com');
expect(result).to.be.true();
});

// possible values for type any
const TEST_VALUES = {
string: 'abc',
number: 123,
object: {random: 'random'},
array: [1, 2, 3],
null: null,
};

context('accepts any type with schema {}', () => {
for (const v in TEST_VALUES) {
testAnyTypeWith(v);
}

function testAnyTypeWith(value: string) {
it(`with value ${value}`, async () => {
const ajvFactory = new AjvFactoryProvider().value();
const validator = ajvFactory().compile({});
const result = await validator(value);
expect(result).to.be.true();
});
}
});

context('accepts any type with schema {} - property', () => {
for (const v in TEST_VALUES) {
testAnyTypeWith(v);
}

function testAnyTypeWith(value: string) {
it(`with value ${value}`, async () => {
const ajvFactory = new AjvFactoryProvider().value();
const validator = ajvFactory().compile({
type: 'object',
properties: {
name: {type: 'string'},
arbitraryProp: {},
},
});
const result = await validator({
name: 'Zoe',
arbitraryProp: value,
});
expect(result).to.be.true();
});
}
});

context('accepts any type with schema true', () => {
for (const v in TEST_VALUES) {
testAnyTypeWith(v);
}

function testAnyTypeWith(value: string) {
it(`with value ${value}`, async () => {
const ajvFactory = new AjvFactoryProvider().value();
const validator = ajvFactory().compile(true);
const result = await validator(value);
expect(result).to.be.true();
});
}
});

context('accepts any type with schema true - property', () => {
for (const v in TEST_VALUES) {
testAnyTypeWith(v);
}

function testAnyTypeWith(value: string) {
it(`with value ${value}`, async () => {
const ajvFactory = new AjvFactoryProvider().value();
const validator = ajvFactory().compile({
type: 'object',
properties: {
name: {type: 'string'},
arbitraryProp: true,
},
});
const result = await validator({
name: 'Zoe',
arbitraryProp: value,
});
expect(result).to.be.true();
});
}
});

it('reports unknown format', async () => {
const ajvFactory = await ctx.get(RestBindings.AJV_FACTORY);
expect(() =>
ajvFactory().compile({type: 'string', format: 'gmail'}),
).to.throw(/unknown format "gmail" is used in schema/);
});

it('honors keyword extensions', async () => {
ctx
.bind<AjvKeyword>('ajv.keywords.smallNumber')
.to({
name: 'smallNumber',
type: 'number',
validate: (schema: unknown, data: number) => {
// The number is smaller than 10
return data < 10;
},
})
.tag(RestTags.AJV_KEYWORD);
const ajvFactory = await ctx.get(RestBindings.AJV_FACTORY);
const validator = ajvFactory().compile({type: 'number', smallNumber: true});
let result = await validator(1);
expect(result).to.be.true();
result = await validator(20);
expect(result).to.be.false();
});

it('honors format extensions', async () => {
ctx
.bind<AjvFormat>('ajv.formats.int')
.to({
name: 'int',
type: 'number',
validate: (data: number) => {
// The number does not have a decimal point
return !String(data).includes('.');
},
})
.tag(RestTags.AJV_FORMAT);
const ajvFactory = await ctx.get(RestBindings.AJV_FACTORY);
const validator = ajvFactory().compile({type: 'number', format: 'int'});
let result = await validator(1);
expect(result).to.be.true();
result = await validator(1.5);
expect(result).to.be.false();
});

function givenContext() {
ctx = new Context();
ctx.bind(RestBindings.AJV_FACTORY).toProvider(AjvFactoryProvider);
}
});
140 changes: 0 additions & 140 deletions packages/rest/src/__tests__/unit/ajv.service.unit.ts

This file was deleted.

11 changes: 11 additions & 0 deletions packages/rest/src/keys.ts
Expand Up @@ -15,6 +15,7 @@ import {RestServer} from './rest.server';
import {RestRouter, RestRouterOptions} from './router';
import {SequenceHandler} from './sequence';
import {
AjvFactory,
BindElement,
FindRoute,
GetFromContext,
Expand Down Expand Up @@ -152,6 +153,13 @@ export namespace RestBindings {
bodyParserBindingKey('StreamBodyParser'),
);

/**
* Binding key for AJV
*/
export const AJV_FACTORY = BindingKey.create<AjvFactory>(
bodyParserBindingKey('rest.ajvFactory'),
);

/**
* Binding key for setting and injecting an OpenAPI spec
*/
Expand Down Expand Up @@ -277,4 +285,7 @@ export namespace RestTags {
* binding key
*/
export const CONTROLLER_BINDING = 'controllerBinding';

export const AJV_KEYWORD = 'ajvKeyword';
export const AJV_FORMAT = 'ajvFormat';
}

0 comments on commit afdee34

Please sign in to comment.