UNPKG

@travetto/schema

Version:

Data type registry for runtime validation, reflection and binding.

318 lines (286 loc) 10.8 kB
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); } }