@travetto/schema
Version:
Data type registry for runtime validation, reflection and binding.
318 lines (286 loc) • 10.8 kB
text/typescript
import { castTo, type Class, classConstruct, asFull, TypedObject, castKey } from '@travetto/runtime';
import { DataUtil } from './data.ts';
import type { SchemaInputConfig, SchemaParameterConfig, SchemaFieldMap } from './service/types.ts';
import { SchemaRegistryIndex } from './service/registry-index.ts';
type BindConfig = {
view?: string;
filterInput?: (input: SchemaInputConfig) => boolean;
filterValue?: (value: unknown, input: SchemaInputConfig) => boolean;
};
function isInstance<T>(value: unknown): value is T {
return !!value && !DataUtil.isPrimitive(value);
}
/**
* Utilities for binding objects to schemas
*/
export class BindUtil {
/**
* Coerce a value to match the field config type
* @param config The field config to coerce to
* @param value The provided value
*/
static #coerceType<T>(config: SchemaInputConfig, value: unknown): T | null | undefined {
if (config.type?.bindSchema) {
value = config.type.bindSchema(value);
} else {
value = DataUtil.coerceType(value, config.type, false);
if (config.type === Number && config.precision && typeof value === 'number') {
if (config.precision[1]) { // Supports decimal
value = +value.toFixed(config.precision[1]);
} else { // 0 digits
value = Math.trunc(value);
}
}
}
return castTo(value);
}
/**
* Convert dotted paths into a full object
*
* This will convert `{ 'a.b[3].c[age]': 5 }` => `{ a : { b : [,,,{ c: { age: 5 }}]}}`
*
* @param input The object to convert
*/
static expandPaths(input: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const property of Object.keys(input)) {
const valueInput = input[property];
const value = DataUtil.isPlainObject(valueInput) ? this.expandPaths(valueInput) : valueInput;
const parts = property.split('.');
const last = parts.pop()!;
let sub = out;
while (parts.length > 0) {
const part = parts.shift()!;
const partArrayIndex = part.indexOf('[') > 0;
const name = part.split(/[^A-Za-z_0-9]/)[0];
const idx = partArrayIndex ? part.split(/[\[\]]/)[1] : '';
const key = partArrayIndex ? (/^\d+$/.test(idx) ? parseInt(idx, 10) : (idx.trim() || undefined)) : undefined;
if (!(name in sub)) {
sub[name] = typeof key === 'number' ? [] : {};
}
sub = castTo(sub[name]);
if (idx && key !== undefined) {
sub[key] ??= {};
sub = castTo(sub[key]);
}
}
const arr = last.indexOf('[') > 0;
if (!arr) {
if (sub[last] && DataUtil.isPlainObject(value)) {
sub[last] = DataUtil.deepAssign(sub[last], value, 'coerce');
} else {
sub[last] = value;
}
} else {
const name = last.split(/[^A-Za-z_0-9]/)[0];
const idx = last.split(/[\[\]]/)[1];
let key = (/^\d+$/.test(idx) ? parseInt(idx, 10) : (idx.trim() || undefined));
sub[name] ??= (typeof key === 'string') ? {} : [];
const arrSub: Record<string, unknown> & { length: number } = castTo(sub[name]);
if (key === undefined) {
key = arrSub.length;
}
if (arrSub[key] && DataUtil.isPlainObject(value) && DataUtil.isPlainObject(arrSub[key])) {
arrSub[key] = DataUtil.deepAssign(arrSub[key], value, 'coerce');
} else {
arrSub[key] = value;
}
}
}
return out;
}
/**
* Convert full object with nesting, into flat set of keys
* @param data The object to flatten the paths for
* @param prefix The starting prefix
*/
static flattenPaths<V extends string = string>(data: Record<string, unknown>, prefix: string = ''): Record<string, V> {
const out: Record<string, V> = {};
for (const [key, value] of Object.entries(data)) {
const pre = `${prefix}${key}`;
if (DataUtil.isPlainObject(value)) {
Object.assign(out, this.flattenPaths(value, `${pre}.`)
);
} else if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
const element = value[i];
if (DataUtil.isPlainObject(element)) {
Object.assign(out, this.flattenPaths(element, `${pre}[${i}].`));
} else {
out[`${pre}[${i}]`] = element ?? '';
}
}
} else {
out[pre] = castTo(value ?? '');
}
}
return out;
}
/**
* Bind data to the schema for a class, with an optional view
* @param cls The schema class to bind against
* @param data The provided data to bind
* @param config The bind configuration
*/
static bindSchema<T>(cls: Class<T>, data?: undefined, config?: BindConfig): undefined;
static bindSchema<T>(cls: Class<T>, data?: null, config?: BindConfig): null;
static bindSchema<T>(cls: Class<T>, data?: object | T, config?: BindConfig): T;
static bindSchema<T>(cls: Class<T>, data?: object | T, config?: BindConfig): T | null | undefined {
if (data === null || data === undefined) {
return data;
}
if (data instanceof cls) {
return castTo(data);
} else {
const resolvedCls = SchemaRegistryIndex.resolveInstanceType<T>(cls, asFull<T>(data));
const instance = classConstruct<T & { type?: string }>(resolvedCls);
for (const key of TypedObject.keys(instance)) { // Do not retain undefined fields
if (instance[key] === undefined) {
delete instance[key];
}
}
const out = this.bindSchemaToObject(resolvedCls, instance, data, config);
SchemaRegistryIndex.get(resolvedCls).ensureInstanceTypeField(out);
return out;
}
}
/**
* Bind the schema to the object
* @param cls The schema class
* @param input The target object (instance of cls)
* @param data The data to bind
* @param config The bind configuration
*/
static bindSchemaToObject<T>(cls: Class<T>, input: T, data?: object, config: BindConfig = {}): T {
const view = config.view; // Does not convey
delete config.view;
if (!!data && isInstance<T>(data)) {
const adapter = SchemaRegistryIndex.get(cls);
const schemaConfig = adapter.get();
// If no configuration
if (!schemaConfig) {
for (const key of TypedObject.keys(data)) {
input[key] = data[key];
}
} else {
let schema: SchemaFieldMap = schemaConfig.fields;
if (view) {
schema = adapter.getFields(view);
if (!schema) {
throw new Error(`View not found: ${view}`);
}
}
for (const [schemaFieldName, field] of Object.entries(schema)) {
let inboundField: string | undefined = undefined;
if (field.access === 'readonly' || config.filterInput?.(field) === false) {
continue; // Skip trying to write readonly fields
}
if (schemaFieldName in data) {
inboundField = schemaFieldName;
} else if (field.aliases) {
for (const aliasedField of (field.aliases ?? [])) {
if (aliasedField in data) {
inboundField = aliasedField;
break;
}
}
}
if (!inboundField) {
continue;
}
let value: unknown = data[castKey<T>(inboundField)];
// Filtering values
if (config.filterValue && !config.filterValue(value, field)) {
continue;
}
if (value !== undefined && value !== null) {
// Ensure its an array
if (!Array.isArray(value) && field.array) {
if (typeof value === 'string' && value.includes(',')) {
value = value.split(/,/).map(part => part.trim());
} else {
value = [value];
}
}
if (SchemaRegistryIndex.has(field.type)) {
if (field.array && Array.isArray(value)) {
value = value.map(item => this.bindSchema(field.type, item, config));
} else {
value = this.bindSchema(field.type, value, config);
}
} else if (field.array && Array.isArray(value)) {
value = value.map(item => this.#coerceType(field, item));
} else {
value = this.#coerceType(field, value);
}
}
input[castKey<T>(schemaFieldName)] = castTo(value);
if (field.accessor) {
Object.defineProperty(input, schemaFieldName, {
...adapter.getAccessorDescriptor(schemaFieldName),
enumerable: true
});
}
}
}
}
return input;
}
/**
* Coerce field to type
* @param config
* @param value
* @param applyDefaults
* @returns
*/
static coerceInput(config: SchemaInputConfig, value: unknown, applyDefaults = false): unknown {
if ((value === undefined || value === null) && applyDefaults) {
value = Array.isArray(config.default) ? config.default.slice(0) : config.default;
}
if (config.required?.active === false && (value === undefined || value === null)) {
return value;
}
const complex = SchemaRegistryIndex.has(config.type);
const bindConfig: BindConfig | undefined = (complex && 'view' in config && typeof config.view === 'string') ? { view: config.view } : undefined;
if (config.array) {
const subValue = !Array.isArray(value) ? [value] : value;
if (complex) {
value = subValue.map(item => this.bindSchema(config.type, item, bindConfig));
} else {
value = subValue.map(item => DataUtil.coerceType(item, config.type, false));
}
} else {
if (complex) {
value = this.bindSchema(config.type, value, bindConfig);
} else {
value = DataUtil.coerceType(value, config.type, false);
}
}
return value;
}
/**
* Coerce multiple params at once
* @param fields
* @param params
* @returns
*/
static coerceParameters(fields: SchemaParameterConfig[], params: unknown[], applyDefaults = true): unknown[] {
params = [...params];
// Coerce types
for (const field of fields) {
params[field.index!] = this.coerceInput(field, params[field.index!], applyDefaults);
}
return params;
}
/**
* Coerce method parameters when possible
* @param cls
* @param method
* @param params
* @returns
*/
static coerceMethodParams<T>(cls: Class<T>, method: string, params: unknown[], applyDefaults = true): unknown[] {
const paramConfigs = SchemaRegistryIndex.get(cls).getMethod(method).parameters;
return this.coerceParameters(paramConfigs, params, applyDefaults);
}
}