From 89f43483ae2165b595fbf9eb911264a46bf49b79 Mon Sep 17 00:00:00 2001 From: "DESKTOP-I3JPKHK\\wy" <1111> Date: Tue, 21 Apr 2026 18:22:41 +0800 Subject: [PATCH] feat: improve duplicate export conflict handling --- README.md | 64 ++++++- src/commands/generate.js | 61 ++++++ src/core/generate.js | 360 ++++++++++++++++++++++++++++++----- test/cli.integration.test.js | 73 ++++++- test/generate.test.js | 218 ++++++++++++++++++++- 5 files changed, 723 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 60bb84c..b2fe33e 100644 --- a/README.md +++ b/README.md @@ -327,7 +327,7 @@ src/api/aixue/ - `curriculum.js` 某个模块的 API 方法集合。 - `generated/index.js` - 汇总导出每个模块,同时提供“命名空间导出”和“直接函数导出”。 + 汇总导出每个模块,同时提供“命名空间导出”和“扁平函数导出”。 - `src/api/aixue/index.js` 业务侧入口文件,`sync` 会在里面维护一个受管区块。 @@ -337,11 +337,28 @@ src/api/aixue/ export * as classAssignmentApi from './class-assignment' export * as curriculumApi from './curriculum' -export { getClassAssignmentListApi } from './class-assignment' -export { getCurriculumListApi } from './curriculum' +export * from './class-assignment' +export * from './curriculum' ``` -如果不同模块里恰好生成了同名函数,`generated/index.js` 会自动为冲突项补上模块前缀别名,避免导出冲突。 +这样你可以同时支持两种写法: + +```js +import { curriculumApi } from '@/api/aixue/generated' +import { getCurriculumListApi } from '@/api/aixue/generated' +``` + +需要注意: + +- 如果不同模块里恰好生成了同名函数,例如都生成了 `getListApi` +- 那么 `generated/index.js` 里的 `export * from './module'` 会产生冲突 +- 如果某个扁平函数名刚好和模块命名空间导出名撞上,例如模块命名空间是 `rankingApi`,同时模块里也生成了 `rankingApi` +- 这类情况也会被当成冲突处理 +- 工具会先列出冲突函数、对应模块名,以及能识别到的完整接口 URL +- 在交互终端里会给你两个选择: +- `1` 在函数名后自动追加模块名,例如 `getListEnglishWordApi` +- `2` 退出本次生成 +- 如果自动重命名后仍然冲突,工具会直接失败,并提示“依然冲突无法执行” ## 参数风格 @@ -383,6 +400,8 @@ const createApi = (id, data) => request.post(buildUrl(`/api/v1/course/{id}`, { i // generated/index.js content: // export * as curriculumApi from './curriculum' // export * as rankingApi from './ranking' +// export * from './curriculum' +// export * from './ranking' export * from './generated' // AUTO-GENERATED API EXPORTS END ``` @@ -435,6 +454,10 @@ run-yx-generate-api.bat Curriculum run-yx-generate-api.bat --modules=Curriculum,class-assignment ``` +无论你是直接执行 `npx yx-generate-api gen`,还是走 `.bat`,后续真正执行的都是同一套 CLI。 + +所以如果遇到 API 函数名冲突,两边都会出现同样的冲突提示和同样的两个处理选项。 + ## 常见问题 ### 1. 为什么 `gen` 没有同步外部 `index.js` @@ -477,6 +500,39 @@ npx yx-generate-api generate Curriculum 工具会保留其他已有模块,避免误删。 +### 6. 为什么生成时报“Duplicate API export names detected” + +说明 `generated/index.js` 里出现了重复导出名。 + +常见场景有两种: + +- 两个或多个模块里生成了同名函数,例如都导出了 `getListApi` +- 某个扁平函数名和模块命名空间导出名撞上,例如模块命名空间是 `rankingApi`,同时模块里也生成了 `rankingApi` + +因为 `generated/index.js` 会使用: + +```js +export * from './module-a' +export * from './module-b' +``` + +这种模式下,重复导出名不能直接共存,命名空间导出也不能把同名扁平导出安全暴露出来。 + +所以工具会先把冲突明细列出来,内容会尽量包含: + +- 冲突函数名 +- 对应模块名 +- 对应的完整接口 URL + +然后给你两个选择: + +- 选 `1` + 自动把冲突函数改成“原函数名 + 模块名”,例如 `getListApi` -> `getListEnglishWordApi` +- 选 `2` + 退出生成,不写入本次结果 + +如果你选了自动重命名,但重命名后依然冲突,例如模块内已经存在同名函数,那么工具会直接失败并提示当前结果仍然无法安全生成。 + ## 本地开发 在工具仓库里直接查看帮助: diff --git a/src/commands/generate.js b/src/commands/generate.js index a84cce3..b83b12c 100644 --- a/src/commands/generate.js +++ b/src/commands/generate.js @@ -1,4 +1,5 @@ import process from 'node:process' +import { createInterface } from 'node:readline/promises' import { parseCliArgs, getFlagValue, getFlagValues, hasFlag } from '../core/args.js' import { @@ -60,6 +61,7 @@ export const resolveGenerateCommandContext = async (args) => { ? Boolean(getFlagValue(parsedArgs.flags, 'clean')) : projectConfig.cleanOutput, modules: moduleArgs, + resolveDuplicateExports: createDuplicateExportResolver(), }, } } @@ -114,3 +116,62 @@ const resolveSwaggerTimeoutMs = (flags, fallbackValue) => { return parsedValue } + +const createDuplicateExportResolver = () => { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + return null + } + + return async ({ conflicts }) => { + console.log('') + console.log('检测到生成后的 API 导出名称冲突:') + + for (const conflict of conflicts) { + console.log(`- 冲突函数:${conflict.exportName}`) + + for (const occurrence of conflict.occurrences) { + const locationText = + occurrence.sourceType === 'namespace' + ? '类型: 模块命名空间导出' + : occurrence.path + ? `URL: ${occurrence.path}` + : 'URL: 当前保留的已生成模块' + console.log( + ` 模块: ${occurrence.moduleName} | 文件: ${occurrence.fileName} | ${locationText}`, + ) + } + } + + console.log('') + console.log('请选择处理方式:') + console.log('1. 在冲突函数名后追加模块名,例如 getListEnglishWordApi') + console.log('2. 退出生成') + + const readline = createInterface({ + input: process.stdin, + output: process.stdout, + }) + + try { + while (true) { + const answer = String(await readline.question('请输入 1 或 2: ')).trim() + + if (answer === '1') { + return { + action: 'rename', + } + } + + if (answer === '2') { + return { + action: 'abort', + } + } + + console.log('输入无效,请输入 1 或 2。') + } + } finally { + readline.close() + } + } +} diff --git a/src/core/generate.js b/src/core/generate.js index 870d8aa..bd29bfe 100644 --- a/src/core/generate.js +++ b/src/core/generate.js @@ -65,17 +65,63 @@ export const generateApiFiles = async ({ paramStyle, modules, cleanOutput, + resolveDuplicateExports, }) => { const swagger = await fetchSwaggerJson(swaggerUrl, swaggerTimeoutMs) const moduleMap = buildModuleMap(swagger) const selectedModules = resolveSelectedModules(moduleMap, modules) + const selectedModuleFileNames = new Set(selectedModules.map((moduleInfo) => `${moduleInfo.fileName}.js`)) + const shouldRemoveStaleGeneratedFiles = cleanOutput && modules.length === 0 + const retainedModuleExports = await readRetainedModuleExports({ + outputDir, + skipFileNames: selectedModuleFileNames, + skipRemovableAutoGeneratedFiles: shouldRemoveStaleGeneratedFiles, + }) + const duplicateConflicts = findDuplicateModuleExports([ + ...buildPlannedModuleExports(selectedModules), + ...retainedModuleExports, + ]) + + if (duplicateConflicts.length) { + const resolution = await resolveDuplicateExports?.({ + conflicts: duplicateConflicts, + }) + + if (resolution?.action === 'rename') { + const renameEntries = applyDuplicateExportRenameStrategy(selectedModules, duplicateConflicts) + const remainingConflicts = findDuplicateModuleExports([ + ...buildPlannedModuleExports(selectedModules), + ...retainedModuleExports, + ]) + + if (remainingConflicts.length) { + throw createDuplicateModuleExportsError(remainingConflicts, { + reason: 'rename-failed', + }) + } + + if (renameEntries.length) { + console.log('resolved duplicate exports by appending module names:') + + for (const renameEntry of renameEntries) { + console.log( + ` ${renameEntry.from} -> ${renameEntry.to} (${renameEntry.moduleName}${renameEntry.path ? ` | ${renameEntry.path}` : ''})`, + ) + } + } + } else { + throw createDuplicateModuleExportsError(duplicateConflicts, { + reason: resolution?.action === 'abort' ? 'user-aborted' : 'unresolved', + }) + } + } await fs.mkdir(outputDir, { recursive: true }) - if (cleanOutput && modules.length === 0) { + if (shouldRemoveStaleGeneratedFiles) { await cleanStaleGeneratedFiles({ outputDir, - nextModuleFileNames: selectedModules.map((moduleInfo) => `${moduleInfo.fileName}.js`), + nextModuleFileNames: [...selectedModuleFileNames], projectRoot, }) } else if (cleanOutput && modules.length > 0) { @@ -201,6 +247,93 @@ const cleanStaleGeneratedFiles = async ({ outputDir, nextModuleFileNames, projec } } +const readRetainedModuleExports = async ({ + outputDir, + skipFileNames = new Set(), + skipRemovableAutoGeneratedFiles = false, +}) => { + try { + const dirEntries = await fs.readdir(outputDir, { withFileTypes: true }) + const moduleFiles = dirEntries.filter((entry) => { + return ( + entry.isFile() && + entry.name.endsWith('.js') && + entry.name !== DEFAULT_INDEX_FILE_NAME && + entry.name !== DEFAULT_SHARED_FILE_NAME && + !skipFileNames.has(entry.name) + ) + }) + + return Promise.all( + moduleFiles.map(async (entry) => { + const filePath = path.join(outputDir, entry.name) + const content = await fs.readFile(filePath, 'utf8') + + if (skipRemovableAutoGeneratedFiles && content.startsWith(AUTO_GENERATED_BANNER)) { + return null + } + + const fileName = entry.name.replace(/\.js$/i, '') + const moduleName = readGeneratedModuleName(content) || fileName + + return { + fileName, + isSelected: false, + moduleName, + occurrences: [ + buildNamespaceExportOccurrence({ + fileName, + isSelected: false, + moduleName, + }), + ...extractGeneratedModuleExportNames(content).map((exportName) => + buildFlatExportOccurrence({ + exportName, + fileName, + isSelected: false, + moduleName, + path: null, + }), + ), + ], + } + }), + ).then((items) => items.filter(Boolean)) + } catch (error) { + if (error?.code === 'ENOENT') { + return [] + } + + throw error + } +} + +const buildPlannedModuleExports = (selectedModules) => { + return selectedModules.map((moduleInfo) => { + return { + fileName: moduleInfo.fileName, + isSelected: true, + moduleName: moduleInfo.moduleName, + occurrences: [ + buildNamespaceExportOccurrence({ + fileName: moduleInfo.fileName, + isSelected: false, + moduleName: moduleInfo.moduleName, + }), + ...moduleInfo.operations.map((operationItem) => + buildFlatExportOccurrence({ + exportName: `${operationItem.functionName}Api`, + fileName: moduleInfo.fileName, + isSelected: true, + moduleName: moduleInfo.moduleName, + path: operationItem.path, + }), + ), + ], + } + }) +} + const buildModuleMap = (swagger) => { const moduleMap = new Map() @@ -393,11 +526,11 @@ const generateIndexFile = async ({ outDir, swaggerUrl }) => { } }), ) - const namedExportEntries = resolveIndexNamedExportEntries(moduleExports) + assertNoDuplicateModuleExports(moduleExports) const lines = [ AUTO_GENERATED_BANNER, `// Swagger: ${swaggerUrl}`, - '// Module entrypoint with namespace and named exports', + '// Module entrypoint with namespace and flattened exports', '', ] @@ -405,12 +538,12 @@ const generateIndexFile = async ({ outDir, swaggerUrl }) => { lines.push(`export * as ${buildModuleNamespaceExportName(fileName)} from './${fileName}'`) } - if (moduleExports.length && namedExportEntries.length) { + if (moduleExports.length) { lines.push('') } - for (const entry of namedExportEntries) { - lines.push(buildIndexNamedExportLine(entry)) + for (const { fileName } of moduleExports) { + lines.push(`export * from './${fileName}'`) } lines.push('') @@ -420,7 +553,12 @@ const generateIndexFile = async ({ outDir, swaggerUrl }) => { const readGeneratedModuleExportNames = async (filePath) => { const content = await fs.readFile(filePath, 'utf8') - const match = content.match(/export\s*\{([\s\S]*?)\}\s*;?\s*$/) + + return extractGeneratedModuleExportNames(content) +} + +const extractGeneratedModuleExportNames = (content) => { + const match = String(content || '').match(/export\s*\{([\s\S]*?)\}\s*;?\s*$/) if (!match) { return [] @@ -432,49 +570,189 @@ const readGeneratedModuleExportNames = async (filePath) => { .filter(Boolean) } -const resolveIndexNamedExportEntries = (moduleExports) => { - const originalNameCounts = new Map() - const flatEntries = moduleExports.flatMap(({ fileName, exportNames }) => { - return exportNames.map((exportName) => ({ - exportName, - fileName, - })) - }) +const readGeneratedModuleName = (content) => { + const match = String(content || '').match(/\/\/ Module:\s*(.+)/) - for (const entry of flatEntries) { - originalNameCounts.set(entry.exportName, (originalNameCounts.get(entry.exportName) || 0) + 1) + return match?.[1]?.trim() || '' +} + +const buildNamespaceExportOccurrence = ({ fileName, isSelected, moduleName }) => { + return { + exportName: buildModuleNamespaceExportName(fileName), + fileName, + isSelected, + moduleName, + path: null, + sourceType: 'namespace', + } +} + +const buildFlatExportOccurrence = ({ exportName, fileName, isSelected, moduleName, path }) => { + return { + exportName, + fileName, + isSelected, + moduleName, + path, + sourceType: 'flat', + } +} + +const findDuplicateModuleExports = (moduleExports) => { + const exportMap = new Map() + + for (const moduleExport of moduleExports) { + for (const occurrence of moduleExport.occurrences) { + if (!exportMap.has(occurrence.exportName)) { + exportMap.set(occurrence.exportName, []) + } + + exportMap.get(occurrence.exportName).push(occurrence) + } } - const usedTargetNames = new Set() + return [...exportMap.entries()] + .map(([exportName, occurrences]) => ({ + exportName, + occurrences: occurrences.sort(compareDuplicateOccurrences), + })) + .filter((item) => item.occurrences.length > 1) + .sort((left, right) => left.exportName.localeCompare(right.exportName)) +} - return flatEntries.map((entry) => { - const hasCollision = (originalNameCounts.get(entry.exportName) || 0) > 1 - const baseTargetName = hasCollision - ? buildModuleScopedExportName(entry.fileName, entry.exportName) - : entry.exportName - let targetName = baseTargetName - let sequence = 2 +const applyDuplicateExportRenameStrategy = (selectedModules, conflicts) => { + const renameEntries = [] - while (usedTargetNames.has(targetName)) { - targetName = `${baseTargetName}${sequence}` - sequence += 1 + for (const conflict of conflicts) { + for (const occurrence of conflict.occurrences) { + if (!occurrence.isSelected || occurrence.sourceType !== 'flat') { + continue + } + + const moduleInfo = selectedModules.find((item) => item.fileName === occurrence.fileName) + + if (!moduleInfo) { + continue + } + + const operationItem = moduleInfo.operations.find((item) => { + return `${item.functionName}Api` === occurrence.exportName && item.path === occurrence.path + }) + + if (!operationItem) { + continue + } + + const nextFunctionName = buildConflictResolvedFunctionName( + operationItem.functionName, + moduleInfo.moduleName, + ) + + if (operationItem.functionName === nextFunctionName) { + continue + } + + renameEntries.push({ + from: `${operationItem.functionName}Api`, + moduleName: moduleInfo.moduleName, + path: operationItem.path, + to: `${nextFunctionName}Api`, + }) + + operationItem.functionName = nextFunctionName } + } - usedTargetNames.add(targetName) + return renameEntries.sort((left, right) => left.to.localeCompare(right.to)) +} - return { - ...entry, - targetName, - } +const buildConflictResolvedFunctionName = (functionName, moduleName) => { + return ensureIdentifier(`${functionName}${capitalize(toCamelCase(moduleName))}`) +} + +const compareDuplicateOccurrences = (left, right) => { + if (left.moduleName !== right.moduleName) { + return left.moduleName.localeCompare(right.moduleName) + } + + if (left.fileName !== right.fileName) { + return left.fileName.localeCompare(right.fileName) + } + + return (left.path || '').localeCompare(right.path || '') +} + +const assertNoDuplicateModuleExports = (moduleExports) => { + const duplicateConflicts = findDuplicateModuleExports( + moduleExports.map((moduleExport) => ({ + fileName: moduleExport.fileName, + isSelected: false, + moduleName: moduleExport.fileName, + occurrences: [ + buildNamespaceExportOccurrence({ + fileName: moduleExport.fileName, + isSelected: false, + moduleName: moduleExport.fileName, + }), + ...moduleExport.exportNames.map((exportName) => + buildFlatExportOccurrence({ + exportName, + fileName: moduleExport.fileName, + isSelected: false, + moduleName: moduleExport.fileName, + path: null, + }), + ), + ], + })), + ) + + if (!duplicateConflicts.length) { + return + } + + throw createDuplicateModuleExportsError(duplicateConflicts, { + reason: 'unresolved', }) } -const buildIndexNamedExportLine = ({ fileName, exportName, targetName }) => { - if (exportName === targetName) { - return `export { ${exportName} } from './${fileName}'` - } +const createDuplicateModuleExportsError = (conflicts, { reason = 'unresolved' } = {}) => { + const reasonMessage = + reason === 'user-aborted' + ? 'Generation cancelled because duplicate exports were not resolved.' + : reason === 'rename-failed' + ? 'Automatic rename still leaves duplicate exports, so generation has been aborted.' + : 'Current index export mode uses "export * from \'./module\'", so duplicate names are not allowed. Run this command in an interactive terminal to choose rename or exit.' - return `export { ${exportName} as ${targetName} } from './${fileName}'` + return new Error( + [ + 'Duplicate API export names detected across generated modules.', + reasonMessage, + '', + ...formatDuplicateConflictLines(conflicts), + ].join('\n'), + ) +} + +const formatDuplicateConflictLines = (conflicts) => { + return conflicts.flatMap((conflict) => { + return [ + `Conflict export: ${conflict.exportName}`, + ...conflict.occurrences.map((occurrence) => { + const locationInfo = + occurrence.sourceType === 'namespace' + ? 'type=namespace-export' + : occurrence.path + ? `url=${occurrence.path}` + : occurrence.isSelected + ? 'url=unknown' + : 'url=existing-generated-file' + + return ` - module=${occurrence.moduleName}, file=${occurrence.fileName}, ${locationInfo}` + }), + '', + ] + }) } const generateModuleFile = ({ moduleInfo, paramStyle, schemas, requestImport, swaggerUrl }) => { @@ -1054,10 +1332,6 @@ const buildModuleNamespaceExportName = (fileName) => { return `${toCamelCase(fileName)}Api` } -const buildModuleScopedExportName = (fileName, exportName) => { - return `${toCamelCase(fileName)}${capitalize(exportName)}` -} - const splitWords = (value) => { return String(value || '') .replace(/([a-z0-9])([A-Z])/g, '$1 $2') diff --git a/test/cli.integration.test.js b/test/cli.integration.test.js index cf0180f..e682d36 100644 --- a/test/cli.integration.test.js +++ b/test/cli.integration.test.js @@ -70,10 +70,81 @@ test('cli gen supports spaced flags and syncs the external index file', async () const externalIndexContent = await fs.readFile(externalIndexFile, 'utf8') assert.match(generatedIndexContent, /export \* as alphaApi from ["']\.\/alpha["']/) - assert.match(generatedIndexContent, /export \{ getThingApi \} from ["']\.\/alpha["']/) + assert.match(generatedIndexContent, /export \* from ["']\.\/alpha["']/) assert.match(externalIndexContent, /AUTO-GENERATED API EXPORTS START/) assert.match(externalIndexContent, /export \* from ["']\.\/generated["']/) } finally { await fs.rm(tempDir, { recursive: true, force: true }) } }) + +test('cli gen reports duplicate export conflicts with module and url details in non-interactive mode', async () => { + const tempDir = await createTempDir() + + try { + const swaggerPath = path.join(tempDir, 'swagger.json') + const configPath = path.join(tempDir, 'yx-generate-api.config.mjs') + const cliPath = path.resolve('bin/yx-generate-api.js') + + await fs.writeFile( + swaggerPath, + JSON.stringify( + { + openapi: '3.0.0', + paths: { + '/api/v1/Alpha/GetList': { + get: { + tags: ['Alpha'], + responses: { + 200: { description: 'OK' }, + }, + }, + }, + '/api/v1/Beta/GetList': { + get: { + tags: ['Beta'], + responses: { + 200: { description: 'OK' }, + }, + }, + }, + }, + }, + null, + 2, + ), + 'utf8', + ) + + await fs.writeFile( + configPath, + `export default { + swaggerUrl: './swagger.json', + swaggerTimeoutMs: 1000, + outputDir: './generated', + externalIndexFile: './api-index.js', + requestImport: '../request', + cleanOutput: true, + sync: { + enabled: true, + }, +} +`, + 'utf8', + ) + + await assert.rejects( + execFile(process.execPath, [cliPath, 'gen', '--config', configPath], { + cwd: tempDir, + }), + (error) => { + assert.match(error.stderr, /Duplicate API export names detected/) + assert.match(error.stderr, /module=Alpha, file=alpha, url=\/api\/v1\/Alpha\/GetList/) + assert.match(error.stderr, /module=Beta, file=beta, url=\/api\/v1\/Beta\/GetList/) + return true + }, + ) + } finally { + await fs.rm(tempDir, { recursive: true, force: true }) + } +}) diff --git a/test/generate.test.js b/test/generate.test.js index d9f75f0..6a20dea 100644 --- a/test/generate.test.js +++ b/test/generate.test.js @@ -159,7 +159,7 @@ test('partial generation keeps other generated modules even when cleanOutput is } }) -test('generated index includes namespace exports and collision-safe named exports', async () => { +test('generated index includes namespace exports and flattened re-exports', async () => { const tempDir = await createTempDir() try { @@ -169,7 +169,7 @@ test('generated index includes namespace exports and collision-safe named export await writeJson(swaggerPath, { openapi: '3.0.0', paths: { - '/api/v1/Alpha/GetList': { + '/api/v1/Alpha/GetThing': { get: { tags: ['Alpha'], responses: { @@ -211,9 +211,217 @@ test('generated index includes namespace exports and collision-safe named export assert.match(indexContent, /export \* as alphaApi from ["']\.\/alpha["']/) assert.match(indexContent, /export \* as betaApi from ["']\.\/beta["']/) - assert.match(indexContent, /export \{ getListApi as alphaGetListApi \} from ["']\.\/alpha["']/) - assert.match(indexContent, /export \{ getListApi as betaGetListApi \} from ["']\.\/beta["']/) - assert.match(indexContent, /export \{ getDetailApi \} from ["']\.\/beta["']/) + assert.match(indexContent, /export \* from ["']\.\/alpha["']/) + assert.match(indexContent, /export \* from ["']\.\/beta["']/) + } finally { + await fs.rm(tempDir, { recursive: true, force: true }) + } +}) + +test('generation can rename duplicate exports by appending module names', async () => { + const tempDir = await createTempDir() + + try { + const swaggerPath = path.join(tempDir, 'swagger.json') + const outputDir = path.join(tempDir, 'generated') + + await writeJson(swaggerPath, { + openapi: '3.0.0', + paths: { + '/api/v1/Alpha/GetList': { + get: { + tags: ['Alpha'], + responses: { + 200: { description: 'OK' }, + }, + }, + }, + '/api/v1/Beta/GetList': { + get: { + tags: ['Beta'], + responses: { + 200: { description: 'OK' }, + }, + }, + }, + }, + }) + + await generateApiFiles({ + projectRoot: tempDir, + swaggerUrl: swaggerPath, + swaggerTimeoutMs: 1000, + outputDir, + requestImport: '../request', + paramStyle: 'object', + modules: [], + cleanOutput: true, + resolveDuplicateExports: async ({ conflicts }) => { + assert.equal(conflicts.length, 1) + assert.equal(conflicts[0].exportName, 'getListApi') + return { action: 'rename' } + }, + }) + + const alphaContent = await readFile(path.join(outputDir, 'alpha.js')) + const betaContent = await readFile(path.join(outputDir, 'beta.js')) + const indexContent = await readFile(path.join(outputDir, 'index.js')) + + assert.match(alphaContent, /const getListAlphaApi =/) + assert.match(betaContent, /const getListBetaApi =/) + assert.match(indexContent, /export \* from ["']\.\/alpha["']/) + assert.match(indexContent, /export \* from ["']\.\/beta["']/) + } finally { + await fs.rm(tempDir, { recursive: true, force: true }) + } +}) + +test('generation renames flat exports that conflict with module namespace exports', async () => { + const tempDir = await createTempDir() + + try { + const swaggerPath = path.join(tempDir, 'swagger.json') + const outputDir = path.join(tempDir, 'generated') + + await writeJson(swaggerPath, { + openapi: '3.0.0', + paths: { + '/api/v1/Ranking': { + get: { + tags: ['Ranking'], + responses: { + 200: { description: 'OK' }, + }, + }, + }, + }, + }) + + await generateApiFiles({ + projectRoot: tempDir, + swaggerUrl: swaggerPath, + swaggerTimeoutMs: 1000, + outputDir, + requestImport: '../request', + paramStyle: 'object', + modules: [], + cleanOutput: true, + resolveDuplicateExports: async ({ conflicts }) => { + assert.equal(conflicts.length, 1) + assert.equal(conflicts[0].exportName, 'rankingApi') + return { action: 'rename' } + }, + }) + + const rankingContent = await readFile(path.join(outputDir, 'ranking.js')) + const indexContent = await readFile(path.join(outputDir, 'index.js')) + + assert.match(rankingContent, /const rankingRankingApi =/) + assert.match(indexContent, /export \* as rankingApi from ["']\.\/ranking["']/) + assert.match(indexContent, /export \* from ["']\.\/ranking["']/) + } finally { + await fs.rm(tempDir, { recursive: true, force: true }) + } +}) + +test('generation fails when two modules expose the same API function name', async () => { + const tempDir = await createTempDir() + + try { + const swaggerPath = path.join(tempDir, 'swagger.json') + + await writeJson(swaggerPath, { + openapi: '3.0.0', + paths: { + '/api/v1/Alpha/GetList': { + get: { + tags: ['Alpha'], + responses: { + 200: { description: 'OK' }, + }, + }, + }, + '/api/v1/Beta/GetList': { + get: { + tags: ['Beta'], + responses: { + 200: { description: 'OK' }, + }, + }, + }, + }, + }) + + await assert.rejects( + generateApiFiles({ + projectRoot: tempDir, + swaggerUrl: swaggerPath, + swaggerTimeoutMs: 1000, + outputDir: path.join(tempDir, 'generated'), + requestImport: '../request', + paramStyle: 'object', + modules: [], + cleanOutput: true, + }), + /\/api\/v1\/Alpha\/GetList/, + ) + } finally { + await fs.rm(tempDir, { recursive: true, force: true }) + } +}) + +test('generation fails if duplicate exports still conflict after rename', async () => { + const tempDir = await createTempDir() + + try { + const swaggerPath = path.join(tempDir, 'swagger.json') + + await writeJson(swaggerPath, { + openapi: '3.0.0', + paths: { + '/api/v1/Alpha/GetList': { + get: { + tags: ['Alpha'], + responses: { + 200: { description: 'OK' }, + }, + }, + }, + '/api/v1/Alpha/GetListAlpha': { + get: { + tags: ['Alpha'], + responses: { + 200: { description: 'OK' }, + }, + }, + }, + '/api/v1/Beta/GetList': { + get: { + tags: ['Beta'], + responses: { + 200: { description: 'OK' }, + }, + }, + }, + }, + }) + + await assert.rejects( + generateApiFiles({ + projectRoot: tempDir, + swaggerUrl: swaggerPath, + swaggerTimeoutMs: 1000, + outputDir: path.join(tempDir, 'generated'), + requestImport: '../request', + paramStyle: 'object', + modules: [], + cleanOutput: true, + resolveDuplicateExports: async () => { + return { action: 'rename' } + }, + }), + /Automatic rename still leaves duplicate exports/, + ) } finally { await fs.rm(tempDir, { recursive: true, force: true }) }