Skip to content

Commit

Permalink
feat: add support for outputting the full property path to errors; ad…
Browse files Browse the repository at this point in the history
…d support for logging all validation errors for a given property
  • Loading branch information
mikaelvesavuori committed May 7, 2024
1 parent 8a7a1d3 commit 4bf4a11
Show file tree
Hide file tree
Showing 4 changed files with 223 additions and 36 deletions.
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "mikrovalid",
"description": "MikroValid is the JSON validator that cuts out all the bullshit.",
"version": "1.0.17",
"version": "1.0.18",
"author": "Mikael Vesavuori",
"license": "MIT",
"type": "module",
Expand Down
113 changes: 83 additions & 30 deletions src/domain/MikroValid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export class MikroValid {
*/
private readonly isSilent: boolean;

private propertyPath: string = '';

constructor(isSilent = false) {
this.isSilent = isSilent;
}
Expand Down Expand Up @@ -71,6 +73,8 @@ export class MikroValid {
) {
if (!input) throw new Error('Missing input!');

this.updatePropertyPath();

const { results, errors } = this.validate(schema.properties, input);
const aggregatedErrors = this.compileErrors(results, errors);
const success = this.isSuccessful(results, aggregatedErrors);
Expand Down Expand Up @@ -125,12 +129,13 @@ export class MikroValid {
const inputKey: ValidationValue = input[key];
const isInnerAdditionalsOk = propertyKey.additionalProperties ?? true;

if (isKeyRequired)
if (isKeyRequired) {
errors = this.checkForRequiredKeysErrors(
propertyKey.required || [],
inputKey as Record<string, any>,
errors
);
}

if (this.isDefined(inputKey)) {
this.handleValidation(key, inputKey, propertyKey, results);
Expand All @@ -149,6 +154,24 @@ export class MikroValid {
return { results, errors };
}

/**
* @description Updates the internal `propertyPath` value. This is used
* when outputting the full path to the key where any errors are found.
*/
private updatePropertyPath(key?: string, startValue = '') {
if (!key) {
this.propertyPath = '';
return;
}

if (startValue) this.propertyPath = startValue;

this.propertyPath = `${this.propertyPath}.${key}`;

if (this.propertyPath.startsWith('.'))
this.propertyPath = this.propertyPath.substring(1, this.propertyPath.length);
}

/**
* @description Checks if a value is actually defined as a non-null value.
*/
Expand Down Expand Up @@ -212,27 +235,47 @@ export class MikroValid {
propertyKey: SchemaDefinition<Schema>,
results: Result[]
) {
const validation = this.validateProperty(key, propertyKey, inputKey);
results.push(validation);
this.updatePropertyPath(key);

if (this.isArray(inputKey) && propertyKey.items != null) {
const validation = this.validateProperty(this.propertyPath, propertyKey, inputKey);
results.push(...validation);

const handleArray = (inputKey: ValidationValue, propertyKey: SchemaDefinition<Schema>) => {
// @ts-ignore - inputKey is an array
inputKey.forEach((arrayItem: ValidationValue) => {
const validation = this.validateProperty(key, propertyKey.items!, arrayItem);
results.push(validation);
const validation = this.validateProperty(this.propertyPath, propertyKey.items!, arrayItem);
results.push(...validation);
});
} else if (this.isObject(inputKey)) {

this.updatePropertyPath();
};

const handleObject = (inputKey: any) => {
const keys = Object.keys(inputKey);
const currentPath = this.propertyPath;

keys.forEach((innerKey: string) => {
const validation = this.validateProperty(
innerKey,
propertyKey[innerKey],
// @ts-ignore - innerKey will be an object
inputKey[innerKey]
);
results.push(validation);
this.updatePropertyPath(innerKey, currentPath);

if (this.isArray(inputKey[innerKey]) && propertyKey[innerKey].items != null)
// @ts-ignore
handleArray(inputKey[innerKey], propertyKey[innerKey]);
else {
const validation = this.validateProperty(
this.propertyPath,
propertyKey[innerKey],
// @ts-ignore - innerKey will be an object
inputKey[innerKey]
);

results.push(...validation);
}
});
}
};

if (this.isArray(inputKey) && propertyKey.items != null) handleArray(inputKey, propertyKey);
else if (this.isObject(inputKey)) handleObject(inputKey);
else this.updatePropertyPath();
}

/**
Expand All @@ -251,7 +294,8 @@ export class MikroValid {
for (const nested of nestedObjects) {
const nextSchema = propertyKey[nested];
const nextInput = inputKey[nested];
if (nextSchema && nextInput) this.validate(nextSchema, nextInput, results, errors);
if (nextSchema && typeof nextInput === 'object')
this.validate(nextSchema, nextInput, results, errors);
}
}
}
Expand Down Expand Up @@ -286,14 +330,20 @@ export class MikroValid {
key: string,
properties: SchemaDefinition<Schema>,
value: ValidationValue
): Result {
const { success, error } = this.validateInput(properties, value);
return {
key,
value,
success,
error: error ?? ''
};
): Result[] {
//const { success, error } = this.validateInput(properties, value);
const results = this.validateInput(properties, value);

return results.map((result: ValidationResult) => {
const { success, error } = result;

return {
key,
value,
success,
error: error ?? ''
};
});
}

/**
Expand All @@ -302,7 +352,7 @@ export class MikroValid {
private validateInput<Schema extends Record<string, any>>(
properties: SchemaDefinition<Schema>,
match: ValidationValue
): ValidationResult {
): ValidationResult[] {
if (properties) {
const checks = [
{
Expand Down Expand Up @@ -342,17 +392,20 @@ export class MikroValid {
}
];

const results: any = [];

for (const check of checks) {
if (check.condition() && !check.validator()) {
return { success: false, error: check.error };
}
if (check.condition() && !check.validator())
results.push({ success: false, error: check.error });
}

return results;
} else {
if (!this.isSilent)
console.warn(`Missing property '${properties}' for match '${match}'. Skipping...`);
}

return { success: true };
return [{ success: true }];
}

/**
Expand Down Expand Up @@ -432,7 +485,7 @@ export class MikroValid {
*/
private isMinimumLength(minLength: number, input: ValidationValue) {
if (Array.isArray(input)) return input.length >= minLength;
return input.toString().length >= minLength;
return input?.toString().length >= minLength;
}

/**
Expand Down
138 changes: 136 additions & 2 deletions tests/MikroValid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,8 +466,8 @@ test('It should invalidate multiple errors separately', () => {
success: false,
error: "Missing the required key: 'second'!"
},
{ key: 'first', value: 1, success: false, error: 'Invalid type' },
{ key: 'third', value: 3, success: false, error: 'Invalid type' }
{ key: 'box.first', value: 1, success: false, error: 'Invalid type' },
{ key: 'box.third', value: 3, success: false, error: 'Invalid type' }
];

const { errors } = mikrovalid.test(
Expand Down Expand Up @@ -495,6 +495,140 @@ test('It should invalidate multiple errors separately', () => {
expect(errors).toMatchObject(expected);
});

test('It should output individual validation errors for each failure, even if they are at the same key', () => {
const expected = [
{
key: 'details.name.fullName',
value: 'Sam',
success: false,
error: 'Length too short'
},
{
key: 'details.name.fullName',
value: 'Sam',
success: false,
error: 'Pattern does not match'
}
];

const mikrovalid = new MikroValid(false);
const { success, errors } = mikrovalid.test(
{
properties: {
details: {
type: 'object',
name: {
type: 'object',
fullName: {
type: 'string',
minLength: 10,
matchesPattern: /^(Harry Mason)$/
},
required: ['fullName']
}
}
}
},
{
details: {
name: {
fullName: 'Sam'
}
}
}
);

expect(success).toBe(false);
expect(errors).toMatchObject(expected);
});

test('It should correctly output the full property path of errors', () => {
const expected = [
{ key: 'age', value: '26', success: false, error: 'Invalid type' },
{ key: 'preferences.doodad', value: {}, success: false, error: 'Invalid type' },
{ key: 'preferences.things', value: 1, success: false, error: 'Invalid type' },
{ key: 'preferences.things', value: 3, success: false, error: 'Invalid type' },
{
key: 'details.name.fullName',
value: 'Sam',
success: false,
error: 'Length too short'
},
{
key: 'address.street',
value: 123,
success: false,
error: 'Invalid type'
},
{
key: 'address.city',
value: 123,
success: false,
error: 'Invalid type'
}
];

const mikrovalid = new MikroValid(false);
const { success, errors } = mikrovalid.test(
{
properties: {
age: {
type: 'number'
},
preferences: {
type: 'object',
doodad: {
type: 'string'
},
things: {
type: 'array',
minLength: 5,
items: {
type: 'string'
}
}
},
details: {
type: 'object',
name: {
type: 'object',
fullName: {
type: 'string',
minLength: 10
},
required: ['fullName']
}
},
address: {
type: 'object',
street: {
type: 'string'
},
city: {
type: 'string'
}
}
}
},
{
age: '26',
preferences: { doodad: {}, things: [1, '2', 3] },
details: {
name: {
fullName: 'Sam'
}
},
address: {
street: 123,
city: 123
}
}
);

expect(success).toBe(false);
expect(errors).toMatchObject(expected);
});

/**
* TYPES
*/
Expand Down

0 comments on commit 4bf4a11

Please sign in to comment.