From 264c557b488f54bfd5a7353caaaabeddca285208 Mon Sep 17 00:00:00 2001 From: Zach Bimson Date: Tue, 15 Sep 2026 19:38:23 +0100 Subject: [PATCH] fix: honor @encodedName on enum members @encodedName now sets the value an enum member is serialized as, so an enum can keep the values another protocol requires and present different values in json. Applied by the compiler (example, default and discriminator values), openapi3, json-schema, http-server-js, http-server-csharp and emitter-framework through the new resolveEncodedEnumMemberValue helper. getDiscriminatedUnionFromInheritance gets a program-first overload so enum member discriminator values can resolve their encoded value; the previous signature is deprecated. --- ...nator-program-compiler-2026-9-15-19-0-6.md | 11 ++ ...-enum-members-compiler-2026-9-15-19-0-1.md | 15 ++ ...bers-emitter-framework-2026-9-15-19-0-7.md | 7 + ...ers-http-server-csharp-2026-9-15-19-0-5.md | 7 + ...members-http-server-js-2026-9-15-19-0-4.md | 7 + ...um-members-json-schema-2026-9-15-19-0-3.md | 7 + ...-enum-members-openapi3-2026-9-15-19-0-2.md | 7 + .../src/core/helpers/discriminator-utils.ts | 79 +++++++++-- packages/compiler/src/core/helpers/index.ts | 1 + packages/compiler/src/index.ts | 2 + packages/compiler/src/lib/decorators.ts | 6 +- packages/compiler/src/lib/encoded-names.ts | 33 ++++- packages/compiler/src/lib/examples.ts | 9 +- packages/compiler/src/typekit/kits/model.ts | 2 +- .../test/decorators/decorators.test.ts | 44 ++++++ .../compiler/test/decorators/examples.test.ts | 22 +++ .../test/helpers/discriminator-utils.test.ts | 47 ++++++- packages/compiler/test/typekit/model.test.ts | 23 +++ .../src/python/components/atom/atom.test.tsx | 28 +++- .../src/python/components/atom/atom.tsx | 13 +- .../components/enum-declaration.tsx | 4 +- .../components/union/expression.test.tsx | 16 +++ .../components/union/expression.tsx | 9 +- .../components/value-expression.tsx | 13 +- .../components/enum-declaration.test.tsx | 28 ++++ .../components/type-transform.test.tsx | 46 +++++- .../components/value-expression.test.tsx | 33 ++++- .../src/components/enums/enums.tsx | 18 ++- .../http-server-csharp/test/emitter.test.ts | 31 +++++ packages/http-server-js/src/common/enum.ts | 3 +- .../http-server-js/src/common/reference.ts | 13 +- .../http-server-js/src/util/differentiate.ts | 3 +- packages/http-server-js/test/enum.test.ts | 81 +++++++++++ packages/http-server-js/test/scalar.test.ts | 12 +- packages/http-server-js/test/tester.ts | 9 ++ packages/http-server-js/vitest.config.ts | 1 + .../json-schema/src/json-schema-emitter.ts | 33 +++-- .../json-schema/test/discriminator.test.ts | 26 ++++ packages/json-schema/test/enums.test.ts | 26 ++++ packages/openapi3/src/schema-emitter-3-0.ts | 6 +- packages/openapi3/src/schema-emitter-3-1.ts | 8 +- packages/openapi3/src/schema-emitter.ts | 26 ++-- packages/openapi3/test/discriminator.test.ts | 26 ++++ packages/openapi3/test/enums.test.ts | 131 +++++++++++++++++- packages/openapi3/test/union-schema.test.ts | 76 ++++++++++ packages/openapi3/test/xml-models.test.ts | 13 ++ .../docs/standard-library/encoded-names.md | 37 +++++ 47 files changed, 1005 insertions(+), 93 deletions(-) create mode 100644 .chronus/changes/encoded-name-discriminator-program-compiler-2026-9-15-19-0-6.md create mode 100644 .chronus/changes/encoded-name-enum-members-compiler-2026-9-15-19-0-1.md create mode 100644 .chronus/changes/encoded-name-enum-members-emitter-framework-2026-9-15-19-0-7.md create mode 100644 .chronus/changes/encoded-name-enum-members-http-server-csharp-2026-9-15-19-0-5.md create mode 100644 .chronus/changes/encoded-name-enum-members-http-server-js-2026-9-15-19-0-4.md create mode 100644 .chronus/changes/encoded-name-enum-members-json-schema-2026-9-15-19-0-3.md create mode 100644 .chronus/changes/encoded-name-enum-members-openapi3-2026-9-15-19-0-2.md create mode 100644 packages/http-server-js/test/enum.test.ts create mode 100644 packages/http-server-js/test/tester.ts 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"); +```