Skip to content

Commit 7aa1f34

Browse files
Fix/struct config binding (#10261)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 01b4a4c commit 7aa1f34

10 files changed

Lines changed: 305 additions & 0 deletions

File tree

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,12 @@ internal static JsonObject GetJsonSchemaForType(CSharpType type, Dictionary<stri
267267
return GetJsonSchemaForEnum(effectiveType, localDefinitions);
268268
}
269269

270+
if (effectiveType.IsStruct)
271+
{
272+
// Non-enum struct — look up custom code constructor to determine the underlying type
273+
return GetJsonSchemaForNonEnumStruct(effectiveType, localDefinitions);
274+
}
275+
270276
return GetJsonSchemaForModel(effectiveType, localDefinitions);
271277
}
272278

@@ -362,6 +368,32 @@ private static JsonObject GetJsonSchemaForEnum(CSharpType enumType, Dictionary<s
362368
return new JsonObject { ["type"] = "string" };
363369
}
364370

371+
private static JsonObject GetJsonSchemaForNonEnumStruct(CSharpType structType, Dictionary<string, JsonObject>? localDefinitions)
372+
{
373+
// Look up the struct's constructor to determine the underlying value type
374+
var underlyingType = ClientSettingsProvider.TryGetStructUnderlyingType(structType);
375+
376+
if (underlyingType != null)
377+
{
378+
var ft = underlyingType.FrameworkType;
379+
if (ft == typeof(string))
380+
{
381+
return new JsonObject { ["type"] = "string" };
382+
}
383+
if (ft == typeof(int) || ft == typeof(long))
384+
{
385+
return new JsonObject { ["type"] = "integer" };
386+
}
387+
if (ft == typeof(float) || ft == typeof(double))
388+
{
389+
return new JsonObject { ["type"] = "number" };
390+
}
391+
}
392+
393+
// Fallback: treat as object to be consistent with AppendComplexObjectBinding
394+
return new JsonObject { ["type"] = "object" };
395+
}
396+
365397
private static JsonObject GetJsonSchemaForModel(CSharpType modelType, Dictionary<string, JsonObject>? localDefinitions)
366398
{
367399
// Search for the model provider in the output library

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,27 @@ internal static void AppendBindingForProperty(
253253
AppendFixedEnumBinding(body, sectionParam, propName, varName, type);
254254
}
255255
}
256+
else if (type.IsStruct && TryGetStructUnderlyingType(type) is { } underlyingType)
257+
{
258+
// Non-enum struct with a discoverable constructor parameter type.
259+
// Use the constructor's parameter type to pick the correct binding.
260+
if (underlyingType.FrameworkType == typeof(string))
261+
{
262+
AppendEnumBinding(body, sectionParam, propName, varName, type);
263+
}
264+
else if (underlyingType.FrameworkType == typeof(int) || underlyingType.FrameworkType == typeof(long))
265+
{
266+
AppendTryParseBinding(body, sectionParam, propName, varName, typeof(int));
267+
}
268+
else if (underlyingType.FrameworkType == typeof(float) || underlyingType.FrameworkType == typeof(double))
269+
{
270+
AppendTryParseBinding(body, sectionParam, propName, varName, typeof(double));
271+
}
272+
else
273+
{
274+
AppendComplexObjectBinding(body, sectionParam, propName, varName, type);
275+
}
276+
}
256277
else
257278
{
258279
AppendComplexObjectBinding(body, sectionParam, propName, varName, type);
@@ -465,6 +486,33 @@ internal static void AppendComplexObjectBinding(
465486
body.Add(ifExistsStatement);
466487
}
467488

489+
/// <summary>
490+
/// Finds the single-value constructor parameter type for a non-framework struct type
491+
/// by looking up the type's constructors in custom code. Returns null if no suitable
492+
/// constructor is found.
493+
/// </summary>
494+
internal static CSharpType? TryGetStructUnderlyingType(CSharpType type)
495+
{
496+
var typeProvider = CodeModelGenerator.Instance.SourceInputModel
497+
.FindForTypeInCustomization(type.Namespace, type.Name);
498+
499+
if (typeProvider == null)
500+
{
501+
return null;
502+
}
503+
504+
foreach (var ctor in typeProvider.Constructors)
505+
{
506+
var parameters = ctor.Signature.Parameters;
507+
if (parameters.Count == 1 && parameters[0].Type.IsFrameworkType)
508+
{
509+
return parameters[0].Type;
510+
}
511+
}
512+
513+
return null;
514+
}
515+
468516
/// <summary>
469517
/// Checks if a type is a standard client parameter type that should not be included as a
470518
/// custom settings property (credential types, endpoint types, or options types).

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -962,6 +962,51 @@ await MockHelpers.LoadMockGeneratorAsync(
962962
"Internal constructor parameter 'anotherInternalParam' should NOT appear in schema");
963963
}
964964

