Skip to content
Draft
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
122 changes: 78 additions & 44 deletions packages/http/src/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
68 changes: 68 additions & 0 deletions packages/openapi3/test/examples.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,74 @@
});
});

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<string, any>)["400"];
ok(typeof resp400 === "object" && "content" in resp400);
expect(resp400.content?.["application/json"].example).toEqual({

Check failure on line 237 in packages/openapi3/test/examples.test.ts

View workflow job for this annotation

GitHub Actions / Build (ubuntu-latest, Node 24.x)

[@typespec/openapi3] test/examples.test.ts > openapi '3.2.0' > Map to the right status code > set example on union of response envelopes with same status code

AssertionError: expected undefined to deeply equal { error1: 'abc' } - Expected: { "error1": "abc", } + Received: undefined ❯ test/examples.test.ts:237:61

Check failure on line 237 in packages/openapi3/test/examples.test.ts

View workflow job for this annotation

GitHub Actions / Build (ubuntu-latest, Node 24.x)

[@typespec/openapi3] test/examples.test.ts > openapi '3.1.0' > Map to the right status code > set example on union of response envelopes with same status code

AssertionError: expected undefined to deeply equal { error1: 'abc' } - Expected: { "error1": "abc", } + Received: undefined ❯ test/examples.test.ts:237:61

Check failure on line 237 in packages/openapi3/test/examples.test.ts

View workflow job for this annotation

GitHub Actions / Build (ubuntu-latest, Node 24.x)

[@typespec/openapi3] test/examples.test.ts > openapi '3.0.0' > Map to the right status code > set example on union of response envelopes with same status code

AssertionError: expected undefined to deeply equal { error1: 'abc' } - Expected: { "error1": "abc", } + Received: undefined ❯ test/examples.test.ts:237:61

Check failure on line 237 in packages/openapi3/test/examples.test.ts

View workflow job for this annotation

GitHub Actions / Build (ubuntu-latest, Node 20.x)

[@typespec/openapi3] test/examples.test.ts > openapi '3.2.0' > Map to the right status code > set example on union of response envelopes with same status code

AssertionError: expected undefined to deeply equal { error1: 'abc' } - Expected: { "error1": "abc", } + Received: undefined ❯ test/examples.test.ts:237:61

Check failure on line 237 in packages/openapi3/test/examples.test.ts

View workflow job for this annotation

GitHub Actions / Build (ubuntu-latest, Node 20.x)

[@typespec/openapi3] test/examples.test.ts > openapi '3.1.0' > Map to the right status code > set example on union of response envelopes with same status code

AssertionError: expected undefined to deeply equal { error1: 'abc' } - Expected: { "error1": "abc", } + Received: undefined ❯ test/examples.test.ts:237:61

Check failure on line 237 in packages/openapi3/test/examples.test.ts

View workflow job for this annotation

GitHub Actions / Build (ubuntu-latest, Node 20.x)

[@typespec/openapi3] test/examples.test.ts > openapi '3.0.0' > Map to the right status code > set example on union of response envelopes with same status code

AssertionError: expected undefined to deeply equal { error1: 'abc' } - Expected: { "error1": "abc", } + Received: undefined ❯ test/examples.test.ts:237:61

Check failure on line 237 in packages/openapi3/test/examples.test.ts

View workflow job for this annotation

GitHub Actions / Build (ubuntu-latest, Node 22.x)

[@typespec/openapi3] test/examples.test.ts > openapi '3.2.0' > Map to the right status code > set example on union of response envelopes with same status code

AssertionError: expected undefined to deeply equal { error1: 'abc' } - Expected: { "error1": "abc", } + Received: undefined ❯ test/examples.test.ts:237:61

Check failure on line 237 in packages/openapi3/test/examples.test.ts

View workflow job for this annotation

GitHub Actions / Build (ubuntu-latest, Node 22.x)

[@typespec/openapi3] test/examples.test.ts > openapi '3.1.0' > Map to the right status code > set example on union of response envelopes with same status code

AssertionError: expected undefined to deeply equal { error1: 'abc' } - Expected: { "error1": "abc", } + Received: undefined ❯ test/examples.test.ts:237:61

Check failure on line 237 in packages/openapi3/test/examples.test.ts

View workflow job for this annotation

GitHub Actions / Build (ubuntu-latest, Node 22.x)

[@typespec/openapi3] test/examples.test.ts > openapi '3.0.0' > Map to the right status code > set example on union of response envelopes with same status code

AssertionError: expected undefined to deeply equal { error1: 'abc' } - Expected: { "error1": "abc", } + Received: undefined ❯ test/examples.test.ts:237:61

Check failure on line 237 in packages/openapi3/test/examples.test.ts

View workflow job for this annotation

GitHub Actions / Build (windows-latest, Node 24.x)

[@typespec/openapi3] test/examples.test.ts > openapi '3.2.0' > Map to the right status code > set example on union of response envelopes with same status code

AssertionError: expected undefined to deeply equal { error1: 'abc' } - Expected: { "error1": "abc", } + Received: undefined ❯ test/examples.test.ts:237:61

Check failure on line 237 in packages/openapi3/test/examples.test.ts

View workflow job for this annotation

GitHub Actions / Build (windows-latest, Node 24.x)

[@typespec/openapi3] test/examples.test.ts > openapi '3.1.0' > Map to the right status code > set example on union of response envelopes with same status code

AssertionError: expected undefined to deeply equal { error1: 'abc' } - Expected: { "error1": "abc", } + Received: undefined ❯ test/examples.test.ts:237:61

Check failure on line 237 in packages/openapi3/test/examples.test.ts

View workflow job for this annotation

GitHub Actions / Build (windows-latest, Node 24.x)

[@typespec/openapi3] test/examples.test.ts > openapi '3.0.0' > Map to the right status code > set example on union of response envelopes with same status code

AssertionError: expected undefined to deeply equal { error1: 'abc' } - Expected: { "error1": "abc", } + Received: undefined ❯ test/examples.test.ts:237:61

Check failure on line 237 in packages/openapi3/test/examples.test.ts

View workflow job for this annotation

GitHub Actions / Build (windows-latest, Node 22.x)

[@typespec/openapi3] test/examples.test.ts > openapi '3.2.0' > Map to the right status code > set example on union of response envelopes with same status code

AssertionError: expected undefined to deeply equal { error1: 'abc' } - Expected: { "error1": "abc", } + Received: undefined ❯ test/examples.test.ts:237:61

Check failure on line 237 in packages/openapi3/test/examples.test.ts

View workflow job for this annotation

GitHub Actions / Build (windows-latest, Node 22.x)

[@typespec/openapi3] test/examples.test.ts > openapi '3.1.0' > Map to the right status code > set example on union of response envelopes with same status code

AssertionError: expected undefined to deeply equal { error1: 'abc' } - Expected: { "error1": "abc", } + Received: undefined ❯ test/examples.test.ts:237:61

Check failure on line 237 in packages/openapi3/test/examples.test.ts

View workflow job for this annotation

GitHub Actions / Build (windows-latest, Node 22.x)

[@typespec/openapi3] test/examples.test.ts > openapi '3.0.0' > Map to the right status code > set example on union of response envelopes with same status code

AssertionError: expected undefined to deeply equal { error1: 'abc' } - Expected: { "error1": "abc", } + Received: undefined ❯ test/examples.test.ts:237:61

Check failure on line 237 in packages/openapi3/test/examples.test.ts

View workflow job for this annotation

GitHub Actions / Build (windows-latest, Node 20.x)

[@typespec/openapi3] test/examples.test.ts > openapi '3.2.0' > Map to the right status code > set example on union of response envelopes with same status code

AssertionError: expected undefined to deeply equal { error1: 'abc' } - Expected: { "error1": "abc", } + Received: undefined ❯ test/examples.test.ts:237:61

Check failure on line 237 in packages/openapi3/test/examples.test.ts

View workflow job for this annotation

GitHub Actions / Build (windows-latest, Node 20.x)

[@typespec/openapi3] test/examples.test.ts > openapi '3.1.0' > Map to the right status code > set example on union of response envelopes with same status code

AssertionError: expected undefined to deeply equal { error1: 'abc' } - Expected: { "error1": "abc", } + Received: undefined ❯ test/examples.test.ts:237:61

Check failure on line 237 in packages/openapi3/test/examples.test.ts

View workflow job for this annotation

GitHub Actions / Build (windows-latest, Node 20.x)

[@typespec/openapi3] test/examples.test.ts > openapi '3.0.0' > Map to the right status code > set example on union of response envelopes with same status code

AssertionError: expected undefined to deeply equal { error1: 'abc' } - Expected: { "error1": "abc", } + Received: undefined ❯ test/examples.test.ts:237:61
error1: "abc",
});
});

it("apply to status code ranges", async () => {
const res: OpenAPI3Document = await openApiFor(
`
Expand Down
6 changes: 2 additions & 4 deletions packages/openapi3/test/union-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
});
});
});
Expand Down
Loading