// 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
/// <summary>
/// Order:
- /// IHostLifetime.WaitForStartAsync
+ /// IHostLifetime.WaitForStartAsync (can abort chain)
+ /// Services.GetService{IStartupValidator}().Validate() (can abort chain)
/// IHostedLifecycleService.StartingAsync
/// IHostedService.Start
/// IHostedLifecycleService.StartedAsync
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) =>
{
}
}).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();
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);
}
--- /dev/null
+// 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))]
+++ /dev/null
-// 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;
- }
-}
+++ /dev/null
-// 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>();
- }
-}
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
using Xunit;
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);
+ }
+ }
}
}
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; }
{
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);
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
{
--- /dev/null
+// 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();
+ }
+}
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETFramework'">
<Reference Include="System.ComponentModel.DataAnnotations" />
+ <PackageReference Include="System.ValueTuple" Version="$(SystemValueTupleVersion)" />
</ItemGroup>
<ItemGroup>
{
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;
--- /dev/null
+// 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>();
+ }
+}
--- /dev/null
+// 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);
+ }
+ }
+ }
+ }
+}
using System;
using System.Collections.Generic;
+using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
}
[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();