diff --git a/.chronus/changes/openapi3-x-enum-varnames-2026-3-26.md b/.chronus/changes/openapi3-x-enum-varnames-2026-3-26.md new file mode 100644 index 00000000000..98d5869e649 --- /dev/null +++ b/.chronus/changes/openapi3-x-enum-varnames-2026-3-26.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/openapi3" +--- + +Add `include-x-enum-varnames` emitter option to emit the `x-enum-varnames` OpenAPI extension on enums, preserving the original enum member names as defined in TypeSpec diff --git a/packages/http-server-js/src/util/openapi3.ts b/packages/http-server-js/src/util/openapi3.ts index 268ec62fb17..c0f68551e25 100644 --- a/packages/http-server-js/src/util/openapi3.ts +++ b/packages/http-server-js/src/util/openapi3.ts @@ -26,6 +26,7 @@ export async function getOpenApi3ServiceRecord( const serviceRecords = await openapi3.getOpenAPI3(program, { "include-x-typespec-name": "never", + "include-x-enum-varnames": false, "omit-unreachable-types": true, "safeint-strategy": "int64", }); diff --git a/packages/openapi3/README.md b/packages/openapi3/README.md index 6512a4c1a86..7b412a6c7f3 100644 --- a/packages/openapi3/README.md +++ b/packages/openapi3/README.md @@ -108,6 +108,16 @@ By default all types declared under the service namespace will be included. With If the generated openapi types should have the `x-typespec-name` extension set with the name of the TypeSpec type that created it. This extension is meant for debugging and should not be depended on. +### `include-x-enum-varnames` + +**Type:** `boolean` + +If the generated openapi enums should have the `x-enum-varnames` extension filled. +This maintains the key of any enum value that defines it in the form `key: value`. +The default behavior is to use the value as both the key and value. + +Default: `false` + ### `safeint-strategy` **Type:** `"double-int" | "int64"` diff --git a/packages/openapi3/src/cli/actions/convert/generators/generate-model.ts b/packages/openapi3/src/cli/actions/convert/generators/generate-model.ts index f73040d194b..811766c4c75 100644 --- a/packages/openapi3/src/cli/actions/convert/generators/generate-model.ts +++ b/packages/openapi3/src/cli/actions/convert/generators/generate-model.ts @@ -58,7 +58,13 @@ function generateEnum(tsEnum: TypeSpecEnum): string { const schema = tsEnum.schema; - if (schema.enum) { + if (schema.enum && schema["x-enum-varnames"]) { + definitions.push( + ...schema.enum.map( + (e, i) => `${schema["x-enum-varnames"][i] ?? JSON.stringify(e)}: ${JSON.stringify(e)},`, + ), + ); + } else if (schema.enum) { definitions.push(...schema.enum.map((e) => `${JSON.stringify(e)},`)); } diff --git a/packages/openapi3/src/lib.ts b/packages/openapi3/src/lib.ts index d5c29f3179b..61b501e8558 100644 --- a/packages/openapi3/src/lib.ts +++ b/packages/openapi3/src/lib.ts @@ -68,6 +68,14 @@ export interface OpenAPI3EmitterOptions { */ "include-x-typespec-name"?: "inline-only" | "never"; + /** + * If the generated openapi enums should have the `x-enum-varnames` extension filled. + * This maintains the key of any enum value that defines it in the form `key: value`. + * The default behavior is to use the value as both the key and value. + * @default false + */ + "include-x-enum-varnames"?: boolean; + /** * How to handle safeint type. Options are: * - `double-int`: Will produce `type: integer, format: double-int` @@ -217,6 +225,13 @@ const EmitterOptionsSchema: JSONSchemaType = { description: "If the generated openapi types should have the `x-typespec-name` extension set with the name of the TypeSpec type that created it.\nThis extension is meant for debugging and should not be depended on.", }, + "include-x-enum-varnames": { + type: "boolean", + nullable: true, + default: false, + description: + "If the generated openapi enums should have the `x-enum-varnames` extension filled.\nThis maintains the key of any enum value that defines it in the form `key: value`.\nThe default behavior is to use the value as both the key and value.", + }, "safeint-strategy": { type: "string", enum: ["double-int", "int64"], diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index a4559806325..42039163ad1 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -139,6 +139,7 @@ const defaultOptions = { "new-line": "lf", "omit-unreachable-types": false, "include-x-typespec-name": "never", + "include-x-enum-varnames": false, "safeint-strategy": "int64", "seal-object-schemas": false, } as const; @@ -225,6 +226,7 @@ export function resolveOptions( newLine: resolvedOptions["new-line"], omitUnreachableTypes: resolvedOptions["omit-unreachable-types"], includeXTypeSpecName: resolvedOptions["include-x-typespec-name"], + includeXEnumVarNames: resolvedOptions["include-x-enum-varnames"], safeintStrategy: resolvedOptions["safeint-strategy"], outputFile: resolvePath(context.emitterOutputDir, specDir, outputFile), openapiVersions, @@ -268,6 +270,7 @@ export interface ResolvedOpenAPI3EmitterOptions { newLine: NewLine; omitUnreachableTypes: boolean; includeXTypeSpecName: "inline-only" | "never"; + includeXEnumVarNames: boolean; safeintStrategy: "double-int" | "int64"; sealObjectSchemas: boolean; parameterExamplesStrategy?: "data" | "serialized"; @@ -1643,7 +1646,34 @@ function createOAPIEmitter( "enum" in apply.schema && apply.schema.enum ) { - schema.enum = [...new Set([...schema.enum, ...apply.schema.enum])]; + const hasEnumVarnames = + "x-enum-varnames" in schema && + schema["x-enum-varnames"] && + "x-enum-varnames" in apply.schema && + apply.schema["x-enum-varnames"]; + + // Merge enum values and varnames together via a map so that + // deduplication by enum value keeps the two arrays index-aligned. + const mergedEnumMembers = new Map(); + for (let i = 0; i < schema.enum.length; i++) { + mergedEnumMembers.set( + schema.enum[i], + hasEnumVarnames ? schema["x-enum-varnames"]![i] : undefined, + ); + } + for (let i = 0; i < apply.schema.enum.length; i++) { + if (!mergedEnumMembers.has(apply.schema.enum[i])) { + mergedEnumMembers.set( + apply.schema.enum[i], + hasEnumVarnames ? apply.schema["x-enum-varnames"]![i] : undefined, + ); + } + } + + schema.enum = [...mergedEnumMembers.keys()]; + if (hasEnumVarnames) { + schema["x-enum-varnames"] = [...mergedEnumMembers.values()] as string[]; + } } target.schema = schema; } else { diff --git a/packages/openapi3/src/schema-emitter-3-0.ts b/packages/openapi3/src/schema-emitter-3-0.ts index da93ac944f6..43161861c1f 100644 --- a/packages/openapi3/src/schema-emitter-3-0.ts +++ b/packages/openapi3/src/schema-emitter-3-0.ts @@ -120,10 +120,10 @@ export class OpenAPI3SchemaEmitter extends OpenAPI3SchemaEmitterBase(); - const enumValues = new Set(); + const enumMembers = new Map(); for (const member of en.members.values()) { enumTypes.add(typeof member.value === "number" ? "number" : "string"); - enumValues.add(member.value ?? member.name); + enumMembers.set(member.value ?? member.name, member.name); } if (enumTypes.size > 1) { @@ -132,9 +132,13 @@ export class OpenAPI3SchemaEmitter extends OpenAPI3SchemaEmitterBase(); - const enumValues = new Set(); + const enumMembers = new Map(); for (const member of en.members.values()) { enumTypes.add(typeof member.value === "number" ? "number" : "string"); - enumValues.add(member.value ?? member.name); + enumMembers.set(member.value ?? member.name, member.name); } const enumTypesArray = [...enumTypes]; const schema: OpenAPISchema3_1 = { type: enumTypesArray.length === 1 ? enumTypesArray[0] : enumTypesArray, - enum: [...enumValues], + enum: [...enumMembers.keys()], }; + if (this._options.includeXEnumVarNames) { + schema["x-enum-varnames"] = [...enumMembers.values()]; + } + return this.applyConstraints(en, schema); } diff --git a/packages/openapi3/test/openapi-output.test.ts b/packages/openapi3/test/openapi-output.test.ts index 650e5cb86bf..b4d32176cea 100644 --- a/packages/openapi3/test/openapi-output.test.ts +++ b/packages/openapi3/test/openapi-output.test.ts @@ -66,6 +66,51 @@ worksFor(supportedVersions, ({ oapiForModel, openApiFor, openapiWithOptions }) = }); }); + describe("openapi3: x-enum-varnames", () => { + it("doesn't include x-enum-varnames by default", async () => { + const output = await openapiWithOptions( + ` + enum Foo { A: "a", B: "b" } + `, + {}, + ); + ok(!("x-enum-varnames" in output.components!.schemas!.Foo)); + }); + + it(`doesn't include x-enum-varnames when option is false`, async () => { + const output = await openapiWithOptions( + ` + enum Foo { A: "a", B: "b" } + `, + { "include-x-enum-varnames": false }, + ); + ok(!("x-enum-varnames" in output.components!.schemas!.Foo)); + }); + + it(`includes x-enum-varnames when option is true`, async () => { + const output = await openapiWithOptions( + ` + enum Foo { A: "a", B: "b" } + `, + { "include-x-enum-varnames": true }, + ); + const schema: any = output.components!.schemas!.Foo; + deepStrictEqual(schema["x-enum-varnames"], ["A", "B"]); + }); + + it(`x-enum-varnames stays aligned with enum values when members share values`, async () => { + const output = await openapiWithOptions( + ` + enum Foo { A: "a", B: "b", AlsoA: "a" } + `, + { "include-x-enum-varnames": true }, + ); + const schema: any = output.components!.schemas!.Foo; + deepStrictEqual(schema.enum, ["a", "b"]); + deepStrictEqual(schema["x-enum-varnames"], ["AlsoA", "B"]); + }); + }); + describe("openapi3: literals", () => { const cases = [ ["1", { type: "number", enum: [1] }], 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 a15c4c7e603..9c4cca10da2 100644 --- a/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md +++ b/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md @@ -102,6 +102,14 @@ By default all types declared under the service namespace will be included. With If the generated openapi types should have the `x-typespec-name` extension set with the name of the TypeSpec type that created it. This extension is meant for debugging and should not be depended on. +### `include-x-enum-varnames` + +**Type:** `boolean` + +If the generated openapi enums should have the `x-enum-varnames` extension filled. +This maintains the key of any enum value that defines it in the form `key: value`. +The default behavior is to use the value as both the key and value. + ### `safeint-strategy` **Type:** `"double-int" | "int64"`