Move startup validation to the options assembly (#88546)
authorSteve Harter <steveharter@users.noreply.github.com>
Wed, 12 Jul 2023 13:48:39 +0000 (08:48 -0500)
committerGitHub <noreply@github.com>
Wed, 12 Jul 2023 13:48:39 +0000 (08:48 -0500)
13 files changed:
src/libraries/Microsoft.Extensions.Hosting/ref/Microsoft.Extensions.Hosting.cs
src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs
src/libraries/Microsoft.Extensions.Hosting/src/Microsoft.Extensions.Hosting.Forwards.cs [new file with mode: 0644]
src/libraries/Microsoft.Extensions.Hosting/src/ValidationHostedService.cs [deleted file]
src/libraries/Microsoft.Extensions.Hosting/src/ValidatorOptions.cs [deleted file]
src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/LifecycleTests.Start.cs
src/libraries/Microsoft.Extensions.Options/ref/Microsoft.Extensions.Options.cs
src/libraries/Microsoft.Extensions.Options/src/IStartupValidator.cs [new file with mode: 0644]
src/libraries/Microsoft.Extensions.Options/src/Microsoft.Extensions.Options.csproj
src/libraries/Microsoft.Extensions.Options/src/OptionsBuilderExtensions.cs [moved from src/libraries/Microsoft.Extensions.Hosting/src/OptionsBuilderExtensions.cs with 86% similarity]
src/libraries/Microsoft.Extensions.Options/src/StartupValidatorOptions.cs [new file with mode: 0644]
src/libraries/Microsoft.Extensions.Options/src/ValidateOnStart.cs [new file with mode: 0644]
src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsValidationTests.cs

index 0085fca..c6415b4 100644 (file)
@@ -4,13 +4,8 @@
 // Changes to this file must follow the https://aka.ms/api-review process.
 // ------------------------------------------------------------------------------
 
-namespace Microsoft.Extensions.DependencyInjection
-{
-    public static partial class OptionsBuilderExtensions
-    {
-        public static Microsoft.Extensions.Options.OptionsBuilder<TOptions> ValidateOnStart<[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] TOptions>(this Microsoft.Extensions.Options.OptionsBuilder<TOptions> optionsBuilder) where TOptions : class { throw null; }
-    }
-}
+[assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(Microsoft.Extensions.DependencyInjection.OptionsBuilderExtensions))]
+
 namespace Microsoft.Extensions.Hosting
 {
     public enum BackgroundServiceExceptionBehavior
index de1657a..8a242b8 100644 (file)
@@ -62,7 +62,8 @@ namespace Microsoft.Extensions.Hosting.Internal
 
         /// <summary>
         /// Order:
-        ///  IHostLifetime.WaitForStartAsync
+        ///  IHostLifetime.WaitForStartAsync (can abort chain)
+        ///  Services.GetService{IStartupValidator}().Validate() (can abort chain)
         ///  IHostedLifecycleService.StartingAsync
         ///  IHostedService.Start
         ///  IHostedLifecycleService.StartedAsync
@@ -99,14 +100,34 @@ namespace Microsoft.Extensions.Hosting.Internal
                 bool concurrent = _options.ServicesStartConcurrently;
                 bool abortOnFirstException = !concurrent;
 
+                // Call startup validators.
+                IStartupValidator? validator = Services.GetService<IStartupValidator>();
+                if (validator is not null)
+                {
+                    try
+                    {
+                        validator.Validate();
+                    }
+                    catch (Exception ex)
+                    {
+                        exceptions.Add(ex);
+
+                        // Validation errors cause startup to be aborted.
+                        LogAndRethrow();
+                    }
+                }
+
+                // Call StartingAsync().
                 if (_hostedLifecycleServices is not null)
                 {
-                    // Call StartingAsync().
                     await ForeachService(_hostedLifecycleServices, token, concurrent, abortOnFirstException, exceptions,
                         (service, token) => service.StartingAsync(token)).ConfigureAwait(false);
+
+                    // We do not abort on exceptions from StartingAsync.
                 }
 
                 // Call StartAsync().
+                // We do not abort on exceptions from StartAsync.
                 await ForeachService(_hostedServices, token, concurrent, abortOnFirstException, exceptions,
                     async (service, token) =>
                     {
@@ -118,33 +139,40 @@ namespace Microsoft.Extensions.Hosting.Internal
                         }
                     }).ConfigureAwait(false);
 
