fix(schema-utils): improve union errors, add weak assert

This commit is contained in:
Tomas Martykan
2024-01-25 14:49:20 +01:00
committed by Tomáš Martykán
parent 02ab9b9436
commit 23b664a894
4 changed files with 162 additions and 13 deletions

View File

@@ -1,7 +1,10 @@
export class InvalidParameter extends Error {
field?: string;
import { ValueErrorType } from '@sinclair/typebox/errors';
constructor(reason: string, field: string, value?: any) {
export class InvalidParameter extends Error {
field: string;
type: ValueErrorType;
constructor(reason: string, field: string, type: ValueErrorType, value?: any) {
let message = `Invalid parameter`;
message += ` "${field.substring(1)}"`;
message += ` (= ${JSON.stringify(value)})`;
@@ -9,5 +12,6 @@ export class InvalidParameter extends Error {
super(message);
this.name = 'InvalidParameter';
this.field = field;
this.type = type;
}
}

View File

@@ -1,5 +1,6 @@
import { JavaScriptTypeBuilder, Static, TSchema, TObject, Optional } from '@sinclair/typebox';
import { ValueErrorType, Errors } from '@sinclair/typebox/errors';
/* eslint-disable @typescript-eslint/no-use-before-define */
import { JavaScriptTypeBuilder, Static, TSchema, TObject, Optional, Kind } from '@sinclair/typebox';
import { ValueErrorType, Errors, ValueError } from '@sinclair/typebox/errors';
import { Mixin } from 'ts-mixer';
import { ArrayBufferBuilder, BufferBuilder, KeyofEnumBuilder, UintBuilder } from './custom-types';
@@ -15,7 +16,6 @@ class CustomTypeBuilder extends Mixin(
export function Validate<T extends TSchema>(schema: T, value: unknown): value is Static<T> {
try {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
Assert(schema, value);
return true;
} catch (e) {
@@ -23,6 +23,41 @@ export function Validate<T extends TSchema>(schema: T, value: unknown): value is
}
}
function FindErrorInUnion(error: ValueError) {
const currentValue: any = error.value;
const unionMembers: TSchema[] = error.schema.anyOf;
const hasValidMember = unionMembers.find(unionSchema => Validate(unionSchema, currentValue));
if (!hasValidMember) {
// Find possible matches by literals
const possibleMatchesByLiterals = unionMembers.filter(unionSchema => {
if (unionSchema[Kind] !== 'Object') return false;
return !Object.entries(unionSchema.properties as TObject['properties']).find(
([property, propertySchema]) =>
propertySchema.const && propertySchema.const !== currentValue[property],
);
});
if (possibleMatchesByLiterals.length === 1) {
// There is only one possible match
Assert(possibleMatchesByLiterals[0], currentValue);
} else if (possibleMatchesByLiterals.length > 1) {
// Find match with least amount of errors
const errorsOfPossibleMatches = possibleMatchesByLiterals.map(
(matchSchema: TSchema) => ({
schema: matchSchema,
errors: [...Errors(matchSchema, currentValue)],
}),
);
const sortedErrors = errorsOfPossibleMatches.sort(
(a, b) => a.errors.length - b.errors.length,
);
const [bestMatch] = sortedErrors;
Assert(bestMatch.schema, currentValue);
}
throw new InvalidParameter(error.message, error.path, error.type, error.value);
}
}
export function Assert<T extends TSchema>(schema: T, value: unknown): asserts value is Static<T> {
const errors = [...Errors(schema, value)];
let [error] = errors;
@@ -33,19 +68,34 @@ export function Assert<T extends TSchema>(schema: T, value: unknown): asserts va
// Optional can also accept null values
} else if (error.type === ValueErrorType.Union) {
// Drill down into the union
const currentValue = error.value;
const hasValidMember = error.schema.anyOf.find((unionSchema: TSchema) =>
Validate(unionSchema, currentValue),
);
if (!hasValidMember) throw new InvalidParameter(error.message, error.path, error.value);
FindErrorInUnion(error);
} else {
throw new InvalidParameter(error.message, error.path, error.value);
throw new InvalidParameter(error.message, error.path, error.type, error.value);
}
errors.shift();
[error] = errors;
}
}
export function AssertWeak<T extends TSchema>(
schema: T,
value: unknown,
): asserts value is Static<T> {
try {
Assert(schema, value);
} catch (e) {
if (e instanceof InvalidParameter) {
if (e.type === ValueErrorType.ObjectRequiredProperty) {
// We consider this error to be serious
throw e;
}
console.warn('Method params validation failed', e);
} else {
throw e;
}
}
}
export const Type = new CustomTypeBuilder();
export { Optional };
export type { Static, TObject, TSchema };

View File

@@ -1,4 +1,4 @@
import { Type, Validate } from '../src';
import { Assert, Type, Validate } from '../src';
describe('complex-example', () => {
it('should work with a schema like StellarSignTx', () => {
@@ -76,4 +76,56 @@ describe('complex-example', () => {
};
expect(Validate(schema, invalidValue)).toBe(false);
});
it('should work with union types', () => {
const schemaA = Type.Object({
type: Type.Literal('A'),
a: Type.String(),
});
const schemaA2 = Type.Object({
type: Type.Literal('A'),
b: Type.Number(),
c: Type.Number(),
});
const schemaB = Type.Object({
type: Type.Literal('B'),
b: Type.String(),
});
const schema = Type.Union([schemaA, schemaA2, schemaB]);
const invalidSchema = {
type: 'C',
};
expect(() => Assert(schema, invalidSchema)).toThrow(
'Invalid parameter "" (= {"type":"C"}): Expected union value',
);
const invalidSchema2 = {
type: 'A',
a: 123,
};
expect(() => Assert(schema, invalidSchema2)).toThrow(
'Invalid parameter "a" (= 123): Expected string',
);
const invalidSchema3 = {
type: 'A',
b: 123,
c: 'str',
};
expect(() => Assert(schema, invalidSchema3)).toThrow(
'Invalid parameter "c" (= "str"): Expected number',
);
const invalidSchema4 = {
type: 'B',
a: 123,
};
expect(() => Assert(schema, invalidSchema4)).toThrow(
'Invalid parameter "b" (= undefined): Required property',
);
});
});

View File

@@ -0,0 +1,43 @@
import { AssertWeak, Type } from '../src';
describe('weak-assert', () => {
it('should not throw if type is mismatched', () => {
const schema = Type.Object({
foo: Type.String(),
});
const value = {
foo: 123,
};
console.warn = jest.fn();
expect(() => AssertWeak(schema, value)).not.toThrow();
expect(console.warn).toHaveBeenCalledTimes(1);
});
it('should not throw if a property is mismatched in a union', () => {
const schema = Type.Union([
Type.Object({
foo: Type.String(),
}),
Type.Object({
bar: Type.Number(),
}),
]);
const value = {
foo: 123,
};
console.warn = jest.fn();
expect(() => AssertWeak(schema, value)).not.toThrow();
expect(console.warn).toHaveBeenCalledTimes(1);
});
it('should throw if a required field is missing', () => {
const schema = Type.Object({
foo: Type.String(),
});
const value = {};
expect(() => AssertWeak(schema, value)).toThrow();
});
});