Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
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
189 changes: 144 additions & 45 deletions packages/http/test/responses.test.ts
Original file line number Diff line number Diff line change
@@ -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<HttpOperationResponse[]> {
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(
Expand Down Expand Up @@ -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(`
Comment thread
timotheeguerin marked this conversation as resolved.
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,

Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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");
});
});
Loading
Loading