From 7454c635ecf128a3c068756765662421e274e4f0 Mon Sep 17 00:00:00 2001 From: Christian Tellnes Date: Thu, 2 Jul 2026 19:24:49 +0200 Subject: [PATCH] feat(openapi3): extend annotated enum strategy to unions of literals When `enum-strategy: annotated` is set, literal unions now emit as `oneOf`/`anyOf` of `const` subschemas with per-variant `title` and `description` from `@summary`/`@doc`, preserving documentation that the default `enum`-merge form discards. Falls back to default on OpenAPI 3.0.0 with a warning. --- ...rategy-annotated-unions-2026-7-2-19-0-0.md | 38 ++++ packages/openapi3/README.md | 4 +- packages/openapi3/src/lib.ts | 4 +- packages/openapi3/src/schema-emitter-3-1.ts | 30 +++ packages/openapi3/test/union-schema.test.ts | 182 ++++++++++++++++++ .../emitters/openapi3/reference/emitter.md | 4 +- 6 files changed, 256 insertions(+), 6 deletions(-) create mode 100644 .chronus/changes/openapi3-enum-strategy-annotated-unions-2026-7-2-19-0-0.md diff --git a/.chronus/changes/openapi3-enum-strategy-annotated-unions-2026-7-2-19-0-0.md b/.chronus/changes/openapi3-enum-strategy-annotated-unions-2026-7-2-19-0-0.md new file mode 100644 index 00000000000..317e323062f --- /dev/null +++ b/.chronus/changes/openapi3-enum-strategy-annotated-unions-2026-7-2-19-0-0.md @@ -0,0 +1,38 @@ +--- +changeKind: feature +packages: + - "@typespec/openapi3" +--- + +Extend the `enum-strategy: annotated` emitter option to unions of literals. When set to `annotated`, a union whose variants are literals is emitted as a `oneOf`/`anyOf` of `const` subschemas with per-variant `title`/`description` taken from `@summary` and `@doc`, instead of collapsing to a single lossy `enum`. Supported for OpenAPI 3.1.0 and above; emitting with OpenAPI 3.0.0 falls back to the default form and reports a warning. + +For example, the following TypeSpec: + +```typespec +/** Set of known error types. */ +union ErrorType { + /** Common error for a bad request. */ + @summary("CommonBadRequest") + commonBadRequest: "https://example.com/errors/bad-request", + + /** The request body could not be parsed. */ + @summary("InvalidBody") + invalidBody: "https://example.com/errors/invalid-body", +} +``` + +emits: + +```yaml +ErrorType: + description: Set of known error types. + anyOf: + - const: https://example.com/errors/bad-request + title: CommonBadRequest + description: Common error for a bad request. + - const: https://example.com/errors/invalid-body + title: InvalidBody + description: The request body could not be parsed. +``` + +Use `@oneOf` on the union to emit `oneOf` instead of `anyOf`. diff --git a/packages/openapi3/README.md b/packages/openapi3/README.md index a9f822d64a7..91e60720cd5 100644 --- a/packages/openapi3/README.md +++ b/packages/openapi3/README.md @@ -178,11 +178,11 @@ See https://github.com/OAI/OpenAPI-Specification/discussions/4622 for discussion **Default:** `"default"` -How to emit TypeSpec enums. Options are: +How to emit TypeSpec enums and unions of literals. Options are: - `default`: Emit as a single schema using the `enum` keyword. - `annotated`: Emit as a `oneOf` of `const` subschemas annotated with `title` and `description` - from each member's `@summary` and `@doc`. Follows the OpenAPI 3.1.1 annotated enumerations pattern. + from each member's/variant's `@summary` and `@doc`. Follows the OpenAPI 3.1.1 annotated enumerations pattern. Only supported by OpenAPI 3.1.0 and above; on 3.0.0 the `default` style is used and a warning is reported. ## Decorators diff --git a/packages/openapi3/src/lib.ts b/packages/openapi3/src/lib.ts index 48e6ef75d12..9eb420c41d7 100644 --- a/packages/openapi3/src/lib.ts +++ b/packages/openapi3/src/lib.ts @@ -289,10 +289,10 @@ const EmitterOptionsSchema: JSONSchemaType = { nullable: true, default: "default", description: [ - "How to emit TypeSpec enums. Options are:", + "How to emit TypeSpec enums and unions of literals. Options are:", " - `default`: Emit as a single schema using the `enum` keyword.", " - `annotated`: Emit as a `oneOf` of `const` subschemas annotated with `title` and `description`", - " from each member's `@summary` and `@doc`. Follows the OpenAPI 3.1.1 annotated enumerations pattern.", + " from each member's/variant's `@summary` and `@doc`. Follows the OpenAPI 3.1.1 annotated enumerations pattern.", " Only supported by OpenAPI 3.1.0 and above; on 3.0.0 the `default` style is used and a warning is reported.", ].join("\n"), }, diff --git a/packages/openapi3/src/schema-emitter-3-1.ts b/packages/openapi3/src/schema-emitter-3-1.ts index ba587c9c25f..7b8c85b1ea5 100644 --- a/packages/openapi3/src/schema-emitter-3-1.ts +++ b/packages/openapi3/src/schema-emitter-3-1.ts @@ -26,6 +26,7 @@ import { Tuple, Type, Union, + UnionVariant, } from "@typespec/compiler"; import { MetadataInfo } from "@typespec/http"; import { getOneOf } from "./decorators.js"; @@ -245,6 +246,26 @@ export class OpenAPI31SchemaEmitter extends OpenAPI3SchemaEmitterBase { const program = this.emitter.getProgram(); const [discriminated] = getDiscriminatedUnion(program, union); @@ -271,6 +292,15 @@ export class OpenAPI31SchemaEmitter extends OpenAPI3SchemaEmitterBase { }); }); }); + +worksFor(["3.1.0", "3.2.0"], ({ oapiForModel }) => { + describe("enum-strategy: annotated (unions of literals)", () => { + it("emits annotated `const` subschemas for a documented literal union", async () => { + const res = await oapiForModel( + "ErrorType", + ` + /** Set of known error types. */ + union ErrorType { + /** Common error for a bad request. */ + @summary("CommonBadRequest") + commonBadRequest: "https://example.com/errors/bad-request", + + /** Body could not be parsed. */ + @summary("InvalidBody") + invalidBody: "https://example.com/errors/invalid-body", + } + `, + { "enum-strategy": "annotated" }, + ); + + deepStrictEqual(res.schemas.ErrorType, { + description: "Set of known error types.", + anyOf: [ + { + const: "https://example.com/errors/bad-request", + title: "CommonBadRequest", + description: "Common error for a bad request.", + }, + { + const: "https://example.com/errors/invalid-body", + title: "InvalidBody", + description: "Body could not be parsed.", + }, + ], + }); + }); + + it("uses `oneOf` when the union carries `@oneOf`", async () => { + const res = await oapiForModel( + "ErrorType", + ` + @oneOf + union ErrorType { + /** A. */ + a: "a", + /** B. */ + b: "b", + } + `, + { "enum-strategy": "annotated" }, + ); + + deepStrictEqual(res.schemas.ErrorType, { + oneOf: [ + { const: "a", description: "A." }, + { const: "b", description: "B." }, + ], + }); + }); + + it("omits title/description for variants without docs", async () => { + const res = await oapiForModel( + "Color", + ` + union Color { + red: "red", + green: "green", + /** Blue is the warmest color. */ + blue: "blue", + } + `, + { "enum-strategy": "annotated" }, + ); + + deepStrictEqual(res.schemas.Color, { + anyOf: [ + { const: "red" }, + { const: "green" }, + { const: "blue", description: "Blue is the warmest color." }, + ], + }); + }); + + it("emits annotated `const` subschemas for number-valued literal unions", async () => { + const res = await oapiForModel( + "Priority", + ` + union Priority { + /** Low priority. */ + low: 1, + /** High priority. */ + high: 10, + } + `, + { "enum-strategy": "annotated" }, + ); + + deepStrictEqual(res.schemas.Priority, { + anyOf: [ + { const: 1, description: "Low priority." }, + { const: 10, description: "High priority." }, + ], + }); + }); + + it("keeps model/scalar variants alongside annotated literal `const` members", async () => { + const res = await oapiForModel( + "Mixed", + ` + model Detailed { code: string; } + union Mixed { + /** Simple literal. */ + @summary("Simple") + simple: "simple", + detailed: Detailed, + } + `, + { "enum-strategy": "annotated" }, + ); + + deepStrictEqual(res.schemas.Mixed, { + anyOf: [ + { const: "simple", title: "Simple", description: "Simple literal." }, + { $ref: "#/components/schemas/Detailed" }, + ], + }); + }); + + it("does not change literal unions under the default strategy", async () => { + const res = await oapiForModel( + "ErrorType", + ` + union ErrorType { + /** Common error for a bad request. */ + @summary("CommonBadRequest") + commonBadRequest: "https://example.com/errors/bad-request", + + /** Body could not be parsed. */ + @summary("InvalidBody") + invalidBody: "https://example.com/errors/invalid-body", + } + `, + ); + + deepStrictEqual(res.schemas.ErrorType, { + type: "string", + enum: ["https://example.com/errors/bad-request", "https://example.com/errors/invalid-body"], + }); + }); + }); +}); + +worksFor(["3.0.0"], ({ emitOpenApiWithDiagnostics }) => { + it("falls back to the default enum form for literal unions on OpenAPI 3.0.0", async () => { + const [doc, diagnostics] = await emitOpenApiWithDiagnostics( + ` + @service + namespace Test; + + union ErrorType { + /** A. */ + a: "a", + /** B. */ + b: "b", + } + op read(): ErrorType; + `, + { "enum-strategy": "annotated" }, + ); + + expectDiagnostics(diagnostics, { + code: "@typespec/openapi3/enum-strategy-not-supported", + severity: "warning", + }); + + deepStrictEqual(doc.components!.schemas!.ErrorType, { + type: "string", + enum: ["a", "b"], + }); + }); +}); diff --git a/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md b/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md index 39f7fc1e342..7d9f3b5dd5e 100644 --- a/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md +++ b/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md @@ -172,9 +172,9 @@ See https://github.com/OAI/OpenAPI-Specification/discussions/4622 for discussion **Default:** `"default"` -How to emit TypeSpec enums. Options are: +How to emit TypeSpec enums and unions of literals. Options are: - `default`: Emit as a single schema using the `enum` keyword. - `annotated`: Emit as a `oneOf` of `const` subschemas annotated with `title` and `description` - from each member's `@summary` and `@doc`. Follows the OpenAPI 3.1.1 annotated enumerations pattern. + from each member's/variant's `@summary` and `@doc`. Follows the OpenAPI 3.1.1 annotated enumerations pattern. Only supported by OpenAPI 3.1.0 and above; on 3.0.0 the `default` style is used and a warning is reported.