Skip to content

Commit 37d205a

Browse files
davidkallesenclaude
andcommitted
Merge branch 'main' into feature/improvements
Combines: - Early Access to Options (from feature/improvements) - Direct Type Registration/AlsoRegisterDirectType (from main) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
2 parents 9382fb3 + 8af73ae commit 37d205a

File tree

9 files changed

+491
-9
lines changed

9 files changed

+491
-9
lines changed

CLAUDE.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,7 @@ services.AddDependencyRegistrationsFromDomain(
344344
- **ConfigureAll support**: Set common default values for all named options instances before individual binding with `ConfigureAll` callbacks (e.g., baseline retry/timeout settings)
345345
- **Named options support**: Multiple configurations of the same options type with different names (e.g., Primary/Secondary email servers)
346346
- **Child sections**: Simplified syntax for creating multiple named instances from subsections using `ChildSections` property (e.g., `Email` → Primary/Secondary/Fallback)
347+
- **Direct type registration**: `AlsoRegisterDirectType` parameter allows registering options classes for both `IOptions<T>` AND direct type injection (for migration scenarios and third-party library compatibility)
347348
- **Nested subsection binding**: Automatic binding of complex properties to configuration subsections (e.g., `StorageOptions.Database.Retry``"Storage:Database:Retry"`) - supported out-of-the-box by Microsoft's `.Bind()` method
348349
- Supports lifetime selection: Singleton (IOptions), Scoped (IOptionsSnapshot), Monitor (IOptionsMonitor)
349350
- Requires classes to be declared `partial`
@@ -591,6 +592,34 @@ services.Configure<EmailOptions>("Fallback", configuration.GetSection("Email:Fal
591592
// [OptionsBinding("Email:Primary", Name = "Primary", ConfigureAll = nameof(SetDefaults))]
592593
// [OptionsBinding("Email:Secondary", Name = "Secondary")]
593594
// [OptionsBinding("Email:Fallback", Name = "Fallback")]
595+
596+
// Input with AlsoRegisterDirectType (for legacy code or third-party library compatibility):
597+
[OptionsBinding("LegacyApi", ValidateDataAnnotations = true, AlsoRegisterDirectType = true)]
598+
public partial class LegacyApiOptions
599+
{
600+
[Required, Url]
601+
public string ApiEndpoint { get; set; } = string.Empty;
602+
603+
[Required, MinLength(32)]
604+
public string ApiKey { get; set; } = string.Empty;
605+
}
606+
607+
// Output with AlsoRegisterDirectType (generates both registrations):
608+
// Standard IOptions<T> registration
609+
services.AddOptions<LegacyApiOptions>()
610+
.Bind(configuration.GetSection("LegacyApi"))
611+
.ValidateDataAnnotations()
612+
.ValidateOnStart();
613+
614+
// Also register direct type (for legacy code or third-party libraries)
615+
services.AddSingleton(sp => sp.GetRequiredService<global::Microsoft.Extensions.Options.IOptions<global::MyApp.LegacyApiOptions>>().Value);
616+
617+
// Usage - Both injection patterns now work:
618+
// Pattern 1: Standard IOptions<T> (recommended for new code)
619+
public class ApiService(IOptions<LegacyApiOptions> options) { }
620+
621+
// Pattern 2: Direct type (for legacy code or third-party libraries that expect unwrapped types)
622+
public class LegacyLibraryClient(LegacyApiOptions options) { }
594623
```
595624

596625
**Smart Naming:**

docs/OptionsBindingGenerators-FeatureRoadmap.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,10 @@ This roadmap is based on comprehensive analysis of:
8888
|| [Auto-Generate Options Classes from appsettings.json](#11-auto-generate-options-classes-from-appsettingsjson) | 🟢 Low |
8989
|| [Environment-Specific Validation](#12-environment-specific-validation) | 🟢 Low |
9090
|| [Hot Reload Support with Filtering](#13-hot-reload-support-with-filtering) | 🟢 Low |
91-
| 🚫 | [Reflection-Based Binding](#13-reflection-based-binding) | - |
92-
| 🚫 | [JSON Schema Generation](#14-json-schema-generation) | - |
93-
| 🚫 | [Configuration Encryption/Decryption](#15-configuration-encryptiondecryption) | - |
94-
| 🚫 | [Dynamic Configuration Sources](#16-dynamic-configuration-sources) | - |
91+
| 🚫 | [Reflection-Based Binding](#15-reflection-based-binding) | - |
92+
| 🚫 | [JSON Schema Generation](#16-json-schema-generation) | - |
93+
| 🚫 | [Configuration Encryption/Decryption](#17-configuration-encryptiondecryption) | - |
94+
| 🚫 | [Dynamic Configuration Sources](#18-dynamic-configuration-sources) | - |
9595

9696
**Legend:**
9797

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
namespace Atc.SourceGenerators.OptionsBinding.Options;
2+
3+
/// <summary>
4+
/// Legacy integration configuration options.
5+
/// Demonstrates Feature #13: Direct type registration (AlsoRegisterDirectType).
6+
/// This feature allows registering the options class both as IOptions&lt;T&gt; AND as the direct type T.
7+
/// Useful for migration scenarios or third-party library compatibility.
8+
/// </summary>
9+
/// <remarks>
10+
/// <para>
11+
/// <b>Use case:</b> You're integrating with a legacy library that expects the options class directly
12+
/// in its constructor, not wrapped in IOptions&lt;T&gt;. Instead of creating a wrapper service,
13+
/// you can use AlsoRegisterDirectType = true to register both patterns.
14+
/// </para>
15+
/// <para>
16+
/// <b>Trade-offs:</b>
17+
/// - Direct injection gets a snapshot at resolution time
18+
/// - No change detection - configuration updates won't be reflected
19+
/// - Should be used sparingly for migration/compatibility only
20+
/// </para>
21+
/// </remarks>
22+
[OptionsBinding("LegacyIntegration", ValidateDataAnnotations = true, AlsoRegisterDirectType = true)]
23+
public partial class LegacyIntegrationOptions
24+
{
25+
/// <summary>
26+
/// Gets or sets the API endpoint for the legacy system.
27+
/// </summary>
28+
[Required]
29+
[Url]
30+
public string ApiEndpoint { get; set; } = string.Empty;
31+
32+
/// <summary>
33+
/// Gets or sets the API key for authentication.
34+
/// </summary>
35+
[Required]
36+
[MinLength(32)]
37+
public string ApiKey { get; set; } = string.Empty;
38+
39+
/// <summary>
40+
/// Gets or sets the timeout in seconds for API calls.
41+
/// </summary>
42+
[Range(1, 300)]
43+
public int TimeoutSeconds { get; set; } = 30;
44+
45+
/// <summary>
46+
/// Gets or sets a value indicating whether to use SSL/TLS for connections.
47+
/// </summary>
48+
public bool UseSsl { get; set; } = true;
49+
50+
/// <summary>
51+
/// Gets or sets the maximum number of retry attempts.
52+
/// </summary>
53+
[Range(0, 10)]
54+
public int MaxRetries { get; set; } = 3;
55+
}

sample/Atc.SourceGenerators.OptionsBinding/appsettings.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,5 +79,13 @@
7979
"CachePath": "/var/cache/myapp",
8080
"TempPath": "C:\\Temp\\MyApp",
8181
"LogPath": "/var/log/myapp"
82+
},
83+
"LegacyIntegration": {
84+
"ApiEndpoint": "https://legacy-api.example.com/v1",
85+
"ApiKey": "legacy-api-key-12345678901234567890abcdef",
86+
"TimeoutSeconds": 30,
87+
"UseSsl": true,
88+
"MaxRetries": 3
8289
}
8390
}
91+

sample/PetStore.Domain/Options/ExternalApiOptions.cs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,22 @@ namespace PetStore.Domain.Options;
22

33
/// <summary>
44
/// External API endpoint configuration options.
5-
/// Demonstrates PostConfigure feature for normalizing API URLs after binding.
5+
/// Demonstrates:
6+
/// - PostConfigure feature for normalizing API URLs after binding
7+
/// - AlsoRegisterDirectType feature for third-party library compatibility
68
/// Ensures all URLs are lowercase and properly formatted for consistent API communication.
79
/// </summary>
8-
[OptionsBinding("ExternalApis", ValidateDataAnnotations = true, ValidateOnStart = true, PostConfigure = nameof(NormalizeUrls))]
10+
/// <remarks>
11+
/// AlsoRegisterDirectType = true allows this class to be injected both as IOptions&lt;ExternalApiOptions&gt;
12+
/// and as ExternalApiOptions directly. This is useful when integrating with third-party API client
13+
/// libraries that expect configuration objects directly in their constructors.
14+
/// </remarks>
15+
[OptionsBinding(
16+
"ExternalApis",
17+
ValidateDataAnnotations = true,
18+
ValidateOnStart = true,
19+
PostConfigure = nameof(NormalizeUrls),
20+
AlsoRegisterDirectType = true)]
921
public partial class ExternalApiOptions
1022
{
1123
/// <summary>

src/Atc.SourceGenerators.Annotations/OptionsBindingAttribute.cs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,4 +140,37 @@ public OptionsBindingAttribute(string? sectionName = null)
140140
/// Useful for multi-tenant scenarios, regional configurations, or environment-specific settings.
141141
/// </remarks>
142142
public string[]? ChildSections { get; set; }
143+
144+
/// <summary>
145+
/// Gets or sets a value indicating whether to also register the options type
146+
/// as a direct service (not wrapped in IOptions&lt;T&gt;).
147+
/// Default is false.
148+
/// </summary>
149+
/// <remarks>
150+
/// <para>
151+
/// When true, the options class will be registered both:
152+
/// <list type="bullet">
153+
/// <item><description>As IOptions&lt;T&gt;, IOptionsSnapshot&lt;T&gt;, or IOptionsMonitor&lt;T&gt; (standard pattern based on Lifetime)</description></item>
154+
/// <item><description>As T directly (for legacy code or third-party libraries)</description></item>
155+
/// </list>
156+
/// </para>
157+
/// <para>
158+
/// The direct type registration resolves through the appropriate options interface (.Value or .CurrentValue)
159+
/// to ensure validation and configuration binding still apply.
160+
/// </para>
161+
/// <para>
162+
/// <b>Trade-offs:</b>
163+
/// <list type="bullet">
164+
/// <item><description><b>Loss of change detection:</b> Direct injection gets a snapshot at resolution time and won't receive updates when configuration changes</description></item>
165+
/// <item><description><b>Loss of scoping benefits:</b> Especially when using Monitor lifetime, the direct type is registered as Singleton and uses CurrentValue snapshot</description></item>
166+
/// <item><description><b>Migration aid:</b> Useful for gradual migration from direct injection to IOptions&lt;T&gt; pattern</description></item>
167+
/// <item><description><b>Third-party compatibility:</b> Some libraries expect direct types, not IOptions&lt;T&gt;</description></item>
168+
/// </list>
169+
/// </para>
170+
/// <para>
171+
/// <b>Usage guidance:</b> Use sparingly for migration scenarios or third-party library compatibility only.
172+
/// The IOptions&lt;T&gt; pattern should be the default choice for new code.
173+
/// </para>
174+
/// </remarks>
175+
public bool AlsoRegisterDirectType { get; set; }
143176
}

src/Atc.SourceGenerators/Generators/Internal/OptionsInfo.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@ internal sealed record OptionsInfo(
1414
string? OnChange,
1515
string? PostConfigure,
1616
string? ConfigureAll,
17-
string?[]? ChildSections);
17+
string?[]? ChildSections,
18+
bool AlsoRegisterDirectType);

src/Atc.SourceGenerators/Generators/OptionsBindingGenerator.cs

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,7 @@ private static List<OptionsInfo> ExtractAllOptionsInfo(
413413
string? postConfigure = null;
414414
string? configureAll = null;
415415
string?[]? childSections = null;
416+
var alsoRegisterDirectType = false;
416417

417418
foreach (var namedArg in attribute.NamedArguments)
418419
{
@@ -458,6 +459,9 @@ private static List<OptionsInfo> ExtractAllOptionsInfo(
458459
.ToArray();
459460
}
460461

462+
break;
463+
case "AlsoRegisterDirectType":
464+
alsoRegisterDirectType = namedArg.Value.Value as bool? ?? false;
461465
break;
462466
}
463467
}
@@ -678,7 +682,8 @@ private static List<OptionsInfo> ExtractAllOptionsInfo(
678682
onChange,
679683
postConfigure,
680684
configureAll,
681-
childSections)); // Store ChildSections to indicate this is part of a child sections group
685+
childSections, // Store ChildSections to indicate this is part of a child sections group
686+
alsoRegisterDirectType));
682687
}
683688

684689
return result;
@@ -701,7 +706,8 @@ private static List<OptionsInfo> ExtractAllOptionsInfo(
701706
onChange,
702707
postConfigure,
703708
configureAll,
704-
null) // No ChildSections
709+
null, // No ChildSections
710+
alsoRegisterDirectType)
705711
];
706712
}
707713

@@ -1339,6 +1345,32 @@ private static void GenerateOptionsRegistration(
13391345
sb.AppendLineLf(">();");
13401346
}
13411347

1348+
// Register direct type if AlsoRegisterDirectType is true
1349+
if (option.AlsoRegisterDirectType && !isNamed)
1350+
{
1351+
sb.AppendLineLf();
1352+
sb.AppendLineLf($" // Also register {option.ClassName} as direct type (for legacy code or third-party library compatibility)");
1353+
1354+
// Choose service lifetime and options interface based on OptionsLifetime
1355+
var (serviceMethod, optionsInterface, valueAccess) = option.Lifetime switch
1356+
{
1357+
0 => ("AddSingleton", "IOptions", "Value"), // Singleton
1358+
1 => ("AddScoped", "IOptionsSnapshot", "Value"), // Scoped
1359+
2 => ("AddSingleton", "IOptionsMonitor", "CurrentValue"), // Monitor
1360+
_ => ("AddSingleton", "IOptions", "Value"), // Default
1361+
};
1362+
1363+
sb.Append(" services.");
1364+
sb.Append(serviceMethod);
1365+
sb.Append("(sp => sp.GetRequiredService<global::Microsoft.Extensions.Options.");
1366+
sb.Append(optionsInterface);
1367+
sb.Append('<');
1368+
sb.Append(optionsType);
1369+
sb.Append(">>().");
1370+
sb.Append(valueAccess);
1371+
sb.AppendLineLf(");");
1372+
}
1373+
13421374
sb.AppendLineLf();
13431375
}
13441376

@@ -1980,6 +2012,13 @@ public OptionsBindingAttribute(string? sectionName = null)
19802012
/// Useful for multi-tenant scenarios, regional configurations, or environment-specific settings.
19812013
/// </remarks>
19822014
public string[]? ChildSections { get; set; }
2015+
2016+
/// <summary>
2017+
/// Gets or sets a value indicating whether to also register the options type
2018+
/// as a direct service (not wrapped in IOptions&lt;T&gt;).
2019+
/// Default is false.
2020+
/// </summary>
2021+
public bool AlsoRegisterDirectType { get; set; }
19832022
}
19842023
}
19852024
""";

0 commit comments

Comments
 (0)