965+
[Test]
966+
public async Task GetJsonSchemaForType_ReturnsStringSchema_ForCustomStringStruct()
967+
{
968+
await MockHelpers.LoadMockGeneratorAsync(
969+
compilation: async () => await Helpers.GetCompilationFromDirectoryAsync());
970+
971+
var typeProvider = CodeModelGenerator.Instance.SourceInputModel
972+
.FindForTypeInCustomization("SampleNamespace", "CustomAudience");
973+
Assert.IsNotNull(typeProvider, "CustomAudience should be found in custom code");
974+
975+
var schema = ConfigurationSchemaGenerator.GetJsonSchemaForType(typeProvider!.Type);
976+
Assert.AreEqual("string", schema["type"]?.GetValue<string>(),
977+
"Custom struct with string constructor should produce string schema");
978+
}
979+
980+
[Test]
981+
public async Task GetJsonSchemaForType_ReturnsIntegerSchema_ForCustomIntStruct()
982+
{
983+
await MockHelpers.LoadMockGeneratorAsync(
984+
compilation: async () => await Helpers.GetCompilationFromDirectoryAsync());
985+
986+
var typeProvider = CodeModelGenerator.Instance.SourceInputModel
987+
.FindForTypeInCustomization("SampleNamespace", "CustomPriority");
988+
Assert.IsNotNull(typeProvider, "CustomPriority should be found in custom code");
989+
990+
var schema = ConfigurationSchemaGenerator.GetJsonSchemaForType(typeProvider!.Type);
991+
Assert.AreEqual("integer", schema["type"]?.GetValue<string>(),
992+
"Custom struct with int constructor should produce integer schema");
993+
}
994+
995+
[Test]
996+
public async Task GetJsonSchemaForType_ReturnsObjectSchema_ForCustomStructWithNoValidConstructor()
997+
{
998+
await MockHelpers.LoadMockGeneratorAsync(
999+
compilation: async () => await Helpers.GetCompilationFromDirectoryAsync());
1000+
1001+
var typeProvider = CodeModelGenerator.Instance.SourceInputModel
1002+
.FindForTypeInCustomization("SampleNamespace", "CustomComplex");
1003+
Assert.IsNotNull(typeProvider, "CustomComplex should be found in custom code");
1004+
1005+
var schema = ConfigurationSchemaGenerator.GetJsonSchemaForType(typeProvider!.Type);
1006+
Assert.AreEqual("object", schema["type"]?.GetValue<string>(),
1007+
"Custom struct with no single-parameter framework-type constructor should fall back to object");
1008+
}
1009+
9651010
/// <summary>
9661011
/// Test output library that wraps provided TypeProviders.
9671012
/// </summary>

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

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@
22
// Licensed under the MIT License.
33

44
using System;
5+
using System.Collections.Generic;
56
using System.Linq;
7+
using System.Reflection;
68
using System.Threading.Tasks;
79
using Microsoft.TypeSpec.Generator.ClientModel.Providers;
810
using Microsoft.TypeSpec.Generator.Input;
911
using Microsoft.TypeSpec.Generator.Primitives;
1012
using Microsoft.TypeSpec.Generator.Providers;
13+
using Microsoft.TypeSpec.Generator.Statements;
1114
using Microsoft.TypeSpec.Generator.Tests.Common;
1215
using NUnit.Framework;
1316

@@ -909,6 +912,95 @@ public void TestSettingsConstructor_WithUrlEndpoint()
909912
Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content);
910913
}
911914

