ts-transformer-fastest-validator
Version:
TypeScript transformer converting types to fastest-validator schema.
618 lines (617 loc) • 25.9 kB
JavaScript
Object.defineProperty(exports, "__esModule", { value: true });
exports.default = transformer;
const ts = require("typescript");
const predefined = {
ICurrency: 'currency', // https://github.com/icebob/fastest-validator#currency zmaj
IDate: 'date', // https://github.com/icebob/fastest-validator#date
IEmail: 'email', // https://github.com/icebob/fastest-validator#email
IForbidden: 'forbidden', // https://github.com/icebob/fastest-validator#forbidden
IMac: 'mac', // https://github.com/icebob/fastest-validator#mac
IUrl: 'url', // https://github.com/icebob/fastest-validator#url
IUUID: 'uuid', // https://github.com/icebob/fastest-validator#uuid
IObjectID: 'objectID', // https://github.com/icebob/fastest-validator#objectid
};
/**
* Transform logic
*/
function transformer(program) {
return (context) => (file) => visitEach(file, program, context);
}
function visitEach(node, program, context) {
return ts.visitEachChild(visitNode(node, program, context), (childNode) => visitEach(childNode, program, context), context);
}
function visitNode(node, program, context) {
var _a;
// Skip if node is not function call expression
if (!ts.isCallExpression(node)) {
return node;
}
const typeChecker = program.getTypeChecker();
const signature = typeChecker.getResolvedSignature(node);
const declaration = signature === null || signature === void 0 ? void 0 : signature.declaration;
if (declaration && ts.isFunctionDeclaration(declaration)) {
const name = (_a = declaration.name) === null || _a === void 0 ? void 0 : _a.getText();
// If function name is schema and has first type argument process transformation
if (name === 'convertToSchema' && node.typeArguments && node.typeArguments[0]) {
const type = typeChecker.getTypeFromTypeNode(node.typeArguments[0]);
return convert(type, typeChecker, node, context.factory, new Set());
}
}
return node;
}
/**
* Convert evaluates type and uses proper function based on the type
*/
function convert(type, typeChecker, node, factory, history) {
var _a;
// Guard against undefined type in edge cases
if (!type) {
return factory.createObjectLiteralExpression([factory.createPropertyAssignment('type', factory.createStringLiteral('any'))]);
}
let result;
const flags = type.flags;
const name = (_a = type.symbol) === null || _a === void 0 ? void 0 : _a.name;
if (flags === ts.TypeFlags.Never || flags === ts.TypeFlags.Undefined || flags === ts.TypeFlags.Null) {
// Convert to never like null, undefined
result = convertNever(type, typeChecker, node, factory, history);
}
else if (flags & ts.TypeFlags.Literal) {
// Convert literals like true/false/1/20/'example'
result = convertLiteral(type, typeChecker, node, factory, history);
}
else if (name && predefined.hasOwnProperty(name)) {
// Convert predefined
result = convertPredefined(type, typeChecker, node, factory, history);
}
else if (name && name === 'Buffer') {
// Convert buffer
result = convertBuffer(type, typeChecker, node, factory, history);
}
else if (flags & ts.TypeFlags.EnumLike) {
// Convert Enum
result = convertEnum(type, typeChecker, node, factory, history);
}
else if (flags & ts.TypeFlags.StringLike || flags & ts.TypeFlags.NumberLike || flags & ts.TypeFlags.BooleanLike) {
// Convert primitive types like number/string/boolean
result = convertPrimitive(type, typeChecker, node, factory, history);
}
else if (flags === ts.TypeFlags.Any || flags & ts.TypeFlags.VoidLike) {
// Convert Any
result = convertAny(type, typeChecker, node, factory, history);
}
else if (flags === ts.TypeFlags.Object && typeChecker.isArrayType(type)) {
// Convert array
result = convertArray(type, typeChecker, node, factory, history);
}
else if (flags === ts.TypeFlags.Object) {
// Convert objects like interface/type
result = convertObject(type, typeChecker, node, factory, history);
}
else if (flags === ts.TypeFlags.Union) {
// Convert union like Type1 | Type2
result = convertUnion(type, typeChecker, node, factory, history);
}
else if (flags === ts.TypeFlags.Intersection) {
// Convert objects like interface/type
result = convertIntersection(type, typeChecker, node, factory, history);
}
else {
throw Error('Unknown type');
}
// Apply annotations
const annotations = [...extractJsDocTagInfos(type.symbol), ...extractJsDocTagInfos(type.aliasSymbol)];
if (annotations.length) {
return applyJSDoc(annotations, result, typeChecker, factory);
}
return result;
}
/**
* Literal types are converted to https://github.com/icebob/fastest-validator#equal
*/
function convertLiteral(type, typeChecker, node, factory, history) {
const properties = [];
const literalValue = parseLiteral(type, typeChecker, node, factory);
properties.push(factory.createPropertyAssignment('type', factory.createStringLiteral('equal')));
properties.push(factory.createPropertyAssignment('value', literalValue));
properties.push(factory.createPropertyAssignment('strict', factory.createTrue()));
if (history.size === 0) {
properties.push(factory.createPropertyAssignment('$$root', factory.createTrue()));
}
return factory.createObjectLiteralExpression(properties);
}
/**
* Predefineds interfaces could be found in 'predefined' constant
*/
function convertPredefined(type, typeChecker, node, factory, history) {
const name = type.symbol.name;
const properties = [];
properties.push(factory.createPropertyAssignment('type', factory.createStringLiteral(predefined[name])));
if (history.size === 0) {
properties.push(factory.createPropertyAssignment('$$root', factory.createTrue()));
}
return factory.createObjectLiteralExpression(properties);
}
/**
* Instance of class Buffer
*/
function convertBuffer(type, typeChecker, node, factory, history) {
const properties = [];
properties.push(factory.createPropertyAssignment('type', factory.createStringLiteral('class')));
properties.push(factory.createPropertyAssignment('instanceOf', factory.createIdentifier('Buffer')));
if (history.size === 0) {
properties.push(factory.createPropertyAssignment('$$root', factory.createTrue()));
}
return factory.createObjectLiteralExpression(properties);
}
/**
* Primities could be converted to one of:
* https://github.com/icebob/fastest-validator#boolean
* https://github.com/icebob/fastest-validator#number
* https://github.com/icebob/fastest-validator#string
*/
function convertPrimitive(type, typeChecker, node, factory, history) {
const properties = [];
const type_string = typeChecker.typeToString(type);
properties.push(factory.createPropertyAssignment('type', factory.createStringLiteral(type_string)));
if (history.size === 0) {
properties.push(factory.createPropertyAssignment('$$root', factory.createTrue()));
}
return factory.createObjectLiteralExpression(properties);
}
/**
* Any is converted to https://github.com/icebob/fastest-validator#any
*/
function convertAny(type, typeChecker, node, factory, history) {
const properties = [];
properties.push(factory.createPropertyAssignment('type', factory.createStringLiteral('any')));
if (history.size === 0) {
properties.push(factory.createPropertyAssignment('$$root', factory.createTrue()));
}
return factory.createObjectLiteralExpression(properties);
}
/**
* Any is converted to https://github.com/icebob/fastest-validator#forbidden
*/
function convertNever(type, typeChecker, node, factory, history) {
const properties = [];
properties.push(factory.createPropertyAssignment('type', factory.createStringLiteral('forbidden')));
if (history.size === 0) {
properties.push(factory.createPropertyAssignment('$$root', factory.createTrue()));
}
return factory.createObjectLiteralExpression(properties);
}
/**
* Array is convereted to https://github.com/icebob/fastest-validator#array
*/
function convertArray(type, typeChecker, node, factory, history) {
const properties = [];
const types = type.typeArguments;
// Creating annonimous history for case when multi is root
if (history.size === 0) {
properties.push(factory.createPropertyAssignment('$$root', factory.createTrue()));
history.add(undefined);
}
if (types && types.length && !(types[0].flags & ts.TypeFlags.Any)) {
// Convert regular arrays
properties.push(factory.createPropertyAssignment('type', factory.createStringLiteral('array')));
properties.push(factory.createPropertyAssignment('items', convert(types[0], typeChecker, node, factory, history)));
}
else {
// Convert in case of Array<any>, any[]
properties.push(factory.createPropertyAssignment('type', factory.createStringLiteral('array')));
}
return factory.createObjectLiteralExpression(properties);
}
/**
* Object is converted to https://github.com/icebob/fastest-validator#object
*/
function convertObject(type, typeChecker, node, factory, history) {
const size = history.size;
const name = type.symbol.name;
if (name && name !== '__type' && history.has(name)) {
return factory.createObjectLiteralExpression([factory.createPropertyAssignment('type', factory.createStringLiteral('any'))]);
}
history.add(name);
const props = typeChecker
.getPropertiesOfType(type)
.filter((property) => {
const propertyType = typeChecker.getTypeOfSymbolAtLocation(property, node);
return !!propertyType;
})
.map((property) => {
const propertyType = typeChecker.getTypeOfSymbolAtLocation(property, node);
let resolvedType = convert(propertyType, typeChecker, node, factory, history);
// Apply annotations
const annotations = extractJsDocTagInfos(property);
if (annotations.length) {
resolvedType = applyJSDoc(annotations, resolvedType, typeChecker, factory);
}
// Apply optional via questionToken
if (property.declarations &&
property.declarations[0] &&
property.declarations[0].questionToken) {
resolvedType = applyOptional(resolvedType, factory);
}
return factory.createPropertyAssignment(property.name, resolvedType);
});
history.delete(name);
if (size === 0) {
return factory.createObjectLiteralExpression(props);
}
else {
const properties = [];
properties.push(factory.createPropertyAssignment('type', factory.createStringLiteral('object')));
if (props.length) {
properties.push(factory.createPropertyAssignment('props', factory.createObjectLiteralExpression(props)));
}
return factory.createObjectLiteralExpression(properties);
}
}
/**
* Enum is converted to https://github.com/icebob/fastest-validator#enum
*/
function convertEnum(type, typeChecker, node, factory, history) {
const properties = [];
// Collect literal member types either from a union or from enum-like members
let memberTypes = [];
if (type.flags & ts.TypeFlags.Union) {
memberTypes = type.types;
}
else {
// Fallback for EnumLike types which are not unions in the type system
const props = typeChecker.getPropertiesOfType(type);
memberTypes = props
.map((symbol) => typeChecker.getTypeOfSymbolAtLocation(symbol, node))
.filter((memberType) => !!(memberType && (memberType.flags & ts.TypeFlags.Literal || memberType.flags & ts.TypeFlags.BooleanLike)));
}
const values = memberTypes
.filter((t) => !(t.flags & ts.TypeFlags.Undefined))
.map((t) => parseLiteral(t, typeChecker, node, factory));
properties.push(factory.createPropertyAssignment('type', factory.createStringLiteral('enum')));
properties.push(factory.createPropertyAssignment('values', factory.createArrayLiteralExpression(values)));
if (history.size === 0) {
properties.push(factory.createPropertyAssignment('$$root', factory.createTrue()));
}
return factory.createObjectLiteralExpression(properties);
}
/**
* Union is convereted to https://github.com/icebob/fastest-validator#multi
*/
function convertUnion(type, typeChecker, node, factory, history) {
const unionType = type;
// Ignoring undefined as a type as it represents optional type
let optional = false;
let nullable = false;
const types = unionType.types.filter((type) => {
if (type.flags & ts.TypeFlags.Undefined) {
optional = true;
return false;
}
if (type.flags & ts.TypeFlags.Null) {
nullable = true;
return false;
}
return true;
});
// Optimization if all union members are literals create an enum
let allLiterals = true;
types.forEach((type) => {
const literal = type.flags & ts.TypeFlags.Literal;
allLiterals = allLiterals && !!literal;
});
if (allLiterals) {
let result = types.length === 1
? convertLiteral(types[0], typeChecker, node, factory, history)
: convertEnum(type, typeChecker, node, factory, history);
if (optional) {
result = applyOptional(result, factory);
}
if (nullable) {
result = applyNullable(result, factory);
}
return result;
}
// Creating annonimous history for case when multi is root
history.add(undefined);
// In case of optional or nullable it is possible to stay with only one element
if (types.length === 1) {
let result = convert(types[0], typeChecker, node, factory, history);
if (optional) {
result = applyOptional(result, factory);
}
if (nullable) {
result = applyNullable(result, factory);
}
return result;
}
else {
return factory.createArrayLiteralExpression(types.map((type) => {
let result = convert(type, typeChecker, node, factory, history);
if (optional) {
result = applyOptional(result, factory);
}
if (nullable) {
result = applyNullable(result, factory);
}
return result;
}));
}
}
/**
* Intersection is converted to https://github.com/icebob/fastest-validator#object
*/
function convertIntersection(type, typeChecker, node, factory, history) {
const size = history.size;
const unionType = type;
const types = unionType.types || [];
const propsRegistry = new Map();
// First, collect all properties from all intersected types
types.forEach((type) => {
var _a;
if (type.flags & ts.TypeFlags.StringLike ||
type.flags & ts.TypeFlags.NumberLike ||
type.flags & ts.TypeFlags.BooleanLike ||
type.flags & ts.TypeFlags.Literal) {
throw new Error("Can't intersect literal or primitive!");
}
const name = (_a = type.symbol) === null || _a === void 0 ? void 0 : _a.name;
history.add(name);
typeChecker
.getPropertiesOfType(type)
.filter((property) => {
const propertyType = typeChecker.getTypeOfSymbolAtLocation(property, node);
return !!propertyType;
})
.forEach((property) => {
// Collect properties for each property
if (!propsRegistry.has(property.name)) {
propsRegistry.set(property.name, []);
}
propsRegistry.get(property.name).push(property);
});
});
// Now process all collected properties
const props = Array.from(propsRegistry.entries()).map(([name, properties]) => {
let resolvedType;
if (properties.length === 1) {
// Single property - convert normally
const propertyType = typeChecker.getTypeOfSymbolAtLocation(properties[0], node);
resolvedType = convert(propertyType, typeChecker, node, factory, history);
}
else {
// Multiple properties - merge property type from intersection
const propSymbol = typeChecker.getPropertyOfType(type, name);
const mergedPropertyType = propSymbol ? typeChecker.getTypeOfSymbolAtLocation(propSymbol, node) : undefined;
resolvedType = convert(mergedPropertyType, typeChecker, node, factory, history);
}
// Apply any property-level JSDoc annotations if needed
const annotations = properties.reduce((result, property) => {
const docs = extractJsDocTagInfos(property);
return result.concat(...docs);
}, []);
if (annotations.length) {
resolvedType = applyJSDoc(annotations, resolvedType, typeChecker, factory);
}
// Count how many have optional tag and if all do apply optional
const optionalCount = properties.reduce((result, property) => {
if (property.declarations &&
property.declarations[0] &&
property.declarations[0].questionToken) {
return result + 1;
}
return result;
}, 0);
if (properties.length === optionalCount) {
resolvedType = applyOptional(resolvedType, factory);
}
history.delete(name);
return factory.createPropertyAssignment(name, resolvedType);
});
if (size === 0) {
return factory.createObjectLiteralExpression(props);
}
else {
const properties = [];
properties.push(factory.createPropertyAssignment('type', factory.createStringLiteral('object')));
properties.push(factory.createPropertyAssignment('props', factory.createObjectLiteralExpression(props)));
return factory.createObjectLiteralExpression(properties);
}
}
/**
* Adds optional property to an object or an array of objects
*/
function applyOptional(type, factory) {
if (ts.isObjectLiteralExpression(type)) {
const exists = type.properties.find((one) => {
const name = one.name;
return (name === null || name === void 0 ? void 0 : name.escapedText) === 'optional';
});
if (exists) {
return type;
}
return factory.updateObjectLiteralExpression(type, [
...type.properties,
factory.createPropertyAssignment('optional', factory.createTrue()),
]);
}
else if (ts.isArrayLiteralExpression(type)) {
return factory.updateArrayLiteralExpression(type, type.elements.map((element) => {
return applyOptional(element, factory);
}));
}
return type;
}
/**
* Adds nullable property to an object or an array of objects
*/
function applyNullable(type, factory) {
if (ts.isObjectLiteralExpression(type)) {
const exists = type.properties.find((one) => {
const name = one.name;
return (name === null || name === void 0 ? void 0 : name.escapedText) === 'nullable';
});
if (exists) {
return type;
}
return factory.updateObjectLiteralExpression(type, [
...type.properties,
factory.createPropertyAssignment('nullable', factory.createTrue()),
]);
}
else if (ts.isArrayLiteralExpression(type)) {
return factory.updateArrayLiteralExpression(type, type.elements.map((element) => {
return applyOptional(element, factory);
}));
}
return type;
}
/**
* Adds annotations as properties to an object or an array of objects
*/
function applyJSDoc(annotations, type, typeChecker, factory) {
const properties = annotations
.filter((annotation) => annotation.text || annotation.name)
.map((annotation) => {
var _a;
const anyAnno = annotation;
const rawText = anyAnno.text;
// Normalize TS tag text: it can be string or array of parts
const firstPart = Array.isArray(rawText) ? (rawText.length ? rawText[0] : undefined) : rawText;
const textValue = firstPart != null ? String((_a = firstPart.text) !== null && _a !== void 0 ? _a : firstPart) : '';
let literalValue;
if (textValue === 'true') {
literalValue = factory.createTrue();
}
else if (textValue === 'false') {
literalValue = factory.createFalse();
}
else if (/^[+-]?\d+(?:\.\d+)?$/.test(textValue)) {
const isNegative = textValue.startsWith('-');
const absStr = isNegative ? textValue.slice(1) : textValue.startsWith('+') ? textValue.slice(1) : textValue;
const numeric = factory.createNumericLiteral(absStr);
literalValue = isNegative
? factory.createPrefixUnaryExpression(ts.SyntaxKind.MinusToken, numeric)
: textValue.startsWith('+')
? numeric
: numeric;
}
else {
literalValue = factory.createStringLiteral(textValue);
}
return factory.createPropertyAssignment(anyAnno.name, literalValue);
});
if (ts.isObjectLiteralExpression(type)) {
return factory.updateObjectLiteralExpression(type, [...type.properties, ...properties]);
}
else if (ts.isArrayLiteralExpression(type)) {
return factory.updateArrayLiteralExpression(type, type.elements.map((element) => {
return applyJSDoc(annotations, element, typeChecker, factory);
}));
}
return type;
}
/**
* Parsing means making elements exactly like they should be, without coverting
*/
function parseLiteralBoolean(type, typeChecker, node, factory) {
const type_string = typeChecker.typeToString(type);
if (type_string === 'true') {
return factory.createTrue();
}
else {
return factory.createFalse();
}
}
function parseLiteral(type, typeChecker, node, factory) {
if (type.flags & ts.TypeFlags.BooleanLike) {
return parseLiteralBoolean(type, typeChecker, node, factory);
}
const literalType = type;
const value = literalType.value;
switch (typeof value) {
case 'string':
return factory.createStringLiteral(String(value));
case 'number': {
const n = Number(value);
if (n < 0) {
// create negative numbers as prefix unary expression per TS factory requirements
return factory.createPrefixUnaryExpression(ts.SyntaxKind.MinusToken, factory.createNumericLiteral(String(Math.abs(n))));
}
return factory.createNumericLiteral(String(n));
}
default:
throw new Error('Unknown literal type ' + value);
}
}
function extractJsDocTagInfos(target) {
var _a, _b;
if (!target)
return [];
// Try legacy API first
const legacy = target.getJsDocTags ? target.getJsDocTags() : undefined;
if (Array.isArray(legacy) && legacy.length) {
return legacy;
}
const collected = [];
const declarations = target.declarations;
if (!declarations || !declarations.length)
return collected;
for (const decl of declarations) {
const infoTags = ts.getJSDocTags ? ts.getJSDocTags(decl) : [];
if (Array.isArray(infoTags) && infoTags.length) {
for (const info of infoTags) {
const tagNameNode = info.tagName;
const name = info.name ||
(tagNameNode && tagNameNode.escapedText) ||
(tagNameNode && tagNameNode.text) ||
'';
const textRawAny = (_a = info.text) !== null && _a !== void 0 ? _a : info.comment;
const text = Array.isArray(textRawAny)
? textRawAny.length
? String((_b = textRawAny[0].text) !== null && _b !== void 0 ? _b : textRawAny[0])
: undefined
: textRawAny != null
? String(textRawAny)
: undefined;
if (name)
collected.push({ name, text });
}
continue;
}
// Fallback: walk jsDoc nodes
const jsDocs = decl.jsDoc;
if (Array.isArray(jsDocs)) {
for (const jsDoc of jsDocs) {
const tagNodes = jsDoc.tags || [];
for (const t of tagNodes) {
const tagName = t.tagName;
const name = (tagName && tagName.escapedText) ||
(tagName && tagName.text) ||
t.name ||
'';
const textRaw = t.comment;
const text = textRaw != null ? String(textRaw) : undefined;
if (name)
collected.push({ name, text });
}
}
}
// Last fallback: parse leading comments for @tag value
const sourceFile = decl.getSourceFile();
const fullText = sourceFile.getFullText();
const ranges = ts.getLeadingCommentRanges(fullText, decl.pos) || [];
for (const range of ranges) {
const comment = fullText.slice(range.pos, range.end);
const regex = /@([\$\w]+)\s+([^\s*]+)/g;
let m;
while ((m = regex.exec(comment))) {
const name = m[1];
const text = m[2];
if (name)
collected.push({ name, text });
}
}
}
return collected;
}
;