Skip to content
Open
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
33 changes: 33 additions & 0 deletions packages/openapi-generator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 15 additions & 3 deletions packages/openapi-generator/src/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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']);
}
Expand Down
102 changes: 102 additions & 0 deletions packages/openapi-generator/test/openapi/union.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks a little funky -- there's a schema that isn't used? Is that expected?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, and you're reading it right — AddMemberRequest in components.schemas is genuinely never $ref'd anywhere in paths here. It's expected though, not a leftover: the test harness (testCase in testHarness.ts) dumps every top-level declaration that isn't itself a valid route into schemas, regardless of whether it's referenced. AddMemberRequest has to be a named top-level const in this test — that's the point, verifying the comment propagates from a named union ref — but parseRequestUnion/derefRequestSchema fully flattens that union into an inline body schema, so no ref node survives pointing back at it. It's an artifact of the test harness's schema-collection convention, unrelated to the description-propagation fix itself — the real CLI's component-collection pass in cli.ts only includes schemas still reachable via ref, so this dangling entry likely wouldn't show up in a real generated spec. Happy to add a one-line comment above that block if it'd help future readers.

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';
Expand Down
75 changes: 75 additions & 0 deletions packages/openapi-generator/test/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down