915+
[Test]
916+
public async Task TestBindCoreMethod_WithCustomStructParam()
917+
{
918+
// A custom struct with a string constructor should use string binding
919+
var singletonField = typeof(ClientOptionsProvider).GetField("_singletonInstance",
920+
BindingFlags.Static | BindingFlags.NonPublic);
921+
singletonField?.SetValue(null, null);
922+
923+
await MockHelpers.LoadMockGeneratorAsync(
924+
compilation: async () => await Helpers.GetCompilationFromDirectoryAsync());
925+
926+
var typeProvider = CodeModelGenerator.Instance.SourceInputModel
927+
.FindForTypeInCustomization("SampleNamespace", "CustomAudience");
928+
Assert.IsNotNull(typeProvider, "CustomAudience should be found in custom code");
929+
930+
var body = new List<MethodBodyStatement>();
931+
var sectionParam = new ParameterProvider(
932+
"section",
933+
$"The configuration section.",
934+
ClientSettingsProvider.IConfigurationSectionType);
935+
936+
ClientSettingsProvider.AppendBindingForProperty(body, sectionParam, "Audience", "audience", typeProvider!.Type);
937+
938+
var bodyString = string.Join("\n", body.Select(s => s.ToDisplayString()));
939+
Assert.IsTrue(bodyString.Contains("is string"),
940+
"Should use 'is string' pattern for custom struct with string constructor");
941+
Assert.IsFalse(bodyString.Contains("GetSection"),
942+
"Should NOT use GetSection for custom struct with string constructor");
943+
}
944+
945+
[Test]
946+
public async Task TestBindCoreMethod_WithCustomIntStructParam()
947+
{
948+
// A custom struct with an int constructor should use int.TryParse binding
949+
var singletonField = typeof(ClientOptionsProvider).GetField("_singletonInstance",
950+
BindingFlags.Static | BindingFlags.NonPublic);
951+
singletonField?.SetValue(null, null);
952+
953+
await MockHelpers.LoadMockGeneratorAsync(
954+
compilation: async () => await Helpers.GetCompilationFromDirectoryAsync());
955+
956+
var typeProvider = CodeModelGenerator.Instance.SourceInputModel
957+
.FindForTypeInCustomization("SampleNamespace", "CustomPriority");
958+
Assert.IsNotNull(typeProvider, "CustomPriority should be found in custom code");
959+
960+
var body = new List<MethodBodyStatement>();
961+
var sectionParam = new ParameterProvider(
962+
"section",
963+
$"The configuration section.",
964+
ClientSettingsProvider.IConfigurationSectionType);
965+
966+
ClientSettingsProvider.AppendBindingForProperty(body, sectionParam, "Priority", "priority", typeProvider!.Type);
967+
968+
var bodyString = string.Join("\n", body.Select(s => s.ToDisplayString()));
969+
Assert.IsTrue(bodyString.Contains("int.TryParse"),
970+
"Should use int.TryParse for custom struct with int constructor");
971+
Assert.IsFalse(bodyString.Contains("GetSection"),
972+
"Should NOT use GetSection for custom struct with int constructor");
973+
}
974+
975+
[Test]
976+
public async Task TestBindCoreMethod_WithCustomStructParam_FallsBackToComplexObject()
977+
{
978+
// A custom struct with no single-parameter framework-type constructor
979+
// should fall back to complex object binding
980+
var singletonField = typeof(ClientOptionsProvider).GetField("_singletonInstance",
981+
BindingFlags.Static | BindingFlags.NonPublic);
982+
singletonField?.SetValue(null, null);
983+
984+
await MockHelpers.LoadMockGeneratorAsync(
985+
compilation: async () => await Helpers.GetCompilationFromDirectoryAsync());
986+
987+
var typeProvider = CodeModelGenerator.Instance.SourceInputModel
988+
.FindForTypeInCustomization("SampleNamespace", "CustomComplex");
989+
Assert.IsNotNull(typeProvider, "CustomComplex should be found in custom code");
990+
991+
var body = new List<MethodBodyStatement>();
992+
var sectionParam = new ParameterProvider(
993+
"section",
994+
$"The configuration section.",
995+
ClientSettingsProvider.IConfigurationSectionType);
996+
997+
ClientSettingsProvider.AppendBindingForProperty(body, sectionParam, "Complex", "complex", typeProvider!.Type);
998+
999+
var bodyString = string.Join("\n", body.Select(s => s.ToDisplayString()));
1000+
Assert.IsTrue(bodyString.Contains("GetSection"),
1001+
"Should fall back to GetSection for struct with no single-parameter framework-type constructor");
1002+
}
1003+
9121004
private static bool IsSettingsConstructor(ConstructorProvider c) =>
9131005
c.Signature?.Initializer != null &&
9141006
c.Signature?.Modifiers == MethodSignatureModifiers.Public &&
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#nullable disable
2+
3+
namespace SampleNamespace
4+
{
5+
public readonly partial struct CustomPriority
6+
{
7+
private readonly int _value;
8+
9+
public CustomPriority(int value)
10+
{
11+
_value = value;
12+
}
13+
}
14+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#nullable disable
2+
3+
namespace SampleNamespace
4+
{
5+
public readonly partial struct CustomAudience
6+
{
7+
private readonly string _value;
8+
9+
public CustomAudience(string value)
10+
{
11+
_value = value;
12+
}
13+
}
14+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
#nullable disable
2+
3+
namespace SampleNamespace
4+
{
5+
public readonly partial struct CustomComplex
6+
{
7+
private readonly string _name;
8+
private readonly int _value;
9+
10+
public CustomComplex(string name, int value)
11+
{
12+
_name = name;
13+
_value = value;
14+
}
15+
}
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#nullable disable
2+
3+
namespace SampleNamespace
4+
{
5+
public readonly partial struct CustomPriority
6+
{
7+
private readonly int _value;
8+
9+
public CustomPriority(int value)
10+
{
11+
_value = value;
12+
}
13+
}
14+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
#nullable disable
2+
3+
namespace SampleNamespace
4+
{
5+
public readonly partial struct CustomComplex
6+
{
7+
private readonly string _name;
8+
private readonly int _value;
9+
10+
public CustomComplex(string name, int value)
11+
{
12+
_name = name;
13+
_value = value;
14+
}
15+
}
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#nullable disable
2+
3+
namespace SampleNamespace
4+
{
5+
public readonly partial struct CustomAudience
6+
{
7+
private readonly string _value;
8+
9+
public CustomAudience(string value)
10+
{
11+
_value = value;
12+
}
13+
}
14+
}

0 commit comments

Comments
 (0)