@travetto/schema
Version:
Data type registry for runtime validation, reflection and binding.
362 lines (328 loc) • 13.9 kB
text/typescript
import ts from 'typescript';
import {
type AnyType, DeclarationUtil, LiteralUtil,
DecoratorUtil, DocUtil, type ParamDocumentation, type TransformerState, transformCast,
} from '@travetto/transformer';
export type ComputeConfig = { type?: AnyType, root?: ts.Node, name?: string, index?: number };
export class SchemaTransformUtil {
static SCHEMA_IMPORT = '@travetto/schema/src/decorator/schema.ts';
static METHOD_IMPORT = '@travetto/schema/src/decorator/method.ts';
static FIELD_IMPORT = '@travetto/schema/src/decorator/field.ts';
static INPUT_IMPORT = '@travetto/schema/src/decorator/input.ts';
static COMMON_IMPORT = '@travetto/schema/src/decorator/common.ts';
static TYPES_IMPORT = '@travetto/schema/src/types.ts';
/**
* 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(subType => this.toConcreteType(state, subType, 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 'mapped': {
const base = state.getOrImport(type);
const uniqueId = state.generateUniqueIdentifier(node, type, 'Δ');
const [id, existing] = state.registerIdentifier(uniqueId);
if (!existing) {
const cls = state.factory.createClassDeclaration(
[
state.createDecorator(this.SCHEMA_IMPORT, 'Schema', state.fromLiteral({
description: type.comment,
mappedOperation: type.operation,
mappedFields: type.fields,
})),
],
id, [], [state.factory.createHeritageClause(
ts.SyntaxKind.ExtendsKeyword, [state.factory.createExpressionWithTypeArguments(base, [])]
)], []
);
cls.getText = (): string => `
class ${uniqueId} extends ${type.mappedClassName} {
fields: ${type.fields?.join(', ')}
operation: ${type.operation}
}`;
state.addStatements([cls], root || node);
}
return id;
}
case 'unknown': {
const imp = state.importFile(this.TYPES_IMPORT);
return state.createAccess(imp.identifier, '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(this.SCHEMA_IMPORT, 'Schema', state.fromLiteral({
description: type.comment
})),
],
id, [], [],
Object.entries(type.fieldTypes)
.map(([key, value]) =>
this.computeInput(state, state.factory.createPropertyDeclaration(
[], /\W/.test(key) ? state.factory.createComputedPropertyName(state.fromLiteral(key)) : key,
value.undefinable || value.nullable ? state.factory.createToken(ts.SyntaxKind.QuestionToken) : undefined,
value.key === 'unknown' ? state.factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword) : undefined, undefined
), { type: value, root })
)
);
cls.getText = (): string => [
`class ${uniqueId} {`,
...Object.entries(type.fieldTypes)
.map(([key, value]) => ` ${key}${value.nullable ? '?' : ''}: ${value.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 decorator params from property/parameter/getter/setter
*/
static computeInputDecoratorParams<T extends ts.PropertyDeclaration | ts.ParameterDeclaration | ts.GetAccessorDeclaration | ts.SetAccessorDeclaration>(
state: TransformerState,
node: T,
config?: ComputeConfig
): ts.Expression[] {
const typeExpr = config?.type ?? state.resolveType(ts.isSetAccessor(node) ? node.parameters[0] : node);
const attrs: Record<string, string | boolean | object | number | ts.Expression> = {};
if (!ts.isGetAccessorDeclaration(node) && !ts.isSetAccessorDeclaration(node)) {
// eslint-disable-next-line no-bitwise
if ((ts.getCombinedModifierFlags(node) & ts.ModifierFlags.Readonly) > 0) {
attrs.access = 'readonly';
} else if (!!node.questionToken || !!typeExpr.undefinable || !!node.initializer) {
attrs.required = { active: false };
}
if (node.initializer !== undefined && (
ts.isLiteralExpression(node.initializer) ||
node.initializer.kind === ts.SyntaxKind.TrueKeyword ||
node.initializer.kind === ts.SyntaxKind.FalseKeyword ||
(ts.isArrayLiteralExpression(node.initializer) && node.initializer.elements.length === 0)
)) {
attrs.default = node.initializer;
}
} else {
const pair = DeclarationUtil.getAccessorPair(node);
attrs.accessor = true;
if (!pair.setter) {
attrs.access = 'readonly';
}
if (!pair.getter) {
attrs.access = 'writeonly';
} else if (!!typeExpr.undefinable) {
attrs.required = { active: false };
}
}
const rawName = node.getSourceFile()?.text ? node.name.getText() ?? undefined : undefined;
const providedName = config?.name ?? rawName!;
attrs.name = providedName;
if (rawName !== providedName && rawName) {
attrs.sourceText = rawName;
}
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(subType => subType.key === 'literal' ? subType.value : undefined)
.filter(value => value !== undefined && value !== null);
if (values.length === primaryExpr.subTypes.length) {
attrs.enum = {
values,
message: `{path} is only allowed to be "${values.join('" or "')}"`
};
}
} else if (primaryExpr.key === 'template' && primaryExpr.template) {
const regex = LiteralUtil.templateLiteralToRegex(primaryExpr.template);
attrs.match = {
regex: new RegExp(regex),
template: primaryExpr.template,
message: `{path} must match "${regex}"`
};
}
if (ts.isParameter(node)) {
const parentComments = DocUtil.describeDocs(node.parent);
const paramComments: Partial<ParamDocumentation> = (parentComments.params ?? [])
.find(param => param.name === node.name.getText()) || {};
if (paramComments.description) {
attrs.description = paramComments.description;
}
} else {
const comments = DocUtil.describeDocs(node);
if (comments.description) {
attrs.description = comments.description;
}
}
const tags = ts.getJSDocTags(node);
const aliases = tags.filter(tag => tag.tagName.getText() === 'alias');
if (aliases.length) {
attrs.aliases = aliases.map(alias => alias.comment).filter(alias => !!alias);
}
const params: ts.Expression[] = [];
const existing =
state.findDecorator('@travetto/schema', node, 'Field', this.FIELD_IMPORT) ??
state.findDecorator('@travetto/schema', node, 'Input', this.INPUT_IMPORT);
if (config?.index !== undefined) {
attrs.index = config.index;
}
if (Object.keys(attrs).length) {
params.push(state.fromLiteral(attrs));
}
const resolved = this.toConcreteType(state, typeExpr, node, config?.root ?? node);
const type = typeExpr.key === 'foreign' ? state.getConcreteType(node) :
ts.isArrayLiteralExpression(resolved) ? resolved.elements[0] : resolved;
params.unshift(LiteralUtil.fromLiteral(state.factory, {
array: ts.isArrayLiteralExpression(resolved),
type
}));
if (existing) {
const args = DecoratorUtil.getArguments(existing) ?? [];
if (args.length > 0) {
params[0] = args[0]; // Overwrite
}
if (args.length > 1) {
params.push(...args.slice(1));
}
}
return params;
}
/**
* Compute property information from declaration
*/
static computeInput<T extends ts.PropertyDeclaration | ts.ParameterDeclaration | ts.GetAccessorDeclaration | ts.SetAccessorDeclaration>(
state: TransformerState, node: T, config?: ComputeConfig
): T {
const existingField = state.findDecorator('@travetto/schema', node, 'Field', this.FIELD_IMPORT);
const existingInput = state.findDecorator('@travetto/schema', node, 'Input', this.INPUT_IMPORT);
const params = this.computeInputDecoratorParams(state, node, config);
let modifiers: ts.ModifierLike[];
if (existingField) {
const decorator = state.createDecorator(this.FIELD_IMPORT, 'Field', ...params);
modifiers = DecoratorUtil.spliceDecorators(node, existingField, [decorator]);
} else {
const decorator = state.createDecorator(this.INPUT_IMPORT, 'Input', ...params);
modifiers = DecoratorUtil.spliceDecorators(node, existingInput, [decorator]);
}
let result: unknown;
if (ts.isPropertyDeclaration(node)) {
result = state.factory.updatePropertyDeclaration(node,
modifiers, node.name, node.questionToken, node.type, node.initializer);
} else if (ts.isParameter(node)) {
result = state.factory.updateParameterDeclaration(node,
modifiers, node.dotDotDotToken, node.name, node.questionToken, node.type, node.initializer);
} else if (ts.isGetAccessorDeclaration(node)) {
result = state.factory.updateGetAccessorDeclaration(node,
modifiers, node.name, node.parameters, node.type, node.body);
} else {
result = state.factory.updateSetAccessorDeclaration(node,
modifiers, node.name, node.parameters, node.body);
}
return transformCast(result);
}
/**
* 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 'foreign': {
out.type = state.getForeignTarget(type);
break;
}
case 'managed': out.type = state.typeToIdentifier(type); break;
case 'mapped': out.type = this.toConcreteType(state, type, target); 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 [decorator] = DeclarationUtil.getDeclarations(type.original!);
cls = decorator && ts.isClassDeclaration(decorator) ? decorator : undefined;
break;
}
case 'shape': cls = type.original; break;
}
if (cls) {
return state.findMethodByName(cls, methodName);
}
}
/**
* Compute return type decorator params
*/
static computeReturnTypeDecoratorParams(state: TransformerState, node: ts.MethodDeclaration): ts.Expression[] {
// If we have a valid response type, declare it
const returnType = state.resolveReturnType(node);
let targetType = returnType;
if (returnType.key === 'literal' && returnType.typeArguments?.length && returnType.name === 'Promise') {
targetType = returnType.typeArguments[0];
}
// TODO: Standardize this using jsdoc
let innerReturnType: AnyType | undefined;
if (targetType.key === 'managed' && targetType.importName.startsWith('@travetto/')) {
innerReturnType = state.getApparentTypeOfField(targetType.original!, 'body');
}
const finalReturnType = SchemaTransformUtil.ensureType(state, innerReturnType ?? returnType, node);
return finalReturnType ? [state.fromLiteral({ returnType: finalReturnType })] : [];
}
}