@travetto/schema
Version:
Data type registry for runtime validation, reflection and binding.
273 lines (251 loc) • 10.8 kB
text/typescript
import ts from 'typescript';
import {
type AnyType, DeclarationUtil, LiteralUtil,
DecoratorUtil, DocUtil, ParamDocumentation, TransformerState, transformCast,
} from '@travetto/transformer';
const SCHEMA_MOD = '@travetto/schema/src/decorator/schema';
const FIELD_MOD = '@travetto/schema/src/decorator/field';
const COMMON_MOD = '@travetto/schema/src/decorator/common';
const TYPES_FILE = '@travetto/schema/src/internal/types';
export class SchemaTransformUtil {
/**
* Produce concrete type given transformer type
*/
static toConcreteType(state: TransformerState, type: AnyType, node: ts.Node, root: ts.Node = node): ts.Expression {
switch (type.key) {
case 'pointer': return this.toConcreteType(state, type.target, node, root);
case 'managed': return state.getOrImport(type);
case 'tuple': return state.fromLiteral(type.subTypes.map(x => this.toConcreteType(state, x, node, root)!));
case 'template': return state.createIdentifier(type.ctor.name);
case 'literal': {
if ((type.ctor === Array) && type.typeArguments?.length) {
return state.fromLiteral([this.toConcreteType(state, type.typeArguments[0], node, root)]);
} else if (type.ctor) {
return state.createIdentifier(type.ctor.name!);
}
break;
}
case 'unknown': {
const imp = state.importFile(TYPES_FILE);
return state.createAccess(imp.ident, 'UnknownType');
}
case 'shape': {
const uniqueId = state.generateUniqueIdentifier(node, type, 'Δ');
// Build class on the fly
const [id, existing] = state.registerIdentifier(uniqueId);
if (!existing) {
const cls = state.factory.createClassDeclaration(
[
state.createDecorator(SCHEMA_MOD, 'Schema'),
state.createDecorator(COMMON_MOD, 'Describe',
state.fromLiteral({
title: type.name,
description: type.comment
})
)
],
id, [], [],
Object.entries(type.fieldTypes)
.map(([k, v]) =>
this.computeField(state, state.factory.createPropertyDeclaration(
[], /\W/.test(k) ? state.factory.createComputedPropertyName(state.fromLiteral(k)) : k,
v.undefinable || v.nullable ? state.factory.createToken(ts.SyntaxKind.QuestionToken) : undefined,
v.key === 'unknown' ? state.factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword) : undefined, undefined
), { type: v, root })
)
);
cls.getText = (): string => [
`class ${uniqueId} {`,
...Object.entries(type.fieldTypes)
.map(([k, v]) => ` ${k}${v.nullable ? '?' : ''}: ${v.name};`),
'}'
].join('\n');
state.addStatements([cls], root || node);
}
return id;
}
case 'composition': {
if (type.commonType) {
return this.toConcreteType(state, type.commonType, node, root);
}
break;
}
case 'foreign':
default: {
// Object
}
}
return state.createIdentifier('Object');
}
/**
* Compute property information from declaration
*/
static computeField<T extends ts.PropertyDeclaration | ts.ParameterDeclaration | ts.GetAccessorDeclaration | ts.SetAccessorDeclaration>(
state: TransformerState, node: T, config: { type?: AnyType, root?: ts.Node, name?: string } = { root: node }
): T {
const typeExpr = config.type ?? state.resolveType(ts.isSetAccessor(node) ? node.parameters[0] : node);
const attrs: ts.PropertyAssignment[] = [];
if (!ts.isGetAccessorDeclaration(node) && !ts.isSetAccessorDeclaration(node)) {
// eslint-disable-next-line no-bitwise
if ((ts.getCombinedModifierFlags(node) & ts.ModifierFlags.Readonly) > 0) {
attrs.push(state.factory.createPropertyAssignment('access', state.fromLiteral('readonly')));
} else if (!node.questionToken && !typeExpr.undefinable && !node.initializer) {
attrs.push(state.factory.createPropertyAssignment('required', state.fromLiteral({ active: true })));
}
if (node.initializer && (
ts.isLiteralExpression(node.initializer) ||
(ts.isArrayLiteralExpression(node.initializer) && node.initializer.elements.length === 0)
)) {
attrs.push(state.factory.createPropertyAssignment('default', node.initializer));
}
} else {
const acc = DeclarationUtil.getAccessorPair(node);
attrs.push(state.factory.createPropertyAssignment('accessor', state.fromLiteral(true)));
if (!acc.setter) {
attrs.push(state.factory.createPropertyAssignment('access', state.fromLiteral('readonly')));
}
if (!acc.getter) {
attrs.push(state.factory.createPropertyAssignment('access', state.fromLiteral('writeonly')));
} else if (!typeExpr.undefinable) {
attrs.push(state.factory.createPropertyAssignment('required', state.fromLiteral({ active: true })));
}
}
if (ts.isParameter(node) || config.name !== undefined) {
attrs.push(state.factory.createPropertyAssignment('name', state.factory.createStringLiteral(
config.name !== undefined ? config.name : node.name.getText())
));
}
const primaryExpr = typeExpr.key === 'literal' && typeExpr.typeArguments?.[0] ? typeExpr.typeArguments[0] : typeExpr;
// We need to ensure we aren't being tripped up by the wrapper for arrays, sets, etc.
// If we have a composition type
if (primaryExpr.key === 'composition') {
const values = primaryExpr.subTypes.map(x => x.key === 'literal' ? x.value : undefined)
.filter(x => x !== undefined && x !== null);
if (values.length === primaryExpr.subTypes.length) {
attrs.push(state.factory.createPropertyAssignment('enum', state.fromLiteral({
values,
message: `{path} is only allowed to be "${values.join('" or "')}"`
})));
}
} else if (primaryExpr.key === 'template' && primaryExpr.template) {
const re = LiteralUtil.templateLiteralToRegex(primaryExpr.template);
attrs.push(state.factory.createPropertyAssignment('match', state.fromLiteral({
re: new RegExp(re),
template: primaryExpr.template,
message: `{path} must match "${re}"`
})));
}
if (ts.isParameter(node)) {
const comments = DocUtil.describeDocs(node.parent);
const commentConfig: Partial<ParamDocumentation> = (comments.params ?? []).find(x => x.name === node.name.getText()) || {};
if (commentConfig.description) {
attrs.push(state.factory.createPropertyAssignment('description', state.fromLiteral(commentConfig.description)));
}
}
const tags = ts.getJSDocTags(node);
const aliases = tags.filter(x => x.tagName.getText() === 'alias');
if (aliases.length) {
attrs.push(state.factory.createPropertyAssignment('aliases', state.fromLiteral(aliases.map(x => x.comment).filter(x => !!x))));
}
const params: ts.Expression[] = [];
const existing = state.findDecorator('@travetto/schema', node, 'Field', FIELD_MOD);
if (!existing) {
const resolved = this.toConcreteType(state, typeExpr, node, config.root);
params.push(resolved);
if (attrs.length) {
params.push(state.factory.createObjectLiteralExpression(attrs));
}
} else {
const args = DecoratorUtil.getArguments(existing) ?? [];
if (args.length > 0) {
params.push(args[0]);
}
params.push(state.factory.createObjectLiteralExpression(attrs));
if (args.length > 1) {
params.push(...args.slice(1));
}
}
const newModifiers = [
...(node.modifiers ?? []).filter(x => x !== existing),
state.createDecorator(FIELD_MOD, 'Field', ...params)
];
let ret: unknown;
if (ts.isPropertyDeclaration(node)) {
const comments = DocUtil.describeDocs(node);
if (comments.description) {
newModifiers.push(state.createDecorator(COMMON_MOD, 'Describe', state.fromLiteral({
description: comments.description
})));
}
ret = state.factory.updatePropertyDeclaration(node,
newModifiers, node.name, node.questionToken, node.type, node.initializer);
} else if (ts.isParameter(node)) {
ret = state.factory.updateParameterDeclaration(node,
newModifiers, node.dotDotDotToken, node.name, node.questionToken, node.type, node.initializer);
} else if (ts.isGetAccessorDeclaration(node)) {
ret = state.factory.updateGetAccessorDeclaration(node,
newModifiers, node.name, node.parameters, node.type, node.body);
} else {
ret = state.factory.updateSetAccessorDeclaration(node,
newModifiers, node.name, node.parameters, node.body);
}
return transformCast(ret);
}
/**
* Unwrap type
*/
static unwrapType(type: AnyType): { out: Record<string, unknown>, type: AnyType } {
const out: Record<string, unknown> = {};
while (type?.key === 'literal' && type.typeArguments?.length) {
if (type.ctor === Array) {
out.array = true;
}
type = type.typeArguments?.[0] ?? { key: 'literal', ctor: Object }; // We have a promise nested
}
return { out, type };
}
/**
* Ensure type
* @param state
* @param node
*/
static ensureType(state: TransformerState, anyType: AnyType, target: ts.Node): Record<string, unknown> {
const { out, type } = this.unwrapType(anyType);
switch (type?.key) {
case 'managed': out.type = state.typeToIdentifier(type); break;
case 'shape': out.type = this.toConcreteType(state, type, target); break;
case 'template': out.type = state.factory.createIdentifier(type.ctor.name); break;
case 'literal': {
if (type.ctor) {
out.type = out.array ?
this.toConcreteType(state, type, target) :
state.factory.createIdentifier(type.ctor.name);
}
}
}
return out;
}
/**
* Find inner return method
* @param state
* @param node
* @param methodName
* @returns
*/
static findInnerReturnMethod(state: TransformerState, node: ts.MethodDeclaration, methodName: string): ts.MethodDeclaration | undefined {
// Process returnType
const { type } = this.unwrapType(state.resolveReturnType(node));
let cls;
switch (type?.key) {
case 'managed': {
const [dec] = DeclarationUtil.getDeclarations(type.original!);
cls = dec && ts.isClassDeclaration(dec) ? dec : undefined;
break;
}
case 'shape': cls = type.original; break;
}
if (cls) {
return state.findMethodByName(cls, methodName);
}
}
}