@travetto/schema
Version:
Data type registry for runtime validation, reflection and binding.
345 lines (303 loc) • 12 kB
text/typescript
import type { RegistryAdapter } from '@travetto/registry';
import { AppError, castKey, castTo, type Class, describeFunction, safeAssign } from '@travetto/runtime';
import {
type SchemaClassConfig, type SchemaMethodConfig, type SchemaFieldConfig,
type SchemaParameterConfig, type SchemaInputConfig, type SchemaFieldMap, type SchemaCoreConfig,
CONSTRUCTOR_PROPERTY
} from './types.ts';
export type SchemaDiscriminatedInfo = Required<Pick<SchemaClassConfig, 'discriminatedType' | 'discriminatedField' | 'discriminatedBase'>>;
const classToDiscriminatedType = (cls: Class): string => cls.name
.replace(/([A-Z])([A-Z][a-z])/g, (all, left, right) => `${left}_${right.toLowerCase()}`)
.replace(/([a-z]|\b)([A-Z])/g, (all, left, right) => left ? `${left}_${right.toLowerCase()}` : right.toLowerCase())
.toLowerCase();
function assignMetadata<T>(key: symbol, base: SchemaCoreConfig, data: Partial<T>[]): T {
const metadata = base.metadata ??= {};
const out = metadata[key] ??= {};
for (const d of data) {
safeAssign(out, d);
}
return castTo(out);
}
function combineCore<T extends SchemaCoreConfig>(base: T, config: Partial<T>): T {
return safeAssign(base, {
...config.metadata ? { metadata: { ...base.metadata, ...config.metadata } } : {},
...config.private ? { private: config.private ?? base.private } : {},
...config.description ? { description: config.description || base.description } : {},
...config.examples ? { examples: [...(base.examples ?? []), ...(config.examples ?? [])] } : {},
});
}
function combineInputs<T extends SchemaInputConfig>(base: T, configs: Partial<T>[]): T {
for (const config of configs) {
if (config) {
safeAssign(base, {
...config,
...config.aliases ? { aliases: [...base.aliases ?? [], ...config.aliases ?? []] } : {},
...config.specifiers ? { specifiers: [...base.specifiers ?? [], ...config.specifiers ?? []] } : {},
...config.enum ? {
enum: {
message: config.enum?.message ?? base.enum?.message,
values: (config.enum?.values ?? base.enum?.values ?? []).toSorted()
}
} : {},
});
}
combineCore(base, config);
}
return base;
}
function combineMethods<T extends SchemaMethodConfig>(base: T, configs: Partial<T>[]): T {
for (const config of configs) {
safeAssign(base, {
...config,
parameters: config.parameters ?? base.parameters,
validators: [...base.validators, ...(config.validators ?? [])],
});
combineCore(base, config);
if (config.parameters) {
for (const param of config.parameters) {
safeAssign(base.parameters[param.index], param);
}
}
}
return base;
}
function getConstructorConfig<T extends SchemaClassConfig>(base: Partial<T>, parent?: Partial<T>): SchemaMethodConfig {
const parentCons = parent?.methods?.[CONSTRUCTOR_PROPERTY];
const baseCons = base.methods?.[CONSTRUCTOR_PROPERTY];
return {
class: base.class!,
parameters: [],
validators: [],
...parentCons,
...baseCons,
returnType: { type: base.class! }
};
}
function combineClassWithParent<T extends SchemaClassConfig>(base: T, parent: T): T {
safeAssign(base, {
...base.views ? { views: { ...parent.views, ...base.views } } : {},
...base.validators ? { validators: [...parent.validators, ...base.validators] } : {},
...base.metadata ? { metadata: { ...parent.metadata, ...base.metadata } } : {},
interfaces: [...parent.interfaces, ...base.interfaces],
methods: { ...parent.methods, ...base.methods },
description: base.description || parent.description,
examples: [...(parent.examples ?? []), ...(base.examples ?? [])],
discriminatedField: base.discriminatedField ?? parent.discriminatedField,
});
switch (base.mappedOperation) {
case 'Required':
case 'Partial': {
base.fields = Object.fromEntries(
Object.entries(parent.fields).map(([key, value]) => [key, {
...value,
required: {
active: base.mappedOperation === 'Required'
}
}])
);
break;
}
case 'Pick':
case 'Omit': {
const keys = new Set<string>(base.mappedFields ?? []);
base.fields = Object.fromEntries(
Object.entries(parent.fields).filter(([key]) =>
base.mappedOperation === 'Pick' ? keys.has(key) : !keys.has(key)
)
);
break;
}
default: {
base.fields = { ...parent.fields, ...base.fields };
}
}
return base;
}
function combineClasses<T extends SchemaClassConfig>(base: T, configs: Partial<T>[]): T {
for (const config of configs) {
Object.assign(base, {
...config,
...config.views ? { views: { ...base.views, ...config.views } } : {},
...config.validators ? { validators: [...base.validators, ...config.validators] } : {},
interfaces: [...base.interfaces, ...(config.interfaces ?? [])],
methods: { ...base.methods, ...config.methods },
fields: { ...base.fields, ...config.fields },
});
combineCore(base, config);
}
return base;
}
export class SchemaRegistryAdapter implements RegistryAdapter<SchemaClassConfig> {
#cls: Class;
#config: SchemaClassConfig;
#views: Map<string, SchemaFieldMap> = new Map();
#accessorDescriptors: Map<string, PropertyDescriptor> = new Map();
constructor(cls: Class) {
this.#cls = cls;
}
register(...data: Partial<SchemaClassConfig>[]): SchemaClassConfig {
const config = this.#config ??= {
methods: {},
class: this.#cls,
views: {},
validators: [],
interfaces: [],
fields: {},
};
return combineClasses(config, data);
}
registerMetadata<T>(key: symbol, ...data: Partial<T>[]): T {
const config = this.register({});
return assignMetadata(key, config, data);
}
getMetadata<T>(key: symbol): T | undefined {
const metadata = this.#config?.metadata;
return castTo<T>(metadata?.[key]);
}
registerField(field: string, ...data: Partial<SchemaFieldConfig>[]): SchemaFieldConfig {
const classConfig = this.register({});
const config = classConfig.fields[field] ??= { name: field, class: this.#cls, type: null! };
const combined = combineInputs(config, data);
return combined;
}
registerFieldMetadata<T>(field: string, key: symbol, ...data: Partial<T>[]): T {
const config = this.registerField(field);
return assignMetadata(key, config, data);
}
getFieldMetadata<T>(field: string, key: symbol): T | undefined {
const metadata = this.#config?.fields[field]?.metadata;
return castTo<T>(metadata?.[key]);
}
registerClass({ methods, ...config }: Partial<SchemaClassConfig> = {}): SchemaClassConfig {
this.register({ ...config });
if (methods?.[CONSTRUCTOR_PROPERTY]) {
const { parameters, ...rest } = methods[CONSTRUCTOR_PROPERTY];
this.registerMethod(CONSTRUCTOR_PROPERTY, rest);
for (const param of parameters ?? []) {
this.registerParameter(CONSTRUCTOR_PROPERTY, param.index!, param);
}
}
return this.#config;
}
registerMethod(method: string, ...data: Partial<SchemaMethodConfig>[]): SchemaMethodConfig {
const classConfig = this.register();
const config = classConfig.methods[method] ??= { class: this.#cls, parameters: [], validators: [] };
return combineMethods(config, data);
}
registerMethodMetadata<T>(method: string, key: symbol, ...data: Partial<T>[]): T {
const config = this.registerMethod(method);
return assignMetadata(key, config, data);
}
getMethodMetadata<T>(method: string, key: symbol): T | undefined {
const metadata = this.#config?.methods[method]?.metadata;
return castTo<T>(metadata?.[key]);
}
registerParameter(method: string, idx: number, ...data: Partial<SchemaParameterConfig>[]): SchemaParameterConfig {
const params = this.registerMethod(method, {}).parameters;
const config = params[idx] ??= { method, index: idx, class: this.#cls, array: false, type: null! };
return combineInputs(config, data);
}
registerParameterMetadata<T>(method: string, idx: number, key: symbol, ...data: Partial<T>[]): T {
const config = this.registerParameter(method, idx);
return assignMetadata(key, config, data);
}
getParameterMetadata<T>(method: string, idx: number, key: symbol): T | undefined {
const metadata = this.#config?.methods[method]?.parameters[idx]?.metadata;
return castTo<T>(metadata?.[key]);
}
finalize(parent?: SchemaClassConfig): void {
const config = this.#config;
if (parent) {
combineClassWithParent(config, parent);
}
if (config.discriminatedField && !config.discriminatedType && !describeFunction(this.#cls).abstract) {
config.discriminatedType = classToDiscriminatedType(this.#cls);
}
if (config.discriminatedField && config.discriminatedType) {
config.fields[config.discriminatedField] = {
...config.fields[config.discriminatedField], // Make a full copy
required: {
active: false
},
enum: {
values: [config.discriminatedType],
message: `${config.discriminatedField} can only be '${config.discriminatedType}'`,
},
};
}
// Compute views on install
for (const view of Object.keys(config.views)) {
const fields = config.views[view];
const withoutSet = 'without' in fields ? new Set<string>(fields.without) : undefined;
const fieldList = withoutSet ?
Object.keys(config.fields).filter(field => !withoutSet.has(field)) :
('with' in fields ? fields.with : []);
this.#views.set(view,
fieldList.reduce<SchemaFieldMap>((map, value) => {
map[value] = config.fields[value];
return map;
}, {})
);
}
config.methods[CONSTRUCTOR_PROPERTY] = getConstructorConfig(config, parent);
for (const method of Object.values(config.methods)) {
method.parameters = method.parameters.toSorted((a, b) => (a.index! - b.index!));
}
}
get(): SchemaClassConfig {
return this.#config;
}
getField(field: string): SchemaFieldConfig {
return this.#config.fields[field];
}
getMethod(method: string): SchemaMethodConfig {
const methodConfig = this.#config.methods[method];
if (!methodConfig) {
throw new AppError(`Unknown method ${String(method)} on class ${this.#cls.Ⲑid}`);
}
return methodConfig;
}
getMethodReturnType(method: string): Class {
return this.getMethod(method).returnType!.type;
}
getFields(view?: string): SchemaFieldMap {
if (!view) {
return this.#config.fields;
}
if (!this.#views.has(view)) {
throw new AppError(`Unknown view ${view} for class ${this.#cls.Ⲑid}`);
}
return this.#views.get(view)!;
}
/**
* Provides the prototype-derived descriptor for a property
*/
getAccessorDescriptor(field: string): PropertyDescriptor {
if (!this.#accessorDescriptors.has(field)) {
let proto = this.#cls.prototype;
while (proto && !Object.hasOwn(proto, field)) {
proto = proto.prototype;
}
this.#accessorDescriptors.set(field, Object.getOwnPropertyDescriptor(proto, field)!);
}
return this.#accessorDescriptors.get(field)!;
}
/**
* Ensure type is set properly
*/
ensureInstanceTypeField<T>(value: T): T {
const config = this.getDiscriminatedConfig();
if (config) {
const typeField = castKey<T>(config.discriminatedField);
value[typeField] ??= castTo(config.discriminatedType); // Assign if missing
}
return value;
}
getDiscriminatedConfig(): SchemaDiscriminatedInfo | undefined {
const { discriminatedField, discriminatedType, discriminatedBase } = this.#config;
if (discriminatedType && discriminatedField) {
return { discriminatedType, discriminatedField, discriminatedBase: !!discriminatedBase };
}
return undefined;
}
}