+                // Call StartedAsync().
+                // We do not abort on exceptions from StartedAsync.
                 if (_hostedLifecycleServices is not null)
                 {
-                    // Call StartedAsync().
                     await ForeachService(_hostedLifecycleServices, token, concurrent, abortOnFirstException, exceptions,
                         (service, token) => service.StartedAsync(token)).ConfigureAwait(false);
                 }
 
-                if (exceptions.Count > 0)
-                {
-                    if (exceptions.Count == 1)
-                    {
-                        // Rethrow if it's a single error
-                        Exception singleException = exceptions[0];
-                        _logger.HostedServiceStartupFaulted(singleException);
-                        ExceptionDispatchInfo.Capture(singleException).Throw();
-                    }
-                    else
-                    {
-                        var ex = new AggregateException("One or more hosted services failed to start.", exceptions);
-                        _logger.HostedServiceStartupFaulted(ex);
-                        throw ex;
-                    }
-                }
+                LogAndRethrow();
 
                 // Call IHostApplicationLifetime.Started
                 // This catches all exceptions and does not re-throw.
                 _applicationLifetime.NotifyStarted();
+
+                // Log and abort if there are exceptions.
+                void LogAndRethrow()
+                {
+                    if (exceptions.Count > 0)
+                    {
+                        if (exceptions.Count == 1)
+                        {
+                            // Rethrow if it's a single error
+                            Exception singleException = exceptions[0];
+                            _logger.HostedServiceStartupFaulted(singleException);
+                            ExceptionDispatchInfo.Capture(singleException).Throw();
+                        }
+                        else
+                        {
+                            var ex = new AggregateException("One or more hosted services failed to start.", exceptions);
+                            _logger.HostedServiceStartupFaulted(ex);
+                            throw ex;
+                        }
+                    }
+                }
             }
 
             _logger.Started();
@@ -244,9 +272,9 @@ namespace Microsoft.Extensions.Hosting.Internal
                     await ForeachService(reversedServices, token, concurrent, abortOnFirstException: false, exceptions, (service, token) =>
                         service.StopAsync(token)).ConfigureAwait(false);
 
+                    // Call StoppedAsync().
                     if (reversedLifetimeServices is not null)
                     {
-                        // Call StoppedAsync().
                         await ForeachService(reversedLifetimeServices, token, concurrent, abortOnFirstException: false, exceptions, (service, token) =>
                             service.StoppedAsync(token)).ConfigureAwait(false);
                     }
