diff --git a/packages/openapi-generator/src/comments.ts b/packages/openapi-generator/src/comments.ts index 54723da8..145d4aa0 100644 --- a/packages/openapi-generator/src/comments.ts +++ b/packages/openapi-generator/src/comments.ts @@ -93,7 +93,12 @@ export function combineComments(schema: Schema): Block | undefined { result.description = comments[0].description; } - // Add all seen tags, problems, and source comments to the result + // Only use the first comment's source to avoid duplicates when parsing + if (comments[0]?.source) { + result.source = comments[0].source; + } + + // Add all seen tags and problems to the result for (const comment of comments) { for (const tag of comment.tags) { // Only add the tag if we haven't seen it before. Otherwise, the higher level tag is 'probably' the more relevant tag. @@ -104,7 +109,6 @@ export function combineComments(schema: Schema): Block | undefined { } result.problems.push(...comment.problems); - result.source.push(...comment.source); } return result; diff --git a/packages/openapi-generator/src/openapi.ts b/packages/openapi-generator/src/openapi.ts index 652a4853..68dafe0c 100644 --- a/packages/openapi-generator/src/openapi.ts +++ b/packages/openapi-generator/src/openapi.ts @@ -266,31 +266,47 @@ export function schemaToOpenAPI( const emptyBlock: Block = { description: '', tags: [], source: [], problems: [] }; const jsdoc = parseCommentBlock(schema.comment ?? emptyBlock); - const defaultValue = jsdoc?.tags?.default ?? schema.default; - const example = jsdoc?.tags?.example ?? schema.example; - const maxLength = jsdoc?.tags?.maxLength ?? schema.maxLength; - const minLength = jsdoc?.tags?.minLength ?? schema.minLength; - const pattern = jsdoc?.tags?.pattern ?? schema.pattern; - const minimum = jsdoc?.tags?.minimum ?? schema.maximum; - const maximum = jsdoc?.tags?.maximum ?? schema.minimum; - const minItems = jsdoc?.tags?.minItems ?? schema.minItems; - const maxItems = jsdoc?.tags?.maxItems ?? schema.maxItems; - const minProperties = jsdoc?.tags?.minProperties ?? schema.minProperties; - const maxProperties = jsdoc?.tags?.maxProperties ?? schema.maxProperties; - const exclusiveMinimum = jsdoc?.tags?.exclusiveMinimum ?? schema.exclusiveMinimum; - const exclusiveMaximum = jsdoc?.tags?.exclusiveMaximum ?? schema.exclusiveMaximum; - const multipleOf = jsdoc?.tags?.multipleOf ?? schema.multipleOf; - const uniqueItems = jsdoc?.tags?.uniqueItems ?? schema.uniqueItems; - const readOnly = jsdoc?.tags?.readOnly ?? schema.readOnly; - const writeOnly = jsdoc?.tags?.writeOnly ?? schema.writeOnly; - const format = jsdoc?.tags?.format ?? schema.format ?? schema.format; - const title = jsdoc?.tags?.title ?? schema.title; + // Use Block.tags directly for combined comments to preserve tags from all schemas + const blockTags = + schema.comment?.tags?.reduce( + (acc, tag) => { + const tagName = tag.tag.replace(/^@/, ''); + acc[tagName] = `${tag.name} ${tag.description}`.trim(); + return acc; + }, + {} as Record, + ) ?? {}; + const tags = { ...blockTags, ...jsdoc?.tags }; + + const defaultValue = tags?.default ?? schema.default; + const example = tags?.example ?? schema.example; + const maxLength = tags?.maxLength ?? schema.maxLength; + const minLength = tags?.minLength ?? schema.minLength; + const pattern = tags?.pattern ?? schema.pattern; + const minimum = tags?.minimum ?? schema.maximum; + const maximum = tags?.maximum ?? schema.minimum; + const minItems = tags?.minItems ?? schema.minItems; + const maxItems = tags?.maxItems ?? schema.maxItems; + const minProperties = tags?.minProperties ?? schema.minProperties; + const maxProperties = tags?.maxProperties ?? schema.maxProperties; + const exclusiveMinimum = tags?.exclusiveMinimum ?? schema.exclusiveMinimum; + const exclusiveMaximum = tags?.exclusiveMaximum ?? schema.exclusiveMaximum; + const multipleOf = tags?.multipleOf ?? schema.multipleOf; + const uniqueItems = tags?.uniqueItems ?? schema.uniqueItems; + const readOnly = tags?.readOnly ?? schema.readOnly; + const writeOnly = tags?.writeOnly ?? schema.writeOnly; + const format = tags?.format ?? schema.format; + const title = tags?.title ?? schema.title; const keys = Object.keys(jsdoc?.tags || {}); const deprecated = keys.includes('deprecated') || !!schema.deprecated; const isPrivate = keys.includes('private'); - const description = schema.comment?.description ?? schema.description; + // Combine summary and description for markdown support + const description = + jsdoc?.summary && jsdoc?.description + ? `${jsdoc.summary.trim()}\n\n${jsdoc.description.trim()}` + : jsdoc?.summary?.trim() ?? jsdoc?.description?.trim() ?? schema.description; const defaultOpenAPIObject = { ...(defaultValue ? { default: parseField(schema, defaultValue) } : {}), @@ -390,11 +406,22 @@ function routeToOpenAPI(route: Route): [string, string, OpenAPIV3.OperationObjec delete schema['x-internal']; } + const paramJsDoc = + p.schema?.comment !== undefined + ? parseCommentBlock(p.schema.comment) + : undefined; + + // Combine summary and description for markdown support; use empty string for tag-only comments + const paramDescription = + paramJsDoc?.summary && paramJsDoc?.description + ? `${paramJsDoc.summary.trim()}\n\n${paramJsDoc.description.trim()}` + : paramJsDoc?.summary?.trim() ?? + paramJsDoc?.description?.trim() ?? + (p.schema?.comment !== undefined ? '' : undefined); + return { name: p.name, - ...(p.schema?.comment?.description !== undefined - ? { description: p.schema.comment.description } - : {}), + ...(paramDescription !== undefined ? { description: paramDescription } : {}), in: p.type, ...(isPrivate ? { 'x-internal': true } : {}), ...(p.required ? { required: true } : {}), diff --git a/packages/openapi-generator/test/openapi/comments.test.ts b/packages/openapi-generator/test/openapi/comments.test.ts index 5b50e16e..9e13bd28 100644 --- a/packages/openapi-generator/test/openapi/comments.test.ts +++ b/packages/openapi-generator/test/openapi/comments.test.ts @@ -1682,3 +1682,139 @@ testCase( }, }, ); + +const ROUTE_WITH_MARKDOWN_BULLET_LISTS = ` +import * as t from 'io-ts'; +import * as h from '@api-ts/io-ts-http'; + +/** + * Route to test markdown formatting with bullet lists + * + * @operationId api.v1.markdownBullets + * @tag Test Routes + */ +export const route = h.httpRoute({ + path: '/auth/token', + method: 'POST', + request: h.httpRequest({ + query: { + /** The permissions granted by this access token. + * + * - \`all\` - Access all actions in the test environment. + * - \`crypto_compare\` - Call CryptoCompare API. + * - \`enterprise_manage_all\` - Manage users and settings for any enterprise to which the user belongs. + * - \`wallet_view\` - View a wallet. + * - \`wallet_spend\` - Initiate transactions from a wallet. + */ + scope: t.string, + }, + body: { + /** Grant type options. + * + * - \`authorization_code\` - Use authorization code flow. + * - \`refresh_token\` - Use refresh token to get new access token. + * - \`client_credentials\` - Use client credentials flow. + */ + grant_type: t.string, + }, + }), + response: { + 200: { + /** Access token information. + * + * - Contains the JWT token + * - Includes expiration time + * - May include refresh token + */ + access_token: t.string, + }, + }, +}); +`; + +testCase( + 'route with markdown bullet lists in parameter and field descriptions', + ROUTE_WITH_MARKDOWN_BULLET_LISTS, + { + openapi: '3.0.3', + info: { + title: 'Test', + version: '1.0.0', + }, + paths: { + '/auth/token': { + post: { + summary: 'Route to test markdown formatting with bullet lists', + operationId: 'api.v1.markdownBullets', + tags: ['Test Routes'], + parameters: [ + { + name: 'scope', + description: + 'The permissions granted by this access token.\n' + + '\n' + + '- `all` - Access all actions in the test environment.\n' + + '- `crypto_compare` - Call CryptoCompare API.\n' + + '- `enterprise_manage_all` - Manage users and settings for any enterprise to which the user belongs.\n' + + '- `wallet_view` - View a wallet.\n' + + '- `wallet_spend` - Initiate transactions from a wallet.', + in: 'query', + required: true, + schema: { + type: 'string', + }, + }, + ], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + grant_type: { + type: 'string', + description: + 'Grant type options.\n' + + '\n' + + '- `authorization_code` - Use authorization code flow.\n' + + '- `refresh_token` - Use refresh token to get new access token.\n' + + '- `client_credentials` - Use client credentials flow.', + }, + }, + required: ['grant_type'], + }, + }, + }, + }, + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + access_token: { + type: 'string', + description: + 'Access token information.\n' + + '\n' + + '- Contains the JWT token\n' + + '- Includes expiration time\n' + + '- May include refresh token', + }, + }, + required: ['access_token'], + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: {}, + }, + }, +);