diff --git a/.chronus/changes/allow-clientoption-model-value-2026-08-20-12-50-00.md b/.chronus/changes/allow-clientoption-model-value-2026-08-20-12-50-00.md new file mode 100644 index 0000000000..7789194018 --- /dev/null +++ b/.chronus/changes/allow-clientoption-model-value-2026-08-20-12-50-00.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@azure-tools/typespec-client-generator-core" +--- + +`@clientOption`'s `value` can now reference a TypeSpec model, in addition to `string`, `boolean`, and `number` literal values. The referenced model (including customizations such as `@alternateType`) is preserved and resolved so scoped emitters can access it via `getClientOptions`. diff --git a/packages/typespec-client-generator-core/README.md b/packages/typespec-client-generator-core/README.md index c1d7a73fa0..875e9ab4cf 100644 --- a/packages/typespec-client-generator-core/README.md +++ b/packages/typespec-client-generator-core/README.md @@ -877,7 +877,7 @@ See supported client options for each language emitter here https://azure.github warning if no scope is provided (since options are typically language-specific). ```typespec -@Azure.ClientGenerator.Core.clientOption(name: valueof string, value: valueof unknown, scope?: valueof string) +@Azure.ClientGenerator.Core.clientOption(name: valueof string, value: unknown | valueof unknown, scope?: valueof string) ``` ##### Target @@ -887,11 +887,11 @@ The type you want to apply the option to. ##### Parameters -| Name | Type | Description | -| ----- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| name | `valueof string` | The name of the option (e.g., "enableFeatureFoo"). | -| value | `valueof unknown` | The value of the option. Can be any type; emitters will cast as needed. | -| scope | `valueof string` | Specifies the target language emitters to which the decorator applies. Every use must provide an explicit scope; omitting it produces an additional warning.

**Supported language identifiers:** `csharp`, `python`, `java`, `javascript`, `go`, and other language emitter names (derived from the emitter package name, e.g., `@azure-tools/typespec-csharp` → `csharp`).

**Valid patterns:**
- Single language: `"python"`
- Multiple languages (comma-separated): `"python, java"`
- Negation to exclude languages: `"!csharp"` or `"!(java, python)"` | +| Name | Type | Description | +| ----- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| name | `valueof string` | The name of the option (e.g., "enableFeatureFoo"). | +| value | `unknown` \| `valueof unknown` | The value of the option. Can be a literal value (string, boolean, number, etc.) or a
reference to a TypeSpec model, in which case the referenced model (including its own decorators,
such as `@alternateType`) is preserved so the scoped emitter can resolve it. Emitters will cast as needed. | +| scope | `valueof string` | Specifies the target language emitters to which the decorator applies. Every use must provide an explicit scope; omitting it produces an additional warning.

**Supported language identifiers:** `csharp`, `python`, `java`, `javascript`, `go`, and other language emitter names (derived from the emitter package name, e.g., `@azure-tools/typespec-csharp` → `csharp`).

