feat: improve duplicate export conflict handling

This commit is contained in:
DESKTOP-I3JPKHK\wy 2026-04-21 18:22:41 +08:00
parent 486cea9913
commit 89f43483ae
5 changed files with 723 additions and 53 deletions

View File

@ -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`
退出生成,不写入本次结果
如果你选了自动重命名,但重命名后依然冲突,例如模块内已经存在同名函数,那么工具会直接失败并提示当前结果仍然无法安全生成。
## 本地开发
在工具仓库里直接查看帮助:

View File

@ -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()
}
}
}

View File

@ -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')

View File

@ -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 })
}
})

View File

@ -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 })
}