Add extension methods for creating OptionsBuilder with ValidateOnStart support (...
authorSteve Harter <steveharter@users.noreply.github.com>
Wed, 9 Aug 2023 14:58:54 +0000 (09:58 -0500)
committerGitHub <noreply@github.com>
Wed, 9 Aug 2023 14:58:54 +0000 (09:58 -0500)
src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/OptionsBuilderExtensionsTests.cs
src/libraries/Microsoft.Extensions.Options/ref/Microsoft.Extensions.Options.cs
src/libraries/Microsoft.Extensions.Options/src/OptionsServiceCollectionExtensions.cs
src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs

index 621d94b..26b61d0 100644 (file)
@@ -5,7 +5,6 @@ using System;
 using System.Linq;
 using System.Threading.Tasks;
 using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Hosting;
 using Microsoft.Extensions.Options;
 using Xunit;
 
@@ -125,6 +124,32 @@ namespace Microsoft.Extensions.Hosting.Tests
         }
 
         [Fact]
+        public async Task ValidateOnStart_NamedOptions_ValidatesFailureOnStart_AddOptionsWithValidateOnStart()
+        {
+            var hostBuilder = CreateHostBuilder(services =>
+            {
+                services.AddOptions().AddSingleton(new FakeService());
+                services
+                    .AddOptionsWithValidateOnStart<FakeSettings>("named")
+                    .Configure<FakeService>((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<OptionsValidationException>(async () =>
+                {
+                    await host.StartAsync();
+                });
+
+                ValidateFailure<FakeSettings>(error, 1, "trigger validation failure for named option!");
+            }
+        }
+
+        [Fact]
         private async Task ValidateOnStart_AddNamedOptionsMultipleTimesForSameType_BothGetTriggered()
         {
             bool firstOptionsBuilderTriggered = false;
@@ -196,6 +221,61 @@ namespace Microsoft.Extensions.Hosting.Tests
         }
 
         [Fact]
+        private async Task ValidateOnStart_AddEagerValidation_DoesValidationWhenHostStartsWithNoFailure_AddOptionsWithValidateOnStart()
+        {
+            bool validateCalled = false;
+
+            var hostBuilder = CreateHostBuilder(services =>
+            {
+                // Adds eager validation using ValidateOnStart
+                services.AddOptionsWithValidateOnStart<ComplexOptions>("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<ComplexOptions, ComplexOptionsValidator>()
+                    .Configure(o => o.Boolean = false));
+
+            using (var host = hostBuilder.Build())
+            {
+                var error = await Assert.ThrowsAsync<OptionsValidationException>(async () =>
+                {
+                    await host.StartAsync();
+                });
+
+                ValidateFailure<ComplexOptions>(error, 1, "Boolean != true");
+            }
+        }
+
+        private class ComplexOptionsValidator : IValidateOptions<ComplexOptions>
+        {
+            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()
         {
             bool validateCalled = false;
index a6df984..b99b34a 100644 (file)
@@ -13,6 +13,8 @@ namespace Microsoft.Extensions.DependencyInjection
     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<TOptions> 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<TOptions> 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<TOptions> { throw null; }
         public static Microsoft.Extensions.Options.OptionsBuilder<TOptions> AddOptions<TOptions>(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) where TOptions : class { throw null; }
         public static Microsoft.Extensions.Options.OptionsBuilder<TOptions> AddOptions<TOptions>(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, string? name) where TOptions : class { throw null; }
         public static Microsoft.Extensions.DependencyInjection.IServiceCollection ConfigureAll<TOptions>(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Action<TOptions> configureOptions) where TOptions : class { throw null; }
@@ -141,7 +143,7 @@ namespace Microsoft.Extensions.Options
     }
     public partial interface IStartupValidator
     {
-        public void Validate();
+        void Validate();
     }
     public partial interface IValidateOptions<TOptions> where TOptions : class
     {
@@ -323,7 +325,7 @@ namespace Microsoft.Extensions.Options
         public static Microsoft.Extensions.Options.ValidateOptionsResult Fail(System.Collections.Generic.IEnumerable<string> 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) { }
index 1d30bd1..22224ab 100644 (file)
@@ -32,6 +32,47 @@ namespace Microsoft.Extensions.DependencyInjection
         }
 
         /// <summary>
+        /// Adds services required for using options and enforces options validation check on start rather than in runtime.
+        /// </summary>
+        /// <remarks>
+        /// The <seealso cref="OptionsBuilderExtensions.ValidateOnStart{TOptions}(OptionsBuilder{TOptions})"/> extension is called by this method.
+        /// </remarks>
+        /// <typeparam name="TOptions">The options type to be configured.</typeparam>
+        /// <param name="services">The <see cref="IServiceCollection"/> to add the services to.</param>
+        /// <param name="name">The name of the options instance.</param>
+        /// <returns>The <see cref="IServiceCollection"/> so that additional calls can be chained.</returns>
+        public static OptionsBuilder<TOptions> AddOptionsWithValidateOnStart<
+            [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TOptions>(
+            this IServiceCollection services,
+            string? name = null)
+            where TOptions : class
+        {
+            return new OptionsBuilder<TOptions>(services, name ?? Options.Options.DefaultName).ValidateOnStart();
+        }
+
+        /// <summary>
+        /// Adds services required for using options and enforces options validation check on start rather than in runtime.
+        /// </summary>
+        /// <remarks>
+        /// The <seealso cref="OptionsBuilderExtensions.ValidateOnStart{TOptions}(OptionsBuilder{TOptions})"/> extension is called by this method.
+        /// </remarks>
+        /// <typeparam name="TOptions">The options type to be configured.</typeparam>
+        /// <typeparam name="TValidateOptions">The <see cref="IValidateOptions{TOptions}"/> validator type.</typeparam>
+        /// <param name="services">The <see cref="IServiceCollection"/> to add the services to.</param>
+        /// <param name="name">The name of the options instance.</param>
+        /// <returns>The <see cref="IServiceCollection"/> so that additional calls can be chained.</returns>
+        public static OptionsBuilder<TOptions> AddOptionsWithValidateOnStart<
+            [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TOptions,
+            [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TValidateOptions>(
+            this IServiceCollection services,
+            string? name = null)
+            where TOptions : class
+            where TValidateOptions : class, IValidateOptions<TOptions>
+        {
+            services.AddOptions().TryAddEnumerable(ServiceDescriptor.Singleton<IValidateOptions<TOptions>, TValidateOptions>());
+            return new OptionsBuilder<TOptions>(services, name ?? Options.Options.DefaultName).ValidateOnStart();
+        }
+        /// <summary>
         /// Registers an action used to configure a particular type of options.
         /// Note: These are run before all <seealso cref="PostConfigure{TOptions}(IServiceCollection, Action{TOptions})"/>.
         /// </summary>
index 9050bcc..342c02a 100644 (file)
@@ -5,9 +5,9 @@ using System;
 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 @@ namespace Microsoft.Extensions.Options.Tests
             var error = Assert.Throws<NotImplementedException>(() => sp.GetRequiredService<IOptions<FakeOptions>>().Value);
         }
 
+        private class ComplexOptionsValidator : IValidateOptions<ComplexOptions>
+        {
+            public ValidateOptionsResult Validate(string name, ComplexOptions options)
+            {
+                if (options.Boolean == true)
+                {
+                    return ValidateOptionsResult.Success;
+                }
+                return ValidateOptionsResult.Fail("Boolean != true");
+            }
+        }
+
         private class MultiOptionValidator : IValidateOptions<ComplexOptions>, IValidateOptions<FakeOptions>
         {
             private readonly string _allowed;
@@ -567,6 +579,34 @@ namespace Microsoft.Extensions.Options.Tests
             ValidateFailure<ComplexOptions>(error, Options.DefaultName, 3, "A validation error has occurred.", "Virtual", "Integer");
         }
 
+        [Fact]
+        public void CanValidateOptionsEagerly_AddOptionsWithValidateOnStart()
+        {
+            var services = new ServiceCollection();
+            services.TryAddEnumerable(ServiceDescriptor.Singleton<IValidateOptions<ComplexOptions>, ComplexOptionsValidator>());
+            services
+                .AddOptionsWithValidateOnStart<ComplexOptions>()
+                .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<OptionsValidationException>(() => sp.GetRequiredService<IOptions<ComplexOptions>>().Value);
+            ValidateFailure<ComplexOptions>(error, Options.DefaultName, 1, "Boolean != true");
+        }
+
+        [Fact]
+        public void CanValidateOptionsEagerly_AddOptionsWithValidateOnStart_IValidateOptions()
+        {
+            var services = new ServiceCollection();
+            services.AddOptionsWithValidateOnStart<ComplexOptions, ComplexOptionsValidator>()
+                .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<OptionsValidationException>(() => sp.GetRequiredService<IOptions<ComplexOptions>>().Value);
+            ValidateFailure<ComplexOptions>(error, Options.DefaultName, 1, "Boolean != true");
+        }
+
         [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
         public class FromAttribute : ValidationAttribute
         {