Skip to content

Commit 54bbb2e

Browse files
authored
fix(http,openapi3): support nested unions in operation return types (#8961)
Nested unions (e.g., `op read(): A | (B | C)` or using a named union like `union Conflict { ... }`) were not being expanded recursively when used as operation return types, causing responses to be missing from the generated OpenAPI output. This fix: - Adds recursive union expansion in processResponseType() to handle nested unions at any level - Updates getResponses() to group responses by status code before processing to properly handle cases where multiple union variants map to the same status code - Ensures response descriptions are taken from the first variant when multiple variants share a status code The most common case is named unions, but this also fixes any scenario where unions are nested within other unions in the return type. ---- The reasons for why I want this fixed is because I want to be able to control the description on responses when there is multiple responses on the same status code. See #8962 for details.
1 parent 0422ee3 commit 54bbb2e

File tree

5 files changed

+138
-2
lines changed

5 files changed

+138
-2
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
changeKind: fix
3+
packages:
4+
- "@typespec/http"
5+
- "@typespec/openapi3"
6+
---
7+
8+
Support nested unions in operation return types

packages/http/src/responses.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,22 @@ function processResponseType(
8282
responses: ResponseIndex,
8383
responseType: Type,
8484
) {
85+
const tk = $(program);
86+
87+
// If the response type is itself a union (and not discriminated), expand it recursively.
88+
// This handles cases where a named union is used as a return type (e.g., `op read(): MyUnion`)
89+
// or when unions are nested (e.g., a union variant is itself a union).
90+
// Each variant will be processed separately to extract its status codes and responses.
91+
if (tk.union.is(responseType) && !tk.union.getDiscriminatedUnion(responseType)) {
92+
for (const option of responseType.variants.values()) {
93+
if (isNullType(option.type)) {
94+
continue;
95+
}
96+
processResponseType(program, diagnostics, operation, responses, option.type);
97+
}
98+
return;
99+
}
100+
85101
// Get body
86102
let { body: resolvedBody, metadata } = diagnostics.pipe(
87103
resolveHttpPayload(program, responseType, Visibility.Read, HttpPayloadDisposition.Response),

packages/openapi3/src/openapi.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -964,14 +964,35 @@ function createOAPIEmitter(
964964
responses: HttpOperationResponse[],
965965
examples: OperationExamples,
966966
): Record<string, Refable<OpenAPI3Response>> {
967-
const result: Record<string, Refable<OpenAPI3Response>> = {};
967+
const responseMap = new Map<string, HttpOperationResponse[]>();
968+
969+
// Group responses by status code first. When named unions are expanded into individual
970+
// response variants, multiple variants may map to the same status code. We need to collect
971+
// all variants for each status code before processing to properly merge content types and
972+
// select the appropriate description.
968973
for (const response of responses) {
969974
for (const statusCode of diagnostics.pipe(
970975
getOpenAPI3StatusCodes(program, response.statusCodes, response.type),
971976
)) {
972-
result[statusCode] = getResponseForStatusCode(operation, statusCode, [response], examples);
977+
if (responseMap.has(statusCode)) {
978+
responseMap.get(statusCode)!.push(response);
979+
} else {
980+
responseMap.set(statusCode, [response]);
981+
}
973982
}
974983
}
984+
985+
// Generate OpenAPI response for each status code
986+
const result: Record<string, Refable<OpenAPI3Response>> = {};
987+
for (const [statusCode, statusCodeResponses] of responseMap) {
988+
result[statusCode] = getResponseForStatusCode(
989+
operation,
990+
statusCode,
991+
statusCodeResponses,
992+
examples,
993+
);
994+
}
995+
975996
return result;
976997
}
977998

packages/openapi3/test/response-descriptions.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,46 @@ worksFor(supportedVersions, ({ openApiFor }) => {
6161
strictEqual(res.paths["/"].get.responses["404"].description, "Not found model");
6262
strictEqual(res.paths["/"].get.responses["default"].description, "Generic error");
6363
});
64+
65+
it("uses first model's description when multiple models have same status code", async () => {
66+
const res = await openApiFor(
67+
`
68+
@doc("Foo") model Foo { @statusCode _: 409 }
69+
@doc("Bar") model Bar { @statusCode _: 409 }
70+
op read(): { @statusCode _: 200, content: string } | Foo | Bar;
71+
`,
72+
);
73+
strictEqual(res.paths["/"].get.responses["200"].description, "The request has succeeded.");
74+
strictEqual(res.paths["/"].get.responses["409"].description, "Foo");
75+
});
76+
77+
it("expands named union in return type and uses first variant's description", async () => {
78+
const res = await openApiFor(
79+
`
80+
@doc("Foo") model Foo { @statusCode _: 409 }
81+
@doc("Bar") model Bar { @statusCode _: 409 }
82+
union Conflict { Foo: Foo; Bar: Bar };
83+
op read(): { @statusCode _: 200, content: string } | Conflict;
84+
`,
85+
);
86+
strictEqual(res.paths["/"].get.responses["200"].description, "The request has succeeded.");
87+
strictEqual(res.paths["/"].get.responses["409"].description, "Foo");
88+
});
89+
90+
it("recursively expands deeply nested unions", async () => {
91+
const res = await openApiFor(
92+
`
93+
@doc("Model A") model A { @statusCode _: 400 }
94+
@doc("Model B") model B { @statusCode _: 401 }
95+
@doc("Model C") model C { @statusCode _: 403 }
96+
union Inner { A: A; B: B };
97+
union Outer { inner: Inner; C: C };
98+
op read(): { @statusCode _: 200, content: string } | Outer;
99+
`,
100+
);
101+
strictEqual(res.paths["/"].get.responses["200"].description, "The request has succeeded.");
102+
strictEqual(res.paths["/"].get.responses["400"].description, "Model A");
103+
strictEqual(res.paths["/"].get.responses["401"].description, "Model B");
104+
strictEqual(res.paths["/"].get.responses["403"].description, "Model C");
105+
});
64106
});

packages/openapi3/test/status-codes.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,53 @@ worksFor(supportedVersions, ({ diagnoseOpenApiFor, openApiFor }) => {
5858
},
5959
]);
6060
});
61+
62+
it("extracts status codes from models in a named union", async () => {
63+
await expectStatusCodes(
64+
`
65+
model A {
66+
@statusCode _: 418;
67+
}
68+
69+
model B {
70+
@statusCode _: 200;
71+
}
72+
73+
union R {
74+
A: A;
75+
B: B;
76+
};
77+
78+
op read(): R;
79+
`,
80+
["200", "418"],
81+
);
82+
});
83+
84+
it("deduplicates status codes when multiple models in union have same code", async () => {
85+
await expectStatusCodes(
86+
`
87+
model A {
88+
@statusCode _: 418;
89+
}
90+
91+
model B {
92+
@statusCode _: 418;
93+
}
94+
95+
model C {
96+
@statusCode _: 200;
97+
}
98+
99+
union R {
100+
A: A;
101+
B: B;
102+
C: C;
103+
};
104+
105+
op read(): R;
106+
`,
107+
["200", "418"],
108+
);
109+
});
61110
});

0 commit comments

Comments
 (0)