Skip to content

Commit

Permalink
Add decorator feature and tests (#71)
Browse files Browse the repository at this point in the history
* Add decorator feature and tests

* Avoid warning

* Remove unwanted change
  • Loading branch information
yuhr authored and pelotom committed Nov 26, 2018
1 parent a4b7787 commit a0693cc
Show file tree
Hide file tree
Showing 6 changed files with 234 additions and 2 deletions.
4 changes: 3 additions & 1 deletion package.json
Expand Up @@ -43,5 +43,7 @@
},
"testEnvironment": "node"
},
"dependencies": {}
"dependencies": {
"reflect-metadata": "^0.1.12"
}
}
125 changes: 125 additions & 0 deletions 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();
});
});
});
});
98 changes: 98 additions & 0 deletions 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.');
}
}
};
}
1 change: 1 addition & 0 deletions src/index.ts
Expand Up @@ -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';
3 changes: 2 additions & 1 deletion tsconfig.json
Expand Up @@ -9,7 +9,8 @@
"declaration": true,
"sourceMap": false,
"outDir": "lib",
"forceConsistentCasingInFileNames": true
"forceConsistentCasingInFileNames": true,
"experimentalDecorators": true
},
"exclude": ["./lib/**/*", "./examples/**/*"]
}
5 changes: 5 additions & 0 deletions yarn.lock
Expand Up @@ -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"
Expand Down

0 comments on commit a0693cc

Please sign in to comment.