修复多层级返回递归问题

This commit is contained in:
DESKTOP-I3JPKHK\wy 2026-04-22 14:23:56 +08:00
parent 1fa9232c57
commit a261632131
2 changed files with 264 additions and 23 deletions

View File

@ -1088,40 +1088,111 @@ const buildResponsePropertyDocLines = (schema, schemas) => {
return []
}
const resolvedSchema = resolveSchema(schema, schemas)
const schemaContext = getSchemaTraversalContext(schema, schemas)
const resolvedSchema = schemaContext.resolvedSchema
if (resolvedSchema?.type === 'object' && resolvedSchema.properties) {
return buildResponseObjectPropertyDocLines(schema, schemas, 'Response fields:')
return [
'Response fields:',
...buildResponseObjectPropertyDocLines({
schema,
schemas,
seenRefs: schemaContext.seenRefs,
}),
]
}
if (resolvedSchema?.type === 'array') {
const itemSchema = resolvedSchema.items
const resolvedItemSchema = resolveSchema(itemSchema, schemas)
const itemLines = buildNestedResponsePropertyDocLines({
schema: resolvedSchema.items,
schemas,
seenRefs: schemaContext.seenRefs,
parentPath: '',
omitRootArrayMarker: true,
})
if (resolvedItemSchema?.type === 'object' && resolvedItemSchema.properties) {
return buildResponseObjectPropertyDocLines(itemSchema, schemas, 'Response item fields:')
if (itemLines.length) {
return ['Response item fields:', ...itemLines]
}
}
return []
}
const buildResponseObjectPropertyDocLines = (schema, schemas, title) => {
const resolvedSchema = resolveSchema(schema, schemas)
const buildResponseObjectPropertyDocLines = ({ schema, schemas, seenRefs = new Set(), parentPath = '' }) => {
const schemaContext = getSchemaTraversalContext(schema, schemas, seenRefs)
const resolvedSchema = schemaContext.resolvedSchema
if (!resolvedSchema?.properties) {
return []
}
const requiredKeys = new Set(resolvedSchema?.required || [])
const lines = [title]
const lines = []
for (const [propertyName, propertySchema] of Object.entries(resolvedSchema?.properties || {})) {
const type = getJsDocType(propertySchema, schemas)
const pathPrefix = parentPath ? `${parentPath}.` : ''
const propertyPath = `${pathPrefix}${propertyName}`
const type = getExpandedResponseJsDocType(propertySchema, schemas)
const description = escapeComment(propertySchema.description || '')
const suffix = requiredKeys.has(propertyName) ? '' : '?'
lines.push(`- ${propertyName}${suffix}: {${type}}${description ? ` ${description}` : ''}`)
lines.push(
`- ${propertyPath}${suffix}: ${formatResponseFieldType(type)}${description ? ` ${description}` : ''}`,
)
lines.push(
...buildNestedResponsePropertyDocLines({
schema: propertySchema,
schemas,
seenRefs: schemaContext.seenRefs,
parentPath: propertyPath,
}),
)
}
return lines
}
const buildNestedResponsePropertyDocLines = ({
schema,
schemas,
seenRefs = new Set(),
parentPath,
omitRootArrayMarker = false,
}) => {
const schemaContext = getSchemaTraversalContext(schema, schemas, seenRefs)
const resolvedSchema = schemaContext.resolvedSchema
if (!resolvedSchema || schemaContext.isCircular) {
return []
}
if (resolvedSchema.type === 'object' && resolvedSchema.properties) {
return buildResponseObjectPropertyDocLines({
schema,
schemas,
seenRefs: schemaContext.seenRefs,
parentPath,
})
}
if (resolvedSchema.type === 'array') {
return buildNestedResponsePropertyDocLines({
schema: resolvedSchema.items,
schemas,
seenRefs: schemaContext.seenRefs,
parentPath: parentPath
? `${parentPath}[]`
: omitRootArrayMarker
? ''
: '[]',
omitRootArrayMarker,
})
}
return []
}
const buildParameterDocLine = (rootName, parameter, schemas) => {
const type = getJsDocType(parameter.schema, schemas)
const accessor = parameter.required
@ -1185,28 +1256,60 @@ const getSchemaDisplayName = (schema, schemas) => {
}
const getExpandedResponseJsDocType = (schema, schemas) => {
const resolvedSchema = resolveSchema(schema, schemas)
return buildExpandedResponseJsDocType(schema, schemas)
}
const buildExpandedResponseJsDocType = (
schema,
schemas,
seenRefs = new Set(),
forceNullable = false,
) => {
const schemaContext = getSchemaTraversalContext(schema, schemas, seenRefs)
const resolvedSchema = schemaContext.resolvedSchema
const nullable = forceNullable || schema?.nullable || resolvedSchema?.nullable
if (!resolvedSchema) {
return 'any'
}
if (schemaContext.isCircular) {
return getJsDocType(schema, schemas)
}
if (resolvedSchema.type === 'array') {
return appendNullable(
`Array<${getExpandedResponseObjectJsDocType(resolvedSchema.items, schemas)}>`,
schema?.nullable || resolvedSchema.nullable,
`Array<${buildExpandedResponseJsDocType(resolvedSchema.items, schemas, schemaContext.seenRefs)}>`,
nullable,
)
}
if (resolvedSchema.type === 'object') {
return getExpandedResponseObjectJsDocType(schema, schemas)
return getExpandedResponseObjectJsDocType(schema, schemas, schemaContext.seenRefs, nullable)
}
return getJsDocType(schema, schemas)
switch (resolvedSchema.type) {
case 'integer':
case 'number':
return appendNullable('number', nullable)
case 'boolean':
return appendNullable('boolean', nullable)
case 'string':
return appendNullable('string', nullable)
default:
return appendNullable('any', nullable)
}
}
const getExpandedResponseObjectJsDocType = (schema, schemas) => {
const resolvedSchema = resolveSchema(schema, schemas)
const getExpandedResponseObjectJsDocType = (
schema,
schemas,
seenRefs = new Set(),
forceNullable = false,
) => {
const schemaContext = getSchemaTraversalContext(schema, schemas, seenRefs)
const resolvedSchema = schemaContext.resolvedSchema
const nullable = forceNullable || schema?.nullable || resolvedSchema?.nullable
if (!resolvedSchema) {
return 'any'
@ -1222,14 +1325,15 @@ const getExpandedResponseObjectJsDocType = (schema, schemas) => {
const renderedName = renderJsDocObjectPropertyName(propertyName)
const optionalToken = requiredKeys.has(propertyName) ? '' : '?'
return `${renderedName}${optionalToken}: ${getJsDocType(propertySchema, schemas)}`
return `${renderedName}${optionalToken}: ${buildExpandedResponseJsDocType(
propertySchema,
schemas,
schemaContext.seenRefs,
)}`
},
)
return appendNullable(
`{ ${propertyEntries.join(', ')} }`,
schema?.nullable || resolvedSchema.nullable,
)
return appendNullable(`{ ${propertyEntries.join(', ')} }`, nullable)
}
const getJsDocType = (schema, schemas) => {
@ -1276,10 +1380,51 @@ const renderJsDocObjectPropertyName = (propertyName) => {
: JSON.stringify(propertyName)
}
const formatResponseFieldType = (type) => {
return type.startsWith('{') ? type : `{${type}}`
}
const appendNullable = (type, nullable) => {
return nullable ? `${type} | null` : type
}
const getSchemaTraversalContext = (schema, schemas, seenRefs = new Set()) => {
if (!schema) {
return {
isCircular: false,
resolvedSchema: null,
seenRefs,
}
}
if (!schema.$ref) {
return {
isCircular: false,
resolvedSchema: resolveSchema(schema, schemas),
seenRefs,
}
}
const resolvedSchema = resolveSchema(schema, schemas)
if (seenRefs.has(schema.$ref)) {
return {
isCircular: true,
resolvedSchema,
seenRefs,
}
}
const nextSeenRefs = new Set(seenRefs)
nextSeenRefs.add(schema.$ref)
return {
isCircular: false,
resolvedSchema,
seenRefs: nextSeenRefs,
}
}
const resolveSchema = (schema, schemas) => {
if (!schema) {
return null

View File

@ -423,6 +423,102 @@ test('generation automatically avoids collisions with module namespace export na
}
})
test('response jsdoc expands nested array object fields', 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/GetRanking': {
get: {
tags: ['Alpha'],
responses: {
200: {
description: 'OK',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/NoteSelectRankingResultPageResponse',
},
},
},
},
},
},
},
},
components: {
schemas: {
NoteSelectRankingResultPageResponse: {
type: 'object',
properties: {
total: {
type: 'integer',
},
items: {
type: 'array',
items: {
$ref: '#/components/schemas/NoteSelectRankingResultItem',
},
},
},
},
NoteSelectRankingResultItem: {
type: 'object',
properties: {
noteId: {
type: 'integer',
},
author: {
$ref: '#/components/schemas/NoteAuthor',
},
},
},
NoteAuthor: {
type: 'object',
properties: {
nickName: {
type: 'string',
},
},
},
},
},
})
await generateApiFiles({
projectRoot: tempDir,
swaggerUrl: swaggerPath,
swaggerTimeoutMs: 1000,
outputDir,
requestImport: '../request',
paramStyle: 'object',
modules: [],
cleanOutput: true,
})
const alphaContent = await readFile(path.join(outputDir, 'alpha.js'))
assert.match(
alphaContent,
/@returns\s+\{Promise<\{\s*total\?: number,\s*items\?: Array<\{\s*noteId\?: number,\s*author\?: \{\s*nickName\?: string\s*\}\s*\}>\s*\}>\}\s+Returns NoteSelectRankingResultPageResponse/s,
)
assert.match(
alphaContent,
/- items\?: \{Array<\{\s*noteId\?: number,\s*author\?: \{\s*nickName\?: string\s*\}\s*\}>\}/s,
)
assert.match(alphaContent, /- items\[\]\.noteId\?: \{number\}/)
assert.match(alphaContent, /- items\[\]\.author\?: \{\s*nickName\?: string\s*\}/s)
assert.match(alphaContent, /- items\[\]\.author\.nickName\?: \{string\}/)
} 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()