@travetto/schema
Version:
Data type registry for runtime validation, reflection and binding.
340 lines (301 loc) • 11.2 kB
text/typescript
import { castKey, castTo, Class, ClassInstance, TypedObject } from '@travetto/runtime';
import { FieldConfig, SchemaConfig } from '../service/types';
import { SchemaRegistry } from '../service/registry';
import { ValidationError, ValidationKindCore, ValidationResult } from './types';
import { Messages } from './messages';
import { isValidationError, TypeMismatchError, ValidationResultError } from './error';
import { DataUtil } from '../data';
import { CommonRegExpToName } from './regexp';
/**
* Get the schema config for Class/Schema config, including support for polymorphism
* @param base The starting type or config
* @param o The value to use for the polymorphic check
*/
function resolveSchema<T>(base: Class<T>, o: T, view?: string): SchemaConfig {
return SchemaRegistry.getViewSchema(
SchemaRegistry.resolveInstanceType(base, o), view
).schema;
}
function isClassInstance<T>(o: unknown): o is ClassInstance<T> {
return !DataUtil.isPlainObject(o) && o !== null && typeof o === 'object' && !!o.constructor;
}
function isRangeValue(o: unknown): o is number | string | Date {
return typeof o === 'string' || typeof o === 'number' || o 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 schema The config to validate against
* @param o The object to validate
* @param relative The relative path as the validation recurses
*/
static #validateSchema<T>(schema: SchemaConfig, o: T, relative: string): ValidationError[] {
let errors: ValidationError[] = [];
const fields = TypedObject.keys<SchemaConfig>(schema);
for (const field of fields) {
if (schema[field].access !== 'readonly') { // Do not validate readonly fields
errors = errors.concat(this.#validateFieldSchema(schema[field], o[castKey<T>(field)], relative));
}
}
return errors;
}
/**
* Validate a single field config against a passed in value
* @param fieldSchema The field schema configuration
* @param val The raw value, could be an array or not
* @param relative The relative path of object traversal
*/
static #validateFieldSchema(fieldSchema: FieldConfig, val: unknown, relative: string = ''): ValidationError[] {
const path = `${relative}${relative ? '.' : ''}${fieldSchema.name}`;
const hasValue = !(val === undefined || val === null || (typeof val === 'string' && val === '') || (Array.isArray(val) && val.length === 0));
if (!hasValue) {
if (fieldSchema.required && fieldSchema.required.active) {
return this.#prepareErrors(path, [{ kind: 'required', ...fieldSchema.required }]);
} else {
return [];
}
}
const { type, array, view } = fieldSchema;
const complex = SchemaRegistry.has(type);
if (type === Object) {
return [];
} else if (array) {
if (!Array.isArray(val)) {
return this.#prepareErrors(path, [{ kind: 'type', type: Array, value: val }]);
}
let errors: ValidationError[] = [];
if (complex) {
for (let i = 0; i < val.length; i++) {
const subErrors = this.#validateSchema(resolveSchema(type, val[i], view), val[i], `${path}[${i}]`);
errors = errors.concat(subErrors);
}
} else {
for (let i = 0; i < val.length; i++) {
const subErrors = this.#validateField(fieldSchema, val[i]);
errors.push(...this.#prepareErrors(`${path}[${i}]`, subErrors));
}
}
return errors;
} else if (complex) {
return this.#validateSchema(resolveSchema(type, val, view), val, path);
} else {
const fieldErrors = this.#validateField(fieldSchema, val);
return this.#prepareErrors(path, fieldErrors);
}
}
/**
* Validate the range for a number, date
* @param field The config to validate against
* @param key The bounds to check
* @param value The value to validate
*/
static #validateRange(field: FieldConfig, key: 'min' | 'max', value: string | number | Date): boolean {
const f = field[key]!;
const valueNum = (typeof value === 'string') ?
(field.type === Date ? Date.parse(value) : parseInt(value, 10)) :
(value instanceof Date ? value.getTime() : value);
const boundary = (typeof f.n === 'number' ? f.n : f.n.getTime());
return key === 'min' ? valueNum < boundary : valueNum > boundary;
}
/**
* Validate a given field by checking all the appropriate constraints
*
* @param field The config of the field to validate
* @param value The actual value
*/
static #validateField(field: FieldConfig, value: unknown): ValidationResult[] {
const criteria: ([string, FieldConfig[ValidationKindCore]] | [string])[] = [];
if (
(field.type === String && (typeof value !== 'string')) ||
(field.type === Number && ((typeof value !== 'number') || Number.isNaN(value))) ||
(field.type === Date && (!(value instanceof Date) || Number.isNaN(value.getTime()))) ||
(field.type === Boolean && typeof value !== 'boolean')
) {
criteria.push(['type']);
return [{ kind: 'type', type: field.type.name.toLowerCase() }];
}
if (field.type?.validateSchema) {
const kind = field.type.validateSchema(value);
switch (kind) {
case undefined: break;
case 'type': return [{ kind, type: field.type.name }];
default:
criteria.push([kind]);
}
}
if (field.match && !field.match.re.test(`${value}`)) {
criteria.push(['match', field.match]);
}
if (field.minlength && `${value}`.length < field.minlength.n) {
criteria.push(['minlength', field.minlength]);
}
if (field.maxlength && `${value}`.length > field.maxlength.n) {
criteria.push(['maxlength', field.maxlength]);
}
if (field.enum && !field.enum.values.includes(castTo(value))) {
criteria.push(['enum', field.enum]);
}
if (field.min && (!isRangeValue(value) || this.#validateRange(field, 'min', value))) {
criteria.push(['min', field.min]);
}
if (field.max && (!isRangeValue(value) || this.#validateRange(field, 'max', value))) {
criteria.push(['max', field.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 res of results) {
const err: ValidationError = {
...res,
kind: res.kind,
value: res.value,
message: '',
re: CommonRegExpToName.get(res.re!) ?? res.re?.source ?? '',
path,
type: (typeof res.type === 'function' ? res.type.name : res.type)
};
if (!err.re) {
delete err.re;
}
const msg = res.message ?? (
Messages.get(err.re ?? '') ??
Messages.get(err.kind) ??
Messages.get('default')!
);
err.message = msg
.replace(/\{([^}]+)\}/g, (_, k: (keyof ValidationError)) => `${err[k]}`);
out.push(err);
}
return out;
}
/**
* Validate the class level validations
*/
static async #validateClassLevel<T>(cls: Class<T>, o: T, view?: string): Promise<ValidationError[]> {
const schema = SchemaRegistry.get(cls);
if (!schema) {
return [];
}
const errors: ValidationError[] = [];
// Handle class level validators
for (const fn of schema.validators) {
try {
const res = await fn(o, view);
if (res) {
if (Array.isArray(res)) {
errors.push(...res);
} else {
errors.push(res);
}
}
} catch (err: unknown) {
if (isValidationError(err)) {
errors.push(err);
} else {
throw err;
}
}
}
return errors;
}
/**
* Validate an object against it's constructor's schema
* @param cls The class to validate the objects against
* @param o The object to validate
* @param view The optional view to limit the scope to
*/
static async validate<T>(cls: Class<T>, o: T, view?: string): Promise<T> {
if (isClassInstance(o) && !(o instanceof cls || cls.Ⲑid === o.constructor.Ⲑid)) {
throw new TypeMismatchError(cls.name, o.constructor.name);
}
cls = SchemaRegistry.resolveInstanceType(cls, o);
const config = SchemaRegistry.getViewSchema(cls, view);
// Validate using standard behaviors
const errors = [
...this.#validateSchema(config.schema, o, ''),
... await this.#validateClassLevel(cls, o, view)
];
if (errors.length) {
throw new ValidationResultError(errors);
}
return o;
}
/**
* Validate an entire array of values
* @param cls The class to validate the objects against
* @param obj The values to validate
* @param view The view to limit by
*/
static async validateAll<T>(cls: Class<T>, obj: T[], view?: string): Promise<T[]> {
return await Promise.all<T>((obj ?? [])
.map(o => this.validate(cls, o, view)));
}
/**
* Validate partial, ignoring required fields as they are partial
*
* @param cls The class to validate against
* @param o The value to validate
* @param view The view to limit by
*/
static async validatePartial<T>(cls: Class<T>, o: T, view?: string): Promise<T> {
try {
await this.validate(cls, o, view);
} catch (err) {
if (err instanceof ValidationResultError) { // Don't check required fields
const errs = err.details.errors.filter(x => x.kind !== 'required');
if (errs.length) {
err.details.errors = errs;
throw err;
}
}
}
return o;
}
/**
* 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[] = [];
for (const field of SchemaRegistry.getMethodSchema(cls, method)) {
const i = field.index!;
errors.push(...[
... this.#validateFieldSchema(field, params[i]),
... await this.#validateClassLevel(field.type, params[i])
].map(x => {
x.path = !prefixes[i] ? x.path.replace(`${field.name}.`, '') : x.path.replace(field.name, prefixes[i]!);
return x;
}));
}
for (const validator of SchemaRegistry.getMethodValidators(cls, method)) {
const res = await validator(...params);
if (res) {
if (Array.isArray(res)) {
errors.push(...res);
} else {
errors.push(res);
}
}
}
if (errors.length) {
throw new ValidationResultError(errors);
}
}
}