Skip to content

Commit 3367c95

Browse files
committed
fix: support multiline descriptions
Ticket: DX-1136
1 parent c03229f commit 3367c95

File tree

3 files changed

+192
-25
lines changed

3 files changed

+192
-25
lines changed

packages/openapi-generator/src/comments.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,12 @@ export function combineComments(schema: Schema): Block | undefined {
9393
result.description = comments[0].description;
9494
}
9595

96-
// Add all seen tags, problems, and source comments to the result
96+
// Only use the first comment's source to avoid duplicates when parsing
97+
if (comments[0]?.source) {
98+
result.source = comments[0].source;
99+
}
100+
101+
// Add all seen tags and problems to the result
97102
for (const comment of comments) {
98103
for (const tag of comment.tags) {
99104
// 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 {
104109
}
105110

106111
result.problems.push(...comment.problems);
107-
result.source.push(...comment.source);
108112
}
109113

110114
return result;

packages/openapi-generator/src/openapi.ts

Lines changed: 50 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -266,31 +266,47 @@ export function schemaToOpenAPI(
266266
const emptyBlock: Block = { description: '', tags: [], source: [], problems: [] };
267267
const jsdoc = parseCommentBlock(schema.comment ?? emptyBlock);
268268

269-
const defaultValue = jsdoc?.tags?.default ?? schema.default;
270-
const example = jsdoc?.tags?.example ?? schema.example;
271-
const maxLength = jsdoc?.tags?.maxLength ?? schema.maxLength;
272-
const minLength = jsdoc?.tags?.minLength ?? schema.minLength;
273-
const pattern = jsdoc?.tags?.pattern ?? schema.pattern;
274-
const minimum = jsdoc?.tags?.minimum ?? schema.maximum;
275-
const maximum = jsdoc?.tags?.maximum ?? schema.minimum;
276-
const minItems = jsdoc?.tags?.minItems ?? schema.minItems;
277-
const maxItems = jsdoc?.tags?.maxItems ?? schema.maxItems;
278-
const minProperties = jsdoc?.tags?.minProperties ?? schema.minProperties;
279-
const maxProperties = jsdoc?.tags?.maxProperties ?? schema.maxProperties;
280-
const exclusiveMinimum = jsdoc?.tags?.exclusiveMinimum ?? schema.exclusiveMinimum;
281-
const exclusiveMaximum = jsdoc?.tags?.exclusiveMaximum ?? schema.exclusiveMaximum;
282-
const multipleOf = jsdoc?.tags?.multipleOf ?? schema.multipleOf;
283-
const uniqueItems = jsdoc?.tags?.uniqueItems ?? schema.uniqueItems;
284-
const readOnly = jsdoc?.tags?.readOnly ?? schema.readOnly;
285-
const writeOnly = jsdoc?.tags?.writeOnly ?? schema.writeOnly;
286-
const format = jsdoc?.tags?.format ?? schema.format ?? schema.format;
287-
const title = jsdoc?.tags?.title ?? schema.title;
269+
// Use Block.tags directly for combined comments to preserve tags from all schemas
270+
const blockTags =
271+
schema.comment?.tags?.reduce(
272+
(acc, tag) => {
273+
const tagName = tag.tag.replace(/^@/, '');
274+
acc[tagName] = `${tag.name} ${tag.description}`.trim();
275+
return acc;
276+
},
277+
{} as Record<string, string>,
278+
) ?? {};
279+
const tags = { ...blockTags, ...jsdoc?.tags };
280+
281+
const defaultValue = tags?.default ?? schema.default;
282+
const example = tags?.example ?? schema.example;
283+
const maxLength = tags?.maxLength ?? schema.maxLength;
284+
const minLength = tags?.minLength ?? schema.minLength;
285+
const pattern = tags?.pattern ?? schema.pattern;
286+
const minimum = tags?.minimum ?? schema.maximum;
287+
const maximum = tags?.maximum ?? schema.minimum;
288+
const minItems = tags?.minItems ?? schema.minItems;
289+
const maxItems = tags?.maxItems ?? schema.maxItems;
290+
const minProperties = tags?.minProperties ?? schema.minProperties;
291+
const maxProperties = tags?.maxProperties ?? schema.maxProperties;
292+
const exclusiveMinimum = tags?.exclusiveMinimum ?? schema.exclusiveMinimum;
293+
const exclusiveMaximum = tags?.exclusiveMaximum ?? schema.exclusiveMaximum;
294+
const multipleOf = tags?.multipleOf ?? schema.multipleOf;
295+
const uniqueItems = tags?.uniqueItems ?? schema.uniqueItems;
296+
const readOnly = tags?.readOnly ?? schema.readOnly;
297+
const writeOnly = tags?.writeOnly ?? schema.writeOnly;
298+
const format = tags?.format ?? schema.format;
299+
const title = tags?.title ?? schema.title;
288300

289301
const keys = Object.keys(jsdoc?.tags || {});
290302

291303
const deprecated = keys.includes('deprecated') || !!schema.deprecated;
292304
const isPrivate = keys.includes('private');
293-
const description = schema.comment?.description ?? schema.description;
305+
// Combine summary and description for markdown support
306+
const description =
307+
jsdoc?.summary && jsdoc?.description
308+
? `${jsdoc.summary.trim()}\n\n${jsdoc.description.trim()}`
309+
: jsdoc?.summary?.trim() ?? jsdoc?.description?.trim() ?? schema.description;
294310

295311
const defaultOpenAPIObject = {
296312
...(defaultValue ? { default: parseField(schema, defaultValue) } : {}),
@@ -390,11 +406,22 @@ function routeToOpenAPI(route: Route): [string, string, OpenAPIV3.OperationObjec
390406
delete schema['x-internal'];
391407
}
392408

409+
const paramJsDoc =
410+
p.schema?.comment !== undefined
411+
? parseCommentBlock(p.schema.comment)
412+
: undefined;
413+
414+
// Combine summary and description for markdown support; use empty string for tag-only comments
415+
const paramDescription =
416+
paramJsDoc?.summary && paramJsDoc?.description
417+
? `${paramJsDoc.summary.trim()}\n\n${paramJsDoc.description.trim()}`
418+
: paramJsDoc?.summary?.trim() ??
419+
paramJsDoc?.description?.trim() ??
420+
(p.schema?.comment !== undefined ? '' : undefined);
421+
393422
return {
394423
name: p.name,
395-
...(p.schema?.comment?.description !== undefined
396-
? { description: p.schema.comment.description }
397-
: {}),
424+
...(paramDescription !== undefined ? { description: paramDescription } : {}),
398425
in: p.type,
399426
...(isPrivate ? { 'x-internal': true } : {}),
400427
...(p.required ? { required: true } : {}),

packages/openapi-generator/test/openapi/comments.test.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1682,3 +1682,139 @@ testCase(
16821682
},
16831683
},
16841684
);
1685+
1686+
const ROUTE_WITH_MARKDOWN_BULLET_LISTS = `
1687+
import * as t from 'io-ts';
1688+
import * as h from '@api-ts/io-ts-http';
1689+
1690+
/**
1691+
* Route to test markdown formatting with bullet lists
1692+
*
1693+
* @operationId api.v1.markdownBullets
1694+
* @tag Test Routes
1695+
*/
1696+
export const route = h.httpRoute({
1697+
path: '/auth/token',
1698+
method: 'POST',
1699+
request: h.httpRequest({
1700+
query: {
1701+
/** The permissions granted by this access token.
1702+
*
1703+
* - \`all\` - Access all actions in the test environment.
1704+
* - \`crypto_compare\` - Call CryptoCompare API.
1705+
* - \`enterprise_manage_all\` - Manage users and settings for any enterprise to which the user belongs.
1706+
* - \`wallet_view\` - View a wallet.
1707+
* - \`wallet_spend\` - Initiate transactions from a wallet.
1708+
*/
1709+
scope: t.string,
1710+
},
1711+
body: {
1712+
/** Grant type options.
1713+
*
1714+
* - \`authorization_code\` - Use authorization code flow.
1715+
* - \`refresh_token\` - Use refresh token to get new access token.
1716+
* - \`client_credentials\` - Use client credentials flow.
1717+
*/
1718+
grant_type: t.string,
1719+
},
1720+
}),
1721+
response: {
1722+
200: {
1723+
/** Access token information.
1724+
*
1725+
* - Contains the JWT token
1726+
* - Includes expiration time
1727+
* - May include refresh token
1728+
*/
1729+
access_token: t.string,
1730+
},
1731+
},
1732+
});
1733+
`;
1734+
1735+
testCase(
1736+
'route with markdown bullet lists in parameter and field descriptions',
1737+
ROUTE_WITH_MARKDOWN_BULLET_LISTS,
1738+
{
1739+
openapi: '3.0.3',
1740+
info: {
1741+
title: 'Test',
1742+
version: '1.0.0',
1743+
},
1744+
paths: {
1745+
'/auth/token': {
1746+
post: {
1747+
summary: 'Route to test markdown formatting with bullet lists',
1748+
operationId: 'api.v1.markdownBullets',
1749+
tags: ['Test Routes'],
1750+
parameters: [
1751+
{
1752+
name: 'scope',
1753+
description:
1754+
'The permissions granted by this access token.\n' +
1755+
'\n' +
1756+
'- `all` - Access all actions in the test environment.\n' +
1757+
'- `crypto_compare` - Call CryptoCompare API.\n' +
1758+
'- `enterprise_manage_all` - Manage users and settings for any enterprise to which the user belongs.\n' +
1759+
'- `wallet_view` - View a wallet.\n' +
1760+
'- `wallet_spend` - Initiate transactions from a wallet.',
1761+
in: 'query',
1762+
required: true,
1763+
schema: {
1764+
type: 'string',
1765+
},
1766+
},
1767+
],
1768+
requestBody: {
1769+
content: {
1770+
'application/json': {
1771+
schema: {
1772+
type: 'object',
1773+
properties: {
1774+
grant_type: {
1775+
type: 'string',
1776+
description:
1777+
'Grant type options.\n' +
1778+
'\n' +
1779+
'- `authorization_code` - Use authorization code flow.\n' +
1780+
'- `refresh_token` - Use refresh token to get new access token.\n' +
1781+
'- `client_credentials` - Use client credentials flow.',
1782+
},
1783+
},
1784+
required: ['grant_type'],
1785+
},
1786+
},
1787+
},
1788+
},
1789+
responses: {
1790+
200: {
1791+
description: 'OK',
1792+
content: {
1793+
'application/json': {
1794+
schema: {
1795+
type: 'object',
1796+
properties: {
1797+
access_token: {
1798+
type: 'string',
1799+
description:
1800+
'Access token information.\n' +
1801+
'\n' +
1802+
'- Contains the JWT token\n' +
1803+
'- Includes expiration time\n' +
1804+
'- May include refresh token',
1805+
},
1806+
},
1807+
required: ['access_token'],
1808+
},
1809+
},
1810+
},
1811+
},
1812+
},
1813+
},
1814+
},
1815+
},
1816+
components: {
1817+
schemas: {},
1818+
},
1819+
},
1820+
);

0 commit comments

Comments
 (0)