feat: 优化生成 API 的 JSDoc 结构与稳定性
- 递归展开请求体与响应体的对象类型注释 - 优化请求体/响应体字段说明的换行、分段与层级缩进 - 增加嵌套对象数组与循环引用 schema 的回归测试
This commit is contained in:
parent
a261632131
commit
c6482c7f08
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue