diff --git a/.chronus/changes/examples-multi-response-2026-3-28-14-36-55.md b/.chronus/changes/examples-multi-response-2026-3-28-14-36-55.md new file mode 100644 index 00000000000..c2caf9dceed --- /dev/null +++ b/.chronus/changes/examples-multi-response-2026-3-28-14-36-55.md @@ -0,0 +1,8 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: feature +packages: + - "@typespec/http" +--- + +[API] Operation returning a union of types without status code or content type will be treated as a single response diff --git a/.chronus/changes/examples-multi-response-2026-3-29-16-25-39.md b/.chronus/changes/examples-multi-response-2026-3-29-16-25-39.md new file mode 100644 index 00000000000..6b8a587998e --- /dev/null +++ b/.chronus/changes/examples-multi-response-2026-3-29-16-25-39.md @@ -0,0 +1,8 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: fix +packages: + - "@typespec/openapi3" +--- + +Fix examples when operation return type have union of response mapping to same status code 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/http/test/responses.test.ts b/packages/http/test/responses.test.ts index 98a40b72185..7e0f78e7b7d 100644 --- a/packages/http/test/responses.test.ts +++ b/packages/http/test/responses.test.ts @@ -1,9 +1,17 @@ -import type { Model } from "@typespec/compiler"; +import type { Model, Union } from "@typespec/compiler"; import { expectDiagnosticEmpty, expectDiagnostics } from "@typespec/compiler/testing"; import { deepStrictEqual, ok, strictEqual } from "assert"; import { describe, expect, it } from "vitest"; +import type { HttpOperationResponse } from "../src/index.js"; import { compileOperations, getOperationsWithServiceNamespace } from "./test-host.js"; +async function getResponses(code: string): Promise { + const [routes, diagnostics] = await getOperationsWithServiceNamespace(code); + expectDiagnosticEmpty(diagnostics); + expect(routes).toHaveLength(1); + return routes[0].responses; +} + describe("body resolution", () => { it("emit diagnostics for duplicate @body decorator", async () => { const [_, diagnostics] = await compileOperations( @@ -141,54 +149,42 @@ it("supports any casing for string literal 'Content-Type' header properties.", a }); it("treats content-type as a header for HEAD responses", async () => { - const [routes, diagnostics] = await getOperationsWithServiceNamespace( - ` - @head - op head(): { @header "content-type": "text/plain" }; - `, - ); - - expectDiagnosticEmpty(diagnostics); - strictEqual(routes.length, 1); - const response = routes[0].responses[0].responses[0]; - strictEqual(response.body, undefined); + const responses = await getResponses(` + @head + op head(): { @header "content-type": "text/plain" }; + `); + const response = responses[0].responses[0]; + expect(response.body).toBeUndefined(); ok(response.headers); - deepStrictEqual(Object.keys(response.headers), ["content-type"]); + expect(Object.keys(response.headers)).toEqual(["content-type"]); }); // Regression test for https://github.com/microsoft/typespec/issues/328 it("empty response model becomes body if it has children", async () => { - const [routes, diagnostics] = await getOperationsWithServiceNamespace( - ` - op read(): A; - - @discriminator("foo") - model A {} + const responses = await getResponses(` + op read(): A; - model B extends A { - foo: "B"; - b: string; - } + @discriminator("foo") + model A {} - model C extends A { - foo: "C"; - c: string; - } + model B extends A { + foo: "B"; + b: string; + } - `, - ); - expectDiagnosticEmpty(diagnostics); - strictEqual(routes.length, 1); - const responses = routes[0].responses; - strictEqual(responses.length, 1); - const response = responses[0]; - const body = response.responses[0].body; + model C extends A { + foo: "C"; + c: string; + } + `); + expect(responses).toHaveLength(1); + const body = responses[0].responses[0].body; ok(body); - strictEqual((body.type as Model).name, "A"); + expect((body.type as Model).name).toBe("A"); }); it("chooses correct content-type for extensible union body", async () => { - const [routes, diagnostics] = await getOperationsWithServiceNamespace(` + const responses = await getResponses(` union DaysOfWeekExtensibleEnum { string, @@ -220,15 +216,10 @@ it("chooses correct content-type for extensible union body", async () => { @body body: DaysOfWeekExtensibleEnum; }; `); - - expectDiagnosticEmpty(diagnostics); - strictEqual(routes.length, 1); - const responses = routes[0].responses; - strictEqual(responses.length, 1); - const response = responses[0]; - const body = response.responses[0].body; + expect(responses).toHaveLength(1); + const body = responses[0].responses[0].body; ok(body); - deepStrictEqual(body.contentTypes, ["text/plain"]); + expect(body.contentTypes).toEqual(["text/plain"]); }); describe("status code", () => { @@ -259,3 +250,111 @@ describe("status code", () => { expect(response.statusCodes).toEqual(201); }); }); + +describe("union of unannotated return types", () => { + it("groups plain model variants into a single union response", async () => { + const responses = await getResponses(` + model Cat { meow: boolean } + model Dog { bark: boolean } + op get(): Cat | Dog; + `); + expect(responses).toHaveLength(1); + expect(responses[0].statusCodes).toBe(200); + const body = responses[0].responses[0].body; + ok(body); + expect(body.type.kind).toBe("Union"); + expect((body.type as Union).variants.size).toBe(2); + }); + + it("preserves the original named union when all variants are plain bodies", async () => { + const responses = await getResponses(` + model Cat { meow: boolean } + model Dog { bark: boolean } + union Pet { cat: Cat, dog: Dog } + op get(): Pet; + `); + expect(responses).toHaveLength(1); + const body = responses[0].responses[0].body; + ok(body); + expect(body.type.kind).toBe("Union"); + expect((body.type as Union).name).toBe("Pet"); + }); + + it("keeps response envelope variants separate from plain body variants", async () => { + const responses = await getResponses(` + model Cat { meow: boolean } + model Dog { bark: boolean } + model NotFoundResponse { @statusCode code: 404; message: string } + op get(): Cat | Dog | NotFoundResponse; + `); + expect(responses).toHaveLength(2); + + const okResponse = responses.find((r) => r.statusCodes === 200); + ok(okResponse); + const okBody = okResponse.responses[0].body; + ok(okBody); + expect(okBody.type.kind).toBe("Union"); + expect((okBody.type as Union).variants.size).toBe(2); + + expect(responses.find((r) => r.statusCodes === 404)).toBeDefined(); + }); + + it("handles nested union mixing plain bodies and response envelopes", async () => { + const responses = await getResponses(` + model Cat { meow: boolean } + model Dog { bark: boolean } + model NotFoundResponse { @statusCode code: 404; message: string } + union CatOrNotFound { cat: Cat, notFound: NotFoundResponse } + op get(): CatOrNotFound | Dog; + `); + expect(responses).toHaveLength(2); + + const okResponse = responses.find((r) => r.statusCodes === 200); + ok(okResponse); + const okBody = okResponse.responses[0].body; + ok(okBody); + expect(okBody.type.kind).toBe("Union"); + expect((okBody.type as Union).variants.size).toBe(2); + + expect(responses.find((r) => r.statusCodes === 404)).toBeDefined(); + }); + + it("handles union with Error model variants as separate responses", async () => { + const responses = await getResponses(` + model Cat { meow: boolean } + @error model MyError { @statusCode code: 500; message: string } + op get(): Cat | MyError; + `); + expect(responses).toHaveLength(2); + + const okResponse = responses.find((r) => r.statusCodes === 200); + ok(okResponse); + expect((okResponse.responses[0].body?.type as Model).name).toBe("Cat"); + + expect(responses.find((r) => r.statusCodes === 500)).toBeDefined(); + }); + + it("handles union with void variant by skipping it", async () => { + const responses = await getResponses(` + model Cat { meow: boolean } + op get(): Cat | void; + `); + expect(responses.length).toBeGreaterThanOrEqual(1); + }); + + it("treats discriminated union as a single body type (not expanded)", async () => { + const responses = await getResponses(` + @discriminated + union Pet { cat: Cat, dog: Dog } + model Cat { kind: "cat"; meow: boolean } + model Dog { kind: "dog"; bark: boolean } + op get(): Pet; + `); + expect(responses).toHaveLength(1); + expect(responses[0].statusCodes).toBe(200); + const body = responses[0].responses[0].body; + ok(body); + expect(body.type.kind).toBe("Union"); + expect((body.type as Union).name).toBe("Pet"); + }); +}); diff --git a/packages/openapi3/src/examples.ts b/packages/openapi3/src/examples.ts index 454bd949735..f49ab56a61d 100644 --- a/packages/openapi3/src/examples.ts +++ b/packages/openapi3/src/examples.ts @@ -101,19 +101,23 @@ export function resolveOperationExamples( if (example.returnType && op.responses) { const match = findResponseForExample(program, example.returnType, op.responses); if (match) { - const value = getBodyValue(example.returnType, match.response.properties); - if (value) { - for (const statusCode of match.statusCodes) { - result.responses[statusCode] ??= {}; - result.responses[statusCode][match.contentType] ??= []; - result.responses[statusCode][match.contentType].push([ - { - value, - title: example.title, - description: example.description, - }, - match.response.body!.type, - ]); + // Try each response content in the matched group to find one that can extract a body value + for (const response of match.responses) { + const value = getBodyValue(example.returnType, response.properties); + if (value) { + for (const statusCode of match.statusCodes) { + result.responses[statusCode] ??= {}; + result.responses[statusCode][match.contentType] ??= []; + result.responses[statusCode][match.contentType].push([ + { + value, + title: example.title, + description: example.description, + }, + response.body!.type, + ]); + } + break; } } } @@ -153,67 +157,87 @@ function findResponseForExample( exampleValue: Value, responses: HttpOperationResponse[], ): - | { contentType: string; statusCodes: string[]; response: HttpOperationResponseContent } + | { contentType: string; statusCodes: string[]; responses: HttpOperationResponseContent[] } | undefined { - const tentatives: [ - { response: HttpOperationResponseContent; contentType?: string; statusCodes?: string[] }, - number, - ][] = []; + // Group response contents by (statusCodes, contentType), then pick the best matching group. + // This ensures that when multiple response contents share the same status code and content type + // (e.g., union variants like ModelA | ModelB both returning 200/json), they are treated as a + // single group rather than competing for individual matching. + const groups = new Map< + string, + { + statusCodes: string[]; + contentType: string; + responses: HttpOperationResponseContent[]; + score: number; + } + >(); + for (const statusCodeResponse of responses) { + const openApiStatusCodes = ignoreDiagnostics( + getOpenAPI3StatusCodes(program, statusCodeResponse.statusCodes, statusCodeResponse.type), + ); + for (const response of statusCodeResponse.responses) { if (response.body === undefined) { continue; } const contentType = getContentTypeValue(exampleValue, response.properties); const statusCode = getStatusCodeValue(exampleValue, response.properties); - const contentTypeProp = response.properties.find((x) => x.kind === "contentType"); // if undefined MUST be application/json - const statusCodeProp = response.properties.find((x) => x.kind === "statusCode"); // if undefined MUST be 200 + const contentTypeProp = response.properties.find((x) => x.kind === "contentType"); + const statusCodeProp = response.properties.find((x) => x.kind === "statusCode"); const statusCodeMatch = statusCode && statusCodeProp && isStatusCodeIn(statusCode, statusCodeResponse.statusCodes); const contentTypeMatch = contentType && response.body?.contentTypes.includes(contentType); + + let score: number; + let resolvedContentType: string; + if (statusCodeMatch && contentTypeMatch) { - return { - contentType, - statusCodes: ignoreDiagnostics( - getOpenAPI3StatusCodes( - program, - statusCodeResponse.statusCodes, - statusCodeResponse.type, - ), - ), - response, - }; + score = 2; + resolvedContentType = contentType; } else if (statusCodeMatch && contentTypeProp === undefined) { - tentatives.push([ - { - response, - statusCodes: ignoreDiagnostics( - getOpenAPI3StatusCodes( - program, - statusCodeResponse.statusCodes, - statusCodeResponse.type, - ), - ), - }, - 1, - ]); + score = 1; + resolvedContentType = "application/json"; } else if (contentTypeMatch && statusCodeMatch === undefined) { - tentatives.push([{ response, contentType }, 1]); + score = 1; + resolvedContentType = contentType; } else if (contentTypeProp === undefined && statusCodeProp === undefined) { - tentatives.push([{ response }, 0]); + score = 0; + resolvedContentType = "application/json"; + } else { + continue; + } + + const key = `${openApiStatusCodes.join(",")}|${resolvedContentType}`; + const existing = groups.get(key); + if (existing) { + existing.responses.push(response); + existing.score = Math.max(existing.score, score); + } else { + groups.set(key, { + statusCodes: openApiStatusCodes, + contentType: resolvedContentType, + responses: [response], + score, + }); } } } - const tentative = tentatives.sort((a, b) => a[1] - b[1]).pop(); - if (tentative) { - return { - contentType: tentative[0].contentType ?? "application/json", - statusCodes: tentative[0].statusCodes ?? ["200"], - response: tentative[0].response, - }; + + let bestGroup: + | { statusCodes: string[]; contentType: string; responses: HttpOperationResponseContent[] } + | undefined; + let bestScore = -1; + for (const group of groups.values()) { + if (group.score > bestScore) { + bestScore = group.score; + bestGroup = group; + } } - return undefined; + + return bestGroup; } /** diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index a4559806325..c2496d88f45 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -1124,8 +1124,10 @@ function createOAPIEmitter( if (contents.length === 1) { obj.content[contentType] = contents[0]; } else { + const { schema: _, ...rest } = contents[0]; obj.content[contentType] = { schema: { anyOf: contents.map((x) => x.schema) as any }, + ...rest, }; } } diff --git a/packages/openapi3/test/examples.test.ts b/packages/openapi3/test/examples.test.ts index b31fc32b01d..0ff7d3b5dff 100644 --- a/packages/openapi3/test/examples.test.ts +++ b/packages/openapi3/test/examples.test.ts @@ -171,6 +171,74 @@ 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("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( ` 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"], }); }); });