**Valid patterns:**
- Single language: `"python"`
- Multiple languages (comma-separated): `"python, java"`
- Negation to exclude languages: `"!csharp"` or `"!(java, python)"` | ##### Examples @@ -905,6 +905,14 @@ model MyModel { } ``` +###### Apply an experimental option that references a model + +```typespec +#suppress "@azure-tools/typespec-client-generator-core/client-option" "preview feature for csharp" +@clientOption("composes", OpenAICreateResponseOptions, "csharp") +model FoundryCreateResponseOptions {} +``` + #### `@convenientAPI` Whether you want to generate an operation as a convenient method. diff --git a/packages/typespec-client-generator-core/generated-defs/Azure.ClientGenerator.Core.ts b/packages/typespec-client-generator-core/generated-defs/Azure.ClientGenerator.Core.ts index 32121f0e50..04f7d00144 100644 --- a/packages/typespec-client-generator-core/generated-defs/Azure.ClientGenerator.Core.ts +++ b/packages/typespec-client-generator-core/generated-defs/Azure.ClientGenerator.Core.ts @@ -1089,7 +1089,9 @@ export type ClientDocDecorator = ( * * @param target The type you want to apply the option to. * @param name The name of the option (e.g., "enableFeatureFoo"). - * @param value The value of the option. Can be any type; emitters will cast as needed. + * @param value The value of the option. Can be a literal value (string, boolean, number, etc.) or a + * reference to a TypeSpec model, in which case the referenced model (including its own decorators, + * such as `@alternateType`) is preserved so the scoped emitter can resolve it. Emitters will cast as needed. * @param scope Specifies the target language emitters to which the decorator applies. Every use must provide an explicit scope; omitting it produces an additional warning. * * **Supported language identifiers:** `csharp`, `python`, `java`, `javascript`, `go`, and other language emitter names (derived from the emitter package name, e.g., `@azure-tools/typespec-csharp` → `csharp`). @@ -1106,12 +1108,18 @@ export type ClientDocDecorator = ( * prop: string; * } * ``` + * @example Apply an experimental option that references a model + * ```typespec + * #suppress "@azure-tools/typespec-client-generator-core/client-option" "preview feature for csharp" + * @clientOption("composes", OpenAICreateResponseOptions, "csharp") + * model FoundryCreateResponseOptions {} + * ``` */ export type ClientOptionDecorator = ( context: DecoratorContext, target: Type, name: string, - value: unknown, + value: Type | unknown, scope?: string, ) => DecoratorValidatorCallbacks | void; diff --git a/packages/typespec-client-generator-core/lib/decorators.tsp b/packages/typespec-client-generator-core/lib/decorators.tsp index 95188ae096..13acf1e668 100644 --- a/packages/typespec-client-generator-core/lib/decorators.tsp +++ b/packages/typespec-client-generator-core/lib/decorators.tsp @@ -1178,7 +1178,9 @@ extern dec clientDoc( * * @param target The type you want to apply the option to. * @param name The name of the option (e.g., "enableFeatureFoo"). - * @param value The value of the option. Can be any type; emitters will cast as needed. + * @param value The value of the option. Can be a literal value (string, boolean, number, etc.) or a + * reference to a TypeSpec model, in which case the referenced model (including its own decorators, + * such as `@alternateType`) is preserved so the scoped emitter can resolve it. Emitters will cast as needed. * @param scope Specifies the target language emitters to which the decorator applies. Every use must provide an explicit scope; omitting it produces an additional warning. * * **Supported language identifiers:** `csharp`, `python`, `java`, `javascript`, `go`, and other language emitter names (derived from the emitter package name, e.g., `@azure-tools/typespec-csharp` → `csharp`). @@ -1196,10 +1198,17 @@ extern dec clientDoc( * prop: string; * } * ``` + * + * @example Apply an experimental option that references a model + * ```typespec + * #suppress "@azure-tools/typespec-client-generator-core/client-option" "preview feature for csharp" + * @clientOption("composes", OpenAICreateResponseOptions, "csharp") + * model FoundryCreateResponseOptions {} + * ``` */ extern dec clientOption( target: unknown, name: valueof string, - value: valueof unknown, + value: unknown | (valueof unknown), scope?: valueof string ); diff --git a/packages/typespec-client-generator-core/src/internal-utils.ts b/packages/typespec-client-generator-core/src/internal-utils.ts index 064c49201a..15e640fd66 100644 --- a/packages/typespec-client-generator-core/src/internal-utils.ts +++ b/packages/typespec-client-generator-core/src/internal-utils.ts @@ -550,7 +550,7 @@ function getDecoratorArgValue( ): [any, readonly Diagnostic[]] { const diagnostics = createDiagnosticCollector(); if (typeof arg === "object" && arg !== null && "kind" in arg) { - if (arg.kind === "EnumMember") { + if (arg.kind === "EnumMember" || arg.kind === "Model") { return diagnostics.wrap(diagnostics.pipe(getClientTypeWithDiagnostics(context, arg as any))); } if ( diff --git a/packages/typespec-client-generator-core/test/decorators/client-option.test.ts b/packages/typespec-client-generator-core/test/decorators/client-option.test.ts index c2593fee64..efc0e39c64 100644 --- a/packages/typespec-client-generator-core/test/decorators/client-option.test.ts +++ b/packages/typespec-client-generator-core/test/decorators/client-option.test.ts @@ -193,6 +193,63 @@ describe("@clientOption with getClientOptions getter", () => { strictEqual(typeof numberValue, "number"); }); + it("should support a model reference as the value", async () => { + const { program } = await SimpleTesterWithService.compile(` + model ComposedOptions { + a: string; + } + + #suppress "@azure-tools/typespec-client-generator-core/client-option" + @clientOption("composes", ComposedOptions, "csharp") + @test + model Test { + id: string; + } + + op getTest(): Test; + `); + + const context = await createSdkContextForTester(program, { + emitterName: "@azure-tools/typespec-csharp", + }); + + const sdkModel = context.sdkPackage.models.find((m) => m.name === "Test"); + ok(sdkModel, "SDK model should exist"); + + const value = getClientOptions(sdkModel, "composes") as { kind?: string; name?: string }; + strictEqual(value?.kind, "model"); + strictEqual(value?.name, "ComposedOptions"); + }); + + it("should preserve customizations, such as @alternateType, on a model reference value", async () => { + const { program } = await SimpleTesterWithService.compile(` + @alternateType(string, "csharp") + model AlternateOptions { + a: string; + } + + #suppress "@azure-tools/typespec-client-generator-core/client-option" + @clientOption("composes", AlternateOptions, "csharp") + @test + model Test { + id: string; + } + + op getTest(): Test; + `); + + const context = await createSdkContextForTester(program, { + emitterName: "@azure-tools/typespec-csharp", + }); + + const sdkModel = context.sdkPackage.models.find((m) => m.name === "Test"); + ok(sdkModel, "SDK model should exist"); + + const value = getClientOptions(sdkModel, "composes") as { kind?: string }; + strictEqual(value?.kind, "string"); + // @alternateType(string) replaces the referenced model with the builtin `string` type + }); + it("should return client option value for operation", async () => { const { program } = await SimpleTesterWithService.compile(` #suppress "@azure-tools/typespec-client-generator-core/client-option" diff --git a/packages/typespec-go/src/codegen/core/models.ts b/packages/typespec-go/src/codegen/core/models.ts index 20f26b1e29..f9f092c1c4 100644 --- a/packages/typespec-go/src/codegen/core/models.ts +++ b/packages/typespec-go/src/codegen/core/models.ts @@ -136,25 +136,17 @@ export function generateModels( if (needsJSONPopulateAsString) { serdeImports.add("reflect"); serdeImports.add("github.com/Azure/azure-sdk-for-go/sdk/azcore"); - serdeTextBody += - "func populateAsString(m map[string]any, k string, v any, fn func() string) {\n"; - serdeTextBody += `${indent.get()}${helpers.buildIfBlock( - indent, - { - condition: "v == nil", - body: (indent) => `${indent.get()}return\n`, - }, - [ - { - condition: "azcore.IsNullValue(v)", - body: (indent) => `${indent.get()}m[k] = nil\n`, - }, - { - condition: "!reflect.ValueOf(v).IsNil()", - body: (indent) => `${indent.get()}m[k] = fn()\n`, - }, - ], - )}\n`; + serdeTextBody += "func populateAsString(m map[string]any, k string, v any, fn func() string) {\n"; + serdeTextBody += `${indent.get()}${helpers.buildIfBlock(indent, { + condition: "v == nil", + body: (indent) => `${indent.get()}return\n`, + }, [{ + condition: "azcore.IsNullValue(v)", + body: (indent) => `${indent.get()}m[k] = nil\n`, + }, { + condition: "!reflect.ValueOf(v).IsNil()", + body: (indent) => `${indent.get()}m[k] = fn()\n`, + }])}\n`; serdeTextBody += "}\n\n"; } if (needsJSONPopulateTime) { @@ -219,8 +211,7 @@ export function generateModels( } if (needsJSONUnpopulateFromString) { serdeImports.add("fmt"); - serdeTextBody += - "func unpopulateFromString(data json.RawMessage, fn string, f func(s string) error) error {\n"; + serdeTextBody += "func unpopulateFromString(data json.RawMessage, fn string, f func(s string) error) error {\n"; serdeTextBody += `${indent.get()}${helpers.buildIfBlock(indent, { condition: `data == nil || string(data) == "null"`, body: (indent) => `${indent.get()}return nil\n`, @@ -233,16 +224,14 @@ export function generateModels( })}\n`; serdeTextBody += `${indent.get()}${helpers.buildIfBlock(indent, { condition: "err != nil", - body: (indent) => - `${indent.get()}return fmt.Errorf("struct field %s: %s", fn, err.Error())\n`, + body: (indent) => `${indent.get()}return fmt.Errorf("struct field %s: %s", fn, err.Error())\n`, })}\n`; serdeTextBody += `${indent.get()}return nil\n`; serdeTextBody += "}\n\n"; } if (needsJSONUnpopulateStringArray) { serdeImports.add("strings"); - serdeTextBody += - "func unpopulateStringArray[T ~string](data json.RawMessage, fn string, v *[]T, d string) error {\n"; + serdeTextBody += "func unpopulateStringArray[T ~string](data json.RawMessage, fn string, v *[]T, d string) error {\n"; serdeTextBody += `${indent.get()}${helpers.buildIfBlock(indent, { condition: `data == nil || string(data) == "null"`, body: (indent) => `${indent.get()}return nil\n`, @@ -250,8 +239,7 @@ export function generateModels( serdeTextBody += `${indent.get()}var encodedValue string\n`; serdeTextBody += `${indent.get()}${helpers.buildIfBlock(indent, { condition: "err := json.Unmarshal(data, &encodedValue); err != nil", - body: (indent) => - `${indent.get()}return fmt.Errorf("struct field %s: %s", fn, err.Error())\n`, + body: (indent) => `${indent.get()}return fmt.Errorf("struct field %s: %s", fn, err.Error())\n`, })}\n`; serdeTextBody += `${indent.get()}${helpers.buildIfBlock(indent, { condition: `encodedValue == ""`, @@ -263,13 +251,9 @@ export function generateModels( })}\n`; serdeTextBody += `${indent.get()}values := strings.Split(encodedValue, d)\n`; serdeTextBody += `${indent.get()}result := make([]T, len(values))\n`; - serdeTextBody += `${indent.get()}${helpers.buildForBlock( - indent, - "i := range values", - (indent) => { - return `${indent.get()}result[i] = T(values[i])\n`; - }, - )}`; + serdeTextBody += `${indent.get()}${helpers.buildForBlock(indent, "i := range values", (indent) => { + return `${indent.get()}result[i] = T(values[i])\n`; + })}`; serdeTextBody += `${indent.get()}*v = result\n`; serdeTextBody += `${indent.get()}return nil\n`; serdeTextBody += "}\n\n"; @@ -305,32 +289,23 @@ export function generateModels( if (needsJSONPopulateStringArray) { serdeImports.add("github.com/Azure/azure-sdk-for-go/sdk/azcore"); serdeImports.add("strings"); - serdeTextBody += - "func populateStringArray[T ~string](m map[string]any, k string, v []T, d string) {"; - serdeTextBody += `${indent.get()}${helpers.buildIfBlock( - indent, + serdeTextBody += "func populateStringArray[T ~string](m map[string]any, k string, v []T, d string) {"; + serdeTextBody += `${indent.get()}${helpers.buildIfBlock(indent, { + condition: "azcore.IsNullValue(v)", + body: (indent) => `${indent.get()}m[k] = nil\n`, + }, [ { - condition: "azcore.IsNullValue(v)", - body: (indent) => `${indent.get()}m[k] = nil\n`, - }, - [ - { - condition: "v != nil", - body: (indent) => { - let controlBlock = `${indent.get()}encodedValue := make([]string, len(v))\n`; - controlBlock += `${indent.get()}${helpers.buildForBlock( - indent, - "i := range v", - (indent) => { - return `${indent.get()}encodedValue[i] = string(v[i])\n`; - }, - )}`; - controlBlock += `${indent.get()}m[k] = strings.Join(encodedValue, d)\n`; - return controlBlock; - }, + condition: "v != nil", + body: (indent) => { + let controlBlock = `${indent.get()}encodedValue := make([]string, len(v))\n`; + controlBlock += `${indent.get()}${helpers.buildForBlock(indent, "i := range v", (indent) => { + return `${indent.get()}encodedValue[i] = string(v[i])\n`; + })}`; + controlBlock += `${indent.get()}m[k] = strings.Join(encodedValue, d)\n`; + return controlBlock; }, - ], - )}\n`; + } + ])}\n`; serdeTextBody += "}\n\n"; } if (needsJSONUnpopulateTime || needsJSONPopulateTime) { diff --git a/website/src/content/docs/docs/libraries/typespec-client-generator-core/reference/decorators.md b/website/src/content/docs/docs/libraries/typespec-client-generator-core/reference/decorators.md index e46ec1f134..245d4a60b2 100644 --- a/website/src/content/docs/docs/libraries/typespec-client-generator-core/reference/decorators.md +++ b/website/src/content/docs/docs/libraries/typespec-client-generator-core/reference/decorators.md @@ -733,7 +733,7 @@ See supported client options for each language emitter here https://azure.github warning if no scope is provided (since options are typically language-specific). ```typespec -@Azure.ClientGenerator.Core.clientOption(name: valueof string, value: valueof unknown, scope?: valueof string) +@Azure.ClientGenerator.Core.clientOption(name: valueof string, value: unknown | valueof unknown, scope?: valueof string) ``` #### Target @@ -743,11 +743,11 @@ The type you want to apply the option to. #### Parameters -| Name | Type | Description | -| ----- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| name | `valueof string` | The name of the option (e.g., "enableFeatureFoo"). | -| value | `valueof unknown` | The value of the option. Can be any type; emitters will cast as needed. | -| scope | `valueof string` | Specifies the target language emitters to which the decorator applies. Every use must provide an explicit scope; omitting it produces an additional warning.

