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`
|
- `curriculum.js`
|
||||||
某个模块的 API 方法集合。
|
某个模块的 API 方法集合。
|
||||||
- `generated/index.js`
|
- `generated/index.js`
|
||||||
汇总导出每个模块,同时提供“命名空间导出”和“直接函数导出”。
|
汇总导出每个模块,同时提供“命名空间导出”和“扁平函数导出”。
|
||||||
- `src/api/aixue/index.js`
|
- `src/api/aixue/index.js`
|
||||||
业务侧入口文件,`sync` 会在里面维护一个受管区块。
|
业务侧入口文件,`sync` 会在里面维护一个受管区块。
|
||||||
|
|
||||||
|
|
@ -337,11 +337,28 @@ src/api/aixue/
|
||||||
export * as classAssignmentApi from './class-assignment'
|
export * as classAssignmentApi from './class-assignment'
|
||||||
export * as curriculumApi from './curriculum'
|
export * as curriculumApi from './curriculum'
|
||||||
|
|
||||||
export { getClassAssignmentListApi } from './class-assignment'
|
export * from './class-assignment'
|
||||||
export { getCurriculumListApi } from './curriculum'
|
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:
|
// generated/index.js content:
|
||||||
// export * as curriculumApi from './curriculum'
|
// export * as curriculumApi from './curriculum'
|
||||||
// export * as rankingApi from './ranking'
|
// export * as rankingApi from './ranking'
|
||||||
|
// export * from './curriculum'
|
||||||
|
// export * from './ranking'
|
||||||
export * from './generated'
|
export * from './generated'
|
||||||
// AUTO-GENERATED API EXPORTS END
|
// AUTO-GENERATED API EXPORTS END
|
||||||
```
|
```
|
||||||
|
|
@ -435,6 +454,10 @@ run-yx-generate-api.bat Curriculum
|
||||||
run-yx-generate-api.bat --modules=Curriculum,class-assignment
|
run-yx-generate-api.bat --modules=Curriculum,class-assignment
|
||||||
```
|
```
|
||||||
|
|
||||||
|
无论你是直接执行 `npx yx-generate-api gen`,还是走 `.bat`,后续真正执行的都是同一套 CLI。
|
||||||
|
|
||||||
|
所以如果遇到 API 函数名冲突,两边都会出现同样的冲突提示和同样的两个处理选项。
|
||||||
|
|
||||||
## 常见问题
|
## 常见问题
|
||||||
|
|
||||||
### 1. 为什么 `gen` 没有同步外部 `index.js`
|
### 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 process from 'node:process'
|
||||||
|
import { createInterface } from 'node:readline/promises'
|
||||||
|
|
||||||
import { parseCliArgs, getFlagValue, getFlagValues, hasFlag } from '../core/args.js'
|
import { parseCliArgs, getFlagValue, getFlagValues, hasFlag } from '../core/args.js'
|
||||||
import {
|
import {
|
||||||
|
|
@ -60,6 +61,7 @@ export const resolveGenerateCommandContext = async (args) => {
|
||||||
? Boolean(getFlagValue(parsedArgs.flags, 'clean'))
|
? Boolean(getFlagValue(parsedArgs.flags, 'clean'))
|
||||||
: projectConfig.cleanOutput,
|
: projectConfig.cleanOutput,
|
||||||
modules: moduleArgs,
|
modules: moduleArgs,
|
||||||
|
resolveDuplicateExports: createDuplicateExportResolver(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -114,3 +116,62 @@ const resolveSwaggerTimeoutMs = (flags, fallbackValue) => {
|
||||||
|
|
||||||
return parsedValue
|
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,
|
paramStyle,
|
||||||
modules,
|
modules,
|
||||||
cleanOutput,
|
cleanOutput,
|
||||||
|
resolveDuplicateExports,
|
||||||
}) => {
|
}) => {
|
||||||
const swagger = await fetchSwaggerJson(swaggerUrl, swaggerTimeoutMs)
|
const swagger = await fetchSwaggerJson(swaggerUrl, swaggerTimeoutMs)
|
||||||
const moduleMap = buildModuleMap(swagger)
|
const moduleMap = buildModuleMap(swagger)
|
||||||
const selectedModules = resolveSelectedModules(moduleMap, modules)
|
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 })
|
await fs.mkdir(outputDir, { recursive: true })
|
||||||
|
|
||||||
if (cleanOutput && modules.length === 0) {
|
if (shouldRemoveStaleGeneratedFiles) {
|
||||||
await cleanStaleGeneratedFiles({
|
await cleanStaleGeneratedFiles({
|
||||||
outputDir,
|
outputDir,
|
||||||
nextModuleFileNames: selectedModules.map((moduleInfo) => `${moduleInfo.fileName}.js`),
|
nextModuleFileNames: [...selectedModuleFileNames],
|
||||||
projectRoot,
|
projectRoot,
|
||||||
})
|
})
|
||||||
} else if (cleanOutput && modules.length > 0) {
|
} 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 buildModuleMap = (swagger) => {
|
||||||
const moduleMap = new Map()
|
const moduleMap = new Map()
|
||||||
|
|
||||||
|
|
@ -393,11 +526,11 @@ const generateIndexFile = async ({ outDir, swaggerUrl }) => {
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const namedExportEntries = resolveIndexNamedExportEntries(moduleExports)
|
assertNoDuplicateModuleExports(moduleExports)
|
||||||
const lines = [
|
const lines = [
|
||||||
AUTO_GENERATED_BANNER,
|
AUTO_GENERATED_BANNER,
|
||||||
`// Swagger: ${swaggerUrl}`,
|
`// 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}'`)
|
lines.push(`export * as ${buildModuleNamespaceExportName(fileName)} from './${fileName}'`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (moduleExports.length && namedExportEntries.length) {
|
if (moduleExports.length) {
|
||||||
lines.push('')
|
lines.push('')
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of namedExportEntries) {
|
for (const { fileName } of moduleExports) {
|
||||||
lines.push(buildIndexNamedExportLine(entry))
|
lines.push(`export * from './${fileName}'`)
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push('')
|
lines.push('')
|
||||||
|
|
@ -420,7 +553,12 @@ const generateIndexFile = async ({ outDir, swaggerUrl }) => {
|
||||||
|
|
||||||
const readGeneratedModuleExportNames = async (filePath) => {
|
const readGeneratedModuleExportNames = async (filePath) => {
|
||||||
const content = await fs.readFile(filePath, 'utf8')
|
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) {
|
if (!match) {
|
||||||
return []
|
return []
|
||||||
|
|
@ -432,49 +570,189 @@ const readGeneratedModuleExportNames = async (filePath) => {
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveIndexNamedExportEntries = (moduleExports) => {
|
const readGeneratedModuleName = (content) => {
|
||||||
const originalNameCounts = new Map()
|
const match = String(content || '').match(/\/\/ Module:\s*(.+)/)
|
||||||
const flatEntries = moduleExports.flatMap(({ fileName, exportNames }) => {
|
|
||||||
return exportNames.map((exportName) => ({
|
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,
|
exportName,
|
||||||
fileName,
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyDuplicateExportRenameStrategy = (selectedModules, conflicts) => {
|
||||||
|
const renameEntries = []
|
||||||
|
|
||||||
|
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
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const entry of flatEntries) {
|
if (!operationItem) {
|
||||||
originalNameCounts.set(entry.exportName, (originalNameCounts.get(entry.exportName) || 0) + 1)
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const usedTargetNames = new Set()
|
const nextFunctionName = buildConflictResolvedFunctionName(
|
||||||
|
operationItem.functionName,
|
||||||
|
moduleInfo.moduleName,
|
||||||
|
)
|
||||||
|
|
||||||
return flatEntries.map((entry) => {
|
if (operationItem.functionName === nextFunctionName) {
|
||||||
const hasCollision = (originalNameCounts.get(entry.exportName) || 0) > 1
|
continue
|
||||||
const baseTargetName = hasCollision
|
|
||||||
? buildModuleScopedExportName(entry.fileName, entry.exportName)
|
|
||||||
: entry.exportName
|
|
||||||
let targetName = baseTargetName
|
|
||||||
let sequence = 2
|
|
||||||
|
|
||||||
while (usedTargetNames.has(targetName)) {
|
|
||||||
targetName = `${baseTargetName}${sequence}`
|
|
||||||
sequence += 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
usedTargetNames.add(targetName)
|
renameEntries.push({
|
||||||
|
from: `${operationItem.functionName}Api`,
|
||||||
|
moduleName: moduleInfo.moduleName,
|
||||||
|
path: operationItem.path,
|
||||||
|
to: `${nextFunctionName}Api`,
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
operationItem.functionName = nextFunctionName
|
||||||
...entry,
|
|
||||||
targetName,
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return renameEntries.sort((left, right) => left.to.localeCompare(right.to))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }) => {
|
const createDuplicateModuleExportsError = (conflicts, { reason = 'unresolved' } = {}) => {
|
||||||
if (exportName === targetName) {
|
const reasonMessage =
|
||||||
return `export { ${exportName} } from './${fileName}'`
|
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 new Error(
|
||||||
|
[
|
||||||
|
'Duplicate API export names detected across generated modules.',
|
||||||
|
reasonMessage,
|
||||||
|
'',
|
||||||
|
...formatDuplicateConflictLines(conflicts),
|
||||||
|
].join('\n'),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return `export { ${exportName} as ${targetName} } from './${fileName}'`
|
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 }) => {
|
const generateModuleFile = ({ moduleInfo, paramStyle, schemas, requestImport, swaggerUrl }) => {
|
||||||
|
|
@ -1054,10 +1332,6 @@ const buildModuleNamespaceExportName = (fileName) => {
|
||||||
return `${toCamelCase(fileName)}Api`
|
return `${toCamelCase(fileName)}Api`
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildModuleScopedExportName = (fileName, exportName) => {
|
|
||||||
return `${toCamelCase(fileName)}${capitalize(exportName)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const splitWords = (value) => {
|
const splitWords = (value) => {
|
||||||
return String(value || '')
|
return String(value || '')
|
||||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
.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')
|
const externalIndexContent = await fs.readFile(externalIndexFile, 'utf8')
|
||||||
|
|
||||||
assert.match(generatedIndexContent, /export \* as alphaApi from ["']\.\/alpha["']/)
|
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, /AUTO-GENERATED API EXPORTS START/)
|
||||||
assert.match(externalIndexContent, /export \* from ["']\.\/generated["']/)
|
assert.match(externalIndexContent, /export \* from ["']\.\/generated["']/)
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(tempDir, { recursive: true, force: true })
|
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()
|
const tempDir = await createTempDir()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -169,7 +169,7 @@ test('generated index includes namespace exports and collision-safe named export
|
||||||
await writeJson(swaggerPath, {
|
await writeJson(swaggerPath, {
|
||||||
openapi: '3.0.0',
|
openapi: '3.0.0',
|
||||||
paths: {
|
paths: {
|
||||||
'/api/v1/Alpha/GetList': {
|
'/api/v1/Alpha/GetThing': {
|
||||||
get: {
|
get: {
|
||||||
tags: ['Alpha'],
|
tags: ['Alpha'],
|
||||||
responses: {
|
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 alphaApi from ["']\.\/alpha["']/)
|
||||||
assert.match(indexContent, /export \* as betaApi from ["']\.\/beta["']/)
|
assert.match(indexContent, /export \* as betaApi from ["']\.\/beta["']/)
|
||||||
assert.match(indexContent, /export \{ getListApi as alphaGetListApi \} from ["']\.\/alpha["']/)
|
assert.match(indexContent, /export \* from ["']\.\/alpha["']/)
|
||||||
assert.match(indexContent, /export \{ getListApi as betaGetListApi \} from ["']\.\/beta["']/)
|
assert.match(indexContent, /export \* from ["']\.\/beta["']/)
|
||||||
assert.match(indexContent, /export \{ getDetailApi \} 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 {
|
} finally {
|
||||||
await fs.rm(tempDir, { recursive: true, force: true })
|
await fs.rm(tempDir, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue