Files
openapi-clean-arch-gen/main.ts

273 lines
11 KiB
JavaScript
Executable File

#!/usr/bin/env node
import fs from 'fs-extra';
import mustache from 'mustache';
import path from 'path';
import { program } from 'commander';
import {
log,
logSuccess,
logWarning,
logError,
logDetail,
initGenerationLog,
colors
} from './src/utils/logger';
import { checkOpenApiGenerator, installOpenApiGenerator } from './src/utils/openapi-generator';
import { createDirectoryStructure, cleanup } from './src/utils/filesystem';
import { analyzeSwagger } from './src/swagger/analyzer';
import { generateCode, organizeFiles, addDtoImports } from './src/generators/dto.generator';
import {
generateCleanArchitecture,
extractTagsWithOperations,
buildTagsMapFromAnalysis,
buildSchemaTagMap
} from './src/generators/clean-arch.generator';
import { generateReport } from './src/generators/report.generator';
import { lintGeneratedFiles } from './src/generators/lint.generator';
import { findEnvironmentFile, parseApiKeys } from './src/utils/environment-finder';
import { getExampleMismatches, clearExampleMismatches } from './src/utils/example-validator';
import { askApiKeysForTags, askSelectionFilter } from './src/utils/prompt';
import {
loadConfig,
generateDefaultConfig,
writeConfig,
deriveSelectionFilter,
deriveTagApiKeyMap
} from './src/utils/config';
import type { SelectionFilter, LintResult } from './src/types';
import type { CliOptions } from './src/types';
import packageJson from './package.json';
// Disable HTML escaping so that < and > produce valid TypeScript generic types.
(mustache as { escape: (text: string) => string }).escape = function (text: string): string {
return text;
};
// ── CLI CONFIGURATION ────────────────────────────────────────────────────────
program
.name('generate-clean-arch')
.description('Angular Clean Architecture code generator from OpenAPI/Swagger')
.version(packageJson.version)
.option('-i, --input <file>', 'OpenAPI/Swagger file (yaml or json)', 'swagger.yaml')
.option('-o, --output <dir>', 'Output directory', './src/app')
.option('-t, --templates <dir>', 'Custom templates directory', path.join(__dirname, 'templates'))
.option('--skip-install', 'Skip dependency installation')
.option('--dry-run', 'Simulate without generating files')
.option('--skip-lint', 'Skip post-generation linting and formatting')
.option('-s, --select-endpoints', 'Interactively select which tags and endpoints to generate')
.option('-c, --config <file>', 'Use a JSON configuration file (skips interactive prompts)')
.option('--init-config [file]', 'Generate a JSON configuration file instead of generating code')
.parse(process.argv);
const options = program.opts<CliOptions>();
// ── MAIN ORCHESTRATOR ────────────────────────────────────────────────────────
async function main(): Promise<void> {
console.log('\n' + '='.repeat(60));
log(' OpenAPI Clean Architecture Generator', 'bright');
log(' Angular + Clean Architecture Code Generator', 'cyan');
console.log('='.repeat(60) + '\n');
const logPath = path.join(process.cwd(), 'generation.log');
initGenerationLog(logPath);
// ── CONFIG FILE: override CLI defaults with config values ─────────────────
const configFile = options.config;
const generationConfig = configFile ? loadConfig(configFile) : undefined;
if (generationConfig) {
if (generationConfig.input) options.input = generationConfig.input;
if (generationConfig.output) options.output = generationConfig.output;
if (generationConfig.templates) options.templates = generationConfig.templates;
if (generationConfig.skipInstall !== undefined)
options.skipInstall = generationConfig.skipInstall;
if (generationConfig.skipLint !== undefined) options.skipLint = generationConfig.skipLint;
logDetail('config', `Using configuration file: ${configFile}`);
}
logDetail('config', `Input: ${options.input}`);
logDetail('config', `Output: ${options.output}`);
logDetail('config', `Templates: ${options.templates}`);
if (!fs.existsSync(options.input)) {
logError(`File not found: ${options.input}`);
process.exit(1);
}
if (options.dryRun) {
logWarning('DRY RUN mode — no files will be generated');
}
if (!checkOpenApiGenerator()) {
logWarning('OpenAPI Generator CLI not found');
if (!options.skipInstall) {
installOpenApiGenerator();
} else {
logError(
'Install openapi-generator-cli with: npm install -g @openapitools/openapi-generator-cli'
);
process.exit(1);
}
} else {
logSuccess('OpenAPI Generator CLI found');
}
const analysis = analyzeSwagger(options.input);
const tagSummaries = extractTagsWithOperations(analysis);
// ── INIT CONFIG MODE: generate config file and exit ───────────────────────
if (options.initConfig !== undefined) {
const envFile = findEnvironmentFile(process.cwd());
let apiKeys: ReturnType<typeof parseApiKeys> = [];
if (envFile) {
const envContent = fs.readFileSync(envFile, 'utf8');
apiKeys = parseApiKeys(envContent);
}
const defaultConfig = generateDefaultConfig(analysis, tagSummaries, options, apiKeys);
const outputFile =
typeof options.initConfig === 'string' ? options.initConfig : 'generation-config.json';
writeConfig(defaultConfig, outputFile);
logSuccess(`Configuration file generated: ${outputFile}`);
logDetail(
'config',
'Edit the file to customise tags, endpoints and baseUrls, then run with --config'
);
return;
}
if (options.dryRun) {
logWarning('Finishing in DRY RUN mode');
return;
}
createDirectoryStructure(options.output);
clearExampleMismatches();
// ── SELECTION: tags and endpoints ─────────────────────────────────────────
let selectionFilter: SelectionFilter = {};
let tagApiKeyMap: Record<string, string>;
if (generationConfig) {
// Config-driven: derive everything from the JSON file
selectionFilter = deriveSelectionFilter(generationConfig);
tagApiKeyMap = deriveTagApiKeyMap(generationConfig);
logDetail('config', `Tags from config: ${Object.keys(generationConfig.tags).join(', ')}`);
Object.entries(tagApiKeyMap).forEach(([tag, key]) => {
logDetail('config', `API key for "${tag}": environment.${key}.url`);
});
} else {
// Interactive mode (original behaviour)
if (options.selectEndpoints) {
selectionFilter = await askSelectionFilter(tagSummaries);
}
const selectedTags = options.selectEndpoints
? Object.keys(selectionFilter)
: tagSummaries.map((t) => t.tag);
// ── ENVIRONMENT API KEY SELECTION ────────────────────────────────────────
const envFile = findEnvironmentFile(process.cwd());
let apiKeys: ReturnType<typeof parseApiKeys> = [];
if (envFile) {
const envContent = fs.readFileSync(envFile, 'utf8');
apiKeys = parseApiKeys(envContent);
logSuccess(
`environment.ts found: ${colors.cyan}${path.relative(process.cwd(), envFile)}${colors.reset}`
);
if (apiKeys.length === 0) {
logWarning('No keys containing "api" found in environment.ts. Will be requested manually.');
}
} else {
logWarning('No environment.ts found. The key will be requested manually per repository.');
}
tagApiKeyMap = await askApiKeysForTags(selectedTags, apiKeys);
Object.entries(tagApiKeyMap).forEach(([tag, key]) => {
logDetail('config', `API key for "${tag}": environment.${key}.url`);
});
}
// ──────────────────────────────────────────────────────────────────────────
const tempDir = generateCode(options.input, options.templates);
// Compute schema→tag map before organizeFiles so DTOs land in the right subfolder
const tagsMapForSchema = buildTagsMapFromAnalysis(analysis, selectionFilter);
const schemaTagMap = buildSchemaTagMap(
(analysis.swagger as { components?: { schemas?: Record<string, unknown> } }).components
?.schemas || {},
tagsMapForSchema
);
organizeFiles(tempDir, options.output, schemaTagMap);
addDtoImports(options.output);
generateCleanArchitecture(
analysis,
options.output,
options.templates,
tagApiKeyMap,
selectionFilter,
schemaTagMap
);
cleanup(tempDir);
// ── EXAMPLE/TYPE MISMATCH WARNINGS ─────────────────────────────────────────
const mismatches = getExampleMismatches();
if (mismatches.length > 0) {
console.log('');
logWarning(`${mismatches.length} example/type mismatch(es) detected in OpenAPI schemas:`);
for (const m of mismatches) {
const action =
m.action === 'coerced'
? `→ coerced to ${JSON.stringify(m.coercedValue)}`
: '→ example ignored, using type default';
logWarning(
` ${m.schemaName}.${m.propertyName}: type '${m.declaredType}' but example is ${m.exampleJsType} (${JSON.stringify(m.exampleValue)}) ${action}`
);
logDetail(
'VALIDATE',
`${m.schemaName}.${m.propertyName}: declared=${m.declaredType} example=${JSON.stringify(m.exampleValue)} (${m.exampleJsType}) action=${m.action}`
);
}
}
const noLintResult: LintResult = {
prettier: { ran: false, filesFormatted: 0 },
eslint: { ran: false, filesFixed: 0 }
};
const lintResult = options.skipLint ? noLintResult : lintGeneratedFiles(options.output);
const report = generateReport(options.output, analysis, lintResult);
console.log('\n' + '='.repeat(60));
log(' ✨ Generation completed successfully', 'green');
console.log('='.repeat(60));
console.log(`\n📊 Summary:`);
console.log(` - DTOs generated: ${report.structure.dtos}`);
console.log(` - Repositories: ${report.structure.repositories}`);
console.log(` - Mappers: ${report.structure.mappers}`);
console.log(` - Use Cases: ${report.structure.useCases}`);
console.log(` - Providers: ${report.structure.providers}`);
console.log(` - Mocks: ${report.structure.mocks}`);
if (report.warnings.total > 0) {
console.log(
`\n ${colors.yellow}⚠️ ${report.warnings.total} example/type mismatch(es) (see above)${colors.reset}`
);
}
console.log(`\n📁 Files generated in: ${colors.cyan}${options.output}${colors.reset}\n`);
}
main().catch((error: unknown) => {
const err = error as Error;
logError(`Fatal error: ${err.message}`);
console.error(error);
process.exit(1);
});