**Supported language identifiers:** `csharp`, `python`, `java`, `javascript`, `go`, and other language emitter names (derived from the emitter package name, e.g., `@azure-tools/typespec-csharp` → `csharp`).

**Valid patterns:**
- Single language: `"python"`
- Multiple languages (comma-separated): `"python, java"`
- Negation to exclude languages: `"!csharp"` or `"!(java, python)"` | +| Name | Type | Description | +| ----- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| name | `valueof string` | The name of the option (e.g., "enableFeatureFoo"). | +| value | `unknown` \| `valueof unknown` | The value of the option. Can be a literal value (string, boolean, number, etc.) or a
reference to a TypeSpec model, in which case the referenced model (including its own decorators,
such as `@alternateType`) is preserved so the scoped emitter can resolve it. Emitters will cast as needed. | +| scope | `valueof string` | Specifies the target language emitters to which the decorator applies. Every use must provide an explicit scope; omitting it produces an additional warning.

**Supported language identifiers:** `csharp`, `python`, `java`, `javascript`, `go`, and other language emitter names (derived from the emitter package name, e.g., `@azure-tools/typespec-csharp` → `csharp`).

**Valid patterns:**
- Single language: `"python"`
- Multiple languages (comma-separated): `"python, java"`
- Negation to exclude languages: `"!csharp"` or `"!(java, python)"` | #### Examples @@ -761,6 +761,14 @@ model MyModel { } ``` +##### Apply an experimental option that references a model + +```typespec +#suppress "@azure-tools/typespec-client-generator-core/client-option" "preview feature for csharp" +@clientOption("composes", OpenAICreateResponseOptions, "csharp") +model FoundryCreateResponseOptions {} +``` + ### `@convenientAPI` {#@Azure.ClientGenerator.Core.convenientAPI} Whether you want to generate an operation as a convenient method.