diff --git a/README.md b/README.md index b2fe33e..5601ca6 100644 --- a/README.md +++ b/README.md @@ -352,8 +352,8 @@ import { getCurriculumListApi } from '@/api/aixue/generated' - 如果不同模块里恰好生成了同名函数,例如都生成了 `getListApi` - 那么 `generated/index.js` 里的 `export * from './module'` 会产生冲突 -- 如果某个扁平函数名刚好和模块命名空间导出名撞上,例如模块命名空间是 `rankingApi`,同时模块里也生成了 `rankingApi` -- 这类情况也会被当成冲突处理 +- 如果某个接口的默认函数名刚好会和模块命名空间导出名撞上,例如 `/api/Values` 同时会推导出模块命名空间 `valuesApi` +- 工具会在生成阶段自动把函数名调整成不冲突的形式,例如 `getValuesApi` - 工具会先列出冲突函数、对应模块名,以及能识别到的完整接口 URL - 在交互终端里会给你两个选择: - `1` 在函数名后自动追加模块名,例如 `getListEnglishWordApi` @@ -502,12 +502,12 @@ npx yx-generate-api generate Curriculum ### 6. 为什么生成时报“Duplicate API export names detected” -说明 `generated/index.js` 里出现了重复导出名。 +说明不同模块之间出现了重复的扁平函数导出名。 常见场景有两种: - 两个或多个模块里生成了同名函数,例如都导出了 `getListApi` -- 某个扁平函数名和模块命名空间导出名撞上,例如模块命名空间是 `rankingApi`,同时模块里也生成了 `rankingApi` +- 你只生成了部分模块,但保留目录里已有模块,导致新旧模块之间出现了同名导出 因为 `generated/index.js` 会使用: @@ -516,7 +516,11 @@ export * from './module-a' export * from './module-b' ``` -这种模式下,重复导出名不能直接共存,命名空间导出也不能把同名扁平导出安全暴露出来。 +这种模式下,重复的扁平导出名不能直接共存。 + +像 `/api/Values` 这类“函数默认名刚好和模块命名空间名相同”的情况,工具会在生成阶段自动规避,不会再单独报这个冲突。 + +如果你是从旧版本升级过来,而 `generated` 目录里还保留着旧规则生成的模块文件,也可能在部分生成时触发这类校验。遇到这种情况,直接做一次全量生成,或者把冲突模块一起重新生成即可。 所以工具会先把冲突明细列出来,内容会尽量包含: diff --git a/src/commands/generate.js b/src/commands/generate.js index b83b12c..d83c119 100644 --- a/src/commands/generate.js +++ b/src/commands/generate.js @@ -130,12 +130,7 @@ const createDuplicateExportResolver = () => { console.log(`- 冲突函数:${conflict.exportName}`) for (const occurrence of conflict.occurrences) { - const locationText = - occurrence.sourceType === 'namespace' - ? '类型: 模块命名空间导出' - : occurrence.path - ? `URL: ${occurrence.path}` - : 'URL: 当前保留的已生成模块' + const locationText = occurrence.path ? `URL: ${occurrence.path}` : 'URL: 当前保留的已生成模块' console.log( ` 模块: ${occurrence.moduleName} | 文件: ${occurrence.fileName} | ${locationText}`, ) diff --git a/src/core/generate.js b/src/core/generate.js index bd29bfe..a6c34fb 100644 --- a/src/core/generate.js +++ b/src/core/generate.js @@ -77,6 +77,27 @@ export const generateApiFiles = async ({ skipFileNames: selectedModuleFileNames, skipRemovableAutoGeneratedFiles: shouldRemoveStaleGeneratedFiles, }) + const reservedNamespaceFunctionNames = new Set( + [...selectedModules.map((moduleInfo) => moduleInfo.fileName), ...retainedModuleExports.map((item) => item.fileName)].map( + (fileName) => toCamelCase(fileName), + ), + ) + + for (const moduleInfo of selectedModules) { + assignFunctionNames(moduleInfo.operations, reservedNamespaceFunctionNames) + } + + const namespaceConflicts = findNamespaceExportConflicts([ + ...buildPlannedModuleExports(selectedModules), + ...retainedModuleExports, + ]) + + if (namespaceConflicts.length) { + throw createDuplicateModuleExportsError(namespaceConflicts, { + reason: 'namespace-conflict', + }) + } + const duplicateConflicts = findDuplicateModuleExports([ ...buildPlannedModuleExports(selectedModules), ...retainedModuleExports, @@ -89,14 +110,19 @@ export const generateApiFiles = async ({ if (resolution?.action === 'rename') { const renameEntries = applyDuplicateExportRenameStrategy(selectedModules, duplicateConflicts) + const remainingNamespaceConflicts = findNamespaceExportConflicts([ + ...buildPlannedModuleExports(selectedModules), + ...retainedModuleExports, + ]) const remainingConflicts = findDuplicateModuleExports([ ...buildPlannedModuleExports(selectedModules), ...retainedModuleExports, ]) - if (remainingConflicts.length) { + if (remainingNamespaceConflicts.length || remainingConflicts.length) { throw createDuplicateModuleExportsError(remainingConflicts, { reason: 'rename-failed', + supplementalConflicts: remainingNamespaceConflicts, }) } @@ -280,22 +306,15 @@ const readRetainedModuleExports = async ({ fileName, isSelected: false, moduleName, - occurrences: [ - buildNamespaceExportOccurrence({ + occurrences: extractGeneratedModuleExportNames(content).map((exportName) => + buildFlatExportOccurrence({ + exportName, fileName, isSelected: false, moduleName, + path: null, }), - ...extractGeneratedModuleExportNames(content).map((exportName) => - buildFlatExportOccurrence({ - exportName, - fileName, - isSelected: false, - moduleName, - path: null, - }), - ), - ], + ), } }), ).then((items) => items.filter(Boolean)) @@ -314,22 +333,15 @@ const buildPlannedModuleExports = (selectedModules) => { fileName: moduleInfo.fileName, isSelected: true, moduleName: moduleInfo.moduleName, - occurrences: [ - buildNamespaceExportOccurrence({ + occurrences: moduleInfo.operations.map((operationItem) => + buildFlatExportOccurrence({ + exportName: `${operationItem.functionName}Api`, fileName: moduleInfo.fileName, - isSelected: false, + isSelected: true, moduleName: moduleInfo.moduleName, + path: operationItem.path, }), - ...moduleInfo.operations.map((operationItem) => - buildFlatExportOccurrence({ - exportName: `${operationItem.functionName}Api`, - fileName: moduleInfo.fileName, - isSelected: true, - moduleName: moduleInfo.moduleName, - path: operationItem.path, - }), - ), - ], + ), } }) } @@ -374,7 +386,6 @@ const buildModuleMap = (swagger) => { for (const moduleInfo of moduleMap.values()) { moduleInfo.operations.sort(compareOperations) - assignFunctionNames(moduleInfo.operations) } return moduleMap @@ -576,11 +587,11 @@ const readGeneratedModuleName = (content) => { return match?.[1]?.trim() || '' } -const buildNamespaceExportOccurrence = ({ fileName, isSelected, moduleName }) => { +const buildNamespaceExportOccurrence = ({ fileName, moduleName }) => { return { exportName: buildModuleNamespaceExportName(fileName), fileName, - isSelected, + isSelected: false, moduleName, path: null, sourceType: 'namespace', @@ -620,12 +631,52 @@ const findDuplicateModuleExports = (moduleExports) => { .sort((left, right) => left.exportName.localeCompare(right.exportName)) } +const findNamespaceExportConflicts = (moduleExports) => { + const namespaceOccurrences = moduleExports.map((moduleExport) => + buildNamespaceExportOccurrence({ + fileName: moduleExport.fileName, + moduleName: moduleExport.moduleName, + }), + ) + const namespaceMap = new Map( + namespaceOccurrences.map((occurrence) => [occurrence.exportName, occurrence]), + ) + const conflicts = new Map() + + for (const moduleExport of moduleExports) { + for (const occurrence of moduleExport.occurrences) { + const namespaceOccurrence = namespaceMap.get(occurrence.exportName) + + if (!namespaceOccurrence) { + continue + } + + if (!conflicts.has(occurrence.exportName)) { + conflicts.set(occurrence.exportName, { + exportName: occurrence.exportName, + occurrences: [namespaceOccurrence], + }) + } + + conflicts.get(occurrence.exportName).occurrences.push(occurrence) + } + } + + return [...conflicts.values()] + .map((conflict) => ({ + ...conflict, + occurrences: conflict.occurrences.sort(compareDuplicateOccurrences), + })) + .filter((conflict) => conflict.occurrences.length > 1) + .sort((left, right) => left.exportName.localeCompare(right.exportName)) +} + const applyDuplicateExportRenameStrategy = (selectedModules, conflicts) => { const renameEntries = [] for (const conflict of conflicts) { for (const occurrence of conflict.occurrences) { - if (!occurrence.isSelected || occurrence.sourceType !== 'flat') { + if (!occurrence.isSelected) { continue } @@ -683,45 +734,45 @@ const compareDuplicateOccurrences = (left, right) => { } 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, - }), - ), - ], - })), - ) + const normalizedModuleExports = moduleExports.map((moduleExport) => ({ + fileName: moduleExport.fileName, + isSelected: false, + moduleName: moduleExport.fileName, + occurrences: moduleExport.exportNames.map((exportName) => + buildFlatExportOccurrence({ + exportName, + fileName: moduleExport.fileName, + isSelected: false, + moduleName: moduleExport.fileName, + path: null, + }), + ), + })) + const namespaceConflicts = findNamespaceExportConflicts(normalizedModuleExports) + const duplicateConflicts = findDuplicateModuleExports(normalizedModuleExports) - if (!duplicateConflicts.length) { + if (!namespaceConflicts.length && !duplicateConflicts.length) { return } throw createDuplicateModuleExportsError(duplicateConflicts, { - reason: 'unresolved', + reason: namespaceConflicts.length ? 'namespace-conflict' : 'unresolved', + supplementalConflicts: namespaceConflicts, }) } -const createDuplicateModuleExportsError = (conflicts, { reason = 'unresolved' } = {}) => { +const createDuplicateModuleExportsError = ( + conflicts, + { reason = 'unresolved', supplementalConflicts = [] } = {}, +) => { + const allConflicts = [...supplementalConflicts, ...conflicts] 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.' + : reason === 'namespace-conflict' + ? 'A flat API export still conflicts with a module namespace export. Regenerate the affected modules or run a full generate to repair stale generated files.' : '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 new Error( @@ -729,7 +780,7 @@ const createDuplicateModuleExportsError = (conflicts, { reason = 'unresolved' } 'Duplicate API export names detected across generated modules.', reasonMessage, '', - ...formatDuplicateConflictLines(conflicts), + ...formatDuplicateConflictLines(allConflicts), ].join('\n'), ) } @@ -1214,8 +1265,8 @@ const compareOperations = (left, right) => { return HTTP_METHOD_ORDER.indexOf(left.method) - HTTP_METHOD_ORDER.indexOf(right.method) } -const assignFunctionNames = (operations) => { - const usedNames = new Set() +const assignFunctionNames = (operations, reservedNames = new Set()) => { + const usedNames = new Set(reservedNames) for (const item of operations) { const baseName = ensureIdentifier(toCamelCase(getEndpointName(item.path))) diff --git a/test/generate.test.js b/test/generate.test.js index 6a20dea..66c66ae 100644 --- a/test/generate.test.js +++ b/test/generate.test.js @@ -276,7 +276,7 @@ test('generation can rename duplicate exports by appending module names', async } }) -test('generation renames flat exports that conflict with module namespace exports', async () => { +test('generation automatically avoids collisions with module namespace export names', async () => { const tempDir = await createTempDir() try { @@ -286,9 +286,9 @@ test('generation renames flat exports that conflict with module namespace export await writeJson(swaggerPath, { openapi: '3.0.0', paths: { - '/api/v1/Ranking': { + '/api/Values': { get: { - tags: ['Ranking'], + tags: ['Values'], responses: { 200: { description: 'OK' }, }, @@ -306,19 +306,65 @@ test('generation renames flat exports that conflict with module namespace export paramStyle: 'object', modules: [], cleanOutput: true, - resolveDuplicateExports: async ({ conflicts }) => { - assert.equal(conflicts.length, 1) - assert.equal(conflicts[0].exportName, 'rankingApi') - return { action: 'rename' } + }) + + const valuesContent = await readFile(path.join(outputDir, 'values.js')) + const indexContent = await readFile(path.join(outputDir, 'index.js')) + + assert.match(valuesContent, /const getValuesApi =/) + assert.match(indexContent, /export \* as valuesApi from ["']\.\/values["']/) + assert.match(indexContent, /export \* from ["']\.\/values["']/) + } finally { + await fs.rm(tempDir, { recursive: true, force: true }) + } +}) + +test('partial generation fails when retained generated files still collide with namespace exports', async () => { + const tempDir = await createTempDir() + + try { + const swaggerPath = path.join(tempDir, 'swagger.json') + const outputDir = path.join(tempDir, 'generated') + + await fs.mkdir(outputDir, { recursive: true }) + await fs.writeFile( + path.join(outputDir, 'values.js'), + `// Auto-generated. Do not edit manually. +// Module: Values +const valuesApi = () => null + +export { valuesApi } +`, + 'utf8', + ) + + await writeJson(swaggerPath, { + openapi: '3.0.0', + paths: { + '/api/v1/Alpha/GetThing': { + get: { + tags: ['Alpha'], + responses: { + 200: { description: 'OK' }, + }, + }, + }, }, }) - 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["']/) + await assert.rejects( + generateApiFiles({ + projectRoot: tempDir, + swaggerUrl: swaggerPath, + swaggerTimeoutMs: 1000, + outputDir, + requestImport: '../request', + paramStyle: 'object', + modules: ['Alpha'], + cleanOutput: true, + }), + /namespace export/, + ) } finally { await fs.rm(tempDir, { recursive: true, force: true }) }