From 4e322ce749107ee67840ec836aa23cc8bedeeff4 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 26 Mar 2026 15:47:23 -0400 Subject: [PATCH 1/2] combine plain responses --- packages/http/src/responses.ts | 122 +++++++++++++------- packages/openapi3/test/examples.test.ts | 48 ++++++++ packages/openapi3/test/union-schema.test.ts | 6 +- 3 files changed, 128 insertions(+), 48 deletions(-) diff --git a/packages/http/src/responses.ts b/packages/http/src/responses.ts index 281030fd339..568cbfa8a8a 100644 --- a/packages/http/src/responses.ts +++ b/packages/http/src/responses.ts @@ -34,31 +34,70 @@ export function getResponsesForOperation( operation: Operation, ): [HttpOperationResponse[], readonly Diagnostic[]] { const diagnostics = createDiagnosticCollector(); - const responseType = operation.returnType; const responses = new ResponseIndex(); + + // Resolve union variants into concrete response types, grouping plain body variants + // (no HTTP metadata) into a single union type. + const variants = resolveResponseVariants(program, operation.returnType); + for (const { type, description } of variants) { + processResponseType(program, diagnostics, operation, responses, type, description); + } + + return diagnostics.wrap(responses.values()); +} + +interface ResolvedResponseVariant { + type: Type; + description?: string; +} + +/** + * Recursively flatten union variants and group "plain body" variants into a single union type. + * Variants with HTTP metadata (e.g., @statusCode, @header) are kept separate. + */ +function resolveResponseVariants( + program: Program, + responseType: Type, + parentDescription?: string, +): ResolvedResponseVariant[] { const tk = $(program); - if (tk.union.is(responseType) && !tk.union.getDiscriminatedUnion(responseType)) { - // Check if the union itself has a @doc to use as the response description - const unionDescription = getDoc(program, responseType); - for (const option of responseType.variants.values()) { - if (isNullType(option.type)) { - // TODO how should we treat this? https://github.com/microsoft/typespec/issues/356 - continue; + if (!tk.union.is(responseType) || tk.union.getDiscriminatedUnion(responseType)) { + return [{ type: responseType, description: parentDescription }]; + } + + const unionDescription = getDoc(program, responseType) ?? parentDescription; + + // Recursively flatten all union variants, then classify. + const plainVariants: Type[] = []; + const responseEnvelopes: ResolvedResponseVariant[] = []; + + for (const option of responseType.variants.values()) { + if (isNullType(option.type)) { + continue; + } + // Recursively resolve nested unions + const resolved = resolveResponseVariants(program, option.type, unionDescription); + for (const variant of resolved) { + if (isPlainResponseBody(program, variant.type)) { + plainVariants.push(variant.type); + } else { + responseEnvelopes.push(variant); } - processResponseType( - program, - diagnostics, - operation, - responses, - option.type, - unionDescription, - ); } - } else { - processResponseType(program, diagnostics, operation, responses, responseType, undefined); } - return diagnostics.wrap(responses.values()); + // Combine plain variants into a single union type, process envelope variants individually. + const results: ResolvedResponseVariant[] = []; + if (plainVariants.length === 1) { + results.push({ type: plainVariants[0], description: unionDescription }); + } else if (plainVariants.length > 1) { + // Reuse the original union if all variants are plain, otherwise create a new one. + const unionType = + responseEnvelopes.length === 0 ? responseType : tk.union.create(plainVariants); + results.push({ type: unionType, description: unionDescription }); + } + results.push(...responseEnvelopes); + return results; } /** @@ -96,31 +135,6 @@ function processResponseType( responseType: Type, parentDescription?: string, ) { - const tk = $(program); - - // If the response type is itself a union (and not discriminated), expand it recursively. - // This handles cases where a named union is used as a return type (e.g., `op read(): MyUnion`) - // or when unions are nested (e.g., a union variant is itself a union). - // Each variant will be processed separately to extract its status codes and responses. - if (tk.union.is(responseType) && !tk.union.getDiscriminatedUnion(responseType)) { - // Check if this nested union has its own @doc, otherwise inherit parent's description - const unionDescription = getDoc(program, responseType) ?? parentDescription; - for (const option of responseType.variants.values()) { - if (isNullType(option.type)) { - continue; - } - processResponseType( - program, - diagnostics, - operation, - responses, - option.type, - unionDescription, - ); - } - return; - } - // Get body const verb = getOperationVerb(program, operation); let { body: resolvedBody, metadata } = diagnostics.pipe( @@ -250,6 +264,26 @@ function isResponseEnvelope(metadata: HttpProperty[]): boolean { ); } +/** + * Check if a type is a plain body with no HTTP response envelope metadata. + * Plain body types can be merged into a union when they share the same status code. + */ +function isPlainResponseBody(program: Program, type: Type): boolean { + if (isVoidType(type) || isErrorModel(program, type)) { + return false; + } + if (type.kind === "Model" && getExplicitSetStatusCode(program, type).length > 0) { + return false; + } + const [result] = resolveHttpPayload( + program, + type, + Visibility.Read, + HttpPayloadDisposition.Response, + ); + return !result || !result.metadata.some((p) => p.kind !== "bodyProperty"); +} + function getResponseDescription( program: Program, operation: Operation, diff --git a/packages/openapi3/test/examples.test.ts b/packages/openapi3/test/examples.test.ts index b31fc32b01d..ee67cee7efe 100644 --- a/packages/openapi3/test/examples.test.ts +++ b/packages/openapi3/test/examples.test.ts @@ -171,6 +171,54 @@ worksFor(supportedVersions, ({ openApiFor }) => { }); }); + it("set examples on union response with same status code", async () => { + const res: OpenAPI3Document = await openApiFor( + ` + model ModelA { a: string; } + model ModelB { b: string; } + + @opExample(#{ returnType: #{ a: "A" } }, #{ title: "ExampleA" }) + @opExample(#{ returnType: #{ b: "B" } }, #{ title: "ExampleB" }) + op getUnion(): ModelA | ModelB; + `, + ); + ok(res.paths["/"].get); + ok(res.paths["/"].get.responses); + ok("200" in res.paths["/"].get.responses); + const resp200 = res.paths["/"].get.responses["200"]; + ok(typeof resp200 === "object" && "content" in resp200); + expect(resp200.content?.["application/json"].examples).toEqual({ + ExampleA: { + summary: "ExampleA", + value: { a: "A" }, + }, + ExampleB: { + summary: "ExampleB", + value: { b: "B" }, + }, + }); + }); + + it("set single example on union response with same status code", async () => { + const res: OpenAPI3Document = await openApiFor( + ` + model ModelA { a: string; } + model ModelB { b: string; } + + @opExample(#{ returnType: #{ a: "A" } }) + op getUnion(): ModelA | ModelB; + `, + ); + ok(res.paths["/"].get); + ok(res.paths["/"].get.responses); + ok("200" in res.paths["/"].get.responses); + const resp200 = res.paths["/"].get.responses["200"]; + ok(typeof resp200 === "object" && "content" in resp200); + expect(resp200.content?.["application/json"].example).toEqual({ + a: "A", + }); + }); + it("apply to status code ranges", async () => { const res: OpenAPI3Document = await openApiFor( ` diff --git a/packages/openapi3/test/union-schema.test.ts b/packages/openapi3/test/union-schema.test.ts index d8550a5251a..72b7c29541e 100644 --- a/packages/openapi3/test/union-schema.test.ts +++ b/packages/openapi3/test/union-schema.test.ts @@ -794,10 +794,8 @@ worksFor(["3.0.0"], ({ diagnoseOpenApiFor, oapiForModel, openApiFor }) => { `, ); expect(res.paths["/"].get.responses["200"].content["text/plain"].schema).toEqual({ - anyOf: [ - { type: "string", enum: ["a"] }, - { type: "string", enum: ["b"] }, - ], + type: "string", + enum: ["a", "b"], }); }); }); From 922424d661ab4f307735ba83c820f5df98433ab5 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 27 Mar 2026 08:53:25 -0400 Subject: [PATCH 2/2] add more tests --- packages/openapi3/test/examples.test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/openapi3/test/examples.test.ts b/packages/openapi3/test/examples.test.ts index ee67cee7efe..0ff7d3b5dff 100644 --- a/packages/openapi3/test/examples.test.ts +++ b/packages/openapi3/test/examples.test.ts @@ -219,6 +219,26 @@ worksFor(supportedVersions, ({ openApiFor }) => { }); }); + it("set example on union of response envelopes with same status code", async () => { + const res: OpenAPI3Document = await openApiFor( + ` + @error model Error { @statusCode _: 400; } + model Error1 is Error { @body body: { error1: string } } + model Error2 is Error { @body body: { error2: string } } + + @opExample(#{ returnType: #{ _: 400, body: #{ error1: "abc" } } }) + op bad(): Error1 | Error2; + `, + ); + ok(res.paths["/"].get); + ok(res.paths["/"].get.responses); + const resp400 = (res.paths["/"].get.responses as Record)["400"]; + ok(typeof resp400 === "object" && "content" in resp400); + expect(resp400.content?.["application/json"].example).toEqual({ + error1: "abc", + }); + }); + it("apply to status code ranges", async () => { const res: OpenAPI3Document = await openApiFor( `