import fs from 'fs-extra'; import path from 'path'; import mustache from 'mustache'; import { logStep, logSuccess, logInfo } from '../utils/logger'; import { mapSwaggerTypeToTs } from '../utils/type-mapper'; import { toCamelCase } from '../utils/name-formatter'; import type { SwaggerAnalysis, OpenApiSchema, OpenApiOperation, TagOperation, TagSummary, SelectionFilter, GeneratedCount } from '../types'; /** * Extracts the unique tags (in order of appearance) from a SwaggerAnalysis. * Only endpoints that have at least one tag are considered; the first tag is used. */ export function extractTagsFromAnalysis(analysis: SwaggerAnalysis): string[] { const seen = new Set(); const tags: string[] = []; Object.values(analysis.paths).forEach((pathObj) => { Object.values(pathObj as Record).forEach((opRaw) => { const op = opRaw as OpenApiOperation; if (op.tags && op.tags.length > 0) { const tag = op.tags[0]; if (!seen.has(tag)) { seen.add(tag); tags.push(tag); } } }); }); return tags; } /** * Extracts all tags with their operations summary for the interactive selection screen. */ export function extractTagsWithOperations(analysis: SwaggerAnalysis): TagSummary[] { const map = new Map(); Object.entries(analysis.paths).forEach(([pathKey, pathObj]) => { Object.entries(pathObj as Record).forEach(([method, opRaw]) => { const op = opRaw as OpenApiOperation; if (!op.tags?.length) return; const tag = op.tags[0]; if (!map.has(tag)) map.set(tag, { tag, operations: [] }); map.get(tag)!.operations.push({ nickname: op.operationId || `${method}${pathKey.replace(/\//g, '_')}`, method: method.toUpperCase(), path: pathKey, summary: op.summary || '' }); }); }); return [...map.values()]; } /** Generates all Clean Architecture artefacts (models, mappers, repos, use cases, providers) using Mustache. */ export function generateCleanArchitecture( analysis: SwaggerAnalysis, outputDir: string, templatesDir: string, tagApiKeyMap: Record = {}, selectionFilter: SelectionFilter = {} ): GeneratedCount { logStep('Generating Clean Architecture artefacts using Mustache...'); const generatedCount: GeneratedCount = { models: 0, repositories: 0, mappers: 0, useCases: 0, providers: 0 }; const schemas = (analysis.swagger as { components?: { schemas?: Record } }).components ?.schemas || {}; // 1. Generate Models, Entities and Mappers from Schemas Object.keys(schemas).forEach((schemaName) => { const baseName = schemaName.replace(/Dto$/, ''); const schemaObj = schemas[schemaName] as OpenApiSchema; const rawProperties = schemaObj.properties || {}; const requiredProps: string[] = schemaObj.required || []; const varsMap = Object.keys(rawProperties).map((k) => { let tsType = mapSwaggerTypeToTs(rawProperties[k].type); if (rawProperties[k].$ref) { tsType = rawProperties[k].$ref.split('/').pop() || 'unknown'; } else if (rawProperties[k].type === 'array' && rawProperties[k].items?.$ref) { tsType = `${rawProperties[k].items.$ref.split('/').pop()}[]`; } return { name: k, dataType: tsType, description: rawProperties[k].description || '', required: requiredProps.includes(k) }; }); // Collect imports for types referenced via $ref in properties const referencedTypes = new Set(); Object.values(rawProperties).forEach((prop) => { if (prop.$ref) { referencedTypes.add(prop.$ref.split('/').pop() || ''); } else if (prop.type === 'array' && prop.items?.$ref) { referencedTypes.add(prop.items.$ref.split('/').pop() || ''); } }); const modelImports = [...referencedTypes] .filter(Boolean) .map((name) => ({ classname: name, classFilename: toCamelCase(name) })); const modelViewData = { models: [ { model: { classname: baseName, classFilename: toCamelCase(baseName), classVarName: toCamelCase(baseName), description: schemaObj.description || '', imports: modelImports, vars: varsMap } } ], allModels: [{ model: { vars: varsMap } }] }; const mapperViewData = { ...modelViewData, apiInfo: { apis: [ { operations: { classname: baseName, classFilename: toCamelCase(baseName), classVarName: toCamelCase(baseName) } } ] } }; // Model (Entities) const modelTemplatePath = path.join(templatesDir, 'model-entity.mustache'); if (fs.existsSync(modelTemplatePath)) { const template = fs.readFileSync(modelTemplatePath, 'utf8'); const output = mustache.render(template, modelViewData); const destPath = path.join(outputDir, 'entities/models', `${toCamelCase(baseName)}.model.ts`); fs.writeFileSync(destPath, output); generatedCount.models++; logInfo(` ${toCamelCase(baseName)}.model.ts → ${path.relative(process.cwd(), destPath)}`); } // Mapper const mapperTemplatePath = path.join(templatesDir, 'mapper.mustache'); if (fs.existsSync(mapperTemplatePath)) { const template = fs.readFileSync(mapperTemplatePath, 'utf8'); const output = mustache.render(template, mapperViewData); const destPath = path.join(outputDir, 'data/mappers', `${toCamelCase(baseName)}.mapper.ts`); fs.writeFileSync(destPath, output); generatedCount.mappers++; } }); // 2. Generate Use Cases and Repositories from Paths/Tags const tagsMap: Record = {}; Object.keys(analysis.paths).forEach((pathKey) => { const pathObj = analysis.paths[pathKey] as Record; Object.keys(pathObj).forEach((method) => { const op = pathObj[method] as OpenApiOperation; if (op.tags && op.tags.length > 0) { const tag = op.tags[0]; if (!tagsMap[tag]) tagsMap[tag] = []; const allParams = (op.parameters || []).map((p) => ({ paramName: p.name, dataType: mapSwaggerTypeToTs(p.schema?.type || ''), description: p.description || '', required: p.required })); if (op.requestBody) { let bodyType = 'unknown'; const content = op.requestBody.content?.['application/json']?.schema; if (content) { if (content.$ref) bodyType = content.$ref.split('/').pop() || 'unknown'; else if (content.type) bodyType = mapSwaggerTypeToTs(content.type); } allParams.push({ paramName: 'body', dataType: bodyType, description: op.requestBody.description || '', required: true }); } let returnType = 'void'; let returnBaseType = 'void'; let isListContainer = false; const responseSchema = op.responses?.['200']?.content?.['application/json']?.schema; if (responseSchema) { if (responseSchema.$ref) { returnType = responseSchema.$ref.split('/').pop() || 'unknown'; returnBaseType = returnType; } else if (responseSchema.type === 'array' && responseSchema.items?.$ref) { returnBaseType = responseSchema.items.$ref.split('/').pop() || 'unknown'; returnType = `${returnBaseType}[]`; isListContainer = true; } } tagsMap[tag].push({ nickname: op.operationId || `${method}${pathKey.replace(/\//g, '_')}`, summary: op.summary || '', notes: op.description || '', httpMethod: method.toLowerCase(), path: pathKey, allParams: allParams.map((p, i: number) => ({ ...p, '-last': i === allParams.length - 1 })), hasQueryParams: (op.parameters || []).some((p) => p.in === 'query'), queryParams: (op.parameters || []) .filter((p) => p.in === 'query') .map((p, i: number, arr: unknown[]) => ({ paramName: p.name, '-last': i === arr.length - 1 })), hasBodyParam: !!op.requestBody, bodyParam: 'body', returnType: returnType !== 'void' ? returnType : false, returnBaseType: returnBaseType !== 'void' ? returnBaseType : false, returnTypeVarName: returnType !== 'void' ? toCamelCase(returnType) : false, returnBaseTypeVarName: returnBaseType !== 'void' ? toCamelCase(returnBaseType) : false, isListContainer: isListContainer, vendorExtensions: {} }); } }); }); if (Object.keys(selectionFilter).length > 0) { Object.keys(tagsMap).forEach((tag) => { if (!selectionFilter[tag]) { delete tagsMap[tag]; } else { tagsMap[tag] = tagsMap[tag].filter((op) => selectionFilter[tag].includes(op.nickname)); if (tagsMap[tag].length === 0) delete tagsMap[tag]; } }); } // Generate per tag Object.keys(tagsMap).forEach((tag) => { const returnImports: { classname: string; classFilename: string; classVarName: string }[] = []; const paramImports: { classname: string; classFilename: string; classVarName: string }[] = []; Object.keys(schemas).forEach((s) => { const usedAsReturn = tagsMap[tag].some( (op) => op.returnType === s || op.returnType === `${s}[]` ); const usedAsParam = tagsMap[tag].some((op) => op.allParams.some((p) => p.dataType === s || p.dataType === `${s}[]`) ); const entry = { classname: s, classFilename: toCamelCase(s), classVarName: toCamelCase(s) }; if (usedAsReturn) { returnImports.push(entry); } else if (usedAsParam) { // Param-only types: entity import needed for method signatures, but no Dto or Mapper paramImports.push(entry); } }); const apiViewData = { apiInfo: { apis: [ { operations: { classname: tag, classFilename: toCamelCase(tag), classVarName: toCamelCase(tag), constantName: tag.toUpperCase().replace(/[^A-Z0-9]/g, '_'), operation: tagsMap[tag], // All entity imports (return + param) — for contracts and use-cases imports: [...returnImports, ...paramImports], // Return-type-only imports — for repo impl (Dto + Entity + Mapper) returnImports, // Param-only imports — for repo impl (Entity only, no Dto/Mapper) paramImports, // Environment API key for the repository base URL (e.g. "aprovalmApi") environmentApiKey: tagApiKeyMap[tag] || 'apiUrl' } } ] } }; renderTemplate( templatesDir, 'api.use-cases.contract.mustache', apiViewData, path.join(outputDir, 'domain/use-cases', `${toCamelCase(tag)}.use-cases.contract.ts`), generatedCount, 'useCases' ); renderTemplate( templatesDir, 'api.use-cases.impl.mustache', apiViewData, path.join(outputDir, 'domain/use-cases', `${toCamelCase(tag)}.use-cases.impl.ts`), generatedCount, 'useCases' ); renderTemplate( templatesDir, 'api.repository.contract.mustache', apiViewData, path.join(outputDir, 'domain/repositories', `${toCamelCase(tag)}.repository.contract.ts`), generatedCount, 'repositories' ); renderTemplate( templatesDir, 'api.repository.impl.mustache', apiViewData, path.join(outputDir, 'data/repositories', `${toCamelCase(tag)}.repository.impl.ts`), generatedCount, 'repositories' ); renderTemplate( templatesDir, 'use-cases.provider.mustache', apiViewData, path.join(outputDir, 'di/use-cases', `${toCamelCase(tag)}.use-cases.provider.ts`), generatedCount, 'providers' ); renderTemplate( templatesDir, 'repository.provider.mustache', apiViewData, path.join(outputDir, 'di/repositories', `${toCamelCase(tag)}.repository.provider.ts`), generatedCount, 'providers' ); }); logSuccess( `${generatedCount.models} Models, ${generatedCount.repositories} Repos, ${generatedCount.useCases} Use Cases, ${generatedCount.mappers} Mappers, ${generatedCount.providers} Providers generados con Mustache` ); return generatedCount; } /** Renders a Mustache template and increments the corresponding counter. */ function renderTemplate( templatesDir: string, templateName: string, viewData: unknown, destPath: string, counter: GeneratedCount, key: keyof GeneratedCount ): void { const templatePath = path.join(templatesDir, templateName); if (fs.existsSync(templatePath)) { const template = fs.readFileSync(templatePath, 'utf8'); const output = mustache.render(template, viewData); fs.writeFileSync(destPath, output); counter[key]++; } }