Skip to content

Commit 978bb9f

Browse files
Include custom code properties in ConfigurationSchema.json and ClientSettings binding (#10243)
## Problem The `ConfigurationSchemaGenerator`, `ClientOptionsProvider` config constructor, and `ClientSettingsProvider` only discover properties from the TypeSpec input model (`BuildProperties()`). Hand-written properties added via partial classes (e.g., `Audience` on `ConfigurationClientOptions` in Azure.Data.AppConfiguration) are silently dropped when: - The JSON schema is regenerated - The configuration section constructor binds values - The settings `BindCore` method binds values This was discovered via [Azure/azure-sdk-for-net#57682](Azure/azure-sdk-for-net#57682) where bumping the emitter version caused the `Audience` property to disappear from the generated `ConfigurationSchema.json`. ## Fix ### ConfigurationSchemaGenerator - **`BuildOptionsSchema`**: After collecting generated properties, also merges public properties from `clientOptions.CustomCodeView?.Properties` - **`BuildClientEntry`**: After collecting generated required params, discovers custom constructor parameters from `client.CustomCodeView?.Constructors` ### ClientOptionsProvider - **`BuildConfigurationSectionConstructor`**: Now also binds custom code properties from the configuration section ### ClientSettingsProvider - **`BuildProperties`**: Now includes custom constructor parameters as settings properties - **`BuildMethods` (BindCore)**: Now binds custom constructor parameters from configuration ## Tests Added 5 new tests across 3 test classes: - `ConfigurationSchemaGeneratorTests.Generate_IncludesCustomCodeOptionsProperties` - `ConfigurationSchemaGeneratorTests.Generate_IncludesCustomConstructorParameters` - `ClientOptionsProviderTests.TestConfigurationSectionConstructorBody_BindsCustomCodeProperties` - `ClientSettingsProviderTests.TestProperties_IncludesCustomConstructorParameters` - `ClientSettingsProviderTests.TestBindCoreMethod_BindsCustomConstructorParameters` All 91 related tests pass. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 8d559e3 commit 978bb9f

11 files changed

Lines changed: 365 additions & 0 deletions

File tree

packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/ConfigurationSchemaGenerator.cs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,30 @@ private static JsonObject BuildClientEntry(ClientProvider client, string options
115115
properties[propName] = GetJsonSchemaForType(param.Type, localDefinitions);
116116
}
117117

118+
// Add custom constructor parameters from custom code (e.g., hand-written constructors
119+
// added via partial classes) that are not already covered by generated parameters.
120+
// Skip credential types, endpoint types (Uri), and options types as they are handled separately.
121+
var customConstructors = client.CustomCodeView?.Constructors;
122+
if (customConstructors != null)
123+
{
124+
var knownProps = new HashSet<string>(properties.Select(p => p.Key));
125+
knownProps.Add("Credential");
126+
knownProps.Add("Options");
127+
foreach (var ctor in customConstructors)
128+
{
129+
foreach (var param in ctor.Signature.Parameters)
130+
{
131+
var propName = param.Name.ToIdentifierName();
132+
if (!knownProps.Contains(propName) &&
133+
!ClientSettingsProvider.IsStandardParameterType(param.Type))
134+
{
135+
properties[propName] = GetJsonSchemaForType(param.Type, localDefinitions);
136+
knownProps.Add(propName);
137+
}
138+
}
139+
}
140+
}
141+
118142
// Add credential reference (defined in System.ClientModel base schema)
119143
properties["Credential"] = new JsonObject
120144
{
@@ -158,6 +182,23 @@ private static JsonObject BuildOptionsSchema(ClientProvider client, string optio
158182
.Where(p => p.Modifiers.HasFlag(MethodSignatureModifiers.Public))
159183
.ToList();
160184

185+
// Also include custom code properties (e.g., hand-written properties added via partial classes)
186+
// that are not already in the generated properties set.
187+
var generatedPropNames = new HashSet<string>(customProperties.Select(p => p.Name));
188+
var customCodeProperties = clientOptions.CustomCodeView?.Properties;
189+
if (customCodeProperties != null)
190+
{
191+
foreach (var prop in customCodeProperties)
192+
{
193+
if (prop.Modifiers.HasFlag(MethodSignatureModifiers.Public) &&
194+
!generatedPropNames.Contains(prop.Name))
195+
{
196+
customProperties.Add(prop);
197+
generatedPropNames.Add(prop.Name);
198+
}
199+
}
200+
}
201+
161202
var allOfArray = new JsonArray
162203
{
163204
new JsonObject { ["$ref"] = $"#/definitions/{optionsRef}" }

packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ClientOptionsProvider.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,31 @@ private ConstructorProvider BuildConfigurationSectionConstructor()
387387
property.Type);
388388
}
389389

390+
// Also bind custom code properties (e.g., hand-written properties added via partial classes)
391+
var generatedPropNames = new HashSet<string>(Properties.Select(p => p.Name));
392+
if (versionPropertyNames != null)
393+
{
394+
generatedPropNames.UnionWith(versionPropertyNames);
395+
}
396+
var customCodeProperties = CustomCodeView?.Properties;
397+
if (customCodeProperties != null)
398+
{
399+
foreach (var prop in customCodeProperties)
400+
{
401+
if (prop.Modifiers.HasFlag(MethodSignatureModifiers.Public) &&
402+
!generatedPropNames.Contains(prop.Name))
403+
{
404+
ClientSettingsProvider.AppendBindingForProperty(
405+
body,
406+
sectionParam,
407+
prop.Name,
408+
prop.Name.ToVariableName(),
409+
prop.Type);
410+
generatedPropNames.Add(prop.Name);
411+
}
412+
}
413+
}
414+
390415
return new ConstructorProvider(
391416
new ConstructorSignature(
392417
Type,

packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ClientSettingsProvider.cs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,35 @@ protected override PropertyProvider[] BuildProperties()
105105
this));
106106
}
107107

108+
// Include custom constructor parameters from custom code (e.g., hand-written constructors
109+
// added via partial classes) that are not already covered by generated parameters.
110+
// Skip credential types, endpoint types (Uri), and options types as they are handled separately.
111+
var customConstructors = _clientProvider.CustomCodeView?.Constructors;
112+
if (customConstructors != null)
113+
{
114+
var knownProps = new HashSet<string>(properties.Select(p => p.Name));
115+
knownProps.Add("Credential");
116+
knownProps.Add("Options");
117+
foreach (var ctor in customConstructors)
118+
{
119+
foreach (var param in ctor.Signature.Parameters)
120+
{
121+
var propName = param.Name.ToIdentifierName();
122+
if (!knownProps.Contains(propName) && !IsStandardParameterType(param.Type))
123+
{
124+
properties.Add(new PropertyProvider(
125+
null,
126+
MethodSignatureModifiers.Public,
127+
param.Type.WithNullable(true),
128+
propName,
129+
new AutoPropertyBody(true),
130+
this));
131+
knownProps.Add(propName);
132+
}
133+
}
134+
}
135+
}
136+
108137
var clientOptions = _clientProvider.EffectiveClientOptions;
109138
if (clientOptions != null)
110139
{
@@ -136,6 +165,36 @@ protected override MethodProvider[] BuildMethods()
136165
AppendBindingForProperty(body, sectionParam, propName, param.Name.ToVariableName(), param.Type);
137166
}
138167

168+
// Bind custom constructor parameters from custom code
169+
// Skip credential types, endpoint types (Uri), and options types as they are handled separately.
170+
var customConstructors = _clientProvider.CustomCodeView?.Constructors;
171+
if (customConstructors != null)
172+
{
173+
var knownProps = new HashSet<string>();
174+
if (EndpointProperty != null)
175+
{
176+
knownProps.Add(EndpointProperty.Name);
177+
}
178+
foreach (var param in OtherRequiredParams)
179+
{
180+
knownProps.Add(param.Name.ToIdentifierName());
181+
}
182+
knownProps.Add("Credential");
183+
knownProps.Add("Options");
184+
foreach (var ctor in customConstructors)
185+
{
186+
foreach (var param in ctor.Signature.Parameters)
187+
{
188+
var propName = param.Name.ToIdentifierName();
189+
if (!knownProps.Contains(propName) && !IsStandardParameterType(param.Type))
190+
{
191+
AppendBindingForProperty(body, sectionParam, propName, param.Name.ToVariableName(), param.Type);
192+
knownProps.Add(propName);
193+
}
194+
}
195+
}
196+
}
197+
139198
var clientOptions = _clientProvider.EffectiveClientOptions;
140199
if (clientOptions != null)
141200
{
@@ -391,5 +450,41 @@ internal static void AppendComplexObjectBinding(
391450
ifExistsStatement.Add(This.Property(propName).Assign(New.Instance(type, sectionVar)).Terminate());
392451
body.Add(ifExistsStatement);
393452
}
453+
454+
/// <summary>
455+
/// Checks if a type is a standard client parameter type that should not be included as a
456+
/// custom settings property (credential types, endpoint types, or options types).
457+
/// </summary>
458+
internal static bool IsStandardParameterType(CSharpType type)
459+
{
460+
var effectiveType = type.IsNullable ? type.WithNullable(false) : type;
461+
462+
// Skip endpoint types (Uri)
463+
if (effectiveType.IsFrameworkType && effectiveType.FrameworkType == typeof(Uri))
464+
{
465+
return true;
466+
}
467+
468+
// Skip credential types — compare by both type equality and name since the CSharpType
469+
// from CustomCodeView (Roslyn-based) may not directly equal typeof()-based CSharpType.
470+
var tokenCredentialType = ScmCodeModelGenerator.Instance.TypeFactory.ClientPipelineApi.TokenCredentialType;
471+
if (tokenCredentialType != null)
472+
{
473+
if (effectiveType.Equals(tokenCredentialType) ||
474+
effectiveType.Name == tokenCredentialType.Name)
475+
{
476+
return true;
477+
}
478+
}
479+
480+
// Skip options types (derives from ClientPipelineOptions)
481+
var optionsType = ScmCodeModelGenerator.Instance.TypeFactory.ClientPipelineApi.ClientPipelineOptionsType;
482+
if (effectiveType.Equals(optionsType) || effectiveType.Name == optionsType.Name)
483+
{
484+
return true;
485+
}
486+
487+
return false;
488+
}
394489
}
395490
}

packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/ConfigurationSchemaGeneratorTests.cs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using System.Runtime.CompilerServices;
1010
using System.Text.Json;
1111
using System.Text.Json.Nodes;
12+
using System.Threading.Tasks;
1213
using Microsoft.TypeSpec.Generator.ClientModel.Providers;
1314
using Microsoft.TypeSpec.Generator.Input;
1415
using Microsoft.TypeSpec.Generator.Primitives;
@@ -839,6 +840,85 @@ public void ConfigurationSchemaOptions_HasCorrectDefaults()
839840
Assert.IsTrue(options.GenerateNuGetTargets);
840841
}
841842

843+
[Test]
844+
public async Task Generate_IncludesCustomCodeOptionsProperties()
845+
{
846+
// Reset singleton before loading async mock
847+
var singletonField = typeof(ClientOptionsProvider).GetField("_singletonInstance", BindingFlags.Static | BindingFlags.NonPublic);
848+
singletonField?.SetValue(null, null);
849+
850+
// Load mock generator with a compilation that contains a custom partial class
851+
// for TestServiceOptions with an "Audience" property.
852+
await MockHelpers.LoadMockGeneratorAsync(
853+
compilation: async () => await Helpers.GetCompilationFromDirectoryAsync());
854+
855+
var client = InputFactory.Client("TestService");
856+
var clientProvider = new ClientProvider(client);
857+
858+
Assert.IsNotNull(clientProvider.ClientOptions, "ClientOptions should not be null");
859+
Assert.IsNotNull(clientProvider.ClientOptions!.CustomCodeView,
860+
"CustomCodeView should be available from the compilation");
861+
862+
var output = new TestOutputLibrary([clientProvider]);
863+
var result = ConfigurationSchemaGenerator.Generate(output);
864+
865+
Assert.IsNotNull(result);
866+
var doc = JsonNode.Parse(result!)!;
867+
868+
// Find the options definition
869+
var clientEntry = doc["properties"]?["Clients"]?["properties"]?["TestService"];
870+
var optionsRef = clientEntry?["properties"]?["Options"]?["$ref"]?.GetValue<string>();
871+
Assert.IsNotNull(optionsRef, "Options should reference a local definition");
872+
var defName = optionsRef!.Replace("#/definitions/", "");
873+
874+
var optionsDef = doc["definitions"]?[defName];
875+
Assert.IsNotNull(optionsDef, $"Options definition '{defName}' should exist");
876+
877+
var allOf = optionsDef!["allOf"]!.AsArray();
878+
Assert.AreEqual(2, allOf.Count, "allOf should have base options + extension with custom properties");
879+
880+
// Verify the custom "Audience" property from the partial class is included
881+
var extensionProperties = allOf[1]?["properties"];
882+
Assert.IsNotNull(extensionProperties, "Extension properties should exist");
883+
var audienceProp = extensionProperties!["Audience"];
884+
Assert.IsNotNull(audienceProp, "Custom code 'Audience' property should be included in the schema");
885+
Assert.AreEqual("string", audienceProp!["type"]?.GetValue<string>());
886+
}
887+
888+
[Test]
889+
public async Task Generate_IncludesCustomConstructorParameters()
890+
{
891+
// Reset singleton before loading async mock
892+
var singletonField = typeof(ClientOptionsProvider).GetField("_singletonInstance", BindingFlags.Static | BindingFlags.NonPublic);
893+
singletonField?.SetValue(null, null);
894+
895+
// Load mock generator with a compilation that contains a custom partial class
896+
// for TestService with a constructor that takes a "connectionString" parameter.
897+
await MockHelpers.LoadMockGeneratorAsync(
898+
compilation: async () => await Helpers.GetCompilationFromDirectoryAsync());
899+
900+
var client = InputFactory.Client("TestService");
901+
var clientProvider = new ClientProvider(client);
902+
903+
Assert.IsNotNull(clientProvider.CustomCodeView,
904+
"CustomCodeView should be available from the compilation");
905+
906+
var output = new TestOutputLibrary([clientProvider]);
907+
var result = ConfigurationSchemaGenerator.Generate(output);
908+
909+
Assert.IsNotNull(result);
910+
var doc = JsonNode.Parse(result!)!;
911+
912+
// Verify the custom "ConnectionString" constructor parameter appears as a client-level property
913+
var clientEntry = doc["properties"]?["Clients"]?["properties"]?["TestService"];
914+
Assert.IsNotNull(clientEntry, "TestService client entry should exist");
915+
916+
var connectionStringProp = clientEntry!["properties"]?["ConnectionString"];
917+
Assert.IsNotNull(connectionStringProp,
918+
"Custom constructor parameter 'connectionString' should appear as 'ConnectionString' in the schema");
919+
Assert.AreEqual("string", connectionStringProp!["type"]?.GetValue<string>());
920+
}
921+
842922
/// <summary>
843923
/// Test output library that wraps provided TypeProviders.
844924
/// </summary>

packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientOptionsProviderTests.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1120,5 +1120,28 @@ public void TestConfigurationSectionConstructorBody_WithFixedEnumProperty()
11201120
Assert.IsFalse(bodyString.Contains("new ClientMode"),
11211121
"IConfigurationSection constructor should NOT use new for fixed enum property binding");
11221122
}
1123+
1124+
[Test]
1125+
public async Task TestConfigurationSectionConstructorBody_BindsCustomCodeProperties()
1126+
{
1127+
await MockHelpers.LoadMockGeneratorAsync(
1128+
compilation: async () => await Helpers.GetCompilationFromDirectoryAsync());
1129+
1130+
var client = InputFactory.Client("TestClient", clientNamespace: "SampleNamespace");
1131+
var clientProvider = ScmCodeModelGenerator.Instance.TypeFactory.CreateClient(client);
1132+
var clientOptionsProvider = clientProvider!.ClientOptions;
1133+
1134+
Assert.IsNotNull(clientOptionsProvider);
1135+
Assert.IsNotNull(clientOptionsProvider!.CustomCodeView,
1136+
"CustomCodeView should be available from the compilation");
1137+
1138+
var configSectionCtor = clientOptionsProvider.Constructors
1139+
.FirstOrDefault(c => c.Signature.Parameters.Any(p => p.Name == "section"));
1140+
Assert.IsNotNull(configSectionCtor);
1141+
1142+
var bodyString = configSectionCtor!.BodyStatements!.ToDisplayString();
1143+
Assert.IsTrue(bodyString.Contains("Audience"),
1144+
"IConfigurationSection constructor should bind the custom code 'Audience' property from configuration");
1145+
}
11231146
}
11241147
}

packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientSettingsProviderTests.cs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System;
55
using System.Linq;
6+
using System.Threading.Tasks;
67
using Microsoft.TypeSpec.Generator.ClientModel.Providers;
78
using Microsoft.TypeSpec.Generator.Input;
89
using Microsoft.TypeSpec.Generator.Primitives;
@@ -912,5 +913,56 @@ private static bool IsSettingsConstructor(ConstructorProvider c) =>
912913
c.Signature?.Initializer != null &&
913914
c.Signature?.Modifiers == MethodSignatureModifiers.Public &&
914915
c.Signature.Parameters.Any(p => p.Name == "settings");
916+
917+
[Test]
918+
public async Task TestProperties_IncludesCustomConstructorParameters()
919+
{
920+
var singletonField = typeof(ClientOptionsProvider).GetField("_singletonInstance",
921+
System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic);
922+
singletonField?.SetValue(null, null);
923+
924+
await MockHelpers.LoadMockGeneratorAsync(
925+
compilation: async () => await Helpers.GetCompilationFromDirectoryAsync());
926+
927+
var client = InputFactory.Client("TestClient", clientNamespace: "SampleNamespace");
928+
var clientProvider = ScmCodeModelGenerator.Instance.TypeFactory.CreateClient(client);
929+
Assert.IsNotNull(clientProvider);
930+
Assert.IsNotNull(clientProvider!.CustomCodeView,
931+
"CustomCodeView should be available from the compilation");
932+
933+
var settings = clientProvider.ClientSettings;
934+
Assert.IsNotNull(settings);
935+
936+
var connectionStringProp = settings!.Properties
937+
.FirstOrDefault(p => p.Name == "ConnectionString");
938+
Assert.IsNotNull(connectionStringProp,
939+
"Settings should include 'ConnectionString' property from custom constructor parameter");
940+
}
941+
942+
[Test]
943+
public async Task TestBindCoreMethod_BindsCustomConstructorParameters()
944+
{
945+
var singletonField = typeof(ClientOptionsProvider).GetField("_singletonInstance",
946+
System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic);
947+
singletonField?.SetValue(null, null);
948+
949+
await MockHelpers.LoadMockGeneratorAsync(
950+
compilation: async () => await Helpers.GetCompilationFromDirectoryAsync());
951+
952+
var client = InputFactory.Client("TestClient", clientNamespace: "SampleNamespace");
953+
var clientProvider = ScmCodeModelGenerator.Instance.TypeFactory.CreateClient(client);
954+
Assert.IsNotNull(clientProvider);
955+
956+
var settings = clientProvider!.ClientSettings;
957+
Assert.IsNotNull(settings);
958+
959+
var bindCoreMethod = settings!.Methods
960+
.FirstOrDefault(m => m.Signature.Name == "BindCore");
961+
Assert.IsNotNull(bindCoreMethod);
962+
963+
var bodyString = bindCoreMethod!.BodyStatements!.ToDisplayString();
964+
Assert.IsTrue(bodyString.Contains("ConnectionString"),
965+
"BindCore should bind the custom constructor parameter 'ConnectionString' from configuration");
966+
}
915967
}
916968
}

0 commit comments

Comments
 (0)