diff --git a/.chronus/changes/encoded-name-discriminator-program-compiler-2026-9-15-19-0-6.md b/.chronus/changes/encoded-name-discriminator-program-compiler-2026-9-15-19-0-6.md new file mode 100644 index 00000000000..3b1ce01515b --- /dev/null +++ b/.chronus/changes/encoded-name-discriminator-program-compiler-2026-9-15-19-0-6.md @@ -0,0 +1,11 @@ +--- +changeKind: deprecation +packages: + - "@typespec/compiler" +--- + +`getDiscriminatedUnionFromInheritance(type, discriminator)` is deprecated. Pass the program as the first argument so enum member discriminator values use their `@encodedName`. + +```ts +const [union, diagnostics] = getDiscriminatedUnionFromInheritance(program, type, discriminator); +``` diff --git a/.chronus/changes/encoded-name-enum-members-compiler-2026-9-15-19-0-1.md b/.chronus/changes/encoded-name-enum-members-compiler-2026-9-15-19-0-1.md new file mode 100644 index 00000000000..5f8bc8f4159 --- /dev/null +++ b/.chronus/changes/encoded-name-enum-members-compiler-2026-9-15-19-0-1.md @@ -0,0 +1,15 @@ +--- +changeKind: breaking +packages: + - "@typespec/compiler" +--- + +`@encodedName` now sets the value an enum member is serialized as, including for members with an explicit value. Example, default and discriminator values use the `application/json` encoded name, and `resolveEncodedEnumMemberValue` resolves it for emitters. A spec that relied on the encoded name being ignored may now report errors, for example a `@discriminated` union variant named after the member instead of its encoded name. + +```tsp +enum ConversationStatus { + // Keeps 0 for protobuf, serialized as "unknown" in JSON + @encodedName("application/json", "unknown") + CONVERSATION_STATUS_UNSPECIFIED: 0, +} +``` diff --git a/.chronus/changes/encoded-name-enum-members-emitter-framework-2026-9-15-19-0-7.md b/.chronus/changes/encoded-name-enum-members-emitter-framework-2026-9-15-19-0-7.md new file mode 100644 index 00000000000..3d6f8796fed --- /dev/null +++ b/.chronus/changes/encoded-name-enum-members-emitter-framework-2026-9-15-19-0-7.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/emitter-framework" +--- + +Enum members use their `application/json` `@encodedName` as their value in TypeScript enum declarations, union expressions and value expressions, and in Python atoms, including members with an explicit value, matching the discriminator values returned by `$.model.getDiscriminatedUnion`. Rendering an enum value with the TypeScript `ValueExpression` or Python `Atom` component requires a `TspContext`. diff --git a/.chronus/changes/encoded-name-enum-members-http-server-csharp-2026-9-15-19-0-5.md b/.chronus/changes/encoded-name-enum-members-http-server-csharp-2026-9-15-19-0-5.md new file mode 100644 index 00000000000..cfd130047a2 --- /dev/null +++ b/.chronus/changes/encoded-name-enum-members-http-server-csharp-2026-9-15-19-0-5.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/http-server-csharp" +--- + +Enum members use their `application/json` `@encodedName` as their serialized value, including members with an explicit value. diff --git a/.chronus/changes/encoded-name-enum-members-http-server-js-2026-9-15-19-0-4.md b/.chronus/changes/encoded-name-enum-members-http-server-js-2026-9-15-19-0-4.md new file mode 100644 index 00000000000..56592cb7e85 --- /dev/null +++ b/.chronus/changes/encoded-name-enum-members-http-server-js-2026-9-15-19-0-4.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/http-server-js" +--- + +Enum members use their `application/json` `@encodedName` as their value, including members with an explicit value. diff --git a/.chronus/changes/encoded-name-enum-members-json-schema-2026-9-15-19-0-3.md b/.chronus/changes/encoded-name-enum-members-json-schema-2026-9-15-19-0-3.md new file mode 100644 index 00000000000..62a368742f0 --- /dev/null +++ b/.chronus/changes/encoded-name-enum-members-json-schema-2026-9-15-19-0-3.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/json-schema" +--- + +Enum members use their `application/json` `@encodedName` as their value, including members with an explicit value. An encoded integer member is emitted as a string. diff --git a/.chronus/changes/encoded-name-enum-members-openapi3-2026-9-15-19-0-2.md b/.chronus/changes/encoded-name-enum-members-openapi3-2026-9-15-19-0-2.md new file mode 100644 index 00000000000..8dd266c6313 --- /dev/null +++ b/.chronus/changes/encoded-name-enum-members-openapi3-2026-9-15-19-0-2.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/openapi3" +--- + +Enum members use their `application/json` `@encodedName` as their value, including members with an explicit value. An encoded integer member is emitted as a string. diff --git a/packages/compiler/src/core/helpers/discriminator-utils.ts b/packages/compiler/src/core/helpers/discriminator-utils.ts index 61721a7eac3..2ecc2efd5fd 100644 --- a/packages/compiler/src/core/helpers/discriminator-utils.ts +++ b/packages/compiler/src/core/helpers/discriminator-utils.ts @@ -1,4 +1,5 @@ import type { DiscriminatedOptions } from "../../../generated-defs/TypeSpec.js"; +import { resolveEncodedEnumMemberValue } from "../../lib/encoded-names.js"; import { DuplicateTracker } from "../../utils/duplicate-tracker.js"; import { isDefined } from "../../utils/misc.js"; import type { Discriminator } from "../intrinsic-type-state.js"; @@ -6,7 +7,7 @@ import { getDiscriminatedOptions, getDiscriminatedTypes } from "../intrinsic-typ import { createDiagnostic } from "../messages.js"; import type { Program } from "../program.js"; import { isTemplateDeclarationOrInstance } from "../type-utils.js"; -import type { Diagnostic, Model, Type, Union } from "../types.js"; +import type { Diagnostic, EnumMember, Model, Type, Union } from "../types.js"; export interface DiscriminatedUnion { readonly options: Required; @@ -36,7 +37,7 @@ export function validateInheritanceDiscriminatedUnions(program: Program) { for (const [type, discriminator] of getDiscriminatedTypes(program)) { // Union would have already reported the issue. if (type.kind === "Model") { - const [_, diagnostics] = getDiscriminatedUnionFromInheritance(type, discriminator); + const [_, diagnostics] = getDiscriminatedUnionFromInheritance(program, type, discriminator); program.reportDiagnostics(diagnostics); } } @@ -87,7 +88,7 @@ function getDiscriminatedUnionForUnion( const prop = variant.type.properties.get(options.discriminatorPropertyName); if (prop !== undefined) { - const key = getStringValue(prop.type); + const key = getStringValue(program, prop.type); if (key !== variant.name) { diagnostics.push( createDiagnostic({ @@ -118,7 +119,48 @@ function getDiscriminatedUnionForUnion( ]; } +/** + * Resolve the discriminated union formed by the models derived from the given model. + * @param type Base model with the discriminator + * @param discriminator Discriminator of the base model + * @deprecated Use `getDiscriminatedUnionFromInheritance(program, type, discriminator)` instead. Without the program, enum member discriminator values ignore `@encodedName`. + */ +export function getDiscriminatedUnionFromInheritance( + type: Model, + discriminator: Discriminator, +): [DiscriminatedUnionLegacy, readonly Diagnostic[]]; +/** + * Resolve the discriminated union formed by the models derived from the given model. + * @param program Program + * @param type Base model with the discriminator + * @param discriminator Discriminator of the base model + */ +export function getDiscriminatedUnionFromInheritance( + program: Program, + type: Model, + discriminator: Discriminator, +): [DiscriminatedUnionLegacy, readonly Diagnostic[]]; export function getDiscriminatedUnionFromInheritance( + programOrType: Program | Model, + typeOrDiscriminator: Model | Discriminator, + discriminator?: Discriminator, +): [DiscriminatedUnionLegacy, readonly Diagnostic[]] { + if (discriminator === undefined) { + return getDiscriminatedUnionFromInheritanceInternal( + undefined, + programOrType as Model, + typeOrDiscriminator as Discriminator, + ); + } + return getDiscriminatedUnionFromInheritanceInternal( + programOrType as Program, + typeOrDiscriminator as Model, + discriminator, + ); +} + +function getDiscriminatedUnionFromInheritanceInternal( + program: Program | undefined, type: Model, discriminator: Discriminator, ): [DiscriminatedUnionLegacy, readonly Diagnostic[]] { @@ -131,7 +173,7 @@ export function getDiscriminatedUnionFromInheritance( if (isTemplateDeclarationOrInstance(derivedModel)) { continue; // Skip template instances as they should be used with `model is` } - const keys = getDiscriminatorValues(derivedModel, discriminator, diagnostics); + const keys = getDiscriminatorValues(program, derivedModel, discriminator, diagnostics); if (keys === undefined) { if (derivedModel.derivedModels.length === 0) { diagnostics.push( @@ -201,6 +243,7 @@ function getDiscriminatorProperty( } function getDiscriminatorValues( + program: Program | undefined, model: Model, discriminator: Discriminator, diagnostics: Diagnostic[], @@ -208,7 +251,7 @@ function getDiscriminatorValues( const prop = getDiscriminatorProperty(model, discriminator, diagnostics); if (!prop) return undefined; - const keys = getStringValues(prop.type); + const keys = getStringValues(program, prop.type); if (keys.length === 0) { diagnostics.push( createDiagnostic({ @@ -221,28 +264,40 @@ function getDiscriminatorValues( return keys; } -function getStringValues(type: Type): string[] { +function getStringValues(program: Program | undefined, type: Type): string[] { switch (type.kind) { case "String": return [type.value]; case "Union": - return [...type.variants.values()].flatMap((x) => getStringValues(x.type)).filter(isDefined); - case "EnumMember": - return typeof type.value !== "number" ? [type.value ?? type.name] : []; + return [...type.variants.values()] + .flatMap((x) => getStringValues(program, x.type)) + .filter(isDefined); + case "EnumMember": { + const value = resolveEnumMemberValue(program, type); + return typeof value === "string" ? [value] : []; + } case "UnionVariant": - return getStringValues(type.type); + return getStringValues(program, type.type); default: return []; } } -function getStringValue(type: Type): string | undefined { +function getStringValue(program: Program, type: Type): string | undefined { switch (type.kind) { case "String": return type.value; case "EnumMember": - return typeof type.value !== "number" ? (type.value ?? type.name) : undefined; + const value = resolveEnumMemberValue(program, type); + return typeof value === "string" ? value : undefined; default: return undefined; } } + +/** Value an enum member is serialized as in json. */ +function resolveEnumMemberValue(program: Program | undefined, member: EnumMember): string | number { + return program + ? resolveEncodedEnumMemberValue(program, member, "application/json") + : (member.value ?? member.name); +} diff --git a/packages/compiler/src/core/helpers/index.ts b/packages/compiler/src/core/helpers/index.ts index 8dbc3d44cff..75398936124 100644 --- a/packages/compiler/src/core/helpers/index.ts +++ b/packages/compiler/src/core/helpers/index.ts @@ -1,5 +1,6 @@ export { getDiscriminatedUnion, + // eslint-disable-next-line @typescript-eslint/no-deprecated -- exporting deprecated overloads for backward compatibility getDiscriminatedUnionFromInheritance, } from "./discriminator-utils.js"; export type { DiscriminatedUnion, DiscriminatedUnionLegacy } from "./discriminator-utils.js"; diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 61e0459d972..d209757449f 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -52,6 +52,7 @@ export { explainStringTemplateNotSerializable, printIdentifier as formatIdentifier, getDiscriminatedUnion, + // eslint-disable-next-line @typescript-eslint/no-deprecated -- exporting deprecated overloads for backward compatibility getDiscriminatedUnionFromInheritance, getEntityName, getLocationContext, @@ -194,6 +195,7 @@ export { isService, isStringType, listServices, + resolveEncodedEnumMemberValue, resolveEncodedName, serializeValueAsJson, setMediaTypeHint, diff --git a/packages/compiler/src/lib/decorators.ts b/packages/compiler/src/lib/decorators.ts index 3d7f4ba788a..5f0465ed772 100644 --- a/packages/compiler/src/lib/decorators.ts +++ b/packages/compiler/src/lib/decorators.ts @@ -105,7 +105,11 @@ import { replaceTemplatedStringFromProperties, } from "./utils.js"; -export { $encodedName, resolveEncodedName } from "./encoded-names.js"; +export { + $encodedName, + resolveEncodedEnumMemberValue, + resolveEncodedName, +} from "./encoded-names.js"; export { serializeValueAsJson } from "./examples.js"; export { getPagingOperation, isList, type PagingOperation, type PagingProperty } from "./paging.js"; export * from "./service.js"; diff --git a/packages/compiler/src/lib/encoded-names.ts b/packages/compiler/src/lib/encoded-names.ts index a7c04b792f9..288e212298c 100644 --- a/packages/compiler/src/lib/encoded-names.ts +++ b/packages/compiler/src/lib/encoded-names.ts @@ -1,7 +1,7 @@ import { reportDiagnostic } from "../core/messages.js"; import { parseMimeType } from "../core/mime-type.js"; import type { Program } from "../core/program.js"; -import type { DecoratorContext, Enum, Model, Type, Union } from "../core/types.js"; +import type { DecoratorContext, Enum, EnumMember, Model, Type, Union } from "../core/types.js"; import { DuplicateTracker, useStateMap } from "../utils/index.js"; import { createStateSymbol } from "./utils.js"; @@ -81,6 +81,37 @@ export function resolveEncodedName( return getEncodedName(program, target, mimeType) ?? target.name; } +/** + * Resolve the value the given enum member serializes to for the given mime type. + * A member is serialized as its `@encodedName` for that mime type if it has one, otherwise as its + * explicit value, otherwise as its name. + * + * @example + * + * For the given + * ```tsp + * enum Status { + * @encodedName("application/json", "ready") + * CONVERSATION_STATUS_READY: 1, + * busy: 2, + * unknown, + * } + * ``` + * + * ```ts + * resolveEncodedEnumMemberValue(program, CONVERSATION_STATUS_READY, "application/json") // "ready" + * resolveEncodedEnumMemberValue(program, busy, "application/json") // 2 + * resolveEncodedEnumMemberValue(program, unknown, "application/json") // "unknown" + * ``` + */ +export function resolveEncodedEnumMemberValue( + program: Program, + member: EnumMember, + mimeType: string, +): string | number { + return getEncodedName(program, member, mimeType) ?? member.value ?? member.name; +} + /** * Validate encoded names for conflicts. Validate the encoded names doesn't encode to an existing property name and that 2 encoded names don't map to the same name for the same mime type. * @internal diff --git a/packages/compiler/src/lib/examples.ts b/packages/compiler/src/lib/examples.ts index 9445029cd97..bf59fa8c7aa 100644 --- a/packages/compiler/src/lib/examples.ts +++ b/packages/compiler/src/lib/examples.ts @@ -12,7 +12,12 @@ import type { Type, Value, } from "../core/types.js"; -import { getEncode, resolveEncodedName, type EncodeData } from "./decorators.js"; +import { + getEncode, + resolveEncodedEnumMemberValue, + resolveEncodedName, + type EncodeData, +} from "./decorators.js"; /** * Error thrown when a value cannot be serialized. @@ -87,7 +92,7 @@ export function serializeValueAsJson( case "NumericValue": return value.value.asNumber(); case "EnumValue": - return value.value.value ?? value.value.name; + return resolveEncodedEnumMemberValue(program, value.value, "application/json"); case "ArrayValue": return value.values.map((v) => serializeValueAsJson( diff --git a/packages/compiler/src/typekit/kits/model.ts b/packages/compiler/src/typekit/kits/model.ts index 289d6e859c4..763c2c53162 100644 --- a/packages/compiler/src/typekit/kits/model.ts +++ b/packages/compiler/src/typekit/kits/model.ts @@ -263,7 +263,7 @@ defineKit({ return [undefined, []]; } - return getDiscriminatedUnionFromInheritance(model, discriminator); + return getDiscriminatedUnionFromInheritance(this.program, model, discriminator); }), }, }); diff --git a/packages/compiler/test/decorators/decorators.test.ts b/packages/compiler/test/decorators/decorators.test.ts index f74071a87db..c2e1475a54c 100644 --- a/packages/compiler/test/decorators/decorators.test.ts +++ b/packages/compiler/test/decorators/decorators.test.ts @@ -1246,6 +1246,24 @@ describe("@discriminated", () => { }); }); + it("use the json encoded name of an enum member discriminator value", async () => { + const diagnostics = await Tester.diagnose(` + enum Kind { + @encodedName("application/json", "a") + aKind, + } + model A { + kind: Kind.aKind, + } + @discriminated(#{envelope: "none"}) + union Foo { + a: A; + } + `); + + expectDiagnosticEmpty(diagnostics); + }); + async function getTestDiscriminatedUnion(code: string) { const { Foo, program } = (await Tester.compile(code)) as any; return getDiscriminatedUnion(program, Foo)[0]!; @@ -1411,6 +1429,32 @@ describe("@encodedName", () => { `); strictEqual(resolveEncodedName(program, expireAt, "application/xml"), "expireAt"); }); + + it("resolve encoded name of an enum member", async () => { + const { active, program } = await Tester.compile(t.code` + enum Status { + @encodedName("application/json", "on") + ${t.enumMember("active")}, + } + `); + strictEqual(resolveEncodedName(program, active, "application/json"), "on"); + strictEqual(resolveEncodedName(program, active, "application/xml"), "active"); + }); + + it("resolve encoded name of an enum member copied with a spread", async () => { + const { Extended, program } = await Tester.compile(t.code` + enum Status { + @encodedName("application/json", "on") + active, + } + enum ${t.enum("Extended")} { + ...Status, + } + `); + const active = Extended.members.get("active")!; + strictEqual(active.enum, Extended); + strictEqual(resolveEncodedName(program, active, "application/json"), "on"); + }); }); describe("@mediaTypeHint", () => { diff --git a/packages/compiler/test/decorators/examples.test.ts b/packages/compiler/test/decorators/examples.test.ts index c3877df2241..568a43292ff 100644 --- a/packages/compiler/test/decorators/examples.test.ts +++ b/packages/compiler/test/decorators/examples.test.ts @@ -400,6 +400,28 @@ describe("json serialization of examples", () => { expect(result).toEqual({ exp: 1 }); }); + it("respect json encodedName of enum members", async () => { + const result = await getJsonValueOfExample(` + @example(#{ + status: Status.active, + explicit: Status.enabled, + }) + model /*test*/test { + status: Status; + explicit: Status; + } + + enum Status { + @encodedName("application/json", "on") + active, + @encodedName("application/json", "off") + enabled: "enabled", + } + `); + + expect(result).toEqual({ status: "on", explicit: "off" }); + }); + describe("scalar encoding", () => { const allCases: [ string, diff --git a/packages/compiler/test/helpers/discriminator-utils.test.ts b/packages/compiler/test/helpers/discriminator-utils.test.ts index 1f6698f523a..964fd719f59 100644 --- a/packages/compiler/test/helpers/discriminator-utils.test.ts +++ b/packages/compiler/test/helpers/discriminator-utils.test.ts @@ -1,4 +1,4 @@ -import { strictEqual } from "assert"; +import { deepStrictEqual, strictEqual } from "assert"; import { describe, it } from "vitest"; import { getDiscriminatedUnionFromInheritance } from "../../src/core/helpers/discriminator-utils.js"; import type { Model, Program } from "../../src/index.js"; @@ -16,7 +16,7 @@ function checkValidDiscriminatedUnion(program: Program, model: Model) { if (discriminator === undefined) { throw new Error("Discriminator shouldn't be undefined."); } - const [union, diagnostics] = getDiscriminatedUnionFromInheritance(model, discriminator); + const [union, diagnostics] = getDiscriminatedUnionFromInheritance(program, model, discriminator); expectDiagnosticEmpty(diagnostics); return union; } @@ -131,6 +131,49 @@ describe("inheritance based", () => { strictEqual(union.variants.size, 1); strictEqual(union.variants.get("cat"), Cat); }); + + it("use the json encoded name of a string enum member", async () => { + const { Pet, Cat, program } = await Tester.compile(t.code` + @discriminator("kind") + model ${t.model("Pet")} {} + + enum PetKind { + @encodedName("application/json", "feline") + cat, + } + model ${t.model("Cat")} extends Pet { + kind: PetKind.cat; + } + `); + + const union = checkValidDiscriminatedUnion(program, Pet); + deepStrictEqual([...union.variants.keys()], ["feline"]); + strictEqual(union.variants.get("feline"), Cat); + }); + + it("use the enum member name with the deprecated signature without a program", async () => { + const { Pet, Cat, program } = await Tester.compile(t.code` + @discriminator("kind") + model ${t.model("Pet")} {} + + enum PetKind { + @encodedName("application/json", "feline") + cat, + } + model ${t.model("Cat")} extends Pet { + kind: PetKind.cat; + } + `); + + // eslint-disable-next-line @typescript-eslint/no-deprecated + const [union, diagnostics] = getDiscriminatedUnionFromInheritance( + Pet, + getDiscriminator(program, Pet)!, + ); + expectDiagnosticEmpty(diagnostics); + deepStrictEqual([...union.variants.keys()], ["cat"]); + strictEqual(union.variants.get("cat"), Cat); + }); }); it("find variants from nested derived types", async () => { diff --git a/packages/compiler/test/typekit/model.test.ts b/packages/compiler/test/typekit/model.test.ts index 859bceab594..9ce33a0d140 100644 --- a/packages/compiler/test/typekit/model.test.ts +++ b/packages/compiler/test/typekit/model.test.ts @@ -67,6 +67,29 @@ it("can get the discriminated union type", async () => { expect(union?.variants.get("dog")).toBe(Dog); }); +it("uses the json encoded name of enum member discriminator values in getDiscriminatedUnion", async () => { + const { Pet, Cat, Lion, program } = await Tester.compile(t.code` + @discriminator("kind") + model ${t.model("Pet")} {} + + enum Kind { + @encodedName("application/json", "feline") + cat, + } + + model ${t.model("Cat")} extends Pet { kind: Kind.cat } + model ${t.model("Lion")} extends Pet { kind: "cat" } + `); + + assert.ok(Pet.kind === "Model"); + + const [union, diagnostics] = $(program).model.getDiscriminatedUnion.withDiagnostics(Pet); + expect(diagnostics).toHaveLength(0); + expect([...union!.variants.keys()]).toEqual(["feline", "cat"]); + expect(union!.variants.get("feline")).toBe(Cat); + expect(union!.variants.get("cat")).toBe(Lion); +}); + it("can get diagnostics from getDiscriminatedUnion", async () => { const [{ Pet, program }] = await Tester.compileAndDiagnose(t.code` @discriminator("kind") diff --git a/packages/emitter-framework/src/python/components/atom/atom.test.tsx b/packages/emitter-framework/src/python/components/atom/atom.test.tsx index 82ed8930a4c..04d01669b59 100644 --- a/packages/emitter-framework/src/python/components/atom/atom.test.tsx +++ b/packages/emitter-framework/src/python/components/atom/atom.test.tsx @@ -210,7 +210,7 @@ describe("ObjectValue", () => { describe("EnumValue", () => { it("different EnumValue types", async () => { // Can be replaced with TypeKit once #6976 is implemented - const { Red, Green, Blue } = await Tester.compile(t.code` + const { program, Red, Green, Blue } = await Tester.compile(t.code` enum ${t.enum("Color")} { Red, Green: 3, @@ -221,17 +221,33 @@ describe("EnumValue", () => { const ${t.value("Blue")} = ${t.enumValue("Color.Blue")}; `); - await testValueExpression(Red, `"Red"`); - await testValueExpression(Green, `3`); - await testValueExpression(Blue, `"cyan"`); + await testValueExpression(Red, `"Red"`, program); + await testValueExpression(Green, `3`, program); + await testValueExpression(Blue, `"cyan"`, program); + }); + + it("renders the json encoded name of a member without a value", async () => { + const { program, Red } = await Tester.compile(t.code` + enum ${t.enum("Color")} { + @encodedName("application/json", "red") + Red, + } + const ${t.value("Red")} = ${t.enumValue("Color.Red")}; + `); + + await testValueExpression(Red, `"red"`, program); }); }); /** * Helper that renders a value expression and checks the output against the expected value. */ -async function testValueExpression(value: Value, expected: string) { - expect(getOutput(program, [])).toRenderTo(`${expected}`); +async function testValueExpression( + value: Value, + expected: string, + valueProgram: Program = program, +) { + expect(getOutput(valueProgram, [])).toRenderTo(`${expected}`); } /** diff --git a/packages/emitter-framework/src/python/components/atom/atom.tsx b/packages/emitter-framework/src/python/components/atom/atom.tsx index 139fac2e217..f055750ca53 100644 --- a/packages/emitter-framework/src/python/components/atom/atom.tsx +++ b/packages/emitter-framework/src/python/components/atom/atom.tsx @@ -1,6 +1,7 @@ import { type Children } from "@alloy-js/core"; import * as py from "@alloy-js/python"; -import { compilerAssert, type Value } from "@typespec/compiler"; +import { compilerAssert, resolveEncodedEnumMemberValue, type Value } from "@typespec/compiler"; +import { useTsp } from "../../../core/context/tsp-context.js"; import { datetimeModule } from "../../builtins.js"; /** @@ -68,8 +69,14 @@ export function Atom(props: Readonly): Children { jsProperties[key] = Atom({ value: value.value }); } return ; - case "EnumValue": - return ; + case "EnumValue": { + const { $ } = useTsp(); + return ( + + ); + } } } diff --git a/packages/emitter-framework/src/typescript/components/enum-declaration.tsx b/packages/emitter-framework/src/typescript/components/enum-declaration.tsx index 814824ebfab..cb4ed4c9bb9 100644 --- a/packages/emitter-framework/src/typescript/components/enum-declaration.tsx +++ b/packages/emitter-framework/src/typescript/components/enum-declaration.tsx @@ -1,6 +1,7 @@ import { type Children, For, type Refkey } from "@alloy-js/core"; import * as ts from "@alloy-js/typescript"; import type { Enum, EnumMember as TspEnumMember, Union } from "@typespec/compiler"; +import { resolveEncodedEnumMemberValue } from "@typespec/compiler"; import { useTsp } from "../../core/context/tsp-context.js"; import { reportDiagnostic } from "../../lib.js"; import { declarationRefkeys, efRefkey } from "../utils/refkey.js"; @@ -63,11 +64,12 @@ export interface EnumMemberProps { } export function EnumMember(props: EnumMemberProps) { + const { $ } = useTsp(); return ( ); diff --git a/packages/emitter-framework/src/typescript/components/union/expression.test.tsx b/packages/emitter-framework/src/typescript/components/union/expression.test.tsx index 6121b478882..74bd2e6d25c 100644 --- a/packages/emitter-framework/src/typescript/components/union/expression.test.tsx +++ b/packages/emitter-framework/src/typescript/components/union/expression.test.tsx @@ -19,6 +19,22 @@ it("renders a union expression", async () => { ).toRenderTo(`"one" | "two"`); }); +it("renders the json encoded name of enum members without a value", async () => { + const { program, TestEnum } = await Tester.compile(t.code` + enum ${t.enum("TestEnum")} { + @encodedName("application/json", "on") + active, + inactive: "off", + } + `); + + expect( + + + , + ).toRenderTo(`"on" | "off"`); +}); + it("renders a union expression without conflicting names", async () => { const { program, TestUnion } = await Tester.compile(t.code` union ${t.union("TestUnion")} { diff --git a/packages/emitter-framework/src/typescript/components/union/expression.tsx b/packages/emitter-framework/src/typescript/components/union/expression.tsx index 2d8df922dd5..e0620fc3418 100644 --- a/packages/emitter-framework/src/typescript/components/union/expression.tsx +++ b/packages/emitter-framework/src/typescript/components/union/expression.tsx @@ -1,7 +1,8 @@ -import { type Children, For, List } from "@alloy-js/core"; +import { For, List, type Children } from "@alloy-js/core"; import * as ts from "@alloy-js/typescript"; import { compilerAssert, + resolveEncodedEnumMemberValue, type Enum, type EnumMember, type Union, @@ -27,7 +28,11 @@ export function UnionExpression({ type, children }: UnionExpressionProps) { {(_, type) => { if ($.enumMember.is(type)) { - return ; + return ( + + ); } const discriminatedUnion = $.union.getDiscriminatedUnion(type.union); diff --git a/packages/emitter-framework/src/typescript/components/value-expression.tsx b/packages/emitter-framework/src/typescript/components/value-expression.tsx index e20b4c60e2d..5599154231d 100644 --- a/packages/emitter-framework/src/typescript/components/value-expression.tsx +++ b/packages/emitter-framework/src/typescript/components/value-expression.tsx @@ -1,6 +1,7 @@ import { type Children } from "@alloy-js/core"; import * as ts from "@alloy-js/typescript"; -import { compilerAssert, type Value } from "@typespec/compiler"; +import { compilerAssert, resolveEncodedEnumMemberValue, type Value } from "@typespec/compiler"; +import { useTsp } from "../../core/context/tsp-context.js"; /** * Properties for the {@link ValueExpression} component. @@ -50,7 +51,13 @@ export function ValueExpression(props: Readonly): Children jsProperties[key] = ValueExpression({ value: value.value }); } return ; - case "EnumValue": - return ; + case "EnumValue": { + const { $ } = useTsp(); + return ( + + ); + } } } diff --git a/packages/emitter-framework/test/typescript/components/enum-declaration.test.tsx b/packages/emitter-framework/test/typescript/components/enum-declaration.test.tsx index 28457cecb1e..df41a0e781d 100644 --- a/packages/emitter-framework/test/typescript/components/enum-declaration.test.tsx +++ b/packages/emitter-framework/test/typescript/components/enum-declaration.test.tsx @@ -200,3 +200,31 @@ it("can be referenced using union", async () => { Foo.one; `); }); + +it("uses the json encoded name as the value of a member without a value", async () => { + const code = ` + enum Foo { + @encodedName("application/json", "on") + active, + @encodedName("application/json", "off") + inactive: "inactive-value", + pending, + } + `; + const output = await getEmitOutput(code, (program) => { + const Foo = program.resolveTypeReference("Foo")[0]! as Enum; + return ( + + + + ); + }); + + expect(output).toBe(d` + enum Foo { + active = "on", + inactive = "off", + pending = "pending" + } + `); +}); diff --git a/packages/emitter-framework/test/typescript/components/type-transform.test.tsx b/packages/emitter-framework/test/typescript/components/type-transform.test.tsx index b1407627473..b994ee0e4e5 100644 --- a/packages/emitter-framework/test/typescript/components/type-transform.test.tsx +++ b/packages/emitter-framework/test/typescript/components/type-transform.test.tsx @@ -1,10 +1,12 @@ import { Tester } from "#test/test-host.js"; -import { code, Output } from "@alloy-js/core"; +import { code, List, Output } from "@alloy-js/core"; import * as ts from "@alloy-js/typescript"; import { SourceFile } from "@alloy-js/typescript"; -import type { Model } from "@typespec/compiler"; +import type { Enum, Model } from "@typespec/compiler"; import { t, type TesterInstance } from "@typespec/compiler/testing"; import { beforeEach, describe, expect, it } from "vitest"; +import { TspContext } from "../../../src/core/index.js"; +import { EnumDeclaration } from "../../../src/typescript/components/enum-declaration.js"; import { ArraySerializer, DateDeserializer, @@ -17,6 +19,7 @@ import { TypeTransformDeclaration, } from "../../../src/typescript/components/type-transform.js"; import { TypeDeclaration } from "../../../src/typescript/index.js"; +import { getEmitOutput } from "../../utils.js"; describe.skip("Typescript Type Transform", () => { let testRunner: TesterInstance; @@ -514,3 +517,42 @@ describe.skip("Typescript Type Transform", () => { }); }); }); + +it("uses the same json encoded name for enum values and discriminator checks", async () => { + const output = await getEmitOutput( + ` + enum PetKind { + @encodedName("application/json", "feline") + cat, + } + + @discriminator("kind") + model Pet { + kind: PetKind; + } + + model Cat extends Pet { + kind: PetKind.cat; + } + `, + (program) => { + const PetKind = program.resolveTypeReference("PetKind")[0]! as Enum; + const Pet = program.resolveTypeReference("Pet")[0]! as Model; + const Cat = program.resolveTypeReference("Cat")[0]! as Model; + return ( + + + + + + + + + + ); + }, + ); + + expect(output).toContain(`cat = "feline"`); + expect(output).toContain(`item.kind === "feline"`); +}); diff --git a/packages/emitter-framework/test/typescript/components/value-expression.test.tsx b/packages/emitter-framework/test/typescript/components/value-expression.test.tsx index b0add223bf4..b957ae9a4d1 100644 --- a/packages/emitter-framework/test/typescript/components/value-expression.test.tsx +++ b/packages/emitter-framework/test/typescript/components/value-expression.test.tsx @@ -13,6 +13,7 @@ import { } from "@typespec/compiler"; import { $ } from "@typespec/compiler/typekit"; import { assert, beforeAll, describe, expect, it } from "vitest"; +import { TspContext } from "../../../src/core/index.js"; import { ValueExpression } from "../../../src/typescript/components/value-expression.js"; let program: Program; @@ -203,6 +204,7 @@ it("renders enums", async () => { value: red, } as EnumValue, `"Red"`, + program, ); const green = colors?.members.get("Green"); @@ -213,21 +215,42 @@ it("renders enums", async () => { value: green, } as EnumValue, `3`, + program, ); }); +it("renders the json encoded name of enum members without a value", async () => { + const program = await getProgram(` + namespace DemoService; + enum Color { + @encodedName("application/json", "red") + Red, + } + `); + const [namespace] = program.resolveTypeReference("DemoService"); + const red = (namespace as Namespace).enums.get("Color")?.members.get("Red"); + assert.exists(red, "unable to find Red enum member"); + await testValueExpression({ valueKind: "EnumValue", value: red } as EnumValue, `"red"`, program); +}); + /** * Helper that renders a value expression and checks the output against the expected value. */ -async function testValueExpression(value: Value, expected: string) { +async function testValueExpression( + value: Value, + expected: string, + valueProgram: Program = program, +) { const prefix = "const val = "; expect( - - {prefix} - - + + + {prefix} + + + , ).toRenderTo(`${prefix}${expected}`); } diff --git a/packages/http-server-csharp/src/components/enums/enums.tsx b/packages/http-server-csharp/src/components/enums/enums.tsx index 69233496bd8..a298276a321 100644 --- a/packages/http-server-csharp/src/components/enums/enums.tsx +++ b/packages/http-server-csharp/src/components/enums/enums.tsx @@ -4,7 +4,10 @@ import * as cs from "@alloy-js/csharp"; import { Attribute } from "@alloy-js/csharp"; import { Serialization } from "@alloy-js/csharp/global/System/Text/Json"; import { + resolveEncodedEnumMemberValue, type Enum, + type EnumMember, + type Program, type Namespace as TspNamespace, type Type, type Union, @@ -31,19 +34,28 @@ interface EnumInfo { members: EnumMemberInfo[]; } -function normalizeEnum(en: Enum): EnumInfo { +function normalizeEnum(program: Program, en: Enum): EnumInfo { return { name: en.name, type: en, namespace: en.namespace, members: Array.from(en.members.entries()).map(([key, value]) => ({ name: key, - serializedValue: typeof value.value === "string" ? value.value : key, + serializedValue: serializedEnumMemberValue(program, value, key), docSource: value, })), }; } +/** + * Value a member serializes to in json. A numeric member without an encoded name keeps its name, + * as the enum is emitted with a string converter. + */ +function serializedEnumMemberValue(program: Program, member: EnumMember, key: string): string { + const value = resolveEncodedEnumMemberValue(program, member, "application/json"); + return typeof value === "string" ? value : key; +} + function normalizeUnionEnum(union: Union): EnumInfo { return { name: union.name!, @@ -75,7 +87,7 @@ export function Enums(props: EnumsProps): Children { const { $ } = useTsp(); const allEnums: EnumInfo[] = [ - ...props.enums.map(normalizeEnum), + ...props.enums.map((en) => normalizeEnum($.program, en)), ...props.unionEnums.map(normalizeUnionEnum), ]; diff --git a/packages/http-server-csharp/test/emitter.test.ts b/packages/http-server-csharp/test/emitter.test.ts index df1dcfaa19e..d6fd1ba5cf1 100644 --- a/packages/http-server-csharp/test/emitter.test.ts +++ b/packages/http-server-csharp/test/emitter.test.ts @@ -120,3 +120,34 @@ it("emits the HTTP service exception filter for server output", async () => { expect(hasPathEndingWith("/generated/lib/HttpServiceException.cs")).toBe(true); expect(hasPathEndingWith("/generated/lib/HttpServiceExceptionFilter.cs")).toBe(true); }); + +it("uses the json encoded name as the serialized value of an enum member without a value", async () => { + const [result] = await compileAndDiagnose( + tester, + getStandardService(` + enum Status { + @encodedName("application/json", "on") + active, + @encodedName("application/json", "off") + inactive: "inactive-value", + @encodedName("application/xml", "xml-pending") + pending, + } + + model Item { + status: Status; + } + + op read(): Item; + `), + { "skip-format": true }, + ); + const status = [...result.fs.fs.entries()].find(([path]) => + path.endsWith("/generated/models/Status.cs"), + )?.[1]; + + expect(status).toContain(`[JsonStringEnumMemberName("on")]`); + expect(status).toContain(`[JsonStringEnumMemberName("off")]`); + expect(status).toContain(`[JsonStringEnumMemberName("pending")]`); + expect(status).not.toContain(`[JsonStringEnumMemberName("active")]`); +}); diff --git a/packages/http-server-js/src/common/enum.ts b/packages/http-server-js/src/common/enum.ts index 0b237595c36..be5a1f34279 100644 --- a/packages/http-server-js/src/common/enum.ts +++ b/packages/http-server-js/src/common/enum.ts @@ -2,6 +2,7 @@ // Licensed under the MIT license. import type { Enum } from "@typespec/compiler"; +import { resolveEncodedEnumMemberValue } from "@typespec/compiler"; import type { JsContext } from "../ctx.js"; import { parseCase } from "../util/case.js"; import { emitDocumentation } from "./documentation.js"; @@ -21,7 +22,7 @@ export function* emitEnum(ctx: JsContext, enum_: Enum): Iterable { for (const member of enum_.members.values()) { const nameCase = parseCase(member.name); - const value = member.value ?? member.name; + const value = resolveEncodedEnumMemberValue(ctx.program, member, "application/json"); yield ` ${nameCase.pascalCase} = ${JSON.stringify(value)},`; } diff --git a/packages/http-server-js/src/common/reference.ts b/packages/http-server-js/src/common/reference.ts index 02d98e7dc20..621cde8e4a9 100644 --- a/packages/http-server-js/src/common/reference.ts +++ b/packages/http-server-js/src/common/reference.ts @@ -14,6 +14,7 @@ import { getEffectiveModelType, getFriendlyName, isArrayModelType, + resolveEncodedEnumMemberValue, } from "@typespec/compiler"; import type { JsContext, Module } from "../ctx.js"; import { isImportableType } from "../ctx.js"; @@ -235,16 +236,8 @@ export function emitTypeReference( case "Boolean": return String(type.value); case "EnumMember": { - if (typeof type.value === "string") { - return escapeUnsafeChars(JSON.stringify(type.value)); - } else if (typeof type.value === "number") { - return String(type.value); - } else if (type.value === undefined) { - return escapeUnsafeChars(JSON.stringify(type.name)); - } else { - void (type.value satisfies never); - return "unknown"; - } + const value = resolveEncodedEnumMemberValue(ctx.program, type, "application/json"); + return typeof value === "number" ? String(value) : escapeUnsafeChars(JSON.stringify(value)); } case "Intrinsic": switch (type.name) { diff --git a/packages/http-server-js/src/util/differentiate.ts b/packages/http-server-js/src/util/differentiate.ts index 98e95adce82..f194e7865e6 100644 --- a/packages/http-server-js/src/util/differentiate.ts +++ b/packages/http-server-js/src/util/differentiate.ts @@ -22,6 +22,7 @@ import { isArrayModelType, isNeverType, isUnknownType, + resolveEncodedEnumMemberValue, } from "@typespec/compiler"; import { $ } from "@typespec/compiler/typekit"; import { getJsScalar } from "../common/scalar.js"; @@ -566,7 +567,7 @@ function getJsValue(ctx: JsContext, literal: JsLiteralType | EnumMember): Litera case "String": return literal.value; case "EnumMember": - return literal.value ?? literal.name; + return resolveEncodedEnumMemberValue(ctx.program, literal, "application/json"); default: throw new UnreachableError( "getJsValue for " + (literal satisfies never as JsLiteralType).kind, diff --git a/packages/http-server-js/test/enum.test.ts b/packages/http-server-js/test/enum.test.ts new file mode 100644 index 00000000000..1ed0b0eddc3 --- /dev/null +++ b/packages/http-server-js/test/enum.test.ts @@ -0,0 +1,81 @@ +import { expect, it } from "vitest"; +import { HttpServerEmitterTester } from "./tester.js"; + +async function emitExample(code: string) { + const { outputs } = await HttpServerEmitterTester.compile(` + @service(#{ title: "Example" }) + @route("/") + namespace Example { + ${code} + } + `); + return outputs; +} + +it("uses the json encoded name as the value of a member without a value", async () => { + const outputs = await emitExample(` + enum Status { + @encodedName("application/json", "on") + active, + @encodedName("application/json", "off") + inactive: "inactive-value", + @encodedName("application/xml", "xml-pending") + pending, + } + + @get op read(): { status: Status }; + `); + + expect(outputs["src/generated/models/all/example.ts"]).toContain( + [ + "export enum Status {", + ' Active = "on",', + ' Inactive = "off",', + ' Pending = "pending",', + "}", + ].join("\n"), + ); +}); + +it("uses the json encoded name for a property typed as a member", async () => { + const outputs = await emitExample(` + enum Status { + @encodedName("application/json", "on") + active, + } + + model Cat { + kind: Status.active; + } + + @get op read(): Cat; + `); + + expect(outputs["src/generated/models/all/example.ts"]).toContain(' kind: "on";'); +}); + +it("uses the json encoded name to differentiate union variants", async () => { + const outputs = await emitExample(` + enum Kind { + @encodedName("application/json", "feline") + cat, + dog, + } + + model Cat { + kind: Kind.cat; + meow: string; + } + + model Dog { + kind: Kind.dog; + bark: string; + } + + @post op create(@body pet: Cat | Dog): Cat | Dog; + `); + + const serverRaw = outputs["src/generated/http/operations/server-raw.ts"]; + expect(serverRaw).toMatch(/\.kind === "feline"/); + expect(serverRaw).not.toMatch(/\.kind === "cat"/); +}); diff --git a/packages/http-server-js/test/scalar.test.ts b/packages/http-server-js/test/scalar.test.ts index 580c70ef21a..af63d634e0f 100644 --- a/packages/http-server-js/test/scalar.test.ts +++ b/packages/http-server-js/test/scalar.test.ts @@ -1,26 +1,20 @@ import type { ModelProperty, Scalar } from "@typespec/compiler"; -import { NoTarget, resolvePath } from "@typespec/compiler"; +import { NoTarget } from "@typespec/compiler"; import type { BasicTestRunner } from "@typespec/compiler/testing"; -import { createTestRunner, createTester } from "@typespec/compiler/testing"; +import { createTestRunner } from "@typespec/compiler/testing"; import { deepStrictEqual, strictEqual } from "assert"; import { beforeEach, describe, expect, it } from "vitest"; import { escapeUnsafeChars } from "../src/common/reference.js"; import { getJsScalar } from "../src/common/scalar.js"; import type { JsContext, Module } from "../src/ctx.js"; import { createPathCursor } from "../src/ctx.js"; +import { HttpServerEmitterTester } from "./tester.js"; import { module as dateTimeModule } from "../generated-defs/helpers/datetime.js"; import { module as temporalHelpersModule } from "../generated-defs/helpers/temporal/native.js"; import { module as temporalPolyfillHelpersModule } from "../generated-defs/helpers/temporal/polyfill.js"; import type { JsEmitterOptions } from "../src/lib.js"; -const HttpServerEmitterTester = createTester(resolvePath(import.meta.dirname, ".."), { - libraries: ["@typespec/http", "@typespec/http-server-js"], -}) - .import("@typespec/http") - .using("Http") - .emit("@typespec/http-server-js"); - describe("scalar", () => { let runner: BasicTestRunner; diff --git a/packages/http-server-js/test/tester.ts b/packages/http-server-js/test/tester.ts new file mode 100644 index 00000000000..b8c98117163 --- /dev/null +++ b/packages/http-server-js/test/tester.ts @@ -0,0 +1,9 @@ +import { resolvePath } from "@typespec/compiler"; +import { createTester } from "@typespec/compiler/testing"; + +export const HttpServerEmitterTester = createTester(resolvePath(import.meta.dirname, ".."), { + libraries: ["@typespec/http", "@typespec/http-server-js"], +}) + .import("@typespec/http") + .using("Http") + .emit("@typespec/http-server-js"); diff --git a/packages/http-server-js/vitest.config.ts b/packages/http-server-js/vitest.config.ts index 53d784ee044..cf361d54cc5 100644 --- a/packages/http-server-js/vitest.config.ts +++ b/packages/http-server-js/vitest.config.ts @@ -6,6 +6,7 @@ export default mergeConfig( defineConfig({ test: { include: ["test/**/*.test.ts"], + testTimeout: 10_000, }, }), ); diff --git a/packages/json-schema/src/json-schema-emitter.ts b/packages/json-schema/src/json-schema-emitter.ts index 24e8423a491..269d9bb5406 100644 --- a/packages/json-schema/src/json-schema-emitter.ts +++ b/packages/json-schema/src/json-schema-emitter.ts @@ -54,6 +54,7 @@ import { isStringType, isType, joinPaths, + resolveEncodedEnumMemberValue, sanitizePathSegment, serializeValueAsJson, } from "@typespec/compiler"; @@ -357,12 +358,14 @@ export class JsonSchemaEmitter extends TypeEmitter, JSONSche } enumDeclaration(en: Enum, name: string): EmitterOutput { + const program = this.emitter.getProgram(); const enumTypes = new Set(); const enumValues = new Set(); for (const member of en.members.values()) { // ???: why do we let emitters decide what the default type of an enum is - enumTypes.add(typeof member.value === "number" ? "number" : "string"); - enumValues.add(member.value ?? member.name); + const value = resolveEncodedEnumMemberValue(program, member, "application/json"); + enumTypes.add(typeof value === "number" ? "number" : "string"); + enumValues.add(value); } const enumTypesArray = [...enumTypes]; @@ -377,14 +380,14 @@ export class JsonSchemaEmitter extends TypeEmitter, JSONSche enumMemberReference(member: EnumMember): EmitterOutput> { // would like to dispatch to the same `literal` codepaths but enum members aren't literal types - switch (typeof member.value) { - case "undefined": - return { type: "string", const: member.name }; - case "string": - return { type: "string", const: member.value }; - case "number": - return { type: "number", const: member.value }; - } + const value = resolveEncodedEnumMemberValue( + this.emitter.getProgram(), + member, + "application/json", + ); + return typeof value === "number" + ? { type: "number", const: value } + : { type: "string", const: value }; } tupleLiteral(tuple: Tuple): EmitterOutput> { @@ -903,8 +906,14 @@ export class JsonSchemaEmitter extends TypeEmitter, JSONSche return [...type.variants.values()].flatMap((v) => this.#getStringLiteralValues(v.type)); case "UnionVariant": return this.#getStringLiteralValues(type.type); - case "EnumMember": - return typeof type.value !== "number" ? [type.value ?? type.name] : []; + case "EnumMember": { + const value = resolveEncodedEnumMemberValue( + this.emitter.getProgram(), + type, + "application/json", + ); + return typeof value === "string" ? [value] : []; + } default: return []; } diff --git a/packages/json-schema/test/discriminator.test.ts b/packages/json-schema/test/discriminator.test.ts index 7ab215c6862..54ec899936d 100644 --- a/packages/json-schema/test/discriminator.test.ts +++ b/packages/json-schema/test/discriminator.test.ts @@ -573,6 +573,32 @@ describe("discriminated union with polymorphic-models-strategy option", () => { ); }); + it("uses the json encoded name of enum members for the catch-all variant", async () => { + const schemas = await emitSchema( + ` + enum KnownToolType { + @encodedName("application/json", "file_search") + fileSearch, + } + + @discriminator("type") + model Tool { + name: string; + type: string | KnownToolType; + } + + model FileSearch extends Tool { + type: KnownToolType.fileSearch; + query: string; + } + `, + { "polymorphic-models-strategy": "oneOf" }, + ); + + const catchAll = schemas["Tool.json"].oneOf[1]; + deepStrictEqual(catchAll.properties.type.not.enum, ["file_search"]); + }); + it("does not generate catch-all for closed discriminators", async () => { const schemas = await emitSchema( ` diff --git a/packages/json-schema/test/enums.test.ts b/packages/json-schema/test/enums.test.ts index 6bab36ad72c..5d23ae0d377 100644 --- a/packages/json-schema/test/enums.test.ts +++ b/packages/json-schema/test/enums.test.ts @@ -83,3 +83,29 @@ it("handles enum member refs", async () => { assert.deepStrictEqual(Bar.properties.b, { type: "number", const: 2 }); assert.deepStrictEqual(Bar.properties.c, { type: "string", const: "c" }); }); + +it("uses the json encoded name as the value of a member without a value", async () => { + const schemas = await emitSchema(` + enum Foo { + @encodedName("application/json", "on") + a; + @encodedName("application/json", "off") + b: "bye"; + @encodedName("application/xml", "xml-c") + c; + } + + model Bar { + a: Foo.a; + b: Foo.b; + withDefault?: Foo = Foo.a; + } + `); + const Foo = schemas["Foo.json"]; + assert.deepStrictEqual(Foo.enum, ["on", "off", "c"]); + + const Bar = schemas["Bar.json"]; + assert.deepStrictEqual(Bar.properties.a, { type: "string", const: "on" }); + assert.deepStrictEqual(Bar.properties.b, { type: "string", const: "off" }); + assert.strictEqual(Bar.properties.withDefault.default, "on"); +}); diff --git a/packages/openapi3/src/schema-emitter-3-0.ts b/packages/openapi3/src/schema-emitter-3-0.ts index 8ef1cc4d6a0..b8ae0826e0c 100644 --- a/packages/openapi3/src/schema-emitter-3-0.ts +++ b/packages/openapi3/src/schema-emitter-3-0.ts @@ -21,6 +21,7 @@ import { getMaxValueExclusive, getMinValueExclusive, isNullType, + resolveEncodedEnumMemberValue, } from "@typespec/compiler"; import { $ } from "@typespec/compiler/typekit"; import type { MetadataInfo } from "@typespec/http"; @@ -123,8 +124,9 @@ export class OpenAPI3SchemaEmitter extends OpenAPI3SchemaEmitterBase(); const enumValues = new Set(); for (const member of en.members.values()) { - enumTypes.add(typeof member.value === "number" ? "number" : "string"); - enumValues.add(member.value ?? member.name); + const value = resolveEncodedEnumMemberValue(program, member, "application/json"); + enumTypes.add(typeof value === "number" ? "number" : "string"); + enumValues.add(value); } if (enumTypes.size > 1) { diff --git a/packages/openapi3/src/schema-emitter-3-1.ts b/packages/openapi3/src/schema-emitter-3-1.ts index 407592550ae..8876e63b60c 100644 --- a/packages/openapi3/src/schema-emitter-3-1.ts +++ b/packages/openapi3/src/schema-emitter-3-1.ts @@ -31,6 +31,7 @@ import { getMaxValueExclusive, getMinValueExclusive, getSummary, + resolveEncodedEnumMemberValue, } from "@typespec/compiler"; import type { MetadataInfo } from "@typespec/http"; import { getOneOf } from "./decorators.js"; @@ -197,8 +198,9 @@ export class OpenAPI31SchemaEmitter extends OpenAPI3SchemaEmitterBase(); const enumValues = new Set(); for (const member of en.members.values()) { - enumTypes.add(typeof member.value === "number" ? "number" : "string"); - enumValues.add(member.value ?? member.name); + const value = resolveEncodedEnumMemberValue(program, member, "application/json"); + enumTypes.add(typeof value === "number" ? "number" : "string"); + enumValues.add(value); } const enumTypesArray = [...enumTypes]; @@ -215,7 +217,7 @@ export class OpenAPI31SchemaEmitter extends OpenAPI3SchemaEmitterBase 0) { schema.discriminator.mapping = this.getDiscriminatorMapping(discriminatedUnion.variants); @@ -548,14 +549,14 @@ export class OpenAPI3SchemaEmitterBase< enumMemberReference(member: EnumMember): EmitterOutput> { // would like to dispatch to the same `literal` codepaths but enum members aren't literal types - switch (typeof member.value) { - case "undefined": - return { type: "string", enum: [member.name] }; - case "string": - return { type: "string", enum: [member.value] }; - case "number": - return { type: "number", enum: [member.value] }; - } + const value = resolveEncodedEnumMemberValue( + this.emitter.getProgram(), + member, + "application/json", + ); + return typeof value === "number" + ? { type: "number", enum: [value] } + : { type: "string", enum: [value] }; } unionDeclaration(union: Union, name: string): EmitterOutput { @@ -735,7 +736,12 @@ export class OpenAPI3SchemaEmitterBase< return type.value; } if (type.kind === "EnumMember") { - return typeof type.value === "string" ? type.value : type.name; + const value = resolveEncodedEnumMemberValue( + this.emitter.getProgram(), + type, + "application/json", + ); + return typeof value === "string" ? value : type.name; } return undefined; } diff --git a/packages/openapi3/test/discriminator.test.ts b/packages/openapi3/test/discriminator.test.ts index 623831401a5..a63f0e2f955 100644 --- a/packages/openapi3/test/discriminator.test.ts +++ b/packages/openapi3/test/discriminator.test.ts @@ -37,6 +37,32 @@ worksFor(supportedVersions, ({ checkFor, openApiFor }) => { deepStrictEqual(openApi.components.schemas.Dog.allOf, [{ $ref: "#/components/schemas/Pet" }]); }); + it("discriminator can be an enum member with an encoded name", async () => { + const openApi = await openApiFor(` + @discriminator("kind") + model Pet { kind: PetKind } + enum PetKind { + @encodedName("application/json", "feline") + cat, + dog, + } + model Cat extends Pet { + kind: PetKind.cat; + } + model Dog extends Pet { + kind: PetKind.dog; + } + `); + deepStrictEqual(openApi.components.schemas.Pet.discriminator, { + propertyName: "kind", + mapping: { + feline: "#/components/schemas/Cat", + dog: "#/components/schemas/Dog", + }, + }); + deepStrictEqual(openApi.components.schemas.PetKind.enum, ["feline", "dog"]); + }); + it("discriminator can be a union", async () => { const openApi = await openApiFor(` union PetKind {cat: "cat-kind", dog: "dog-kind" } diff --git a/packages/openapi3/test/enums.test.ts b/packages/openapi3/test/enums.test.ts index 087ba49ef12..6ed2346d0ca 100644 --- a/packages/openapi3/test/enums.test.ts +++ b/packages/openapi3/test/enums.test.ts @@ -1,6 +1,6 @@ import { expectDiagnostics } from "@typespec/compiler/testing"; import { deepStrictEqual, strictEqual } from "assert"; -import { it } from "vitest"; +import { describe, it } from "vitest"; import { diagnoseOpenApiFor } from "./test-host.js"; import { supportedVersions, worksFor } from "./works-for.js"; @@ -26,9 +26,120 @@ worksFor(supportedVersions, ({ diagnoseOpenApiFor, oapiForModel }) => { ); strictEqual(res.schemas.Foo.title, "FooEnum"); }); + + describe("@encodedName on members", () => { + it("uses the json encoded name as the value of a member without a value", async () => { + const res = await oapiForModel( + "Status", + ` + enum Status { + @encodedName("application/json", "on") + active, + inactive, + } + `, + ); + deepStrictEqual(res.schemas.Status, { type: "string", enum: ["on", "inactive"] }); + }); + + it("uses the encoded name over an explicit value", async () => { + const res = await oapiForModel( + "Status", + ` + enum Status { + @encodedName("application/json", "on") + active: "active-value", + inactive, + } + `, + ); + deepStrictEqual(res.schemas.Status, { type: "string", enum: ["on", "inactive"] }); + }); + + it("uses the encoded name over an integer value, including zero", async () => { + const res = await oapiForModel( + "Status", + ` + enum Status { + @encodedName("application/json", "unknown") + CONVERSATION_STATUS_UNSPECIFIED: 0, + @encodedName("application/json", "ready") + CONVERSATION_STATUS_READY: 2, + } + `, + ); + deepStrictEqual(res.schemas.Status, { type: "string", enum: ["unknown", "ready"] }); + }); + + it("leaves an integer member without an encoded name as a number", async () => { + const res = await oapiForModel("Status", `enum Status { a: 0, b: 1 }`); + deepStrictEqual(res.schemas.Status, { type: "number", enum: [0, 1] }); + }); + + it("ignores an encoded name for another mime type", async () => { + const res = await oapiForModel( + "Status", + ` + enum Status { + @encodedName("application/xml", "on") + active, + } + `, + ); + deepStrictEqual(res.schemas.Status, { type: "string", enum: ["active"] }); + }); + + it("uses the encoded name for a property typed as a member", async () => { + const res = await oapiForModel( + "Foo", + ` + model Foo { + status: Status.active; + } + enum Status { + @encodedName("application/json", "on") + active, + } + `, + ); + deepStrictEqual(res.schemas.Foo.properties.status, { type: "string", enum: ["on"] }); + }); + + it("uses the encoded name for a default value", async () => { + const res = await oapiForModel( + "Foo", + ` + model Foo { + status?: Status = Status.active; + } + enum Status { + @encodedName("application/json", "on") + active, + } + `, + ); + strictEqual(res.schemas.Foo.properties.status.default, "on"); + }); + }); }); worksFor(["3.0.0"], ({ diagnoseOpenApiFor }) => { + it("throws diagnostics when only some members of an integer enum have an encoded name", async () => { + const diagnostics = await diagnoseOpenApiFor(` + enum Status { + @encodedName("application/json", "ready") + CONVERSATION_STATUS_READY: 2, + CONVERSATION_STATUS_BUSY: 1, + } + model Foo { status: Status } + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/openapi3/enum-unique-type", + message: "Enums are not supported unless all options are literals of the same type.", + }); + }); + it("throws diagnostics for enum with different types", async () => { const diagnostics = await diagnoseOpenApiFor(`enum PetType {asString: "dog", asNumber: 1}`); @@ -86,6 +197,24 @@ worksFor(["3.1.0", "3.2.0"], ({ oapiForModel }) => { }); }); + it("emits the json encoded name of a member without a value with `enum-strategy: annotated`", async () => { + const res = await oapiForModel( + "Status", + ` + enum Status { + @encodedName("application/json", "on") + active, + inactive: "off", + } + `, + { "enum-strategy": "annotated" }, + ); + + deepStrictEqual(res.schemas.Status, { + oneOf: [{ const: "on" }, { const: "off" }], + }); + }); + it("emits annotated enums for number-valued enums", async () => { const res = await oapiForModel( "Priority", diff --git a/packages/openapi3/test/union-schema.test.ts b/packages/openapi3/test/union-schema.test.ts index 7ed4db3655c..d122b60efd6 100644 --- a/packages/openapi3/test/union-schema.test.ts +++ b/packages/openapi3/test/union-schema.test.ts @@ -255,6 +255,82 @@ worksFor(supportedVersions, ({ diagnoseOpenApiFor, oapiForModel, openApiFor }) = ); } }); + + it("default variant with a numeric enum member discriminator value uses its encoded name", async () => { + const res = await openApiFor( + ` + @discriminated(#{discriminatorPropertyName: "taxonomic_family", envelope: "none"}) + union Animal { + Dog, + felidae: Cat, + } + + enum Family { + @encodedName("application/json", "canidae") + dogs: 1, + } + + model Dog { + taxonomic_family: Family.dogs; + } + + model Cat { + taxonomic_family: "felidae"; + } + + op read(): { @body body: Animal }; + `, + ); + + if (res.openapi === "3.0.0" || res.openapi === "3.1.0") { + deepStrictEqual(res.components.schemas.Animal.discriminator.mapping, { + felidae: "#/components/schemas/Cat", + canidae: "#/components/schemas/Dog", + }); + } else { + deepStrictEqual(res.components.schemas.Animal.discriminator.mapping, { + felidae: "#/components/schemas/Cat", + }); + } + }); + + it("default variant with an enum member discriminator value uses its encoded name", async () => { + const res = await openApiFor( + ` + @discriminated(#{discriminatorPropertyName: "taxonomic_family", envelope: "none"}) + union Animal { + Dog, + felidae: Cat, + } + + enum Family { + @encodedName("application/json", "canidae") + dogs, + } + + model Dog { + taxonomic_family: Family.dogs; + } + + model Cat { + taxonomic_family: "felidae"; + } + + op read(): { @body body: Animal }; + `, + ); + + if (res.openapi === "3.0.0" || res.openapi === "3.1.0") { + deepStrictEqual(res.components.schemas.Animal.discriminator.mapping, { + felidae: "#/components/schemas/Cat", + canidae: "#/components/schemas/Dog", + }); + } else { + deepStrictEqual(res.components.schemas.Animal.discriminator.mapping, { + felidae: "#/components/schemas/Cat", + }); + } + }); }); describe("union literals", () => { diff --git a/packages/openapi3/test/xml-models.test.ts b/packages/openapi3/test/xml-models.test.ts index a6014e856fc..81c2f78f53c 100644 --- a/packages/openapi3/test/xml-models.test.ts +++ b/packages/openapi3/test/xml-models.test.ts @@ -144,6 +144,19 @@ worksFor(supportedVersions, ({ emitOpenApiWithDiagnostics, oapiForModel }) => { }); }); + it("does not change the value of an enum member", async () => { + const res = await oapiForModel( + "Status", + ` + enum Status { + @name("on") + active, + };`, + ); + + deepStrictEqual(res.schemas.Status, { type: "string", enum: ["active"] }); + }); + it("set the json name and no xml name", async () => { const res = await oapiForModel( "Book", diff --git a/website/src/content/docs/docs/standard-library/encoded-names.md b/website/src/content/docs/docs/standard-library/encoded-names.md index c704f584b67..a9b8d2173d9 100644 --- a/website/src/content/docs/docs/standard-library/encoded-names.md +++ b/website/src/content/docs/docs/standard-library/encoded-names.md @@ -94,6 +94,35 @@ updated: 1493938291 +## Enum members + +`@encodedName` sets the value an enum member is serialized as. Without it, a member is serialized as its explicit value, or as its name when it has none. + +```typespec +enum Status { + // Serialized as `"on"` in JSON, not `"active"` + @encodedName("application/json", "on") + active, + + // Serialized as `"inactive"`, its name + inactive, +} +``` + +This lets an enum carry values one protocol requires while presenting different values in JSON. The members below keep their integers for protobuf and are serialized as strings in JSON. + +```typespec +enum ConversationStatus { + @encodedName("application/json", "unknown") + CONVERSATION_STATUS_UNSPECIFIED: 0, + + @encodedName("application/json", "ready") + CONVERSATION_STATUS_READY: 1, +} +``` + +An encoded name is a string, so encoding only some members of an integer enum produces an enum with mixed types, which OpenAPI 3.0 does not support. + ## Use in library/emitter To consume the value of `@encodedName` in your library or emitter you can use `resolveEncodedName(target: Type, mimeType: string): string` from the compiler. @@ -107,3 +136,11 @@ const encodedName = resolveEncodedName(property, "application/json"); // You can also pass a full http mime type and `resolveEncodedName` will automatically resolve it to the base mime type. const encodedName = resolveEncodedName(property, "application/merge-patch+json"); ``` + +For an enum member, use `resolveEncodedEnumMemberValue(program, member, mimeType): string | number`. It returns the encoded name if one was set, otherwise the member's explicit value, otherwise its name. + +```ts +import { resolveEncodedEnumMemberValue } from "@typespec/compiler"; + +const value = resolveEncodedEnumMemberValue(program, member, "application/json"); +```