feat: improve duplicate export conflict handling
This commit is contained in:
parent
486cea9913
commit
89f43483ae
64
README.md
64
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`
|
||||
退出生成,不写入本次结果
|
||||
|
||||
如果你选了自动重命名,但重命名后依然冲突,例如模块内已经存在同名函数,那么工具会直接失败并提示当前结果仍然无法安全生成。
|
||||
|
||||
## 本地开发
|
||||
|
||||
在工具仓库里直接查看帮助:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue