diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/ConfigurationSchemaGenerator.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/ConfigurationSchemaGenerator.cs index 72245c0305a..fbe69ae179f 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/ConfigurationSchemaGenerator.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/ConfigurationSchemaGenerator.cs @@ -115,6 +115,30 @@ private static JsonObject BuildClientEntry(ClientProvider client, string options properties[propName] = GetJsonSchemaForType(param.Type, localDefinitions); } + // Add custom constructor parameters from custom code (e.g., hand-written constructors + // added via partial classes) that are not already covered by generated parameters. + // Skip credential types, endpoint types (Uri), and options types as they are handled separately. + var customConstructors = client.CustomCodeView?.Constructors; + if (customConstructors != null) + { + var knownProps = new HashSet(properties.Select(p => p.Key)); + knownProps.Add("Credential"); + knownProps.Add("Options"); + foreach (var ctor in customConstructors) + { + foreach (var param in ctor.Signature.Parameters) + { + var propName = param.Name.ToIdentifierName(); + if (!knownProps.Contains(propName) && + !ClientSettingsProvider.IsStandardParameterType(param.Type)) + { + properties[propName] = GetJsonSchemaForType(param.Type, localDefinitions); + knownProps.Add(propName); + } + } + } + } + // Add credential reference (defined in System.ClientModel base schema) properties["Credential"] = new JsonObject { @@ -158,6 +182,23 @@ private static JsonObject BuildOptionsSchema(ClientProvider client, string optio .Where(p => p.Modifiers.HasFlag(MethodSignatureModifiers.Public)) .ToList(); + // Also include custom code properties (e.g., hand-written properties added via partial classes) + // that are not already in the generated properties set. + var generatedPropNames = new HashSet(customProperties.Select(p => p.Name)); + var customCodeProperties = clientOptions.CustomCodeView?.Properties; + if (customCodeProperties != null) + { + foreach (var prop in customCodeProperties) + { + if (prop.Modifiers.HasFlag(MethodSignatureModifiers.Public) && + !generatedPropNames.Contains(prop.Name)) + { + customProperties.Add(prop); + generatedPropNames.Add(prop.Name); + } + } + } + var allOfArray = new JsonArray { new JsonObject { ["$ref"] = $"#/definitions/{optionsRef}" } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ClientOptionsProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ClientOptionsProvider.cs index a2420740832..e0a0bf9edf8 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ClientOptionsProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ClientOptionsProvider.cs @@ -387,6 +387,31 @@ private ConstructorProvider BuildConfigurationSectionConstructor() property.Type); } + // Also bind custom code properties (e.g., hand-written properties added via partial classes) + var generatedPropNames = new HashSet(Properties.Select(p => p.Name)); + if (versionPropertyNames != null) + { + generatedPropNames.UnionWith(versionPropertyNames); + } + var customCodeProperties = CustomCodeView?.Properties; + if (customCodeProperties != null) + { + foreach (var prop in customCodeProperties) + { + if (prop.Modifiers.HasFlag(MethodSignatureModifiers.Public) && + !generatedPropNames.Contains(prop.Name)) + { + ClientSettingsProvider.AppendBindingForProperty( + body, + sectionParam, + prop.Name, + prop.Name.ToVariableName(), + prop.Type); + generatedPropNames.Add(prop.Name); + } + } + } + return new ConstructorProvider( new ConstructorSignature( Type, diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ClientSettingsProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ClientSettingsProvider.cs index c8408170637..a3b06754522 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ClientSettingsProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ClientSettingsProvider.cs @@ -105,6 +105,35 @@ protected override PropertyProvider[] BuildProperties() this)); } + // Include custom constructor parameters from custom code (e.g., hand-written constructors + // added via partial classes) that are not already covered by generated parameters. + // Skip credential types, endpoint types (Uri), and options types as they are handled separately. + var customConstructors = _clientProvider.CustomCodeView?.Constructors; + if (customConstructors != null) + { + var knownProps = new HashSet(properties.Select(p => p.Name)); + knownProps.Add("Credential"); + knownProps.Add("Options"); + foreach (var ctor in customConstructors) + { + foreach (var param in ctor.Signature.Parameters) + { + var propName = param.Name.ToIdentifierName(); + if (!knownProps.Contains(propName) && !IsStandardParameterType(param.Type)) + { + properties.Add(new PropertyProvider( + null, + MethodSignatureModifiers.Public, + param.Type.WithNullable(true), + propName, + new AutoPropertyBody(true), + this)); + knownProps.Add(propName); + } + } + } + } + var clientOptions = _clientProvider.EffectiveClientOptions; if (clientOptions != null) { @@ -136,6 +165,36 @@ protected override MethodProvider[] BuildMethods() AppendBindingForProperty(body, sectionParam, propName, param.Name.ToVariableName(), param.Type); } + // Bind custom constructor parameters from custom code + // Skip credential types, endpoint types (Uri), and options types as they are handled separately. + var customConstructors = _clientProvider.CustomCodeView?.Constructors; + if (customConstructors != null) + { + var knownProps = new HashSet(); + if (EndpointProperty != null) + { + knownProps.Add(EndpointProperty.Name); + } + foreach (var param in OtherRequiredParams) + { + knownProps.Add(param.Name.ToIdentifierName()); + } + knownProps.Add("Credential"); + knownProps.Add("Options"); + foreach (var ctor in customConstructors) + { + foreach (var param in ctor.Signature.Parameters) + { + var propName = param.Name.ToIdentifierName(); + if (!knownProps.Contains(propName) && !IsStandardParameterType(param.Type)) + { + AppendBindingForProperty(body, sectionParam, propName, param.Name.ToVariableName(), param.Type); + knownProps.Add(propName); + } + } + } + } + var clientOptions = _clientProvider.EffectiveClientOptions; if (clientOptions != null) { @@ -391,5 +450,41 @@ internal static void AppendComplexObjectBinding( ifExistsStatement.Add(This.Property(propName).Assign(New.Instance(type, sectionVar)).Terminate()); body.Add(ifExistsStatement); } + + /// + /// Checks if a type is a standard client parameter type that should not be included as a + /// custom settings property (credential types, endpoint types, or options types). + /// + internal static bool IsStandardParameterType(CSharpType type) + { + var effectiveType = type.IsNullable ? type.WithNullable(false) : type; + + // Skip endpoint types (Uri) + if (effectiveType.IsFrameworkType && effectiveType.FrameworkType == typeof(Uri)) + { + return true; + } + + // Skip credential types — compare by both type equality and name since the CSharpType + // from CustomCodeView (Roslyn-based) may not directly equal typeof()-based CSharpType. + var tokenCredentialType = ScmCodeModelGenerator.Instance.TypeFactory.ClientPipelineApi.TokenCredentialType; + if (tokenCredentialType != null) + { + if (effectiveType.Equals(tokenCredentialType) || + effectiveType.Name == tokenCredentialType.Name) + { + return true; + } + } + + // Skip options types (derives from ClientPipelineOptions) + var optionsType = ScmCodeModelGenerator.Instance.TypeFactory.ClientPipelineApi.ClientPipelineOptionsType; + if (effectiveType.Equals(optionsType) || effectiveType.Name == optionsType.Name) + { + return true; + } + + return false; + } } } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/ConfigurationSchemaGeneratorTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/ConfigurationSchemaGeneratorTests.cs index dbc47ede7e8..1f65ccbbb10 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/ConfigurationSchemaGeneratorTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/ConfigurationSchemaGeneratorTests.cs @@ -9,6 +9,7 @@ using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Nodes; +using System.Threading.Tasks; using Microsoft.TypeSpec.Generator.ClientModel.Providers; using Microsoft.TypeSpec.Generator.Input; using Microsoft.TypeSpec.Generator.Primitives; @@ -839,6 +840,85 @@ public void ConfigurationSchemaOptions_HasCorrectDefaults() Assert.IsTrue(options.GenerateNuGetTargets); } + [Test] + public async Task Generate_IncludesCustomCodeOptionsProperties() + { + // Reset singleton before loading async mock + var singletonField = typeof(ClientOptionsProvider).GetField("_singletonInstance", BindingFlags.Static | BindingFlags.NonPublic); + singletonField?.SetValue(null, null); + + // Load mock generator with a compilation that contains a custom partial class + // for TestServiceOptions with an "Audience" property. + await MockHelpers.LoadMockGeneratorAsync( + compilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + var client = InputFactory.Client("TestService"); + var clientProvider = new ClientProvider(client); + + Assert.IsNotNull(clientProvider.ClientOptions, "ClientOptions should not be null"); + Assert.IsNotNull(clientProvider.ClientOptions!.CustomCodeView, + "CustomCodeView should be available from the compilation"); + + var output = new TestOutputLibrary([clientProvider]); + var result = ConfigurationSchemaGenerator.Generate(output); + + Assert.IsNotNull(result); + var doc = JsonNode.Parse(result!)!; + + // Find the options definition + var clientEntry = doc["properties"]?["Clients"]?["properties"]?["TestService"]; + var optionsRef = clientEntry?["properties"]?["Options"]?["$ref"]?.GetValue(); + Assert.IsNotNull(optionsRef, "Options should reference a local definition"); + var defName = optionsRef!.Replace("#/definitions/", ""); + + var optionsDef = doc["definitions"]?[defName]; + Assert.IsNotNull(optionsDef, $"Options definition '{defName}' should exist"); + + var allOf = optionsDef!["allOf"]!.AsArray(); + Assert.AreEqual(2, allOf.Count, "allOf should have base options + extension with custom properties"); + + // Verify the custom "Audience" property from the partial class is included + var extensionProperties = allOf[1]?["properties"]; + Assert.IsNotNull(extensionProperties, "Extension properties should exist"); + var audienceProp = extensionProperties!["Audience"]; + Assert.IsNotNull(audienceProp, "Custom code 'Audience' property should be included in the schema"); + Assert.AreEqual("string", audienceProp!["type"]?.GetValue()); + } + + [Test] + public async Task Generate_IncludesCustomConstructorParameters() + { + // Reset singleton before loading async mock + var singletonField = typeof(ClientOptionsProvider).GetField("_singletonInstance", BindingFlags.Static | BindingFlags.NonPublic); + singletonField?.SetValue(null, null); + + // Load mock generator with a compilation that contains a custom partial class + // for TestService with a constructor that takes a "connectionString" parameter. + await MockHelpers.LoadMockGeneratorAsync( + compilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + var client = InputFactory.Client("TestService"); + var clientProvider = new ClientProvider(client); + + Assert.IsNotNull(clientProvider.CustomCodeView, + "CustomCodeView should be available from the compilation"); + + var output = new TestOutputLibrary([clientProvider]); + var result = ConfigurationSchemaGenerator.Generate(output); + + Assert.IsNotNull(result); + var doc = JsonNode.Parse(result!)!; + + // Verify the custom "ConnectionString" constructor parameter appears as a client-level property + var clientEntry = doc["properties"]?["Clients"]?["properties"]?["TestService"]; + Assert.IsNotNull(clientEntry, "TestService client entry should exist"); + + var connectionStringProp = clientEntry!["properties"]?["ConnectionString"]; + Assert.IsNotNull(connectionStringProp, + "Custom constructor parameter 'connectionString' should appear as 'ConnectionString' in the schema"); + Assert.AreEqual("string", connectionStringProp!["type"]?.GetValue()); + } + /// /// Test output library that wraps provided TypeProviders. /// diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientOptionsProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientOptionsProviderTests.cs index 63d5bb33f97..93664c6310b 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientOptionsProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientOptionsProviderTests.cs @@ -1120,5 +1120,28 @@ public void TestConfigurationSectionConstructorBody_WithFixedEnumProperty() Assert.IsFalse(bodyString.Contains("new ClientMode"), "IConfigurationSection constructor should NOT use new for fixed enum property binding"); } + + [Test] + public async Task TestConfigurationSectionConstructorBody_BindsCustomCodeProperties() + { + await MockHelpers.LoadMockGeneratorAsync( + compilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + var client = InputFactory.Client("TestClient", clientNamespace: "SampleNamespace"); + var clientProvider = ScmCodeModelGenerator.Instance.TypeFactory.CreateClient(client); + var clientOptionsProvider = clientProvider!.ClientOptions; + + Assert.IsNotNull(clientOptionsProvider); + Assert.IsNotNull(clientOptionsProvider!.CustomCodeView, + "CustomCodeView should be available from the compilation"); + + var configSectionCtor = clientOptionsProvider.Constructors + .FirstOrDefault(c => c.Signature.Parameters.Any(p => p.Name == "section")); + Assert.IsNotNull(configSectionCtor); + + var bodyString = configSectionCtor!.BodyStatements!.ToDisplayString(); + Assert.IsTrue(bodyString.Contains("Audience"), + "IConfigurationSection constructor should bind the custom code 'Audience' property from configuration"); + } } } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientSettingsProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientSettingsProviderTests.cs index d864e4b3ea9..b0bde02be11 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientSettingsProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientSettingsProviderTests.cs @@ -3,6 +3,7 @@ using System; using System.Linq; +using System.Threading.Tasks; using Microsoft.TypeSpec.Generator.ClientModel.Providers; using Microsoft.TypeSpec.Generator.Input; using Microsoft.TypeSpec.Generator.Primitives; @@ -912,5 +913,56 @@ private static bool IsSettingsConstructor(ConstructorProvider c) => c.Signature?.Initializer != null && c.Signature?.Modifiers == MethodSignatureModifiers.Public && c.Signature.Parameters.Any(p => p.Name == "settings"); + + [Test] + public async Task TestProperties_IncludesCustomConstructorParameters() + { + var singletonField = typeof(ClientOptionsProvider).GetField("_singletonInstance", + System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic); + singletonField?.SetValue(null, null); + + await MockHelpers.LoadMockGeneratorAsync( + compilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + var client = InputFactory.Client("TestClient", clientNamespace: "SampleNamespace"); + var clientProvider = ScmCodeModelGenerator.Instance.TypeFactory.CreateClient(client); + Assert.IsNotNull(clientProvider); + Assert.IsNotNull(clientProvider!.CustomCodeView, + "CustomCodeView should be available from the compilation"); + + var settings = clientProvider.ClientSettings; + Assert.IsNotNull(settings); + + var connectionStringProp = settings!.Properties + .FirstOrDefault(p => p.Name == "ConnectionString"); + Assert.IsNotNull(connectionStringProp, + "Settings should include 'ConnectionString' property from custom constructor parameter"); + } + + [Test] + public async Task TestBindCoreMethod_BindsCustomConstructorParameters() + { + var singletonField = typeof(ClientOptionsProvider).GetField("_singletonInstance", + System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic); + singletonField?.SetValue(null, null); + + await MockHelpers.LoadMockGeneratorAsync( + compilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + var client = InputFactory.Client("TestClient", clientNamespace: "SampleNamespace"); + var clientProvider = ScmCodeModelGenerator.Instance.TypeFactory.CreateClient(client); + Assert.IsNotNull(clientProvider); + + var settings = clientProvider!.ClientSettings; + Assert.IsNotNull(settings); + + var bindCoreMethod = settings!.Methods + .FirstOrDefault(m => m.Signature.Name == "BindCore"); + Assert.IsNotNull(bindCoreMethod); + + var bodyString = bindCoreMethod!.BodyStatements!.ToDisplayString(); + Assert.IsTrue(bodyString.Contains("ConnectionString"), + "BindCore should bind the custom constructor parameter 'ConnectionString' from configuration"); + } } } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/TestData/ClientOptionsProviderTests/TestConfigurationSectionConstructorBody_BindsCustomCodeProperties/TestClientOptions.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/TestData/ClientOptionsProviderTests/TestConfigurationSectionConstructorBody_BindsCustomCodeProperties/TestClientOptions.cs new file mode 100644 index 00000000000..30f1955a770 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/TestData/ClientOptionsProviderTests/TestConfigurationSectionConstructorBody_BindsCustomCodeProperties/TestClientOptions.cs @@ -0,0 +1,11 @@ +#nullable disable + +using System.ClientModel.Primitives; + +namespace SampleNamespace +{ + public partial class TestClientOptions : ClientPipelineOptions + { + public string Audience { get; set; } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/TestData/ClientSettingsProviderTests/TestBindCoreMethod_BindsCustomConstructorParameters/TestClient.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/TestData/ClientSettingsProviderTests/TestBindCoreMethod_BindsCustomConstructorParameters/TestClient.cs new file mode 100644 index 00000000000..488eb113e09 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/TestData/ClientSettingsProviderTests/TestBindCoreMethod_BindsCustomConstructorParameters/TestClient.cs @@ -0,0 +1,9 @@ +#nullable disable + +namespace SampleNamespace +{ + public partial class TestClient + { + public TestClient(string connectionString) { } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/TestData/ClientSettingsProviderTests/TestProperties_IncludesCustomConstructorParameters/TestClient.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/TestData/ClientSettingsProviderTests/TestProperties_IncludesCustomConstructorParameters/TestClient.cs new file mode 100644 index 00000000000..488eb113e09 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/TestData/ClientSettingsProviderTests/TestProperties_IncludesCustomConstructorParameters/TestClient.cs @@ -0,0 +1,9 @@ +#nullable disable + +namespace SampleNamespace +{ + public partial class TestClient + { + public TestClient(string connectionString) { } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/TestData/ConfigurationSchemaGeneratorTests/Generate_IncludesCustomCodeOptionsProperties/TestServiceOptions.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/TestData/ConfigurationSchemaGeneratorTests/Generate_IncludesCustomCodeOptionsProperties/TestServiceOptions.cs new file mode 100644 index 00000000000..f420dba0474 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/TestData/ConfigurationSchemaGeneratorTests/Generate_IncludesCustomCodeOptionsProperties/TestServiceOptions.cs @@ -0,0 +1,11 @@ +#nullable disable + +using System.ClientModel.Primitives; + +namespace Sample +{ + public partial class TestServiceOptions : ClientPipelineOptions + { + public string Audience { get; set; } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/TestData/ConfigurationSchemaGeneratorTests/Generate_IncludesCustomConstructorParameters/TestService.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/TestData/ConfigurationSchemaGeneratorTests/Generate_IncludesCustomConstructorParameters/TestService.cs new file mode 100644 index 00000000000..22eada52e54 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/TestData/ConfigurationSchemaGeneratorTests/Generate_IncludesCustomConstructorParameters/TestService.cs @@ -0,0 +1,9 @@ +#nullable disable + +namespace Sample +{ + public partial class TestService + { + public TestService(string connectionString) { } + } +}