diff --git a/packages/http-client-csharp/DYNAMIC_MODEL_IMPLEMENTATION.md b/packages/http-client-csharp/DYNAMIC_MODEL_IMPLEMENTATION.md new file mode 100644 index 00000000000..aaac52a53b8 --- /dev/null +++ b/packages/http-client-csharp/DYNAMIC_MODEL_IMPLEMENTATION.md @@ -0,0 +1,109 @@ +# @dynamicModel Decorator Implementation + +## Overview + +This implementation adds support for the `@dynamicModel` decorator in @typespec/http-client-csharp. When applied to a model, it enables AdditionalProperties-based serialization using the new System.ClientModel AdditionalProperties struct instead of the traditional `_serializedAdditionalRawData` dictionary approach. + +## Usage Example + +```typespec +import "@typespec/http-client-csharp"; +using TypeSpec.CSharp; + +@dynamicModel +model User { + id: string; + name: string; + email?: string; +} + +op getUser(): User; +``` + +## Generated C# Code Comparison + +### Traditional Approach (without @dynamicModel) + +```csharp +public partial class User +{ + private readonly IDictionary _serializedAdditionalRawData; + + public User(string id, string name, string email = null, IDictionary serializedAdditionalRawData = null) + { + Id = id; + Name = name; + Email = email; + _serializedAdditionalRawData = serializedAdditionalRawData; + } + + public string Id { get; } + public string Name { get; } + public string Email { get; } +} +``` + +### New Approach (with @dynamicModel) + +```csharp +public partial class User +{ + public User(string id, string name, string email = null, AdditionalProperties patch = default) + { + Id = id; + Name = name; + Email = email; + Patch = patch; + } + + public string Id { get; } + public string Name { get; } + public string Email { get; } + public AdditionalProperties Patch { get; set; } +} +``` + +## Implementation Status + +### ✅ Completed +- TypeSpec decorator definition and registration +- Decorator processing pipeline in TypeScript emitter +- Input model type extension to track dynamic model flag +- C# model provider modifications: + - Skip raw data field generation for dynamic models + - Generate Patch property for dynamic models +- Comprehensive test suite + +### 🚧 Pending (blocked on System.ClientModel alpha release) +- Update to System.ClientModel 1.6.0-alpha.20250804.4 +- Replace object placeholder with actual AdditionalProperties type +- Implement serialization logic modifications: + - Deserialization: Use `AdditionalProperties.Set()` for unknown properties + - Serialization: Check patches and propagate to child objects + +## Architecture + +The implementation follows a clean pipeline: + +1. **TypeSpec Layer**: `@dynamicModel` decorator marks models +2. **Emitter Layer**: Decorator is processed and flag is set on InputModelType +3. **Serialization Layer**: JSON carries the isDynamicModel flag to C# generator +4. **C# Generation Layer**: ModelProvider generates different code based on flag +5. **Generated Code**: Models have either raw data field or Patch property + +## Testing + +Comprehensive tests cover: +- Basic dynamic model functionality +- Inheritance scenarios +- Models with additional properties +- Multiple dynamic models in same specification +- Negative cases (regular models without decorator) + +## Future Work + +When System.ClientModel alpha becomes available: +1. Update package reference +2. Replace placeholder type with AdditionalProperties +3. Implement serialization/deserialization logic per the reference implementations +4. Add integration tests with actual serialization scenarios \ No newline at end of file diff --git a/packages/http-client-csharp/emitter/src/index.ts b/packages/http-client-csharp/emitter/src/index.ts index a37aa7b2480..e8cb95b7bd2 100644 --- a/packages/http-client-csharp/emitter/src/index.ts +++ b/packages/http-client-csharp/emitter/src/index.ts @@ -7,7 +7,7 @@ export { $onEmit } from "./emitter.js"; // we export `createModel` only for autorest.csharp because it uses the emitter to generate the code model file but not calling the dll here // we could remove this export when in the future we deprecate autorest.csharp export { createModel } from "./lib/client-model-builder.js"; -export { $lib, createDiagnostic, getTracer, reportDiagnostic } from "./lib/lib.js"; +export { $lib, $dynamicModel, createDiagnostic, getTracer, reportDiagnostic } from "./lib/lib.js"; export { LoggerLevel } from "./lib/logger-level.js"; export { Logger } from "./lib/logger.js"; export { diff --git a/packages/http-client-csharp/emitter/src/lib/decorators.ts b/packages/http-client-csharp/emitter/src/lib/decorators.ts index b8cc59e0047..2671f81ec70 100644 --- a/packages/http-client-csharp/emitter/src/lib/decorators.ts +++ b/packages/http-client-csharp/emitter/src/lib/decorators.ts @@ -2,8 +2,9 @@ // Licensed under the MIT License. See License.txt in the project root for license information. import { SdkContext } from "@azure-tools/typespec-client-generator-core"; -import { DecoratedType, Operation, Type } from "@typespec/compiler"; +import { DecoratedType, Model, Operation, Type } from "@typespec/compiler"; import { ExternalDocs } from "../type/external-docs.js"; +import { $lib } from "./lib.js"; const externalDocsKey = Symbol("externalDocs"); export function getExternalDocs(context: SdkContext, entity: Type): ExternalDocs | undefined { @@ -18,6 +19,22 @@ export function getOperationId(context: SdkContext, entity: Operation): string | return context.program.stateMap(operationIdsKey).get(entity); } +const dynamicModelKey = Symbol("dynamicModel"); +/** + * @returns true if the model is marked with @dynamicModel decorator + */ +export function isDynamicModel(context: SdkContext, entity: Model): boolean { + return context.program.stateMap(dynamicModelKey).get(entity) === true; +} + +/** + * Marks a model to use AdditionalProperties-based serialization in C# + * instead of the traditional _serializedAdditionalRawData approach. + */ +export function $dynamicModel(context: SdkContext, target: Model) { + context.program.stateMap(dynamicModelKey).set(target, true); +} + export function hasDecorator(type: DecoratedType, name: string): boolean { return type.decorators.find((it) => it.decorator.name === name) !== undefined; } diff --git a/packages/http-client-csharp/emitter/src/lib/lib.ts b/packages/http-client-csharp/emitter/src/lib/lib.ts index 069450e5240..616551a0645 100644 --- a/packages/http-client-csharp/emitter/src/lib/lib.ts +++ b/packages/http-client-csharp/emitter/src/lib/lib.ts @@ -115,6 +115,8 @@ export const $lib = createTypeSpecLibrary({ }, }); +export { $dynamicModel } from "./decorators.js"; + /** * Reports a diagnostic. Defined in the core compiler. * @beta diff --git a/packages/http-client-csharp/emitter/test/Unit/dynamic-model-comprehensive.test.ts b/packages/http-client-csharp/emitter/test/Unit/dynamic-model-comprehensive.test.ts new file mode 100644 index 00000000000..8b3625f2814 --- /dev/null +++ b/packages/http-client-csharp/emitter/test/Unit/dynamic-model-comprehensive.test.ts @@ -0,0 +1,178 @@ +import { describe, it, beforeEach } from "vitest"; +import { TestHost } from "@typespec/compiler/testing"; +import { strictEqual, ok } from "assert"; +import { createModel } from "../../src/lib/client-model-builder.js"; +import { + createCSharpSdkContext, + createEmitterContext, + createEmitterTestHost, + typeSpecCompile, +} from "./utils/test-util.js"; + +describe("Test @dynamicModel decorator functionality", () => { + let runner: TestHost; + + beforeEach(async () => { + runner = await createEmitterTestHost(); + }); + + it("should mark simple model as dynamic", async () => { + const program = await typeSpecCompile( + ` + import "@typespec/http-client-csharp"; + using TypeSpec.CSharp; + + @dynamicModel + model SimpleModel { + name: string; + value: int32; + } + + op getSimple(): SimpleModel; + `, + runner, + ); + + const context = createEmitterContext(program); + const sdkContext = await createCSharpSdkContext(context); + const root = createModel(sdkContext); + + strictEqual(root.models.length, 1); + const model = root.models[0]; + strictEqual(model.name, "SimpleModel"); + strictEqual(model.isDynamicModel, true); + }); + + it("should not mark regular model as dynamic", async () => { + const program = await typeSpecCompile( + ` + model RegularModel { + name: string; + value: int32; + } + + op getRegular(): RegularModel; + `, + runner, + ); + + const context = createEmitterContext(program); + const sdkContext = await createCSharpSdkContext(context); + const root = createModel(sdkContext); + + strictEqual(root.models.length, 1); + const model = root.models[0]; + strictEqual(model.name, "RegularModel"); + strictEqual(model.isDynamicModel, false); + }); + + it("should handle dynamic models with additional properties", async () => { + const program = await typeSpecCompile( + ` + import "@typespec/http-client-csharp"; + using TypeSpec.CSharp; + + @dynamicModel + model ModelWithAdditionalProps { + name: string; + ...Record; + } + + op getWithAdditional(): ModelWithAdditionalProps; + `, + runner, + ); + + const context = createEmitterContext(program); + const sdkContext = await createCSharpSdkContext(context); + const root = createModel(sdkContext); + + strictEqual(root.models.length, 1); + const model = root.models[0]; + strictEqual(model.name, "ModelWithAdditionalProps"); + strictEqual(model.isDynamicModel, true); + ok(model.additionalProperties, "Model should have additional properties"); + }); + + it("should handle inheritance with dynamic models", async () => { + const program = await typeSpecCompile( + ` + import "@typespec/http-client-csharp"; + using TypeSpec.CSharp; + + model BaseModel { + id: string; + } + + @dynamicModel + model DerivedModel extends BaseModel { + name: string; + } + + op getDerived(): DerivedModel; + `, + runner, + ); + + const context = createEmitterContext(program); + const sdkContext = await createCSharpSdkContext(context); + const root = createModel(sdkContext); + + // Should have both base and derived models + ok(root.models.length >= 2); + + const derivedModel = root.models.find(m => m.name === "DerivedModel"); + ok(derivedModel, "Should find derived model"); + strictEqual(derivedModel.isDynamicModel, true); + + const baseModel = root.models.find(m => m.name === "BaseModel"); + ok(baseModel, "Should find base model"); + strictEqual(baseModel.isDynamicModel, false); + }); + + it("should work with multiple dynamic models", async () => { + const program = await typeSpecCompile( + ` + import "@typespec/http-client-csharp"; + using TypeSpec.CSharp; + + @dynamicModel + model FirstDynamic { + first: string; + } + + @dynamicModel + model SecondDynamic { + second: int32; + } + + model RegularModel { + regular: boolean; + } + + op getFirst(): FirstDynamic; + op getSecond(): SecondDynamic; + op getRegular(): RegularModel; + `, + runner, + ); + + const context = createEmitterContext(program); + const sdkContext = await createCSharpSdkContext(context); + const root = createModel(sdkContext); + + strictEqual(root.models.length, 3); + + const firstDynamic = root.models.find(m => m.name === "FirstDynamic"); + ok(firstDynamic); + strictEqual(firstDynamic.isDynamicModel, true); + + const secondDynamic = root.models.find(m => m.name === "SecondDynamic"); + ok(secondDynamic); + strictEqual(secondDynamic.isDynamicModel, true); + + const regular = root.models.find(m => m.name === "RegularModel"); + ok(regular); + strictEqual(regular.isDynamicModel, false); + }); +}); \ No newline at end of file diff --git a/packages/http-client-csharp/emitter/test/Unit/dynamic-model.test.ts b/packages/http-client-csharp/emitter/test/Unit/dynamic-model.test.ts new file mode 100644 index 00000000000..b892d7b2c71 --- /dev/null +++ b/packages/http-client-csharp/emitter/test/Unit/dynamic-model.test.ts @@ -0,0 +1,62 @@ +import { describe, it, beforeEach } from "vitest"; +import { TestHost } from "@typespec/compiler/testing"; +import { strictEqual } from "assert"; +import { createModel } from "../../src/lib/client-model-builder.js"; +import { + createCSharpSdkContext, + createEmitterContext, + createEmitterTestHost, + typeSpecCompile, +} from "./utils/test-util.js"; + +describe("Test @dynamicModel decorator", () => { + let runner: TestHost; + + beforeEach(async () => { + runner = await createEmitterTestHost(); + }); + + it("marks model as dynamic when @dynamicModel decorator is present", async () => { + const program = await typeSpecCompile( + ` + import "@typespec/http-client-csharp"; + using TypeSpec.CSharp; + + @dynamicModel + model TestModel { + name: string; + value: int32; + } + + op test(): TestModel; + `, + runner, + ); + const context = createEmitterContext(program); + const sdkContext = await createCSharpSdkContext(context); + const root = createModel(sdkContext); + const models = root.models; + strictEqual(models.length, 1); + strictEqual(models[0].isDynamicModel, true); + }); + + it("does not mark model as dynamic when @dynamicModel decorator is not present", async () => { + const program = await typeSpecCompile( + ` + model TestModel { + name: string; + value: int32; + } + + op test(): TestModel; + `, + runner, + ); + const context = createEmitterContext(program); + const sdkContext = await createCSharpSdkContext(context); + const root = createModel(sdkContext); + const models = root.models; + strictEqual(models.length, 1); + strictEqual(models[0].isDynamicModel, false); + }); +}); \ No newline at end of file diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs index 810b7951f76..80c9aeeb196 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs @@ -55,9 +55,12 @@ protected override FormattableString BuildDescription() private readonly bool _isAbstract; private readonly CSharpType _additionalBinaryDataPropsFieldType = typeof(IDictionary); + // TODO: Replace with typeof(System.ClientModel.Primitives.AdditionalProperties) when System.ClientModel 1.6.0-alpha.20250804.4 becomes available + private readonly CSharpType _additionalPropertiesType = new CSharpType(typeof(object)); private readonly Type _additionalPropsUnknownType = typeof(BinaryData); private readonly Lazy? _baseTypeProvider; private FieldProvider? _rawDataField; + private PropertyProvider? _patchProperty; private List? _additionalPropertyFields; private List? _additionalPropertyProperties; private ModelProvider? _baseModelProvider; @@ -75,6 +78,11 @@ public ModelProvider(InputModelType inputModel) : base(inputModel) } } + /// + /// Checks if the model has the @dynamicModel decorator + /// + private bool IsDynamicModel => _inputModel.Decorators.Any(d => d.Name == "dynamicModel"); + public bool IsUnknownDiscriminatorModel => _inputModel.IsUnknownDiscriminatorModel; public string? DiscriminatorValue => _inputModel.DiscriminatorValue; @@ -113,6 +121,7 @@ private IReadOnlyList BuildDerivedModels() public ModelProvider? BaseModelProvider => _baseModelProvider ??= (_baseTypeProvider?.Value is ModelProvider baseModelProvider ? baseModelProvider : null); private FieldProvider? RawDataField => _rawDataField ??= BuildRawDataField(); + private PropertyProvider? PatchProperty => _patchProperty ??= BuildPatchProperty(); private List AdditionalPropertyFields => _additionalPropertyFields ??= BuildAdditionalPropertyFields(); private List AdditionalPropertyProperties => _additionalPropertyProperties ??= BuildAdditionalPropertyProperties(); internal bool SupportsBinaryDataAdditionalProperties => AdditionalPropertyProperties.Any(p => p.Type.ElementType.Equals(_additionalPropsUnknownType)); @@ -429,6 +438,12 @@ protected override PropertyProvider[] BuildProperties() properties.AddRange(AdditionalPropertyProperties); } + // Add Patch property for dynamic models + if (IsDynamicModel && PatchProperty != null) + { + properties.Add(PatchProperty); + } + return [.. properties]; } @@ -885,10 +900,17 @@ private ValueExpression GetConversion(PropertyProvider? property = default, Fiel /// /// Builds the raw data field for the model to be used for serialization. + /// For dynamic models, this will return null as they use the Patch property instead. /// /// The constructed if the model should generate the field. private FieldProvider? BuildRawDataField() { + // Dynamic models use AdditionalProperties struct instead of raw data field + if (IsDynamicModel) + { + return null; + } + // check if there is a raw data field on any of the base models, if so, we do not have to have one here. var baseModelProvider = BaseModelProvider; while (baseModelProvider != null) @@ -917,6 +939,40 @@ private ValueExpression GetConversion(PropertyProvider? property = default, Fiel return rawDataField; } + /// + /// Builds the Patch property for dynamic models to be used for AdditionalProperties-based serialization. + /// + /// The constructed if the model is dynamic. + private PropertyProvider? BuildPatchProperty() + { + // Only dynamic models get the Patch property + if (!IsDynamicModel) + { + return null; + } + + // Check if there is a patch property on any of the base models, if so, we do not have to have one here. + var baseModelProvider = BaseModelProvider; + while (baseModelProvider != null) + { + if (baseModelProvider.PatchProperty != null) + { + return null; + } + baseModelProvider = baseModelProvider.BaseModelProvider; + } + + var patchProperty = new PropertyProvider( + description: FormattableStringHelpers.FromString("Gets or sets additional properties for the model."), + modifiers: MethodSignatureModifiers.Public, + type: _additionalPropertiesType, + name: "Patch", + body: new AutoPropertyBody(true), + enclosingType: this); + + return patchProperty; + } + /// /// Replaces unverifiable types, types that do not have value kind checks during deserialization of additional properties, /// with the corresponding verifiable types. By default, BinaryData is used as the value type for unknown additional properties. diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/DynamicModelSerializationTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/DynamicModelSerializationTests.cs new file mode 100644 index 00000000000..871a8f36b54 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/DynamicModelSerializationTests.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Reflection; +using Microsoft.TypeSpec.Generator.Input; +using Microsoft.TypeSpec.Generator.Primitives; +using Microsoft.TypeSpec.Generator.Providers; +using Microsoft.TypeSpec.Generator.Tests.Common; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests.Providers +{ + public class DynamicModelSerializationTests + { + [SetUp] + public void Setup() + { + MockHelpers.LoadMockGenerator(); + } + [Test] + public void DynamicModelSerialization() + { + var properties = new[] + { + InputFactory.Property("id", InputPrimitiveType.String, isRequired: true), + InputFactory.Property("name", InputPrimitiveType.String, isRequired: true), + InputFactory.Property("email", InputPrimitiveType.String, isRequired: false) + }; + + // Create a model with the dynamicModel decorator + var inputModel = InputFactory.Model("User", properties: properties); + + // Use reflection to set decorators since the property has an internal setter + var decoratorsProperty = typeof(InputType).GetProperty("Decorators"); + var decorators = new List + { + new InputDecoratorInfo("dynamicModel", null) + }; + decoratorsProperty?.SetValue(inputModel, decorators); + + var modelProvider = new ModelProvider(inputModel); + var writer = new TypeProviderWriter(modelProvider); + var file = writer.Write(); + + // Verify the model doesn't have the raw data field + Assert.That(file.Content, Does.Not.Contain("_additionalBinaryDataProperties")); + + // Verify the model has the Patch property + Assert.That(file.Content, Contains.Substring("public object Patch { get; set; }")); + } + + [Test] + public void RegularModelStillHasRawDataField() + { + var properties = new[] + { + InputFactory.Property("id", InputPrimitiveType.String, isRequired: true), + InputFactory.Property("name", InputPrimitiveType.String, isRequired: true) + }; + + // Create a regular model without the dynamicModel decorator + var inputModel = InputFactory.Model("RegularUser", properties: properties); + + var modelProvider = new ModelProvider(inputModel); + var writer = new TypeProviderWriter(modelProvider); + var file = writer.Write(); + + // Verify the model has the raw data field + Assert.That(file.Content, Contains.Substring("_additionalBinaryDataProperties")); + + // Verify the model doesn't have the Patch property + Assert.That(file.Content, Does.Not.Contain("public object Patch { get; set; }")); + } + } +} \ No newline at end of file diff --git a/packages/http-client-csharp/generator/NuGet.Config b/packages/http-client-csharp/generator/NuGet.Config new file mode 100644 index 00000000000..d9f9f057c8e --- /dev/null +++ b/packages/http-client-csharp/generator/NuGet.Config @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/packages/http-client-csharp/generator/Packages.Data.props b/packages/http-client-csharp/generator/Packages.Data.props index b55ea514ea6..e7a66e2e6aa 100644 --- a/packages/http-client-csharp/generator/Packages.Data.props +++ b/packages/http-client-csharp/generator/Packages.Data.props @@ -14,6 +14,7 @@ + diff --git a/packages/http-client-csharp/lib/decorators.tsp b/packages/http-client-csharp/lib/decorators.tsp new file mode 100644 index 00000000000..384b4b8e912 --- /dev/null +++ b/packages/http-client-csharp/lib/decorators.tsp @@ -0,0 +1,21 @@ +namespace TypeSpec.CSharp; + +/** + * Marks a model to use AdditionalProperties-based serialization in C# + * instead of the traditional _serializedAdditionalRawData approach. + * + * When this decorator is applied to a model: + * - The model will have a Patch property of type AdditionalProperties + * - Deserialization will use AdditionalProperties.Set(...) methods + * - Serialization will check for patches and propagate to child objects + * + * @example + * ```tsp + * @dynamicModel + * model MyModel { + * name: string; + * value: int32; + * } + * ``` + */ +extern dec dynamicModel(target: Model); \ No newline at end of file diff --git a/packages/http-client-csharp/lib/main.tsp b/packages/http-client-csharp/lib/main.tsp new file mode 100644 index 00000000000..6479dd8eb39 --- /dev/null +++ b/packages/http-client-csharp/lib/main.tsp @@ -0,0 +1,3 @@ +import "./decorators.tsp"; + +namespace TypeSpec.CSharp; \ No newline at end of file