Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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);
```
Original file line number Diff line number Diff line change
@@ -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,
}
```
Original file line number Diff line number Diff line change
@@ -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`.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
79 changes: 67 additions & 12 deletions packages/compiler/src/core/helpers/discriminator-utils.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
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";
import { getDiscriminatedOptions, getDiscriminatedTypes } from "../intrinsic-type-state.js";
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<DiscriminatedOptions>;
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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[]] {
Expand All @@ -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(
Expand Down Expand Up @@ -201,14 +243,15 @@ function getDiscriminatorProperty(
}

function getDiscriminatorValues(
program: Program | undefined,
model: Model,
discriminator: Discriminator,
diagnostics: Diagnostic[],
): string[] | undefined {
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({
Expand All @@ -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);
}
1 change: 1 addition & 0 deletions packages/compiler/src/core/helpers/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
2 changes: 2 additions & 0 deletions packages/compiler/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -194,6 +195,7 @@ export {
isService,
isStringType,
listServices,
resolveEncodedEnumMemberValue,
resolveEncodedName,
serializeValueAsJson,
setMediaTypeHint,
Expand Down
6 changes: 5 additions & 1 deletion packages/compiler/src/lib/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
33 changes: 32 additions & 1 deletion packages/compiler/src/lib/encoded-names.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions packages/compiler/src/lib/examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion packages/compiler/src/typekit/kits/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ defineKit<TypekitExtension>({
return [undefined, []];
}

return getDiscriminatedUnionFromInheritance(model, discriminator);
return getDiscriminatedUnionFromInheritance(this.program, model, discriminator);
}),
},
});
44 changes: 44 additions & 0 deletions packages/compiler/test/decorators/decorators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]!;
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading