Skip to content

Commit bb8c79b

Browse files
committed
feat: extend support for Options Snapshots OptionsBinding
1 parent d1798ec commit bb8c79b

File tree

14 files changed

+1460
-71
lines changed

14 files changed

+1460
-71
lines changed

CLAUDE.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,7 @@ services.AddDependencyRegistrationsFromDomain(
302302
- **Post-configuration support**: `PostConfigure` callbacks for normalizing/transforming values after binding (e.g., path normalization, URL lowercase)
303303
- **ConfigureAll support**: Set common default values for all named options instances before individual binding with `ConfigureAll` callbacks (e.g., baseline retry/timeout settings)
304304
- **Named options support**: Multiple configurations of the same options type with different names (e.g., Primary/Secondary email servers)
305+
- **Child sections**: Simplified syntax for creating multiple named instances from subsections using `ChildSections` property (e.g., `Email` → Primary/Secondary/Fallback)
305306
- **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
306307
- Supports lifetime selection: Singleton (IOptions), Scoped (IOptionsSnapshot), Monitor (IOptionsMonitor)
307308
- Requires classes to be declared `partial`
@@ -455,6 +456,32 @@ services.ConfigureAll<EmailOptions>(options => EmailOptions.SetDefaults(options)
455456
services.Configure<EmailOptions>("Primary", configuration.GetSection("Email:Primary"));
456457
services.Configure<EmailOptions>("Secondary", configuration.GetSection("Email:Secondary"));
457458
services.Configure<EmailOptions>("Fallback", configuration.GetSection("Email:Fallback"));
459+
460+
// Input with ChildSections (simplified syntax for multiple named instances):
461+
[OptionsBinding("Email", ChildSections = new[] { "Primary", "Secondary", "Fallback" }, ConfigureAll = nameof(SetDefaults))]
462+
public partial class EmailOptions
463+
{
464+
public string SmtpServer { get; set; } = string.Empty;
465+
public int Port { get; set; } = 587;
466+
public int MaxRetries { get; set; }
467+
468+
internal static void SetDefaults(EmailOptions options)
469+
{
470+
options.MaxRetries = 3;
471+
options.Port = 587;
472+
}
473+
}
474+
475+
// Output with ChildSections (generates identical code to multiple attributes):
476+
services.ConfigureAll<EmailOptions>(options => EmailOptions.SetDefaults(options));
477+
services.Configure<EmailOptions>("Primary", configuration.GetSection("Email:Primary"));
478+
services.Configure<EmailOptions>("Secondary", configuration.GetSection("Email:Secondary"));
479+
services.Configure<EmailOptions>("Fallback", configuration.GetSection("Email:Fallback"));
480+
481+
// ChildSections is equivalent to writing multiple [OptionsBinding] attributes:
482+
// [OptionsBinding("Email:Primary", Name = "Primary", ConfigureAll = nameof(SetDefaults))]
483+
// [OptionsBinding("Email:Secondary", Name = "Secondary")]
484+
// [OptionsBinding("Email:Fallback", Name = "Fallback")]
458485
```
459486

460487
**Smart Naming:**
@@ -495,6 +522,9 @@ services.AddOptionsFromDomain(configuration, "DataAccess", "Infrastructure");
495522
- `ATCOPT011` - ConfigureAll requires multiple named options (Error)
496523
- `ATCOPT012` - ConfigureAll callback method not found (Error)
497524
- `ATCOPT013` - ConfigureAll callback has invalid signature (Error)
525+
- `ATCOPT014` - ChildSections cannot be used with Name property (Error)
526+
- `ATCOPT015` - ChildSections requires at least 2 items (Error)
527+
- `ATCOPT016` - ChildSections array contains null or empty value (Error)
498528

499529
### MappingGenerator
500530

docs/OptionsBindingGenerators-FeatureRoadmap.md

Lines changed: 172 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,15 @@ This roadmap is based on comprehensive analysis of:
5959
- **Error on missing keys** - `ErrorOnMissingKeys` fail-fast validation when configuration sections are missing
6060
- **Configuration change callbacks** - `OnChange` callbacks for Monitor lifetime (auto-generates IHostedService)
6161
- **Post-configuration support** - `PostConfigure` callbacks for normalizing/transforming values after binding (e.g., path normalization, URL lowercase)
62+
- **ConfigureAll support** - Set common defaults for all named options instances before individual binding
63+
- **Child sections** - Simplified syntax for multiple named instances from subsections (e.g., `Email` → Primary/Secondary/Fallback)
6264
- **Nested subsection binding** - Automatic binding of complex properties to configuration subsections (e.g., `Storage:Database:Retry`)
6365
- **Lifetime selection** - Singleton (`IOptions`), Scoped (`IOptionsSnapshot`), Monitor (`IOptionsMonitor`)
6466
- **Multi-project support** - Assembly-specific extension methods with smart naming
6567
- **Transitive registration** - 4 overloads for automatic/selective assembly registration
6668
- **Partial class requirement** - Enforced at compile time
6769
- **Native AOT compatible** - Zero reflection, compile-time generation
68-
- **Compile-time diagnostics** - Validate partial class, section names, OnChange/PostConfigure callbacks (ATCOPT001-010)
70+
- **Compile-time diagnostics** - Validate partial class, section names, OnChange/PostConfigure/ConfigureAll callbacks, ChildSections usage (ATCOPT001-016)
6971

7072
---
7173

@@ -80,7 +82,7 @@ This roadmap is based on comprehensive analysis of:
8082
|| [Configuration Change Callbacks](#5-configuration-change-callbacks) | 🟡 Medium |
8183
|| [Bind Configuration Subsections to Properties](#6-bind-configuration-subsections-to-properties) | 🟡 Medium |
8284
|| [ConfigureAll Support](#7-configureall-support) | 🟢 Low-Medium |
83-
| | [Options Snapshots for Specific Sections](#8-options-snapshots-for-specific-sections) | 🟢 Low-Medium |
85+
| | [Child Sections (Simplified Named Options)](#8-child-sections-simplified-named-options) | 🟢 Low-Medium |
8486
|| [Compile-Time Section Name Validation](#9-compile-time-section-name-validation) | 🟡 Medium |
8587
|| [Auto-Generate Options Classes from appsettings.json](#10-auto-generate-options-classes-from-appsettingsjson) | 🟢 Low |
8688
|| [Environment-Specific Validation](#11-environment-specific-validation) | 🟢 Low |
@@ -666,12 +668,177 @@ services.Configure<EmailOptions>("Fallback", config.GetSection("Email:Fallback")
666668

667669
These features would improve usability but are not critical.
668670

669-
### 8. Options Snapshots for Specific Sections
671+
### 8. Child Sections (Simplified Named Options)
670672

671673
**Priority**: 🟢 **Low-Medium**
672-
**Status**: ❌ Not Implemented
674+
**Status**: ✅ **Implemented**
675+
**Inspiration**: Community feedback on reducing boilerplate for multiple named instances
676+
677+
**Description**: Provide a simplified syntax for creating multiple named options instances from configuration subsections. Instead of writing multiple `[OptionsBinding]` attributes for each named instance, developers can use a single `ChildSections` property.
678+
679+
**User Story**:
680+
> "As a developer, I want to configure multiple related named options (Primary/Secondary/Fallback servers, Email/SMS/Push channels) without repeating multiple attribute declarations."
681+
682+
**Example**:
683+
684+
**Before (Multiple Attributes):**
685+
686+
```csharp
687+
[OptionsBinding("Email:Primary", Name = "Primary", ConfigureAll = nameof(SetDefaults))]
688+
[OptionsBinding("Email:Secondary", Name = "Secondary")]
689+
[OptionsBinding("Email:Fallback", Name = "Fallback")]
690+
public partial class EmailOptions
691+
{
692+
public string SmtpServer { get; set; } = string.Empty;
693+
public int Port { get; set; } = 587;
694+
public bool UseSsl { get; set; } = true;
695+
696+
internal static void SetDefaults(EmailOptions options)
697+
{
698+
options.Port = 587;
699+
options.UseSsl = true;
700+
options.MaxRetries = 3;
701+
}
702+
}
703+
```
704+
705+
**After (With ChildSections):**
706+
707+
```csharp
708+
[OptionsBinding("Email", ChildSections = new[] { "Primary", "Secondary", "Fallback" }, ConfigureAll = nameof(SetDefaults))]
709+
public partial class EmailOptions
710+
{
711+
public string SmtpServer { get; set; } = string.Empty;
712+
public int Port { get; set; } = 587;
713+
public bool UseSsl { get; set; } = true;
714+
715+
internal static void SetDefaults(EmailOptions options)
716+
{
717+
options.Port = 587;
718+
options.UseSsl = true;
719+
options.MaxRetries = 3;
720+
}
721+
}
722+
```
723+
724+
**Generated Code (Identical for Both):**
725+
726+
```csharp
727+
// Configure defaults for ALL instances FIRST
728+
services.ConfigureAll<EmailOptions>(options => EmailOptions.SetDefaults(options));
729+
730+
// Configure individual named instances
731+
services.Configure<EmailOptions>("Primary", configuration.GetSection("Email:Primary"));
732+
services.Configure<EmailOptions>("Secondary", configuration.GetSection("Email:Secondary"));
733+
services.Configure<EmailOptions>("Fallback", configuration.GetSection("Email:Fallback"));
734+
```
735+
736+
**Real-World Example (PetStore Sample):**
737+
738+
```csharp
739+
/// <summary>
740+
/// Notification channel options with support for multiple named configurations.
741+
/// Demonstrates ChildSections + ConfigureAll for common defaults.
742+
/// </summary>
743+
[OptionsBinding("Notifications", ChildSections = new[] { "Email", "SMS", "Push" }, ConfigureAll = nameof(SetCommonDefaults))]
744+
public partial class NotificationOptions
745+
{
746+
public bool Enabled { get; set; }
747+
public string Provider { get; set; } = string.Empty;
748+
public string ApiKey { get; set; } = string.Empty;
749+
public string SenderId { get; set; } = string.Empty;
750+
public int TimeoutSeconds { get; set; } = 30;
751+
public int MaxRetries { get; set; } = 3;
752+
public int RateLimitPerMinute { get; set; }
753+
754+
internal static void SetCommonDefaults(NotificationOptions options)
755+
{
756+
options.TimeoutSeconds = 30;
757+
options.MaxRetries = 3;
758+
options.RateLimitPerMinute = 60;
759+
options.Enabled = true;
760+
}
761+
}
762+
```
763+
764+
**Configuration (appsettings.json):**
765+
766+
```json
767+
{
768+
"Notifications": {
769+
"Email": {
770+
"Enabled": true,
771+
"Provider": "SendGrid",
772+
"ApiKey": "your-api-key",
773+
"SenderId": "noreply@example.com",
774+
"TimeoutSeconds": 30,
775+
"MaxRetries": 3
776+
},
777+
"SMS": {
778+
"Enabled": false,
779+
"Provider": "Twilio",
780+
"ApiKey": "your-api-key",
781+
"SenderId": "+1234567890",
782+
"TimeoutSeconds": 15,
783+
"MaxRetries": 2
784+
},
785+
"Push": {
786+
"Enabled": true,
787+
"Provider": "Firebase",
788+
"ApiKey": "your-server-key",
789+
"SenderId": "app-id",
790+
"TimeoutSeconds": 20,
791+
"MaxRetries": 3
792+
}
793+
}
794+
}
795+
```
796+
797+
**Implementation Details**:
798+
799+
- ✅ Added `ChildSections` string array property to `[OptionsBinding]` attribute
800+
- ✅ Generator expands ChildSections into multiple OptionsInfo instances at extraction time
801+
- ✅ Each child section becomes a named instance: `Parent:Child` section path with `Child` as the name
802+
-**Works with all named options features**: ConfigureAll, validation, ErrorOnMissingKeys, custom validators
803+
-**Validation support**: Named options with ChildSections can use fluent API for validation
804+
-**Mutual exclusivity**: Cannot be combined with `Name` property (compile-time error ATCOPT014)
805+
-**Minimum 2 items**: Requires at least 2 child sections (compile-time error ATCOPT015)
806+
-**No null/empty items**: All child section names must be non-empty (compile-time error ATCOPT016)
807+
-**Nested paths supported**: Works with paths like `"App:Services:Cache"``"App:Services:Cache:Redis"`
808+
809+
**Diagnostics**:
810+
811+
- **ATCOPT014**: ChildSections cannot be used with Name property
812+
- **ATCOPT015**: ChildSections requires at least 2 items (found X item(s))
813+
- **ATCOPT016**: ChildSections array contains null or empty value at index X
814+
815+
**Testing**:
816+
817+
- ✅ 13 comprehensive unit tests covering all scenarios and error cases
818+
- ✅ Sample project: EmailOptions demonstrates Primary/Secondary/Fallback with ConfigureAll
819+
- ✅ PetStore.Api sample: NotificationOptions demonstrates Email/SMS/Push channels
820+
- ✅ All existing tests pass (275 succeeded, 0 failed, 33 skipped)
821+
822+
**Key Benefits**:
823+
824+
1. **Less Boilerplate**: One attribute instead of 3+ separate declarations
825+
2. **Clearer Intent**: Explicitly shows configurations are grouped under common parent
826+
3. **Easier Maintenance**: Add/remove sections by updating the array
827+
4. **Feature Complete**: Supports all named options capabilities (validation, ConfigureAll, validators)
828+
5. **Same Power**: Generates identical code to multiple attributes approach
829+
830+
**Use Cases**:
831+
832+
- **Notification Channels**: Email, SMS, Push configurations (as shown in PetStore sample)
833+
- **Database Fallback**: Primary, Secondary, Tertiary connections
834+
- **Multi-Region APIs**: USEast, USWest, EUWest, APSouth endpoints
835+
- **Cache Tiers**: L1, L2, L3 cache configurations
836+
- **Multi-Tenant**: Tenant1, Tenant2, Tenant3 configurations
837+
838+
**Design Decision**:
673839

674-
**Description**: Support binding multiple sections dynamically at runtime using `IOptionsSnapshot`.
840+
- **Expansion at extraction time**: ChildSections array is expanded into multiple OptionsInfo instances during attribute extraction, not at code generation. This simplifies generator logic and ensures all features work consistently.
841+
- **No OnChange support**: Like regular named options, ChildSections-based instances don't support OnChange callbacks (use `IOptionsMonitor<T>.OnChange()` manually if needed).
675842

676843
---
677844

0 commit comments

Comments
 (0)