diff --git a/src/libraries/Microsoft.Extensions.Hosting/src/Microsoft.Extensions.Hosting.Forwards.cs b/src/libraries/Microsoft.Extensions.Hosting/src/Microsoft.Extensions.Hosting.Forwards.cs
new file mode 100644 (file)
index 0000000..f27da9b
--- /dev/null
@@ -0,0 +1,4 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+[assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(Microsoft.Extensions.DependencyInjection.OptionsBuilderExtensions))]
diff --git a/src/libraries/Microsoft.Extensions.Hosting/src/ValidationHostedService.cs b/src/libraries/Microsoft.Extensions.Hosting/src/ValidationHostedService.cs
deleted file mode 100644 (file)
index d8ad460..0000000
+++ /dev/null
@@ -1,57 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-
-using System;
-using System.Collections.Generic;
-using System.Runtime.ExceptionServices;
-using System.Threading;
-using System.Threading.Tasks;
-using Microsoft.Extensions.Hosting;
-using Microsoft.Extensions.Options;
-
-namespace Microsoft.Extensions.DependencyInjection
-{
-    internal sealed class ValidationHostedService : IHostedService
-    {
-        private readonly IDictionary<(Type, string), Action> _validators;
-
-        public ValidationHostedService(IOptions<ValidatorOptions> validatorOptions)
-        {
-            _validators = validatorOptions?.Value?.Validators ?? throw new ArgumentNullException(nameof(validatorOptions));
-        }
-
-        public Task StartAsync(CancellationToken cancellationToken)
-        {
-            var exceptions = new List<Exception>();
-
-            foreach (var validate in _validators.Values)
-            {
-                try
-                {
-                    // Execute the validation method and catch the validation error
-                    validate();
-                }
-                catch (OptionsValidationException ex)
-                {
-                    exceptions.Add(ex);
-                }
-            }
-
-            if (exceptions.Count == 1)
-            {
-                // Rethrow if it's a single error
-                ExceptionDispatchInfo.Capture(exceptions[0]).Throw();
-            }
-
-            if (exceptions.Count > 1)
-            {
-                // Aggregate if we have many errors
-                throw new AggregateException(exceptions);
-            }
-
-            return Task.CompletedTask;
-        }
-
-        public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
-    }
-}
diff --git a/src/libraries/Microsoft.Extensions.Hosting/src/ValidatorOptions.cs b/src/libraries/Microsoft.Extensions.Hosting/src/ValidatorOptions.cs
deleted file mode 100644 (file)
index 57998c3..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-
-using System;
-using System.Collections.Generic;
-
-namespace Microsoft.Extensions.DependencyInjection
-{
-    internal sealed class ValidatorOptions
-    {
-        // Maps each pair of a) options type and b) options name to a method that forces its evaluation, e.g. IOptionsMonitor<TOptions>.Get(name)
-        public IDictionary<(Type optionsType, string optionsName), Action> Validators { get; } = new Dictionary<(Type, string), Action>();
-    }
-}
index 31692fe..70cee50 100644 (file)
@@ -5,6 +5,7 @@ using System;
 using System.Threading;
 using System.Threading.Tasks;
 using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
 using Xunit;
 
 namespace Microsoft.Extensions.Hosting.Tests
@@ -334,5 +335,24 @@ namespace Microsoft.Extensions.Hosting.Tests
                 Assert.Contains("(ThrowOnStarted)", ex.InnerExceptions[2].Message);
             }
         }
+
+        [Fact]
+        public async Task ValidateOnStartAbortsChain()
+        {
+            ExceptionImpl impl = new(throwAfterAsyncCall: true, throwOnStartup: true, throwOnShutdown: false);
+            var hostBuilder = CreateHostBuilder(services =>
+            {
+                services.AddHostedService((token) => impl)
+                .AddOptions<ComplexOptions>()
+                .Validate(o => o.Boolean)
+                .ValidateOnStart();
+            });
+
+            using (IHost host = hostBuilder.Build())
+            {
+                await Assert.ThrowsAnyAsync<OptionsValidationException>(async () => await host.StartAsync());
+                Assert.False(impl.StartingCalled);
+            }
+        }
     }
 }
