diff --git a/package.json b/package.json index 8d4bf7e..fc68bb2 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "lerna": "8.1.2", "prettier": "2.7.1", "ts-node": "10.9.1", - "typescript": "5.2.2", + "typescript": "5.6.3", "moment": "2.30.1", "luxon": "3.4.4", "@types/luxon": "3.4.2" diff --git a/packages/core/README.md b/packages/core/README.md index e63a70f..a97c68d 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -23,6 +23,25 @@ Or [`npm`](https://www.npmjs.com/): npm install @finnair/v-validation ``` +## Major Changes Comming in Version 7 +### New Features +* **Typing**: Validators may have a specific input and especially output type. +* `V.objectType()` builder can be used to build an ObjectValidator with inferred type. +* Validator (output/result) type can be acquired with `VType`. +* Direct, chainable support for most used "next" validation rules, e.g. `V.number().min(1).max(2)`: + * `V.string()` supports `notEmpty`, `notBlank` and `pattern`. + * `V.number()` supports `min` and `max`. +* Use `Validator#validateValue` to get valid a valid value or an exception directly + +### Breaking changes: +* V.string() and some other validators do not support String object as input any more. +* V.number() does not support Number object as input any more. +* Validators that accept multiple subvalidators (`V.optional`, `V.required`, `V.check`, `V.if`, `V.whenGroup`, `V.json` and `ObjectModel#next`) are combined using `V.compositionOf` instead of `V.allOf` as composition makes more sense in general. However, if there are multiple parents with next validators, those are still combined with `V.allOf` as they are not aware of each other. +* More straightforward internal architecture: + * Internal Validator#validatePath returns now a Promise of valid value or reject of Violation(s) directly instead of ValidationResult + * Custom SyncPromise is removed in favor of Promise.resolve and reject. + * ValidatorContext no longer has `success`, `successPromise`, `failurePromise` and `promise` functions - use `Promise.resolve(value)` or `Promise.reject(new Violation(...))` with single violation or an array of violations. + ## Show Me the Code! ```typescript @@ -46,7 +65,7 @@ npm install @finnair/v-validation Validators can be chained and combined. ```typescript -const percentageValidator = V.integer().next(V.min(0), V.max(100)); +const percentageValidator = V.integer().min(0).max(100)); (await percentageValidator.validate(123)).getValue(); // ValidationError: [ // { @@ -114,30 +133,30 @@ const base64json = V.map(value => JSON.parse(new Buffer(value, 'base64').toStrin Even complex custom validators can be implemented as simple anonymous functions. ```typescript -// 1) MODEL -interface UserRegistration { - password1: string; - password2: string; -} - -// 2) VALIDATION RULES +// 1) VALIDATION RULES // A custom validator to check that password1 === password2 with failures targeted at password2 field -const checkPassword = V.fn(async (value: UserRegistration, path: Path, ctx: ValidationContext) => { - if (value.password1 !== value.password2) { - return ctx.failure(new Violation(path.property('password2'), 'PasswordsMustMatch'), value); - } - return ctx.success(value); -}); -const UserRegistrationValidator = V.object({ - properties: { +// Use V.objectType() to build typed ObjectValidator. The inferred type is accesseible with Vtype +const UserRegistrationValidator = V.objectType() + .properties({ password1: V.string().next(V.pattern(/[A-Z]/), V.pattern(/[a-z]/), V.pattern(/[0-9]/), V.size(8, 32)), password2: V.string(), - }, - // next: checkPassword, /* An alternative way of defining cross-property rules. This allows extending UserRegistrationValidator. */ -}).next(checkPassword); // Because of this, UserRegistrationValidator is actually a NextValidator, which cannot be extended by V.object(). + }) + .next(V.fn(async (value, path: Path, ctx: ValidationContext) => { + if (value.password1 !== value.password2) { + return Promise.reject(new Violation(path.property('password2'), 'PasswordsMustMatch')); + } + return Promise.resolve(value); + })) + .build(); + +// 2) Derived type +type UserRegistration = VType; // 3) INPUT VALIDATION +// Valid object +(await UserRegistrationValidator.validate({ password1: 'foo', password2: 'foo' })).getValue() satisfies UserRegistration; + (await UserRegistrationValidator.validate({ password1: 'FooBar' })).getValue(); // ValidationError: ValidationError: [ // { @@ -199,7 +218,7 @@ Conversions are always applied internally as in validation rule combinations lat ## Validator Chaining -All validators can be chained using `Validator.next(...allOf: Validator[])` function. Next-validators are only run for successful results with the converted value. Often occurring pattern is to first verify/convert the type and then run the rest of the validations, e.g. validating a prime number between 1 and 1000: +All validators can be chained using `Validator.next(...compositionOf: Validator[])` function. Next-validators are only run for successful results with the converted value. Often occurring pattern is to first verify/convert the type and then run the rest of the validations, e.g. validating a prime number between 1 and 1000: ```typescript V.toInteger().next(V.min(1), V.max(1000), V.assertTrue(isPrime)); @@ -209,12 +228,13 @@ V.toInteger().next(V.min(1), V.max(1000), V.assertTrue(isPrime)); `V` supports +- All validators have [`Validator.next`](#next) function to chain validator rules,. +- `compositionOf` - validators are run one after another against the (current) converted value (a shortcut for [`Validator.next`](#next)) - `allOf` - value must satisfy all the validators - validators are run in parallel and the results are combined - if conversion happens, all the validators must return the same value (deepEquals) - `anyOf` - at least one of the validators must match - `oneOf` - exactly one validator must match while others should return false -- `compositionOf` - validators are run one after another against the (current) converted value (a shortcut for [`Validator.next`](#next)) ## V.object @@ -234,45 +254,57 @@ An object may have any named property defined in a parent `properties`, it's own A child model may extend the validation rules of any inherited properties. In such a case inherited property validators are executed first and, if success, the converted value is validated against child's property validators. A child may only further restrict parent's property rules. ```typescript -const vehicle = V.object({ - properties: { + const vehicle = V.objectType() + .properties({ wheelCount: V.required(V.toInteger(), V.min(0)), - ownerName: V.optional(V.string()), - }, - localProperties: { - type: 'Vehicle', // This rule is not inherited! A string or number value is a shortcur for V.hasValue(...). - }, -}); - -const bike = V.object({ - extends: vehicle, - properties: { + ownerName: V.optionalStrict(V.string()), + }) + .localProperties({ + // This rule is not inherited! "as const" for 'Vehicle' instead of string type + type: V.hasValue('Vehicle' as const), + }) + .build(); + + const bike = V.objectType() + .extends(vehicle) + .properties({ wheelCount: V.allOf(V.min(1), V.max(3)), // Extend parent rules sideBags: V.boolean(), // Add a property - }, - localProperties: { - type: 'Bike', - }, -}); - -const abike = { type: 'Bike', wheelCount: 2, sideBags: false }; - -(await bike.validate(abike)).isSuccess(); -// true - -(await vehicle.validate(abike)).getValue(); -// ValidationError: [ -// { -// "path": "$.type", -// "type": "HasValue", -// "invalidValue": "Bike", -// "expectedValue": "Vehicle" -// }, -// { -// "path": "$.sideBags", -// "type": "UnknownProperty" -// } -// ] + }) + .localProperties({ + type: V.hasValue('Bike' as const), + }) + .build(); + + const abike1 = { type: 'Bike', wheelCount: 2, sideBags: false } satisfies VType; + (await bike.validate(abike1)).isSuccess(); + // true + + const abike2 = { type: 'Bike', wheelCount: 4, sideBags: false } satisfies VType; + (await bike.validate(abike2)).getValue(); + // ValidationError: [ + // { + // "path": "$.wheelCount", + // "type": "Max", + // "invalidValue": 4, + // "max": 3, + // "inclusive": true + // } + // ] + + (await vehicle.validate(abike1)).getValue(); + // ValidationError: [ + // { + // "path": "$.type", + // "type": "HasValue", + // "invalidValue": "Bike", + // "expectedValue": "Vehicle" + // }, + // { + // "path": "$.sideBags", + // "type": "UnknownProperty" + // } + // ] ``` ### Optional Properties @@ -468,7 +500,7 @@ Options are passed to to `validate` function as optional second argument. | ignoreUnknownEnumValues?: boolean | Unknown enum values allowed by default | | warnLogger?: WarnLogger | A reporter function for ignored Violations | | group?: Group | A group used to activate validation rules | -| allowCycles?: boolean  | Multiple references to same object allowed | +| allowCycles?: boolean | Multiple references to same object allowed | \*) Note that this option has no effect in cases where additional properties are explicitly denied. @@ -494,8 +526,10 @@ V.map(...) // 3) If a validator doesn't have any parameters, but needs access to path and context, -// it can be defined as a simple anonymous function: -V.fn((value: any, path: Path, ctx: ValidationContext): Promise => {...}) +// it can be defined as a simple anonymous function +V.fn((value: any, path: Path, ctx: ValidationContext): PromiseLike => { + // return either successful or rejected Promise or throw an error +}) // 4) Full parametrizable validators extend Validator @@ -506,9 +540,9 @@ class MyValidator extends Validator { } async validatePath(value: any, path: Path, ctx: ValidationContext) { if (isOK(value)) { - return ctx.success(value); + return Promise.resolve(value); } else { - return ctx.failure(new MyViolation(path, myParameter, value)); + return Promise.reject(new MyViolation(path, myParameter, value)); } } } @@ -527,13 +561,16 @@ Unless otherwise stated, all validators require non-null and non-undefined value | V. | Arguments | Description | | ----------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| fn  |  fn: ValidatorFn, type?: string | Function reference as a validator. A short cut for extending Validator class. | +| fn | fn: ValidatorFn, type?: string | Function reference as a validator. A short cut for extending Validator class. | | ignore | | Converts any input value to undefined. | | any | | Accepts any value, including undefined and null. | -| check | ...allOf: Validator[] | Runs all the validators and, if successful, returns the original value discarding any conversions. | | map | fn: MappingFn, error?: any | Mapping function to convert a value. Catches and converts errors to Violations | -| optional | type: Validator, ...allOf: Validator[] | Allows null and undefined. For other values, runs first the `type` validator and then `allOf` the rest validators. | -| required | type: Validator, ...allOf: Validator[] | 1) Requires a non-null and non-undefined value, 2) runs `type` validator and 3) then `allOf` the rest validators. | +| compositionOf | ...validators: Validator[] | Runs given the validators one after another, chaining the result. | +| check | ...validators: Validator[] | Runs all the validators as `compositionOf` and, if successful, returns the original value discarding any conversions. | +| required | ...validators: Validator[] | A non-null and non-undefined valid `compositionOf` of validators. | +| optional | ...validators: Validator[] | Null, undefined or valid `compositionOf` validators. | +| optionalStrict | ...validators: Validator[] | Undefined or valid `compositionOf` validators. | +| nullable | ...validators: Validator[] | Null or valid `compositionOf` validators. | | string | | Requires string or String. | | toString | | Converts primitive values to (primitive) strings. | | notNull | | Requires non-null and non-undefined value. | @@ -547,11 +584,11 @@ Unless otherwise stated, all validators require non-null and non-undefined value | toBoolean | truePattern?: RegExp, falsePattern?: RegExp | Converts strings and numbers to boolean. Patterns for true and false can be configured using regexp, defaults to true and false. | | number | | Requires that input is either primitive number or Number and not NaN. | | toNumber | | Converts numeric strings to numbers. | -| integer | |  Requires that input is integer. | +| integer | | Requires that input is integer. | | toInteger | | Converts numeric strings to integers. | | min | min: number, inclusive = true | Asserts that numeric input is greater than or equal (if inclusive = true) than `min`. | | max | max: number, inclusive = true | Asserts that numeric input is less than or equal (if inclusive = true) than `max`. | -| date |   | Reqruires a valid date. Converts string to Date. | +| date | | Reqruires a valid date. Converts string to Date. | | enum | enumType: object, name: string | Requires that the input is one of given enumType. Name of the enum provided for error message. | | assertTrue | fn: AssertTrue, type: string = 'AssertTrue', path?: Path | Requires that the input passes `fn`. Type can be provided for error messages and path to target a sub property | | hasValue | expectedValue: any | Requires that the input matches `expectedValue`. Uses `node-deep-equal` library. | @@ -563,18 +600,17 @@ Unless otherwise stated, all validators require non-null and non-undefined value | toMapType(keys, values) | keys: Validator, values: Validator | Converts an array-of-arrays representation of a Map into a JsonSafeMap instance. | | array | ...items: Validator[] | [Array validator](#array) | | toArray | items: Validator | Converts undefined to an empty array and non-arrays to single-valued arrays. | -| size | min: number, max: number |  Asserts that input's numeric `length` property is between min and max (both inclusive). | +| size | min: number, max: number | Asserts that input's numeric `length` property is between min and max (both inclusive). | | allOf | ...validators: Validator[] | Requires that all given validators match. Validators are run in parallel and in case they convert the input, all must provide same output. | | anyOf | ...validators: Validator[] | Requires minimum one of given validators matches. Validators are run in parallel and in case of failure, all violations will be returned. | | oneOf | ...validators: Validator[] | Requires that exactly one of the given validators match. | -| compositionOf | ...validators: Validator[] | Runs given the validators one after another, chaining the result. | | emptyToUndefined | | Converts null or empty string to undefined. Does not touch any other values. | | emptyToNull | | Converts undefined or empty string to null. Does not touch any other values. | | emptyTo | defaultValue: string | Uses given `defaultValue` in place of null, undefined or empty string. Does not touch any other values. | | nullTo | defaultValue: string | Uses given `defaultValue` in place of null. Does not touch any other values. | | undefinedToNull | | Convets undefined to null. Does not touch any other values. | -| if...elseif...else | fn: AssertTrue, ...allOf: Validator[] | Configures validators (`allOf`) to be executed for cases where if/elseif AssertTrue fn returns true. | -| whenGroup...otherwise | group: GroupOrName, ...allOf: Validator[] | Defines validation rules (`allOf`) to be executed for given `ValidatorOptions.group`. | +| if...elseif...else | fn: AssertTrue, ...validators: Validator[] | Configures validators (`compositionOf`) to be executed for cases where if/elseif AssertTrue fn returns true. | +| whenGroup...otherwise | group: GroupOrName, ...validators: Validator[] | Defines validation rules (`compositionOf`) to be executed for given `ValidatorOptions.group`. | | json | ...validators: Validator[] | Parse JSON input and validate it against given validators. | ## Violations @@ -591,23 +627,23 @@ All `Violations` have following propertie in common: ### Built-in Violations -| Class | Type | Properties | Description | -| ---------------------- | --------------------- | -------------------------------- | ----------------------------------------------------------------------------------- | -| TypeMismatch | TypeMismatch | expected: string | Type mismatch: `expected` is a description of expected type. | -| EnumMismatch | EnumMismatch | enumType: string | Invalid enum value: `enumType` is the name of the expected enumeration. | -| ErrorViolation | Error | error: any | An unspecified Error that was thrown and caught. | -| HasValueViolation | HasValue | expectedValue: any | Input does not match (deepEqual) expectedValue. | -| PatternViolationi | Pattern | pattern: string | Input does not match the regular expression (pattern). | -| OneOfMismatch | OneOf | matches: number | Input matches 0 or >= 2 of the configured validators. | -| MaxViolation | Max | max: number, inclusive: boolean | Input is greater-than or greater-than-or-equal, if `inclusive=true`, than `max`. | -| MinViolation | Min |  min: number, inclusive: boolean | Input is less-than or less-than-or-equal if inclusive=true than `min`. | -| SizeViolation | Size | min: number, max: number | Input `length` (required numeric property) is less-than `min` or grater-than `max`. | -| Violation | NotNull | | Input is `null` or `undefined`. | -| Violation | NotEmpty | | Input is `null`, `undefined` or empty (i.e. input.length === 0). | -| Violation | NotBlank | | Input (string) is `null`, `undefined` or empty when trimmed. | -| Violation | UnknownProperty | | Additional property that is denied by default (see ignoreUnknownProperties). | -| Violation | UnknownPropertyDenied | | Explicitly denied additional property. | -| DiscriminatorViolation | Discriminator | expectedOneOf: string[] | Invalid discriminator value: `expectedOneOf` is a list of known types. | +| Class | Type | Properties | Description | +| ---------------------- | --------------------- | ------------------------------- | ----------------------------------------------------------------------------------- | +| TypeMismatch | TypeMismatch | expected: string | Type mismatch: `expected` is a description of expected type. | +| EnumMismatch | EnumMismatch | enumType: string | Invalid enum value: `enumType` is the name of the expected enumeration. | +| ErrorViolation | Error | error: any | An unspecified Error that was thrown and caught. | +| HasValueViolation | HasValue | expectedValue: any | Input does not match (deepEqual) expectedValue. | +| PatternViolationi | Pattern | pattern: string | Input does not match the regular expression (pattern). | +| OneOfMismatch | OneOf | matches: number | Input matches 0 or >= 2 of the configured validators. | +| MaxViolation | Max | max: number, inclusive: boolean | Input is greater-than or greater-than-or-equal, if `inclusive=true`, than `max`. | +| MinViolation | Min | min: number, inclusive: boolean | Input is less-than or less-than-or-equal if inclusive=true than `min`. | +| SizeViolation | Size | min: number, max: number | Input `length` (required numeric property) is less-than `min` or grater-than `max`. | +| Violation | NotNull | | Input is `null` or `undefined`. | +| Violation | NotEmpty | | Input is `null`, `undefined` or empty (i.e. input.length === 0). | +| Violation | NotBlank | | Input (string) is `null`, `undefined` or empty when trimmed. | +| Violation | UnknownProperty | | Additional property that is denied by default (see ignoreUnknownProperties). | +| Violation | UnknownPropertyDenied | | Explicitly denied additional property. | +| DiscriminatorViolation | Discriminator | expectedOneOf: string[] | Invalid discriminator value: `expectedOneOf` is a list of known types. | ## Roadmap diff --git a/packages/core/src/V.spec.ts b/packages/core/src/V.spec.ts index 5d7dc07..15745d6 100644 --- a/packages/core/src/V.spec.ts +++ b/packages/core/src/V.spec.ts @@ -6,7 +6,6 @@ import { defaultViolations, ValidationError, TypeMismatch, - ObjectValidator, Group, Groups, NumberFormat, @@ -14,15 +13,15 @@ import { isString, ValidationContext, ErrorViolation, - ObjectModel, IfValidator, WhenGroupValidator, HasValueViolation, SizeViolation, EnumMismatch, - SyncPromise, JsonSet, -} from './validators'; + VType, +} from './validators.js'; +import { ObjectValidator, ObjectModel } from './objectValidator.js'; import { V } from './V.js'; import { Path } from '@finnair/path'; import { expectUndefined, expectValid, expectViolations, verifyValid } from './testUtil.spec.js'; @@ -48,17 +47,17 @@ const failAlwaysViolation = (path: Path = ROOT) => new Violation(path, 'Fail'); const validDateString = '2019-01-23T09:10:00Z'; const validDate = new Date(Date.UTC(2019, 0, 23, 9, 10, 0)); -function defer(validator: Validator, ms: number = 1) { - return new DeferredValidator(validator, ms); +function defer(validator: Validator, ms: number = 1) { + return new DeferredValidator(validator, ms); } -class DeferredValidator extends Validator { - constructor(public validator: Validator, public ms: number = 1) { +class DeferredValidator extends Validator { + constructor(public validator: Validator, public ms: number = 1) { super(); } - async validatePath(value: any, path: Path, ctx: ValidationContext): Promise { - return new Promise((resolve, reject) => { + async validatePath(value: any, path: Path, ctx: ValidationContext): Promise { + return new Promise((resolve, reject) => { setTimeout(() => { this.validator.validatePath(value, path, ctx).then(resolve, reject); }, this.ms); @@ -69,12 +68,13 @@ 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 () => { try { - const result = await V.string().validate(123); + const result = await V.string().validate(123 as any); result.getValue(); fail('expected ValidationError'); } catch (e) { @@ -82,8 +82,28 @@ describe('ValidationResult', () => { expect((e as ValidationError).violations).toEqual([defaultViolations.string(123)]); } }); + + test('value xor violations', () => { + expect(() => new ValidationResult([defaultViolations.notNull()], 'value')) + .toThrow('both violations and success value defined'); + }) }); +describe('getValid', () => { + test('valid string', async () => { + const value = await V.string().getValid('string'); + expect(value).toEqual('string'); + }); + test('invalid string', async () => { + try { + await V.string().getValid(123 as any); + fail('Expected getValue to throw an error') + } catch (error) { + // as expected + } + }); +}) + test('assertTrue', () => expectValid( true, @@ -93,7 +113,12 @@ test('assertTrue', () => describe('strings', () => { test('valid value', () => expectValid('str', V.string())); - test('number is not accepted', () => expectViolations(123, V.string(), defaultViolations.string(123))); + test('String objects do not pass as string primitives', () => { + const str = new String('String'); + expectViolations(str, V.string(), defaultViolations.string(str)) + }); + + test('number is not accepted', () => expectViolations(123 as any, V.string(), defaultViolations.string(123))); test('null is not accepted', () => expectViolations(null, V.string(), defaultViolations.notNull())); @@ -108,6 +133,8 @@ describe('strings', () => { describe('toString', () => { test('string as such', () => expectValid('abc', V.toString())); + test('convert String to string', () => expectValid(new String('String'), V.toString(), 'String')); + test('convert number to string', () => expectValid(123, V.toString(), '123')); test('no not convert object to string', () => expectViolations({}, V.toString(), new TypeMismatch(ROOT, 'primitive value', {}))); @@ -121,6 +148,14 @@ describe('strings', () => { test('undefined is not allowed', () => expectViolations(undefined, V.toString(), defaultViolations.notNull())); }); + describe('string validator chaining', () => { + test('notBlank > notEmpty > pattern', () => expectValid('A', V.string().notBlank().notEmpty().pattern(/^[A-Z]$/))); + + test('first validator failure', () => { + expectViolations(123 as any, V.string().notBlank(), new TypeMismatch(ROOT, 'string', 123)); + }) + }) + describe('NotBlank', () => { test('valid string', () => expectValid(' A ', V.notBlank())); @@ -128,7 +163,7 @@ describe('strings', () => { test('undefined is invalid', () => expectViolations(null, V.notBlank(), defaultViolations.notBlank())); - test('non-string is invalid', () => expectViolations(123, V.notBlank(), defaultViolations.string(123))); + test('non-string is invalid', () => expectViolations(123 as any, V.notBlank(), defaultViolations.string(123))); test('blank is invalid', () => expectViolations(' \t\n ', V.notBlank(), defaultViolations.notBlank())); }); @@ -136,7 +171,7 @@ describe('strings', () => { describe('pattern', () => { const pattern = '^[A-Z]{3}$'; const regexp = new RegExp(pattern); - test('valid string', () => expectValid('ABC', V.pattern(regexp))); + test('valid string', () => expectValid('ABC', V.string().pattern(regexp))); test('too short', () => expectViolations('AB', V.pattern(pattern), defaultViolations.pattern(regexp, 'AB'))); @@ -236,8 +271,6 @@ describe('boolean', () => { }); }); -test('empty next', () => expectValid(1, V.number().next())); - describe('uuid', () => { test('null is not valid', () => expectViolations(null, V.uuid(), defaultViolations.notNull())); @@ -314,7 +347,7 @@ describe('objects', () => { test('explicitly denied additionalProperties are still not allowed', async () => { const object = { unknownProperty: true }; - const result = await V.object({ additionalProperties: false }).validate(object, { ignoreUnknownProperties: true }); + const result = await V.objectType().allowAdditionalProperties(false).build().validate(object, { ignoreUnknownProperties: true }); expect(result).toEqual(new ValidationResult([defaultViolations.unknownPropertyDenied(property('unknownProperty'))])); }); }); @@ -349,13 +382,14 @@ describe('objects', () => { }); describe('extending additional property validations', () => { - const validator = V.object({ - extends: { - additionalProperties: { - keys: V.any(), - values: V.toInteger(), - }, + const parent = V.object({ + additionalProperties: { + keys: V.any(), + values: V.toInteger(), }, + }); + const validator = V.object({ + extends: parent, additionalProperties: { keys: V.any(), values: V.min(1), @@ -378,7 +412,8 @@ describe('objects', () => { test('number not allowed', () => expectViolations(123, V.object({}), defaultViolations.object())); test('parent may disallow additional properties for child models', async () => { - const validator = V.object({ extends: { additionalProperties: false }, additionalProperties: true }); + const parent = V.object({ additionalProperties: false }); + const validator = V.object({ extends: parent, additionalProperties: true }); await expectViolations({ property: 'string' }, validator, defaultViolations.unknownPropertyDenied(ROOT.property('property'))); }); @@ -389,23 +424,19 @@ describe('objects', () => { password1: string; password2: string; } - class PasswordRequest implements IPasswordRequest { + class PasswordRequest { password1: string; - password2: string; - constructor(properties: IPasswordRequest) { this.password1 = properties.password1; this.password2 = properties.password2; } } - - const modelValidator = V.object({ - properties: { - password1: V.allOf(V.string(), V.notEmpty()), - password2: [V.string(), V.notEmpty()], - }, - }).nextMap(value => new PasswordRequest(value as IPasswordRequest)); + const modelValidator = V.objectType().properties({ + password1: V.compositionOf(V.string(), V.notEmpty()), + password2: V.compositionOf(V.string(), V.notBlank()), + }).next(V.map(value => new PasswordRequest(value))) + .build() const validator = modelValidator.next( V.assertTrue((request: PasswordRequest) => request.password1 === request.password2, 'ConfirmPassword', property('password1')), @@ -419,10 +450,14 @@ describe('objects', () => { }); describe('recursive models', () => { - const validator = V.object({ + interface RecursiveModel { + first: string; + next?: RecursiveModel; + } + const validator = V.object({ properties: { first: V.string(), - next: V.optional(V.fn((value: any, path: Path, ctx: ValidationContext) => validator.validatePath(value, path, ctx))), + next: V.optionalStrict(V.fn((value: any, path: Path, ctx: ValidationContext): PromiseLike => validator.validatePath(value, path, ctx))), }, }); @@ -437,11 +472,11 @@ describe('objects', () => { }); describe('custom property filtering ObjectValidator extension', () => { - class DropAllPropertiesValidator extends ObjectValidator { + class DropAllPropertiesValidator extends ObjectValidator> { constructor(model: ObjectModel) { super(model); } - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { + validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike> { return this.validateFilteredPath(value, path, ctx, _ => false); } } @@ -475,20 +510,19 @@ describe('objects', () => { }); describe('localProperties', () => { - const parent = V.object({ - properties: { + const parent = V.objectType().properties({ type: V.string(), - }, - localProperties: { - type: 'Parent', - }, - }); - const child = V.object({ - extends: parent, - localProperties: { - type: 'Child', - }, - }); + }).localProperties({ + type: V.hasValue<'Parent'>('Parent'), + }).build(); + const child = V.objectType() + .extends(parent) + .localProperties({ + type: V.hasValue<'Child'>('Child'), + }).build(); + + ({ type: 'Parent' }) satisfies VType; + ({ type: 'Child' }) satisfies VType; test('valid parent', () => expectValid({ type: 'Parent' }, parent)); @@ -508,6 +542,20 @@ describe('objects', () => { test('object', () => expectValid({}, V.toObject('value'))); }); + + describe('derived validators', () => { + const base = V.objectType().properties({ prop1: V.string(), prop2: V.number()}).localProperties({ local: V.boolean() }).build(); + test('omit', async () => { + const omit = base.omit('prop1', 'local'); + const value = ({ prop2: 123 }) satisfies VType; + expectValid(value, omit); + }) + test('pick', async () => { + const pick = base.pick('prop1', 'local'); + const value = ({ prop1: 'foo', local: true }) satisfies VType; + expectValid(value, pick); + }) + }); }); describe('additionalProperties', () => { @@ -550,28 +598,20 @@ describe('additionalProperties', () => { }); describe('inheritance', () => { - const parentValidator: ObjectValidator = V.object({ - properties: { - id: V.notNull(), - }, - }); - const childValidator: ObjectValidator = V.object({ - extends: parentValidator, - properties: { + const parentValidator = V.objectType().properties({ id: V.notNull()}).build(); + + const childValidator = V.objectType().extends(parentValidator).properties({ id: V.string(), // Additional requirements for the id property - name: V.notEmpty(), - }, - }); - const addionalPropertiesAllowed: ObjectValidator = V.object({ - additionalProperties: true, - properties: {}, - }); - const multiParentChild: ObjectValidator = V.object({ - extends: [childValidator, addionalPropertiesAllowed], - properties: { - anything: V.notNull(), - }, - }); + name: V.string().notEmpty(), + }).build(); + + const addionalPropertiesAllowed = V.objectType().allowAdditionalProperties(true).build(); + + const multiParentChild = V.objectType() + .extends(childValidator) + .extends(addionalPropertiesAllowed) + .properties({ anything: V.notNull() }).build(); + test('valid parent', () => expectValid({ id: 123 }, parentValidator)); test('valid child', () => expectValid({ id: '123', name: 'child' }, childValidator)); @@ -589,25 +629,26 @@ describe('inheritance', () => { name: 'multi-parent', anything: true, additionalProperty: 123, - }, + } satisfies VType, multiParentChild, )); test('invalid multi-parent object', () => expectViolations( - { additionalProperty: 123 }, + { additionalProperty: 123, name: '' }, multiParentChild, + defaultViolations.notNull(property('anything')), defaultViolations.notNull(property('id')), defaultViolations.notEmpty(property('name')), - defaultViolations.notNull(property('anything')), )); test("child's extended property validators are only run after successful parent property validation", async () => { - const type = V.object({ - extends: { - properties: { - required: V.required(V.toInteger(), V.min(0)), - }, + const parent = V.object({ + properties: { + required: V.required(V.toInteger(), V.min(0)), }, + }); + const type = V.object({ + extends: parent, properties: { required: V.allOf(V.min(1), V.max(3)), // Extend parent rules }, @@ -629,15 +670,42 @@ describe('inheritance', () => { ).getValue(); expect(Object.keys(value)).toEqual(['id', 'name', 'anything', 'firstAdditional', 'additionalProperty', 'thirdAdditional']); }); + + describe('allOf parent next validators', async () => { + const parent1 = V.objectType().properties({ + pwd1: V.string(), + pwd2: V.string(), + }).next(V.assertTrue(obj => obj.pwd1 === obj.pwd2, 'PasswordsMatch')) + .build(); + + const parent2 = V.objectType().properties({ + min: V.number(), + max: V.number(), + }).next(V.assertTrue(obj => obj.min <= obj.max, 'MinLoeMax')) + .build(); + + const child = V.objectType() + .extends(parent1) + .extends(parent2) + .build(); + + test('valid child', () => expectValid({ pwd1: 'pwd', pwd2: 'pwd', min: 1, max: 2} satisfies VType, child)) + + test('invalid child', () => expectViolations({ pwd1: 'pwd1', pwd2: 'pwd2', min: 2, max: 1}, child, new Violation(ROOT, 'PasswordsMatch'), new Violation(ROOT, 'MinLoeMax'))); + }); }); describe('object next', () => { - const passwordValidator = V.object({ + interface Password { + pw1: string; + pw2: string; + } + const passwordValidator = V.object({ properties: { pw1: V.string(), pw2: V.string(), }, - next: V.assertTrue(user => user.pw1 === user.pw2, 'PasswordVerification', Path.of('pw2')), + next: V.assertTrue((user: Password) => user.pw1 === user.pw2, 'PasswordVerification', Path.of('pw2')), }); test('passwords match', () => expectValid({ pw1: 'test', pw2: 'test' }, passwordValidator)); @@ -647,12 +715,15 @@ describe('object next', () => { test('run after property validators', () => expectViolations({ pw1: 'test' }, passwordValidator, defaultViolations.notNull(Path.of('pw2')))); describe('inherited next', () => { + interface User extends Password { + name: string; + } const userValidator = V.object({ extends: passwordValidator, properties: { name: V.string(), }, - next: V.assertTrue(user => user.pw1.indexOf(user.name) < 0, 'BadPassword', Path.of('pw1')), + next: V.assertTrue((user: User) => user.pw1.indexOf(user.name) < 0, 'BadPassword', Path.of('pw1')), }); test('BadPassword', () => expectViolations({ pw1: 'test', pw2: 'test', name: 'tes' }, userValidator, new Violation(Path.of('pw1'), 'BadPassword'))); @@ -663,24 +734,23 @@ describe('object next', () => { }); describe('object localNext', () => { - const parent = V.object({ - properties: { + const parent = V.objectType() + .properties({ name: V.string(), upper: V.optional(V.boolean()), - }, - next: V.map(obj => { + }).next(V.map(obj => { if (obj.upper) { obj.name = (obj.name as string).toUpperCase(); } return obj; - }), - localNext: V.map(obj => `parent:${obj.name}`), - }); - const child = V.object({ - extends: parent, - localNext: V.map(obj => `child:${obj.name}`), - }); - + })) + .localNext(V.map(obj => `parent:${obj.name}`)) + .build(); + const child = V.objectType() + .extends(parent) + .localNext(V.map(obj => `child:${obj.name}`)) + .build(); + test('parent', async () => { expect((await parent.validate({ name: 'Darth' })).getValue()).toEqual('parent:Darth'); }); @@ -702,7 +772,7 @@ describe('object localNext', () => { properties: { name: V.string(), }, - localNext: V.map(obj => `parent:${obj.name}`), + localNext: V.map((obj: any) => `parent:${obj.name}`), }); await expectViolations({}, model, defaultViolations.notNull(property('name'))); }); @@ -710,11 +780,11 @@ describe('object localNext', () => { describe('Date', () => { const now = new Date(); - const validator = V.object({ - properties: { - date: V.date(), - }, - }); + const validator = V.objectType().properties({ + date: V.date(), + }).build(); + + ({ date: new Date() }) satisfies VType; test('null is not allowed', () => expectViolations(null, V.date(), defaultViolations.notNull())); @@ -734,12 +804,12 @@ describe('Date', () => { test('invalid date format', () => expectViolations('23.10.2019T09:10:00Z', V.date(), defaultViolations.date('23.10.2019T09:10:00Z'))); test('subsequent validators get to work on Date', () => { - async function notInstanceOfDate(value: any, path: Path, ctx: ValidationContext): Promise { + async function notInstanceOfDate(value: any, path: Path, ctx: ValidationContext) { // Return violation of expected case to verify that this validator is actually run if (value instanceof Date) { - return ctx.failure(new Violation(path, 'NotInstanceOfDate'), value); + throw new Violation(path, 'NotInstanceOfDate'); } - return ctx.success(value); + return Promise.resolve(value); } const validator = V.object({ properties: { @@ -782,13 +852,13 @@ describe('enum', () => { }); test('ignoreUnknownEnumValues', () => - expectValid('B', V.enum(StrEnum, 'StrEnum'), 'B', { + expectValid('B', V.enum(StrEnum, 'StrEnum'), 'B' as any, { ignoreUnknownEnumValues: true, })); test('ignoreUnknownEnumValues with warning', async () => { const warnings: Violation[] = []; - await expectValid('B', V.enum(StrEnum, 'StrEnum'), 'B', { + await expectValid('B', V.enum(StrEnum, 'StrEnum'), 'B' as any, { ignoreUnknownEnumValues: true, warnLogger: (violation: Violation) => warnings.push(violation), }); @@ -890,11 +960,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(1, 1))); test('too short', () => expectViolations([1], V.size(2, 3), defaultViolations.size(2, 3))); @@ -920,7 +990,11 @@ describe('number', () => { describe('min', () => { test('undefined is not allowed', () => expectViolations(undefined, V.min(1), defaultViolations.notNull())); - test('string is not allowed', () => expectViolations('123', V.min(1), defaultViolations.number('123'))); + test('string is not allowed', () => expectViolations('123' as any, V.min(1), defaultViolations.number('123'))); + + test('min/max chaining', () => expectValid(2, V.number().min(1).max(3))); + + test('min/max chaining failure', () => expectViolations('2' as any, V.number().min(1).max(3), new TypeMismatch(ROOT, 'number', '2'))); test('min inclusive equal value', () => expectValid(1.1, V.min(1.1, true))); @@ -928,7 +1002,7 @@ describe('number', () => { test('min larger value', () => expectValid(1.2, V.min(1.1, false))); - test('min inclusive equal (string) value', () => expectValid('1.1', V.check(V.toNumber().next(V.min(1.1, true))))); + test('min inclusive equal (string) value', () => expectValid('1.1', V.check(V.toNumber(), V.min(1.1, true)))); test('min strict equal value', () => expectViolations(1.1, V.min(1.1, false), defaultViolations.min(1.1, false, 1.1))); @@ -938,7 +1012,9 @@ describe('number', () => { describe('max', () => { test('undefined is not allowed', () => expectViolations(undefined, V.max(1), defaultViolations.notNull())); - test('string is not allowed', () => expectViolations('123', V.max(1), defaultViolations.number('123'))); + test('string is not allowed', () => expectViolations('123' as any, V.max(1), defaultViolations.number('123'))); + + test('max/min chaining', () => expectValid(2, V.number().max(3).min(1))); test('max inclusive equal value', () => expectValid(1.1, V.max(1.1, true))); @@ -946,7 +1022,7 @@ describe('number', () => { test('max smaller value', () => expectValid(1.0, V.max(1.1, false))); - test('max inclusive equal (string) value', () => expectValid('1.1', V.check(V.toNumber().next(V.max(1.1, true))))); + test('max inclusive equal (string) value', () => expectValid('1.1', V.check(V.toNumber(), V.max(1.1, true)))); test('max strict equal value', () => expectViolations(1.1, V.max(1.1, false), defaultViolations.max(1.1, false, 1.1))); @@ -968,28 +1044,30 @@ describe('number', () => { describe('min', () => { test('min inclusive equal value', () => expectValid(0, V.min(0, true))); - test('min inclusive equal (string) value', () => expectValid('1', V.check(V.toNumber().next(V.min(1, true))))); + test('min inclusive equal (string) value', () => expectValid('1', V.check(V.toNumber().min(1, true)))); test('min strict equal value', () => expectViolations(0, V.min(0, false), defaultViolations.min(0, false, 0))); - test('min strict equal (string) value', () => expectViolations('1', V.toNumber().next(V.min(1, false)), defaultViolations.min(1, false, 1))); + test('min strict equal (string) value', () => expectViolations('1', V.toNumber().min(1, false), defaultViolations.min(1, false, 1))); }); describe('convert', () => { test('valid integer', () => expectValid(-123, V.toInteger(), -123)); + test('convert Number to number', () => expectValid(new Number(123), V.toInteger(), 123)); + test('convert string to integer', () => expectValid('-123', V.toInteger(), -123)); }); }); describe('async', () => { - class WaitValidator extends Validator { + class WaitValidator extends Validator { public executionOrder: any[] = []; - async validatePath(value: any, path: Path, ctx: ValidationContext): Promise { - return new Promise((resolve, reject) => { + async validatePath(value: InOut, path: Path, ctx: ValidationContext) { + return new Promise((resolve, reject) => { setTimeout(() => { this.executionOrder.push(value); - resolve(ctx.success(value)); + resolve(value); }, value as number); }); } @@ -1026,6 +1104,10 @@ describe('async validation', () => { await expectValid('123', V.allOf(V.string(), V.toInteger()), 123); }); + test('return original', async () => { + await expectValid('123', V.allOf(V.string(), V.check(V.toInteger())), '123'); + }); + test('conflicting conversions not allowed', async () => { try { await V.allOf(defer(V.toInteger(), 3), defer(V.toObject('value'), 1)).validate('123'); @@ -1105,7 +1187,7 @@ describe('groups', () => { expect(groups.get('grandchild')).toEqual(grandchild); }); - test('default not included in parent', () => expectGroupValid(null, DEFAULT, V.whenGroup(parent, failAlways))); + test('default not included in parent', () => expectGroupValid(null, DEFAULT, V.whenGroup(parent, failAlways).otherwise(V.any()))); test('group includes itself', () => expectGroupViolations(null, parent, V.whenGroup('parent', failAlways), failAlwaysViolation())); @@ -1113,12 +1195,14 @@ describe('groups', () => { test('grandhild includes parent', () => expectGroupViolations(null, grandchild, V.whenGroup('parent', failAlways), failAlwaysViolation())); - test('child does not include grandchild', () => expectGroupValid(null, child, V.whenGroup(grandchild, failAlways))); + test('child does not include grandchild', () => expectGroupViolations(null, child, V.whenGroup(grandchild, failAlways), new Violation(ROOT, 'NoMatchingGroup', null))); test('grandchild indluces default', () => expectGroupViolations(null, grandchild, V.whenGroup(DEFAULT, failAlways), failAlwaysViolation())); test('non-grouped validations run always', () => expectGroupViolations(null, DEFAULT, V.notNull(), defaultViolations.notNull())); + test('both parent and child validators are executed', () => expectGroupValid('123', child, V.whenGroup(parent, V.string()).whenGroup(child, V.toNumber()), 123)); + test('referencing unknown groups not allowed', () => { const groups = new Groups(); expect(() => groups.define('foo', 'bar')).toThrow(); @@ -1131,11 +1215,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', () => { @@ -1147,7 +1231,9 @@ describe('groups', () => { }); describe('chaining', () => { - const whenChainValidator = V.whenGroup(DEFAULT, V.notNull()).whenGroup(parent, V.notEmpty()).otherwise(V.string()); + const whenChainValidator = V.whenGroup(DEFAULT, V.notNull()) + .whenGroup(parent, V.notEmpty()) + .otherwise(V.string()); test('chain first match', () => expectGroupViolations(null, withDefault, whenChainValidator, defaultViolations.notNull())); @@ -1160,57 +1246,73 @@ describe('groups', () => { describe('required', () => { const validator = V.required(V.string()); + const dateInPast = (date: Date) => date.valueOf() < Date.now(); + test('null is invalid', () => expectViolations(null, validator, defaultViolations.notNull())); test('undefined is invalid', () => expectViolations(undefined, validator, defaultViolations.notNull())); - test('value is passed to next', () => expectViolations(123, validator, defaultViolations.string(123))); + test('value is passed to next', () => expectViolations(123 as any, validator, defaultViolations.string(123))); test('valid string', () => expectValid('123', validator)); - test('type conversion with allOf', () => expectValid(validDateString, V.required(V.date(), V.hasValue(validDate)), validDate)); + test('type conversion with compositionOf', () => + expectValid(validDateString, V.required(V.hasValue(validDateString), V.date(), V.assertTrue(dateInPast)), validDate)); - test('type validation with allOf', () => expectValid(validDateString, V.check(V.required(V.date(), V.hasValue(validDate))))); + test('type validation with compositionOf', () => + expectValid(validDateString, V.check(V.hasValue(validDateString), V.date(), V.assertTrue(dateInPast)))); }); describe('optional', () => { const validator = V.optional(V.string()); + const dateInPast = (date: Date) => date.valueOf() < Date.now(); + test('null is valid', () => expectValid(null, validator)); test('undefined is valid', () => expectValid(undefined, validator)); - test('value is passed to next', () => expectViolations(123, validator, defaultViolations.string(123))); + test('value is passed to next', () => expectViolations(123 as any, validator, defaultViolations.string(123))); test('valid string', () => expectValid('123', validator)); - test('type conversion with allOf', () => expectValid(validDateString, V.optional(V.date(), V.hasValue(validDate)), validDate)); + test('type conversion with compositionOf', () => expectValid(validDateString, V.optional(V.hasValue(validDateString), V.date(), V.assertTrue(dateInPast)), validDate)); - test('type validation with allOf', () => expectValid(validDateString, V.check(V.optional(V.date(), V.hasValue(validDate))))); + test('type validation with compositionOf', () => expectValid(validDateString, V.check(V.optional(V.hasValue(validDateString), V.date(), V.assertTrue(dateInPast))))); test('chained validation rules', () => expectViolations('0', V.optional(V.toInteger(), V.min(1)), defaultViolations.min(1, true, 0))); }); +describe('nullable', () => { + const validator = V.nullable(V.string()); + + test('valid null', () => expectValid(null, validator)); + + test('valid string', () => expectValid('string', validator)); + + test('undefined is invalid', () => expectViolations(undefined, validator, defaultViolations.notUndefined())); +}); + describe('isNumber', () => { describe('positive cases', () => { test('123', () => expect(isNumber(123)).toBe(true)); test('Number("123")', () => expect(isNumber(Number('123'))).toBe(true)); - test('new Number("123")', () => expect(isNumber(new Number('123'))).toBe(true)); + test('Number objects do not pass as number primitives', () => expect(isNumber(new Number('123'))).toBe(false)); }); describe('negative cases', () => { test('"123"', () => expect(isNumber('123')).toBe(false)); test('Number("abc")', () => expect(isNumber(Number('abc'))).toBe(false)); - test('new Number("abc")', () => expect(isNumber(new Number('abc'))).toBe(false)); + test('new Number(123)', () => expect(isNumber(new Number(123))).toBe(false)); }); }); describe('if', () => { const validator = V.if((value: any) => typeof value === 'number', V.min(1)) - .elseIf((value: any) => typeof value === 'boolean', V.hasValue(true)) + .elseIf((value: unknown) => typeof value === 'boolean', V.hasValue(true)) .else(V.string()); test('if matches valid', () => expectValid(123, validator)); @@ -1226,14 +1328,18 @@ 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([]))); + test('no-conditional anomaly', () => { + expect(() => new IfValidator([])).toThrow('At least one conditional required'); + }); + + test('no matching conditional', () => expectViolations("foo", V.if(() => false, V.boolean()), new Violation(ROOT, 'NoMatchingCondition', "foo"))); }); describe('ignore', () => { @@ -1270,6 +1376,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'))); }); @@ -1366,7 +1474,7 @@ describe('Map', () => { const map = new Map(mapArray); expect(map.get(key)).toEqual('value'); let result = await validator.validate(map); - expect(result.isSuccess()).toBe(true); + expect(result.isFailure()).toBe(false); // Serializes to JSON as array const jsonString = JSON.stringify(result.getValue()); @@ -1442,52 +1550,5 @@ describe('json', () => { test('Invalid JSON', () => expectViolations('["foo", "bar"', validator, new TypeMismatch(Path.of(), 'JSON', '["foo", "bar"'))); - test('Non-string input is invalid', () => expectViolations(123, validator, new TypeMismatch(Path.of(), 'string', 123))); -}); - -describe('SyncPromise', () => { - test('onfullfilled is wrapped in a new SyncPromise', () => { - const promise = new SyncPromise('result').then(_ => 'new result'); - expect(promise).toBeInstanceOf(SyncPromise); - promise.then(value => expect(value).toBe('new result')); - }); - - test('promise returned by onfulfilled is retuned as such', async () => { - const promise = Promise.resolve('promised result'); - const result = await new SyncPromise('result').then(_ => promise); - expect(result).toBe('promised result'); - }); - - test('call onrejected on error', async () => { - let thrownError: undefined | any; - // Awaiting for SyncPromise is optional - const result = await new SyncPromise('result').then( - () => { - throw 'error'; - }, - error => { - thrownError = error; - return 'handled'; - }, - ); - expect(result).toBe('handled'); - expect(thrownError).toBe('error'); - }); - - test('throw error if onrejected is missing', () => { - try { - new SyncPromise('result').then(() => { - throw 'error'; - }); - fail('expected an error'); - } catch (thrownError) { - expect(thrownError).toBe('error'); - // as expected - } - }); - - test('return this if both callbacks are missing', () => { - const promise = new SyncPromise('result'); - expect(promise.then()).toBe(promise); - }); + test('Non-string input is invalid', () => expectViolations(123 as any, validator, new TypeMismatch(Path.of(), 'string', 123))); }); diff --git a/packages/core/src/V.ts b/packages/core/src/V.ts index cd93625..d8d9047 100644 --- a/packages/core/src/V.ts +++ b/packages/core/src/V.ts @@ -23,7 +23,6 @@ import { MappingFn, Validator, CheckValidator, - maybeAllOfValidator, OptionalValidator, AssertTrue, IfValidator, @@ -37,9 +36,6 @@ import { BooleanNormalizer, MinValidator, MaxValidator, - ObjectModel, - ObjectValidator, - ObjectNormalizer, MapValidator, MapNormalizer, ArrayValidator, @@ -47,22 +43,25 @@ import { AllOfValidator, AnyOfValidator, OneOfValidator, - CompositionValidator, EnumValidator, HasValueValidator, JsonValidator, RequiredValidator, SetValidator, UuidValidator, + VType, + maybeCompositionOf, + CompositionParameters, + OptionalUndefinedValidator, + NullableValidator, } from './validators.js'; +import {ObjectModel, ObjectValidator, ObjectNormalizer } from './objectValidator.js'; +import { ObjectValidatorBuilder } from './objectValidatorBuilder.js'; 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(), emptyToNullValidator = new ValueMapper((value: any) => (isNullOrUndefined(value) || value === '' ? null : value)), emptyToUndefinedValidator = new ValueMapper((value: any) => (isNullOrUndefined(value) || value === '' ? undefined : value)), @@ -75,33 +74,53 @@ const ignoreValidator = new IgnoreValidator(), dateValidator = new DateValidator(ValidatorType.Date); export const V = { - fn: (fn: ValidatorFn, type?: string) => new ValidatorFnWrapper(fn, type), + fn: (fn: ValidatorFn, type?: string) => new ValidatorFnWrapper(fn, type), - map: (fn: MappingFn, error?: any) => new ValueMapper(fn, error), + map: (fn: MappingFn, error?: any) => new ValueMapper(fn, error), ignore: () => ignoreValidator, - any: () => anyValidator, + any: () => new AnyValidator(), - check: (...allOf: Validator[]) => new CheckValidator(maybeAllOfValidator(allOf)), + check: (...validators: CompositionParameters) => + new CheckValidator(maybeCompositionOf(...validators)), - optional: (type: Validator, ...allOf: Validator[]) => new OptionalValidator(type, allOf), + /** + * Allows only undefined, null or valid value. + */ + optional: (...validators: CompositionParameters) => + new OptionalValidator(maybeCompositionOf(...validators)), + + /** + * Allows only undefined or valid value. + */ + optionalStrict: (...validators: CompositionParameters) => + new OptionalUndefinedValidator(maybeCompositionOf(...validators)), - required: (type: Validator, ...allOf: Validator[]) => new RequiredValidator(type, allOf), + /** + * Allows only null or valid value. + */ + nullable: (...validators: CompositionParameters) => + new NullableValidator(maybeCompositionOf(...validators)), - if: (fn: AssertTrue, ...allOf: Validator[]) => new IfValidator([new Conditional(fn, allOf)]), + required: (...validators: CompositionParameters) => + new RequiredValidator(maybeCompositionOf(...validators)), - whenGroup: (group: GroupOrName, ...allOf: Validator[]) => new WhenGroupValidator([new WhenGroup(group, allOf)]), + if: (fn: AssertTrue, ...validators: CompositionParameters) => + new IfValidator([new Conditional(fn, maybeCompositionOf(...validators))]), + + whenGroup: (group: GroupOrName, ...validators: CompositionParameters) => + new WhenGroupValidator([new WhenGroup(group, maybeCompositionOf(...validators))]), string: () => stringValidator, toString: () => toStringValidator, - notNull: () => notNullValidator, + notNull: () => new NotNullOrUndefinedValidator(), nullOrUndefined: () => nullOrUndefinedValidator, - notEmpty: () => notEmptyValidator, + notEmpty: () => new NotEmptyValidator(), notBlank: () => notBlankValidator, @@ -111,7 +130,8 @@ export const V = { undefinedToNull: () => undefinedToNullValidator, - emptyTo: (defaultValue: any) => new ValueMapper((value: any) => (isNullOrUndefined(value) || value === '' ? defaultValue : value)), + emptyTo: (defaultValue: InOut) => + new ValueMapper((value: InOut) => (isNullOrUndefined(value) || value.length === 0 ? defaultValue : value)), uuid: (version?: number) => new UuidValidator(version), @@ -135,7 +155,9 @@ export const V = { max: (max: number, inclusive = true) => new MaxValidator(max, inclusive), - object: (model: ObjectModel) => new ObjectValidator(model), + object: (model: ObjectModel) => new ObjectValidator(model), + + objectType: () => new ObjectValidatorBuilder(), toObject: (property: string) => new ObjectNormalizer(property), @@ -149,36 +171,41 @@ export const V = { setType: (values: Validator, jsonSafeSet: boolean = true) => new SetValidator(values, jsonSafeSet), - nullTo: (defaultValue: string | number | bigint | boolean | symbol) => new ValueMapper(value => (isNullOrUndefined(value) ? defaultValue : value)), + nullTo: (defaultValue: Out) => + new ValueMapper((value: In) => (isNullOrUndefined(value) ? defaultValue : value)), - nullToObject: () => new ValueMapper((value: any) => (isNullOrUndefined(value) ? {} : value)), + nullToObject: () => new ValueMapper<{} | In, In>(value => (isNullOrUndefined(value) ? {} : value)), - nullToArray: () => new ValueMapper((value: any) => (isNullOrUndefined(value) ? [] : value)), + nullToArray: () => new ValueMapper<[] | In, In>(value => (isNullOrUndefined(value) ? [] : value)), - array: (...items: Validator[]) => new ArrayValidator(maybeAllOfValidator(items)), + array: (...items: CompositionParameters) => + new ArrayValidator(maybeCompositionOf(...items)), - toArray: (...items: Validator[]) => new ArrayNormalizer(maybeAllOfValidator(items)), + toArray: (...items: CompositionParameters) => + new ArrayNormalizer(maybeCompositionOf(...items)), - size: (min: number, max: number) => new SizeValidator(min, max), + size: (min: number, max: number) => new SizeValidator(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: , B extends Array>>(...validators: [A, ...B]) => new AnyOfValidator | VType>(validators), - oneOf: (...validators: Validator[]) => new OneOfValidator(validators), + oneOf: , B extends Array>>(...validators: [A, ...B]) => new OneOfValidator | VType>(validators), - compositionOf: (...validators: Validator[]) => new CompositionValidator(validators), + compositionOf: (...validators: CompositionParameters) => + maybeCompositionOf(...validators), date: () => dateValidator, - enum: (enumType: object, name: string) => new EnumValidator(enumType, name), + enum: (enumType: Out, name: string) => new EnumValidator(enumType, name), - assertTrue: (fn: AssertTrue, type: string = 'AssertTrue', path?: Path) => new AssertTrueValidator(fn, type, path), + assertTrue: (fn: AssertTrue, type: string = 'AssertTrue', path?: Path) => new AssertTrueValidator(fn, type, path), - hasValue: (expectedValue: any) => new HasValueValidator(expectedValue), + hasValue: (expectedValue: InOut) => new HasValueValidator(expectedValue), - json: (...validators: Validator[]) => new JsonValidator(validators), + json: (...validators: CompositionParameters) => + new JsonValidator(maybeCompositionOf(...validators)), }; Object.freeze(V); diff --git a/packages/core/src/index.spec.ts b/packages/core/src/index.spec.ts new file mode 100644 index 0000000..5278564 --- /dev/null +++ b/packages/core/src/index.spec.ts @@ -0,0 +1,4 @@ +import { expect, test } from 'vitest'; +import { V } from './index.js'; + +test('index coverage', () => expect(V).toBeDefined()); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f5c5a3c..92632c0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,2 +1,4 @@ export * from './V.js'; export * from './validators.js'; +export * from './objectValidator.js'; +export * from './objectValidatorBuilder.js'; diff --git a/packages/core/src/objectValidator.ts b/packages/core/src/objectValidator.ts new file mode 100644 index 0000000..51ead35 --- /dev/null +++ b/packages/core/src/objectValidator.ts @@ -0,0 +1,365 @@ +import { Path } from "@finnair/path"; +import { + AnyValidator, + CompositionParameters, + defaultViolations, + HasValueValidator, + isNullOrUndefined, + isNumber, + isString, + maybeAllOfValidator, + maybeCompositionOf, + ValidationContext, + Validator, + ValidatorFnWrapper, + Violation, + violationsOf, +} from "./validators"; + +export type PropertyModel = { [s: string]: string | number | Validator }; + +export type ParentModel = ObjectValidator | ObjectValidator[]; + +export type Properties = { [s: string]: Validator }; + +export interface MapEntryModel { + readonly keys: Validator; + readonly values: Validator; +} + +export interface PropertyFilter { + (key: string): boolean; +} + +export interface ObjectModel { + /** + * Inherit all non-local rules from parent validators. + */ + readonly extends?: ParentModel; + /** + * Inheritable property rules. + */ + readonly properties?: PropertyModel; + /** + * Local, non-inheritable property rules, e.g. discriminator property in a class hierarchy. + */ + readonly localProperties?: PropertyModel; + /** + * Validation rules for additional properties. True allows any additional property. + * With MapEntryModel valueValidator must match if keyValidator matches and at least one keyValidator must match. + */ + readonly additionalProperties?: boolean | MapEntryModel | MapEntryModel[]; + /** + * Next validator to be executed after all properties are validated successfully. + * Use this to define additional rules or conversions for the ObjectValidator. + * Using the `next` function returns a `NextValidator` that cannot be further extended. + */ + readonly next?: Validator | Validator[]; + /** + * Local, non-inheritable rules. + */ + readonly localNext?: Validator | Validator[]; +} + +export class ObjectValidator extends Validator { + public readonly properties: Properties; + + public readonly localProperties: Properties; + + public readonly additionalProperties: MapEntryValidator[]; + + public readonly parentValidators: ObjectValidator[]; + + public readonly nextValidator?: Validator; + + public readonly localNextValidator?: Validator; + + constructor(public readonly model: ObjectModel) { + super(); + let properties: Properties = {}; + let additionalProperties: MapEntryValidator[] = []; + let parentNextValidators: Validator[] = []; + let nextValidators: Validator[] = []; + + this.parentValidators = model.extends ? ([] as ObjectValidator[]).concat(model.extends) : []; + for (let i = 0; i < this.parentValidators.length; i++) { + const parent = this.parentValidators[i]; + additionalProperties = additionalProperties.concat(parent.additionalProperties); + properties = mergeProperties(parent.properties, properties); + if (parent.nextValidator) { + parentNextValidators.push(parent.nextValidator); + } + } + if (parentNextValidators.length > 0) { + nextValidators.push(maybeAllOfValidator(parentNextValidators as [Validator, ...Validator[]])); + } + if (model.next) { + nextValidators = nextValidators.concat(model.next); + } + this.additionalProperties = additionalProperties.concat(getMapEntryValidators(model.additionalProperties)); + this.properties = mergeProperties(getPropertyValidators(model.properties), properties); + this.localProperties = getPropertyValidators(model.localProperties); + this.nextValidator = nextValidators.length > 0 ? maybeCompositionOf(...(nextValidators as CompositionParameters)) : undefined; + if (model.localNext) { + if (!Array.isArray(model.localNext)) { + this.localNextValidator = model.localNext; + } else if (model.localNext.length > 0) { + this.localNextValidator = maybeCompositionOf(...(model.localNext as CompositionParameters)); + } + } + + Object.freeze(this.properties); + Object.freeze(this.localProperties); + Object.freeze(this.additionalProperties); + Object.freeze(this.parentValidators); + Object.freeze(this); + } + + validatePath(value: unknown, path: Path, ctx: ValidationContext): PromiseLike { + return this.validateFilteredPath(value, path, ctx, _ => true); + } + + validateFilteredPath(value: any, path: Path, ctx: ValidationContext, filter: PropertyFilter): PromiseLike { + if (value === null || value === undefined) { + return Promise.reject(defaultViolations.notNull(path)); + } + if (typeof value !== 'object' || Array.isArray(value)) { + return Promise.reject(defaultViolations.object(path)); + } + const anyValue = value as any; + const context: ObjectValidationContext = { + path, + ctx, + filter, + convertedObject: {} as LocalType, + violations: [], + }; + const cycleResult = ctx.registerObject(value, path, context.convertedObject); + if (cycleResult) { + return cycleResult; + } + const propertyResults: PromiseLike[] = []; + + for (const key in this.properties) { + propertyResults.push(validateProperty(key, anyValue[key], this.properties[key], context)); + } + for (const key in this.localProperties) { + propertyResults.push(validateProperty(key, anyValue[key], this.localProperties[key], context)); + } + for (const key in value) { + if (!this.properties[key] && !this.localProperties[key]) { + propertyResults.push(validateAdditionalProperty(key, anyValue[key], this.additionalProperties, context)); + } + } + + let validationChain = Promise.allSettled(propertyResults).then(_ => { + if (context.violations.length === 0) { + return Promise.resolve(context.convertedObject); + } + return Promise.reject(context.violations); + }); + if (this.nextValidator) { + const validator = this.nextValidator; + validationChain = validationChain.then( + result => { + return validator.validatePath(result, path, ctx); + } + ); + } + if (this.localNextValidator) { + const validator = this.localNextValidator; + validationChain = validationChain.then( + result => { + return validator.validatePath(result, path, ctx); + } + ); + } + return validationChain; + } + + omit(...keys: K[]) { + return new ObjectValidator, Omit>({ + properties: pick(this.properties, key => !keys.includes(key as any)), + localProperties: pick(this.localProperties, key => !keys.includes(key as any)), + }); + } + + pick(...keys: K[]) { + return new ObjectValidator, Pick>({ + properties: pick(this.properties, key => keys.includes(key as any)), + localProperties: pick(this.localProperties, key => keys.includes(key as any)), + }); + } +} + +function pick(properties: Properties, fn: (key: keyof any) => boolean): Properties { + return Object.entries(properties).reduce((current: Properties, [key, validator]) => { + if (fn(key)) { + current[key] = validator; + } + return current; + }, {} as Properties); +} + +interface ObjectValidationContext { + readonly path: Path; + readonly ctx: ValidationContext; + readonly filter: PropertyFilter; + readonly convertedObject: any; + violations: Violation[]; +} + +function validateProperty(key: string, currentValue: any, validator: Validator, context: ObjectValidationContext) { + if (!context.filter(key)) { + return Promise.resolve(); + } + // Assign for property order + context.convertedObject[key] = undefined; + return validator.validatePath(currentValue, context.path.property(key), context.ctx).then( + result => { + if (result !== undefined) { + context.convertedObject[key] = result; + } else { + delete context.convertedObject[key]; + } + return result; + }, + error => { + delete context.convertedObject[key]; + context.violations = context.violations.concat(violationsOf(error)); + return Promise.reject(context.violations); + } + ); +} + +async function validateAdditionalProperty( + key: string, + originalValue: any, + additionalProperties: MapEntryValidator[], + context: ObjectValidationContext, +): Promise { + if (!context.filter(key)) { + return Promise.resolve(); + } + const keyPath = context.path.property(key); + let currentValue = originalValue; + let validKey = false; + let keyViolations: undefined | Violation[]; + for (let i = 0; i < additionalProperties.length; i++) { + const entryValidator = additionalProperties[i]; + try { + await entryValidator.keyValidator.validatePath(key, keyPath, context.ctx); + validKey = true; + try { + currentValue = await validateProperty(key, currentValue, entryValidator.valueValidator, context); + } catch (error) { + return Promise.reject(violationsOf(error)); + } + } catch (error) { + keyViolations = violationsOf(error); + } + } + if (!validKey) { + if (additionalProperties.length == 1 && keyViolations) { + // Only one kind of key accepted -> give out violations related to that + context.violations = context.violations.concat(keyViolations); + } else { + return validateProperty(key, originalValue, lenientUnknownPropertyValidator, context); + } + } +} + +export function mergeProperties(from: Properties, to: Properties): Properties { + if (from) { + for (const key in from) { + if (to[key]) { + to[key] = to[key].next(from[key]); + } else { + to[key] = from[key]; + } + } + } + return to; +} + +/** + * Converts a primitive `value` into an object `{ property: value }`. This normalizer can be used + * to e.g. preprocess the results of an XML parser and a schema having textual elements with optional attributes + * where an element without attributes would be simple string and an element with attributes would be an object. + */ +export class ObjectNormalizer extends Validator { + constructor(public readonly property: string) { + super(); + Object.freeze(this); + } + validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { + if (value === undefined) { + return Promise.resolve(undefined); + } + if (typeof value !== 'object' || value === null) { + const object: any = {}; + object[this.property] = value; + return Promise.resolve(object); + } + return Promise.resolve(value); + } +} + +export class MapEntryValidator { + public readonly keyValidator: Validator; + public readonly valueValidator: Validator; + + constructor(entryModel: MapEntryModel) { + this.keyValidator = entryModel.keys; + this.valueValidator = entryModel.values; + Object.freeze(this); + } +} + +function getPropertyValidators(properties?: PropertyModel): Properties { + const propertyValidators: Properties = {}; + if (properties) { + for (const name in properties) { + if (isString(properties[name]) || isNumber(properties[name])) { + propertyValidators[name] = new HasValueValidator(properties[name]); + } else { + propertyValidators[name] = properties[name] as Validator; + } + } + } + return propertyValidators; +} + +function getMapEntryValidators(additionalProperties?: boolean | MapEntryModel | MapEntryModel[]): MapEntryValidator[] { + if (isNullOrUndefined(additionalProperties)) { + return []; + } + if (typeof additionalProperties === 'boolean') { + if (additionalProperties) { + return [allowAllMapEntries]; + } + return [allowNoneMapEntries]; + } + const models: MapEntryModel[] = ([] as MapEntryModel[]).concat(additionalProperties as MapEntryModel | MapEntryModel[]); + const validators: MapEntryValidator[] = []; + for (let i = 0; i < models.length; i++) { + validators[i] = new MapEntryValidator(models[i]); + } + return validators; +} + +export const lenientUnknownPropertyValidator = new ValidatorFnWrapper((value: any, path: Path, ctx: ValidationContext) => + ctx.failure(defaultViolations.unknownProperty(path), value)); + +export const strictUnknownPropertyValidator = new ValidatorFnWrapper((value: any, path: Path, ctx: ValidationContext) => + Promise.reject(defaultViolations.unknownPropertyDenied(path))); + +const allowAllMapEntries: MapEntryValidator = new MapEntryValidator({ + keys: new AnyValidator(), + values: new AnyValidator(), +}); + +const allowNoneMapEntries: MapEntryValidator = new MapEntryValidator({ + keys: new AnyValidator(), + values: strictUnknownPropertyValidator, +}); diff --git a/packages/core/src/objectValidatorBuilder.ts b/packages/core/src/objectValidatorBuilder.ts new file mode 100644 index 0000000..4e2de45 --- /dev/null +++ b/packages/core/src/objectValidatorBuilder.ts @@ -0,0 +1,68 @@ +import { V } from "./V.js"; +import { Validator } from "./validators.js"; +import { MapEntryModel, ObjectValidator, PropertyModel, strictUnknownPropertyValidator } from "./objectValidator.js"; + +type KeysOfType = { + [key in keyof T]: SelectedType extends T[key] ? key : never; +}[keyof T]; + +type Optional = Partial>>; + +type Required = Omit>; + +type OptionalUndefined = Optional & Required; + +export class ObjectValidatorBuilder { + private _extends: ObjectValidator[] = []; + private _properties: PropertyModel = {}; + private _localProperties: PropertyModel = {}; + private _additionalProperties: MapEntryModel[] = []; + private _next?: Validator[] = []; + private _localNext?: Validator[] = []; + constructor() {} + extends(parent: ObjectValidator) { + this._extends.push(parent); + return this as ObjectValidatorBuilder; + } + properties(properties: { [K in keyof X]: Validator }) { + for (const key in properties) { + this._properties[key] = properties[key]; + } + return this as ObjectValidatorBuilder; + } + localProperties(localProperties: { [K in keyof X]: Validator }) { + for (const key in localProperties) { + this._localProperties[key] = localProperties[key]; + } + return this as ObjectValidatorBuilder; + } + allowAdditionalProperties(allow: boolean) { + if (allow) { + return this.additionalProperties(V.any(), V.any()); + } else { + return this.additionalProperties(V.any(), strictUnknownPropertyValidator); + } + } + additionalProperties(keys: Validator, values: Validator) { + this._additionalProperties.push({ keys, values }); + return this as ObjectValidatorBuilder, Next, LocalProps, LocalNext>; + } + next(validator: Validator) { + this._next?.push(validator); + return this as unknown as ObjectValidatorBuilder; + } + localNext(validator: Validator) { + this._localNext?.push(validator); + return this as unknown as ObjectValidatorBuilder; + } + build() { + return new ObjectValidator, OptionalUndefined>({ + extends: this._extends, + properties: this._properties, + additionalProperties: this._additionalProperties, + next: this._next, + localProperties: this._localProperties, + localNext: this._localNext, + }); + } +}; diff --git a/packages/core/src/schema.spec.ts b/packages/core/src/schema.spec.ts index 28e79df..3205753 100644 --- a/packages/core/src/schema.spec.ts +++ b/packages/core/src/schema.spec.ts @@ -15,6 +15,7 @@ describe('schema', () => { type: V.string(), }, }); + V.compositionOf(V.string()) const schema = new SchemaValidator((schema: SchemaValidator) => ({ discriminator: 'type', // or (value: any) => string function models: { @@ -138,7 +139,7 @@ describe('schema', () => { extends: { type: 'Object' }, type: 'ObjectNormalizer', }) - ).getValue(); + ).getValue() as any; expect(Object.keys(value)).toEqual(['type', 'extends', 'properties', 'property']); expect(Object.keys(value.properties)).toEqual(['first', 'second']); }); @@ -161,7 +162,7 @@ describe('schema', () => { test('valid object', () => expectValid({ number: 123, string: 'string' }, schema)); - test('valid string reference', () => expectViolations({ number: 123, string: 123 }, schema, new TypeMismatch(property('string'), 'string', 'number'))); + test('invalid string reference', () => expectViolations({ number: 123, string: 123 }, schema, new TypeMismatch(property('string'), 'string', 'number'))); }); test('parent must be ObjectValidator', () => { @@ -210,14 +211,14 @@ describe('ClassModel.next', () => { pw1: V.string(), pw2: V.string(), }, - next: V.assertTrue(user => user.pw1 === user.pw2, 'PasswordVerification', Path.of('pw2')), + next: V.assertTrue((user: any) => user.pw1 === user.pw2, 'PasswordVerification', Path.of('pw2')), }, NewUserRequest: { extends: 'PasswordChangeRequest', properties: { name: V.string(), }, - next: V.assertTrue(user => user.pw1.indexOf(user.name) < 0, 'BadPassword', Path.of('pw1')), + next: V.assertTrue((user: any) => user.pw1.indexOf(user.name) < 0, 'BadPassword', Path.of('pw1')), }, }, })); @@ -243,7 +244,7 @@ describe('ClassModel.localNext', () => { properties: { name: V.string(), }, - localNext: V.map(obj => `${obj.name}`), + localNext: V.map((obj: any) => `${obj.name}`), }, }, })); @@ -276,7 +277,7 @@ describe('Recursive object', () => { const second: any = { name: 'second', head: first, tail: first }; first.head = second; first.tail = second; - const result = (await schema.validate(first, { allowCycles: true })).getValue(); + const result = (await schema.validate(first, { allowCycles: true })).getValue() as any; expect(result).not.toBe(first); expect(result.head).not.toBe(second); expect(result.head).toBe(result.tail); @@ -312,7 +313,7 @@ describe('Recursive array', () => { array[0] = array; array[1] = array; - const result = (await schema.validate(array, { allowCycles: true })).getValue(); + const result = (await schema.validate(array, { allowCycles: true })).getValue() as any; expect(result).not.toBe(array); expect(result[0]).toBe(result); expect(result[1]).toBe(result); diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 6dd0186..32cd8d9 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -2,16 +2,18 @@ import { Validator, ValidationContext, ValidationResult, - ObjectValidator, - PropertyModel, - MapEntryModel, isNullOrUndefined, defaultViolations, isString, Violation, TypeMismatch, - ObjectModel, } from './validators.js'; +import { + ObjectValidator, + PropertyModel, + MapEntryModel, + ObjectModel, +} from './objectValidator.js'; import { Path } from '@finnair/path'; export interface DiscriminatorFn { @@ -28,10 +30,10 @@ export interface SchemaModel { export type ClassParentModel = string | ObjectValidator | (string | ObjectValidator)[]; export interface ClassModel { - readonly properties?: PropertyModel; - readonly additionalProperties?: boolean | MapEntryModel | MapEntryModel[]; readonly extends?: ClassParentModel; readonly localProperties?: PropertyModel; + readonly properties?: PropertyModel; + readonly additionalProperties?: boolean | MapEntryModel | MapEntryModel[]; readonly next?: Validator; readonly localNext?: Validator; } @@ -78,9 +80,9 @@ export class SchemaValidator extends Validator { return this.validateClass(value, path, ctx); } - validateClass(value: any, path: Path, ctx: ValidationContext, expectedType?: string): PromiseLike { + validateClass(value: any, path: Path, ctx: ValidationContext, expectedType?: string): PromiseLike { if (isNullOrUndefined(value)) { - return ctx.failurePromise(defaultViolations.notNull(path), value); + throw defaultViolations.notNull(path) } // 1) Validate discriminator let type: string; @@ -93,14 +95,14 @@ export class SchemaValidator extends Validator { } const validator = this.validators[type]; if (!validator) { - return ctx.failurePromise(new DiscriminatorViolation(typePath, type, Object.keys(this.validators)), type); + throw new DiscriminatorViolation(typePath, type, Object.keys(this.validators)) } // 2) Validate that the type is assignable to the expected type (polymorphism) if (expectedType) { const expectedParent = this.validators[expectedType]; if (!this.isSubtypeOf(validator, expectedParent)) { - return ctx.failurePromise(new TypeMismatch(path, expectedType, type), type); + throw new TypeMismatch(path, expectedType, type) } } diff --git a/packages/core/src/testUtil.spec.ts b/packages/core/src/testUtil.spec.ts index a6e4119..6d6d65c 100644 --- a/packages/core/src/testUtil.spec.ts +++ b/packages/core/src/testUtil.spec.ts @@ -1,12 +1,12 @@ import { test, expect } from 'vitest' import { Validator, Violation, ValidationResult, ValidatorOptions } from './validators.js'; -export async function expectViolations(value: any, validator: Validator, ...violations: Violation[]) { +export async function expectViolations(value: In, validator: Validator, ...violations: Violation[]) { const result = await validator.validate(value); expect(result).toEqual(new ValidationResult(violations)); } -export async function expectValid(value: any, validator: Validator, convertedValue?: any, ctx?: ValidatorOptions) { +export async function expectValid(value: In, validator: Validator, convertedValue?: Out, ctx?: ValidatorOptions) { const result = await validator.validate(value, ctx); return verifyValid(result, value, convertedValue); } @@ -17,7 +17,7 @@ export async function expectUndefined(value: any, validator: Validator, converte expect(result.getValue()).toBeUndefined(); } -export function verifyValid(result: ValidationResult, value: any, convertedValue?: any) { +export function verifyValid(result: ValidationResult, value: any, convertedValue?: Out) { expect(result.getViolations()).toEqual([]); if (convertedValue !== undefined) { expect(result.getValue()).toEqual(convertedValue); diff --git a/packages/core/src/validators.ts b/packages/core/src/validators.ts index a5e1aef..8c9b7f1 100644 --- a/packages/core/src/validators.ts +++ b/packages/core/src/validators.ts @@ -4,16 +4,14 @@ import { validate as uuidValidate, version as uuidVersion } from 'uuid'; const ROOT = Path.ROOT; -export interface ValidatorFn { - (value: any, path: Path, ctx: ValidationContext): PromiseLike; +export interface ValidatorFn{ + (value: In, path: Path, ctx: ValidationContext): PromiseLike; } -export interface MappingFn { - (value: any, path: Path, ctx: ValidationContext): any | Promise; +export interface MappingFn { + (value: In, path: Path, ctx: ValidationContext): Out | PromiseLike; } -export type Properties = { [s: string]: Validator }; - export interface ValidatorOptions { readonly group?: Group; readonly ignoreUnknownProperties?: boolean; @@ -27,36 +25,25 @@ export class ValidationContext { private readonly objects = new Map(); - failure(violation: Violation | Violation[], value: any) { + /** + * Optionally ignore an error for backwards compatible changes (enum values, new properties). + */ + failure(violation: Violation | Violation[], value: In) { const violations: Violation[] = ([] as Violation[]).concat(violation); - if (violations.length === 1) { - if (this.ignoreViolation(violations[0])) { - if (this.options.warnLogger) { - this.options.warnLogger(violations[0], this.options); - } - return this.success(value); + if (violations.length === 1 && this.ignoreViolation(violations[0])) { + if (this.options.warnLogger) { + this.options.warnLogger(violations[0], this.options); } + return Promise.resolve(value as unknown as Out); } - return new ValidationResult(violations); - } - success(value: any) { - return new ValidationResult(undefined, value); - } - failurePromise(violation: Violation | Violation[], value: any) { - return this.promise(this.failure(violation, value)); - } - successPromise(value: any) { - return this.promise(this.success(value)); + return Promise.reject(violations); } - promise(result: ValidationResult) { - return new SyncPromise(result); - } - registerObject(value: any, path: Path, convertedValue: any): undefined | ValidationResult { + registerObject(value: any, path: Path, convertedValue: T): undefined | PromiseLike { if (this.objects.has(value)) { if (this.options.allowCycles) { - return this.success(this.objects.get(value)); + return Promise.resolve(this.objects.get(value)); } - return this.failure(defaultViolations.cycle(path), value); + return Promise.reject(defaultViolations.cycle(path)); } this.objects.set(value, convertedValue); return undefined; @@ -69,67 +56,70 @@ export class ValidationContext { } } -export class SyncPromise implements PromiseLike { - constructor(private readonly value: T) {} - then( - onfulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null, - onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null, - ): PromiseLike { - if (onfulfilled) { - let result: any; - try { - result = onfulfilled(this.value); - } catch (reason) { - if (onrejected) { - result = onrejected(reason); - } else { - throw reason; - } - } - if (result && result.then) { - return result; - } - return new SyncPromise(result); - } - return this as unknown as PromiseLike; +export abstract class Validator { + validateGroup(value: In, group: Group): Promise> { + return this.validate(value, { group }); } -} -export abstract class Validator { - validateGroup(value: any, group: Group): Promise { - return this.validate(value, { group }); + /** + * Returns a valid value directly or throws a ValidationError with Violations. + * + * @param value value to be validated + * @param options validation options + * @returns a valid, possibly converted value + */ + async getValid(value: In, options?: ValidatorOptions): Promise { + try { + return await this.validatePath(value, ROOT, new ValidationContext(options || {})); + } catch (error) { + throw new ValidationError(violationsOf(error)); + } } - validate(value: any, options?: ValidatorOptions): Promise { - return Promise.resolve(this.validatePath(value, ROOT, new ValidationContext(options || {}))); + /** + * Returns a ValidationResult of value. + * + * @param value value to be validated + * @param options validation options + * @returns ValidationResult of either valid, possibly converted value or Violations + */ + async validate(value: In, options?: ValidatorOptions): Promise> { + try { + const result = await this.validatePath(value, ROOT, new ValidationContext(options || {})); + return new ValidationResult(undefined, result); + } catch (error) { + return new ValidationResult(violationsOf(error)); + } } - abstract validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike; + /** + * Validate `value` and return either resolved of valid/converted value or rejected of Violation or Violation[] Promise. + * @param value + * @param path + * @param ctx + */ + abstract validatePath(value: In, path: Path, ctx: ValidationContext): PromiseLike; - next(...allOf: Validator[]): Validator { - if (allOf.length == 0) { - return this; - } - return new NextValidator(this, maybeAllOfValidator(allOf)); + next(...validators: NextCompositionParameters) { + return maybeCompositionOf(this, ...validators); } - nextMap(fn: MappingFn): Validator { - return this.next(new ValueMapper(fn)); + nextMap(fn: MappingFn): Validator { + return this.next(new ValueMapper(fn)); } } +export type VType> = V extends Validator ? Out : unknown; + export interface WarnLogger { (violation: Violation, ctx: ValidatorOptions): void; } -export class ValidationResult { - private value?: unknown; - - private violations?: Violation[]; - - constructor(violations?: Violation[], value?: unknown) { - this.violations = violations; - this.value = value; +export class ValidationResult { + constructor(private readonly violations?: Violation[], private readonly value?: T) { + if (violations?.length && value !== undefined) { + throw new Error('both violations and success value defined'); + } Object.freeze(this.violations); } @@ -141,11 +131,15 @@ export class ValidationResult { return !this.isSuccess(); } - getValue(): unknown { + /** + * Either returns a valid, possibly converted value or throws a ValidationError with Violations. + * @returns + */ + getValue(): T { if (!this.isSuccess()) { throw new ValidationError(this.getViolations()); } - return this.value; + return this.value!; } getViolations(): Violation[] { @@ -156,6 +150,7 @@ export class ValidationResult { export class ValidationError extends Error { constructor(public readonly violations: Violation[]) { super(`ValidationError: ${JSON.stringify(violations, undefined, 2)}`); + this.violations = violations; Object.setPrototypeOf(this, ValidationError.prototype); } } @@ -278,7 +273,7 @@ export class Groups { } } -export function isNullOrUndefined(value: any) { +export function isNullOrUndefined(value: any): value is null | undefined { return value === null || value === undefined; } @@ -295,6 +290,7 @@ export enum ValidatorType { AnyOf = 'AnyOf', OneOf = 'OneOf', Pattern = 'Pattern', + NotUndefined = "NotUndefined", } export const defaultViolations = { @@ -307,6 +303,7 @@ export const defaultViolations = { max: (max: number, inclusive: boolean, invalidValue: any, path: Path = ROOT) => new MaxViolation(path, max, inclusive, invalidValue), size: (min: number, max: number, path: Path = ROOT) => new SizeViolation(path, min, max), notNull: (path: Path = ROOT) => new Violation(path, ValidatorType.NotNull), + notUndefined: (path: Path = ROOT) => new Violation(path, ValidatorType.NotUndefined), notEmpty: (path: Path = ROOT) => new Violation(path, ValidatorType.NotEmpty), notBlank: (path: Path = ROOT) => new Violation(path, ValidatorType.NotBlank), oneOf: (matches: number, path: Path = ROOT) => new OneOfMismatch(path, matches), @@ -317,367 +314,64 @@ export const defaultViolations = { cycle: (path: Path) => new Violation(path, 'Cycle'), }; -export interface AssertTrue { - (value: any, path: Path, ctx: ValidationContext): boolean; -} - -export type PropertyModel = { [s: string]: string | number | Validator | Validator[] }; - -export type ParentModel = ObjectModel | ObjectValidator | (ObjectModel | ObjectValidator)[]; - -export interface ObjectModel { - readonly extends?: ParentModel; - readonly properties?: PropertyModel; - readonly localProperties?: PropertyModel; - readonly additionalProperties?: boolean | MapEntryModel | MapEntryModel[]; - readonly next?: Validator | Validator[]; - readonly localNext?: Validator; -} - -export interface MapEntryModel { - readonly keys: Validator | Validator[]; - readonly values: Validator | Validator[]; -} - -function getPropertyValidators(properties?: PropertyModel): Properties { - const propertyValidators: Properties = {}; - if (properties) { - for (const name in properties) { - if (isString(properties[name]) || isNumber(properties[name])) { - propertyValidators[name] = new HasValueValidator(properties[name]); - } else { - propertyValidators[name] = maybeAllOfValidator(properties[name] as Validator | Validator[]); - } - } - } - return propertyValidators; -} - -function getParentValidators(parents: undefined | ParentModel): ObjectValidator[] { - let validators: ObjectValidator[] = []; - let parentValidators: any = []; - if (parents) { - parentValidators = parentValidators.concat(parents); - } - for (let i = 0; i < parentValidators.length; i++) { - const modelOrValidator = parentValidators[i]; - if (modelOrValidator instanceof ObjectValidator) { - validators[i] = modelOrValidator as ObjectValidator; - } else { - validators[i] = new ObjectValidator(modelOrValidator as ObjectModel); - } - } - return validators; +export interface AssertTrue { + (value: In, path: Path, ctx: ValidationContext): boolean; } -function getMapEntryValidators(additionalProperties?: boolean | MapEntryModel | MapEntryModel[]): MapEntryValidator[] { - if (isNullOrUndefined(additionalProperties)) { - return []; - } - if (typeof additionalProperties === 'boolean') { - if (additionalProperties) { - return [allowAllMapEntries]; - } - return [allowNoneMapEntries]; - } - const models: MapEntryModel[] = ([] as MapEntryModel[]).concat(additionalProperties as MapEntryModel | MapEntryModel[]); - const validators: MapEntryValidator[] = []; - for (let i = 0; i < models.length; i++) { - validators[i] = new MapEntryValidator(models[i]); - } - return validators; -} - -export class ValidatorFnWrapper extends Validator { - private readonly fn: ValidatorFn; - - constructor(fn: ValidatorFn, public readonly type?: string) { +export class ValidatorFnWrapper extends Validator { + constructor(private readonly fn: ValidatorFn, public readonly type?: string) { super(); - this.fn = fn; Object.freeze(this); } - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { + validatePath(value: In, path: Path, ctx: ValidationContext): PromiseLike { return this.fn(value, path, ctx); } } -const lenientUnknownPropertyValidator = new ValidatorFnWrapper((value: any, path: Path, ctx: ValidationContext) => - ctx.failurePromise(defaultViolations.unknownProperty(path), value), -); - -const strictUnknownPropertyValidator = new ValidatorFnWrapper((value: any, path: Path, ctx: ValidationContext) => - ctx.failurePromise(defaultViolations.unknownPropertyDenied(path), value), -); - -export interface PropertyFilter { - (key: string): boolean; -} - -export function mergeProperties(from: Properties, to: Properties): Properties { - if (from) { - for (const key in from) { - if (to[key]) { - to[key] = to[key].next(from[key]); - } else { - to[key] = from[key]; - } - } - } - return to; -} - -export class ObjectValidator extends Validator { - public readonly properties: Properties; - - public readonly localProperties: Properties; - - public readonly additionalProperties: MapEntryValidator[]; - - public readonly parentValidators: ObjectValidator[]; - - public readonly nextValidator: undefined | Validator; - - public readonly localNextValidator: undefined | Validator; - - constructor(public readonly model: ObjectModel) { - super(); - let properties: Properties = {}; - let additionalProperties: MapEntryValidator[] = []; - let inheritedThenValidators: Validator[] = []; - - this.parentValidators = getParentValidators(model.extends); - for (let i = 0; i < this.parentValidators.length; i++) { - const parent = this.parentValidators[i]; - additionalProperties = additionalProperties.concat(parent.additionalProperties); - properties = mergeProperties(parent.properties, properties); - if (parent.nextValidator) { - inheritedThenValidators = inheritedThenValidators.concat(parent.nextValidator); - } - } - let nextValidator = inheritedThenValidators.length ? maybeAllOfValidator(inheritedThenValidators) : undefined; - if (model.next) { - if (nextValidator) { - nextValidator = nextValidator.next(maybeAllOfValidator(model.next)); - } else { - nextValidator = maybeAllOfValidator(model.next); - } - } - this.additionalProperties = additionalProperties.concat(getMapEntryValidators(model.additionalProperties)); - this.properties = mergeProperties(getPropertyValidators(model.properties), properties); - this.localProperties = getPropertyValidators(model.localProperties); - this.nextValidator = nextValidator; - this.localNextValidator = model.localNext; - - Object.freeze(this.properties); - Object.freeze(this.localProperties); - Object.freeze(this.additionalProperties); - Object.freeze(this.parentValidators); - Object.freeze(this); - } - - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { - return this.validateFilteredPath(value, path, ctx, _ => true); - } - - validateFilteredPath(value: any, path: Path, ctx: ValidationContext, filter: PropertyFilter): PromiseLike { - if (isNullOrUndefined(value)) { - return ctx.failurePromise(defaultViolations.notNull(path), value); - } - if (typeof value !== 'object') { - return ctx.failurePromise(defaultViolations.object(path), value); - } - const context: ObjectValidationContext = { - path, - ctx, - filter, - convertedObject: {}, - violations: [], - }; - const cycleResult = ctx.registerObject(value, path, context.convertedObject); - if (cycleResult) { - return ctx.promise(cycleResult); - } - const propertyResults: PromiseLike[] = []; - - for (const key in this.properties) { - propertyResults.push(validateProperty(key, value[key], this.properties[key], context)); - } - for (const key in this.localProperties) { - propertyResults.push(validateProperty(key, value[key], this.localProperties[key], context)); - } - for (const key in value) { - if (!this.properties[key] && !this.localProperties[key]) { - propertyResults.push(validateAdditionalProperty(key, value[key], this.additionalProperties, context)); - } - } - - let validationChain = Promise.all(propertyResults).then(_ => { - if (context.violations.length === 0) { - return ctx.successPromise(context.convertedObject); - } - return ctx.failurePromise(context.violations, context.convertedObject); - }); - if (this.nextValidator) { - const validator = this.nextValidator; - validationChain = validationChain.then(result => (result.isSuccess() ? validator.validatePath(result.getValue(), path, ctx) : result)); - } - if (this.localNextValidator) { - const validator = this.localNextValidator; - validationChain = validationChain.then(result => (result.isSuccess() ? validator.validatePath(result.getValue(), path, ctx) : result)); - } - return validationChain; - } -} - -interface ObjectValidationContext { - readonly path: Path; - readonly ctx: ValidationContext; - readonly filter: PropertyFilter; - readonly convertedObject: any; - violations: Violation[]; -} - -function validateProperty(key: string, currentValue: any, validator: Validator, context: ObjectValidationContext) { - if (!context.filter(key)) { - return context.ctx.successPromise(undefined); - } - // Assign for property order - context.convertedObject[key] = undefined; - return validator.validatePath(currentValue, context.path.property(key), context.ctx).then(result => { - if (result.isSuccess()) { - const newValue = result.getValue(); - if (newValue !== undefined) { - context.convertedObject[key] = newValue; - } else { - delete context.convertedObject[key]; - } - } else { - delete context.convertedObject[key]; - context.violations = context.violations.concat(result.getViolations()); - } - return result; - }); -} - -async function validateAdditionalProperty( - key: string, - originalValue: any, - additionalProperties: MapEntryValidator[], - context: ObjectValidationContext, -): Promise { - if (!context.filter(key)) { - return Promise.resolve(context.ctx.success(undefined)); - } - const keyPath = context.path.property(key); - let currentValue = originalValue; - let validKey = false; - let result: undefined | ValidationResult; - for (let i = 0; i < additionalProperties.length; i++) { - const entryValidator = additionalProperties[i]; - result = await entryValidator.keyValidator.validatePath(key, keyPath, context.ctx); - if (result.isSuccess()) { - validKey = true; - result = await validateProperty(key, currentValue, entryValidator.valueValidator, context); - if (result.isSuccess()) { - currentValue = result.getValue(); - } else { - return result; - } - } - } - if (!validKey) { - if (additionalProperties.length == 1 && result) { - // Only one kind of key accepted -> give out violations related to that - context.violations = context.violations.concat(result!.getViolations()); - } else { - return validateProperty(key, originalValue, lenientUnknownPropertyValidator, context); - } - } -} - -/** - * Converts a primitive `value` into an object `{ property: value }`. This normalizer can be used - * to e.g. preprocess the results of an XML parser and a schema having textual elements with optional attributes - * where an element without attributes would be simple string and an element with attributes would be an object. - */ -export class ObjectNormalizer extends Validator { - constructor(public readonly property: string) { +export class ArrayValidator extends Validator { + constructor(public readonly itemsValidator: Validator) { super(); Object.freeze(this); } - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { - if (value === undefined) { - return ctx.successPromise(undefined); - } - if (typeof value !== 'object' || value === null) { - const object: any = {}; - object[this.property] = value; - return ctx.successPromise(object); - } - return ctx.successPromise(value); - } -} -export class MapEntryValidator { - public readonly keyValidator: Validator; - - public readonly valueValidator: Validator; - - constructor(entryModel: MapEntryModel) { - this.keyValidator = maybeAllOfValidator(entryModel.keys); - this.valueValidator = maybeAllOfValidator(entryModel.values); - Object.freeze(this); - } -} - -export class ArrayValidator extends Validator { - constructor(public readonly itemsValidator: Validator) { - super(); - Object.freeze(this); - } - - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { + validatePath(value: unknown, path: Path, ctx: ValidationContext): PromiseLike { if (isNullOrUndefined(value)) { - return ctx.failurePromise(defaultViolations.notNull(path), value); + return Promise.reject(defaultViolations.notNull(path)); } if (!Array.isArray(value)) { - return ctx.failurePromise(new TypeMismatch(path, 'array', value), value); + return Promise.reject(new TypeMismatch(path, 'array', value)); } const convertedArray: Array = []; const cycleResult = ctx.registerObject(value, path, convertedArray); if (cycleResult) { - return ctx.promise(cycleResult); + return cycleResult; } - const promises: PromiseLike[] = []; + const promises: PromiseLike[] = []; let violations: Violation[] = []; for (let i = 0; i < value.length; i++) { const item = value[i]; - promises[i] = this.itemsValidator.validatePath(item, path.index(i), ctx).then(result => { - if (result.isSuccess()) { - convertedArray[i] = result.getValue(); - } else { - violations = violations.concat(result.getViolations()); - } - return result; - }); + promises[i] = this.itemsValidator.validatePath(item, path.index(i), ctx).then( + result => convertedArray[i] = result, + reject => violations = violations.concat(violationsOf(reject)) + ); } - return Promise.all(promises).then(_ => { + return Promise.allSettled(promises).then(_ => { if (violations.length == 0) { - return ctx.successPromise(convertedArray); + return Promise.resolve(convertedArray); } - return ctx.failurePromise(violations, value); + return Promise.reject(violations); }); } } -export class ArrayNormalizer extends ArrayValidator { - constructor(itemsValidator: Validator) { +export class ArrayNormalizer extends ArrayValidator { + constructor(itemsValidator: Validator) { super(itemsValidator); } - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { + validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { if (value === undefined) { return super.validatePath([], path, ctx); } @@ -688,118 +382,107 @@ export class ArrayNormalizer extends ArrayValidator { } } -export class NextValidator extends Validator { - constructor(public readonly firstValidator: Validator, public readonly nextValidator: Validator) { - super(); - Object.freeze(this); - } - - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { - return this.firstValidator.validatePath(value, path, ctx).then(firstResult => { - if (firstResult.isSuccess()) { - return this.nextValidator.validatePath(firstResult.getValue(), path, ctx); - } - return firstResult; - }); - } -} - -export class CheckValidator extends Validator { - constructor(public readonly validator: Validator) { +export class CheckValidator extends Validator { + constructor(public readonly validator: Validator) { super(); Object.freeze(this); } - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { - return this.validator.validatePath(value, path, ctx).then(result => { - if (result.isSuccess()) { - return ctx.successPromise(value); - } - return result; + validatePath(value: In, path: Path, ctx: ValidationContext): PromiseLike { + return this.validator.validatePath(value, path, ctx).then(() => { + return value; }); } } -export class CompositionValidator extends Validator { +export class CompositionValidator extends Validator { public readonly validators: Validator[]; - - constructor(validators: Validator | Validator[]) { + constructor(validators: Validator[]) { super(); this.validators = ([] as Validator[]).concat(validators); Object.freeze(this.validators); Object.freeze(this); } - async validatePath(value: any, path: Path, ctx: ValidationContext): Promise { - let currentValue = value; + async validatePath(value: In, path: Path, ctx: ValidationContext): Promise { + let currentValue: any = value; for (let i = 0; i < this.validators.length; i++) { - const result = await this.validators[i].validatePath(currentValue, path, ctx); - if (result.isSuccess()) { - currentValue = result.getValue(); - } else { - return result; - } + currentValue = await this.validators[i].validatePath(currentValue, path, ctx); } - return ctx.success(currentValue); + return Promise.resolve(currentValue); } } -export class OneOfValidator extends Validator { - constructor(public readonly validators: Validator[]) { +export class OneOfValidator extends Validator { + constructor(public readonly validators: [Validator, ...Validator[]]) { super(); Object.freeze(this.validators); Object.freeze(this); } - async validatePath(value: any, path: Path, ctx: ValidationContext): Promise { + async validatePath(value: unknown, path: Path, ctx: ValidationContext): Promise { let matches = 0; let newValue: any = null; - const promises: PromiseLike[] = []; + const promises: PromiseLike[] = []; for (let i = 0; i < this.validators.length; i++) { - promises[i] = this.validators[i].validatePath(value, path, ctx).then(result => { - if (result.isSuccess()) { + promises[i] = this.validators[i].validatePath(value, path, ctx).then( + result => { matches++; - newValue = result.getValue(); - } - return result; - }); + newValue = result; + }, + error => {} + ); } - await Promise.all(promises); + await Promise.allSettled(promises); if (matches === 1) { - return ctx.successPromise(newValue); + return Promise.resolve(newValue); } - return ctx.failurePromise(defaultViolations.oneOf(matches, path), value); + return Promise.reject(defaultViolations.oneOf(matches, path)); } } -export class AnyOfValidator extends Validator { - constructor(public readonly validators: Validator[]) { +export class AnyOfValidator extends Validator { + constructor(public readonly validators: Validator[]) { super(); Object.freeze(this.validators); Object.freeze(this); } - async validatePath(value: any, path: Path, ctx: ValidationContext): Promise { - const passes: unknown[] = []; - const failures: Violation[] = []; - - for (let i = 0; i < this.validators.length; i++) { - const validator = this.validators[i]; - const result = await validator.validatePath(value, path, ctx); - result.isSuccess() ? passes.push(result.getValue()) : failures.push(...result.getViolations()); + validatePath(value: In, path: Path, ctx: ValidationContext): PromiseLike { + let passes: Out[] = []; + let violations: Violation[] = []; + const promises = [] as PromiseLike[]; + for (const validator of this.validators) { + promises.push( + validator.validatePath(value, path, ctx).then( + result => { + passes.push(result); + }, + (error) => { + violations = violations.concat(violationsOf(error)); + } + ) + ); } - - return passes.length > 0 ? ctx.success(passes.pop()) : ctx.failure(failures, value); + return Promise.allSettled(promises).then(() => { + if (passes.length > 0) { + return Promise.resolve(passes[passes.length - 1]); + } + return Promise.reject(violations); + }) } } -export class IfValidator extends Validator { - constructor(public readonly conditionals: Conditional[], public readonly elseValidator?: Validator) { +export class IfValidator extends Validator { + constructor(public readonly conditionals: Conditional[], public readonly elseValidator?: Validator) { super(); + if (conditionals.length === 0) { + throw new Error('At least one conditional required'); + } Object.freeze(this.conditionals); Object.freeze(this); } - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { + validatePath(value: In, path: Path, ctx: ValidationContext): PromiseLike { for (let i = 0; i < this.conditionals.length; i++) { const conditional = this.conditionals[i]; if (conditional.fn(value, path, ctx)) { @@ -809,98 +492,111 @@ export class IfValidator extends Validator { if (this.elseValidator) { return this.elseValidator.validatePath(value, path, ctx); } - return ctx.successPromise(value); + return Promise.reject(new Violation(path, 'NoMatchingCondition', value)); } - elseIf(fn: AssertTrue, ...allOf: Validator[]): IfValidator { + elseIf(fn: AssertTrue, validator: Validator): IfValidator { if (this.elseValidator) { throw new Error('Else is already defined. Define elseIfs first.'); } - return new IfValidator([...this.conditionals, new Conditional(fn, allOf)], this.elseValidator); + return new IfValidator( + [...this.conditionals, new Conditional(fn, validator)] as Conditional[], + this.elseValidator + ); } - else(...allOf: Validator[]): Validator { + else(validator: Validator): IfValidator { if (this.elseValidator) { throw new Error('Else is already defined.'); } - return new IfValidator(this.conditionals, maybeAllOfValidator(allOf)); + return new IfValidator(this.conditionals, validator); } } -export class Conditional { - public readonly fn: AssertTrue; - public readonly validator: Validator; - - constructor(fn: AssertTrue, allOf: Validator[]) { - this.fn = fn; - this.validator = maybeAllOfValidator(allOf); +export class Conditional { + constructor(public readonly fn: AssertTrue, public readonly validator: Validator) { Object.freeze(this.validator); Object.freeze(this); } } -export class WhenGroupValidator extends Validator { - constructor(public readonly whenGroups: WhenGroup[], public readonly otherwiseValidator?: Validator) { +export class WhenGroupValidator extends Validator { + constructor(public readonly whenGroups: WhenGroup[], public readonly otherwiseValidator?: Validator) { super(); Object.freeze(this.whenGroups); Object.freeze(this); } - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { + validatePath(value: unknown, path: Path, ctx: ValidationContext): PromiseLike{ if (ctx.options.group) { + let passes: When[] = []; + let violations: Violation[] = []; + const promises = [] as PromiseLike[]; for (let i = 0; i < this.whenGroups.length; i++) { const whenGroup = this.whenGroups[i]; if (ctx.options.group.includes(whenGroup.group)) { - return whenGroup.validator.validatePath(value, path, ctx); + promises.push(whenGroup.validator.validatePath(value, path, ctx).then( + result => { + passes.push(result); + }, + error => { + violations = violations.concat(violationsOf(error)); + } + )); } } + if (promises.length) { + return Promise.allSettled(promises).then(() => { + if (violations.length > 0) { + return Promise.reject(violations); + } + return passes[passes.length - 1]; + }); + } } if (this.otherwiseValidator) { return this.otherwiseValidator.validatePath(value, path, ctx); } - return ctx.successPromise(value); + return Promise.reject(new Violation(path, 'NoMatchingGroup', value)); } - whenGroup(group: GroupOrName, ...allOf: Validator[]) { + whenGroup(group: GroupOrName, validator: Validator): WhenGroupValidator { if (this.otherwiseValidator) { throw new Error('Otherwise already defined. Define whenGroups first.'); } - return new WhenGroupValidator([...this.whenGroups, new WhenGroup(group, allOf)], this.otherwiseValidator); + return new WhenGroupValidator([...this.whenGroups, new WhenGroup(group, validator)], this.otherwiseValidator); } - otherwise(...allOf: Validator[]): Validator { + otherwise(validator: Validator): Validator { if (this.otherwiseValidator) { throw new Error('Otherwise already defined.'); } - return new WhenGroupValidator(this.whenGroups, maybeAllOfValidator(allOf)); + return new WhenGroupValidator(this.whenGroups, validator); } } -export class WhenGroup { +export class WhenGroup { public readonly group: string; - public readonly validator: Validator; - - constructor(group: GroupOrName, allOf: Validator[]) { + constructor(group: GroupOrName, public readonly validator: Validator) { this.group = isString(group) ? (group as string) : (group as Group).name; - this.validator = maybeAllOfValidator(allOf); Object.freeze(this); } } -export class MapValidator extends Validator { - constructor(public readonly keys: Validator, public readonly values: Validator, public readonly jsonSafeMap: boolean) { +export class MapValidator extends Validator> { + constructor(public readonly keys: Validator, public readonly values: Validator, public readonly jsonSafeMap: boolean) { super(); Object.freeze(this); } - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { + validatePath(value: unknown, path: Path, ctx: ValidationContext): PromiseLike>{ if (isNullOrUndefined(value)) { - return ctx.failurePromise(defaultViolations.notNull(path), value); + return Promise.reject(defaultViolations.notNull(path)); } if (!(value instanceof Map)) { - return ctx.failurePromise(new TypeMismatch(path, 'Map'), value); + return Promise.reject(new TypeMismatch(path, 'Map')); } const map: Map = value as Map; - const convertedMap: Map = this.jsonSafeMap ? new JsonMap() : new Map(); + const convertedMap: Map = this.jsonSafeMap ? new JsonMap() : new Map(); const promises: Promise[] = []; let violations: Violation[] = []; let i = 0; @@ -908,40 +604,37 @@ export class MapValidator extends Validator { const entryPath = path.index(i); const keyPromise = this.keys.validatePath(key, entryPath.index(0), ctx); const valuePromise = this.values.validatePath(value, entryPath.index(1), ctx); - promises[i] = Promise.all([keyPromise, valuePromise]).then(results => { - const keyResult = results[0] as ValidationResult; - const valueResult = results[1] as ValidationResult; - const keySuccess = handleResult(keyResult); - const valueSuccess = handleResult(valueResult); - if (keySuccess && valueSuccess) { - convertedMap.set(keyResult.getValue(), valueResult.getValue()); + promises[i] = Promise.allSettled([keyPromise, valuePromise]).then(results => { + const keyResult = results[0]; + const valueResult = results[1]; + if (keyResult.status === 'fulfilled' && valueResult.status === 'fulfilled') { + convertedMap.set(keyResult.value, valueResult.value); + } else { + if (keyResult.status === 'rejected') { + violations = violations.concat(violationsOf(keyResult.reason)); + } + if (valueResult.status === 'rejected') { + violations = violations.concat(violationsOf(valueResult.reason)); + } } }); ++i; } - return Promise.all(promises).then(_ => { + return Promise.allSettled(promises).then(_ => { if (violations.length > 0) { - return ctx.failurePromise(violations, value); + return Promise.reject(violations); } - return ctx.successPromise(convertedMap); + return Promise.resolve(convertedMap); }); - - function handleResult(result: ValidationResult) { - if (result.isFailure()) { - violations = violations.concat(result.getViolations()); - return false; - } - return true; - } } } -export class MapNormalizer extends MapValidator { - constructor(keys: Validator, values: Validator) { - super(keys, values, true); +export class MapNormalizer extends MapValidator { + constructor(keys: Validator, values: Validator, jsonSafeMap: boolean = true) { + super(keys, values, jsonSafeMap); } - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { + validatePath(value: unknown, path: Path, ctx: ValidationContext): PromiseLike> { if (value instanceof Map) { return super.validatePath(value, path, ctx); } @@ -961,11 +654,11 @@ export class MapNormalizer extends MapValidator { } } if (violations.length > 0) { - return ctx.failurePromise(violations, value); + return Promise.reject(violations); } return super.validatePath(map, path, ctx); } - return ctx.failurePromise(new TypeMismatch(path, 'Map OR array of [key, value] arrays'), value); + return Promise.reject(new TypeMismatch(path, 'Map OR array of [key, value] arrays')); } } @@ -978,17 +671,17 @@ export class JsonMap extends Map { } } -export class SetValidator extends Validator { - constructor(public readonly values: Validator, public readonly jsonSafeSet: boolean) { +export class SetValidator extends Validator> { + constructor(public readonly values: Validator, public readonly jsonSafeSet: boolean) { super(); Object.freeze(this); } - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { + validatePath(value: unknown, path: Path, ctx: ValidationContext): PromiseLike> { if (isNullOrUndefined(value)) { - return ctx.failurePromise(defaultViolations.notNull(path), value); + return Promise.reject(defaultViolations.notNull(path)); } if (!(value instanceof Set || Array.isArray(value))) { - return ctx.failurePromise(new TypeMismatch(path, 'Set'), value); + return Promise.reject(new TypeMismatch(path, 'Set')); } const convertedSet: Set = this.jsonSafeSet ? new JsonSet() : new Set(); const promises: PromiseLike[] = []; @@ -996,21 +689,22 @@ export class SetValidator extends Validator { let i = 0; for (const entry of value) { const entryPath = path.index(i); - promises[i] = this.values.validatePath(entry, entryPath, ctx).then((result: ValidationResult) => { - if (result.isFailure()) { - violations = violations.concat(result.getViolations()); - } else { - convertedSet.add(result.getValue()); + promises[i] = this.values.validatePath(entry, entryPath, ctx).then( + result => { + convertedSet.add(result); + }, + error => { + violations = violations.concat(violationsOf(error)); } - }); + ); ++i; } - return Promise.all(promises).then(_ => { + return Promise.allSettled(promises).then(_ => { if (violations.length > 0) { - return ctx.failurePromise(violations, value); + return Promise.reject(violations); } - return ctx.successPromise(convertedSet); + return Promise.resolve(convertedSet); }); } } @@ -1024,14 +718,15 @@ export class JsonSet extends Set { } } -export class AnyValidator extends Validator { - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { - return ctx.successPromise(value); + +export class AnyValidator extends Validator { + validatePath(value: InOut, path: Path, ctx: ValidationContext): PromiseLike { + return Promise.resolve(value); } } -export function isString(value: any) { - return typeof value === 'string' || value instanceof String; +export function isString(value: any): value is string { + return typeof value === 'string'; } export function isSimplePrimitive(value: any) { @@ -1039,54 +734,91 @@ export function isSimplePrimitive(value: any) { return type === 'boolean' || type === 'number' || type === 'bigint' || type === 'string' || type === 'symbol'; } -export class StringValidator extends Validator { - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { +export abstract class StringValidatorBase extends Validator { + notEmpty() { + return new NextStringValidator(this, new NotEmptyValidator()); + } + notBlank() { + return new NextStringValidator(this, new NotBlankValidator()); + } + pattern(pattern: string | RegExp, flags?: string) { + return new NextStringValidator(this, new PatternValidator(pattern, flags)); + } +} + +export class NextStringValidator extends StringValidatorBase { + constructor(public readonly firstValidator: Validator, public readonly nextValidator: Validator) { + super(); + Object.freeze(this); + } + + validatePath(value: string, path: Path, ctx: ValidationContext): PromiseLike { + return this.firstValidator.validatePath(value, path, ctx).then(firstResult => this.nextValidator.validatePath(firstResult, path, ctx)); + } +} + +export class StringValidator extends StringValidatorBase { + validatePath(value: string, path: Path, ctx: ValidationContext): PromiseLike { if (isNullOrUndefined(value)) { - return ctx.failurePromise(defaultViolations.notNull(path), value); + return Promise.reject(defaultViolations.notNull(path)); } if (isString(value)) { - return ctx.successPromise(value); + return Promise.resolve(value); } - return ctx.failurePromise(defaultViolations.string(value, path), value); + return Promise.reject(defaultViolations.string(value, path)); } } -export class StringNormalizer extends Validator { - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { +export class StringNormalizer extends StringValidatorBase { + validatePath(value: unknown, path: Path, ctx: ValidationContext): PromiseLike { if (isNullOrUndefined(value)) { - return ctx.failurePromise(defaultViolations.notNull(path), value); + return Promise.reject(defaultViolations.notNull(path)); } if (isString(value)) { - return ctx.successPromise(value); + return Promise.resolve(value); + } + if (value instanceof String) { + return Promise.resolve(value.valueOf()); } if (isSimplePrimitive(value)) { - return ctx.successPromise(String(value)); + return Promise.resolve(String(value)); } - return ctx.failurePromise(new TypeMismatch(path, 'primitive value', value), value); + return Promise.reject(new TypeMismatch(path, 'primitive value', value)); } } -export class NotNullOrUndefinedValidator extends Validator { - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { - return isNullOrUndefined(value) ? ctx.failurePromise(defaultViolations.notNull(path), value) : ctx.successPromise(value); +export class NotNullOrUndefinedValidator extends Validator< + InOut extends null ? never : InOut extends undefined ? never : InOut, + InOut extends null ? never : InOut extends undefined ? never : InOut + > { + validatePath(value: InOut extends null ? never : InOut extends undefined ? never : InOut, path: Path, ctx: ValidationContext): + PromiseLike { + if (isNullOrUndefined(value)) { + return Promise.reject(defaultViolations.notNull(path)); + } + return Promise.resolve(value); } } -export class IsNullOrUndefinedValidator extends Validator { - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { - return isNullOrUndefined(value) ? ctx.successPromise(value) : ctx.failurePromise(new TypeMismatch(path, 'NullOrUndefined', value), value); +export class IsNullOrUndefinedValidator extends Validator { + validatePath(value: unknown, path: Path, ctx: ValidationContext): PromiseLike { + if (isNullOrUndefined(value)) { + return Promise.resolve(value); + } + return Promise.reject(new TypeMismatch(path, 'NullOrUndefined', value)); } } -export class NotEmptyValidator extends Validator { - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { - return !isNullOrUndefined(value) && isNumber(value.length) && value.length > 0 - ? ctx.successPromise(value) - : ctx.failurePromise(defaultViolations.notEmpty(path), value); +export class NotEmptyValidator extends Validator { + validatePath(value: InOut, path: Path, ctx: ValidationContext): PromiseLike { + if (!isNullOrUndefined(value) && isNumber((value as any).length) && (value as any).length > 0) { + return Promise.resolve(value); + } + return Promise.reject(defaultViolations.notEmpty(path)); } } -export class SizeValidator extends Validator { +export class SizeValidator extends Validator { constructor(private readonly min: number, private readonly max: number) { super(); if (max < min) { @@ -1095,49 +827,49 @@ export class SizeValidator extends Validator { Object.freeze(this); } - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { + validatePath(value: InOut, path: Path, ctx: ValidationContext): PromiseLike { if (isNullOrUndefined(value)) { - return ctx.failurePromise(defaultViolations.notNull(path), value); + return Promise.reject(defaultViolations.notNull(path)); } if (!isNumber(value.length)) { - return ctx.failurePromise(new TypeMismatch(path, 'value with numeric length field'), value); + return Promise.reject(new TypeMismatch(path, 'value with numeric length field')); } if (value.length < this.min || value.length > this.max) { - return ctx.failurePromise(defaultViolations.size(this.min, this.max, path), value); + return Promise.reject(defaultViolations.size(this.min, this.max, path)); } - return ctx.successPromise(value); + return Promise.resolve(value); } } -export class NotBlankValidator extends Validator { - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { +export class NotBlankValidator extends Validator { + validatePath(value: unknown, path: Path, ctx: ValidationContext): PromiseLike { if (isNullOrUndefined(value)) { - return ctx.failurePromise(defaultViolations.notBlank(path), value); + return Promise.reject(defaultViolations.notBlank(path)); } if (!isString(value)) { - return ctx.failurePromise(defaultViolations.string(value, path), value); + return Promise.reject(defaultViolations.string(value, path)); } const trimmed = (value as String).trim(); if (trimmed === '') { - return ctx.failurePromise(defaultViolations.notBlank(path), value); + return Promise.reject(defaultViolations.notBlank(path)); } - return ctx.successPromise(value); + return Promise.resolve(value); } } -export class BooleanValidator extends Validator { - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { +export class BooleanValidator extends Validator { + validatePath(value: unknown, path: Path, ctx: ValidationContext): PromiseLike { if (isNullOrUndefined(value)) { - return ctx.failurePromise(defaultViolations.notNull(path), value); + return Promise.reject(defaultViolations.notNull(path)); } if (typeof value === 'boolean') { - return ctx.successPromise(value); + return Promise.resolve(value); } - return ctx.failurePromise(defaultViolations.boolean(value, path), value); + return Promise.reject(defaultViolations.boolean(value, path)); } } -export class BooleanNormalizer extends Validator { +export class BooleanNormalizer extends Validator { constructor(public readonly truePattern: RegExp, public readonly falsePattern: RegExp) { super(); Object.freeze(this.truePattern); @@ -1145,28 +877,28 @@ export class BooleanNormalizer extends Validator { Object.freeze(this); } - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { + validatePath(value: unknown, path: Path, ctx: ValidationContext): PromiseLike { if (isNullOrUndefined(value)) { - return ctx.failurePromise(defaultViolations.notNull(path), value); + return Promise.reject(defaultViolations.notNull(path)); } if (typeof value === 'boolean') { - return ctx.successPromise(value); + return Promise.resolve(value); } if (value instanceof Boolean) { - return ctx.successPromise(value.valueOf()); + return Promise.resolve(value.valueOf()); } if (isString(value)) { if (this.truePattern.test(value)) { - return ctx.successPromise(true); + return Promise.resolve(true); } if (this.falsePattern.test(value)) { - return ctx.successPromise(false); + return Promise.resolve(false); } - return ctx.failurePromise(defaultViolations.boolean(value, path), value); + return Promise.reject(defaultViolations.boolean(value, path)); } else if (isNumber(value)) { - return ctx.successPromise(!!value); + return Promise.resolve(!!value); } - return ctx.failurePromise(defaultViolations.boolean(value, path), value); + return Promise.reject(defaultViolations.boolean(value, path)); } } @@ -1175,241 +907,256 @@ export enum NumberFormat { integer = 'integer', } -export function isNumber(value: any) { - return (typeof value === 'number' || value instanceof Number) && !Number.isNaN(value.valueOf()); +export function isNumber(value: any): value is number { + return typeof value === 'number' && !Number.isNaN(value); } -export class NumberValidator extends Validator { - constructor(public readonly format: NumberFormat) { +export abstract class NumberValidatorBase extends Validator { + constructor() { super(); - Object.freeze(this); } - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { - if (isNullOrUndefined(value)) { - return ctx.failurePromise(defaultViolations.notNull(path), value); - } - if (!isNumber(value)) { - return ctx.failurePromise(defaultViolations.number(value, this.format, path), value); - } - return this.validateFormat(value, path, ctx); + min(min: number, inclusive = true) { + return new NextNumberValidator(this, new MinValidator(min, inclusive)); } - protected validateFormat(value: any, path: Path, ctx: ValidationContext) { - switch (this.format) { + + max(max: number, inclusive = true) { + return new NextNumberValidator(this, new MaxValidator(max, inclusive)); + } + + protected validateNumberFormat(value: number, format: undefined | NumberFormat, path: Path, ctx: ValidationContext) { + switch (format) { case NumberFormat.integer: if (!Number.isInteger(value)) { - return ctx.failurePromise(defaultViolations.number(value, this.format, path), value); + return Promise.reject(defaultViolations.number(value, format, path)); } break; } - return ctx.successPromise(value); + return Promise.resolve(value); + } +} + +export class NextNumberValidator extends NumberValidatorBase { + constructor(public readonly firstValidator: Validator, public readonly nextValidator: Validator) { + super(); + Object.freeze(this); + } + + validatePath(value: In, path: Path, ctx: ValidationContext): PromiseLike { + return this.firstValidator.validatePath(value, path, ctx).then( + firstResult => this.nextValidator.validatePath(firstResult, path, ctx)); + } +} + +export class NumberValidator extends NumberValidatorBase { + constructor(public readonly format: NumberFormat) { + super(); + Object.freeze(this); + } + + validatePath(value: number, path: Path, ctx: ValidationContext): PromiseLike { + if (isNullOrUndefined(value)) { + return Promise.reject(defaultViolations.notNull(path)); + } + if (!isNumber(value)) { + return Promise.reject(defaultViolations.number(value, this.format, path)); + } + return super.validateNumberFormat(value, this.format, path, ctx); } } -export class NumberNormalizer extends NumberValidator { - constructor(format: NumberFormat) { - super(format); +export class NumberNormalizer extends NumberValidatorBase { + constructor(public readonly format: NumberFormat) { + super(); + Object.freeze(this); } - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { + validatePath(value: unknown, path: Path, ctx: ValidationContext): PromiseLike { if (isNullOrUndefined(value)) { - return ctx.failurePromise(defaultViolations.notNull(path), value); + return Promise.reject(defaultViolations.notNull(path)); } if (isNumber(value)) { - return super.validateFormat(value, path, ctx); + return super.validateNumberFormat(value, this.format, path, ctx); + } + if (value instanceof Number) { + return super.validateNumberFormat(value.valueOf(), this.format, path, ctx); } if (isString(value)) { if (value.trim() === '') { - return ctx.failurePromise(defaultViolations.number(value, this.format, path), value); + return Promise.reject(defaultViolations.number(value, this.format, path)); } const nbr = Number(value); if (isNumber(nbr)) { - return this.validateFormat(nbr, path, ctx); + return super.validateNumberFormat(nbr, this.format, path, ctx); } - return ctx.failurePromise(defaultViolations.number(value, this.format, path), value); + return Promise.reject(defaultViolations.number(value, this.format, path)); } - return ctx.failurePromise(defaultViolations.number(value, this.format, path), value); + return Promise.reject(defaultViolations.number(value, this.format, path)); } } -export class MinValidator extends Validator { +export class MinValidator extends Validator { constructor(public readonly min: number, public readonly inclusive: boolean) { super(); Object.freeze(this); } - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { + validatePath(value: number, path: Path, ctx: ValidationContext): PromiseLike { if (isNullOrUndefined(value)) { - return ctx.failurePromise(defaultViolations.notNull(path), value); + return Promise.reject(defaultViolations.notNull(path)); } if (!isNumber(value)) { - return ctx.failurePromise(defaultViolations.number(value, NumberFormat.number, path), value); + return Promise.reject(defaultViolations.number(value, NumberFormat.number, path)); } if (this.inclusive) { if (value < this.min) { - return ctx.failurePromise(defaultViolations.min(this.min, this.inclusive, value, path), value); + return Promise.reject(defaultViolations.min(this.min, this.inclusive, value, path)); } } else if (value <= this.min) { - return ctx.failurePromise(defaultViolations.min(this.min, this.inclusive, value, path), value); + return Promise.reject(defaultViolations.min(this.min, this.inclusive, value, path)); } - return ctx.successPromise(value); + return Promise.resolve(value); } } -export class MaxValidator extends Validator { +export class MaxValidator extends Validator { constructor(public readonly max: number, public readonly inclusive: boolean) { super(); Object.freeze(this); } - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { + validatePath(value: number, path: Path, ctx: ValidationContext): PromiseLike { if (isNullOrUndefined(value)) { - return ctx.failurePromise(defaultViolations.notNull(path), value); + return Promise.reject(defaultViolations.notNull(path)); } if (!isNumber(value)) { - return ctx.failurePromise(defaultViolations.number(value, NumberFormat.number, path), value); + return Promise.reject(defaultViolations.number(value, NumberFormat.number, path)); } if (this.inclusive) { if (value > this.max) { - return ctx.failurePromise(defaultViolations.max(this.max, this.inclusive, value, path), value); + return Promise.reject(defaultViolations.max(this.max, this.inclusive, value, path)); } } else if (value >= this.max) { - return ctx.failurePromise(defaultViolations.max(this.max, this.inclusive, value, path), value); + return Promise.reject(defaultViolations.max(this.max, this.inclusive, value, path)); } - return ctx.successPromise(value); + return Promise.resolve(value); } } -export class EnumValidator extends Validator { - constructor(public readonly enumType: object, public readonly name: string) { +export class EnumValidator extends Validator { + constructor(public readonly enumType: T, public readonly name: string) { super(); Object.freeze(this); } - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { + validatePath(value: unknown, path: Path, ctx: ValidationContext): PromiseLike { if (isNullOrUndefined(value)) { - return ctx.failurePromise(defaultViolations.notNull(path), value); + return Promise.reject(defaultViolations.notNull(path)); } - const isValid = Object.values(this.enumType).includes(value); - if (isValid) { - return ctx.successPromise(value); + if (typeof value === 'string' || typeof value === 'number') { + const isValid = Object.values(this.enumType).includes(value); + if (isValid) { + return Promise.resolve(value as T); + } } - return ctx.failurePromise(defaultViolations.enum(this.name, value, path), value); + return ctx.failure(defaultViolations.enum(this.name, value, path), value); } } -export class AssertTrueValidator extends Validator { - public fn: AssertTrue; - - constructor(fn: AssertTrue, public readonly type: string, public readonly path?: Path) { +export class AssertTrueValidator extends Validator { + constructor(public readonly fn: AssertTrue, public readonly type: string, public readonly path?: Path) { super(); - this.fn = fn; Object.freeze(this); } - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { + validatePath(value: In, path: Path, ctx: ValidationContext): PromiseLike { if (!this.fn(value, path, ctx)) { - return ctx.failurePromise(new Violation(this.path ? this.path.connectTo(path) : path, this.type), value); + return Promise.reject(new Violation(this.path ? this.path.connectTo(path) : path, this.type)); } - return ctx.successPromise(value); + return Promise.resolve(value); } } -export class UuidValidator extends Validator { +export class UuidValidator extends Validator { constructor(public readonly version?: number) { super(); } - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { + validatePath(value: unknown, path: Path, ctx: ValidationContext): PromiseLike { if (isNullOrUndefined(value)) { - return ctx.failurePromise(defaultViolations.notNull(path), value); + return Promise.reject(defaultViolations.notNull(path)); } if (!isString(value)) { - return ctx.failurePromise(defaultViolations.string(value, path), value); + return Promise.reject(defaultViolations.string(value, path)); } if (!uuidValidate(value)) { - return ctx.failurePromise(new Violation(path, 'UUID', value), value); + return Promise.reject(new Violation(path, 'UUID', value)); } if (this.version && uuidVersion(value) !== this.version) { - return ctx.failurePromise(new Violation(path, `UUIDv${this.version}`, value), value); + return Promise.reject(new Violation(path, `UUIDv${this.version}`, value)); } - return ctx.successPromise(value); + return Promise.resolve(value); } } -export class HasValueValidator extends Validator { - constructor(public readonly expectedValue: any) { +export class HasValueValidator extends Validator { + constructor(public readonly expectedValue: InOut) { super(); Object.freeze(this); } - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { + validatePath(value: unknown, path: Path, ctx: ValidationContext): PromiseLike { if (deepEqual(value, this.expectedValue)) { - return ctx.successPromise(value); + return Promise.resolve(value as InOut); } - return ctx.failurePromise(new HasValueViolation(path, this.expectedValue, value), value); + return Promise.reject(new HasValueViolation(path, this.expectedValue, value)); } } -export function maybeAllOfValidator(validatorOrArray: Validator | Validator[]): Validator { - if (Array.isArray(validatorOrArray)) { - if (validatorOrArray.length === 0) { - return new AnyValidator(); - } - if (validatorOrArray.length === 1) { - return validatorOrArray[0]; - } - return new AllOfValidator(validatorOrArray); - } - return validatorOrArray as Validator; -} - -export class AllOfValidator extends Validator { - public readonly validators: Validator[]; - - constructor(validators: Validator[]) { +export class AllOfValidator extends Validator { + constructor(public readonly validators: [Validator, ...Validator[]]) { super(); - this.validators = ([] as Validator[]).concat(validators); Object.freeze(this.validators); Object.freeze(this); } - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { + validatePath(value: In, path: Path, ctx: ValidationContext): PromiseLike { let violations: Violation[] = []; let convertedValue: any; const promises: PromiseLike[] = []; for (let i = 0; i < this.validators.length; i++) { const validator = this.validators[i]; - promises[i] = validator.validatePath(value, path, ctx).then(result => { - if (!result.isSuccess()) { - violations = violations.concat(result.getViolations()); - } else { - const resultValue = result.getValue(); - if (resultValue !== value) { - if (convertedValue !== undefined && !deepEqual(resultValue, convertedValue)) { - throw new Error('Conflicting conversions'); + promises[i] = validator.validatePath(value, path, ctx).then( + result => { + if (result !== value as any) { + if (convertedValue !== undefined && !deepEqual(result, convertedValue)) { + violations.push(new Violation(path, 'ConflictingConversions', value)); + } else { + convertedValue = result; } - convertedValue = resultValue; } + }, + error => { + violations = violations.concat(violationsOf(error)); } - }); + ); } - return Promise.all(promises).then(_ => { + return Promise.allSettled(promises).then(_ => { if (violations.length == 0) { - return ctx.successPromise(convertedValue !== undefined ? convertedValue : value); + return Promise.resolve(convertedValue !== undefined ? convertedValue : value); } - return ctx.failurePromise(violations, value); + return Promise.reject(violations); }); } } -export class DateValidator extends Validator { +export class DateValidator extends Validator { constructor(public readonly dateType: string) { super(); Object.freeze(this); } - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { + validatePath(value: unknown, path: Path, ctx: ValidationContext): PromiseLike { if (isNullOrUndefined(value)) { - return ctx.failurePromise(defaultViolations.notNull(path), value); + return Promise.reject(defaultViolations.notNull(path)); } let dateValue: any; if (isString(value) || isNumber(value)) { @@ -1419,15 +1166,15 @@ export class DateValidator extends Validator { } if (dateValue instanceof Date) { if (isNaN((dateValue as Date).getTime())) { - return ctx.failurePromise(defaultViolations.date(value, path), value); + return Promise.reject(defaultViolations.date(value, path)); } - return ctx.successPromise(dateValue); + return Promise.resolve(dateValue); } - return ctx.failurePromise(defaultViolations.date(value, path, this.dateType), value); + return Promise.reject(defaultViolations.date(value, path, this.dateType)); } } -export class PatternValidator extends Validator { +export class PatternValidator extends StringValidatorBase { public readonly regExp: RegExp; constructor(pattern: string | RegExp, flags?: string) { @@ -1437,17 +1184,17 @@ export class PatternValidator extends Validator { Object.freeze(this); } - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { + validatePath(value: unknown, path: Path, ctx: ValidationContext): PromiseLike { if (isNullOrUndefined(value)) { - return ctx.failurePromise(defaultViolations.notNull(path), value); + return Promise.reject(defaultViolations.notNull(path)); } if (!isString(value)) { - return ctx.failurePromise(defaultViolations.string(value, path), value); + return Promise.reject(defaultViolations.string(value, path)); } if (this.regExp.test(value)) { - return ctx.successPromise(value); + return Promise.resolve(value); } - return ctx.failurePromise(defaultViolations.pattern(this.regExp, value, path), value); + return Promise.reject(defaultViolations.pattern(this.regExp, value, path)); } toJSON() { @@ -1461,9 +1208,9 @@ export class PatternNormalizer extends PatternValidator { constructor(pattern: string | RegExp, flags?: string) { super(pattern, flags); } - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { + validatePath(value: unknown, path: Path, ctx: ValidationContext): PromiseLike { if (isNullOrUndefined(value)) { - return ctx.failurePromise(defaultViolations.notNull(path), value); + return Promise.reject(defaultViolations.notNull(path)); } if (isString(value)) { return super.validatePath(value, path, ctx); @@ -1471,130 +1218,166 @@ export class PatternNormalizer extends PatternValidator { if (isSimplePrimitive(value)) { return super.validatePath(String(value), path, ctx); } - return ctx.failurePromise(new TypeMismatch(path, 'primitive value', value), value); + return Promise.reject(new TypeMismatch(path, 'primitive value', value)); } } -export class OptionalValidator extends Validator { - private readonly validator: Validator; - - constructor(type: Validator, allOf: Validator[]) { +export class OptionalValidator extends Validator { + constructor(private readonly validator: Validator) { super(); - if (allOf && allOf.length > 0) { - this.validator = new NextValidator(type, maybeAllOfValidator(allOf)); - } else { - this.validator = type; - } Object.freeze(this); } - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { + validatePath(value: null | undefined | In, path: Path, ctx: ValidationContext): PromiseLike { if (isNullOrUndefined(value)) { - return ctx.successPromise(value); + return Promise.resolve(value as null | undefined); } - return this.validator.validatePath(value, path, ctx); + return this.validator.validatePath(value as In, path, ctx); } } -export class RequiredValidator extends Validator { - private readonly validator: Validator; +export class OptionalUndefinedValidator extends Validator { + constructor(private readonly validator: Validator) { + super(); + Object.freeze(this); + } + + validatePath(value: undefined | In, path: Path, ctx: ValidationContext): PromiseLike { + if (value === undefined) { + return Promise.resolve(undefined); + } + return this.validator.validatePath(value, path, ctx); + } +} - constructor(type: Validator, allOf: Validator[]) { +export class NullableValidator extends Validator { + constructor(private readonly validator: Validator) { super(); - if (allOf && allOf.length > 0) { - this.validator = new NextValidator(type, maybeAllOfValidator(allOf)); - } else { - this.validator = type; + Object.freeze(this); + } + + validatePath(value: null | In, path: Path, ctx: ValidationContext): PromiseLike { + if (value === null) { + return Promise.resolve(null); } + if (value === undefined) { + return Promise.reject(defaultViolations.notUndefined(path)); + } + return this.validator.validatePath(value, path, ctx); + } +} + +export class RequiredValidator extends Validator { + constructor(private readonly validator: Validator) { + super(); Object.freeze(this); } - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { + validatePath(value: In, path: Path, ctx: ValidationContext): PromiseLike { if (isNullOrUndefined(value)) { - return ctx.failurePromise(defaultViolations.notNull(path), value); + return Promise.reject(defaultViolations.notNull(path)); } return this.validator.validatePath(value, path, ctx); } } -export class ValueMapper extends Validator { - constructor(public readonly fn: MappingFn, public readonly error?: any) { +export class ValueMapper extends Validator { + constructor(public readonly fn: MappingFn, public readonly error?: any) { super(); Object.freeze(this); } - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { + validatePath(value: In, path: Path, ctx: ValidationContext): PromiseLike { try { const maybePromise = this.fn(value, path, ctx); if (isPromise(maybePromise)) { return maybePromise.then( (result: any) => this.handleResult(result, value, ctx), - (error: any) => this.handleError(error, value, path, ctx), + (error: any) => Promise.reject(violationsOf(error)), ); } else { return this.handleResult(maybePromise, value, ctx); } } catch (error) { - return this.handleError(error, value, path, ctx); + return Promise.reject(violationsOf(error)); } } - private handleError(error: any, value: any, path: Path, ctx: ValidationContext) { - if (error instanceof ValidationError) { - return ctx.failurePromise(error.violations, value); - } - return ctx.failurePromise(new ErrorViolation(path, this.error || error), value); - } - private handleResult(result: any, value: any, ctx: ValidationContext) { if (result instanceof Violation) { - return ctx.failurePromise(result as Violation, value); + return ctx.failure(result, value); } - return ctx.successPromise(result); + return Promise.resolve(result); } } -export function isPromise(value: any) { +export function isPromise(value: any): value is PromiseLike { return value && typeof value['then'] === 'function'; } -export class IgnoreValidator extends Validator { - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { - return ctx.successPromise(undefined); +export class IgnoreValidator extends Validator { + validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { + return Promise.resolve(undefined); } } -const allowAllMapEntries: MapEntryValidator = new MapEntryValidator({ - keys: new AnyValidator(), - values: new AnyValidator(), -}); - -const allowNoneMapEntries: MapEntryValidator = new MapEntryValidator({ - keys: new AnyValidator(), - values: strictUnknownPropertyValidator, -}); - -export class JsonValidator extends Validator { - private readonly validator: Validator; - - constructor(allOf: Validator[]) { +export class JsonValidator extends Validator { + constructor(private readonly validator: Validator) { super(); - this.validator = maybeAllOfValidator(allOf); Object.freeze(this); } - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { + validatePath(value: string, path: Path, ctx: ValidationContext): PromiseLike { if (isNullOrUndefined(value)) { - return ctx.failurePromise(defaultViolations.notNull(path), value); + return Promise.reject(defaultViolations.notNull(path)); } if (!isString(value)) { - return ctx.failurePromise(defaultViolations.string(value, path), value); + return Promise.reject(defaultViolations.string(value, path)); } try { const parsedValue = JSON.parse(value); return this.validator.validatePath(parsedValue, path, ctx); } catch (e) { - return ctx.failurePromise(new TypeMismatch(path, 'JSON', value), value); + return Promise.reject(new TypeMismatch(path, 'JSON', value)); } } } + +export type NextCompositionParameters = +[Validator] | +[Validator, Validator] | +[Validator, Validator, Validator] | +[Validator, Validator, Validator, Validator] | +[Validator, Validator, Validator, Validator, Validator]; + +export type CompositionParameters = + NextCompositionParameters | + [Validator, Validator, Validator, Validator, Validator, Validator]; + +export function maybeCompositionOf(...validators: CompositionParameters): Validator { + if (validators.length === 1) { + return validators[0]; + } else { + return new CompositionValidator(validators); + } +} + +export function maybeAllOfValidator(validators: [Validator, ...Validator[]]): Validator { + if (validators.length === 1) { + return validators[0]; + } + return new AllOfValidator(validators); +} + +export function violationsOf(error: any): Violation[] { + if (error instanceof Violation) { + return [ error ]; + } + if (error instanceof ValidationError) { + return error.violations; + } + if (Array.isArray(error) && error[0] instanceof Violation) { + return error as Violation[]; + } + return [ new ErrorViolation(ROOT, error) ]; +} diff --git a/packages/diff/src/index.spec.ts b/packages/diff/src/index.spec.ts new file mode 100644 index 0000000..6d60f11 --- /dev/null +++ b/packages/diff/src/index.spec.ts @@ -0,0 +1,4 @@ +import { expect, test } from 'vitest'; +import { Diff } from './index.js'; + +test('index coverage', () => expect(Diff).toBeDefined()); diff --git a/packages/diff/src/index.ts b/packages/diff/src/index.ts index 071c801..c8d87f5 100644 --- a/packages/diff/src/index.ts +++ b/packages/diff/src/index.ts @@ -1,2 +1,2 @@ -export * from './Diff'; -export * from './VersionInfo'; +export * from './Diff.js'; +export * from './VersionInfo.js'; diff --git a/packages/luxon/src/Vluxon.spec.ts b/packages/luxon/src/Vluxon.spec.ts index c5e79ed..4b70902 100644 --- a/packages/luxon/src/Vluxon.spec.ts +++ b/packages/luxon/src/Vluxon.spec.ts @@ -14,12 +14,12 @@ import { import { DateTime, Duration, FixedOffsetZone, IANAZone, Settings } from 'luxon'; import { Path } from '@finnair/path'; -async function expectViolations(value: any, validator: Validator, ...violations: Violation[]) { +async function expectViolations(value: In, validator: Validator, ...violations: Violation[]) { const result = await validator.validate(value); expect(result).toEqual(new ValidationResult(violations)); } -async function expectValid(value: any, validator: Validator, convertedValue?: any, ctx?: ValidatorOptions) { +async function expectValid(value: In, validator: Validator, convertedValue?: Out, ctx?: ValidatorOptions) { const result = await validator.validate(value, ctx); verifyValid(result, value, convertedValue); } diff --git a/packages/luxon/src/Vluxon.ts b/packages/luxon/src/Vluxon.ts index 8fe7260..b7637cc 100644 --- a/packages/luxon/src/Vluxon.ts +++ b/packages/luxon/src/Vluxon.ts @@ -1,4 +1,4 @@ -import { ValidationContext, isNullOrUndefined, defaultViolations, isString, V, Validator, ValidationResult, TypeMismatch } from '@finnair/v-validation'; +import { ValidationContext, isNullOrUndefined, defaultViolations, isString, V, Validator, TypeMismatch } from '@finnair/v-validation'; import { Path } from '@finnair/path'; import { DateTime, DateTimeJSOptions, DateTimeOptions, Duration, FixedOffsetZone } from 'luxon'; import { @@ -14,45 +14,61 @@ import { export type LuxonInput = string | DateTime | LuxonDateTime; -export interface ValidateLuxonParams { +export interface DateTimeParams { type: string; pattern: RegExp; - proto?: any; parser: (value: string, match: RegExpExecArray) => DateTime; } -export class LuxonValidator extends Validator { - constructor(public params: ValidateLuxonParams) { +export interface ValidateLuxonParams extends DateTimeParams { + proto: new (...args:any[]) => Out; +} + +export class DateTimeValidator extends Validator { + constructor(public readonly params: DateTimeParams) { super(); Object.freeze(params); Object.freeze(this); } - - validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { + validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { const params = this.params; if (isNullOrUndefined(value)) { - return ctx.failurePromise(defaultViolations.notNull(path), value); + throw defaultViolations.notNull(path); } - if (params.proto && value instanceof params.proto) { - return ctx.successPromise(value); - } else if (DateTime.isDateTime(value)) { + if (DateTime.isDateTime(value)) { if (value.isValid) { - return success(value); + return Promise.resolve(value as DateTime); } } else if (isString(value)) { const match = params.pattern.exec(value); if (match) { const dateTime = params.parser(value, match); if (dateTime.isValid) { - return success(dateTime); + return Promise.resolve(dateTime); } } } - return ctx.failurePromise(defaultViolations.date(value, path, params.type), value); + throw defaultViolations.date(value, path, params.type); + } +} + +export class LuxonValidator extends Validator { + private readonly dateTimeValidator: DateTimeValidator; + constructor(public readonly params: ValidateLuxonParams) { + super(); + this.dateTimeValidator = new DateTimeValidator(params); + Object.freeze(this); + } - function success(dateTime: DateTime) { - return ctx.successPromise(params.proto ? new params.proto(dateTime) : dateTime); + validatePath(value: any, path: Path, ctx: ValidationContext): PromiseLike { + if (value instanceof this.params.proto) { + return Promise.resolve(value); } + return this.dateTimeValidator.validatePath(value, path, ctx).then( + (result: DateTime) => { + return new this.params.proto(result); + } + ); } } @@ -112,7 +128,7 @@ function dateTimeUtc(options: DateTimeOptions = { zone: FixedOffsetZone.utcInsta const dateTimeMillisPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}(?:Z|[+-]\d{2}(?::?\d{2})?)$/; function dateTimeMillis(options: DateTimeOptions = { setZone: true }) { - return new LuxonValidator({ + return new LuxonValidator({ type: 'DateTimeMillis', proto: DateTimeMillisLuxon, pattern: dateTimeMillisPattern, @@ -120,7 +136,7 @@ function dateTimeMillis(options: DateTimeOptions = { setZone: true }) { }); } -function dateTimeMillisUtc(options: DateTimeOptions = { zone: FixedOffsetZone.utcInstance }) { +function dateTimeMillisUtc(options: DateTimeOptions = { zone: FixedOffsetZone.utcInstance }) { return new LuxonValidator({ type: 'DateTimeMillis', proto: DateTimeMillisUtcLuxon, @@ -130,7 +146,7 @@ function dateTimeMillisUtc(options: DateTimeOptions = { zone: FixedOffsetZone.ut } function dateTimeFromISO(options: DateTimeOptions = { setZone: true }) { - return new LuxonValidator({ + return new DateTimeValidator({ type: 'ISODateTime', pattern: /./, parser: (value: string) => DateTime.fromISO(value, options), @@ -138,7 +154,7 @@ function dateTimeFromISO(options: DateTimeOptions = { setZone: true }) { } function dateTimeFromRFC2822(options: DateTimeOptions = { setZone: true }) { - return new LuxonValidator({ + return new DateTimeValidator({ type: 'RFC2822DateTime', pattern: /./, parser: (value: string) => DateTime.fromRFC2822(value, options), @@ -146,7 +162,7 @@ function dateTimeFromRFC2822(options: DateTimeOptions = { setZone: true }) { } function dateTimeFromHTTP(options: DateTimeOptions = { setZone: true }) { - return new LuxonValidator({ + return new DateTimeValidator({ type: 'HTTPDateTime', pattern: /./, parser: (value: string) => DateTime.fromHTTP(value, options), @@ -154,7 +170,7 @@ function dateTimeFromHTTP(options: DateTimeOptions = { setZone: true }) { } function dateTimeFromSQL(options: DateTimeOptions = { zone: FixedOffsetZone.utcInstance }) { - return new LuxonValidator({ + return new DateTimeValidator({ type: 'SQLDateTime', pattern: /./, parser: (value: string) => DateTime.fromSQL(value, options), @@ -169,20 +185,20 @@ export interface ValidateLuxonNumberParams { parser: (value: number) => DateTime; } -export async function validateLuxonNumber({ value, path, ctx, type, parser }: ValidateLuxonNumberParams): Promise { +export async function validateLuxonNumber({ value, path, ctx, type, parser }: ValidateLuxonNumberParams): Promise { if (isNullOrUndefined(value)) { - return ctx.failure(defaultViolations.notNull(path), value); + throw defaultViolations.notNull(path); } else if (DateTime.isDateTime(value)) { if (value.isValid) { - return ctx.success(value); + return Promise.resolve(value); } } else if (typeof value === 'number' && !Number.isNaN(value)) { const dateTime = parser(value); if (dateTime.isValid) { - return ctx.success(dateTime); + return Promise.resolve(dateTime); } } - return ctx.failure(defaultViolations.date(value, path, type), value); + throw defaultViolations.date(value, path, type); } function dateTimeFromMillis(options: DateTimeJSOptions = { zone: FixedOffsetZone.utcInstance }) { @@ -212,32 +228,32 @@ function dateTimeFromSeconds(options: DateTimeJSOptions = { zone: FixedOffsetZon const durationPattern = /^P(?!$)(\d+(?:\.\d+)?Y)?(\d+(?:\.\d+)?M)?(\d+(?:\.\d+)?W)?(\d+(?:\.\d+)?D)?(T(?=\d)(\d+(?:\.\d+)?H)?(\d+(?:\.\d+)?M)?(\d+(?:\.\d+)?S)?)?$/; -export class DurationValidator extends Validator { - async validatePath(value: any, path: Path, ctx: ValidationContext): Promise { +export class DurationValidator extends Validator { + async validatePath(value: any, path: Path, ctx: ValidationContext): Promise { if (isNullOrUndefined(value)) { - return ctx.failure(defaultViolations.notNull(path), value); + throw defaultViolations.notNull(path); } else if (Duration.isDuration(value)) { - return ctx.success(value); + return Promise.resolve(value); } else if (isString(value) && durationPattern.test(value)) { const duration = Duration.fromISO(value); if (duration.isValid) { - return ctx.success(duration); + return Promise.resolve(duration); } } - return ctx.failure(new TypeMismatch(path, 'Duration', value), value); + throw new TypeMismatch(path, 'Duration', value); } } -export class TimeDurationValidator extends Validator { - async validatePath(value: any, path: Path, ctx: ValidationContext): Promise { +export class TimeDurationValidator extends Validator { + async validatePath(value: any, path: Path, ctx: ValidationContext): Promise { if (isNullOrUndefined(value)) { return ctx.failure(defaultViolations.notNull(path), value); } else if (Duration.isDuration(value)) { - return ctx.success(value); + return Promise.resolve(value); } else if (isString(value)) { const duration = Duration.fromISOTime(value); if (duration.isValid) { - return ctx.success(duration); + return Promise.resolve(duration); } } return ctx.failure(new TypeMismatch(path, 'TimeDuration', value), value); diff --git a/packages/luxon/src/index.spec.ts b/packages/luxon/src/index.spec.ts new file mode 100644 index 0000000..1ef0691 --- /dev/null +++ b/packages/luxon/src/index.spec.ts @@ -0,0 +1,4 @@ +import { expect, test } from 'vitest'; +import { LuxonDateTime } from './index.js'; + +test('index coverage', () => expect(LuxonDateTime).toBeDefined()); diff --git a/packages/moment/src/Vmoment.spec.ts b/packages/moment/src/Vmoment.spec.ts index e007663..d456d36 100644 --- a/packages/moment/src/Vmoment.spec.ts +++ b/packages/moment/src/Vmoment.spec.ts @@ -4,17 +4,17 @@ import { V, defaultViolations, Validator, ValidatorOptions, ValidationResult, Vi import { Path } from '@finnair/path'; import { Vmoment, dateUtcMoment, dateTimeUtcMoment, dateTimeMoment, timeMoment, dateMoment, dateTimeMillisUtcMoment, dateTimeMillisMoment } from './Vmoment.js'; -async function expectViolations(value: any, validator: Validator, ...violations: Violation[]) { +async function expectViolations(value: In, validator: Validator, ...violations: Violation[]) { const result = await validator.validate(value); expect(result).toEqual(new ValidationResult(violations)); } -async function expectValid(value: any, validator: Validator, convertedValue?: any, ctx?: ValidatorOptions) { +async function expectValid(value: In, validator: Validator, convertedValue?: Out, ctx?: ValidatorOptions) { const result = await validator.validate(value, ctx); return verifyValid(result, value, convertedValue); } -function verifyValid(result: ValidationResult, value: any, convertedValue?: any) { +function verifyValid(result: ValidationResult, value: any, convertedValue?: Out) { expect(result.getViolations()).toEqual([]); if (convertedValue !== undefined) { expect(result.getValue()).toEqual(convertedValue); diff --git a/packages/moment/src/Vmoment.ts b/packages/moment/src/Vmoment.ts index 16e381c..928346c 100644 --- a/packages/moment/src/Vmoment.ts +++ b/packages/moment/src/Vmoment.ts @@ -1,37 +1,37 @@ -import { Validator, ValidationContext, ValidationResult, isNullOrUndefined, defaultViolations, isString, TypeMismatch } from '@finnair/v-validation'; +import { Validator, ValidationContext, isNullOrUndefined, defaultViolations, isString, TypeMismatch } from '@finnair/v-validation'; import { Path } from '@finnair/path'; import moment, { Moment, MomentInput } from 'moment'; -export class MomentValidator extends Validator { +export class MomentValidator extends Validator { constructor(public readonly type: string, public readonly parse: (value?: MomentInput) => Moment) { super(); Object.freeze(this); } - async validatePath(value: any, path: Path, ctx: ValidationContext): Promise { + async validatePath(value: string | Moment, path: Path, ctx: ValidationContext): Promise { if (isNullOrUndefined(value)) { return ctx.failure(defaultViolations.notNull(path), value); } if (isString(value) || moment.isMoment(value)) { const convertedValue = this.parse(value); if (convertedValue.isValid()) { - return ctx.success(convertedValue); + return Promise.resolve(convertedValue); } } - return ctx.failure(defaultViolations.date(value, path, this.type), value); + throw defaultViolations.date(value, path, this.type); } } const durationPattern = /^P(?!$)(\d+(?:\.\d+)?Y)?(\d+(?:\.\d+)?M)?(\d+(?:\.\d+)?W)?(\d+(?:\.\d+)?D)?(T(?=\d)(\d+(?:\.\d+)?H)?(\d+(?:\.\d+)?M)?(\d+(?:\.\d+)?S)?)?$/; -export class DurationValidator extends Validator { - async validatePath(value: any, path: Path, ctx: ValidationContext): Promise { +export class DurationValidator extends Validator { + async validatePath(value: any, path: Path, ctx: ValidationContext): Promise { if (isNullOrUndefined(value)) { return ctx.failure(defaultViolations.notNull(path), value); } if ((isString(value) && durationPattern.test(value)) || moment.isDuration(value)) { const convertedValue = moment.duration(value); if (convertedValue.isValid()) { - return ctx.success(convertedValue); + return Promise.resolve(convertedValue); } } return ctx.failure(new TypeMismatch(path, 'Duration', value), value); diff --git a/packages/moment/src/index.spec.ts b/packages/moment/src/index.spec.ts new file mode 100644 index 0000000..6cec4d1 --- /dev/null +++ b/packages/moment/src/index.spec.ts @@ -0,0 +1,4 @@ +import { expect, test } from 'vitest'; +import { MomentValidator } from './index.js'; + +test('index coverage', () => expect(MomentValidator).toBeDefined()); diff --git a/packages/path-parser/src/index.spec.ts b/packages/path-parser/src/index.spec.ts new file mode 100644 index 0000000..542204e --- /dev/null +++ b/packages/path-parser/src/index.spec.ts @@ -0,0 +1,4 @@ +import { expect, test } from 'vitest'; +import { parsePath } from './index'; + +test('index coverage', () => expect(parsePath).toBeDefined()); diff --git a/packages/path/src/index.spec.ts b/packages/path/src/index.spec.ts new file mode 100644 index 0000000..a3d6712 --- /dev/null +++ b/packages/path/src/index.spec.ts @@ -0,0 +1,4 @@ +import { expect, test } from 'vitest'; +import { Path } from './index.js'; + +test('index coverage', () => expect(Path).toBeDefined()); diff --git a/yarn.lock b/yarn.lock index c4f70c8..58f8c0b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5193,10 +5193,10 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= -typescript@5.2.2: - version "5.2.2" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.2.tgz#5ebb5e5a5b75f085f22bc3f8460fba308310fa78" - integrity sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w== +typescript@5.6.3: + version "5.6.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.6.3.tgz#5f3449e31c9d94febb17de03cc081dd56d81db5b" + integrity sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw== "typescript@>=3 < 6": version "5.3.3"