From e5119da46db7d6cee8ce361ee8c68e34fa57080e Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Wed, 31 Jan 2018 17:08:38 -0800 Subject: [PATCH 1/5] feat: add @loopback/types to be the typing system --- packages/types/.npmrc | 1 + packages/types/LICENSE | 25 + packages/types/README.md | 32 ++ packages/types/docs.json | 4 + packages/types/index.d.ts | 6 + packages/types/index.js | 7 + packages/types/index.ts | 8 + packages/types/package.json | 53 ++ packages/types/src/common-types.ts | 52 ++ packages/types/src/handlers/any.ts | 38 ++ packages/types/src/handlers/array.ts | 53 ++ packages/types/src/handlers/boolean.ts | 35 ++ packages/types/src/handlers/buffer.ts | 53 ++ packages/types/src/handlers/date.ts | 61 +++ packages/types/src/handlers/index.ts | 47 ++ packages/types/src/handlers/number.ts | 42 ++ packages/types/src/handlers/object.ts | 53 ++ packages/types/src/handlers/string.ts | 42 ++ packages/types/src/handlers/type.ts | 51 ++ packages/types/src/handlers/union.ts | 55 ++ packages/types/src/index.ts | 8 + packages/types/src/mixin.ts | 57 ++ packages/types/test/unit/mixin.test.ts | 78 +++ .../types/test/unit/type-handlers.test.ts | 497 ++++++++++++++++++ packages/types/tsconfig.build.json | 8 + 25 files changed, 1366 insertions(+) create mode 100644 packages/types/.npmrc create mode 100644 packages/types/LICENSE create mode 100644 packages/types/README.md create mode 100644 packages/types/docs.json create mode 100644 packages/types/index.d.ts create mode 100644 packages/types/index.js create mode 100644 packages/types/index.ts create mode 100644 packages/types/package.json create mode 100644 packages/types/src/common-types.ts create mode 100644 packages/types/src/handlers/any.ts create mode 100644 packages/types/src/handlers/array.ts create mode 100644 packages/types/src/handlers/boolean.ts create mode 100644 packages/types/src/handlers/buffer.ts create mode 100644 packages/types/src/handlers/date.ts create mode 100644 packages/types/src/handlers/index.ts create mode 100644 packages/types/src/handlers/number.ts create mode 100644 packages/types/src/handlers/object.ts create mode 100644 packages/types/src/handlers/string.ts create mode 100644 packages/types/src/handlers/type.ts create mode 100644 packages/types/src/handlers/union.ts create mode 100644 packages/types/src/index.ts create mode 100644 packages/types/src/mixin.ts create mode 100644 packages/types/test/unit/mixin.test.ts create mode 100644 packages/types/test/unit/type-handlers.test.ts create mode 100644 packages/types/tsconfig.build.json diff --git a/packages/types/.npmrc b/packages/types/.npmrc new file mode 100644 index 000000000000..43c97e719a5a --- /dev/null +++ b/packages/types/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/types/LICENSE b/packages/types/LICENSE new file mode 100644 index 000000000000..004a114ac2c9 --- /dev/null +++ b/packages/types/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) IBM Corp. 2017,2018. All Rights Reserved. +Node module: @loopback/types +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/types/README.md b/packages/types/README.md new file mode 100644 index 000000000000..4f248d1b447d --- /dev/null +++ b/packages/types/README.md @@ -0,0 +1,32 @@ +# @loopback/types + +This module contains the typing system for LoopBack 4 to handle: + +- Types for models and properties +- Types for method parameters/return + +## Installation + +``` +$ npm install --save @loopback/metadata +``` + +## Contributions + +IBM/StrongLoop is an active supporter of open source and welcomes contributions +to our projects as well as those of the Node.js community in general. For more +information on how to contribute please refer to the +[Contribution Guide](https://loopback.io/doc/en/contrib/index.html). + +## 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/types/docs.json b/packages/types/docs.json new file mode 100644 index 000000000000..6da56462be5f --- /dev/null +++ b/packages/types/docs.json @@ -0,0 +1,4 @@ +{ + "content": ["index.ts", "src/index.ts"], + "codeSectionDepth": 4 +} diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts new file mode 100644 index 000000000000..8b01933d37d9 --- /dev/null +++ b/packages/types/index.d.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/types +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './dist'; diff --git a/packages/types/index.js b/packages/types/index.js new file mode 100644 index 000000000000..32536a645319 --- /dev/null +++ b/packages/types/index.js @@ -0,0 +1,7 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/types +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +const nodeMajorVersion = +process.versions.node.split('.')[0]; +module.exports = nodeMajorVersion >= 7 ? require('./dist') : require('./dist6'); diff --git a/packages/types/index.ts b/packages/types/index.ts new file mode 100644 index 000000000000..6c262162a340 --- /dev/null +++ b/packages/types/index.ts @@ -0,0 +1,8 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/types +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +// DO NOT EDIT THIS FILE +// Add any aditional (re)exports to src/index.ts instead. +export * from './src'; diff --git a/packages/types/package.json b/packages/types/package.json new file mode 100644 index 000000000000..658f049d7912 --- /dev/null +++ b/packages/types/package.json @@ -0,0 +1,53 @@ +{ + "name": "@loopback/types", + "version": "4.0.0-alpha.8", + "description": "LoopBack's types utilities for reflection and decoration", + "engines": { + "node": ">=6" + }, + "scripts": { + "acceptance": "lb-mocha \"DIST/test/acceptance/**/*.js\"", + "build": "npm run build:dist && npm run build:dist6", + "build:current": "lb-tsc", + "build:dist": "lb-tsc es2017", + "build:dist6": "lb-tsc es2015", + "build:apidocs": "lb-apidocs", + "clean": "lb-clean loopback-types*.tgz dist dist6 package api-docs", + "prepublishOnly": "npm run build && npm run build:apidocs", + "pretest": "npm run build:current", + "test": "lb-mocha \"DIST/test/unit/**/*.js\" \"DIST/test/acceptance/**/*.js\"", + "unit": "lb-mocha \"DIST/test/unit/**/*.js\"", + "verify": "npm pack && tar xf loopback-types*.tgz && tree package && npm run clean" + }, + "author": "IBM", + "copyright.owner": "IBM Corp.", + "license": "MIT", + "dependencies": { + "debug": "^3.1.0" + }, + "devDependencies": { + "@loopback/build": "^4.0.0-alpha.12", + "@loopback/testlab": "^4.0.0-alpha.22", + "@types/debug": "^0.0.30" + }, + "keywords": [ + "LoopBack", + "Types" + ], + "files": [ + "README.md", + "index.js", + "index.d.ts", + "dist/src", + "dist6/src", + "api-docs", + "src" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/strongloop/loopback-next.git" + } +} diff --git a/packages/types/src/common-types.ts b/packages/types/src/common-types.ts new file mode 100644 index 000000000000..1252e89806d9 --- /dev/null +++ b/packages/types/src/common-types.ts @@ -0,0 +1,52 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/types +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +/** + * Common types/interfaces such as Class/Constructor/Options/Callback + */ +// tslint:disable:no-any + +/** + * Interface for classes with `new` operator and static properties/methods + */ +export interface Class { + // new MyClass(...args) ==> T + new (...args: any[]): T; + // Other static properties/operations + [property: string]: any; +} + +/** + * Interface for constructor functions without `new` operator, for example, + * ``` + * function Foo(x) { + * if (!(this instanceof Foo)) { return new Foo(x); } + * this.x = x; + * } + * ``` + */ +export interface ConstructorFunction { + (...args: any[]): T; +} + +/** + * Constructor type - class or function + */ +export type Constructor = Class | ConstructorFunction; + +/** + * Objects with open properties + */ +export interface AnyObject { + [property: string]: any; +} + +export type Options = AnyObject | undefined; + +/** + * Same as Partial but goes deeper and makes Partial all its properties + * and sub-properties. + */ +export type DeepPartial = {[P in keyof T]?: DeepPartial}; diff --git a/packages/types/src/handlers/any.ts b/packages/types/src/handlers/any.ts new file mode 100644 index 000000000000..11f83d8a1ad4 --- /dev/null +++ b/packages/types/src/handlers/any.ts @@ -0,0 +1,38 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/types +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Type} from './type'; + +// tslint:disable:no-any + +/** + * Any type + */ +export class AnyType implements Type { + readonly name = 'any'; + + isInstance(value: any) { + return true; + } + + isCoercible(value: any) { + return true; + } + + defaultValue(): any { + return undefined; + } + + coerce(value: any) { + return value; + } + + serialize(value: any) { + if (value && typeof value.toJSON === 'function') { + return value.toJSON(); + } + return value; + } +} diff --git a/packages/types/src/handlers/array.ts b/packages/types/src/handlers/array.ts new file mode 100644 index 000000000000..0fb75cafe35a --- /dev/null +++ b/packages/types/src/handlers/array.ts @@ -0,0 +1,53 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/types +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Type} from './type'; +import * as util from 'util'; + +// tslint:disable:no-any + +/** + * Array type, such as string[] + */ +export class ArrayType implements Type> { + constructor(public itemType: Type) {} + + readonly name = 'array'; + + isInstance(value: any) { + if (value == null) return true; + if (!Array.isArray(value)) { + return false; + } + const list = value as Array; + return list.every(i => this.itemType.isInstance(i)); + } + + isCoercible(value: any): boolean { + if (value == null) return true; + if (!Array.isArray(value)) { + return false; + } + return value.every(i => this.itemType.isCoercible(i)); + } + + defaultValue(): Array { + return []; + } + + coerce(value: any) { + if (value == null) return value; + if (!Array.isArray(value)) { + const msg = util.format('Invalid %s: %j', this.name, value); + throw new TypeError(msg); + } + return value.map(i => this.itemType.coerce(i)); + } + + serialize(value: Array | null | undefined) { + if (value == null) return value; + return value.map(i => this.itemType.serialize(i)); + } +} diff --git a/packages/types/src/handlers/boolean.ts b/packages/types/src/handlers/boolean.ts new file mode 100644 index 000000000000..424767cfe19d --- /dev/null +++ b/packages/types/src/handlers/boolean.ts @@ -0,0 +1,35 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/types +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Type} from './type'; + +// tslint:disable:no-any + +/** + * Boolean type + */ +export class BooleanType implements Type { + readonly name = 'boolean'; + + isInstance(value: any) { + return value == null || typeof value === 'boolean'; + } + + defaultValue() { + return false; + } + + isCoercible(value: any): boolean { + return true; + } + + coerce(value: any) { + return value == null ? value : Boolean(value); + } + + serialize(value: boolean | null | undefined) { + return value; + } +} diff --git a/packages/types/src/handlers/buffer.ts b/packages/types/src/handlers/buffer.ts new file mode 100644 index 000000000000..992f3cb07a9a --- /dev/null +++ b/packages/types/src/handlers/buffer.ts @@ -0,0 +1,53 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/types +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import * as util from 'util'; +import {Type} from './type'; +import {Options} from '../common-types'; + +// tslint:disable:no-any + +/** + * Buffer (binary) type + */ +export class BufferType implements Type { + readonly name = 'buffer'; + + isInstance(value: any) { + return value == null || Buffer.isBuffer(value); + } + + defaultValue() { + return Buffer.from([]); + } + + isCoercible(value: any): boolean { + if (value == null) return true; + if (typeof value === 'string') return true; + if (Buffer.isBuffer(value)) return true; + if (Array.isArray(value)) return true; + return false; + } + + coerce(value: any, options?: Options) { + if (value == null) return value; + if (Buffer.isBuffer(value)) return value as Buffer; + if (typeof value === 'string') { + options = options || {}; + const encoding = options.encoding || 'utf-8'; + return Buffer.from(value, encoding); + } else if (Array.isArray(value)) { + return Buffer.from(value); + } + const msg = util.format('Invalid %s: %j', this.name, value); + throw new TypeError(msg); + } + + serialize(value: Buffer | null | undefined, options?: Options) { + if (value == null) return value; + const encoding = (options && options.encoding) || 'base64'; + return value.toString(encoding); + } +} diff --git a/packages/types/src/handlers/date.ts b/packages/types/src/handlers/date.ts new file mode 100644 index 000000000000..773e959923ef --- /dev/null +++ b/packages/types/src/handlers/date.ts @@ -0,0 +1,61 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/types +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import * as util from 'util'; +import {Type} from './type'; + +// tslint:disable:no-any + +/** + * Date type + */ +export class DateType implements Type { + readonly name = 'date'; + + isInstance(value: any) { + return value == null || value instanceof Date; + } + + isCoercible(value: any): boolean { + // Please note new Date(...) allows the following + /* + > new Date('1') + 2001-01-01T08:00:00.000Z + > new Date('0') + 2000-01-01T08:00:00.000Z + > new Date(1) + 1970-01-01T00:00:00.001Z + > new Date(0) + 1970-01-01T00:00:00.000Z + > new Date(true) + 1970-01-01T00:00:00.001Z + > new Date(false) + 1970-01-01T00:00:00.000Z + */ + return value == null || !isNaN(new Date(value).getTime()); + } + + defaultValue() { + return new Date(); + } + + coerce(value: any) { + if (value == null) return value; + if (value instanceof Date) { + return value; + } + const d = new Date(value); + if (isNaN(d.getTime())) { + const msg = util.format('Invalid %s: %j', this.name, value); + throw new TypeError(msg); + } + return d; + } + + serialize(value: Date | null | undefined) { + if (value == null) return value; + return value.toJSON(); + } +} diff --git a/packages/types/src/handlers/index.ts b/packages/types/src/handlers/index.ts new file mode 100644 index 000000000000..f51d8c4da142 --- /dev/null +++ b/packages/types/src/handlers/index.ts @@ -0,0 +1,47 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/types +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +/** + * Built-in types for LoopBack modeling + * - Type: abstract base type + * - StringType: string + * - BooleanType: boolean + * - NumberType: number + * - DateType: Date + * - BufferType: Buffer + * - AnyType: any + * - ArrayType: Array + * - UnionType: Union of types + */ +import {Type} from './type'; +import {StringType} from './string'; +import {BooleanType} from './boolean'; +import {NumberType} from './number'; +import {DateType} from './date'; +import {BufferType} from './buffer'; +import {AnyType} from './any'; +import {ArrayType} from './array'; +import {UnionType} from './union'; +import {ObjectType} from './object'; + +export { + Type, + StringType, + BooleanType, + NumberType, + DateType, + BufferType, + AnyType, + ArrayType, + UnionType, + ObjectType, +}; + +export const STRING = new StringType(); +export const BOOLEAN = new BooleanType(); +export const NUMBER = new NumberType(); +export const DATE = new DateType(); +export const BUFFER = new BufferType(); +export const ANY = new AnyType(); diff --git a/packages/types/src/handlers/number.ts b/packages/types/src/handlers/number.ts new file mode 100644 index 000000000000..9f403c390bb8 --- /dev/null +++ b/packages/types/src/handlers/number.ts @@ -0,0 +1,42 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/types +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import * as util from 'util'; +import {Type} from './type'; + +// tslint:disable:no-any + +/** + * Number type + */ +export class NumberType implements Type { + readonly name = 'number'; + + isInstance(value: any) { + return value == null || (!isNaN(value) && typeof value === 'number'); + } + + isCoercible(value: any): boolean { + return value == null || !isNaN(Number(value)); + } + + defaultValue() { + return 0; + } + + coerce(value: any) { + if (value == null) return value; + const n = Number(value); + if (isNaN(n)) { + const msg = util.format('Invalid %s: %j', this.name, value); + throw new TypeError(msg); + } + return n; + } + + serialize(value: number | null | undefined) { + return value; + } +} diff --git a/packages/types/src/handlers/object.ts b/packages/types/src/handlers/object.ts new file mode 100644 index 000000000000..19acafd1a7a9 --- /dev/null +++ b/packages/types/src/handlers/object.ts @@ -0,0 +1,53 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/types +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import * as util from 'util'; +import {Class, AnyObject} from '../common-types'; +import {Type} from './type'; + +// tslint:disable:no-any + +/** + * Object type + */ +export class ObjectType implements Type { + name = 'object'; + + constructor(public type: Class) {} + + isInstance(value: any) { + return value == null || value instanceof this.type; + } + + isCoercible(value: any): boolean { + return ( + value == null || (typeof value === 'object' && !Array.isArray(value)) + ); + } + + defaultValue() { + return new this.type(); + } + + coerce(value: any) { + if (value == null) return value; + if (value instanceof this.type) { + return value; + } + if (typeof value !== 'object' || Array.isArray(value)) { + const msg = util.format('Invalid %s: %j', this.name, value); + throw new TypeError(msg); + } + return new this.type(value); + } + + serialize(value: T | null | undefined) { + if (value == null) return value; + if (typeof value.toJSON === 'function') { + return value.toJSON(); + } + return value; + } +} diff --git a/packages/types/src/handlers/string.ts b/packages/types/src/handlers/string.ts new file mode 100644 index 000000000000..66a3847656f4 --- /dev/null +++ b/packages/types/src/handlers/string.ts @@ -0,0 +1,42 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/types +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Type} from './type'; + +// tslint:disable:no-any + +/** + * String type + */ +export class StringType implements Type { + readonly name = 'string'; + + isInstance(value: any): boolean { + return value == null || typeof value === 'string'; + } + + isCoercible(value: any): boolean { + return true; + } + + defaultValue(): string { + return ''; + } + + coerce(value: any): string { + if (value == null) return value; + if (typeof value.toJSON === 'function') { + value = value.toJSON(); + } + if (typeof value === 'object') { + return JSON.stringify(value); + } + return String(value); + } + + serialize(value: string | null | undefined) { + return value; + } +} diff --git a/packages/types/src/handlers/type.ts b/packages/types/src/handlers/type.ts new file mode 100644 index 000000000000..589ed8c33c78 --- /dev/null +++ b/packages/types/src/handlers/type.ts @@ -0,0 +1,51 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/types +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +/** + * Typing system for LoopBack + */ +import {Options} from '../common-types'; + +// tslint:disable:no-any + +export interface Type { + /** + * Name of the type + */ + name: string; + + /** + * Test if the given value is an instance of this type + * @param value The value + */ + isInstance(value: any): boolean; + + /** + * Generate the default value for this type + */ + defaultValue(): T | null | undefined; + + /** + * Check if the given value can be coerced into this type + * @param value The value to to be coerced + * @returns {boolean} + */ + isCoercible(value: any, options?: Options): boolean; + + /** + * Coerce the value into this type + * @param value The value to be coerced + * @param options Options for coercion + * @returns Coerced value of this type + */ + coerce(value: any, options?: Options): T | null | undefined; + + /** + * Serialize a value into json + * @param value The value of this type + * @param options Options for serialization + */ + serialize(value: T | null | undefined, options?: Options): any; +} diff --git a/packages/types/src/handlers/union.ts b/packages/types/src/handlers/union.ts new file mode 100644 index 000000000000..cc7c4bb34737 --- /dev/null +++ b/packages/types/src/handlers/union.ts @@ -0,0 +1,55 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/types +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import * as util from 'util'; +import {Type} from './type'; + +// tslint:disable:no-any + +/** + * Union type, such as string | number + */ +export class UnionType implements Type { + constructor(public itemTypes: Type[]) {} + + readonly name = 'union'; + + isInstance(value: any) { + return this.itemTypes.some(t => t.isInstance(value)); + } + + isCoercible(value: any) { + return this.itemTypes.some(t => t.isCoercible(value)); + } + + defaultValue() { + return this.itemTypes[0].defaultValue(); + } + + coerce(value: any) { + // First find instances + for (const type of this.itemTypes) { + if (type.isInstance(value)) { + return type.coerce(value); + } + } + // Try coercible + for (const type of this.itemTypes) { + if (type.isCoercible(value)) { + return type.coerce(value); + } + } + const msg = util.format('Invalid %s: %j', this.name, value); + throw new TypeError(msg); + } + + serialize(value: any) { + for (const type of this.itemTypes) { + if (type.isInstance(value)) { + return type.serialize(value); + } + } + } +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts new file mode 100644 index 000000000000..dcb9f10d0420 --- /dev/null +++ b/packages/types/src/index.ts @@ -0,0 +1,8 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/types +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './common-types'; +export * from './mixin'; +export * from './handlers'; diff --git a/packages/types/src/mixin.ts b/packages/types/src/mixin.ts new file mode 100644 index 000000000000..e8cc4eeddf26 --- /dev/null +++ b/packages/types/src/mixin.ts @@ -0,0 +1,57 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/types +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Class} from './common-types'; + +// tslint:disable:no-any + +/** + * Interface for functions that can mix properties/methods into a base class + * + * For example, + * ``` + * var calculatorMixin = Base => class extends Base { + * calc() { } + * }; + * + * function timestampMixin(Base) { + * return class extends Base { + * created: Date = new Date(); + * modified: Date = new Date(); + * } + * } + * ``` + * See http://justinfagnani.com/2015/12/21/real-mixins-with-javascript-classes/. + */ +export interface MixinFunc { + >(baseClass: BC): BC; +} + +/** + * A builder to compose mixins + */ +export class MixinBuilder { + /** + * Constructor for MixinBuilder + * @param baseClass The base class + */ + constructor(public baseClass: Class) {} + + /** + * Apply one or more mixin functions + * @param mixins An array of mixin functions + */ + with(...mixins: MixinFunc[]) { + return mixins.reduce((c, mixin) => mixin(c), this.baseClass); + } + + /** + * Create an instance of MixinBuilder with the base class + * @param baseClass The base class + */ + static mix(baseClass: Class) { + return new MixinBuilder(baseClass); + } +} diff --git a/packages/types/test/unit/mixin.test.ts b/packages/types/test/unit/mixin.test.ts new file mode 100644 index 000000000000..0069bdcd4a2d --- /dev/null +++ b/packages/types/test/unit/mixin.test.ts @@ -0,0 +1,78 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/types +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import {Class, MixinBuilder} from '../../'; + +// tslint:disable:no-any + +class BaseClass { + baseProp: string = 'baseProp'; + + static staticMethod1(): string { + return 'static'; + } + + method1() {} +} + +function Mixin1>(superClass: T) { + return class extends superClass { + mixinProp1: string = 'mixinProp1'; + + static staticMixinMethod1(): string { + return 'mixin1.static'; + } + + mixinMethod1(): string { + return 'mixin1'; + } + }; +} + +function Mixin2>(superClass: T) { + return class extends superClass { + mixinProp2: string = 'mixinProp2'; + + static staticMixinMethod2(): string { + return 'mixin2.static'; + } + + mixinMethod2() { + return 'mixin2'; + } + }; +} + +describe('mixin builder', () => { + let newClass: any; + before(() => { + newClass = MixinBuilder.mix(BaseClass).with(Mixin1, Mixin2); + }); + + it('allows multiple classes to be mixed in', () => { + expect(newClass.staticMethod1).to.eql(BaseClass.staticMethod1); + expect(newClass.prototype.method1).to.eql(BaseClass.prototype.method1); + expect(newClass.staticMixinMethod1).to.not.null(); + expect(newClass.staticMixinMethod2).to.not.null(); + + expect(newClass.staticMethod1()).to.eql('static'); + expect(newClass.staticMixinMethod1()).to.eql('mixin1.static'); + expect(newClass.staticMixinMethod2()).to.eql('mixin2.static'); + + const x = new newClass(); + expect(typeof x.mixinMethod1).to.eql('function'); + expect(typeof x.mixinMethod2).to.eql('function'); + expect(x.mixinMethod1()).to.eql('mixin1'); + expect(x.mixinMethod2()).to.eql('mixin2'); + expect(x.mixinProp1).to.eql('mixinProp1'); + expect(x.mixinProp2).to.eql('mixinProp2'); + }); + + it('allows inheritance', () => { + const x = new newClass(); + expect(x instanceof BaseClass).to.true(); + }); +}); diff --git a/packages/types/test/unit/type-handlers.test.ts b/packages/types/test/unit/type-handlers.test.ts new file mode 100644 index 000000000000..8bf31ef7a175 --- /dev/null +++ b/packages/types/test/unit/type-handlers.test.ts @@ -0,0 +1,497 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/types +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import * as types from '../../'; + +describe('types', () => { + describe('string', () => { + const stringType = new types.StringType(); + it('checks isInstance', () => { + expect(stringType.isInstance('str')).to.be.true(); + expect(stringType.isInstance(null)).to.be.true(); + expect(stringType.isInstance(undefined)).to.be.true(); + expect(stringType.isInstance(true)).to.be.false(); + expect(stringType.isInstance({x: 1})).to.be.false(); + expect(stringType.isInstance([1, 2])).to.be.false(); + expect(stringType.isInstance(1)).to.be.false(); + expect(stringType.isInstance(new Date())).to.be.false(); + }); + + it('checks isCoercible', () => { + expect(stringType.isCoercible('str')).to.be.true(); + expect(stringType.isCoercible(null)).to.be.true(); + expect(stringType.isCoercible(undefined)).to.be.true(); + expect(stringType.isCoercible(true)).to.be.true(); + expect(stringType.isCoercible({x: 1})).to.be.true(); + expect(stringType.isCoercible(1)).to.be.true(); + expect(stringType.isCoercible(new Date())).to.be.true(); + }); + + it('creates defaultValue', () => { + expect(stringType.defaultValue()).to.be.equal(''); + }); + + it('coerces values', () => { + expect(stringType.coerce('str')).to.equal('str'); + expect(stringType.coerce(null)).to.equal(null); + expect(stringType.coerce(undefined)).to.equal(undefined); + expect(stringType.coerce(true)).to.equal('true'); + expect(stringType.coerce({x: 1})).to.equal('{"x":1}'); + expect(stringType.coerce([1, '2'])).to.equal('[1,"2"]'); + expect(stringType.coerce(1)).to.equal('1'); + const date = new Date(); + expect(stringType.coerce(date)).to.equal(date.toJSON()); + }); + + it('serializes values', () => { + expect(stringType.serialize('str')).to.eql('str'); + expect(stringType.serialize(null)).null(); + expect(stringType.serialize(undefined)).undefined(); + }); + }); + + describe('boolean', () => { + const booleanType = new types.BooleanType(); + it('checks isInstance', () => { + expect(booleanType.isInstance('str')).to.be.false(); + expect(booleanType.isInstance(null)).to.be.true(); + expect(booleanType.isInstance(undefined)).to.be.true(); + expect(booleanType.isInstance(true)).to.be.true(); + expect(booleanType.isInstance(false)).to.be.true(); + expect(booleanType.isInstance({x: 1})).to.be.false(); + expect(booleanType.isInstance([1, 2])).to.be.false(); + expect(booleanType.isInstance(1)).to.be.false(); + expect(booleanType.isInstance(new Date())).to.be.false(); + }); + + it('checks isCoercible', () => { + expect(booleanType.isCoercible('str')).to.be.true(); + expect(booleanType.isCoercible(null)).to.be.true(); + expect(booleanType.isCoercible(undefined)).to.be.true(); + expect(booleanType.isCoercible(true)).to.be.true(); + expect(booleanType.isCoercible({x: 1})).to.be.true(); + expect(booleanType.isCoercible(1)).to.be.true(); + expect(booleanType.isCoercible(new Date())).to.be.true(); + }); + + it('creates defaultValue', () => { + expect(booleanType.defaultValue()).to.be.equal(false); + }); + + it('coerces values', () => { + expect(booleanType.coerce('str')).to.equal(true); + expect(booleanType.coerce(null)).to.equal(null); + expect(booleanType.coerce(undefined)).to.equal(undefined); + expect(booleanType.coerce(true)).to.equal(true); + expect(booleanType.coerce(false)).to.equal(false); + expect(booleanType.coerce({x: 1})).to.equal(true); + expect(booleanType.coerce([1, '2'])).to.equal(true); + expect(booleanType.coerce('')).to.equal(false); + expect(booleanType.coerce('true')).to.equal(true); + // string 'false' is boolean true + expect(booleanType.coerce('false')).to.equal(true); + expect(booleanType.coerce(0)).to.equal(false); + expect(booleanType.coerce(1)).to.equal(true); + const date = new Date(); + expect(booleanType.coerce(date)).to.equal(true); + }); + + it('serializes values', () => { + expect(booleanType.serialize(true)).to.eql(true); + expect(booleanType.serialize(false)).to.eql(false); + expect(booleanType.serialize(null)).null(); + expect(booleanType.serialize(undefined)).undefined(); + }); + }); + + describe('number', () => { + const numberType = new types.NumberType(); + it('checks isInstance', () => { + expect(numberType.isInstance('str')).to.be.false(); + expect(numberType.isInstance(null)).to.be.true(); + expect(numberType.isInstance(undefined)).to.be.true(); + expect(numberType.isInstance(NaN)).to.be.false(); + expect(numberType.isInstance(true)).to.be.false(); + expect(numberType.isInstance({x: 1})).to.be.false(); + expect(numberType.isInstance([1, 2])).to.be.false(); + expect(numberType.isInstance(1)).to.be.true(); + expect(numberType.isInstance(1.1)).to.be.true(); + expect(numberType.isInstance(new Date())).to.be.false(); + }); + + it('checks isCoercible', () => { + expect(numberType.isCoercible('str')).to.be.false(); + expect(numberType.isCoercible('1')).to.be.true(); + expect(numberType.isCoercible('1.1')).to.be.true(); + expect(numberType.isCoercible(null)).to.be.true(); + expect(numberType.isCoercible(undefined)).to.be.true(); + expect(numberType.isCoercible(true)).to.be.true(); + expect(numberType.isCoercible(false)).to.be.true(); + expect(numberType.isCoercible({x: 1})).to.be.false(); + expect(numberType.isCoercible(1)).to.be.true(); + expect(numberType.isCoercible(1.1)).to.be.true(); + // Date can be converted to number + expect(numberType.isCoercible(new Date())).to.be.true(); + }); + + it('creates defaultValue', () => { + expect(numberType.defaultValue()).to.be.equal(0); + }); + + it('coerces values', () => { + expect(() => numberType.coerce('str')).to.throw(/Invalid number/); + expect(numberType.coerce('1')).to.equal(1); + expect(numberType.coerce('1.1')).to.equal(1.1); + expect(numberType.coerce(null)).to.equal(null); + expect(numberType.coerce(undefined)).to.equal(undefined); + expect(numberType.coerce(true)).to.equal(1); + expect(numberType.coerce(false)).to.equal(0); + expect(() => numberType.coerce({x: 1})).to.throw(/Invalid number/); + expect(() => numberType.coerce([1, '2'])).to.throw(/Invalid number/); + expect(numberType.coerce(1)).to.equal(1); + expect(numberType.coerce(1.1)).to.equal(1.1); + const date = new Date(); + expect(numberType.coerce(date)).to.equal(date.getTime()); + }); + + it('serializes values', () => { + expect(numberType.serialize(1)).to.eql(1); + expect(numberType.serialize(1.1)).to.eql(1.1); + expect(numberType.serialize(null)).null(); + expect(numberType.serialize(undefined)).undefined(); + }); + }); + + describe('date', () => { + const dateType = new types.DateType(); + it('checks isInstance', () => { + expect(dateType.isInstance('str')).to.be.false(); + expect(dateType.isInstance(null)).to.be.true(); + expect(dateType.isInstance(undefined)).to.be.true(); + expect(dateType.isInstance(NaN)).to.be.false(); + expect(dateType.isInstance(true)).to.be.false(); + expect(dateType.isInstance({x: 1})).to.be.false(); + expect(dateType.isInstance([1, 2])).to.be.false(); + expect(dateType.isInstance(1)).to.be.false(); + expect(dateType.isInstance(1.1)).to.be.false(); + expect(dateType.isInstance(new Date())).to.be.true(); + }); + + it('checks isCoercible', () => { + expect(dateType.isCoercible('str')).to.be.false(); + expect(dateType.isCoercible('1')).to.be.true(); + expect(dateType.isCoercible('1.1')).to.be.true(); + expect(dateType.isCoercible(null)).to.be.true(); + expect(dateType.isCoercible(undefined)).to.be.true(); + expect(dateType.isCoercible(true)).to.be.true(); + expect(dateType.isCoercible(false)).to.be.true(); + expect(dateType.isCoercible({x: 1})).to.be.false(); + expect(dateType.isCoercible(1)).to.be.true(); + expect(dateType.isCoercible(1.1)).to.be.true(); + // Date can be converted to number + expect(dateType.isCoercible(new Date())).to.be.true(); + }); + + it('creates defaultValue', () => { + const d = new Date(); + const v = dateType.defaultValue(); + expect(v.getTime()).to.aboveOrEqual(d.getTime()); + expect(v.getTime()).to.approximately(d.getTime(), 50); + }); + + it('coerces values', () => { + expect(() => dateType.coerce('str')).to.throw(/Invalid date/); + // '1' will be parsed as local 2001-01-01 + expect(dateType.coerce('1')).to.eql(new Date('01/01/2001')); + // '1.1' will be parsed as local 2001-01-01 + expect(dateType.coerce('1.1')).to.eql(new Date('01/01/2001')); + expect(dateType.coerce(null)).to.equal(null); + expect(dateType.coerce(undefined)).to.equal(undefined); + expect(dateType.coerce(true)).to.eql(new Date(1)); + expect(dateType.coerce(false)).to.eql(new Date(0)); + expect(() => dateType.coerce({x: 1})).to.throw(/Invalid date/); + expect(dateType.coerce([1, '2'])).to.eql(new Date('01/02/2001')); + expect(dateType.coerce(1)).to.eql(new Date('1970-01-01T00:00:00.001Z')); + expect(dateType.coerce(1.1)).to.eql(new Date('1970-01-01T00:00:00.001Z')); + const date = new Date(); + expect(dateType.coerce(date)).to.equal(date); + }); + + it('serializes values', () => { + const date = new Date(); + expect(dateType.serialize(date)).to.eql(date.toJSON()); + expect(dateType.serialize(null)).null(); + expect(dateType.serialize(undefined)).undefined(); + }); + }); + + describe('buffer', () => { + const bufferType = new types.BufferType(); + it('checks isInstance', () => { + expect(bufferType.isInstance(new Buffer([1]))).to.be.true(); + expect(bufferType.isInstance(new Buffer('123'))).to.be.true(); + expect(bufferType.isInstance('str')).to.be.false(); + expect(bufferType.isInstance(null)).to.be.true(); + expect(bufferType.isInstance(undefined)).to.be.true(); + expect(bufferType.isInstance(true)).to.be.false(); + expect(bufferType.isInstance({x: 1})).to.be.false(); + expect(bufferType.isInstance([1, 2])).to.be.false(); + expect(bufferType.isInstance(1)).to.be.false(); + expect(bufferType.isInstance(new Date())).to.be.false(); + expect(bufferType.isInstance([1])).to.be.false(); + }); + + it('checks isCoercible', () => { + expect(bufferType.isCoercible('str')).to.be.true(); + expect(bufferType.isCoercible(null)).to.be.true(); + expect(bufferType.isCoercible(undefined)).to.be.true(); + expect(bufferType.isCoercible(new Buffer('12'))).to.be.true(); + expect(bufferType.isCoercible([1, 2])).to.be.true(); + expect(bufferType.isCoercible({x: 1})).to.be.false(); + expect(bufferType.isCoercible(1)).to.be.false(); + expect(bufferType.isCoercible(new Date())).to.be.false(); + }); + + it('creates defaultValue', () => { + expect(bufferType.defaultValue().equals(Buffer.from([]))).to.be.true(); + }); + + it('coerces values', () => { + expect(bufferType.coerce('str').equals(new Buffer('str'))).to.be.true(); + expect(bufferType.coerce([1]).equals(Buffer.from([1]))).to.be.true(); + expect(bufferType.coerce(null)).to.equal(null); + expect(bufferType.coerce(undefined)).to.equal(undefined); + const buf = new Buffer('12'); + expect(bufferType.coerce(buf)).exactly(buf); + expect(() => bufferType.coerce(1)).to.throw(/Invalid buffer/); + expect(() => bufferType.coerce(new Date())).to.throw(/Invalid buffer/); + expect(() => bufferType.coerce(true)).to.throw(/Invalid buffer/); + expect(() => bufferType.coerce(false)).to.throw(/Invalid buffer/); + }); + + it('serializes values', () => { + expect( + bufferType.serialize(new Buffer('str'), {encoding: 'utf-8'}), + ).to.eql('str'); + expect(bufferType.serialize(new Buffer('str'))).to.eql('c3Ry'); + expect(bufferType.serialize(null)).null(); + expect(bufferType.serialize(undefined)).undefined(); + }); + }); + + describe('any', () => { + const anyType = new types.AnyType(); + it('checks isInstance', () => { + expect(anyType.isInstance('str')).to.be.true(); + expect(anyType.isInstance(null)).to.be.true(); + expect(anyType.isInstance(undefined)).to.be.true(); + expect(anyType.isInstance(true)).to.be.true(); + expect(anyType.isInstance({x: 1})).to.be.true(); + expect(anyType.isInstance([1, 2])).to.be.true(); + expect(anyType.isInstance(1)).to.be.true(); + expect(anyType.isInstance(new Date())).to.be.true(); + expect(anyType.isInstance(new Buffer('123'))).to.be.true(); + }); + + it('checks isCoercible', () => { + expect(anyType.isCoercible('str')).to.be.true(); + expect(anyType.isCoercible(null)).to.be.true(); + expect(anyType.isCoercible(undefined)).to.be.true(); + expect(anyType.isCoercible(true)).to.be.true(); + expect(anyType.isCoercible({x: 1})).to.be.true(); + expect(anyType.isCoercible(1)).to.be.true(); + expect(anyType.isCoercible([1, '2'])).to.be.true(); + expect(anyType.isCoercible(new Date())).to.be.true(); + expect(anyType.isCoercible(new Buffer('123'))).to.be.true(); + }); + + it('creates defaultValue', () => { + expect(anyType.defaultValue()).to.equal(undefined); + }); + + it('coerces values', () => { + expect(anyType.coerce('str')).to.equal('str'); + expect(anyType.coerce(null)).to.equal(null); + expect(anyType.coerce(undefined)).to.equal(undefined); + expect(anyType.coerce(true)).to.equal(true); + const obj = {x: 1}; + expect(anyType.coerce(obj)).to.equal(obj); + const arr = [1, '2']; + expect(anyType.coerce(arr)).to.equal(arr); + expect(anyType.coerce(1)).to.equal(1); + const date = new Date(); + expect(anyType.coerce(date)).to.equal(date); + const buf = new Buffer('12'); + expect(anyType.coerce(buf)).to.equal(buf); + }); + + it('serializes values', () => { + expect(anyType.serialize('str')).to.eql('str'); + expect(anyType.serialize(1)).to.eql(1); + expect(anyType.serialize([1, '2'])).to.eql([1, '2']); + expect(anyType.serialize(null)).null(); + expect(anyType.serialize(undefined)).undefined(); + const date = new Date(); + expect(anyType.serialize(date)).to.eql(date.toJSON()); + const obj = {x: 1}; + expect(anyType.serialize(obj)).to.eql(obj); + const json = { + x: 1, + y: 2, + toJSON() { + return {a: json.x + json.y}; + }, + }; + expect(anyType.serialize(json)).to.eql({a: 3}); + }); + }); + + describe('array', () => { + const stringType = new types.StringType(); + const arrayType = new types.ArrayType(stringType); + + it('checks isInstance', () => { + expect(arrayType.isInstance('str')).to.be.false(); + expect(arrayType.isInstance(null)).to.be.true(); + expect(arrayType.isInstance(undefined)).to.be.true(); + expect(arrayType.isInstance(NaN)).to.be.false(); + expect(arrayType.isInstance(true)).to.be.false(); + expect(arrayType.isInstance({x: 1})).to.be.false(); + expect(arrayType.isInstance([1, 2])).to.be.false(); + expect(arrayType.isInstance([1, '2'])).to.be.false(); + expect(arrayType.isInstance(['1'])).to.be.true(); + expect(arrayType.isInstance(['1', 'a'])).to.be.true(); + expect(arrayType.isInstance(['1', 'a', null])).to.be.true(); + expect(arrayType.isInstance(['1', 'a', undefined])).to.be.true(); + expect(arrayType.isInstance([])).to.be.true(); + expect(arrayType.isInstance(1)).to.be.false(); + expect(arrayType.isInstance(1.1)).to.be.false(); + expect(arrayType.isInstance(new Date())).to.be.false(); + }); + + it('checks isCoercible', () => { + expect(arrayType.isCoercible('str')).to.be.false(); + expect(arrayType.isCoercible('1')).to.be.false(); + expect(arrayType.isCoercible('1.1')).to.be.false(); + expect(arrayType.isCoercible(null)).to.be.true(); + expect(arrayType.isCoercible(undefined)).to.be.true(); + expect(arrayType.isCoercible(true)).to.be.false(); + expect(arrayType.isCoercible(false)).to.be.false(); + expect(arrayType.isCoercible({x: 1})).to.be.false(); + expect(arrayType.isCoercible(1)).to.be.false(); + expect(arrayType.isCoercible(1.1)).to.be.false(); + expect(arrayType.isCoercible(new Date())).to.be.false(); + expect(arrayType.isCoercible([])).to.be.true(); + expect(arrayType.isCoercible(['1'])).to.be.true(); + expect(arrayType.isCoercible(['1', 2])).to.be.true(); + }); + + it('creates defaultValue', () => { + expect(arrayType.defaultValue()).to.be.eql([]); + }); + + it('coerces values', () => { + expect(() => arrayType.coerce('str')).to.throw(/Invalid array/); + expect(() => arrayType.coerce('1')).to.throw(/Invalid array/); + expect(() => arrayType.coerce('1.1')).to.throw(/Invalid array/); + expect(arrayType.coerce(null)).to.equal(null); + expect(arrayType.coerce(undefined)).to.equal(undefined); + + expect(() => arrayType.coerce(true)).to.throw(/Invalid array/); + expect(() => arrayType.coerce(false)).to.throw(/Invalid array/); + expect(() => arrayType.coerce({x: 1})).to.throw(/Invalid array/); + expect(() => arrayType.coerce(1)).to.throw(/Invalid array/); + + const date = new Date(); + expect(() => arrayType.coerce(date)).to.throw(/Invalid array/); + + expect(arrayType.coerce([1, '2'])).to.eql(['1', '2']); + expect(arrayType.coerce(['2'])).to.eql(['2']); + expect(arrayType.coerce([null, undefined, '2'])).to.eql([ + null, + undefined, + '2', + ]); + expect(arrayType.coerce([true, '2'])).to.eql(['true', '2']); + expect(arrayType.coerce([false, '2'])).to.eql(['false', '2']); + expect(arrayType.coerce([date])).to.eql([date.toJSON()]); + }); + + it('serializes values', () => { + expect(arrayType.serialize(['a'])).to.eql(['a']); + expect(arrayType.serialize(null)).null(); + expect(arrayType.serialize(undefined)).undefined(); + }); + }); + + describe('union', () => { + const numberType = new types.NumberType(); + const booleanType = new types.BooleanType(); + const unionType = new types.UnionType([numberType, booleanType]); + + it('checks isInstance', () => { + expect(unionType.isInstance('str')).to.be.false(); + expect(unionType.isInstance(null)).to.be.true(); + expect(unionType.isInstance(undefined)).to.be.true(); + expect(unionType.isInstance(NaN)).to.be.false(); + expect(unionType.isInstance(true)).to.be.true(); + expect(unionType.isInstance({x: 1})).to.be.false(); + expect(unionType.isInstance([1, 2])).to.be.false(); + expect(unionType.isInstance(1)).to.be.true(); + expect(unionType.isInstance(1.1)).to.be.true(); + expect(unionType.isInstance(new Date())).to.be.false(); + }); + + it('checks isCoercible', () => { + expect(unionType.isCoercible('str')).to.be.true(); + expect(unionType.isCoercible('1')).to.be.true(); + expect(unionType.isCoercible('1.1')).to.be.true(); + expect(unionType.isCoercible(null)).to.be.true(); + expect(unionType.isCoercible(undefined)).to.be.true(); + expect(unionType.isCoercible(true)).to.be.true(); + expect(unionType.isCoercible(false)).to.be.true(); + expect(unionType.isCoercible({x: 1})).to.be.true(); + expect(unionType.isCoercible(1)).to.be.true(); + expect(unionType.isCoercible(1.1)).to.be.true(); + // Date can be converted to number + expect(unionType.isCoercible(new Date())).to.be.true(); + }); + + it('creates defaultValue', () => { + expect(unionType.defaultValue()).to.be.equal(0); + }); + + it('coerces values', () => { + expect(unionType.coerce('str')).to.equal(true); + expect(unionType.coerce('1')).to.equal(1); + expect(unionType.coerce('1.1')).to.equal(1.1); + expect(unionType.coerce(null)).to.equal(null); + expect(unionType.coerce(undefined)).to.equal(undefined); + expect(unionType.coerce(true)).to.equal(true); + expect(unionType.coerce(false)).to.equal(false); + expect(unionType.coerce({x: 1})).to.equal(true); + expect(unionType.coerce([1, '2'])).to.equal(true); + expect(unionType.coerce(1)).to.equal(1); + expect(unionType.coerce(1.1)).to.equal(1.1); + const date = new Date(); + expect(unionType.coerce(date)).to.equal(date.getTime()); + + // Create a new union type to test invalid value + const dateType = new types.DateType(); + const numberOrDateType = new types.UnionType([numberType, dateType]); + expect(() => numberOrDateType.coerce('str')).to.throw(/Invalid union/); + }); + + it('serializes values', () => { + expect(unionType.serialize(1)).to.equal(1); + expect(unionType.serialize(1.1)).to.equal(1.1); + expect(unionType.serialize(true)).to.equal(true); + expect(unionType.serialize(false)).to.equal(false); + expect(unionType.serialize(null)).null(); + expect(unionType.serialize(undefined)).undefined(); + }); + }); +}); diff --git a/packages/types/tsconfig.build.json b/packages/types/tsconfig.build.json new file mode 100644 index 000000000000..3ffcd508d23e --- /dev/null +++ b/packages/types/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"] +} From f3b2b8eaa8e7346e04c171e1550631435d9a248c Mon Sep 17 00:00:00 2001 From: shimks Date: Fri, 13 Apr 2018 14:13:45 -0400 Subject: [PATCH 2/5] feature: do a very short PoC on type coercion --- packages/rest/package.json | 1 + packages/rest/src/parser.ts | 29 ++++++++- packages/rest/src/router/routing-table.ts | 1 + .../coercion/coercion.acceptance.ts | 64 +++++++++++++++++++ packages/types/package.json | 2 +- packages/types/src/convert.ts | 17 +++++ packages/types/src/index.ts | 1 + 7 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 packages/rest/test/acceptance/coercion/coercion.acceptance.ts create mode 100644 packages/types/src/convert.ts diff --git a/packages/rest/package.json b/packages/rest/package.json index 25295b57234b..225dbddc4c6f 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -24,6 +24,7 @@ "@loopback/core": "^0.6.1", "@loopback/openapi-v3": "^0.8.0", "@loopback/openapi-v3-types": "^0.5.0", + "@loopback/types": "^0.1.0", "@types/cors": "^2.8.3", "@types/http-errors": "^1.6.1", "body": "^5.1.0", diff --git a/packages/rest/src/parser.ts b/packages/rest/src/parser.ts index 210c361ed2fc..06917665a106 100644 --- a/packages/rest/src/parser.ts +++ b/packages/rest/src/parser.ts @@ -9,6 +9,7 @@ import { OperationObject, ParameterObject, isReferenceObject, + isSchemaObject, } from '@loopback/openapi-v3-types'; import {REQUEST_BODY_INDEX} from '@loopback/openapi-v3'; import {promisify} from 'util'; @@ -18,6 +19,8 @@ import { PathParameterValues, } from './internal-types'; import {ResolvedRoute} from './router/routing-table'; +import {getSerializer} from '@loopback/types'; +const debug = require('debug')('loopback:rest:parser'); type HttpError = HttpErrors.HttpError; // tslint:disable-next-line:no-any @@ -123,6 +126,28 @@ function buildOperationArguments( ); } } - if (requestBodyIndex > -1) paramArgs.splice(requestBodyIndex, 0, body); - return paramArgs; + + debug('Coercing parameters', paramArgs); + + const coercedParamArgs: OperationArgs = []; + const paramObjects = operationSpec.parameters; + // coercion is done IFF parameters are defined in the OpenAPI spec + if (paramObjects) { + for (let i = 0; i < paramArgs.length; i++) { + const paramObject = paramObjects[i]; + if (!isReferenceObject(paramObject)) { + // is a ParameterObject + if (paramObject.schema) { + if (isSchemaObject(paramObject.schema)) { + // basic type + const serializer = getSerializer(paramObject.schema.type!); + coercedParamArgs.push(serializer.coerce(paramArgs[i])); + } + } + } + } + } + if (requestBodyIndex > -1) coercedParamArgs.splice(requestBodyIndex, 0, body); + + return coercedParamArgs; } diff --git a/packages/rest/src/router/routing-table.ts b/packages/rest/src/router/routing-table.ts index 8c166925ed4c..9c0e5860bc7f 100644 --- a/packages/rest/src/router/routing-table.ts +++ b/packages/rest/src/router/routing-table.ts @@ -15,6 +15,7 @@ import { invokeMethod, instantiateClass, ValueOrPromise, + MetadataInspector, } from '@loopback/context'; import {ServerRequest} from 'http'; import * as HttpErrors from 'http-errors'; diff --git a/packages/rest/test/acceptance/coercion/coercion.acceptance.ts b/packages/rest/test/acceptance/coercion/coercion.acceptance.ts new file mode 100644 index 000000000000..16da3e5c563b --- /dev/null +++ b/packages/rest/test/acceptance/coercion/coercion.acceptance.ts @@ -0,0 +1,64 @@ +import {supertest, createClientForHandler, sinon} from '@loopback/testlab'; +import { + RestApplication, + RestServer, + get, + param, + post, + requestBody, + RestBindings, + InvokeMethod, +} from '../../..'; + +describe.only('Coercion', () => { + let app: RestApplication; + let server: RestServer; + let client: supertest.SuperTest; + let invokeMethod: InvokeMethod; + + before(givenAnApplication); + before(givenAServer); + before(givenAClient); + + after(async () => { + await app.stop(); + }); + + class MyController { + @get('/create-number/{num}') + createNumber(@param.path.number('num') num: number) { + return num; + } + + @post('/create-object/') + createObject(@requestBody() obj: object) {} + } + + it('coerces into number', async () => { + const spy = sinon.spy(MyController.prototype, 'createNumber'); + await client.get('/create-number/13').expect(200); + sinon.assert.calledWithExactly(spy, 13); + sinon.assert.neverCalledWith(spy, '13'); + }); + + it('works with requestBody', async () => { + await client + .post('/create-object') + .send({foo: 'bar'}) + .expect(200); + }); + + async function givenAnApplication() { + app = new RestApplication(); + app.controller(MyController); + await app.start(); + } + + async function givenAServer() { + server = await app.getServer(RestServer); + } + + async function givenAClient() { + client = await createClientForHandler(server.requestHandler); + } +}); diff --git a/packages/types/package.json b/packages/types/package.json index 658f049d7912..b67fe2229293 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@loopback/types", - "version": "4.0.0-alpha.8", + "version": "0.1.0", "description": "LoopBack's types utilities for reflection and decoration", "engines": { "node": ">=6" diff --git a/packages/types/src/convert.ts b/packages/types/src/convert.ts new file mode 100644 index 000000000000..97d3c57720a9 --- /dev/null +++ b/packages/types/src/convert.ts @@ -0,0 +1,17 @@ +import {NumberType, StringType} from './handlers'; + +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/types +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +// tslint:disable-next-line:no-any +export function getSerializer(type: string) { + if (type === 'number') { + return new NumberType(); + } + if (type === 'string') { + return new StringType(); + } + throw new Error('only numberType and stringType are implemented'); +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index dcb9f10d0420..4493efce8d8e 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -4,5 +4,6 @@ // License text available at https://opensource.org/licenses/MIT export * from './common-types'; +export * from './convert'; export * from './mixin'; export * from './handlers'; From 94977f9d3a79be923250eb740c2a10b06930538a Mon Sep 17 00:00:00 2001 From: shimks Date: Tue, 17 Apr 2018 22:02:23 -0400 Subject: [PATCH 3/5] feat: add tier 1 validation support --- 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 | 48 ++++++++++ packages/validator/src/index.ts | 6 ++ packages/validator/src/validate.decorator.ts | 63 ++++++++++++ .../validate.decorator.acceptance.ts | 96 +++++++++++++++++++ packages/validator/tsconfig.build.json | 8 ++ 12 files changed, 329 insertions(+) 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/tsconfig.build.json 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..5bdd80b2a30b --- /dev/null +++ b/packages/validator/package.json @@ -0,0 +1,48 @@ +{ + "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/context": "^0.7.0", + "@loopback/rest": "^0.7.0", + "@types/json-schema": "^6.0.1", + "ajv": "^6.4.0" + }, + "devDependencies": { + "@loopback/build": "^0.4.3", + "@loopback/testlab": "^0.6.0", + "@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..3142ae3cc5db --- /dev/null +++ b/packages/validator/src/index.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 './validate.decorator'; diff --git a/packages/validator/src/validate.decorator.ts b/packages/validator/src/validate.decorator.ts new file mode 100644 index 000000000000..60c14a33cb21 --- /dev/null +++ b/packages/validator/src/validate.decorator.ts @@ -0,0 +1,63 @@ +// 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/context'; +import {HttpErrors} from '@loopback/rest'; + +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 = 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 (schema) { + const isValid = ajv.validate(schema, args[i]); + if (!isValid) { + throw new HttpErrors.UnprocessableEntity('bad param'); + } + } + } + // 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(schema: JSONSchema6) { + return ParameterDecoratorFactory.createDecorator(VALIDATION_KEY, schema); +} 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..f90acaadacee --- /dev/null +++ b/packages/validator/test/acceptance/validate.decorator.acceptance.ts @@ -0,0 +1,96 @@ +import {RestApplication, get, param, RestServer} from '@loopback/rest'; +import {validate, validatable} from '../..'; +import {supertest, createClientForHandler} from '@loopback/testlab'; + +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(); + }); + + 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, + ) {} + } + it('simple valid', async () => { + await client.get('/simple?str=foo@bar.com').expect(200); + }); + + it('simple invalid', async () => { + await client.get('/simple?str=foo.bar').expect(422); + }); + + it('multiple valid', async () => { + await client.get('/multiple/10?str=foo@bar.com&num2=7').expect(200); + }); + + it('multiple invalid', async () => { + await client.get('/multiple/10?str=foo@bar.com&num2=6').expect(422); + }); + + it('select valid', async () => { + await client.get('/select/5?str=foo@bar.com&num2=7').expect(200); + }); + + it('select invalid', async () => { + await client.get('/select/5?str=foo@bar.com&num2=6').expect(422); + }); + + 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/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"] +} From f41eebc6d60fd8a0e50177b79c25fc848616bd2e Mon Sep 17 00:00:00 2001 From: shimks Date: Fri, 20 Apr 2018 11:56:17 -0400 Subject: [PATCH 4/5] add 2nd tier validation and helpful error messages --- packages/openapi-v3/package.json | 2 + packages/openapi-v3/src/json-to-schema.ts | 15 ++--- .../test/unit/json-to-schema.unit.ts | 29 ++++----- packages/repository-json-schema/package.json | 1 + .../src/build-schema.ts | 15 ++--- .../integration/build-schema.integration.ts | 3 +- packages/validator/package.json | 16 +++-- packages/validator/src/index.ts | 3 + packages/validator/src/validate.decorator.ts | 11 +++- .../validate.decorator.acceptance.ts | 61 +++++++++++++++++-- .../validate.decorator.integration.ts | 35 +++++++++++ 11 files changed, 149 insertions(+), 42 deletions(-) create mode 100644 packages/validator/test/integration/validate.decorator.integration.ts diff --git a/packages/openapi-v3/package.json b/packages/openapi-v3/package.json index ba8917a30369..0f180e1b036f 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.6.1", + "@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/src/json-to-schema.ts b/packages/openapi-v3/src/json-to-schema.ts index 2fafad3bbb77..f9a52ecaeb34 100644 --- a/packages/openapi-v3/src/json-to-schema.ts +++ b/packages/openapi-v3/src/json-to-schema.ts @@ -5,9 +5,10 @@ import {JsonDefinition} from '@loopback/repository-json-schema'; import {SchemaObject} from '@loopback/openapi-v3-types'; +import {JSONSchema6} from 'json-schema'; import * as _ from 'lodash'; -export function jsonToSchemaObject(json: JsonDefinition): SchemaObject { +export function jsonToSchemaObject(json: JSONSchema6): SchemaObject { const result: SchemaObject = {}; const propsToIgnore = [ 'anyOf', @@ -36,27 +37,27 @@ export function jsonToSchemaObject(json: JsonDefinition): SchemaObject { } case 'definitions': { result.definitions = _.mapValues(json.definitions, def => - jsonToSchemaObject(def), + jsonToSchemaObject(def as JSONSchema6), ); break; } case 'properties': { result.properties = _.mapValues(json.properties, item => - jsonToSchemaObject(item), + jsonToSchemaObject(item as JSONSchema6), ); break; } case 'additionalProperties': { if (typeof json.additionalProperties !== 'boolean') { result.additionalProperties = jsonToSchemaObject( - json.additionalProperties as JsonDefinition, + json.additionalProperties as JSONSchema6, ); } break; } case 'items': { const items = Array.isArray(json.items) ? json.items[0] : json.items; - result.items = jsonToSchemaObject(items as JsonDefinition); + result.items = jsonToSchemaObject(items as JSONSchema6); break; } case 'enum': { @@ -67,7 +68,7 @@ export function jsonToSchemaObject(json: JsonDefinition): SchemaObject { newEnum.push(element); } else { // if element is JsonDefinition, convert to SchemaObject - newEnum.push(jsonToSchemaObject(element as JsonDefinition)); + newEnum.push(jsonToSchemaObject(element as JSONSchema6)); } } result.enum = newEnum; @@ -82,7 +83,7 @@ export function jsonToSchemaObject(json: JsonDefinition): SchemaObject { break; } default: { - result[property] = json[property as keyof JsonDefinition]; + result[property] = json[property as keyof JSONSchema6]; break; } } 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 99240459ac9c..63427efde46e 100644 --- a/packages/openapi-v3/test/unit/json-to-schema.unit.ts +++ b/packages/openapi-v3/test/unit/json-to-schema.unit.ts @@ -8,19 +8,20 @@ import {JsonDefinition} from '@loopback/repository-json-schema'; import {SchemaObject} from '@loopback/openapi-v3-types'; import {jsonToSchemaObject} from '../..'; +import {JSONSchema6} from 'json-schema'; describe('jsonToSchemaObject', () => { it('does nothing when given an empty object', () => { expect({}).to.eql({}); }); - const typeDef: JsonDefinition = {type: ['string', 'number']}; + const typeDef: JSONSchema6 = {type: ['string', 'number']}; const expectedType: SchemaObject = {type: 'string'}; it('converts type', () => { propertyConversionTest(typeDef, expectedType); }); it('ignores non-compatible JSON schema properties', () => { - const nonCompatibleDef: JsonDefinition = { + const nonCompatibleDef = { anyOf: [], oneOf: [], additionalItems: { @@ -34,7 +35,7 @@ describe('jsonToSchemaObject', () => { }); it('converts allOf', () => { - const allOfDef: JsonDefinition = { + const allOfDef: JSONSchema6 = { allOf: [typeDef, typeDef], }; const expectedAllOf: SchemaObject = { @@ -44,7 +45,7 @@ describe('jsonToSchemaObject', () => { }); it('converts definitions', () => { - const definitionsDef: JsonDefinition = { + const definitionsDef: JSONSchema6 = { definitions: {foo: typeDef, bar: typeDef}, }; const expectedDef: SchemaObject = { @@ -54,7 +55,7 @@ describe('jsonToSchemaObject', () => { }); it('converts properties', () => { - const propertyDef: JsonDefinition = { + const propertyDef: JSONSchema6 = { properties: { foo: typeDef, }, @@ -69,7 +70,7 @@ describe('jsonToSchemaObject', () => { context('additionalProperties', () => { it('is converted properly when the type is JsonDefinition', () => { - const additionalDef: JsonDefinition = { + const additionalDef: JSONSchema6 = { additionalProperties: typeDef, }; const expectedAdditional: SchemaObject = { @@ -79,7 +80,7 @@ describe('jsonToSchemaObject', () => { }); it('is converted properly when it is "false"', () => { - const noAdditionalDef: JsonDefinition = { + const noAdditionalDef: JSONSchema6 = { additionalProperties: false, }; const expectedDef: SchemaObject = {}; @@ -88,7 +89,7 @@ describe('jsonToSchemaObject', () => { }); it('converts items', () => { - const itemsDef: JsonDefinition = { + const itemsDef: JSONSchema6 = { type: 'array', items: typeDef, }; @@ -101,7 +102,7 @@ describe('jsonToSchemaObject', () => { context('enum', () => { it('is converted properly when the type is primitive', () => { - const enumStringDef: JsonDefinition = { + const enumStringDef: JSONSchema6 = { enum: ['foo', 'bar'], }; const expectedStringDef: SchemaObject = { @@ -111,17 +112,17 @@ describe('jsonToSchemaObject', () => { }); it('is converted properly when it is null', () => { - const enumNullDef: JsonDefinition = { + const enumNullDef: JSONSchema6 = { enum: [null, null], }; - const expectedNullDef: JsonDefinition = { + const expectedNullDef: JSONSchema6 = { enum: [null, null], }; propertyConversionTest(enumNullDef, expectedNullDef); }); it('is converted properly when the type is complex', () => { - const enumCustomDef: JsonDefinition = { + const enumCustomDef: JSONSchema6 = { enum: [typeDef, typeDef], }; const expectedCustomDef: SchemaObject = { @@ -132,7 +133,7 @@ describe('jsonToSchemaObject', () => { }); it('retains given properties in the conversion', () => { - const inputDef: JsonDefinition = { + const inputDef: JSONSchema6 = { title: 'foo', type: 'object', properties: { @@ -142,7 +143,7 @@ describe('jsonToSchemaObject', () => { }, default: 'Default string', }; - const expectedDef: SchemaObject = { + const expectedDef: JSONSchema6 = { title: 'foo', type: 'object', properties: { diff --git a/packages/repository-json-schema/package.json b/packages/repository-json-schema/package.json index b3086b2b3819..a6387d5c8f83 100644 --- a/packages/repository-json-schema/package.json +++ b/packages/repository-json-schema/package.json @@ -26,6 +26,7 @@ "dependencies": { "@loopback/context": "^0.8.1", "@loopback/repository": "^0.8.1", + "@types/json-schema": "^6.0.1", "lodash": "^4.17.5", "typescript-json-schema": "^0.22.0" }, diff --git a/packages/repository-json-schema/src/build-schema.ts b/packages/repository-json-schema/src/build-schema.ts index eb3d890f9fa1..eaec17028392 100644 --- a/packages/repository-json-schema/src/build-schema.ts +++ b/packages/repository-json-schema/src/build-schema.ts @@ -11,8 +11,9 @@ import { import {includes} from 'lodash'; import {Definition, PrimitiveType} from 'typescript-json-schema'; import {MetadataInspector, MetadataAccessor} from '@loopback/context'; +import {JSONSchema6} from 'json-schema'; -export const JSON_SCHEMA_KEY = MetadataAccessor.create( +export const JSON_SCHEMA_KEY = MetadataAccessor.create( 'loopback:json-schema', ); @@ -38,7 +39,7 @@ export interface JsonDefinition extends Definition { * in a cache. If not, one is generated and then cached. * @param ctor Contructor of class to get JSON Schema from */ -export function getJsonSchema(ctor: Function): JsonDefinition { +export function getJsonSchema(ctor: Function): JSONSchema6 { // NOTE(shimks) currently impossible to dynamically update const jsonSchema = MetadataInspector.getClassMetadata(JSON_SCHEMA_KEY, ctor); if (jsonSchema) { @@ -89,9 +90,9 @@ export function isComplexType(ctor: Function) { * Converts property metadata into a JSON property definition * @param meta */ -export function metaToJsonProperty(meta: PropertyDefinition): JsonDefinition { +export function metaToJsonProperty(meta: PropertyDefinition): JSONSchema6 { let ctor = meta.type as string | Function; - let def: JsonDefinition = {}; + let def: JSONSchema6 = {}; // errors out if @property.array() is not used on a property of array if (ctor === Array) { @@ -108,7 +109,7 @@ export function metaToJsonProperty(meta: PropertyDefinition): JsonDefinition { if (meta.array) { def.type = 'array'; - def.items = propDef; + def.items = propDef; } else { Object.assign(def, propDef); } @@ -124,9 +125,9 @@ export function metaToJsonProperty(meta: PropertyDefinition): JsonDefinition { * reflection API * @param ctor Constructor of class to convert from */ -export function modelToJsonSchema(ctor: Function): JsonDefinition { +export function modelToJsonSchema(ctor: Function): JSONSchema6 { const meta: ModelDefinition | {} = ModelMetadataHelper.getModelMetadata(ctor); - const result: JsonDefinition = {}; + const result: JSONSchema6 = {}; // returns an empty object if metadata is an empty object if (!(meta instanceof ModelDefinition)) { diff --git a/packages/repository-json-schema/test/integration/build-schema.integration.ts b/packages/repository-json-schema/test/integration/build-schema.integration.ts index 67d29de505e2..82139f10c54e 100644 --- a/packages/repository-json-schema/test/integration/build-schema.integration.ts +++ b/packages/repository-json-schema/test/integration/build-schema.integration.ts @@ -8,6 +8,7 @@ import {modelToJsonSchema} from '../../src/build-schema'; import {expect} from '@loopback/testlab'; import {MetadataInspector} from '@loopback/context'; import {JSON_SCHEMA_KEY, getJsonSchema} from '../../index'; +import {JSONSchema6} from 'json-schema'; describe('build-schema', () => { describe('modelToJsonSchema', () => { @@ -339,7 +340,7 @@ describe('build-schema', () => { class TestModel { @property() foo: number; } - const cachedSchema = { + const cachedSchema: JSONSchema6 = { properties: { cachedProperty: { type: 'string', diff --git a/packages/validator/package.json b/packages/validator/package.json index 5bdd80b2a30b..e422205e59e3 100644 --- a/packages/validator/package.json +++ b/packages/validator/package.json @@ -24,14 +24,20 @@ "access": "public" }, "dependencies": { - "@loopback/context": "^0.7.0", - "@loopback/rest": "^0.7.0", + "@loopback/metadata": "^0.6.0", + "@types/http-errors": "^1.6.1", "@types/json-schema": "^6.0.1", - "ajv": "^6.4.0" + "ajv": "^6.4.0", + "debug": "^3.1.0", + "http-errors": "^1.6.3" }, "devDependencies": { - "@loopback/build": "^0.4.3", - "@loopback/testlab": "^0.6.0", + "@loopback/build": "^0.5.0", + "@loopback/repository": "^0.8.0", + "@loopback/repository-json-schema": "^0.6.0", + "@loopback/rest": "^0.7.0", + "@loopback/testlab": "^0.7.0", + "@types/debug": "0.0.30", "@types/node": "^8.10.4" }, "files": [ diff --git a/packages/validator/src/index.ts b/packages/validator/src/index.ts index 3142ae3cc5db..f58c210c971b 100644 --- a/packages/validator/src/index.ts +++ b/packages/validator/src/index.ts @@ -4,3 +4,6 @@ // 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 index 60c14a33cb21..211f463a1f92 100644 --- a/packages/validator/src/validate.decorator.ts +++ b/packages/validator/src/validate.decorator.ts @@ -9,8 +9,10 @@ import { MetadataAccessor, MetadataInspector, ParameterDecoratorFactory, -} from '@loopback/context'; -import {HttpErrors} from '@loopback/rest'; +} from '@loopback/metadata'; +import * as HttpErrors from 'http-errors'; + +const debug = require('debug')('loopback:validator'); export const VALIDATION_KEY = MetadataAccessor.create( 'validation.parameter', @@ -40,9 +42,12 @@ export function validatable() { for (let i = 0; i < args.length; i++) { const schema = schemas[i]; if (schema) { + debug('validating %s against %o', args[i], schema); const isValid = ajv.validate(schema, args[i]); if (!isValid) { - throw new HttpErrors.UnprocessableEntity('bad param'); + throw new HttpErrors.UnprocessableEntity( + ajv.errorsText(ajv.errors, {dataVar: args[i]}), + ); } } } diff --git a/packages/validator/test/acceptance/validate.decorator.acceptance.ts b/packages/validator/test/acceptance/validate.decorator.acceptance.ts index f90acaadacee..69d8bfd12848 100644 --- a/packages/validator/test/acceptance/validate.decorator.acceptance.ts +++ b/packages/validator/test/acceptance/validate.decorator.acceptance.ts @@ -1,6 +1,15 @@ -import {RestApplication, get, param, RestServer} from '@loopback/rest'; +import { + RestApplication, + get, + param, + RestServer, + post, + requestBody, +} from '@loopback/rest'; import {validate, validatable} from '../..'; -import {supertest, createClientForHandler} from '@loopback/testlab'; +import {supertest, createClientForHandler, expect} from '@loopback/testlab'; +import {model, property} from '@loopback/repository'; +import {getJsonSchema, JsonDefinition} from '@loopback/repository-json-schema'; describe('validate decorator', () => { let app: RestApplication; @@ -18,6 +27,12 @@ describe('validate decorator', () => { await app.stop(); }); + @model() + class TestModel { + @property() str: string; + @property() num: number; + } + class TestController { @get('/simple') @validatable() @@ -58,13 +73,22 @@ describe('validate decorator', () => { }) num2: number, ) {} + + @post('/custom') + @validatable() + custom( + @requestBody() + @validate(getJsonSchema(TestModel)) + body: TestModel, + ) {} } it('simple valid', async () => { await client.get('/simple?str=foo@bar.com').expect(200); }); it('simple invalid', async () => { - await client.get('/simple?str=foo.bar').expect(422); + 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 () => { @@ -72,7 +96,10 @@ describe('validate decorator', () => { }); it('multiple invalid', async () => { - await client.get('/multiple/10?str=foo@bar.com&num2=6').expect(422); + 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 () => { @@ -80,7 +107,31 @@ describe('validate decorator', () => { }); it('select invalid', async () => { - await client.get('/select/5?str=foo@bar.com&num2=6').expect(422); + 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/); }); function givenAnApplication() { 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..59239769a816 --- /dev/null +++ b/packages/validator/test/integration/validate.decorator.integration.ts @@ -0,0 +1,35 @@ +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"/, + ); + }); +}); From b5ec200b8cdff43720dbb0220f4028e207a7673c Mon Sep 17 00:00:00 2001 From: shimks Date: Wed, 25 Apr 2018 13:57:30 -0400 Subject: [PATCH 5/5] add valdation function support for @validator --- packages/validator/src/validate.decorator.ts | 26 +++++++++--- .../validate.decorator.acceptance.ts | 42 +++++++++++++++++++ .../validate.decorator.integration.ts | 5 ++- 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/packages/validator/src/validate.decorator.ts b/packages/validator/src/validate.decorator.ts index 211f463a1f92..5fd83b775502 100644 --- a/packages/validator/src/validate.decorator.ts +++ b/packages/validator/src/validate.decorator.ts @@ -14,7 +14,10 @@ import * as HttpErrors from 'http-errors'; const debug = require('debug')('loopback:validator'); -export const VALIDATION_KEY = MetadataAccessor.create( +// tslint:disable-next-line:no-any +export type Validator = (arg: any) => boolean | Promise; + +export const VALIDATION_KEY = MetadataAccessor.create( 'validation.parameter', ); @@ -32,7 +35,7 @@ export function validatable() { ); } // tslint:disable-next-line:no-any - descriptor.value = function(...args: any[]) { + descriptor.value = async function(...args: any[]) { const ajv = new AJV(); const schemas = MetadataInspector.getAllParameterMetadata( VALIDATION_KEY, @@ -41,7 +44,8 @@ export function validatable() { )!; for (let i = 0; i < args.length; i++) { const schema = schemas[i]; - if (schema) { + 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) { @@ -49,6 +53,15 @@ export function validatable() { 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 @@ -63,6 +76,9 @@ export function validatable() { }; } -export function validate(schema: JSONSchema6) { - return ParameterDecoratorFactory.createDecorator(VALIDATION_KEY, schema); +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 index 69d8bfd12848..3f921adddc3c 100644 --- a/packages/validator/test/acceptance/validate.decorator.acceptance.ts +++ b/packages/validator/test/acceptance/validate.decorator.acceptance.ts @@ -27,6 +27,14 @@ describe('validate decorator', () => { 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; @@ -81,6 +89,22 @@ describe('validate decorator', () => { @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); @@ -134,6 +158,24 @@ describe('validate decorator', () => { 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); diff --git a/packages/validator/test/integration/validate.decorator.integration.ts b/packages/validator/test/integration/validate.decorator.integration.ts index 59239769a816..549753b51412 100644 --- a/packages/validator/test/integration/validate.decorator.integration.ts +++ b/packages/validator/test/integration/validate.decorator.integration.ts @@ -28,7 +28,10 @@ describe('validate', () => { } const ctrl = new EmailController(); - expect(() => ctrl.createEmail('foobar')).to.throw( + // expect(() => ctrl.createEmail('foobar')).to.throw( + // /should match format "email"/, + // ); + expect(ctrl.createEmail('foobar')).to.be.rejectedWith( /should match format "email"/, ); });