src/domain/MikroValid.ts
import {
FirstLevelDefinition,
PropertySchema,
Result,
RootDefinition,
SchemaDefinition,
ValidationError,
ValidationFormat,
ValidationResult,
ValidationSchema,
ValidationTypes,
ValidationValue
} from '../interfaces/MikroValid.js';
export class MikroValid {
/**
* Toggle to silence (suppress) non-critical messages, such as warnings.
*/
private readonly isSilent: boolean;
private propertyPath: string = '';
constructor(isSilent = false) {
this.isSilent = isSilent;
}
/**
* @description MikroValid is a lightweight validator
* that works both on the client and server.
*
* Provide a JSON object schema and your input and MikroValid
* takes care of the rest.
*
* @example
* import { MikroValid } from 'mikrovalid';
*
* const mikrovalid = new MikroValid();
*
* const schema = {
* properties: {
* personal: {
* name: { type: 'string' },
* required: ['name']
* },
* work: {
* office: { type: 'string' },
* currency: { type: 'string' },
* salary: { type: 'number' },
* required: ['office']
* },
* required: ['personal', 'work']
* }
* };
*
* const input = {
* personal: {
* name: 'Sam Person'
* },
* work: {
* office: 'London',
* currency: 'GBP',
* salary: 10000
* }
* };
*
* const { success, errors } = mikrovalid.test(schema, input);
*
* console.log('Was the test successful?', success);
*/
public test<Schema extends { properties: any }>(
schema: Schema & RootDefinition<Schema>,
input: Record<string, any>
) {
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);
return {
errors: aggregatedErrors,
success
};
}
/**
* @description Aggregate errors into a flat array.
*/
private compileErrors(results: Result[], errors: ValidationError[]): ValidationError[] {
const resultErrors = results.filter((result: Result) => result.success === false);
return [...errors, ...resultErrors].flatMap((error: Result) => error);
}
/**
* @description Check if this was, ultimately, a successful and valid test run.
*/
private isSuccessful(results: Result[], errors: ValidationError[]) {
return (
results.every((result: Record<string, any>) => result.success === true) && errors.length === 0
);
}
/**
* @description This is the main recursive loop that checks
* all fields/properties and any nested objects.
*/
private validate<Schema extends Record<string, any>>(
schema: FirstLevelDefinition<Schema>,
input: Record<string, any>,
results: Result[] = [],
errors: ValidationError[] = []
) {
const isAdditionalsOk = schema?.additionalProperties ?? true;
const requiredKeys: string[] = schema?.required || [];
errors = this.checkForRequiredKeysErrors(requiredKeys, input, errors);
errors = this.checkForDisallowedProperties(
Object.keys(input),
Object.keys(schema),
errors,
isAdditionalsOk
);
for (const key in schema) {
const isKeyRequired = requiredKeys.includes(key) && key !== 'required';
const propertyKey = schema[key];
const inputKey: ValidationValue = input[key];
const isInnerAdditionalsOk = propertyKey.additionalProperties ?? true;
if (isKeyRequired) {
errors = this.checkForRequiredKeysErrors(
propertyKey.required || [],
inputKey as Record<string, any>,
errors
);
}
if (this.isDefined(inputKey)) {
this.handleValidation(key, inputKey, propertyKey, results);
errors = this.checkForDisallowedProperties(
Object.keys(inputKey),
Object.keys(propertyKey),
errors,
isInnerAdditionalsOk
);
this.handleNestedObject(inputKey as Record<string, any>, propertyKey, results, errors);
}
}
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.
*/
private isDefined(value: unknown) {
if (typeof value === 'number' && value === 0) return true;
if (!!value || value === '' || typeof value === 'boolean') return true;
return false;
}
/**
* @description Checks if there are required keys and adds errors if needed.
*/
private checkForRequiredKeysErrors(
schema: string[],
input: Record<string, any>,
errors: ValidationError[]
) {
if (!this.areRequiredKeysPresent(schema, input)) {
const inputKeys = input ? Object.keys(input) : [];
const missingKeys = this.findNonOverlappingElements(schema, inputKeys);
const message =
missingKeys.length > 0
? `Missing the required key: '${missingKeys.join(', ')}'!`
: `Missing values for required keys: '${inputKeys.filter((key) => !input[key]).join(', ')}'!`;
errors.push({
key: '',
value: input,
success: false,
error: message
});
}
return errors;
}
/**
* @description Checks if there are disallowed properties and adds errors if needed.
*/
private checkForDisallowedProperties(
inputKeys: string[],
propertyKeys: string[],
errors: ValidationError[],
isAdditionalsOk: boolean
) {
if (!isAdditionalsOk) {
const additionals = this.findNonOverlappingElements(inputKeys, propertyKeys);
if (additionals.length > 0)
errors.push({
key: `${propertyKeys}`,
value: inputKeys,
success: false,
error: `Has additional (disallowed) properties: '${additionals.join(', ')}'!`
});
}
return errors;
}
/**
* @description Runs validation in the right way, based on whether the
* input is an object or not.
*/
private handleValidation<Schema extends Record<string, any>>(
key: string,
inputKey: ValidationValue,
propertyKey: SchemaDefinition<Schema>,
results: Result[]
) {
this.updatePropertyPath(key);
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(this.propertyPath, propertyKey.items!, arrayItem);
results.push(...validation);
});
this.updatePropertyPath();
};
const handleObject = (inputKey: any) => {
const keys = Object.keys(inputKey);
const currentPath = this.propertyPath;
keys.forEach((innerKey: string) => {
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();
}
/**
* @description Check for nested objects and handle them.
* @note Currently, this skips checking array contents.
*/
private handleNestedObject(
inputKey: Record<string, any>,
propertyKey: Record<string, any>,
results: Result[],
errors: ValidationError[]
) {
if (this.isObject(inputKey)) {
const nestedObjects = this.getNestedObjects(inputKey);
for (const nested of nestedObjects) {
const nextSchema = propertyKey[nested];
const nextInput = inputKey[nested];
if (nextSchema && typeof nextInput === 'object')
this.validate(nextSchema, nextInput, results, errors);
}
}
}
/**
* @description Get the name of all objects with nesting from a parent object.
*/
private getNestedObjects(item: ValidationValue) {
return Object.keys(item).filter((key: string) => {
if (this.isObject(item as string)) return key;
});
}
/**
* @description Return a list of all unique, non-overlapping elements from an array.
*/
private findNonOverlappingElements(target: string[], truth: string[]) {
return target.filter((value: string) => !truth.includes(value));
}
/**
* @description Checks if all required keys are present in the input object and that they have a defined value.
*/
private areRequiredKeysPresent(requiredKeys: string[], input: Record<string, any> = []) {
return requiredKeys.every((key) => {
if (Object.keys(input).includes(key)) return this.isDefined(input[key]);
return false;
});
}
/**
* @description Controller for validation purposes. Returns back a more comprehensive validation object.
*/
private validateProperty<Schema>(
key: string,
properties: SchemaDefinition<Schema>,
value: ValidationValue
): 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 ?? ''
};
});
}
/**
* @description Performs field-level validation.
*/
private validateInput<Schema extends Record<string, any>>(
properties: SchemaDefinition<Schema>,
match: ValidationValue
): ValidationResult[] {
if (properties) {
const checks = [
{
condition: () => properties['type'],
validator: () => this.isCorrectType(properties['type']!, match),
error: 'Invalid type'
},
{
condition: () => properties['format'],
validator: () => this.isCorrectFormat(properties['format']!, match as string),
error: 'Invalid format'
},
{
condition: () => properties['minLength'],
validator: () => this.isMinimumLength(properties['minLength']!, match),
error: 'Length too short'
},
{
condition: () => properties['maxLength'],
validator: () => this.isMaximumLength(properties['maxLength']!, match),
error: 'Length too long'
},
{
condition: () => properties['minValue'],
validator: () => this.isMinimumValue(properties['minValue']!, match as number),
error: 'Value too small'
},
{
condition: () => properties['maxValue'],
validator: () => this.isMaximumValue(properties['maxValue']!, match as number),
error: 'Value too large'
},
{
condition: () => properties['matchesPattern'],
validator: () => this.matchesPattern(properties['matchesPattern']!, match as string),
error: 'Pattern does not match'
}
];
const results: any = [];
for (const check of checks) {
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 }];
}
/**
* @description Checks whether or not a type is correct.
*/
private isCorrectType(expected: ValidationTypes, input: ValidationValue) {
if (!Array.isArray(expected)) expected = [expected];
return expected.some((type) => {
switch (type) {
case 'string':
return typeof input === 'string';
case 'number':
return typeof input === 'number' && !isNaN(input);
case 'boolean':
return typeof input === 'boolean';
case 'object':
return this.isObject(input);
case 'array':
return this.isArray(input);
}
});
}
/**
* @description Checks if input is an object.
*/
private isObject(input: any) {
return (
input !== null &&
!this.isArray(input) &&
typeof input === 'object' &&
input instanceof Object &&
Object.prototype.toString.call(input) === '[object Object]' // This will solve many validation cases, but will break Symbol support
);
}
/**
* @description Checks if input is an array.
*/
private isArray(input: unknown) {
return Array.isArray(input);
}
/**
* @description Checks if the input string matches a particular format.
*
* Valid formats are:
* - `alphanumeric`
* - `date`
* - `email`
* - `hexColor`
* - `numeric`
* - `url`
*/
private isCorrectFormat(expected: ValidationFormat, input: string) {
switch (expected) {
case 'alphanumeric': {
return new RegExp(/^[a-zA-Z0-9]+$/).test(input);
}
case 'numeric': {
return new RegExp(/^-?\d+(\.\d+)?$/).test(input);
}
case 'email': {
return new RegExp(/^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$/).test(input);
}
case 'date': {
return new RegExp(/^\d{4}-\d{2}-\d{2}$/).test(input);
}
case 'url': {
return new RegExp(/^(https?):\/\/[^\s$.?#].[^\s]*$/).test(input);
}
case 'hexColor': {
return new RegExp(/^#?([a-f0-9]{6}|[a-f0-9]{3})$/i).test(input);
}
}
}
/**
* @description Checks if an input is of a minimum length. Works for both arrays and strings.
*/
private isMinimumLength(minLength: number, input: ValidationValue) {
if (Array.isArray(input)) return input.length >= minLength;
return input?.toString().length >= minLength;
}
/**
* @description Checks if an input is of a maximum length. Works for both arrays and strings.
*/
private isMaximumLength(maxLength: number, input: ValidationValue) {
if (Array.isArray(input)) return input.length <= maxLength;
return input.toString().length <= maxLength;
}
/**
* @description Checks if an inpu is of a minimum numeric value.
*/
private isMinimumValue(minValue: number, input: number) {
return input >= minValue;
}
/**
* @description Checks if an input is of a maximum numeric value.
*/
private isMaximumValue(minValue: number, input: number) {
return input <= minValue;
}
/**
* @description Checks whether a string matches against a user-provided regular expression.
*/
private matchesPattern(pattern: RegExp, input: string) {
return new RegExp(pattern).test(input);
}
/**
* @description Generates a functional validation schema from the provided input.
*
* @example
* import { MikroValid } from 'mikrovalid';
*
* const mikrovalid = new MikroValid();
*
* const input = {
* personal: {
* name: 'Sam Person'
* },
* work: {
* office: 'London',
* currency: 'GBP',
* salary: 10000
* }
* };
*
* mikrovalid.schemaFrom(input);
*/
public schemaFrom(input: any): ValidationSchema {
const schema: ValidationSchema = {
properties: {
additionalProperties: false,
required: []
}
};
for (const key in input) {
const value = input[key];
(schema as Record<string, any>).properties.required.push(key);
if (Array.isArray(value)) schema.properties![key] = this.generateArraySchema(value);
else if (typeof value === 'object' && value !== null)
schema.properties![key] = this.generateNestedObjectSchema(value);
else schema.properties![key] = this.generatePropertySchema(value);
}
return schema;
}
private generateArraySchema(array: unknown[]): ValidationSchema {
const schema: Record<string, any> = { type: 'array' };
const cleanedArray = array.filter((element) => element);
if (cleanedArray.length > 0) {
const firstElement = cleanedArray[0];
const allOfSameType = cleanedArray.every((element) => typeof element === typeof firstElement);
if (allOfSameType) {
if (typeof firstElement === 'object' && !Array.isArray(firstElement)) {
schema.items = this.generateNestedObjectSchema(firstElement as Record<string, any>);
} else {
schema.items = this.generatePropertySchema(firstElement);
}
} else {
console.warn(
'All elements in array are not of the same type. Unable to generate a schema for these elements.'
);
}
}
return schema as ValidationSchema;
}
private generateNestedObjectSchema(input: Record<string, any>): ValidationSchema {
const schema: Record<string, any> = {
type: 'object',
additionalProperties: false,
required: []
};
for (const key in input) {
const value = input[key];
schema.required.push(key);
if (typeof value === 'object' && !Array.isArray(value) && value !== null) {
schema[key] = this.generateNestedObjectSchema(value);
} else schema[key] = this.generatePropertySchema(value);
}
return schema as ValidationSchema;
}
private generatePropertySchema(value: unknown): PropertySchema {
const type: string = typeof value;
const schema: Record<string, any> = { type };
switch (type) {
case 'string':
schema.minLength = 1;
break;
}
return schema as PropertySchema;
}
}