UNPKG

ts-transformer-fastest-validator

Version:

TypeScript transformer converting types to fastest-validator schema.

618 lines (617 loc) 25.9 kB
"use strict"; 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; }