Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions packages/openapi-generator/src/comments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -104,7 +109,6 @@ export function combineComments(schema: Schema): Block | undefined {
}

result.problems.push(...comment.problems);
result.source.push(...comment.source);
}

return result;
Expand Down
73 changes: 50 additions & 23 deletions packages/openapi-generator/src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>,
) ?? {};
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) } : {}),
Expand Down Expand Up @@ -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 } : {}),
Expand Down
136 changes: 136 additions & 0 deletions packages/openapi-generator/test/openapi/comments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
},
},
);