From 68bee475705d241253923ca1e780d42e44ae173c Mon Sep 17 00:00:00 2001 From: shimks Date: Thu, 26 Apr 2018 16:44:53 -0400 Subject: [PATCH] feat(validator): add in a JSON Schema validator decorator --- packages/openapi-v3/package.json | 2 + .../test/unit/json-to-schema.unit.ts | 2 +- packages/validator/.npmrc | 1 + packages/validator/LICENSE | 25 +++ packages/validator/README.md | 54 +++++ packages/validator/docs.json | 8 + packages/validator/index.d.ts | 6 + packages/validator/index.js | 6 + packages/validator/index.ts | 8 + packages/validator/package.json | 54 +++++ packages/validator/src/index.ts | 9 + packages/validator/src/validate.decorator.ts | 84 ++++++++ .../validate.decorator.acceptance.ts | 189 ++++++++++++++++++ .../validate.decorator.integration.ts | 38 ++++ packages/validator/tsconfig.build.json | 8 + 15 files changed, 493 insertions(+), 1 deletion(-) create mode 100644 packages/validator/.npmrc create mode 100644 packages/validator/LICENSE create mode 100644 packages/validator/README.md create mode 100644 packages/validator/docs.json create mode 100644 packages/validator/index.d.ts create mode 100644 packages/validator/index.js create mode 100644 packages/validator/index.ts create mode 100644 packages/validator/package.json create mode 100644 packages/validator/src/index.ts create mode 100644 packages/validator/src/validate.decorator.ts create mode 100644 packages/validator/test/acceptance/validate.decorator.acceptance.ts create mode 100644 packages/validator/test/integration/validate.decorator.integration.ts create mode 100644 packages/validator/tsconfig.build.json diff --git a/packages/openapi-v3/package.json b/packages/openapi-v3/package.json index 1954c2cad23f..02474c0dad9b 100644 --- a/packages/openapi-v3/package.json +++ b/packages/openapi-v3/package.json @@ -50,6 +50,8 @@ "@loopback/context": "^0.8.1", "@loopback/openapi-v3-types": "^0.5.0", "@loopback/repository-json-schema": "^0.7.0", + "@loopback/validator": "^0.1.0", + "@types/json-schema": "^6.0.1", "debug": "^3.1.0", "lodash": "^4.17.5" } diff --git a/packages/openapi-v3/test/unit/json-to-schema.unit.ts b/packages/openapi-v3/test/unit/json-to-schema.unit.ts index 0ae57c1faab6..343215eac83e 100644 --- a/packages/openapi-v3/test/unit/json-to-schema.unit.ts +++ b/packages/openapi-v3/test/unit/json-to-schema.unit.ts @@ -110,7 +110,7 @@ describe('jsonToSchemaObject', () => { }, default: 'Default string', }; - const expectedDef: SchemaObject = { + const expectedDef: JSONSchema = { title: 'foo', type: 'object', properties: { diff --git a/packages/validator/.npmrc b/packages/validator/.npmrc new file mode 100644 index 000000000000..43c97e719a5a --- /dev/null +++ b/packages/validator/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/validator/LICENSE b/packages/validator/LICENSE new file mode 100644 index 000000000000..ee0220d9255e --- /dev/null +++ b/packages/validator/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) IBM Corp. 2018. All Rights Reserved. +Node module: @loopback/validator +This project is licensed under the MIT License, full text below. + +-------- + +MIT license + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/validator/README.md b/packages/validator/README.md new file mode 100644 index 000000000000..49ee4165f171 --- /dev/null +++ b/packages/validator/README.md @@ -0,0 +1,54 @@ +# @loopback/validator + + +## Overview + + + + +## Installation + + + + + +## Basic use + + + + + + + + + + + + + + + + + + + + + + +## Contributions + +- [Guidelines](https://github.com/strongloop/loopback-next/blob/master/docs/CONTRIBUTING.md) +- [Join the team](https://github.com/strongloop/loopback-next/issues/110) + +## Tests + +Run `npm test` from the root folder. + +## Contributors + +See +[all contributors](https://github.com/strongloop/loopback-next/graphs/contributors). + +## License + +MIT diff --git a/packages/validator/docs.json b/packages/validator/docs.json new file mode 100644 index 000000000000..04013f4d6655 --- /dev/null +++ b/packages/validator/docs.json @@ -0,0 +1,8 @@ +{ + "content": [ + "index.ts", + "src/index.ts", + "src/build-schema.ts" + ], + "codeSectionDepth": 4 +} diff --git a/packages/validator/index.d.ts b/packages/validator/index.d.ts new file mode 100644 index 000000000000..dd9491bde49a --- /dev/null +++ b/packages/validator/index.d.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/validator +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './dist'; diff --git a/packages/validator/index.js b/packages/validator/index.js new file mode 100644 index 000000000000..13188fa905f1 --- /dev/null +++ b/packages/validator/index.js @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/validator +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +module.exports = require('./dist'); diff --git a/packages/validator/index.ts b/packages/validator/index.ts new file mode 100644 index 000000000000..7b3b949be01d --- /dev/null +++ b/packages/validator/index.ts @@ -0,0 +1,8 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/validator +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +// DO NOT EDIT THIS FILE +// Add any additional (re)exports to src/index.ts instead. +export * from './src'; diff --git a/packages/validator/package.json b/packages/validator/package.json new file mode 100644 index 000000000000..d48f6641e54f --- /dev/null +++ b/packages/validator/package.json @@ -0,0 +1,54 @@ +{ + "name": "@loopback/validator", + "version": "0.1.0", + "description": "", + "engines": { + "node": ">=8" + }, + "scripts": { + "build": "lb-tsc es2017", + "build:apidocs": "lb-apidocs", + "clean": "lb-clean loopback-validator*.tgz dist package api-docs", + "pretest": "npm run build", + "test": "lb-mocha \"DIST/test/unit/**/*.js\" \"DIST/test/integration/**/*.js\" \"DIST/test/acceptance/**/*.js\"", + "verify": "npm pack && tar xf validator*.tgz && tree package && npm run clean" + }, + "author": "IBM", + "license": "MIT", + "keywords": [ + "LoopBack", + "TypeScript", + "JSON Schema" + ], + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@loopback/metadata": "^0.6.0", + "@types/http-errors": "^1.6.1", + "@types/json-schema": "^6.0.1", + "ajv": "^6.4.0", + "debug": "^3.1.0", + "http-errors": "^1.6.3" + }, + "devDependencies": { + "@loopback/build": "^0.6.0", + "@loopback/repository": "^0.8.0", + "@loopback/repository-json-schema": "^0.6.0", + "@loopback/rest": "^0.8.0", + "@loopback/testlab": "^0.8.0", + "@types/debug": "0.0.30", + "@types/node": "^8.10.4" + }, + "files": [ + "README.md", + "index.js", + "index.d.ts", + "dist/src", + "src" + ], + "repository": { + "type": "git", + "url": "https://github.com/strongloop/loopback-next.git" + } +} diff --git a/packages/validator/src/index.ts b/packages/validator/src/index.ts new file mode 100644 index 000000000000..f58c210c971b --- /dev/null +++ b/packages/validator/src/index.ts @@ -0,0 +1,9 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/validator +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './validate.decorator'; + +import * as HttpErrors from 'http-errors'; +export {HttpErrors}; diff --git a/packages/validator/src/validate.decorator.ts b/packages/validator/src/validate.decorator.ts new file mode 100644 index 000000000000..5fd83b775502 --- /dev/null +++ b/packages/validator/src/validate.decorator.ts @@ -0,0 +1,84 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/validator +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {JSONSchema6} from 'json-schema'; +import * as AJV from 'ajv'; +import { + MetadataAccessor, + MetadataInspector, + ParameterDecoratorFactory, +} from '@loopback/metadata'; +import * as HttpErrors from 'http-errors'; + +const debug = require('debug')('loopback:validator'); + +// tslint:disable-next-line:no-any +export type Validator = (arg: any) => boolean | Promise; + +export const VALIDATION_KEY = MetadataAccessor.create( + 'validation.parameter', +); + +export function validatable() { + return function( + target: Object, + member: string, + // tslint:disable-next-line:no-any + descriptor: TypedPropertyDescriptor<(...args: any[]) => any>, + ) { + const originalMethod = descriptor.value; + if (!originalMethod) { + throw new Error( + 'No method found for some reason, this should not happen', + ); + } + // tslint:disable-next-line:no-any + descriptor.value = async function(...args: any[]) { + const ajv = new AJV(); + const schemas = MetadataInspector.getAllParameterMetadata( + VALIDATION_KEY, + target, + member, + )!; + for (let i = 0; i < args.length; i++) { + const schema = schemas[i]; + if (typeof schema === 'object') { + // is a JSON Schema + debug('validating %s against %o', args[i], schema); + const isValid = ajv.validate(schema, args[i]); + if (!isValid) { + throw new HttpErrors.UnprocessableEntity( + ajv.errorsText(ajv.errors, {dataVar: args[i]}), + ); + } + } else if (typeof schema === 'function') { + // is a validator function + debug('validating %s against %o', args[i], schema); + const isValid = await schema(args[i]); + if (!isValid) { + throw new HttpErrors.UnprocessableEntity( + `${args[i]} is not a valid argument`, + ); + } + } + } + // tslint:disable-next-line:no-invalid-this + return originalMethod.apply(this, args); + }; + // hacky way of bypassing rest parameter length issue; + // see DecoratorFactory.getNumberOfParameters in @loopback/context + Object.defineProperty(descriptor.value, 'length', { + value: originalMethod.length, + }); + return descriptor; + }; +} + +export function validate(schemaOrValidator: JSONSchema6 | Validator) { + return ParameterDecoratorFactory.createDecorator( + VALIDATION_KEY, + schemaOrValidator, + ); +} diff --git a/packages/validator/test/acceptance/validate.decorator.acceptance.ts b/packages/validator/test/acceptance/validate.decorator.acceptance.ts new file mode 100644 index 000000000000..6fb87e2b8cb8 --- /dev/null +++ b/packages/validator/test/acceptance/validate.decorator.acceptance.ts @@ -0,0 +1,189 @@ +import { + RestApplication, + get, + param, + RestServer, + post, + requestBody, +} from '@loopback/rest'; +import {validate, validatable} from '../..'; +import {supertest, createClientForHandler, expect} from '@loopback/testlab'; +import {model, property} from '@loopback/repository'; +import {getJsonSchema} from '@loopback/repository-json-schema'; + +describe('validate decorator', () => { + let app: RestApplication; + let server: RestServer; + let client: supertest.SuperTest; + + before(givenAnApplication); + before(givenAServer); + before(givenAClient); + before(async () => { + await app.start(); + }); + + after(async () => { + await app.stop(); + }); + + function greaterThan10(num: number) { + return num > 10; + } + + async function asyncGreaterThan10(num: number) { + return (await num) > 10; + } + + @model() + class TestModel { + @property() str: string; + @property() num: number; + } + + class TestController { + @get('/simple') + @validatable() + simple( + @param.query.string('str') + @validate({format: 'email'}) + str: string, + ) {} + + @get('/multiple/{num1}') + @validatable() + multiple( + @param.query.string('str') + @validate({format: 'email'}) + str: string, + @param.path.number('num1') + @validate({ + multipleOf: 5, + }) + num1: number, + @param.query.number('num2') + @validate({ + minimum: 7, + }) + num2: number, + ) {} + + @get('/select/{num1}') + @validatable() + select( + @param.query.string('str') + @validate({format: 'email'}) + str: string, + @param.path.number('num1') num1: number, + @param.query.number('num2') + @validate({ + minimum: 7, + }) + num2: number, + ) {} + + @post('/custom') + @validatable() + custom( + @requestBody() + @validate(getJsonSchema(TestModel)) + body: TestModel, + ) {} + + @get('/func') + @validatable() + func( + @param.query.number('num') + @validate(greaterThan10) + num: number, + ) {} + + @get('/async-func') + @validatable() + asyncFunc( + @param.query.number('num') + @validate(asyncGreaterThan10) + num: number, + ) {} + } + it('simple valid', async () => { + await client.get('/simple?str=foo@bar.com').expect(200); + }); + + it('simple invalid', async () => { + const res = await client.get('/simple?str=foo.bar').expect(422); + expect(res.body.message).to.match(/should match format "email"/); + }); + + it('multiple valid', async () => { + await client.get('/multiple/10?str=foo@bar.com&num2=7').expect(200); + }); + + it('multiple invalid', async () => { + const res = await client + .get('/multiple/10?str=foo@bar.com&num2=6') + .expect(422); + expect(res.body.message).to.match(/should be >= 7/); + }); + + it('select valid', async () => { + await client.get('/select/5?str=foo@bar.com&num2=7').expect(200); + }); + + it('select invalid', async () => { + const res = await client + .get('/select/5?str=foo@bar.com&num2=6') + .expect(422); + expect(res.body.message).to.match(/should be >= 7/); + }); + + it('custom valid', async () => { + await client + .post('/custom') + .send({ + str: 'testString', + num: 10, + }) + .expect(200); + }); + + it('custom invalid', async () => { + const res = await client + .post('/custom') + .send({ + str: 10, + num: 10, + }) + .expect(422); + expect(res.body.message).to.match(/should be string/); + }); + + it('function valid', async () => { + await client.get('/func?num=11').expect(200); + }); + + it('function invalid', async () => { + const res = await client.get('/func?num=10').expect(422); + expect(res.body.message).to.match(/is not a valid argument/); + }); + + it('async function valid', async () => { + await client.get('/func?num=11').expect(200); + }); + + it('async function invalid', async () => { + const res = await client.get('/func?num=10').expect(422); + expect(res.body.message).to.match(/is not a valid argument/); + }); + + function givenAnApplication() { + app = new RestApplication(); + app.controller(TestController); + } + async function givenAServer() { + server = await app.getServer(RestServer); + } + function givenAClient() { + client = createClientForHandler(server.requestHandler); + } +}); diff --git a/packages/validator/test/integration/validate.decorator.integration.ts b/packages/validator/test/integration/validate.decorator.integration.ts new file mode 100644 index 000000000000..549753b51412 --- /dev/null +++ b/packages/validator/test/integration/validate.decorator.integration.ts @@ -0,0 +1,38 @@ +import {validate, validatable, VALIDATION_KEY} from '../..'; +import {expect} from '@loopback/testlab'; +import {MetadataInspector} from '@loopback/metadata'; + +describe('validate', () => { + it('can be used to persist validation metadata', () => { + class TestClass { + testMethod(testParam: string) {} + } + const inst = new TestClass(); + validate({format: 'email'})(inst, 'testMethod', 0); + const meta = MetadataInspector.getAllParameterMetadata( + VALIDATION_KEY, + inst, + 'testMethod', + ); + expect(meta).to.containEql({format: 'email'}); + }); + it('can be used to create custom decorators', () => { + function emailValidator() { + return validate({format: 'email'}); + } + class EmailController { + @validatable() + createEmail(@emailValidator() email: string) { + return email; + } + } + + const ctrl = new EmailController(); + // expect(() => ctrl.createEmail('foobar')).to.throw( + // /should match format "email"/, + // ); + expect(ctrl.createEmail('foobar')).to.be.rejectedWith( + /should match format "email"/, + ); + }); +}); diff --git a/packages/validator/tsconfig.build.json b/packages/validator/tsconfig.build.json new file mode 100644 index 000000000000..3ffcd508d23e --- /dev/null +++ b/packages/validator/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "../build/config/tsconfig.common.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["index.ts", "src", "test"] +}