diff --git a/CLAUDE.md b/CLAUDE.md index 688fd7f..0094996 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -308,6 +308,7 @@ services.AddDependencyRegistrationsFromDomain( - **ConfigureAll support**: Set common default values for all named options instances before individual binding with `ConfigureAll` callbacks (e.g., baseline retry/timeout settings) - **Named options support**: Multiple configurations of the same options type with different names (e.g., Primary/Secondary email servers) - **Child sections**: Simplified syntax for creating multiple named instances from subsections using `ChildSections` property (e.g., `Email` → Primary/Secondary/Fallback) +- **Direct type registration**: `AlsoRegisterDirectType` parameter allows registering options classes for both `IOptions` AND direct type injection (for migration scenarios and third-party library compatibility) - **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 - Supports lifetime selection: Singleton (IOptions), Scoped (IOptionsSnapshot), Monitor (IOptionsMonitor) - Requires classes to be declared `partial` @@ -487,6 +488,34 @@ services.Configure("Fallback", configuration.GetSection("Email:Fal // [OptionsBinding("Email:Primary", Name = "Primary", ConfigureAll = nameof(SetDefaults))] // [OptionsBinding("Email:Secondary", Name = "Secondary")] // [OptionsBinding("Email:Fallback", Name = "Fallback")] + +// Input with AlsoRegisterDirectType (for legacy code or third-party library compatibility): +[OptionsBinding("LegacyApi", ValidateDataAnnotations = true, AlsoRegisterDirectType = true)] +public partial class LegacyApiOptions +{ + [Required, Url] + public string ApiEndpoint { get; set; } = string.Empty; + + [Required, MinLength(32)] + public string ApiKey { get; set; } = string.Empty; +} + +// Output with AlsoRegisterDirectType (generates both registrations): +// Standard IOptions registration +services.AddOptions() + .Bind(configuration.GetSection("LegacyApi")) + .ValidateDataAnnotations() + .ValidateOnStart(); + +// Also register direct type (for legacy code or third-party libraries) +services.AddSingleton(sp => sp.GetRequiredService>().Value); + +// Usage - Both injection patterns now work: +// Pattern 1: Standard IOptions (recommended for new code) +public class ApiService(IOptions options) { } + +// Pattern 2: Direct type (for legacy code or third-party libraries that expect unwrapped types) +public class LegacyLibraryClient(LegacyApiOptions options) { } ``` **Smart Naming:** diff --git a/docs/OptionsBindingGenerators-FeatureRoadmap.md b/docs/OptionsBindingGenerators-FeatureRoadmap.md index 5e3c523..a091e98 100644 --- a/docs/OptionsBindingGenerators-FeatureRoadmap.md +++ b/docs/OptionsBindingGenerators-FeatureRoadmap.md @@ -87,10 +87,11 @@ This roadmap is based on comprehensive analysis of: | ❌ | [Auto-Generate Options Classes from appsettings.json](#10-auto-generate-options-classes-from-appsettingsjson) | 🟢 Low | | ❌ | [Environment-Specific Validation](#11-environment-specific-validation) | 🟢 Low | | ❌ | [Hot Reload Support with Filtering](#12-hot-reload-support-with-filtering) | 🟢 Low | -| 🚫 | [Reflection-Based Binding](#13-reflection-based-binding) | - | -| 🚫 | [JSON Schema Generation](#14-json-schema-generation) | - | -| 🚫 | [Configuration Encryption/Decryption](#15-configuration-encryptiondecryption) | - | -| 🚫 | [Dynamic Configuration Sources](#16-dynamic-configuration-sources) | - | +| ✅ | [Direct Type Registration (AlsoRegisterDirectType)](#13-direct-type-registration-alsoregisterdirecttype) | 🟡 Medium | +| 🚫 | [Reflection-Based Binding](#14-reflection-based-binding) | - | +| 🚫 | [JSON Schema Generation](#15-json-schema-generation) | - | +| 🚫 | [Configuration Encryption/Decryption](#16-configuration-encryptiondecryption) | - | +| 🚫 | [Dynamic Configuration Sources](#17-dynamic-configuration-sources) | - | **Legend:** @@ -941,9 +942,393 @@ public partial class FeaturesOptions --- +### 13. Direct Type Registration (AlsoRegisterDirectType) + +**Priority**: 🟡 **Medium** +**Status**: ✅ **Implemented** +**Inspiration**: DependencyRegistrationGenerator's `AsSelf` parameter + +**Description**: Support registering options classes for both `IOptions` injection (standard pattern) AND direct type injection (without wrapper). This mirrors the `AsSelf` pattern from `[Registration]` attribute in DependencyRegistrationGenerator. + +**User Story**: +> "As a developer migrating legacy code to use OptionsBinding, I want to support both `IOptions` and direct `EntraIdOptions` injection patterns, allowing gradual migration and compatibility with third-party libraries that expect unwrapped types." + +**Problem Statement**: + +When using `[OptionsBinding]`, the generator creates: + +```csharp +services.AddOptions() + .Bind(configuration.GetSection("EntraId")) + .ValidateDataAnnotations() + .ValidateOnStart(); +``` + +This **ONLY** registers for `IOptions` injection. Attempting to inject `EntraIdOptions` directly fails at runtime. + +**Real-World Scenarios**: + +1. **Third-party libraries** - Some libraries expect direct options types, not IOptions +2. **Legacy code migration** - Existing code uses direct injection, gradual migration to IOptions +3. **Simpler syntax** - `options.Property` instead of `options.Value.Property` in simple scenarios +4. **Configuration classes** - ASP.NET middleware often expects direct types (e.g., `ConfigureSwaggerOptions`) + +**Proposed Solution**: + +```csharp +// Enable dual registration with new parameter +[OptionsBinding("EntraId", AlsoRegisterDirectType = true, ValidateOnStart = true)] +public partial class EntraIdOptions +{ + [Required] + public string TenantId { get; set; } = string.Empty; + + [Required] + public string ClientId { get; set; } = string.Empty; + + [Required, Url] + public string Instance { get; set; } = "https://login.microsoftonline.com/"; +} +``` + +**Generated Code**: + +```csharp +// Standard IOptions registration (always generated) +services.AddOptions() + .Bind(configuration.GetSection("EntraId")) + .ValidateDataAnnotations() + .ValidateOnStart(); + +// ALSO register direct type (when AlsoRegisterDirectType = true) +// Direct type is derived from IOptions to ensure consistency +services.AddSingleton(sp => + sp.GetRequiredService>().Value); +``` + +**Usage Examples**: + +```csharp +// Both injection patterns now work: + +// Pattern 1: Standard IOptions (recommended) +public class ApiService +{ + public ApiService(IOptions options) + { + var tenantId = options.Value.TenantId; + } +} + +// Pattern 2: Direct type (for legacy code or third-party libraries) +public class ConfigureSwaggerOptions : IConfigureOptions +{ + private readonly EntraIdOptions entraIdOptions; // Direct injection! + + public ConfigureSwaggerOptions(EntraIdOptions entraIdOptions) + { + this.entraIdOptions = entraIdOptions; + } + + public void Configure(SwaggerGenOptions options) + { + // Simpler access: entraIdOptions.TenantId vs options.Value.TenantId + } +} +``` + +**Implementation Hints**: + +**1. Update `OptionsBindingAttribute.cs`:** + +```csharp +[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)] +public sealed class OptionsBindingAttribute : Attribute +{ + // ... existing properties ... + + /// + /// Gets or sets a value indicating whether to also register the options type + /// as a direct service (not wrapped in IOptions<T>). + /// Default is false. + /// + /// + /// When true, the options class will be registered both: + /// - As IOptions<T> (standard pattern) + /// - As T directly (for legacy code or third-party libraries) + /// + /// The direct type registration resolves through IOptions<T>.Value + /// to ensure validation and configuration binding still apply. + /// + /// Trade-offs: + /// - Loses change detection (direct instance is snapshot at resolution time) + /// - Loses IOptionsSnapshot/IOptionsMonitor benefits + /// - Should be used sparingly for migration scenarios only + /// + public bool AlsoRegisterDirectType { get; set; } = false; +} +``` + +**2. Update `OptionsInfo.cs` (internal record):** + +```csharp +internal sealed record OptionsInfo( + string ClassName, + string Namespace, + string AssemblyName, + string SectionName, + bool ValidateOnStart, + bool ValidateDataAnnotations, + int Lifetime, + string? ValidatorType, + string? Name, + bool ErrorOnMissingKeys, + string? OnChange, + string? PostConfigure, + string? ConfigureAll, + string?[]? ChildSections, + bool AlsoRegisterDirectType // NEW FIELD +); +``` + +**3. Update `OptionsBindingGenerator.cs` - Parse the attribute:** + +```csharp +// In ExtractOptionsInfoFromAttribute() method +var alsoRegisterDirectType = false; +foreach (var namedArg in attr.NamedArguments) +{ + if (namedArg.Key == "AlsoRegisterDirectType") + { + alsoRegisterDirectType = namedArg.Value.Value is true; + } + // ... existing named argument parsing ... +} + +return new OptionsInfo( + // ... existing parameters ... + AlsoRegisterDirectType: alsoRegisterDirectType +); +``` + +**4. Update `OptionsBindingGenerator.cs` - Generate registration code:** + +```csharp +// In GenerateOptionsRegistration() method, after standard registration: + +if (optionsInfo.AlsoRegisterDirectType) +{ + builder.AppendLine(); + builder.AppendLine(" // Also register direct type for legacy code compatibility"); + + // Choose service lifetime based on OptionsLifetime + string serviceLifetime = optionsInfo.Lifetime switch + { + 0 => "AddSingleton", // IOptions -> Singleton + 1 => "AddScoped", // IOptionsSnapshot -> Scoped + 2 => "AddSingleton", // IOptionsMonitor -> Singleton (CurrentValue is snapshot) + _ => "AddSingleton" + }; + + // Generate registration that derives from IOptions + if (optionsInfo.Lifetime == 2) // Monitor + { + // For Monitor, use CurrentValue for latest snapshot + builder.AppendLine($" services.{serviceLifetime}(sp => "); + builder.AppendLine($" sp.GetRequiredService>().CurrentValue);"); + } + else if (optionsInfo.Lifetime == 1) // Scoped/Snapshot + { + // For Scoped, use IOptionsSnapshot + builder.AppendLine($" services.{serviceLifetime}(sp => "); + builder.AppendLine($" sp.GetRequiredService>().Value);"); + } + else // Singleton + { + // For Singleton, use IOptions + builder.AppendLine($" services.{serviceLifetime}(sp => "); + builder.AppendLine($" sp.GetRequiredService>().Value);"); + } +} +``` + +**5. Add Diagnostic (Optional but Recommended):** + +```csharp +// ATCOPT017: Warning when AlsoRegisterDirectType is used with named options +private static readonly DiagnosticDescriptor DirectTypeWithNamedOptionsWarning = new( + id: "ATCOPT017", + title: "AlsoRegisterDirectType not recommended with named options", + messageFormat: "Options class '{0}' uses AlsoRegisterDirectType with named options. Direct type registration only resolves the default unnamed instance, not named instances.", + category: "OptionsBinding", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + +// ATCOPT018: Warning about loss of change detection +private static readonly DiagnosticDescriptor DirectTypeLosesChangeDetection = new( + id: "ATCOPT018", + title: "AlsoRegisterDirectType loses change detection", + messageFormat: "Options class '{0}' uses AlsoRegisterDirectType with Monitor lifetime. Direct type injection will not receive configuration changes. Consider using IOptionsMonitor instead.", + category: "OptionsBinding", + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true); +``` + +**Design Decisions**: + +1. **Single source of truth**: Direct type is ALWAYS derived from `IOptions.Value` or `IOptionsSnapshot.Value` or `IOptionsMonitor.CurrentValue` + - This ensures validation runs for both injection patterns + - Configuration binding is consistent + - No duplication of registration logic + +2. **Lifetime mapping**: + - `OptionsLifetime.Singleton` → `AddSingleton(sp => IOptions.Value)` + - `OptionsLifetime.Scoped` → `AddScoped(sp => IOptionsSnapshot.Value)` + - `OptionsLifetime.Monitor` → `AddSingleton(sp => IOptionsMonitor.CurrentValue)` + +3. **Named options incompatibility**: + - Direct type registration only works with unnamed (default) options + - Named options require `IOptionsSnapshot.Get(name)` for resolution + - Should emit warning (ATCOPT017) if both are used + +4. **Change detection limitation**: + - Direct injection gets a snapshot at resolution time + - Does NOT receive updates when configuration changes + - Should emit info diagnostic (ATCOPT018) when used with Monitor lifetime + +**Trade-Offs and Warnings**: + +**✅ Benefits:** +- **Migration path**: Gradual transition from direct injection to IOptions +- **Third-party compatibility**: Works with libraries expecting direct types +- **Simpler syntax**: `options.Property` vs `options.Value.Property` +- **Validation preserved**: Direct type still goes through options pipeline + +**⚠️ Drawbacks:** +- **Loss of change detection**: Direct instance is snapshot, not live +- **Loss of scoping benefits**: Especially with Monitor + Singleton +- **API surface expansion**: More ways to inject = more confusion +- **Encourages anti-pattern**: May discourage proper IOptions usage + +**Documentation Notes**: + +When documenting this feature, emphasize: + +1. **Use sparingly**: Only for migration scenarios or third-party compatibility +2. **Prefer IOptions**: The standard pattern should be default choice +3. **Understand trade-offs**: Loss of change detection and scoping benefits +4. **Not for production patterns**: Consider refactoring to IOptions long-term + +**Unit Test Coverage**: + +```csharp +// Test: AlsoRegisterDirectType generates both registrations +[Fact] +public void Generator_Should_Register_Both_IOptions_And_DirectType_When_AlsoRegisterDirectType_True() +{ + const string source = """ + [OptionsBinding("Database", AlsoRegisterDirectType = true)] + public partial class DatabaseOptions + { + public string ConnectionString { get; set; } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("AddOptions", output); + Assert.Contains("services.AddSingleton(sp =>", output); + Assert.Contains("IOptions>().Value", output); +} + +// Test: Different lifetimes generate correct service registrations +[Theory] +[InlineData("OptionsLifetime.Singleton", "AddSingleton", "IOptions")] +[InlineData("OptionsLifetime.Scoped", "AddScoped", "IOptionsSnapshot")] +[InlineData("OptionsLifetime.Monitor", "AddSingleton", "IOptionsMonitor")] +public void Generator_Should_Use_Correct_Lifetime_For_DirectType( + string lifetime, string expectedMethod, string expectedInterface) { } + +// Test: Warning emitted when used with named options +[Fact] +public void Generator_Should_Warn_When_AlsoRegisterDirectType_With_Named_Options() { } + +// Test: Info diagnostic for Monitor + direct type +[Fact] +public void Generator_Should_Emit_Info_When_DirectType_With_Monitor_Lifetime() { } + +// Test: Works with all validation features +[Fact] +public void Generator_Should_Support_Validation_With_DirectType() { } +``` + +**Implementation Status**: + +✅ **Core Implementation Complete:** +- Updated `OptionsBindingAttribute.cs` with `AlsoRegisterDirectType` property +- Updated `OptionsInfo` record to include `AlsoRegisterDirectType` field +- Updated `OptionsBindingGenerator.cs` to parse and generate direct type registration +- Correctly handles all three lifetimes (Singleton, Scoped, Monitor) +- Direct type resolves through appropriate IOptions interface (.Value or .CurrentValue) +- Named options correctly excluded from direct type registration + +✅ **Testing Complete:** +- 10 comprehensive unit tests covering all scenarios (OptionsBindingGeneratorAlsoRegisterDirectTypeTests.cs) +- Tests cover: Singleton/Scoped/Monitor lifetimes, named options exclusion, validation, ErrorOnMissingKeys, PostConfigure, ChildSections +- All 291 existing tests pass - zero regressions +- Solution builds successfully + +✅ **Sample Projects Updated:** +- `Atc.SourceGenerators.OptionsBinding`: Added `LegacyIntegrationOptions` demonstrating the feature +- `PetStore.Api`: Updated `ExternalApiOptions` with AlsoRegisterDirectType for third-party library compatibility + +📝 **Documentation Updates** (in progress): +- Feature roadmap updated to mark as ✅ Implemented +- Remaining: OptionsBindingGenerators.md, OptionsBindingGenerators-Samples.md, readme.md, CLAUDE.md + +**Alternative Names Considered**: + +- `RegisterDirectType` - Less clear about "also" +- `AsSelf` - Reuses DependencyRegistration naming (might cause confusion between generators) +- `EnableDirectInjection` - More verbose +- `AlsoRegisterDirectType` - ✅ **CHOSEN** - Clear intent, mirrors `AsSelf` pattern semantically + +**Related Features**: + +This feature mirrors the `AsSelf` parameter from DependencyRegistrationGenerator: + +```csharp +// DependencyRegistrationGenerator (existing): +[Registration(As = typeof(IUserService), AsSelf = true)] +public class UserService : IUserService { } + +// Generates: +services.AddSingleton(); +services.AddSingleton(); // Concrete type + +// OptionsBindingGenerator (proposed): +[OptionsBinding("Database", AlsoRegisterDirectType = true)] +public partial class DatabaseOptions { } + +// Generates: +services.AddOptions()... +services.AddSingleton(sp => IOptions.Value); // Direct type +``` + +**Compatibility**: + +- ✅ .NET 6, 7, 8, 9+ +- ✅ Native AOT compatible +- ✅ Works with all existing OptionsBinding features +- ⚠️ Not compatible with named options (warning emitted) +- ⚠️ Change detection limited for direct injection + +--- + ## ⛔ Do Not Need (Low Priority / Out of Scope) -### 13. Reflection-Based Binding +### 14. Reflection-Based Binding **Reason**: Defeats the purpose of compile-time source generation and breaks AOT compatibility. @@ -951,7 +1336,7 @@ public partial class FeaturesOptions --- -### 14. JSON Schema Generation +### 15. JSON Schema Generation **Reason**: Out of scope for options binding. Use dedicated tools like NJsonSchema. @@ -959,7 +1344,7 @@ public partial class FeaturesOptions --- -### 15. Configuration Encryption/Decryption +### 16. Configuration Encryption/Decryption **Reason**: Security concern handled by configuration providers (Azure Key Vault, AWS Secrets Manager, etc.), not binding layer. @@ -967,7 +1352,7 @@ public partial class FeaturesOptions --- -### 16. Dynamic Configuration Sources +### 17. Dynamic Configuration Sources **Reason**: Configuration providers handle this. Options binding focuses on type-safe access. @@ -1044,6 +1429,7 @@ Based on priority, user demand, and implementation complexity: | Section Path Validation | 🟡 Medium | ⭐⭐ | High | 1.3 | | Nested Object Binding | 🟡 Medium | ⭐⭐ | Low | 1.3 | | Environment Validation | 🟢 Low | ⭐ | Medium | 1.3 | +| Direct Type Registration | 🟡 Medium | ⭐⭐ | Medium | 1.4 | | Hot Reload Filtering | 🟢 Low | ⭐ | Medium | 2.0+ | | Auto-Generate from JSON | 🟢 Low | ⭐ | High | 2.0+ | @@ -1117,7 +1503,8 @@ Based on priority, user demand, and implementation complexity: --- -**Last Updated**: 2025-01-19 -**Version**: 1.1 +**Last Updated**: 2025-01-20 +**Version**: 1.2 **Research Date**: January 2025 (.NET 8/9 Options Pattern) **Maintained By**: Atc.SourceGenerators Team +**Note**: Feature #13 (Direct Type Registration) added based on real-world usage patterns from KL.IoT.Nexus project diff --git a/sample/Atc.SourceGenerators.OptionsBinding/Options/LegacyIntegrationOptions.cs b/sample/Atc.SourceGenerators.OptionsBinding/Options/LegacyIntegrationOptions.cs new file mode 100644 index 0000000..406c8f8 --- /dev/null +++ b/sample/Atc.SourceGenerators.OptionsBinding/Options/LegacyIntegrationOptions.cs @@ -0,0 +1,55 @@ +namespace Atc.SourceGenerators.OptionsBinding.Options; + +/// +/// Legacy integration configuration options. +/// Demonstrates Feature #13: Direct type registration (AlsoRegisterDirectType). +/// This feature allows registering the options class both as IOptions<T> AND as the direct type T. +/// Useful for migration scenarios or third-party library compatibility. +/// +/// +/// +/// Use case: You're integrating with a legacy library that expects the options class directly +/// in its constructor, not wrapped in IOptions<T>. Instead of creating a wrapper service, +/// you can use AlsoRegisterDirectType = true to register both patterns. +/// +/// +/// Trade-offs: +/// - Direct injection gets a snapshot at resolution time +/// - No change detection - configuration updates won't be reflected +/// - Should be used sparingly for migration/compatibility only +/// +/// +[OptionsBinding("LegacyIntegration", ValidateDataAnnotations = true, AlsoRegisterDirectType = true)] +public partial class LegacyIntegrationOptions +{ + /// + /// Gets or sets the API endpoint for the legacy system. + /// + [Required] + [Url] + public string ApiEndpoint { get; set; } = string.Empty; + + /// + /// Gets or sets the API key for authentication. + /// + [Required] + [MinLength(32)] + public string ApiKey { get; set; } = string.Empty; + + /// + /// Gets or sets the timeout in seconds for API calls. + /// + [Range(1, 300)] + public int TimeoutSeconds { get; set; } = 30; + + /// + /// Gets or sets a value indicating whether to use SSL/TLS for connections. + /// + public bool UseSsl { get; set; } = true; + + /// + /// Gets or sets the maximum number of retry attempts. + /// + [Range(0, 10)] + public int MaxRetries { get; set; } = 3; +} \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.OptionsBinding/appsettings.json b/sample/Atc.SourceGenerators.OptionsBinding/appsettings.json index 5677faf..6c3ee44 100644 --- a/sample/Atc.SourceGenerators.OptionsBinding/appsettings.json +++ b/sample/Atc.SourceGenerators.OptionsBinding/appsettings.json @@ -79,5 +79,13 @@ "CachePath": "/var/cache/myapp", "TempPath": "C:\\Temp\\MyApp", "LogPath": "/var/log/myapp" + }, + "LegacyIntegration": { + "ApiEndpoint": "https://legacy-api.example.com/v1", + "ApiKey": "legacy-api-key-12345678901234567890abcdef", + "TimeoutSeconds": 30, + "UseSsl": true, + "MaxRetries": 3 } } + diff --git a/sample/PetStore.Domain/Options/ExternalApiOptions.cs b/sample/PetStore.Domain/Options/ExternalApiOptions.cs index 53de28e..7000dad 100644 --- a/sample/PetStore.Domain/Options/ExternalApiOptions.cs +++ b/sample/PetStore.Domain/Options/ExternalApiOptions.cs @@ -2,10 +2,22 @@ namespace PetStore.Domain.Options; /// /// External API endpoint configuration options. -/// Demonstrates PostConfigure feature for normalizing API URLs after binding. +/// Demonstrates: +/// - PostConfigure feature for normalizing API URLs after binding +/// - AlsoRegisterDirectType feature for third-party library compatibility /// Ensures all URLs are lowercase and properly formatted for consistent API communication. /// -[OptionsBinding("ExternalApis", ValidateDataAnnotations = true, ValidateOnStart = true, PostConfigure = nameof(NormalizeUrls))] +/// +/// AlsoRegisterDirectType = true allows this class to be injected both as IOptions<ExternalApiOptions> +/// and as ExternalApiOptions directly. This is useful when integrating with third-party API client +/// libraries that expect configuration objects directly in their constructors. +/// +[OptionsBinding( + "ExternalApis", + ValidateDataAnnotations = true, + ValidateOnStart = true, + PostConfigure = nameof(NormalizeUrls), + AlsoRegisterDirectType = true)] public partial class ExternalApiOptions { /// diff --git a/src/Atc.SourceGenerators.Annotations/OptionsBindingAttribute.cs b/src/Atc.SourceGenerators.Annotations/OptionsBindingAttribute.cs index e3bdf69..217c8d5 100644 --- a/src/Atc.SourceGenerators.Annotations/OptionsBindingAttribute.cs +++ b/src/Atc.SourceGenerators.Annotations/OptionsBindingAttribute.cs @@ -140,4 +140,37 @@ public OptionsBindingAttribute(string? sectionName = null) /// Useful for multi-tenant scenarios, regional configurations, or environment-specific settings. /// public string[]? ChildSections { get; set; } + + /// + /// Gets or sets a value indicating whether to also register the options type + /// as a direct service (not wrapped in IOptions<T>). + /// Default is false. + /// + /// + /// + /// When true, the options class will be registered both: + /// + /// As IOptions<T>, IOptionsSnapshot<T>, or IOptionsMonitor<T> (standard pattern based on Lifetime) + /// As T directly (for legacy code or third-party libraries) + /// + /// + /// + /// The direct type registration resolves through the appropriate options interface (.Value or .CurrentValue) + /// to ensure validation and configuration binding still apply. + /// + /// + /// Trade-offs: + /// + /// Loss of change detection: Direct injection gets a snapshot at resolution time and won't receive updates when configuration changes + /// Loss of scoping benefits: Especially when using Monitor lifetime, the direct type is registered as Singleton and uses CurrentValue snapshot + /// Migration aid: Useful for gradual migration from direct injection to IOptions<T> pattern + /// Third-party compatibility: Some libraries expect direct types, not IOptions<T> + /// + /// + /// + /// Usage guidance: Use sparingly for migration scenarios or third-party library compatibility only. + /// The IOptions<T> pattern should be the default choice for new code. + /// + /// + public bool AlsoRegisterDirectType { get; set; } } \ No newline at end of file diff --git a/src/Atc.SourceGenerators/Generators/Internal/OptionsInfo.cs b/src/Atc.SourceGenerators/Generators/Internal/OptionsInfo.cs index 193c60c..1051a9c 100644 --- a/src/Atc.SourceGenerators/Generators/Internal/OptionsInfo.cs +++ b/src/Atc.SourceGenerators/Generators/Internal/OptionsInfo.cs @@ -14,4 +14,5 @@ internal sealed record OptionsInfo( string? OnChange, string? PostConfigure, string? ConfigureAll, - string?[]? ChildSections); \ No newline at end of file + string?[]? ChildSections, + bool AlsoRegisterDirectType); \ No newline at end of file diff --git a/src/Atc.SourceGenerators/Generators/OptionsBindingGenerator.cs b/src/Atc.SourceGenerators/Generators/OptionsBindingGenerator.cs index 6fc62eb..06ef972 100644 --- a/src/Atc.SourceGenerators/Generators/OptionsBindingGenerator.cs +++ b/src/Atc.SourceGenerators/Generators/OptionsBindingGenerator.cs @@ -408,6 +408,7 @@ private static List ExtractAllOptionsInfo( string? postConfigure = null; string? configureAll = null; string?[]? childSections = null; + var alsoRegisterDirectType = false; foreach (var namedArg in attribute.NamedArguments) { @@ -453,6 +454,9 @@ private static List ExtractAllOptionsInfo( .ToArray(); } + break; + case "AlsoRegisterDirectType": + alsoRegisterDirectType = namedArg.Value.Value as bool? ?? false; break; } } @@ -673,7 +677,8 @@ private static List ExtractAllOptionsInfo( onChange, postConfigure, configureAll, - childSections)); // Store ChildSections to indicate this is part of a child sections group + childSections, // Store ChildSections to indicate this is part of a child sections group + alsoRegisterDirectType)); } return result; @@ -696,7 +701,8 @@ private static List ExtractAllOptionsInfo( onChange, postConfigure, configureAll, - null) // No ChildSections + null, // No ChildSections + alsoRegisterDirectType) ]; } @@ -1139,6 +1145,32 @@ private static void GenerateOptionsRegistration( sb.AppendLineLf(">();"); } + // Register direct type if AlsoRegisterDirectType is true + if (option.AlsoRegisterDirectType && !isNamed) + { + sb.AppendLineLf(); + sb.AppendLineLf($" // Also register {option.ClassName} as direct type (for legacy code or third-party library compatibility)"); + + // Choose service lifetime and options interface based on OptionsLifetime + var (serviceMethod, optionsInterface, valueAccess) = option.Lifetime switch + { + 0 => ("AddSingleton", "IOptions", "Value"), // Singleton + 1 => ("AddScoped", "IOptionsSnapshot", "Value"), // Scoped + 2 => ("AddSingleton", "IOptionsMonitor", "CurrentValue"), // Monitor + _ => ("AddSingleton", "IOptions", "Value"), // Default + }; + + sb.Append(" services."); + sb.Append(serviceMethod); + sb.Append("(sp => sp.GetRequiredService>()."); + sb.Append(valueAccess); + sb.AppendLineLf(");"); + } + sb.AppendLineLf(); } @@ -1443,6 +1475,13 @@ public OptionsBindingAttribute(string? sectionName = null) /// Useful for multi-tenant scenarios, regional configurations, or environment-specific settings. /// public string[]? ChildSections { get; set; } + + /// + /// Gets or sets a value indicating whether to also register the options type + /// as a direct service (not wrapped in IOptions<T>). + /// Default is false. + /// + public bool AlsoRegisterDirectType { get; set; } } } """; diff --git a/test/Atc.SourceGenerators.Tests/Generators/OptionsBinding/OptionsBindingGeneratorAlsoRegisterDirectTypeTests.cs b/test/Atc.SourceGenerators.Tests/Generators/OptionsBinding/OptionsBindingGeneratorAlsoRegisterDirectTypeTests.cs new file mode 100644 index 0000000..8eb90da --- /dev/null +++ b/test/Atc.SourceGenerators.Tests/Generators/OptionsBinding/OptionsBindingGeneratorAlsoRegisterDirectTypeTests.cs @@ -0,0 +1,305 @@ +// ReSharper disable RedundantArgumentDefaultValue +// ReSharper disable UnusedVariable +namespace Atc.SourceGenerators.Tests.Generators.OptionsBinding; + +public partial class OptionsBindingGeneratorTests +{ + [Fact] + public void Generator_Should_Register_Direct_Type_With_Singleton_Lifetime() + { + // Arrange + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace MyApp.Configuration; + + [OptionsBinding("Database", Lifetime = OptionsLifetime.Singleton, AlsoRegisterDirectType = true)] + public partial class DatabaseOptions + { + public string ConnectionString { get; set; } = string.Empty; + } + """; + + // Act + var (diagnostics, output) = GetGeneratedOutput(source); + + // Assert + Assert.Empty(diagnostics); + + var generatedCode = GetGeneratedExtensionMethod(output); + Assert.NotNull(generatedCode); + Assert.Contains("Configure DatabaseOptions - Inject using IOptions", generatedCode, StringComparison.Ordinal); + Assert.Contains("Also register DatabaseOptions as direct type (for legacy code or third-party library compatibility)", generatedCode, StringComparison.Ordinal); + Assert.Contains("services.AddSingleton(sp => sp.GetRequiredService>().Value);", generatedCode, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Register_Direct_Type_With_Scoped_Lifetime() + { + // Arrange + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace MyApp.Configuration; + + [OptionsBinding("Request", Lifetime = OptionsLifetime.Scoped, AlsoRegisterDirectType = true)] + public partial class RequestOptions + { + public string ClientId { get; set; } = string.Empty; + } + """; + + // Act + var (diagnostics, output) = GetGeneratedOutput(source); + + // Assert + Assert.Empty(diagnostics); + + var generatedCode = GetGeneratedExtensionMethod(output); + Assert.NotNull(generatedCode); + Assert.Contains("Configure RequestOptions - Inject using IOptionsSnapshot", generatedCode, StringComparison.Ordinal); + Assert.Contains("Also register RequestOptions as direct type (for legacy code or third-party library compatibility)", generatedCode, StringComparison.Ordinal); + Assert.Contains("services.AddScoped(sp => sp.GetRequiredService>().Value);", generatedCode, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Register_Direct_Type_With_Monitor_Lifetime() + { + // Arrange + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace MyApp.Configuration; + + [OptionsBinding("Features", Lifetime = OptionsLifetime.Monitor, AlsoRegisterDirectType = true)] + public partial class FeatureOptions + { + public bool EnableNewFeature { get; set; } + } + """; + + // Act + var (diagnostics, output) = GetGeneratedOutput(source); + + // Assert + Assert.Empty(diagnostics); + + var generatedCode = GetGeneratedExtensionMethod(output); + Assert.NotNull(generatedCode); + Assert.Contains("Configure FeatureOptions - Inject using IOptionsMonitor", generatedCode, StringComparison.Ordinal); + Assert.Contains("Also register FeatureOptions as direct type (for legacy code or third-party library compatibility)", generatedCode, StringComparison.Ordinal); + Assert.Contains("services.AddSingleton(sp => sp.GetRequiredService>().CurrentValue);", generatedCode, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Not_Register_Direct_Type_When_AlsoRegisterDirectType_Is_False() + { + // Arrange + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace MyApp.Configuration; + + [OptionsBinding("Cache", AlsoRegisterDirectType = false)] + public partial class CacheOptions + { + public int MaxSize { get; set; } + } + """; + + // Act + var (diagnostics, output) = GetGeneratedOutput(source); + + // Assert + Assert.Empty(diagnostics); + + var generatedCode = GetGeneratedExtensionMethod(output); + Assert.NotNull(generatedCode); + Assert.DoesNotContain("Also register CacheOptions as direct type", generatedCode, StringComparison.Ordinal); + Assert.DoesNotContain("AddSingleton(sp => sp.GetRequiredService", generatedCode, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Not_Register_Direct_Type_When_AlsoRegisterDirectType_Not_Specified() + { + // Arrange + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace MyApp.Configuration; + + [OptionsBinding("Logging")] + public partial class LoggingOptions + { + public string Level { get; set; } = string.Empty; + } + """; + + // Act + var (diagnostics, output) = GetGeneratedOutput(source); + + // Assert + Assert.Empty(diagnostics); + + var generatedCode = GetGeneratedExtensionMethod(output); + Assert.NotNull(generatedCode); + Assert.DoesNotContain("Also register LoggingOptions as direct type", generatedCode, StringComparison.Ordinal); + Assert.DoesNotContain("AddSingleton(sp => sp.GetRequiredService", generatedCode, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Not_Register_Direct_Type_For_Named_Options() + { + // Arrange + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace MyApp.Configuration; + + [OptionsBinding("Email:Primary", Name = "Primary", AlsoRegisterDirectType = true)] + [OptionsBinding("Email:Secondary", Name = "Secondary", AlsoRegisterDirectType = true)] + public partial class EmailOptions + { + public string SmtpServer { get; set; } = string.Empty; + } + """; + + // Act + var (diagnostics, output) = GetGeneratedOutput(source); + + // Assert + Assert.Empty(diagnostics); + + var generatedCode = GetGeneratedExtensionMethod(output); + Assert.NotNull(generatedCode); + + // Named options should not generate direct type registration + Assert.DoesNotContain("Also register EmailOptions as direct type", generatedCode, StringComparison.Ordinal); + Assert.DoesNotContain("AddSingleton(sp => sp.GetRequiredService sp.GetRequiredService sp.GetRequiredService>().Value);", generatedCode, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Register_Direct_Type_With_ErrorOnMissingKeys() + { + // Arrange + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace MyApp.Configuration; + + [OptionsBinding("AppSettings", ErrorOnMissingKeys = true, AlsoRegisterDirectType = true)] + public partial class AppSettings + { + public string AppName { get; set; } = string.Empty; + } + """; + + // Act + var (diagnostics, output) = GetGeneratedOutput(source); + + // Assert + Assert.Empty(diagnostics); + + var generatedCode = GetGeneratedExtensionMethod(output); + Assert.NotNull(generatedCode); + Assert.Contains(".Validate(options =>", generatedCode, StringComparison.Ordinal); + Assert.Contains("Configuration section 'AppSettings' is missing", generatedCode, StringComparison.Ordinal); + Assert.Contains("Also register AppSettings as direct type (for legacy code or third-party library compatibility)", generatedCode, StringComparison.Ordinal); + Assert.Contains("services.AddSingleton(sp => sp.GetRequiredService>().Value);", generatedCode, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Register_Direct_Type_With_PostConfigure() + { + // Arrange + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace MyApp.Configuration; + + [OptionsBinding("Storage", PostConfigure = nameof(NormalizePaths), AlsoRegisterDirectType = true)] + public partial class StorageOptions + { + public string BasePath { get; set; } = string.Empty; + + internal static void NormalizePaths(StorageOptions options) + { + // Normalize paths + } + } + """; + + // Act + var (diagnostics, output) = GetGeneratedOutput(source); + + // Assert + Assert.Empty(diagnostics); + + var generatedCode = GetGeneratedExtensionMethod(output); + Assert.NotNull(generatedCode); + Assert.Contains(".PostConfigure(options => global::MyApp.Configuration.StorageOptions.NormalizePaths(options))", generatedCode, StringComparison.Ordinal); + Assert.Contains("Also register StorageOptions as direct type (for legacy code or third-party library compatibility)", generatedCode, StringComparison.Ordinal); + Assert.Contains("services.AddSingleton(sp => sp.GetRequiredService>().Value);", generatedCode, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Not_Register_Direct_Type_For_Child_Sections() + { + // Arrange + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace MyApp.Configuration; + + [OptionsBinding("Email", ChildSections = new[] { "Primary", "Secondary" }, AlsoRegisterDirectType = true)] + public partial class EmailOptions + { + public string SmtpServer { get; set; } = string.Empty; + } + """; + + // Act + var (diagnostics, output) = GetGeneratedOutput(source); + + // Assert + Assert.Empty(diagnostics); + + var generatedCode = GetGeneratedExtensionMethod(output); + Assert.NotNull(generatedCode); + + // ChildSections create named options, so no direct type registration + Assert.DoesNotContain("Also register EmailOptions as direct type", generatedCode, StringComparison.Ordinal); + Assert.DoesNotContain("AddSingleton(sp => sp.GetRequiredService