From c99960853d6f106182bdb01b71b8ab350bfc6b39 Mon Sep 17 00:00:00 2001 From: Steve Harter Date: Wed, 9 Aug 2023 09:58:54 -0500 Subject: [PATCH] Add extension methods for creating OptionsBuilder with ValidateOnStart support (#89973) --- .../OptionsBuilderExtensionsTests.cs | 82 ++++++++++++++++++- .../ref/Microsoft.Extensions.Options.cs | 6 +- .../src/OptionsServiceCollectionExtensions.cs | 41 ++++++++++ .../OptionsBuilderTest.cs | 42 +++++++++- 4 files changed, 167 insertions(+), 4 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/OptionsBuilderExtensionsTests.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/OptionsBuilderExtensionsTests.cs index 621d94bb4c3..26b61d0a2d7 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/OptionsBuilderExtensionsTests.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/OptionsBuilderExtensionsTests.cs @@ -5,7 +5,6 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using Xunit; @@ -124,6 +123,32 @@ public async Task ValidateOnStart_NamedOptions_ValidatesFailureOnStart() } } + [Fact] + public async Task ValidateOnStart_NamedOptions_ValidatesFailureOnStart_AddOptionsWithValidateOnStart() + { + var hostBuilder = CreateHostBuilder(services => + { + services.AddOptions().AddSingleton(new FakeService()); + services + .AddOptionsWithValidateOnStart("named") + .Configure((o, _) => + { + o.Name = "named"; + }) + .Validate(o => o.Name == null, "trigger validation failure for named option!"); + }); + + using (var host = hostBuilder.Build()) + { + var error = await Assert.ThrowsAsync(async () => + { + await host.StartAsync(); + }); + + ValidateFailure(error, 1, "trigger validation failure for named option!"); + } + } + [Fact] private async Task ValidateOnStart_AddNamedOptionsMultipleTimesForSameType_BothGetTriggered() { @@ -195,6 +220,61 @@ private async Task ValidateOnStart_AddEagerValidation_DoesValidationWhenHostStar Assert.True(validateCalled); } + [Fact] + private async Task ValidateOnStart_AddEagerValidation_DoesValidationWhenHostStartsWithNoFailure_AddOptionsWithValidateOnStart() + { + bool validateCalled = false; + + var hostBuilder = CreateHostBuilder(services => + { + // Adds eager validation using ValidateOnStart + services.AddOptionsWithValidateOnStart("correct_configuration") + .Configure(o => o.Boolean = true) + .Validate(o => + { + validateCalled = true; + return o.Boolean; + }, "correct_configuration"); + }); + + using (var host = hostBuilder.Build()) + { + await host.StartAsync(); + } + + Assert.True(validateCalled); + } + + [Fact] + private async void CanValidateOptionsEagerly_AddOptionsWithValidateOnStart_IValidateOptions() + { + var hostBuilder = CreateHostBuilder(services => + services.AddOptionsWithValidateOnStart() + .Configure(o => o.Boolean = false)); + + using (var host = hostBuilder.Build()) + { + var error = await Assert.ThrowsAsync(async () => + { + await host.StartAsync(); + }); + + ValidateFailure(error, 1, "Boolean != true"); + } + } + + private class ComplexOptionsValidator : IValidateOptions + { + public ValidateOptionsResult Validate(string name, ComplexOptions options) + { + if (options.Boolean == true) + { + return ValidateOptionsResult.Success; + } + return ValidateOptionsResult.Fail("Boolean != true"); + } + } + [Fact] private async Task ValidateOnStart_AddLazyValidation_SkipsValidationWhenHostStarts() { 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 a6df9848465..b99b34aa6a6 100644 --- a/src/libraries/Microsoft.Extensions.Options/ref/Microsoft.Extensions.Options.cs +++ b/src/libraries/Microsoft.Extensions.Options/ref/Microsoft.Extensions.Options.cs @@ -13,6 +13,8 @@ public static partial class OptionsBuilderExtensions public static partial class OptionsServiceCollectionExtensions { public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddOptions(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) { throw null; } + public static Microsoft.Extensions.Options.OptionsBuilder AddOptionsWithValidateOnStart<[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All)] TOptions>(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, string? name = null) where TOptions : class { throw null; } + public static Microsoft.Extensions.Options.OptionsBuilder AddOptionsWithValidateOnStart<[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All)] TOptions, [System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] TValidateOptions>(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, string? name = null) where TOptions : class where TValidateOptions : class, Microsoft.Extensions.Options.IValidateOptions { throw null; } public static Microsoft.Extensions.Options.OptionsBuilder AddOptions(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) where TOptions : class { throw null; } public static Microsoft.Extensions.Options.OptionsBuilder AddOptions(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, string? name) where TOptions : class { throw null; } public static Microsoft.Extensions.DependencyInjection.IServiceCollection ConfigureAll(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Action configureOptions) where TOptions : class { throw null; } @@ -141,7 +143,7 @@ public partial interface IPostConfigureOptions where TOptions : cla } public partial interface IStartupValidator { - public void Validate(); + void Validate(); } public partial interface IValidateOptions where TOptions : class { @@ -323,7 +325,7 @@ public partial class ValidateOptionsResult public static Microsoft.Extensions.Options.ValidateOptionsResult Fail(System.Collections.Generic.IEnumerable failures) { throw null; } public static Microsoft.Extensions.Options.ValidateOptionsResult Fail(string failureMessage) { throw null; } } - public class ValidateOptionsResultBuilder + public partial class ValidateOptionsResultBuilder { public ValidateOptionsResultBuilder() { } public void AddError(string error, string? propertyName = null) { } diff --git a/src/libraries/Microsoft.Extensions.Options/src/OptionsServiceCollectionExtensions.cs b/src/libraries/Microsoft.Extensions.Options/src/OptionsServiceCollectionExtensions.cs index 1d30bd1155b..22224ab96a4 100644 --- a/src/libraries/Microsoft.Extensions.Options/src/OptionsServiceCollectionExtensions.cs +++ b/src/libraries/Microsoft.Extensions.Options/src/OptionsServiceCollectionExtensions.cs @@ -31,6 +31,47 @@ public static IServiceCollection AddOptions(this IServiceCollection services) return services; } + /// + /// Adds services required for using options and enforces options validation check on start rather than in runtime. + /// + /// + /// The extension is called by this method. + /// + /// The options type to be configured. + /// The to add the services to. + /// The name of the options instance. + /// The so that additional calls can be chained. + public static OptionsBuilder AddOptionsWithValidateOnStart< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TOptions>( + this IServiceCollection services, + string? name = null) + where TOptions : class + { + return new OptionsBuilder(services, name ?? Options.Options.DefaultName).ValidateOnStart(); + } + + /// + /// Adds services required for using options and enforces options validation check on start rather than in runtime. + /// + /// + /// The extension is called by this method. + /// + /// The options type to be configured. + /// The validator type. + /// The to add the services to. + /// The name of the options instance. + /// The so that additional calls can be chained. + public static OptionsBuilder AddOptionsWithValidateOnStart< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TOptions, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TValidateOptions>( + this IServiceCollection services, + string? name = null) + where TOptions : class + where TValidateOptions : class, IValidateOptions + { + services.AddOptions().TryAddEnumerable(ServiceDescriptor.Singleton, TValidateOptions>()); + return new OptionsBuilder(services, name ?? Options.Options.DefaultName).ValidateOnStart(); + } /// /// Registers an action used to configure a particular type of options. /// Note: These are run before all . 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 9050bcc2831..342c02ad786 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 @@ -5,9 +5,9 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; -using System.Reflection; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Xunit; namespace Microsoft.Extensions.Options.Tests @@ -321,6 +321,18 @@ public void BadValidatorFailsGracefully() var error = Assert.Throws(() => sp.GetRequiredService>().Value); } + private class ComplexOptionsValidator : IValidateOptions + { + public ValidateOptionsResult Validate(string name, ComplexOptions options) + { + if (options.Boolean == true) + { + return ValidateOptionsResult.Success; + } + return ValidateOptionsResult.Fail("Boolean != true"); + } + } + private class MultiOptionValidator : IValidateOptions, IValidateOptions { private readonly string _allowed; @@ -567,6 +579,34 @@ public void CanValidateOptionsEagerly() ValidateFailure(error, Options.DefaultName, 3, "A validation error has occurred.", "Virtual", "Integer"); } + [Fact] + public void CanValidateOptionsEagerly_AddOptionsWithValidateOnStart() + { + var services = new ServiceCollection(); + services.TryAddEnumerable(ServiceDescriptor.Singleton, ComplexOptionsValidator>()); + services + .AddOptionsWithValidateOnStart() + .Configure(o => o.Boolean = false); + + var sp = services.BuildServiceProvider(); + // This doesn't really verify eager validation since we have no host to start. + var error = Assert.Throws(() => sp.GetRequiredService>().Value); + ValidateFailure(error, Options.DefaultName, 1, "Boolean != true"); + } + + [Fact] + public void CanValidateOptionsEagerly_AddOptionsWithValidateOnStart_IValidateOptions() + { + var services = new ServiceCollection(); + services.AddOptionsWithValidateOnStart() + .Configure(o => o.Boolean = false); + + var sp = services.BuildServiceProvider(); + // This doesn't really verify eager validation since we have no host to start. + var error = Assert.Throws(() => sp.GetRequiredService>().Value); + ValidateFailure(error, Options.DefaultName, 1, "Boolean != true"); + } + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false, Inherited = true)] public class FromAttribute : ValidationAttribute { -- GitLab