From c6482c7f0897e3380e662a54cb38d73570b000bb Mon Sep 17 00:00:00 2001 From: "DESKTOP-I3JPKHK\\wy" <1111> Date: Wed, 22 Apr 2026 16:18:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E7=94=9F=E6=88=90=20?= =?UTF-8?q?API=20=E7=9A=84=20JSDoc=20=E7=BB=93=E6=9E=84=E4=B8=8E=E7=A8=B3?= =?UTF-8?q?=E5=AE=9A=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 递归展开请求体与响应体的对象类型注释 - 优化请求体/响应体字段说明的换行、分段与层级缩进 - 增加嵌套对象数组与循环引用 schema 的回归测试 --- src/core/generate.js | 153 ++++++++++++++++--------- test/generate.test.js | 251 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 345 insertions(+), 59 deletions(-) diff --git a/src/core/generate.js b/src/core/generate.js index 02d1f8d..8dac798 100644 --- a/src/core/generate.js +++ b/src/core/generate.js @@ -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 } diff --git a/test/generate.test.js b/test/generate.test.js index a698ed0..ff0b56c 100644 --- a/test/generate.test.js +++ b/test/generate.test.js @@ -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\}/) + 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\}/) + 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\s*\}>\}/s) + assert.match(treeContent, /- children\?: \{Array\}/) + 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\s*\}\}\s+data Request body/s, + ) + assert.match(treeContent, /Request body schema: TreeNode/) + assert.match(treeContent, /- data\.children\?: \{Array\}/) + assert.doesNotMatch(treeContent, /data\.children\[\]\./) } finally { await fs.rm(tempDir, { recursive: true, force: true }) }