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
7 changes: 7 additions & 0 deletions .chronus/changes/openapi3-x-enum-varnames-2026-3-26.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions packages/http-server-js/src/util/openapi3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
Expand Down
10 changes: 10 additions & 0 deletions packages/openapi3/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)},`));
}

Expand Down
15 changes: 15 additions & 0 deletions packages/openapi3/src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -217,6 +225,13 @@ const EmitterOptionsSchema: JSONSchemaType<OpenAPI3EmitterOptions> = {
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"],
Expand Down
32 changes: 31 additions & 1 deletion packages/openapi3/src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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<string | number | boolean, string | undefined>();
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 {
Expand Down
10 changes: 7 additions & 3 deletions packages/openapi3/src/schema-emitter-3-0.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,10 @@ export class OpenAPI3SchemaEmitter extends OpenAPI3SchemaEmitterBase<OpenAPI3Sch
}

const enumTypes = new Set<JsonType>();
const enumValues = new Set<string | number>();
const enumMembers = new Map<string | number, string>();
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) {
Expand All @@ -132,9 +132,13 @@ export class OpenAPI3SchemaEmitter extends OpenAPI3SchemaEmitterBase<OpenAPI3Sch

const schema: OpenAPI3Schema = {
type: enumTypes.values().next().value!,
enum: [...enumValues],
enum: [...enumMembers.keys()],
};

if (this._options.includeXEnumVarNames) {
schema["x-enum-varnames"] = [...enumMembers.values()];
}

return this.applyConstraints(en, schema);
}

Expand Down
10 changes: 7 additions & 3 deletions packages/openapi3/src/schema-emitter-3-1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,19 +184,23 @@ export class OpenAPI31SchemaEmitter extends OpenAPI3SchemaEmitterBase<OpenAPISch
}

const enumTypes = new Set<JsonType>();
const enumValues = new Set<string | number>();
const enumMembers = new Map<string | number, string>();
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);
}

Expand Down
45 changes: 45 additions & 0 deletions packages/openapi3/test/openapi-output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] }],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down