Skip to content

Commit

Permalink
feat: typed validators (simple cases)
Browse files Browse the repository at this point in the history
Signed-off-by: Samppa Saarela <samppa.saarela@iki.fi>
  • Loading branch information
ssaarela committed Oct 28, 2024
1 parent 24a7a43 commit f51a1ba
Show file tree
Hide file tree
Showing 4 changed files with 270 additions and 281 deletions.
44 changes: 27 additions & 17 deletions packages/core/src/V.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ class DeferredValidator extends Validator {
describe('ValidationResult', () => {
test('getValue() returns valid value', async () => {
const result = await V.string().validate('123');
expect(result.getValue()).toEqual('123');
const str: string = result.getValue();
expect(str).toEqual('123');
});

test('getValue() throws ValidationError if validation failed', async () => {
Expand Down Expand Up @@ -236,8 +237,6 @@ describe('boolean', () => {
});
});

test('empty next', () => expectValid(1, V.number().next()));

describe('uuid', () => {
test('null is not valid', () => expectViolations(null, V.uuid(), defaultViolations.notNull()));

Expand Down Expand Up @@ -400,12 +399,12 @@ describe('objects', () => {
}
}

const modelValidator = V.object({
const modelValidator: Validator<PasswordRequest> = V.object<IPasswordRequest>({
properties: {
password1: V.allOf(V.string(), V.notEmpty()),
password2: [V.string(), V.notEmpty()],
password2: V.compositionOf(V.string(), V.notEmpty()),
},
}).nextMap(value => new PasswordRequest(value as IPasswordRequest));
}).nextMap(value => new PasswordRequest(value));

const validator = modelValidator.next(
V.assertTrue((request: PasswordRequest) => request.password1 === request.password2, 'ConfirmPassword', property('password1')),
Expand All @@ -419,7 +418,11 @@ describe('objects', () => {
});

describe('recursive models', () => {
const validator = V.object({
interface RecursiveModel {
first: string;
next?: RecursiveModel;
}
const validator: Validator<RecursiveModel> = V.object<RecursiveModel>({
properties: {
first: V.string(),
next: V.optional(V.fn((value: any, path: Path, ctx: ValidationContext) => validator.validatePath(value, path, ctx))),
Expand All @@ -437,11 +440,11 @@ describe('objects', () => {
});

describe('custom property filtering ObjectValidator extension', () => {
class DropAllPropertiesValidator extends ObjectValidator {
class DropAllPropertiesValidator<T> extends ObjectValidator<Partial<T>> {
constructor(model: ObjectModel) {
super(model);
}
validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike<ValidationResult> {
validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike<ValidationResult<Partial<T>>> {
return this.validateFilteredPath(value, path, ctx, _ => false);
}
}
Expand Down Expand Up @@ -663,7 +666,12 @@ describe('object next', () => {
});

describe('object localNext', () => {
const parent = V.object({
interface Parent {
name: string;
upper?: boolean;
}
interface Child extends Parent {}
const parent = V.object<Parent>({
properties: {
name: V.string(),
upper: V.optional(V.boolean()),
Expand All @@ -676,7 +684,7 @@ describe('object localNext', () => {
}),
localNext: V.map(obj => `parent:${obj.name}`),
});
const child = V.object({
const child = V.object<Child>({
extends: parent,
localNext: V.map(obj => `child:${obj.name}`),
});
Expand Down Expand Up @@ -890,11 +898,11 @@ describe('arrays', () => {

test('single value to array of one', () => expectValid('foo', stringArray, ['foo']));

test('any item is allowed', () => expectValid(['string'], V.toArray()));
test('any item is allowed', () => expectValid(['string'], V.toArray(V.string())));
});

describe('size', () => {
test('valid', () => expectValid([1], V.size(1, 1)));
test('valid', () => expectValid([1], V.size<number[]>(1, 1)));

test('too short', () => expectViolations([1], V.size(2, 3), defaultViolations.size(2, 3)));

Expand Down Expand Up @@ -1131,11 +1139,11 @@ describe('groups', () => {
});

test('whenGroup not allowed after otherwise', () => {
expect(() => (V.whenGroup('any').otherwise() as WhenGroupValidator).whenGroup('foo')).toThrow();
expect(() => (V.whenGroup('any', V.any()).otherwise(V.any()) as WhenGroupValidator).whenGroup('foo', V.any())).toThrow();
});

test('otherwise not allowed after otherwise', () => {
expect(() => (V.whenGroup('any').otherwise() as WhenGroupValidator).otherwise()).toThrow();
expect(() => (V.whenGroup('any', V.any()).otherwise(V.any()) as WhenGroupValidator).otherwise(V.any())).toThrow();
});

test('Group.of', () => {
Expand Down Expand Up @@ -1226,11 +1234,11 @@ describe('if', () => {
test('else matches invalid', () => expectViolations({}, validator, defaultViolations.string({})));

test('defining else before elseif is not allowed', () => {
expect(() => (V.if(_ => true).else() as IfValidator).elseIf(_ => true)).toThrow();
expect(() => (V.if(_ => true, V.any()).else(V.any()) as IfValidator).elseIf(_ => true, V.any())).toThrow();
});

test('redefining else is not allowed', () => {
expect(() => (V.if(_ => true).else() as IfValidator).else()).toThrow();
expect(() => (V.if(_ => true, V.any()).else(V.any()) as IfValidator).else(V.any())).toThrow();
});

test('no-conditional anomaly', () => expectValid({}, new IfValidator([])));
Expand Down Expand Up @@ -1270,6 +1278,8 @@ describe('normalizers', () => {

test('empty string', () => expectValid('', V.emptyTo('default'), 'default'));

test('empty array', () => expectValid([], V.emptyTo(['default']), ['default']));

test('anything else is passed as is', () => expectValid('anything', V.emptyTo('default')));
});

Expand Down
74 changes: 45 additions & 29 deletions packages/core/src/V.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import {
MappingFn,
Validator,
CheckValidator,
maybeAllOfValidator,
OptionalValidator,
AssertTrue,
IfValidator,
Expand Down Expand Up @@ -60,7 +59,6 @@ const ignoreValidator = new IgnoreValidator(),
anyValidator = new AnyValidator(),
stringValidator = new StringValidator(),
toStringValidator = new StringNormalizer(),
notNullValidator = new NotNullOrUndefinedValidator(),
nullOrUndefinedValidator = new IsNullOrUndefinedValidator(),
notEmptyValidator = new NotEmptyValidator(),
notBlankValidator = new NotBlankValidator(),
Expand All @@ -75,29 +73,47 @@ const ignoreValidator = new IgnoreValidator(),
dateValidator = new DateValidator(ValidatorType.Date);

export const V = {
fn: (fn: ValidatorFn, type?: string) => new ValidatorFnWrapper(fn, type),
fn: <T>(fn: ValidatorFn<T>, type?: string) => new ValidatorFnWrapper<T>(fn, type),

map: (fn: MappingFn, error?: any) => new ValueMapper(fn, error),
map: <T>(fn: MappingFn<T>, error?: any) => new ValueMapper<T>(fn, error),

ignore: () => ignoreValidator,

any: () => anyValidator,

check: (...allOf: Validator[]) => new CheckValidator(maybeAllOfValidator(allOf)),

optional: (type: Validator, ...allOf: Validator[]) => new OptionalValidator(type, allOf),

required: (type: Validator, ...allOf: Validator[]) => new RequiredValidator(type, allOf),

if: (fn: AssertTrue, ...allOf: Validator[]) => new IfValidator([new Conditional(fn, allOf)]),

whenGroup: (group: GroupOrName, ...allOf: Validator[]) => new WhenGroupValidator([new WhenGroup(group, allOf)]),
check: <T>(...validators: [...Validator<unknown>[], Validator<T>]) => {
if (validators.length === 1) {
return new CheckValidator<T>(validators[0] as Validator<T>);
} else {
return new CheckValidator<T>(new CompositionValidator<T>(validators))
}

Check warning on line 89 in packages/core/src/V.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/V.ts#L88-L89

Added lines #L88 - L89 were not covered by tests
},

optional: <T>(...validators: [...Validator<unknown>[], Validator<T>]) => {
if (validators.length === 1) {
return new OptionalValidator<T>(validators[0] as Validator<T>);
} else {
return new OptionalValidator<T>(new CompositionValidator<T>(validators))
}
},

required: <T>(...validators: [...Validator<unknown>[], Validator<T>]) => {
if (validators.length === 1) {
return new RequiredValidator<T>(validators[0] as Validator<T>);
} else {
return new RequiredValidator<T>(new CompositionValidator<T>(validators))
}
},

if: <T>(fn: AssertTrue, validator: Validator<T>) => new IfValidator<T>([new Conditional<T>(fn, validator)]),

whenGroup: <T>(group: GroupOrName, validator: Validator<T>) => new WhenGroupValidator([new WhenGroup(group, validator)]),

string: () => stringValidator,

toString: () => toStringValidator,

notNull: () => notNullValidator,
notNull: <T>() => new NotNullOrUndefinedValidator<T>(),

nullOrUndefined: () => nullOrUndefinedValidator,

Expand All @@ -111,7 +127,7 @@ export const V = {

undefinedToNull: () => undefinedToNullValidator,

emptyTo: (defaultValue: any) => new ValueMapper((value: any) => (isNullOrUndefined(value) || value === '' ? defaultValue : value)),
emptyTo: <T extends { length: number }>(defaultValue: T) => new ValueMapper<T>((value: T) => (isNullOrUndefined(value) || value.length === 0 ? defaultValue : value)),

uuid: (version?: number) => new UuidValidator(version),

Expand All @@ -135,11 +151,11 @@ export const V = {

max: (max: number, inclusive = true) => new MaxValidator(max, inclusive),

object: (model: ObjectModel) => new ObjectValidator(model),
object: <T>(model: ObjectModel) => new ObjectValidator<T>(model),

toObject: (property: string) => new ObjectNormalizer(property),

schema: (fn: (schema: SchemaValidator) => SchemaModel) => new SchemaValidator(fn),
schema: <T>(fn: (schema: SchemaValidator<T>) => SchemaModel) => new SchemaValidator<T>(fn),

/** WARN: Objects as Map keys use identity hash/equals, i.e. === */
mapType: (keys: Validator, values: Validator, jsonSafeMap: boolean = true) => new MapValidator(keys, values, jsonSafeMap),
Expand All @@ -155,30 +171,30 @@ export const V = {

nullToArray: () => new ValueMapper((value: any) => (isNullOrUndefined(value) ? [] : value)),

array: (...items: Validator[]) => new ArrayValidator(maybeAllOfValidator(items)),
array: <T>(items: Validator<T>) => new ArrayValidator<T>(items),

toArray: (...items: Validator[]) => new ArrayNormalizer(maybeAllOfValidator(items)),
toArray: <T>(items: Validator<T>) => new ArrayNormalizer<T>(items),

size: (min: number, max: number) => new SizeValidator(min, max),
size: <T extends { length: number }>(min: number, max: number) => new SizeValidator<T>(min, max),

properties: (keys: Validator | Validator[], values: Validator | Validator[]) => new ObjectValidator({ additionalProperties: { keys, values } }),
properties: (keys: Validator, values: Validator) => new ObjectValidator({ additionalProperties: { keys, values } }),

allOf: (...validators: Validator[]) => new AllOfValidator(validators),
allOf: (...validators: [Validator, ...Validator[]]) => new AllOfValidator(validators),

anyOf: (...validators: Validator[]) => new AnyOfValidator(validators),
anyOf: (...validators: [Validator, ...Validator[]]) => new AnyOfValidator(validators),

oneOf: (...validators: Validator[]) => new OneOfValidator(validators),
oneOf: (...validators: [Validator, ...Validator[]]) => new OneOfValidator(validators),

compositionOf: (...validators: Validator[]) => new CompositionValidator(validators),
compositionOf: <T>(...validators: [...Validator<any>[], Validator<T>]) => new CompositionValidator<T>(validators),

date: () => dateValidator,

enum: (enumType: object, name: string) => new EnumValidator(enumType, name),
enum: <T extends {[key: number]: string | number}>(enumType: T, name: string) => new EnumValidator(enumType, name),

assertTrue: (fn: AssertTrue, type: string = 'AssertTrue', path?: Path) => new AssertTrueValidator(fn, type, path),
assertTrue: <T>(fn: AssertTrue<T>, type: string = 'AssertTrue', path?: Path) => new AssertTrueValidator<T>(fn, type, path),

hasValue: (expectedValue: any) => new HasValueValidator(expectedValue),
hasValue: <T>(expectedValue: T) => new HasValueValidator(expectedValue),

json: (...validators: Validator[]) => new JsonValidator(validators),
json: <T>(validator: Validator<T>) => new JsonValidator(validator),
};
Object.freeze(V);
20 changes: 10 additions & 10 deletions packages/core/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,21 +27,21 @@ export interface SchemaModel {

export type ClassParentModel = string | ObjectValidator | (string | ObjectValidator)[];

export interface ClassModel {
export interface ClassModel<T = unknown> {
readonly properties?: PropertyModel;
readonly additionalProperties?: boolean | MapEntryModel | MapEntryModel[];
readonly extends?: ClassParentModel;
readonly localProperties?: PropertyModel;
readonly next?: Validator;
readonly next?: Validator<T>;
readonly localNext?: Validator;
}

export class ModelRef extends Validator {
constructor(private schema: SchemaValidator, public readonly name: string) {
export class ModelRef<T> extends Validator<T> {
constructor(private schema: SchemaValidator<T>, public readonly name: string) {
super();
Object.freeze(this);
}
validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike<ValidationResult> {
validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike<ValidationResult<T>> {
return this.schema.validateClass(value, path, ctx, this.name);
}
}
Expand All @@ -52,14 +52,14 @@ export class DiscriminatorViolation extends Violation {
}
}

export class SchemaValidator extends Validator {
export class SchemaValidator<T> extends Validator<T> {
public readonly discriminator: Discriminator;

private readonly proxies = new Map<string, Validator>();

private readonly validators: { [name: string]: Validator } = {};

constructor(fn: (schema: SchemaValidator) => SchemaModel) {
constructor(fn: (schema: SchemaValidator<T>) => SchemaModel) {
super();
const schema = fn(this);
for (const name of this.proxies.keys()) {
Expand All @@ -74,11 +74,11 @@ export class SchemaValidator extends Validator {
Object.freeze(this);
}

validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike<ValidationResult> {
validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike<ValidationResult<T>> {
return this.validateClass(value, path, ctx);
}

validateClass(value: any, path: Path, ctx: ValidationContext, expectedType?: string): PromiseLike<ValidationResult> {
validateClass(value: any, path: Path, ctx: ValidationContext, expectedType?: string): PromiseLike<ValidationResult<T>> {
if (isNullOrUndefined(value)) {
return ctx.failurePromise(defaultViolations.notNull(path), value);
}
Expand All @@ -105,7 +105,7 @@ export class SchemaValidator extends Validator {
}

// 3) Validate value
return validator.validatePath(value, path, ctx);
return validator.validatePath(value, path, ctx) as PromiseLike<ValidationResult<T>>;
}

of(name: string): Validator {
Expand Down
Loading

0 comments on commit f51a1ba

Please sign in to comment.