@travetto/schema
Version:
Data type registry for runtime validation, reflection and binding.
346 lines (305 loc) • 11.8 kB
text/typescript
import { castKey, castTo, type Class, type ClassInstance, TypedObject } from '@travetto/runtime';
import type { SchemaInputConfig, SchemaFieldMap } from '../service/types.ts';
import type { ValidationError, ValidationKindCore, ValidationResult } from './types.ts';
import { Messages } from './messages.ts';
import { isValidationError, TypeMismatchError, ValidationResultError } from './error.ts';
import { DataUtil } from '../data.ts';
import { CommonRegexToName } from './regex.ts';
import { SchemaRegistryIndex } from '../service/registry-index.ts';
/**
* Get the schema config for Class/Schema config, including support for polymorphism
* @param base The starting type or config
* @param item The item to use for the polymorphic check
*/
function resolveFieldMap<T>(base: Class<T>, item: T): SchemaFieldMap {
const target = SchemaRegistryIndex.resolveInstanceType(base, item);
return SchemaRegistryIndex.get(target).getFields();
}
function isClassInstance<T>(value: unknown): value is ClassInstance<T> {
return !DataUtil.isPlainObject(value) && value !== null && typeof value === 'object' && !!value.constructor;
}
function isRangeValue(value: unknown): value is number | string | Date {
return typeof value === 'string' || typeof value === 'number' || value instanceof Date;
}
/**
* The schema validator applies the schema constraints to a given object and looks
* for errors
*/
export class SchemaValidator {
/**
* Validate the schema for a given object
* @param fields The config to validate against
* @param item The object to validate
* @param relative The relative path as the validation recurses
*/
static #validateFields<T>(fields: SchemaFieldMap, item: T, relative: string): ValidationError[] {
let errors: ValidationError[] = [];
for (const [field, fieldConfig] of TypedObject.entries(fields)) {
if (fieldConfig.access !== 'readonly') { // Do not validate readonly fields
errors = errors.concat(this.#validateInputSchema(fieldConfig, item[castKey<T>(field)], relative));
}
}
return errors;
}
/**
* Validate a single input config against a passed in value
* @param input The input schema configuration
* @param value The raw value, could be an array or not
* @param relative The relative path of object traversal
*/
static #validateInputSchema(input: SchemaInputConfig, value: unknown, relative: string = ''): ValidationError[] {
const key = 'name' in input ? input.name : ('index' in input ? input.index : 'unknown');
const path = `${relative}${relative ? '.' : ''}${key}`;
const hasValue = !(value === undefined || value === null || (typeof value === 'string' && value === '') || (Array.isArray(value) && value.length === 0));
if (!hasValue) {
if (input.required?.active !== false) {
return this.#prepareErrors(path, [{ kind: 'required', active: true, ...input.required }]);
} else {
return [];
}
}
const { type, array } = input;
const complex = SchemaRegistryIndex.has(type);
if (type === Object) {
return [];
} else if (array) {
if (!Array.isArray(value)) {
return this.#prepareErrors(path, [{ kind: 'type', type: Array, value }]);
}
let errors: ValidationError[] = [];
if (complex) {
for (let i = 0; i < value.length; i++) {
const subErrors = this.#validateFields(resolveFieldMap(type, value[i]), value[i], `${path}[${i}]`);
errors = errors.concat(subErrors);
}
} else {
for (let i = 0; i < value.length; i++) {
const subErrors = this.#validateInput(input, value[i]);
errors.push(...this.#prepareErrors(`${path}[${i}]`, subErrors));
}
}
return errors;
} else if (complex) {
return this.#validateFields(resolveFieldMap(type, value), value, path);
} else {
const fieldErrors = this.#validateInput(input, value);
return this.#prepareErrors(path, fieldErrors);
}
}
/**
* Validate the range for a number, date
* @param input The config to validate against
* @param key The bounds to check
* @param value The value to validate
*/
static #validateRange(input: SchemaInputConfig, key: 'min' | 'max', value: string | number | Date): boolean {
const config = input[key]!;
const parsed = (typeof value === 'string') ?
(input.type === Date ? Date.parse(value) : parseInt(value, 10)) :
(value instanceof Date ? value.getTime() : value);
const boundary = (typeof config.limit === 'number' ? config.limit : config.limit.getTime());
return key === 'min' ? parsed < boundary : parsed > boundary;
}
/**
* Validate a given field by checking all the appropriate constraints
*
* @param input The config of the field to validate
* @param value The actual value
*/
static #validateInput(input: SchemaInputConfig, value: unknown): ValidationResult[] {
const criteria: ([string, SchemaInputConfig[ValidationKindCore]] | [string])[] = [];
if (
(input.type === String && (typeof value !== 'string')) ||
(input.type === Number && ((typeof value !== 'number') || Number.isNaN(value))) ||
(input.type === Date && (!(value instanceof Date) || Number.isNaN(value.getTime()))) ||
(input.type === Boolean && typeof value !== 'boolean')
) {
criteria.push(['type']);
return [{ kind: 'type', type: input.type.name.toLowerCase() }];
}
if (input.type?.validateSchema) {
const kind = input.type.validateSchema(value);
switch (kind) {
case undefined: break;
case 'type': return [{ kind, type: input.type.name }];
default:
criteria.push([kind]);
}
}
if (input.match && !input.match.regex.test(`${value}`)) {
criteria.push(['match', input.match]);
}
if (input.minlength && `${value}`.length < input.minlength.limit) {
criteria.push(['minlength', input.minlength]);
}
if (input.maxlength && `${value}`.length > input.maxlength.limit) {
criteria.push(['maxlength', input.maxlength]);
}
if (input.enum && !input.enum.values.includes(castTo(value))) {
criteria.push(['enum', input.enum]);
}
if (input.min && (!isRangeValue(value) || this.#validateRange(input, 'min', value))) {
criteria.push(['min', input.min]);
}
if (input.max && (!isRangeValue(value) || this.#validateRange(input, 'max', value))) {
criteria.push(['max', input.max]);
}
const errors: ValidationResult[] = [];
for (const [key, block] of criteria) {
errors.push({ ...block, kind: key, value });
}
return errors;
}
/**
* Convert validation results into proper errors
* @param path The object path
* @param results The list of results for that specific path
*/
static #prepareErrors(path: string, results: ValidationResult[]): ValidationError[] {
const out: ValidationError[] = [];
for (const result of results) {
const error: ValidationError = {
...result,
kind: result.kind,
value: result.value,
message: '',
regex: CommonRegexToName.get(result.regex!) ?? result.regex?.source ?? '',
path,
type: (typeof result.type === 'function' ? result.type.name : result.type)
};
if (!error.regex) {
delete error.regex;
}
const msg = result.message ?? (
Messages.get(error.regex ?? '') ??
Messages.get(error.kind) ??
Messages.get('default')!
);
error.message = msg
.replace(/\{([^}]+)\}/g, (_, key: (keyof ValidationError)) => `${error[key]}`);
out.push(error);
}
return out;
}
/**
* Validate the class level validations
*/
static async #validateClassLevel<T>(cls: Class<T>, item: T, view?: string): Promise<ValidationError[]> {
if (!SchemaRegistryIndex.has(cls)) {
return [];
}
const classConfig = SchemaRegistryIndex.getConfig(cls);
const errors: ValidationError[] = [];
// Handle class level validators
for (const fn of classConfig.validators) {
try {
const error = await fn(item, view);
if (error) {
if (Array.isArray(error)) {
errors.push(...error);
} else {
errors.push(error);
}
}
} catch (error: unknown) {
if (isValidationError(error)) {
errors.push(error);
} else {
throw error;
}
}
}
return errors;
}
/**
* Validate an object against it's constructor's schema
* @param cls The class to validate the objects against
* @param item The object to validate
* @param view The optional view to limit the scope to
*/
static async validate<T>(cls: Class<T>, item: T, view?: string): Promise<T> {
if (isClassInstance(item) && !(item instanceof cls || cls.Ⲑid === item.constructor.Ⲑid)) {
throw new TypeMismatchError(cls.name, item.constructor.name);
}
cls = SchemaRegistryIndex.resolveInstanceType(cls, item);
const fields = SchemaRegistryIndex.get(cls).getFields(view);
// Validate using standard behaviors
const errors = [
...this.#validateFields(fields, item, ''),
... await this.#validateClassLevel(cls, item, view)
];
if (errors.length) {
throw new ValidationResultError(errors);
}
return item;
}
/**
* Validate an entire array of values
* @param cls The class to validate the objects against
* @param items The values to validate
* @param view The view to limit by
*/
static async validateAll<T>(cls: Class<T>, items: T[], view?: string): Promise<T[]> {
return await Promise.all<T>((items ?? [])
.map(item => this.validate(cls, item, view)));
}
/**
* Validate partial, ignoring required fields as they are partial
*
* @param cls The class to validate against
* @param item The value to validate
* @param view The view to limit by
*/
static async validatePartial<T>(cls: Class<T>, item: T, view?: string): Promise<T> {
try {
await this.validate(cls, item, view);
} catch (error) {
if (error instanceof ValidationResultError) { // Don't check required fields
const errs = error.details.errors.filter(validationError => validationError.kind !== 'required');
if (errs.length) {
error.details.errors = errs;
throw error;
}
}
}
return item;
}
/**
* Validate method invocation
*
* @param cls The class to validate against
* @param method The method being invoked
* @param params The params to validate
*/
static async validateMethod<T>(cls: Class<T>, method: string, params: unknown[], prefixes: (string | undefined)[] = []): Promise<void> {
const errors: ValidationError[] = [];
const config = SchemaRegistryIndex.get(cls).getMethod(method);
for (const param of config.parameters) {
const i = param.index;
errors.push(...[
... this.#validateInputSchema(param, params[i]),
... await this.#validateClassLevel(param.type, params[i])
].map(error => {
if (param.name && typeof param.name === 'string') {
error.path = !prefixes[i] ?
error.path.replace(`${param.name}.`, '') :
error.path.replace(param.name, prefixes[i]!);
}
return error;
}));
}
for (const validator of config.validators) {
const error = await validator(...params);
if (error) {
if (Array.isArray(error)) {
errors.push(...error);
} else {
errors.push(error);
}
}
}
if (errors.length) {
throw new ValidationResultError(errors);
}
}
}