index d810360..a6df984 100644 (file)
@@ -6,6 +6,10 @@
 
 namespace Microsoft.Extensions.DependencyInjection
 {
+    public static partial class OptionsBuilderExtensions
+    {
+        public static Microsoft.Extensions.Options.OptionsBuilder<TOptions> ValidateOnStart<[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] TOptions>(this Microsoft.Extensions.Options.OptionsBuilder<TOptions> optionsBuilder) where TOptions : class { throw null; }
+    }
     public static partial class OptionsServiceCollectionExtensions
     {
         public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddOptions(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) { throw null; }
@@ -135,6 +139,10 @@ namespace Microsoft.Extensions.Options
     {
         void PostConfigure(string? name, TOptions options);
     }
+    public partial interface IStartupValidator
+    {
+        public void Validate();
+    }
     public partial interface IValidateOptions<TOptions> where TOptions : class
     {
         Microsoft.Extensions.Options.ValidateOptionsResult Validate(string? name, TOptions options);
@@ -318,12 +326,12 @@ namespace Microsoft.Extensions.Options
     public class ValidateOptionsResultBuilder
     {
         public ValidateOptionsResultBuilder() { }
-        public void AddError(string error, string? propertyName = null) { throw null; }
-        public void AddResult(System.ComponentModel.DataAnnotations.ValidationResult? result) { throw null; }
-        public void AddResults(System.Collections.Generic.IEnumerable<System.ComponentModel.DataAnnotations.ValidationResult?>? results) { throw null; }
-        public void AddResult(ValidateOptionsResult result) { throw null; }
-        public ValidateOptionsResult Build() { throw null; }
-        public void Clear() { throw null; }
+        public void AddError(string error, string? propertyName = null) { }
+        public void AddResult(Microsoft.Extensions.Options.ValidateOptionsResult result) { }
+        public void AddResult(System.ComponentModel.DataAnnotations.ValidationResult? result) { }
+        public void AddResults(System.Collections.Generic.IEnumerable<System.ComponentModel.DataAnnotations.ValidationResult?>? results) { }
+        public Microsoft.Extensions.Options.ValidateOptionsResult Build() { throw null; }
+        public void Clear() { }
     }
     public partial class ValidateOptions<TOptions> : Microsoft.Extensions.Options.IValidateOptions<TOptions> where TOptions : class
     {
diff --git a/src/libraries/Microsoft.Extensions.Options/src/IStartupValidator.cs b/src/libraries/Microsoft.Extensions.Options/src/IStartupValidator.cs
new file mode 100644 (file)
index 0000000..a48a9c1
--- /dev/null
@@ -0,0 +1,18 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.Extensions.Options
+{
+    /// <summary>
+    /// Interface used by hosts to validate options during startup.
+    /// Options are enabled to be validated during startup by calling <see cref="DependencyInjection.OptionsBuilderExtensions.ValidateOnStart{TOptions}(OptionsBuilder{TOptions})"/>.
+    /// </summary>
+    public interface IStartupValidator
+    {
+        /// <summary>
+        /// Calls the <see cref="IValidateOptions{TOptions}"/> validators.
+        /// </summary>
+        /// <exception cref="OptionsValidationException">One or more <see cref="IValidateOptions{TOptions}"/> return failed <see cref="ValidateOptionsResult"/> when validating.</exception>
+        void Validate();
+    }
+}
index adfda8a..7225edf 100644 (file)
@@ -28,6 +28,7 @@
 
   <ItemGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETFramework'">
     <Reference Include="System.ComponentModel.DataAnnotations" />
+    <PackageReference Include="System.ValueTuple" Version="$(SystemValueTupleVersion)" />  
   </ItemGroup>
 
   <ItemGroup>
@@ -25,13 +25,13 @@ namespace Microsoft.Extensions.DependencyInjection
         {
             ThrowHelper.ThrowIfNull(optionsBuilder);
 
-            optionsBuilder.Services.AddHostedService<ValidationHostedService>();
-            optionsBuilder.Services.AddOptions<ValidatorOptions>()
+            optionsBuilder.Services.AddTransient<IStartupValidator, StartupValidator>();
+            optionsBuilder.Services.AddOptions<StartupValidatorOptions>()
                 .Configure<IOptionsMonitor<TOptions>>((vo, options) =>
                 {
                     // This adds an action that resolves the options value to force evaluation
                     // We don't care about the result as duplicates are not important
-                    vo.Validators[(typeof(TOptions), optionsBuilder.Name)] = () => options.Get(optionsBuilder.Name);
+                    vo._validators[(typeof(TOptions), optionsBuilder.Name)] = () => options.Get(optionsBuilder.Name);
                 });
 
             return optionsBuilder;
diff --git a/src/libraries/Microsoft.Extensions.Options/src/StartupValidatorOptions.cs b/src/libraries/Microsoft.Extensions.Options/src/StartupValidatorOptions.cs
new file mode 100644 (file)
index 0000000..d1186f1
--- /dev/null
@@ -0,0 +1,14 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+
+namespace Microsoft.Extensions.Options
+{
+    internal sealed class StartupValidatorOptions
+    {
+        // Maps each pair of a) options type and b) options name to a method that forces its evaluation, e.g. IOptionsMonitor<TOptions>.Get(name)
+        public Dictionary<(Type, string), Action> _validators { get; } = new Dictionary<(Type, string), Action>();
+    }
+}
diff --git a/src/libraries/Microsoft.Extensions.Options/src/ValidateOnStart.cs b/src/libraries/Microsoft.Extensions.Options/src/ValidateOnStart.cs
new file mode 100644 (file)
index 0000000..d2e9729
--- /dev/null
@@ -0,0 +1,54 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Runtime.ExceptionServices;
+using Microsoft.Extensions.Options;
+
+namespace Microsoft.Extensions.Options
+{
+    internal sealed class StartupValidator : IStartupValidator
+    {
+        private readonly StartupValidatorOptions _validatorOptions;
+
+        public StartupValidator(IOptions<StartupValidatorOptions> validators)
+        {
+            _validatorOptions = validators.Value;
+        }
+
+        public void Validate()
+        {
+            List<Exception>? exceptions = null;
+
+            foreach (Action validator in _validatorOptions._validators.Values)
+            {
+                try
+                {
+                    // Execute the validation method and catch the validation error
+                    validator();
+                }
+                catch (OptionsValidationException ex)
+                {
+                    exceptions ??= new();
+                    exceptions.Add(ex);
+                }
+            }
+
+            if (exceptions != null)
+            {
+                if (exceptions.Count == 1)
+                {
+                    // Rethrow if it's a single error
+                    ExceptionDispatchInfo.Capture(exceptions[0]).Throw();
+                }
+
+                if (exceptions.Count > 1)
+                {
+                    // Aggregate if we have many errors
+                    throw new AggregateException(exceptions);
+                }
+            }
+        }
+    }
+}
index 8866038..c082a2e 100644 (file)
@@ -3,6 +3,7 @@
 
 using System;
 using System.Collections.Generic;
+using System.Linq;
 using Microsoft.Extensions.DependencyInjection;
 using Xunit;
 
@@ -34,6 +35,52 @@ namespace Microsoft.Extensions.Options.Tests
         }
 
         [Fact]
+        public void ValidateOnStart_NotCalled()
+        {
+            var services = new ServiceCollection();
+            services.AddOptions<ComplexOptions>()
+                .Validate(o => o.Integer > 12);
+
+            var sp = services.BuildServiceProvider();
+
+            var validator = sp.GetService<IStartupValidator>();
+            Assert.Null(validator);
+        }
+
+        [Fact]
+        public void ValidateOnStart_Called()
+        {
+            var services = new ServiceCollection();
+            services.AddOptions<ComplexOptions>()
+                .Validate(o => o.Integer > 12)
+                .ValidateOnStart();
+
+            var sp = services.BuildServiceProvider();
+
+            var validator = sp.GetService<IStartupValidator>();
+            Assert.NotNull(validator);
+            OptionsValidationException ex = Assert.Throws<OptionsValidationException>(validator.Validate);
+            Assert.Equal(1, ex.Failures.Count());
+        }
+
+        [Fact]
+        public void ValidateOnStart_CalledMultiple()
+        {
+            var services = new ServiceCollection();
+            services.AddOptions<ComplexOptions>()
+                .Validate(o => o.Boolean)
+                .Validate(o => o.Integer > 12)
+                .ValidateOnStart();
+
+            var sp = services.BuildServiceProvider();
+
+            var validator = sp.GetService<IStartupValidator>();
+            Assert.NotNull(validator);
+            OptionsValidationException ex = Assert.Throws<OptionsValidationException>(validator.Validate);
+            Assert.Equal(2, ex.Failures.Count());
+        }
+
+        [Fact]
         public void ValidationResultSkippedIfNameNotMatched()
         {
             var services = new ServiceCollection();