import fs from 'fs-extra'; import path from 'path'; import mustache from 'mustache'; import { logStep, logSuccess, logDetail } from '../utils/logger'; import { mapSwaggerTypeToTs } from '../utils/type-mapper'; import { toCamelCase, toPascalCase, safePropertyName } from '../utils/name-formatter'; import { resolveMockValue } from '../utils/mock-value-resolver'; 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()]; } /** * Builds and returns the tagsMap from the swagger analysis, applying the optional selection filter. * Exported so callers (e.g. main.ts) can compute it before organizeFiles runs. */ export function buildTagsMapFromAnalysis( analysis: SwaggerAnalysis, selectionFilter: SelectionFilter = {} ): Record { 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, testValue: resolveTestParamValue(mapSwaggerTypeToTs(p.schema?.type || '')) })); 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, testValue: resolveTestParamValue(bodyType) }); } let returnType = 'void'; let returnBaseType = 'void'; let isListContainer = false; const successCode = ['200', '201', '202', '203'].find((code) => op.responses?.[code]); const responseSchema = successCode !== undefined ? op.responses?.[successCode]?.content?.['application/json']?.schema : undefined; 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; } } const hasQueryParams = (op.parameters || []).some((p) => p.in === 'query'); const hasBodyParam = !!op.requestBody; // Sort: required params first, optional params last (TypeScript requirement) allParams.sort((a, b) => { if (a.required === b.required) return 0; return a.required ? -1 : 1; }); tagsMap[tag].push({ nickname: op.operationId || `${method}${pathKey.replace(/\//g, '_')}`, summary: op.summary || '', notes: op.description || '', httpMethod: method.toLowerCase(), uppercaseHttpMethod: method.toUpperCase(), path: pathKey, allParams: allParams.map((p, i: number) => ({ ...p, '-last': i === allParams.length - 1 })), hasQueryParams, queryParams: (op.parameters || []) .filter((p) => p.in === 'query') .map((p, i: number, arr: unknown[]) => ({ paramName: p.name, '-last': i === arr.length - 1 })), hasBodyParam, bodyParam: 'body', hasOptions: hasQueryParams || hasBodyParam, hasBothParamsAndBody: hasQueryParams && hasBodyParam, 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]; } }); } return tagsMap; } /** * Maps each schema basename to the tag subfolder it belongs to. * Schemas used by exactly one tag → that tag's camelCase name. * Schemas used by 0 or multiple tags → 'shared'. */ export function buildSchemaTagMap( schemas: Record, tagsMap: Record ): Record { const result: Record = {}; Object.keys(schemas).forEach((schemaName) => { const baseName = schemaName.replace(/Dto$/, ''); const tagsUsing: string[] = []; Object.keys(tagsMap).forEach((tag) => { const used = tagsMap[tag].some( (op) => op.returnType === baseName || op.returnType === `${baseName}[]` || op.allParams.some((p) => p.dataType === baseName || p.dataType === `${baseName}[]`) ); if (used) tagsUsing.push(tag); }); result[baseName] = tagsUsing.length === 1 ? toCamelCase(tagsUsing[0]) : 'shared'; }); return result; } /** 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 = {}, precomputedSchemaTagMap: Record = {} ): GeneratedCount { logStep('Generating Clean Architecture artefacts using Mustache...'); const generatedCount: GeneratedCount = { models: 0, repositories: 0, mappers: 0, useCases: 0, providers: 0, mocks: 0, specs: 0 }; const schemas = (analysis.swagger as { components?: { schemas?: Record } }).components ?.schemas || {}; // Build tagsMap first — needed to compute schemaTagMap before the schema loop const tagsMap = buildTagsMapFromAnalysis(analysis, selectionFilter); // Map each schema basename → tag subfolder ('shared' if used by 0 or >1 tags) const schemaTagMap = Object.keys(precomputedSchemaTagMap).length > 0 ? precomputedSchemaTagMap : buildSchemaTagMap(schemas, tagsMap); // 1. Generate Models, Entities and Mappers from Schemas Object.keys(schemas).forEach((schemaName) => { const baseName = schemaName.replace(/Dto$/, ''); const tagFilename = schemaTagMap[baseName] || 'shared'; 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); const isInlineObject = rawProperties[k].type === 'object' && !rawProperties[k].$ref; 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: safePropertyName(k), originalName: k, dataType: tsType, description: rawProperties[k].description || '', required: requiredProps.includes(k), hasMockValue: !isInlineObject }; }); // 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), tagFilename: schemaTagMap[name] || 'shared' })); const modelViewData = { tagFilename, 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), tagFilename } } ] } }; // 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', tagFilename, `${toCamelCase(baseName)}.model.ts` ); fs.ensureDirSync(path.dirname(destPath)); fs.writeFileSync(destPath, output); generatedCount.models++; logDetail('generate', `model-entity → ${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', tagFilename, `${toCamelCase(baseName)}.mapper.ts` ); fs.ensureDirSync(path.dirname(destPath)); fs.writeFileSync(destPath, output); generatedCount.mappers++; } // DTO mock — values resolved from raw schema (example, format, type) const dtoMockVarsMap = Object.keys(rawProperties).map((k) => ({ name: safePropertyName(k), mockValue: resolveMockValue(k, rawProperties[k], 'dto', schemaName) })); const dtoMockImports = [...referencedTypes].filter(Boolean).map((name) => { const targetTag = schemaTagMap[name] || 'shared'; const targetFile = `${toCamelCase(name)}.dto.mock`; const importPath = targetTag === tagFilename ? `./${targetFile}` : `../${targetTag}/${targetFile}`; return { classname: name, classFilename: toCamelCase(name), tagFilename: targetTag, importPath }; }); const dtoMockViewData = { tagFilename, models: [ { model: { classname: baseName, classFilename: toCamelCase(baseName), classVarName: toCamelCase(baseName), mockImports: dtoMockImports, vars: dtoMockVarsMap } } ] }; renderTemplate( templatesDir, 'dto.mock.mustache', dtoMockViewData, path.join(outputDir, 'data/dtos', tagFilename, `${toCamelCase(baseName)}.dto.mock.ts`), generatedCount, 'mocks' ); // Model mock — delegates to mapper + DTO mock (no property values needed) renderTemplate( templatesDir, 'model.mock.mustache', modelViewData, path.join( outputDir, 'entities/models', tagFilename, `${toCamelCase(baseName)}.model.mock.ts` ), generatedCount, 'mocks' ); // Model spec renderTemplate( templatesDir, 'model-entity.spec.mustache', modelViewData, path.join( outputDir, 'entities/models', tagFilename, `${toCamelCase(baseName)}.model.spec.ts` ), generatedCount, 'specs' ); // Mapper spec renderTemplate( templatesDir, 'mapper.spec.mustache', mapperViewData, path.join(outputDir, 'data/mappers', tagFilename, `${toCamelCase(baseName)}.mapper.spec.ts`), generatedCount, 'specs' ); }); // 2. Generate Use Cases and Repositories from Paths/Tags // Generate per tag Object.keys(tagsMap).forEach((tag) => { const tagFilename = toCamelCase(tag); const returnImports: { classname: string; classFilename: string; classVarName: string; tagFilename: string; }[] = []; const paramImports: { classname: string; classFilename: string; classVarName: string; tagFilename: 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), tagFilename: schemaTagMap[s] || 'shared' }; 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: toPascalCase(tag), classFilename: tagFilename, classVarName: tagFilename, constantName: tag.toUpperCase().replace(/[^A-Z0-9]/g, '_'), operation: tagsMap[tag], imports: [...returnImports, ...paramImports], returnImports, paramImports, environmentApiKey: tagApiKeyMap[tag] || 'apiUrl' } } ] } }; renderTemplate( templatesDir, 'api.use-cases.contract.mustache', apiViewData, path.join(outputDir, 'domain/use-cases', tagFilename, `${tagFilename}.use-cases.contract.ts`), generatedCount, 'useCases' ); renderTemplate( templatesDir, 'api.use-cases.impl.mustache', apiViewData, path.join(outputDir, 'domain/use-cases', tagFilename, `${tagFilename}.use-cases.impl.ts`), generatedCount, 'useCases' ); renderTemplate( templatesDir, 'api.repository.contract.mustache', apiViewData, path.join( outputDir, 'domain/repositories', tagFilename, `${tagFilename}.repository.contract.ts` ), generatedCount, 'repositories' ); renderTemplate( templatesDir, 'api.repository.impl.mustache', apiViewData, path.join(outputDir, 'data/repositories', tagFilename, `${tagFilename}.repository.impl.ts`), generatedCount, 'repositories' ); renderTemplate( templatesDir, 'use-cases.provider.mustache', apiViewData, path.join(outputDir, 'di/use-cases', tagFilename, `${tagFilename}.use-cases.provider.ts`), generatedCount, 'providers' ); renderTemplate( templatesDir, 'repository.provider.mustache', apiViewData, path.join(outputDir, 'di/repositories', tagFilename, `${tagFilename}.repository.provider.ts`), generatedCount, 'providers' ); // Mocks renderTemplate( templatesDir, 'api.repository.impl.mock.mustache', apiViewData, path.join( outputDir, 'data/repositories', tagFilename, `${tagFilename}.repository.impl.mock.ts` ), generatedCount, 'mocks' ); renderTemplate( templatesDir, 'api.use-cases.mock.mustache', apiViewData, path.join(outputDir, 'domain/use-cases', tagFilename, `${tagFilename}.use-cases.mock.ts`), generatedCount, 'mocks' ); renderTemplate( templatesDir, 'repository.provider.mock.mustache', apiViewData, path.join( outputDir, 'di/repositories', tagFilename, `${tagFilename}.repository.provider.mock.ts` ), generatedCount, 'mocks' ); renderTemplate( templatesDir, 'use-cases.provider.mock.mustache', apiViewData, path.join( outputDir, 'di/use-cases', tagFilename, `${tagFilename}.use-cases.provider.mock.ts` ), generatedCount, 'mocks' ); // Repository impl spec renderTemplate( templatesDir, 'api.repository.impl.spec.mustache', apiViewData, path.join( outputDir, 'data/repositories', tagFilename, `${tagFilename}.repository.impl.spec.ts` ), generatedCount, 'specs' ); // Use-cases impl spec renderTemplate( templatesDir, 'api.use-cases.impl.spec.mustache', apiViewData, path.join( outputDir, 'domain/use-cases', tagFilename, `${tagFilename}.use-cases.impl.spec.ts` ), generatedCount, 'specs' ); }); logSuccess( `${generatedCount.models} Models, ${generatedCount.repositories} Repos, ${generatedCount.useCases} Use Cases, ${generatedCount.mappers} Mappers, ${generatedCount.providers} Providers, ${generatedCount.mocks} Mocks, ${generatedCount.specs} Specs generated` ); 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.ensureDirSync(path.dirname(destPath)); fs.writeFileSync(destPath, output); counter[key]++; logDetail( 'generate', `${templateName.replace('.mustache', '')} → ${path.relative(process.cwd(), destPath)}` ); } } /** Resolves a simple test value literal for a given TypeScript type. */ function resolveTestParamValue(dataType: string): string { switch (dataType) { case 'string': return "'test'"; case 'number': return '1'; case 'boolean': return 'true'; default: if (dataType.endsWith('[]')) return '[]'; return '{} as any'; } }