UNPKG

@travetto/schema

Version:

Data type registry for runtime validation, reflection and binding.

314 lines (281 loc) 9.98 kB
import { castTo, Class, classConstruct, asFull, TypedObject, castKey } from '@travetto/runtime'; import { DataUtil } from './data'; import { AllViewSymbol } from './internal/types'; import { SchemaRegistry } from './service/registry'; import { FieldConfig } from './service/types'; type BindConfig = { view?: string | typeof AllViewSymbol; filterField?: (field: FieldConfig) => boolean; filterValue?: (value: unknown, field: FieldConfig) => boolean; }; function isInstance<T>(o: unknown): o is T { return !!o && !DataUtil.isPrimitive(o); } /** * Utilities for binding objects to schemas */ export class BindUtil { /** * Coerce a value to match the field config type * @param conf The field config to coerce to * @param val The provided value */ static #coerceType<T>(conf: FieldConfig, val: unknown): T | null | undefined { if (conf.type?.bindSchema) { val = conf.type.bindSchema(val); } else { val = DataUtil.coerceType(val, conf.type, false); if (conf.type === Number && conf.precision && typeof val === 'number') { if (conf.precision[1]) { // Supports decimal val = +val.toFixed(conf.precision[1]); } else { // 0 digits val = Math.trunc(val); } } } return castTo(val); } /** * Convert dotted paths into a full object * * This will convert `{ 'a.b[3].c[age]': 5 }` => `{ a : { b : [,,,{ c: { age: 5 }}]}}` * * @param obj The object to convert */ static expandPaths(obj: Record<string, unknown>): Record<string, unknown> { const out: Record<string, unknown> = {}; for (const k of Object.keys(obj)) { const objK = obj[k]; const val = DataUtil.isPlainObject(objK) ? this.expandPaths(objK) : objK; const parts = k.split('.'); const last = parts.pop()!; let sub = out; while (parts.length > 0) { const part = parts.shift()!; const partArr = part.indexOf('[') > 0; const name = part.split(/[^A-Za-z_0-9]/)[0]; const idx = partArr ? part.split(/[\[\]]/)[1] : ''; const key = partArr ? (/^\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(val)) { sub[last] = DataUtil.deepAssign(sub[last], val, 'coerce'); } else { sub[last] = val; } } 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(val) && DataUtil.isPlainObject(arrSub[key])) { arrSub[key] = DataUtil.deepAssign(arrSub[key], val, 'coerce'); } else { arrSub[key] = val; } } } return out; } /** * Convert full object with nesting, into flat set of keys * @param conf The object to flatten the paths for * @param val 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 v = value[i]; if (DataUtil.isPlainObject(v)) { Object.assign(out, this.flattenPaths(v, `${pre}[${i}].`)); } else { out[`${pre}[${i}]`] = v ?? ''; } } } else { out[pre] = castTo(value ?? ''); } } return out; } /** * Bind data to the schema for a class, with an optional view * @param cons The schema class to bind against * @param data The provided data to bind * @param cfg The bind configuration */ static bindSchema<T>(cons: Class<T>, data?: undefined, cfg?: BindConfig): undefined; static bindSchema<T>(cons: Class<T>, data?: null, cfg?: BindConfig): null; static bindSchema<T>(cons: Class<T>, data?: object | T, cfg?: BindConfig): T; static bindSchema<T>(cons: Class<T>, data?: object | T, cfg?: BindConfig): T | null | undefined { if (data === null || data === undefined) { return data; } const cls = SchemaRegistry.resolveInstanceType<T>(cons, asFull<T>(data)); if (data instanceof cls) { return castTo(data); } else { const tgt = classConstruct<T & { type?: string }>(cls); SchemaRegistry.ensureInstanceTypeField(cls, tgt); for (const k of TypedObject.keys(tgt)) { // Do not retain undefined fields if (tgt[k] === undefined) { delete tgt[k]; } } return this.bindSchemaToObject(cls, tgt, data, cfg); } } /** * Bind the schema to the object * @param cons The schema class * @param obj The target object (instance of cons) * @param data The data to bind * @param cfg The bind configuration */ static bindSchemaToObject<T>(cons: Class<T>, obj: T, data?: object, cfg: BindConfig = {}): T { const view = cfg.view ?? AllViewSymbol; // Does not convey delete cfg.view; if (!!data && isInstance<T>(data)) { const conf = SchemaRegistry.get(cons); // If no configuration if (!conf) { for (const k of TypedObject.keys(data)) { obj[k] = data[k]; } } else { const viewConf = conf.views[view]; if (!viewConf) { throw new Error(`View not found: ${view.toString()}`); } for (const schemaFieldName of viewConf.fields) { const field = viewConf.schema[schemaFieldName]; let inboundField: string | undefined = undefined; if (field.access === 'readonly' || cfg.filterField?.(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 v: unknown = data[castKey<T>(inboundField)]; // Filtering values if (cfg.filterValue && !cfg.filterValue(v, field)) { continue; } if (v !== undefined && v !== null) { // Ensure its an array if (!Array.isArray(v) && field.array) { if (typeof v === 'string' && v.includes(',')) { v = v.split(/,/).map(x => x.trim()); } else { v = [v]; } } if (SchemaRegistry.has(field.type)) { if (field.array && Array.isArray(v)) { v = v.map(el => this.bindSchema(field.type, el, cfg)); } else { v = this.bindSchema(field.type, v, cfg); } } else if (field.array && Array.isArray(v)) { v = v.map(el => this.#coerceType(field, el)); } else { v = this.#coerceType(field, v); } } obj[castKey<T>(schemaFieldName)] = castTo(v); if (field.accessor) { Object.defineProperty(obj, schemaFieldName, { ...SchemaRegistry.getAccessorDescriptor(cons, schemaFieldName), enumerable: true }); } } } } return obj; } /** * Coerce field to type * @param field * @param val * @param applyDefaults * @returns */ static coerceField(field: FieldConfig, val: unknown, applyDefaults = false): unknown { if ((val === undefined || val === null) && applyDefaults) { val = Array.isArray(field.default) ? field.default.slice(0) : field.default; } if (!field.required && (val === undefined || val === null)) { return val; } const complex = SchemaRegistry.has(field.type); if (field.array) { const valArr = !Array.isArray(val) ? [val] : val; if (complex) { val = valArr.map(x => this.bindSchema(field.type, x, { view: field.view })); } else { val = valArr.map(x => DataUtil.coerceType(x, field.type, false)); } } else { if (complex) { val = this.bindSchema(field.type, val, { view: field.view }); } else { val = DataUtil.coerceType(val, field.type, false); } } return val; } /** * Coerce multiple params at once * @param fields * @param params * @returns */ static coerceFields(fields: FieldConfig[], params: unknown[], applyDefaults = true): unknown[] { params = [...params]; // Coerce types for (const el of fields) { params[el.index!] = this.coerceField(el, params[el.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[] { return this.coerceFields(SchemaRegistry.getMethodSchema(cls, method), params, applyDefaults); } }