From cc1abed477cfd1180379d2113b1c11a1c2122d48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A5rten=20=C3=85sberg?= Date: Wed, 30 Apr 2025 17:56:59 +0200 Subject: [PATCH 01/12] Add a Validate overload for adding validator types Adds a Validate overload to the OptionsBuilder that registers an IValidateOptions type that validates the option type. --- .../src/OptionsBuilder.cs | 13 +++++++++ .../OptionsBuilderTest.cs | 29 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilder.cs b/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilder.cs index 37704e947e9eb3..a0c008109f4109 100644 --- a/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilder.cs +++ b/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilder.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.DependencyInjection; namespace Microsoft.Extensions.Options @@ -335,6 +336,18 @@ public virtual OptionsBuilder PostConfigure + /// Registers an type for an options type. + /// + /// The validation type. + /// The current . + public virtual OptionsBuilder Validate<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TValidateOptions>() + where TValidateOptions : class, IValidateOptions + { + Services.AddTransient, TValidateOptions>(); + return this; + } + /// /// Registers a validation action for an options type using a default failure message. /// diff --git a/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs b/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs index 342c02ad786691..d25829927a7f2c 100644 --- a/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs +++ b/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs @@ -814,5 +814,34 @@ public void ValidateOnStart_ConfigureBasedOnDataAnnotationRestrictions_Validatio Assert.NotNull(value); } + + [Fact] + public void ValidateWithValidatorType_ValidatesFailureCorrectly() + { + var services = new ServiceCollection(); + services.AddOptions() + .Configure(o => o.Boolean = false) + .Validate(); + + var sp = services.BuildServiceProvider(); + + var error = Assert.Throws(() => sp.GetRequiredService>().Value); + ValidateFailure(error, Options.DefaultName, 1, "Boolean != true"); + } + + [Fact] + public void ValidateWithValidatorType_ValidationSuccessful() + { + var services = new ServiceCollection(); + services.AddOptions() + .Configure(o => o.Boolean = true) + .Validate(); + + var sp = services.BuildServiceProvider(); + + var value = sp.GetRequiredService>().Value; + + Assert.NotNull(value); + } } } From 302b2fe1b6d3d8ea817dc7978cbd93ebbc8b067e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A5rten=20=C3=85sberg?= Date: Wed, 22 Apr 2026 10:39:21 +0200 Subject: [PATCH 02/12] Add new Validate overload to Options ref --- .../ref/Microsoft.Extensions.Options.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libraries/Microsoft.Extensions.Options/ref/Microsoft.Extensions.Options.cs b/src/libraries/Microsoft.Extensions.Options/ref/Microsoft.Extensions.Options.cs index c6564969132635..70f856d1313cfb 100644 --- a/src/libraries/Microsoft.Extensions.Options/ref/Microsoft.Extensions.Options.cs +++ b/src/libraries/Microsoft.Extensions.Options/ref/Microsoft.Extensions.Options.cs @@ -171,6 +171,7 @@ public OptionsBuilder(Microsoft.Extensions.DependencyInjection.IServiceCollectio public virtual Microsoft.Extensions.Options.OptionsBuilder PostConfigure(System.Action configureOptions) where TDep1 : class where TDep2 : class where TDep3 : class { throw null; } public virtual Microsoft.Extensions.Options.OptionsBuilder PostConfigure(System.Action configureOptions) where TDep1 : class where TDep2 : class where TDep3 : class where TDep4 : class { throw null; } public virtual Microsoft.Extensions.Options.OptionsBuilder PostConfigure(System.Action configureOptions) where TDep1 : class where TDep2 : class where TDep3 : class where TDep4 : class where TDep5 : class { throw null; } + public virtual Microsoft.Extensions.Options.OptionsBuilder Validate<[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] TValidateOptions>() where TValidateOptions : class, Microsoft.Extensions.Options.IValidateOptions { throw null; } public virtual Microsoft.Extensions.Options.OptionsBuilder Validate(System.Func validation) { throw null; } public virtual Microsoft.Extensions.Options.OptionsBuilder Validate(System.Func validation, string failureMessage) { throw null; } public virtual Microsoft.Extensions.Options.OptionsBuilder Validate(System.Func validation) where TDep : notnull { throw null; } From 050fae38df63164165eccb660e76ff5f77dc3361 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A5rten=20=C3=85sberg?= Date: Thu, 23 Apr 2026 10:36:35 +0200 Subject: [PATCH 03/12] Test IValidateOptions types with dependencies --- .../OptionsBuilderTest.cs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs b/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs index d25829927a7f2c..e70ffdd74a40ee 100644 --- a/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs +++ b/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs @@ -843,5 +843,41 @@ public void ValidateWithValidatorType_ValidationSuccessful() Assert.NotNull(value); } + + [Fact] + public void ValidateWithValidatorType_WithDependencies_ValidationSuccessful() + { + var services = new ServiceCollection(); + var dependency = new ObservableDependency(); + services.AddSingleton(dependency); + services.AddOptions() + .Validate(); + + var sp = services.BuildServiceProvider(); + + var value = sp.GetRequiredService>().Value; + + Assert.NotNull(value); + Assert.True(dependency.HasBeenCalled); + } + + private class FakeOptionsValidatorWithDependencies(ObservableDependency dependency) : IValidateOptions + { + public ValidateOptionsResult Validate(string? name, FakeOptions options) + { + dependency.Call(); + return ValidateOptionsResult.Success; + } + } + + private class ObservableDependency + { + public bool HasBeenCalled { get; private set; } = false; + + public void Call() + { + HasBeenCalled = true; + } + } } } From 1fdf046229a39d7482900631777b09d62467ecf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A5rten=20=C3=85sberg?= Date: Thu, 23 Apr 2026 11:02:25 +0200 Subject: [PATCH 04/12] Verify that validators applies regardless of name Adds a test case that verifies that adding an `IValidateOption` validator to a named `OptionsBuilder` does not scope it to that name only. --- .../OptionsBuilderTest.cs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs b/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs index e70ffdd74a40ee..7d3bc4342f4774 100644 --- a/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs +++ b/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs @@ -879,5 +879,25 @@ public void Call() HasBeenCalled = true; } } + + [Fact] + public void ValidateWithValidatorType_AreNotScopedToNamedOptions() + { + var services = new ServiceCollection(); + + services.AddOptions("invalid") + .Configure(o => o.Boolean = false); + + // ComplexOptionsValidator is only added to the valid option + services.AddOptions("valid") + .Configure(o => o.Boolean = true) + .Validate(); + + var sp = services.BuildServiceProvider(); + + // but validation fails regardless + var error = Assert.Throws(() => sp.GetRequiredService>().Value); + ValidateFailure(error, Options.DefaultName, 1, "Boolean != true"); + } } } From ad983921a102d0a4460d1f5d0652c1e20b101d84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A5rten=20=C3=85sberg?= Date: Thu, 23 Apr 2026 11:42:01 +0200 Subject: [PATCH 05/12] Add name filter to validators from OptionsBuilder A decorator is used to skip the registered implementation of `IValidateOptions` if the options name is not matching the registration. --- .../src/NamedValidateOptionsFilter.cs | 34 +++++++++++++++++++ .../src/OptionsBuilder.cs | 3 +- .../OptionsBuilderTest.cs | 8 ++--- 3 files changed, 40 insertions(+), 5 deletions(-) create mode 100644 src/libraries/Microsoft.Extensions.Options/src/NamedValidateOptionsFilter.cs diff --git a/src/libraries/Microsoft.Extensions.Options/src/NamedValidateOptionsFilter.cs b/src/libraries/Microsoft.Extensions.Options/src/NamedValidateOptionsFilter.cs new file mode 100644 index 00000000000000..59401f11aa33a1 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Options/src/NamedValidateOptionsFilter.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace Microsoft.Extensions.Options +{ + internal class NamedValidateOptionsFilter : IValidateOptions + where TOptions : class + where TInner : IValidateOptions + { + private readonly string _name; + private readonly TInner _inner; + + internal NamedValidateOptionsFilter(string name, TInner inner) + { + ArgumentNullException.ThrowIfNull(inner); + + _name = name; + _inner = inner; + } + + public ValidateOptionsResult Validate(string? name, TOptions options) + { + if (name == _name) + { + return _inner.Validate(name, options); + } + + // ignored if not validating this instance + return ValidateOptionsResult.Skip; + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilder.cs b/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilder.cs index a0c008109f4109..5e7f5f737e743d 100644 --- a/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilder.cs +++ b/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilder.cs @@ -344,7 +344,8 @@ public virtual OptionsBuilder PostConfigure Validate<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TValidateOptions>() where TValidateOptions : class, IValidateOptions { - Services.AddTransient, TValidateOptions>(); + Services.AddTransient>(sp => + new NamedValidateOptionsFilter(Name, ActivatorUtilities.GetServiceOrCreateInstance(sp))); return this; } diff --git a/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs b/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs index 7d3bc4342f4774..ccb8a5cff291ca 100644 --- a/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs +++ b/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs @@ -881,7 +881,7 @@ public void Call() } [Fact] - public void ValidateWithValidatorType_AreNotScopedToNamedOptions() + public void ValidateWithValidatorType_AreScopedToNamedOptions() { var services = new ServiceCollection(); @@ -895,9 +895,9 @@ public void ValidateWithValidatorType_AreNotScopedToNamedOptions() var sp = services.BuildServiceProvider(); - // but validation fails regardless - var error = Assert.Throws(() => sp.GetRequiredService>().Value); - ValidateFailure(error, Options.DefaultName, 1, "Boolean != true"); + // validation succeeds because it only applies to the valid option + var value = sp.GetRequiredService>().Value; + Assert.NotNull(value); } } } From 8f9a28b712864836d969ea1d922d93843c2dc99f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A5rten=20=C3=85sberg?= Date: Thu, 23 Apr 2026 20:33:14 +0200 Subject: [PATCH 06/12] Fix name-scoped validator registration test Co-authored-by: Tarek Mahmoud Sayed <10833894+tarekgh@users.noreply.github.com> --- .../OptionsBuilderTest.cs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs b/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs index ccb8a5cff291ca..7ed1c7822b5a4e 100644 --- a/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs +++ b/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs @@ -895,9 +895,15 @@ public void ValidateWithValidatorType_AreScopedToNamedOptions() var sp = services.BuildServiceProvider(); - // validation succeeds because it only applies to the valid option - var value = sp.GetRequiredService>().Value; - Assert.NotNull(value); + var monitor = sp.GetRequiredService>(); + + // "valid" passes — Boolean is true, validator runs and succeeds + var valid = monitor.Get("valid"); + Assert.NotNull(valid); + + // "invalid" passes — validator is scoped to "valid", so it skips "invalid" + var invalid = monitor.Get("invalid"); + Assert.NotNull(invalid); } } } From 184d33bbbc04467aec5169117869d14f85d1a559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A5rten=20=C3=85sberg?= Date: Thu, 23 Apr 2026 21:19:27 +0200 Subject: [PATCH 07/12] Make `NamedValidateOptionsFilter` sealed --- .../src/NamedValidateOptionsFilter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/Microsoft.Extensions.Options/src/NamedValidateOptionsFilter.cs b/src/libraries/Microsoft.Extensions.Options/src/NamedValidateOptionsFilter.cs index 59401f11aa33a1..75ace207d9f448 100644 --- a/src/libraries/Microsoft.Extensions.Options/src/NamedValidateOptionsFilter.cs +++ b/src/libraries/Microsoft.Extensions.Options/src/NamedValidateOptionsFilter.cs @@ -5,7 +5,7 @@ namespace Microsoft.Extensions.Options { - internal class NamedValidateOptionsFilter : IValidateOptions + internal sealed class NamedValidateOptionsFilter : IValidateOptions where TOptions : class where TInner : IValidateOptions { From d611e1b395e94b23040579fa29ccab63932f97fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A5rten=20=C3=85sberg?= Date: Thu, 23 Apr 2026 21:55:35 +0200 Subject: [PATCH 08/12] Definitively test that scoped validators are run By observing an error instead of relying on "nothing" happening when validating a valid option. --- .../OptionsBuilderTest.cs | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs b/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs index 7ed1c7822b5a4e..b150f01896e959 100644 --- a/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs +++ b/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs @@ -885,25 +885,21 @@ public void ValidateWithValidatorType_AreScopedToNamedOptions() { var services = new ServiceCollection(); - services.AddOptions("invalid") + services.AddOptions("unvalidated") .Configure(o => o.Boolean = false); - // ComplexOptionsValidator is only added to the valid option - services.AddOptions("valid") - .Configure(o => o.Boolean = true) + services.AddOptions("validated") + .Configure(o => o.Boolean = false) .Validate(); var sp = services.BuildServiceProvider(); - var monitor = sp.GetRequiredService>(); - - // "valid" passes — Boolean is true, validator runs and succeeds - var valid = monitor.Get("valid"); - Assert.NotNull(valid); - - // "invalid" passes — validator is scoped to "valid", so it skips "invalid" - var invalid = monitor.Get("invalid"); - Assert.NotNull(invalid); + + var error = Assert.Throws(() => monitor.Get("validated")); + ValidateFailure(error, "validated", 1, "Boolean != true"); + + var unvalidated = monitor.Get("unvalidated"); + Assert.NotNull(unvalidated); } } } From 32662538c6141a85eb6c712080ffa7f1500c2248 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A5rten=20=C3=85sberg?= Date: Thu, 23 Apr 2026 21:57:57 +0200 Subject: [PATCH 09/12] Add remarks to `Validate` overload Co-authored-by: Tarek Mahmoud Sayed <10833894+tarekgh@users.noreply.github.com> --- .../Microsoft.Extensions.Options/src/OptionsBuilder.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilder.cs b/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilder.cs index 5e7f5f737e743d..a007a45a0fb7ea 100644 --- a/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilder.cs +++ b/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilder.cs @@ -341,6 +341,11 @@ public virtual OptionsBuilder PostConfigure /// The validation type. /// The current . + /// + /// Validation is scoped to the options name associated with this builder. + /// Dependencies required by + /// are resolved from the service provider. + /// public virtual OptionsBuilder Validate<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TValidateOptions>() where TValidateOptions : class, IValidateOptions { From 6cff367d8850bbde93f09261965207465793241f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A5rten=20=C3=85sberg?= Date: Thu, 23 Apr 2026 22:01:13 +0200 Subject: [PATCH 10/12] Remove redundant initialization Co-authored-by: Tarek Mahmoud Sayed <10833894+tarekgh@users.noreply.github.com> --- .../Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs b/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs index b150f01896e959..15be6497c82182 100644 --- a/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs +++ b/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs @@ -872,7 +872,7 @@ public ValidateOptionsResult Validate(string? name, FakeOptions options) private class ObservableDependency { - public bool HasBeenCalled { get; private set; } = false; + public bool HasBeenCalled { get; private set; } public void Call() { From c981bb1ff0a38859d674e19b7bfa75d9b780d19b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A5rten=20=C3=85sberg?= Date: Fri, 24 Apr 2026 12:34:32 +0200 Subject: [PATCH 11/12] Never filter out `null` named options --- .../src/NamedValidateOptionsFilter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/Microsoft.Extensions.Options/src/NamedValidateOptionsFilter.cs b/src/libraries/Microsoft.Extensions.Options/src/NamedValidateOptionsFilter.cs index 75ace207d9f448..1377c191e4f8a8 100644 --- a/src/libraries/Microsoft.Extensions.Options/src/NamedValidateOptionsFilter.cs +++ b/src/libraries/Microsoft.Extensions.Options/src/NamedValidateOptionsFilter.cs @@ -22,7 +22,7 @@ internal NamedValidateOptionsFilter(string name, TInner inner) public ValidateOptionsResult Validate(string? name, TOptions options) { - if (name == _name) + if (name is null || name == _name) { return _inner.Validate(name, options); } From 4dd7333ad5275907be204332dfb2319091d958a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A5rten=20=C3=85sberg?= Date: Fri, 24 Apr 2026 13:23:46 +0200 Subject: [PATCH 12/12] Update examples to use the new `Validate` overload Instead of registering the `IValidateOptions` types manually, the new `Validate` overload is used in the examples in PACKAGE.md. --- .../Microsoft.Extensions.Options/src/PACKAGE.md | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Options/src/PACKAGE.md b/src/libraries/Microsoft.Extensions.Options/src/PACKAGE.md index ee0cc2ab6f99ee..0842fe91ac144a 100644 --- a/src/libraries/Microsoft.Extensions.Options/src/PACKAGE.md +++ b/src/libraries/Microsoft.Extensions.Options/src/PACKAGE.md @@ -58,12 +58,10 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllersWithViews(); // Configuration to validate -builder.Services.Configure(builder.Configuration.GetSection( - MyConfigOptions.MyConfig)); - -// OPtions validation through the DI container -builder.Services.AddSingleton, MyConfigValidation>(); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(MyConfigOptions.MyConfig)) + // Validate with custom IValidateOptions + .Validate(); var app = builder.Build(); @@ -134,13 +132,11 @@ public class MyConfigOptions public partial class MyConfigValidation : IValidateOptions { // Source generator will automatically provide the implementation of IValidateOptions - // Then you can add the validation to the DI Container using the following code: + // Then you can add the validation using the following code: // - // builder.Services.AddSingleton, MyConfigValidation>(); // builder.Services.AddOptions() // .Bind(builder.Configuration.GetSection(MyConfigOptions.MyConfig)) - // .ValidateDataAnnotations(); + // .Validate(); } ```