feat: 优化生成 API 的 JSDoc 结构与稳定性

- 递归展开请求体与响应体的对象类型注释

- 优化请求体/响应体字段说明的换行、分段与层级缩进

- 增加嵌套对象数组与循环引用 schema 的回归测试
This commit is contained in:
DESKTOP-I3JPKHK\wy 2026-04-22 16:18:36 +08:00
parent a261632131
commit c6482c7f08
2 changed files with 345 additions and 59 deletions

View File

@ -906,27 +906,52 @@ const generateOperationBlock = (
}
if (hasBody) {
const bodyType = getJsDocType(bodySchemaInfo.schema, schemas)
const bodyType = getExpandedJsDocType(bodySchemaInfo.schema, schemas)
const bodySchemaName = getSchemaDisplayName(bodySchemaInfo.schema, schemas)
const bodyPropertyLines = buildRequestBodyPropertyDocLines({
rootName: signatureInfo.bodyArgName,
schema: bodySchemaInfo.schema,
schemas,
})
if (hasParams) {
jsDocLines.push(' *')
}
jsDocLines.push(` * @param {${bodyType}} ${signatureInfo.bodyArgName} Request body`)
for (const propertyLine of buildSchemaPropertyDocLines(
signatureInfo.bodyArgName,
bodySchemaInfo.schema,
schemas,
)) {
if (bodySchemaName) {
jsDocLines.push(` * Request body schema: ${bodySchemaName}`)
}
if (bodyPropertyLines.length) {
jsDocLines.push(' *')
}
for (const propertyLine of bodyPropertyLines) {
jsDocLines.push(` * ${propertyLine}`)
}
}
const responseSchemaInfo = getResponseSchemaInfo(operation.responses, schemas)
const returnType = `Promise<${getExpandedResponseJsDocType(responseSchemaInfo?.schema, schemas)}>`
const returnDescription = responseSchemaInfo?.displayName
? ` Returns ${responseSchemaInfo.displayName}`
: ''
const returnType = `Promise<${getExpandedJsDocType(responseSchemaInfo?.schema, schemas)}>`
const responsePropertyLines = buildResponsePropertyDocLines(responseSchemaInfo?.schema, schemas)
jsDocLines.push(` * @returns {${returnType}}${returnDescription}`)
if (hasParams || hasBody) {
jsDocLines.push(' *')
}
for (const propertyLine of buildResponsePropertyDocLines(responseSchemaInfo?.schema, schemas)) {
jsDocLines.push(` * @returns {${returnType}}`)
if (responseSchemaInfo?.displayName) {
jsDocLines.push(` * Response schema: ${responseSchemaInfo.displayName}`)
}
if (responsePropertyLines.length) {
jsDocLines.push(' *')
}
for (const propertyLine of responsePropertyLines) {
jsDocLines.push(` * ${propertyLine}`)
}
@ -1084,6 +1109,31 @@ const getResponseSchemaInfo = (responses, schemas) => {
}
const buildResponsePropertyDocLines = (schema, schemas) => {
return buildStructuredPropertyDocLines({
title: 'Response fields:',
itemTitle: 'Response item fields:',
schema,
schemas,
})
}
const buildRequestBodyPropertyDocLines = ({ rootName, schema, schemas }) => {
return buildStructuredPropertyDocLines({
title: 'Request body fields:',
itemTitle: 'Request body item fields:',
schema,
schemas,
rootPath: rootName,
})
}
const buildStructuredPropertyDocLines = ({
title,
itemTitle,
schema,
schemas,
rootPath = '',
}) => {
if (!schema) {
return []
}
@ -1093,33 +1143,42 @@ const buildResponsePropertyDocLines = (schema, schemas) => {
if (resolvedSchema?.type === 'object' && resolvedSchema.properties) {
return [
'Response fields:',
...buildResponseObjectPropertyDocLines({
title,
...buildStructuredObjectPropertyDocLines({
schema,
schemas,
depth: 0,
parentPath: rootPath,
seenRefs: schemaContext.seenRefs,
}),
]
}
if (resolvedSchema?.type === 'array') {
const itemLines = buildNestedResponsePropertyDocLines({
const itemLines = buildNestedStructuredPropertyDocLines({
schema: resolvedSchema.items,
schemas,
depth: 0,
seenRefs: schemaContext.seenRefs,
parentPath: '',
omitRootArrayMarker: true,
parentPath: rootPath,
omitRootArrayMarker: rootPath === '',
})
if (itemLines.length) {
return ['Response item fields:', ...itemLines]
return [itemTitle, ...itemLines]
}
}
return []
}
const buildResponseObjectPropertyDocLines = ({ schema, schemas, seenRefs = new Set(), parentPath = '' }) => {
const buildStructuredObjectPropertyDocLines = ({
schema,
schemas,
depth = 0,
seenRefs = new Set(),
parentPath = '',
}) => {
const schemaContext = getSchemaTraversalContext(schema, schemas, seenRefs)
const resolvedSchema = schemaContext.resolvedSchema
@ -1133,17 +1192,18 @@ const buildResponseObjectPropertyDocLines = ({ schema, schemas, seenRefs = new S
for (const [propertyName, propertySchema] of Object.entries(resolvedSchema?.properties || {})) {
const pathPrefix = parentPath ? `${parentPath}.` : ''
const propertyPath = `${pathPrefix}${propertyName}`
const type = getExpandedResponseJsDocType(propertySchema, schemas)
const type = getJsDocType(propertySchema, schemas)
const description = escapeComment(propertySchema.description || '')
const suffix = requiredKeys.has(propertyName) ? '' : '?'
lines.push(
`- ${propertyPath}${suffix}: ${formatResponseFieldType(type)}${description ? ` ${description}` : ''}`,
`${buildResponseDocIndent(depth)}- ${propertyPath}${suffix}: ${formatResponseFieldType(type)}${description ? ` ${description}` : ''}`,
)
lines.push(
...buildNestedResponsePropertyDocLines({
...buildNestedStructuredPropertyDocLines({
schema: propertySchema,
schemas,
depth: depth + 1,
seenRefs: schemaContext.seenRefs,
parentPath: propertyPath,
}),
@ -1153,9 +1213,10 @@ const buildResponseObjectPropertyDocLines = ({ schema, schemas, seenRefs = new S
return lines
}
const buildNestedResponsePropertyDocLines = ({
const buildNestedStructuredPropertyDocLines = ({
schema,
schemas,
depth = 0,
seenRefs = new Set(),
parentPath,
omitRootArrayMarker = false,
@ -1168,18 +1229,20 @@ const buildNestedResponsePropertyDocLines = ({
}
if (resolvedSchema.type === 'object' && resolvedSchema.properties) {
return buildResponseObjectPropertyDocLines({
return buildStructuredObjectPropertyDocLines({
schema,
schemas,
depth,
seenRefs: schemaContext.seenRefs,
parentPath,
})
}
if (resolvedSchema.type === 'array') {
return buildNestedResponsePropertyDocLines({
return buildNestedStructuredPropertyDocLines({
schema: resolvedSchema.items,
schemas,
depth,
seenRefs: schemaContext.seenRefs,
parentPath: parentPath
? `${parentPath}[]`
@ -1211,28 +1274,6 @@ const buildPositionalParameterDocLine = (parameter, schemas) => {
return `@param {${type}} ${accessor}${description ? ` ${description}` : ''}`
}
const buildSchemaPropertyDocLines = (rootName, schema, schemas) => {
const resolvedSchema = resolveSchema(schema, schemas)
const requiredKeys = new Set(resolvedSchema?.required || [])
const propertyLines = []
if (!resolvedSchema?.properties) {
return propertyLines
}
for (const [propertyName, propertySchema] of Object.entries(resolvedSchema.properties)) {
const type = getJsDocType(propertySchema, schemas)
const accessor = requiredKeys.has(propertyName)
? `${rootName}.${propertyName}`
: `[${rootName}.${propertyName}]`
const description = escapeComment(propertySchema.description || '')
propertyLines.push(`@param {${type}} ${accessor}${description ? ` ${description}` : ''}`)
}
return propertyLines
}
const getSchemaDisplayName = (schema, schemas) => {
if (!schema) {
return ''
@ -1255,11 +1296,11 @@ const getSchemaDisplayName = (schema, schemas) => {
return ''
}
const getExpandedResponseJsDocType = (schema, schemas) => {
return buildExpandedResponseJsDocType(schema, schemas)
const getExpandedJsDocType = (schema, schemas) => {
return buildExpandedJsDocType(schema, schemas)
}
const buildExpandedResponseJsDocType = (
const buildExpandedJsDocType = (
schema,
schemas,
seenRefs = new Set(),
@ -1279,13 +1320,13 @@ const buildExpandedResponseJsDocType = (
if (resolvedSchema.type === 'array') {
return appendNullable(
`Array<${buildExpandedResponseJsDocType(resolvedSchema.items, schemas, schemaContext.seenRefs)}>`,
`Array<${buildExpandedJsDocType(resolvedSchema.items, schemas, schemaContext.seenRefs)}>`,
nullable,
)
}
if (resolvedSchema.type === 'object') {
return getExpandedResponseObjectJsDocType(schema, schemas, schemaContext.seenRefs, nullable)
return getExpandedObjectJsDocType(schema, schemas, schemaContext.seenRefs, nullable)
}
switch (resolvedSchema.type) {
@ -1301,7 +1342,7 @@ const buildExpandedResponseJsDocType = (
}
}
const getExpandedResponseObjectJsDocType = (
const getExpandedObjectJsDocType = (
schema,
schemas,
seenRefs = new Set(),
@ -1325,7 +1366,7 @@ const getExpandedResponseObjectJsDocType = (
const renderedName = renderJsDocObjectPropertyName(propertyName)
const optionalToken = requiredKeys.has(propertyName) ? '' : '?'
return `${renderedName}${optionalToken}: ${buildExpandedResponseJsDocType(
return `${renderedName}${optionalToken}: ${buildExpandedJsDocType(
propertySchema,
schemas,
schemaContext.seenRefs,
@ -1384,6 +1425,10 @@ const formatResponseFieldType = (type) => {
return type.startsWith('{') ? type : `{${type}}`
}
const buildResponseDocIndent = (depth = 0) => {
return ' '.repeat(Math.max(0, depth))
}
const appendNullable = (type, nullable) => {
return nullable ? `${type} | null` : type
}

View File

@ -505,15 +505,256 @@ test('response jsdoc expands nested array object fields', async () => {
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,
/@returns\s+\{Promise<\{\s*total\?: number,\s*items\?: Array<\{\s*noteId\?: number,\s*author\?: \{\s*nickName\?: string\s*\}\s*\}>\s*\}>\}/s,
)
assert.match(alphaContent, /Response schema: NoteSelectRankingResultPageResponse/)
assert.match(alphaContent, /\*\s*\n \* Response fields:/)
assert.match(alphaContent, /- items\?: \{Array<Object>\}/)
assert.match(alphaContent, / - items\[\]\.noteId\?: \{number\}/)
assert.match(alphaContent, / - items\[\]\.author\?: \{Object\}/)
assert.match(alphaContent, / - items\[\]\.author\.nickName\?: \{string\}/)
} finally {
await fs.rm(tempDir, { recursive: true, force: true })
}
})
test('request body 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/Create': {
post: {
tags: ['Alpha'],
requestBody: {
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/CreatePayload',
},
},
},
},
responses: {
200: { description: 'OK' },
},
},
},
},
components: {
schemas: {
CreatePayload: {
type: 'object',
properties: {
title: {
type: 'string',
},
items: {
type: 'array',
items: {
$ref: '#/components/schemas/CreateItem',
},
},
},
},
CreateItem: {
type: 'object',
properties: {
id: {
type: 'integer',
},
author: {
$ref: '#/components/schemas/Author',
},
},
},
Author: {
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,
/- items\?: \{Array<\{\s*noteId\?: number,\s*author\?: \{\s*nickName\?: string\s*\}\s*\}>\}/s,
/@param\s+\{\{\s*title\?: string,\s*items\?: Array<\{\s*id\?: number,\s*author\?: \{\s*nickName\?: string\s*\}\s*\}>\s*\}\}\s+data Request body/s,
)
assert.match(alphaContent, /- items\[\]\.noteId\?: \{number\}/)
assert.match(alphaContent, /- items\[\]\.author\?: \{\s*nickName\?: string\s*\}/s)
assert.match(alphaContent, /- items\[\]\.author\.nickName\?: \{string\}/)
assert.match(alphaContent, /Request body schema: CreatePayload/)
assert.match(alphaContent, /\*\s*\n \* Request body fields:/)
assert.match(alphaContent, /- data\.title\?: \{string\}/)
assert.match(alphaContent, /- data\.items\?: \{Array<Object>\}/)
assert.match(alphaContent, / - data\.items\[\]\.id\?: \{number\}/)
assert.match(alphaContent, / - data\.items\[\]\.author\?: \{Object\}/)
assert.match(alphaContent, / - data\.items\[\]\.author\.nickName\?: \{string\}/)
} finally {
await fs.rm(tempDir, { recursive: true, force: true })
}
})
test('response jsdoc handles circular schemas without recursion errors', 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/Tree/GetNode': {
get: {
tags: ['Tree'],
responses: {
200: {
description: 'OK',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/TreeNode',
},
},
},
},
},
},
},
},
components: {
schemas: {
TreeNode: {
type: 'object',
properties: {
id: {
type: 'integer',
},
children: {
type: 'array',
items: {
$ref: '#/components/schemas/TreeNode',
},
},
},
},
},
},
})
await assert.doesNotReject(
generateApiFiles({
projectRoot: tempDir,
swaggerUrl: swaggerPath,
swaggerTimeoutMs: 1000,
outputDir,
requestImport: '../request',
paramStyle: 'object',
modules: [],
cleanOutput: true,
}),
)
const treeContent = await readFile(path.join(outputDir, 'tree.js'))
assert.match(treeContent, /@returns\s+\{Promise<\{\s*id\?: number,\s*children\?: Array<Object>\s*\}>\}/s)
assert.match(treeContent, /- children\?: \{Array<Object>\}/)
assert.doesNotMatch(treeContent, /children\[\]\./)
} finally {
await fs.rm(tempDir, { recursive: true, force: true })
}
})
test('request body jsdoc handles circular schemas without recursion errors', 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/Tree/CreateNode': {
post: {
tags: ['Tree'],
requestBody: {
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/TreeNode',
},
},
},
},
responses: {
200: { description: 'OK' },
},
},
},
},
components: {
schemas: {
TreeNode: {
type: 'object',
properties: {
id: {
type: 'integer',
},
children: {
type: 'array',
items: {
$ref: '#/components/schemas/TreeNode',
},
},
},
},
},
},
})
await assert.doesNotReject(
generateApiFiles({
projectRoot: tempDir,
swaggerUrl: swaggerPath,
swaggerTimeoutMs: 1000,
outputDir,
requestImport: '../request',
paramStyle: 'object',
modules: [],
cleanOutput: true,
}),
)
const treeContent = await readFile(path.join(outputDir, 'tree.js'))
assert.match(
treeContent,
/@param\s+\{\{\s*id\?: number,\s*children\?: Array<Object>\s*\}\}\s+data Request body/s,
)
assert.match(treeContent, /Request body schema: TreeNode/)
assert.match(treeContent, /- data\.children\?: \{Array<Object>\}/)
assert.doesNotMatch(treeContent, /data\.children\[\]\./)
} finally {
await fs.rm(tempDir, { recursive: true, force: true })
}