diff --git a/package.json b/package.json index 6652a68f..7283176a 100644 --- a/package.json +++ b/package.json @@ -43,5 +43,7 @@ }, "testEnvironment": "node" }, - "dependencies": {} + "dependencies": { + "reflect-metadata": "^0.1.12" + } } diff --git a/src/decorator.spec.ts b/src/decorator.spec.ts new file mode 100644 index 00000000..cc342b0f --- /dev/null +++ b/src/decorator.spec.ts @@ -0,0 +1,125 @@ +import { check, checked, String } from '.'; + +describe('Decorators', () => { + describe('@checked', () => { + describe('parameter length', () => { + it('works', () => { + expect(() => { + class Class { + @checked(String) + static method(s: string) { + return s; + } + } + return Class; + }).not.toThrow(); + expect(() => { + class Class { + @checked(String, String) + static method(s: string) { + return s; + } + } + return Class; + }).toThrow(); + expect(() => { + class Class { + @checked() + static method(s: string) { + return s; + } + } + return Class; + }).toThrow(); + expect(() => { + class Class { + @checked(String, String) + static method(s: string = 'initial', t: string = 'initial') { + return { s, t }; + } + } + return Class; + }).not.toThrow(); + }); + }); + describe('parameter check', () => { + it('works', () => { + class Class { + @checked(String) + static method1(s: string) { + return s; + } + @checked(String.withConstraint(s => /^world$/.test(s))) + static method2(s: string) { + return s; + } + } + expect(() => { + Class.method1('hello'); + }).not.toThrow(); + expect(() => { + Class.method2('hello'); + }).toThrow(/Failed constraint check/); + expect(() => { + Class.method2('world'); + }).not.toThrow(); + }); + }); + }); + describe('@check', () => { + describe('parameter length', () => { + it('works', () => { + expect(() => { + class Class { + @checked(String) + static method(@check s: string) { + return s; + } + } + return Class; + }).not.toThrow(); + expect(() => { + class Class { + @checked(String, String) + static method(@check s: string, t: string) { + return { s, t }; + } + } + return Class; + }).toThrow(); + expect(() => { + class Class { + @checked(String) + static method(@check s: string, t: string) { + return { s, t }; + } + } + return Class; + }).not.toThrow(); + }); + }); + describe('parameter check', () => { + it('works', () => { + class Class { + @checked(String) + static method1(@check s: string) { + return s; + } + @checked(String.withConstraint(s => /^world$/.test(s))) + static method2(s: string, @check t: string) { + return { s, t }; + } + } + expect(() => { + Class.method1('hello'); + }).not.toThrow(); + expect(() => { + Class.method2('world', 'hello'); + }).toThrow(/Failed constraint check/); + expect(() => { + Class.method2('hello', 'world'); + }).not.toThrow(); + }); + }); + }); +}); diff --git a/src/decorator.ts b/src/decorator.ts new file mode 100644 index 00000000..f6f939c9 --- /dev/null +++ b/src/decorator.ts @@ -0,0 +1,98 @@ +import { Runtype, ValidationError } from './runtype'; + +import 'reflect-metadata'; + +const CHECKED_PARAMETER_INDICES = Symbol.for('runtypes:checked-parameter-indices'); + +/** + * A parameter decorator. Explicitly mark the parameter as checked on every method call in combination with `@checked` method decorator. The number of `@check` params must be the same as the number of provided runtypes into `@checked`.\ + * Usage: + * ```ts + * @checked(Runtype1, Runtype3) + * method(@check p1: Runtype1, p2: number, @check p3: Runtype3) { ... } + * ``` + */ +export function check(target: any, propertyKey: string | symbol, parameterIndex: number) { + const existingValidParameterIndices: number[] = + Reflect.getOwnMetadata(CHECKED_PARAMETER_INDICES, target, propertyKey) || []; + existingValidParameterIndices.push(parameterIndex); + Reflect.defineMetadata( + CHECKED_PARAMETER_INDICES, + existingValidParameterIndices, + target, + propertyKey, + ); +} + +/** + * A method decorator. Takes runtypes as arguments which correspond to the ones of the actual method. + * + * Usually, the number of provided runtypes must be _**the same as**_ or _**less than**_ the actual parameters. + * + * If you explicitly mark which parameter shall be checked using `@check` parameter decorator, the number of `@check` parameters must be _**the same as**_ the runtypes provided into `@checked`. + * + * Usage: + * ```ts + * @checked(Runtype1, Runtype2) + * method1(param1: Static1, param2: Static2, param3: any) { + * ... + * } + * + * @checked(Runtype1, Runtype3) + * method2(@check param1: Static1, param2: any, @check param3: Static3) { + * ... + * } + * ``` + */ +export function checked(...runtypes: Runtype[]) { + if (runtypes.length === 0) { + throw new Error('No runtype provided to `@checked`. Please remove the decorator.'); + } + return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => { + const method: Function = descriptor.value!; + const className = target.name || target.constructor.name; + const methodId = + className + + (target.name ? '' : '.prototype') + + (typeof propertyKey === 'string' ? `["${propertyKey}"]` : `[${String(propertyKey)}]`); + const validParameterIndices: number[] | undefined = Reflect.getOwnMetadata( + CHECKED_PARAMETER_INDICES, + target, + propertyKey, + ); + if (validParameterIndices) { + // if used with `@check` parameter decorator + if (runtypes.length === validParameterIndices.length) { + descriptor.value = function(...args: any[]) { + runtypes.forEach((type, typeIndex) => { + const parameterIndex = validParameterIndices[typeIndex]; + try { + type.check(args[parameterIndex]); + } catch (err) { + throw new ValidationError(`${methodId}, argument #${parameterIndex}: ${err.message}`); + } + }); + + return method.apply(this, args); + }; + } else { + throw new Error('Number of `@checked` runtypes and @valid parameters not matched.'); + } + } else { + // if used without `@check` parameter decorator + if (runtypes.length <= method.length) { + descriptor.value = function(...args: any[]) { + runtypes.forEach((type, typeIndex) => { + try { + type.check(args[typeIndex]); + } catch (err) { + throw new ValidationError(`${methodId}, argument #${typeIndex}: ${err.message}`); + } + }); + }; + } else { + throw new Error('Number of `@checked` runtypes exceeds actual parameter length.'); + } + } + }; +} diff --git a/src/index.ts b/src/index.ts index 415959fd..a52b32c7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,3 +23,4 @@ export { InstanceOf } from './types/instanceof'; export * from './types/lazy'; export * from './types/constraint'; export { Brand } from './types/brand'; +export * from './decorator'; diff --git a/tsconfig.json b/tsconfig.json index 54693ba0..3bfd7a11 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,8 @@ "declaration": true, "sourceMap": false, "outDir": "lib", - "forceConsistentCasingInFileNames": true + "forceConsistentCasingInFileNames": true, + "experimentalDecorators": true }, "exclude": ["./lib/**/*", "./examples/**/*"] } diff --git a/yarn.lock b/yarn.lock index d94e9ea6..2fda5131 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2630,6 +2630,11 @@ realpath-native@^1.0.0: dependencies: util.promisify "^1.0.0" +reflect-metadata@^0.1.12: + version "0.1.12" + resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.12.tgz#311bf0c6b63cd782f228a81abe146a2bfa9c56f2" + integrity sha512-n+IyV+nGz3+0q3/Yf1ra12KpCyi001bi4XFxSjbiWWjfqb52iTTtpGXmCCAOWWIAn9KEuFZKGqBERHmrtScZ3A== + regenerator-runtime@^0.11.0: version "0.11.1" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9"