fix: avoid false namespace export conflicts
This commit is contained in:
parent
89f43483ae
commit
57a2492408
14
README.md
14
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` 目录里还保留着旧规则生成的模块文件,也可能在部分生成时触发这类校验。遇到这种情况,直接做一次全量生成,或者把冲突模块一起重新生成即可。
|
||||
|
||||
所以工具会先把冲突明细列出来,内容会尽量包含:
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue