UNPKG

@travetto/schema

Version:

Data type registry for runtime validation, reflection and binding.

340 lines (301 loc) 11.2 kB
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); } } }