Skip to content

Commit 5206b58

Browse files
committed
fix(http,openapi3): support named unions in operation response types
Named unions (e.g., `union Conflict { ... }`) were not being expanded 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 named unions the same as inline unions - 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
1 parent 563f6a8 commit 5206b58

File tree

4 files changed

+127
-2
lines changed

4 files changed

+127
-2
lines changed

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: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -955,14 +955,30 @@ function createOAPIEmitter(
955955
responses: HttpOperationResponse[],
956956
examples: OperationExamples,
957957
): Record<string, Refable<OpenAPI3Response>> {
958-
const result: Record<string, Refable<OpenAPI3Response>> = {};
958+
const responseMap = new Map<string, HttpOperationResponse[]>();
959+
960+
// Group responses by status code first. When named unions are expanded into individual
961+
// response variants, multiple variants may map to the same status code. We need to collect
962+
// all variants for each status code before processing to properly merge content types and
963+
// select the appropriate description.
959964
for (const response of responses) {
960965
for (const statusCode of diagnostics.pipe(
961966
getOpenAPI3StatusCodes(program, response.statusCodes, response.type),
962967
)) {
963-
result[statusCode] = getResponseForStatusCode(operation, statusCode, [response], examples);
968+
if (responseMap.has(statusCode)) {
969+
responseMap.get(statusCode)!.push(response);
970+
} else {
971+
responseMap.set(statusCode, [response]);
972+
}
964973
}
965974
}
975+
976+
// Generate OpenAPI response for each status code
977+
const result: Record<string, Refable<OpenAPI3Response>> = {};
978+
for (const [statusCode, statusCodeResponses] of responseMap) {
979+
result[statusCode] = getResponseForStatusCode(operation, statusCode, statusCodeResponses, examples);
980+
}
981+
966982
return result;
967983
}
968984

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,47 @@ worksFor(["3.0.0", "3.1.0"], ({ 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+
});
106+
64107
});

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,54 @@ worksFor(["3.0.0", "3.1.0"], ({ 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+
});
110+
61111
});

0 commit comments

Comments
 (0)