diff --git a/src/core/generate.js b/src/core/generate.js index 15b8c10..02d1f8d 100644 --- a/src/core/generate.js +++ b/src/core/generate.js @@ -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 diff --git a/test/generate.test.js b/test/generate.test.js index e5ce72f..a698ed0 100644 --- a/test/generate.test.js +++ b/test/generate.test.js @@ -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()