diff --git a/.chronus/changes/openapi3-enum-style-annotated-2026-6-4-13-30-0.md b/.chronus/changes/openapi3-enum-style-annotated-2026-6-4-13-30-0.md new file mode 100644 index 00000000000..3e7738fdddf --- /dev/null +++ b/.chronus/changes/openapi3-enum-style-annotated-2026-6-4-13-30-0.md @@ -0,0 +1,42 @@ +--- +changeKind: feature +packages: + - "@typespec/openapi3" +--- + +Add opt-in `enum-strategy` emitter option to emit TypeSpec enums as [annotated enumerations](https://spec.openapis.org/oas/v3.1.1.html#annotated-enumerations) (a `oneOf` of `const` subschemas with per-member `title`/`description`). Supported for OpenAPI 3.1.0 and above; emitting with OpenAPI 3.0.0 falls back to the default form and reports a warning. + +```yaml +options: + "@typespec/openapi3": + enum-strategy: annotated +``` + +For example, the following TypeSpec: + +```typespec +/** Type of pet. */ +enum PetType { + /** A loyal canine companion. */ + @summary("Dog") + Dog: "dog", + + /** A self-sufficient feline. */ + @summary("Cat") + Cat: "cat", +} +``` + +emits: + +```yaml +PetType: + description: Type of pet. + oneOf: + - const: dog + title: Dog + description: A loyal canine companion. + - const: cat + title: Cat + description: A self-sufficient feline. +``` diff --git a/packages/openapi3/README.md b/packages/openapi3/README.md index d0ec89cf3e1..a9f822d64a7 100644 --- a/packages/openapi3/README.md +++ b/packages/openapi3/README.md @@ -172,6 +172,19 @@ See https://github.com/OAI/OpenAPI-Specification/discussions/4622 for discussion | `kind` | `"parent-container" \| "fqn" \| "explicit-only"` | `"parent-container"` | Determines how to generate operation IDs when `@operationId` is not used.
Avaliable options are:
- `parent-container`: Uses the parent namespace and operation name to generate the ID.
- `fqn`: Uses the fully qualified name of the operation to generate the ID.
- `explicit-only`: Only use explicitly defined operation IDs. | | `separator` | `string` | | Separator used to join segment in the operation name. | +### `enum-strategy` + +**Type:** `"default" | "annotated"` + +**Default:** `"default"` + +How to emit TypeSpec enums. 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. + Only supported by OpenAPI 3.1.0 and above; on 3.0.0 the `default` style is used and a warning is reported. + ## Decorators ### TypeSpec.OpenAPI diff --git a/packages/openapi3/src/lib.ts b/packages/openapi3/src/lib.ts index d5c29f3179b..48e6ef75d12 100644 --- a/packages/openapi3/src/lib.ts +++ b/packages/openapi3/src/lib.ts @@ -3,6 +3,7 @@ import { createTypeSpecLibrary, JSONSchemaType, paramMessage } from "@typespec/c export type FileType = "yaml" | "json"; export type OpenAPIVersion = "3.0.0" | "3.1.0" | "3.2.0"; export type ExperimentalParameterExamplesStrategy = "data" | "serialized"; +export type EnumStrategy = "default" | "annotated"; export interface OpenAPI3EmitterOptions { /** * If the content should be serialized as YAML or JSON. Can be a single value or an array to emit multiple file types. @@ -108,6 +109,20 @@ export interface OpenAPI3EmitterOptions { /** Separator used to join segment in the operation name. */ separator?: string; }; + + /** + * How to emit TypeSpec enums. + * + * - `default`: Emit as a single schema using the `enum` keyword. + * - `annotated`: Emit as a `oneOf` of `const` subschemas, each annotated with `title` and `description` + * when the corresponding enum member has `@summary` or `@doc`. This follows the OpenAPI 3.1.1 + * [annotated enumerations](https://spec.openapis.org/oas/v3.1.1.html#annotated-enumerations) pattern. + * Only supported by OpenAPI 3.1.0 and above. When emitting OpenAPI 3.0.0, a warning will be reported + * and the `default` style will be used instead. + * + * @default "default" + */ + "enum-strategy"?: EnumStrategy; } export type OperationIdStrategy = "parent-container" | "fqn" | "explicit-only"; @@ -268,6 +283,19 @@ const EmitterOptionsSchema: JSONSchemaType = { }, ], } as any, + "enum-strategy": { + type: "string", + enum: ["default", "annotated"], + nullable: true, + default: "default", + description: [ + "How to emit TypeSpec enums. 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.", + " 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"), + }, }, required: [], }; @@ -428,6 +456,13 @@ export const $lib = createTypeSpecLibrary({ default: paramMessage`Default value is not supported in OpenAPI 3.0 ${"message"}`, }, }, + "enum-strategy-not-supported": { + severity: "warning", + messages: { + default: + "`enum-strategy: annotated` is only supported for OpenAPI 3.1.0 and above. The default enum strategy will be used for OpenAPI 3.0.0.", + }, + }, }, emitter: { options: EmitterOptionsSchema as JSONSchemaType, diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 0c41f87d160..ad085c74e47 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -33,6 +33,7 @@ import { Namespace, navigateTypesInNamespace, NewLine, + NoTarget, Program, resolvePath, Service, @@ -86,6 +87,7 @@ import { getExampleOrExamples, OperationExamples, resolveOperationExamples } fro import { JsonSchemaModule, resolveJsonSchemaModule } from "./json-schema.js"; import { createDiagnostic, + EnumStrategy, FileType, OpenAPI3EmitterOptions, OpenAPIVersion, @@ -219,6 +221,11 @@ export function resolveOptions( const openapiVersions = resolvedOptions["openapi-versions"] ?? ["3.0.0"]; + const enumStrategy: EnumStrategy = resolvedOptions["enum-strategy"] ?? "default"; + if (enumStrategy === "annotated" && openapiVersions.includes("3.0.0")) { + reportDiagnostic(context.program, { code: "enum-strategy-not-supported", target: NoTarget }); + } + const specDir = openapiVersions.length > 1 ? "{openapi-version}" : ""; return { fileTypes, @@ -231,6 +238,7 @@ export function resolveOptions( sealObjectSchemas: resolvedOptions["seal-object-schemas"], parameterExamplesStrategy: resolvedOptions["experimental-parameter-examples"], operationIdStrategy: resolveOperationIdStrategy(resolvedOptions["operation-id-strategy"]), + enumStrategy, }; } @@ -272,6 +280,7 @@ export interface ResolvedOpenAPI3EmitterOptions { sealObjectSchemas: boolean; parameterExamplesStrategy?: "data" | "serialized"; operationIdStrategy: { kind: OperationIdStrategy; separator: string }; + enumStrategy: EnumStrategy; } function createOAPIEmitter( diff --git a/packages/openapi3/src/schema-emitter-3-1.ts b/packages/openapi3/src/schema-emitter-3-1.ts index 42b07d00c28..af58ad561d2 100644 --- a/packages/openapi3/src/schema-emitter-3-1.ts +++ b/packages/openapi3/src/schema-emitter-3-1.ts @@ -12,9 +12,11 @@ import { compilerAssert, Enum, getDiscriminatedUnion, + getDoc, getExamples, getMaxValueExclusive, getMinValueExclusive, + getSummary, IntrinsicScalarName, IntrinsicType, Model, @@ -183,6 +185,10 @@ export class OpenAPI31SchemaEmitter extends OpenAPI3SchemaEmitterBase(); const enumValues = new Set(); for (const member of en.members.values()) { @@ -200,6 +206,26 @@ export class OpenAPI31SchemaEmitter extends OpenAPI3SchemaEmitterBase { const program = this.emitter.getProgram(); const [discriminated] = getDiscriminatedUnion(program, union); diff --git a/packages/openapi3/test/enums.test.ts b/packages/openapi3/test/enums.test.ts index 3d8a325a441..087ba49ef12 100644 --- a/packages/openapi3/test/enums.test.ts +++ b/packages/openapi3/test/enums.test.ts @@ -1,6 +1,7 @@ import { expectDiagnostics } from "@typespec/compiler/testing"; import { deepStrictEqual, strictEqual } from "assert"; import { it } from "vitest"; +import { diagnoseOpenApiFor } from "./test-host.js"; import { supportedVersions, worksFor } from "./works-for.js"; worksFor(supportedVersions, ({ diagnoseOpenApiFor, oapiForModel }) => { @@ -48,3 +49,190 @@ worksFor(["3.1.0"], ({ oapiForModel }) => { }); }); }); + +worksFor(["3.1.0", "3.2.0"], ({ oapiForModel }) => { + it("emits annotated enums with `enum-strategy: annotated`", async () => { + const res = await oapiForModel( + "PetType", + ` + @doc("Type of pet.") + enum PetType { + /** A loyal canine companion. */ + @summary("Dog") + Dog: "dog", + + /** A self-sufficient feline. */ + @summary("Cat") + Cat: "cat", + } + `, + { "enum-strategy": "annotated" }, + ); + + deepStrictEqual(res.schemas.PetType, { + description: "Type of pet.", + oneOf: [ + { + const: "dog", + title: "Dog", + description: "A loyal canine companion.", + }, + { + const: "cat", + title: "Cat", + description: "A self-sufficient feline.", + }, + ], + }); + }); + + it("emits annotated enums for number-valued enums", async () => { + const res = await oapiForModel( + "Priority", + ` + enum Priority { + /** Low priority. */ + Low: 1, + /** High priority. */ + High: 10, + } + `, + { "enum-strategy": "annotated" }, + ); + + deepStrictEqual(res.schemas.Priority, { + oneOf: [ + { const: 1, description: "Low priority." }, + { const: 10, description: "High priority." }, + ], + }); + }); + + it("emits annotated enums for mixed-type enums", async () => { + const res = await oapiForModel( + "Mixed", + ` + enum Mixed { + asString: "value", + asNumber: 42, + } + `, + { "enum-strategy": "annotated" }, + ); + + deepStrictEqual(res.schemas.Mixed, { + oneOf: [{ const: "value" }, { const: 42 }], + }); + }); + + it("emits annotated enums omitting title/description for members without docs", async () => { + const res = await oapiForModel( + "Color", + ` + enum Color { + Red, + Green: "green", + /** Blue is the warmest color. */ + Blue: "blue", + } + `, + { "enum-strategy": "annotated" }, + ); + + deepStrictEqual(res.schemas.Color, { + oneOf: [ + { const: "Red" }, + { const: "green" }, + { const: "blue", description: "Blue is the warmest color." }, + ], + }); + }); + + it("emits default enum form when `enum-strategy` is unset", async () => { + const res = await oapiForModel( + "PetType", + ` + /** Type of pet. */ + enum PetType { + /** A loyal canine companion. */ + @summary("Dog") + Dog: "dog", + } + `, + ); + + deepStrictEqual(res.schemas.PetType, { + type: "string", + enum: ["dog"], + description: "Type of pet.", + }); + }); +}); + +worksFor(["3.0.0"], ({ emitOpenApiWithDiagnostics }) => { + it("falls back to the default enum form when `enum-strategy: annotated` is set", async () => { + const [doc, diagnostics] = await emitOpenApiWithDiagnostics( + ` + @service + namespace Test; + + /** Type of pet. */ + enum PetType { + /** A loyal canine companion. */ + Dog: "dog", + /** A self-sufficient feline. */ + Cat: "cat", + } + op read(): PetType; + `, + { "enum-strategy": "annotated" }, + ); + + expectDiagnostics(diagnostics, { + code: "@typespec/openapi3/enum-strategy-not-supported", + severity: "warning", + }); + + deepStrictEqual(doc.components!.schemas!.PetType, { + type: "string", + enum: ["dog", "cat"], + description: "Type of pet.", + }); + }); +}); + +it("warns when `enum-strategy: annotated` is used with OpenAPI 3.0.0", async () => { + const diagnostics = await diagnoseOpenApiFor(`enum PetType { Dog: "dog" }`, { + "enum-strategy": "annotated", + "openapi-versions": ["3.0.0"], + }); + + expectDiagnostics(diagnostics, { + code: "@typespec/openapi3/enum-strategy-not-supported", + severity: "warning", + }); +}); + +it("warns once when `enum-strategy: annotated` is used with mixed openapi-versions including 3.0.0", async () => { + const diagnostics = await diagnoseOpenApiFor(`enum PetType { Dog: "dog" }`, { + "enum-strategy": "annotated", + "openapi-versions": ["3.0.0", "3.1.0"], + }); + + expectDiagnostics(diagnostics, { + code: "@typespec/openapi3/enum-strategy-not-supported", + severity: "warning", + }); +}); + +it("does not warn when `enum-strategy: annotated` is used with only 3.1.0/3.2.0", async () => { + const diagnostics = await diagnoseOpenApiFor(`enum PetType { Dog: "dog" }`, { + "enum-strategy": "annotated", + "openapi-versions": ["3.1.0", "3.2.0"], + }); + + strictEqual( + diagnostics.filter((d) => d.code === "@typespec/openapi3/enum-strategy-not-supported").length, + 0, + ); +}); 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 f1c6d3539bf..39f7fc1e342 100644 --- a/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md +++ b/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md @@ -165,3 +165,16 @@ See https://github.com/OAI/OpenAPI-Specification/discussions/4622 for discussion | ----------- | ------------------------------------------------ | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `kind` | `"parent-container" \| "fqn" \| "explicit-only"` | `"parent-container"` | Determines how to generate operation IDs when `@operationId` is not used.
Avaliable options are:
- `parent-container`: Uses the parent namespace and operation name to generate the ID.
- `fqn`: Uses the fully qualified name of the operation to generate the ID.
- `explicit-only`: Only use explicitly defined operation IDs. | | `separator` | `string` | | Separator used to join segment in the operation name. | + +### `enum-strategy` + +**Type:** `"default" | "annotated"` + +**Default:** `"default"` + +How to emit TypeSpec enums. 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. + Only supported by OpenAPI 3.1.0 and above; on 3.0.0 the `default` style is used and a warning is reported.