From 78c823c2053fa89a97c416539755a17d6e5911e8 Mon Sep 17 00:00:00 2001 From: Vivian McCarty Date: Thu, 16 Jul 2026 21:26:07 +0000 Subject: [PATCH] fix(openapi-generator): propagate comment from union requestBody to body schema Two gaps in route.ts prevented JSDoc descriptions from reaching the body schema node for union-of-httpRequest request bodies: 1. derefRequestSchema resolved a named-const ref but dropped the third element of the findSymbolInitializer tuple (Block | undefined), so any JSDoc on the named const was silently discarded. 2. parseRequestUnion built the merged body as a brand-new { type: 'union', schemas: [] } node and never copied schema.comment to it, so even when the union schema already carried a comment (e.g. after fix 1), the body node the lint inspects had none. Together these mean that a route like: /** Either a userId or an email */ const AddMemberRequest = t.union([httpRequest({body:{userId}}), ...]); httpRoute({ request: AddMemberRequest, ... }) now emits a description on requestBody.content['application/json'].schema, satisfying the missing-request-body-description quality check. Fixes affect patterns such as POST /enterprise/{id}/safes/{id}/members where the body enforces userId | email. Ticket: TW-295 Session-Id: fc63a9af-dca6-4ec9-9637-7afb61367955 Task-Id: a1d22b41-99d1-4474-8e8a-82ff2fe84a38 --- packages/openapi-generator/README.md | 33 ++++++ packages/openapi-generator/src/route.ts | 18 +++- .../test/openapi/union.test.ts | 102 ++++++++++++++++++ packages/openapi-generator/test/route.test.ts | 75 +++++++++++++ 4 files changed, 225 insertions(+), 3 deletions(-) diff --git a/packages/openapi-generator/README.md b/packages/openapi-generator/README.md index 7f0353f0..8c03fe93 100644 --- a/packages/openapi-generator/README.md +++ b/packages/openapi-generator/README.md @@ -512,6 +512,39 @@ const Schema = t.type({ }); ``` +#### 6.2.3.1 Union request bodies + +When a request body is defined as a `t.union` of `httpRequest` schemas (to accept one of +several mutually-exclusive shapes), the description must be placed on the **union codec +itself**, not on the individual variants: + +```typescript +import * as t from 'io-ts'; +import * as h from '@api-ts/io-ts-http'; + +/** + * The member to add — supply either a userId or an email address. + */ +const AddMemberRequest = t.union([ + h.httpRequest({ body: { userId: t.string } }), + h.httpRequest({ body: { email: t.string } }), +]); + +const route = h.httpRoute({ + path: '/enterprise/{enterpriseId}/members', + method: 'POST', + request: AddMemberRequest, + response: { 200: t.string }, +}); +``` + +This produces `description` on `requestBody.content['application/json'].schema`, which +satisfies the `missing-request-body-description` quality check. + +> **Note:** Putting JSDoc on the individual `httpRequest(...)` members instead of the +> union wrapper will **not** propagate a description to the body schema — the +> description must be on the union codec. + #### 6.2.4 Enum Documentation When using `t.keyof` to define enums, you can add descriptions and deprecation notices diff --git a/packages/openapi-generator/src/route.ts b/packages/openapi-generator/src/route.ts index be8df670..69eeb7c4 100644 --- a/packages/openapi-generator/src/route.ts +++ b/packages/openapi-generator/src/route.ts @@ -42,8 +42,16 @@ function derefRequestSchema( if (E.isLeft(initE)) { return initE; } - const [newSourceFile, init] = initE.right; - return parseCodecInitializer(project, newSourceFile, init); + const [newSourceFile, init, comment] = initE.right; + const resolvedE = parseCodecInitializer(project, newSourceFile, init); + if ( + E.isRight(resolvedE) && + comment !== undefined && + resolvedE.right.comment === undefined + ) { + resolvedE.right.comment = comment; + } + return resolvedE; } else { return E.right(schema); } @@ -139,7 +147,11 @@ function parseRequestUnion( } if (subSchema.properties['body'] !== undefined) { if (body === undefined) { - body = { type: 'union', schemas: [] }; + body = { + type: 'union', + schemas: [], + ...(schema.comment !== undefined ? { comment: schema.comment } : {}), + }; } (body as CombinedType).schemas.push(subSchema.properties['body']); } diff --git a/packages/openapi-generator/test/openapi/union.test.ts b/packages/openapi-generator/test/openapi/union.test.ts index 22edbea1..b939e755 100644 --- a/packages/openapi-generator/test/openapi/union.test.ts +++ b/packages/openapi-generator/test/openapi/union.test.ts @@ -780,6 +780,108 @@ testCase( }, ); +const UNION_BODY_WITH_REF_COMMENT = ` +import * as t from 'io-ts'; +import * as h from '@api-ts/io-ts-http'; + +/** Either a userId or an email */ +const AddMemberRequest = t.union([ + h.httpRequest({ body: { userId: t.string } }), + h.httpRequest({ body: { email: t.string } }), +]); + +export const route = h.httpRoute({ + path: '/foo', + method: 'POST', + request: AddMemberRequest, + response: { + 200: t.string, + }, +}); +`; + +testCase( + 'union body description propagates from named const ref to requestBody schema', + UNION_BODY_WITH_REF_COMMENT, + { + openapi: '3.0.3', + info: { + title: 'Test', + version: '1.0.0', + }, + paths: { + '/foo': { + post: { + parameters: [], + requestBody: { + content: { + 'application/json': { + schema: { + description: 'Either a userId or an email', + oneOf: [ + { + type: 'object', + properties: { userId: { type: 'string' } }, + required: ['userId'], + }, + { + type: 'object', + properties: { email: { type: 'string' } }, + required: ['email'], + }, + ], + }, + }, + }, + }, + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + AddMemberRequest: { + title: 'AddMemberRequest', + description: 'Either a userId or an email', + oneOf: [ + { + type: 'object', + properties: { + body: { + type: 'object', + properties: { userId: { type: 'string' } }, + required: ['userId'], + }, + }, + required: ['body'], + }, + { + type: 'object', + properties: { + body: { + type: 'object', + properties: { email: { type: 'string' } }, + required: ['email'], + }, + }, + required: ['body'], + }, + ], + }, + }, + }, + }, +); + const ROUTE_WITH_REQUEST_UNION = ` import * as t from 'io-ts'; import * as h from '@api-ts/io-ts-http'; diff --git a/packages/openapi-generator/test/route.test.ts b/packages/openapi-generator/test/route.test.ts index 1caf337d..6a6194d6 100644 --- a/packages/openapi-generator/test/route.test.ts +++ b/packages/openapi-generator/test/route.test.ts @@ -719,6 +719,81 @@ testCase('route with operationId', WITH_OPERATION_ID, { }, }); +const BODY_UNION_WITH_COMMENT_REF = ` +import * as t from 'io-ts'; +import * as h from '@api-ts/io-ts-http'; + +/** Either a userId or an email */ +const AddMemberRequest = t.union([ + h.httpRequest({ body: { userId: t.string } }), + h.httpRequest({ body: { email: t.string } }), +]); + +export const route = h.httpRoute({ + path: '/foo', + method: 'POST', + request: AddMemberRequest, + response: { + 200: t.string + }, +}); +`; + +testCase( + 'union body route with comment on named const ref', + BODY_UNION_WITH_COMMENT_REF, + { + route: { + path: '/foo', + method: 'POST', + parameters: [], + body: { + type: 'union', + comment: { + description: 'Either a userId or an email', + tags: [], + source: [ + { + number: 0, + source: '/** Either a userId or an email */', + tokens: { + start: '', + delimiter: '/**', + postDelimiter: ' ', + tag: '', + postTag: '', + name: '', + postName: '', + type: '', + postType: '', + description: 'Either a userId or an email ', + end: '*/', + lineEnd: '', + }, + }, + ], + problems: [], + }, + schemas: [ + { + type: 'object', + properties: { userId: { type: 'string', primitive: true } }, + required: ['userId'], + }, + { + type: 'object', + properties: { email: { type: 'string', primitive: true } }, + required: ['email'], + }, + ], + }, + response: { + 200: { type: 'string', primitive: true }, + }, + }, + }, +); + const HEADER_PARAM = ` import * as t from 'io-ts'; import * as h from '@api-ts/io-ts-http';