diff --git a/src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs b/src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs index efeb09405ff2ba..4625a1e57685d2 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs @@ -66,6 +66,7 @@ public Host(IServiceProvider services, /// Order: /// IHostLifetime.WaitForStartAsync /// Services.GetService{IStartupValidator}().Validate() + /// Services.GetService{IAsyncStartupValidator}().ValidateAsync() /// IHostedLifecycleService.StartingAsync /// IHostedService.Start /// IHostedLifecycleService.StartedAsync @@ -96,12 +97,31 @@ public async Task StartAsync(CancellationToken cancellationToken = default) _hostedServices ??= Services.GetRequiredService>(); _hostedLifecycleServices = GetHostLifecycles(_hostedServices); - // Call startup validators. + // Two-stage startup validation: + // Stage 1 (sync): Run IStartupValidator.Validate() — iterates _validators dictionary + // (or user's custom implementation if registered). + // If sync validation fails, skip async to avoid expensive I/O on invalid config. + // Stage 2 (async): Run IAsyncStartupValidator.ValidateAsync() — iterates _asyncValidators + // dictionary (or user's custom implementation if registered). + // + // Each interface is resolved independently via DI. TryAddTransient semantics ensure + // user-registered implementations replace the built-in for each interface separately. IStartupValidator? validator = Services.GetService(); validator?.Validate(); + + IAsyncStartupValidator? asyncValidator = Services.GetService(); + if (asyncValidator is not null) + { + await asyncValidator.ValidateAsync(cancellationToken).ConfigureAwait(false); + } } catch (Exception ex) { + if (ex is OperationCanceledException) + { + cancellationToken.ThrowIfCancellationRequested(); + } + // service factory or validation failed, abort startup. exceptions.Add(ex); LogAndRethrow(); 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 70f856d1313cfb..1618851dc52682 100644 --- a/src/libraries/Microsoft.Extensions.Options/ref/Microsoft.Extensions.Options.cs +++ b/src/libraries/Microsoft.Extensions.Options/ref/Microsoft.Extensions.Options.cs @@ -145,10 +145,18 @@ public partial interface IStartupValidator { void Validate(); } + public partial interface IAsyncStartupValidator + { + System.Threading.Tasks.Task ValidateAsync(System.Threading.CancellationToken cancellationToken = default); + } public partial interface IValidateOptions where TOptions : class { Microsoft.Extensions.Options.ValidateOptionsResult Validate(string? name, TOptions options); } + public partial interface IAsyncValidateOptions where TOptions : class + { + System.Threading.Tasks.Task ValidateAsync(string? name, TOptions options, System.Threading.CancellationToken cancellationToken = default); + } public static partial class Options { public static readonly string DefaultName; @@ -184,6 +192,18 @@ public OptionsBuilder(Microsoft.Extensions.DependencyInjection.IServiceCollectio public virtual Microsoft.Extensions.Options.OptionsBuilder Validate(System.Func validation, string failureMessage) where TDep1 : notnull where TDep2 : notnull where TDep3 : notnull where TDep4 : notnull { throw null; } public virtual Microsoft.Extensions.Options.OptionsBuilder Validate(System.Func validation) where TDep1 : notnull where TDep2 : notnull where TDep3 : notnull where TDep4 : notnull where TDep5 : notnull { throw null; } public virtual Microsoft.Extensions.Options.OptionsBuilder Validate(System.Func validation, string failureMessage) where TDep1 : notnull where TDep2 : notnull where TDep3 : notnull where TDep4 : notnull where TDep5 : notnull { 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; } + public virtual Microsoft.Extensions.Options.OptionsBuilder Validate(System.Func> validation, string failureMessage) where TDep : notnull { throw null; } + public virtual Microsoft.Extensions.Options.OptionsBuilder Validate(System.Func> validation) where TDep1 : notnull where TDep2 : notnull { throw null; } + public virtual Microsoft.Extensions.Options.OptionsBuilder Validate(System.Func> validation, string failureMessage) where TDep1 : notnull where TDep2 : notnull { throw null; } + public virtual Microsoft.Extensions.Options.OptionsBuilder Validate(System.Func> validation) where TDep1 : notnull where TDep2 : notnull where TDep3 : notnull { throw null; } + public virtual Microsoft.Extensions.Options.OptionsBuilder Validate(System.Func> validation, string failureMessage) where TDep1 : notnull where TDep2 : notnull where TDep3 : notnull { throw null; } + public virtual Microsoft.Extensions.Options.OptionsBuilder Validate(System.Func> validation) where TDep1 : notnull where TDep2 : notnull where TDep3 : notnull where TDep4 : notnull { throw null; } + public virtual Microsoft.Extensions.Options.OptionsBuilder Validate(System.Func> validation, string failureMessage) where TDep1 : notnull where TDep2 : notnull where TDep3 : notnull where TDep4 : notnull { throw null; } + public virtual Microsoft.Extensions.Options.OptionsBuilder Validate(System.Func> validation) where TDep1 : notnull where TDep2 : notnull where TDep3 : notnull where TDep4 : notnull where TDep5 : notnull { throw null; } + public virtual Microsoft.Extensions.Options.OptionsBuilder Validate(System.Func> validation, string failureMessage) where TDep1 : notnull where TDep2 : notnull where TDep3 : notnull where TDep4 : notnull where TDep5 : notnull { throw null; } } public partial class OptionsCache<[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] TOptions> : Microsoft.Extensions.Options.IOptionsMonitorCache where TOptions : class { @@ -399,4 +419,67 @@ public ValidateOptions(string? name, TDep1 dependency1, TDep2 dependency2, TDep3 public System.Func Validation { get { throw null; } } public Microsoft.Extensions.Options.ValidateOptionsResult Validate(string? name, TOptions options) { throw null; } } + public partial class AsyncValidateOptions : Microsoft.Extensions.Options.IAsyncValidateOptions where TOptions : class + { + public AsyncValidateOptions(string? name, System.Func> validation, string failureMessage) { } + public string FailureMessage { get { throw null; } } + public string? Name { get { throw null; } } + public System.Func> Validation { get { throw null; } } + public System.Threading.Tasks.Task ValidateAsync(string? name, TOptions options, System.Threading.CancellationToken cancellationToken = default) { throw null; } + } + public partial class AsyncValidateOptions : Microsoft.Extensions.Options.IAsyncValidateOptions where TOptions : class + { + public AsyncValidateOptions(string? name, TDep dependency, System.Func> validation, string failureMessage) { } + public TDep Dependency { get { throw null; } } + public string FailureMessage { get { throw null; } } + public string? Name { get { throw null; } } + public System.Func> Validation { get { throw null; } } + public System.Threading.Tasks.Task ValidateAsync(string? name, TOptions options, System.Threading.CancellationToken cancellationToken = default) { throw null; } + } + public partial class AsyncValidateOptions : Microsoft.Extensions.Options.IAsyncValidateOptions where TOptions : class + { + public AsyncValidateOptions(string? name, TDep1 dependency1, TDep2 dependency2, System.Func> validation, string failureMessage) { } + public TDep1 Dependency1 { get { throw null; } } + public TDep2 Dependency2 { get { throw null; } } + public string FailureMessage { get { throw null; } } + public string? Name { get { throw null; } } + public System.Func> Validation { get { throw null; } } + public System.Threading.Tasks.Task ValidateAsync(string? name, TOptions options, System.Threading.CancellationToken cancellationToken = default) { throw null; } + } + public partial class AsyncValidateOptions : Microsoft.Extensions.Options.IAsyncValidateOptions where TOptions : class + { + public AsyncValidateOptions(string? name, TDep1 dependency1, TDep2 dependency2, TDep3 dependency3, System.Func> validation, string failureMessage) { } + public TDep1 Dependency1 { get { throw null; } } + public TDep2 Dependency2 { get { throw null; } } + public TDep3 Dependency3 { get { throw null; } } + public string FailureMessage { get { throw null; } } + public string? Name { get { throw null; } } + public System.Func> Validation { get { throw null; } } + public System.Threading.Tasks.Task ValidateAsync(string? name, TOptions options, System.Threading.CancellationToken cancellationToken = default) { throw null; } + } + public partial class AsyncValidateOptions : Microsoft.Extensions.Options.IAsyncValidateOptions where TOptions : class + { + public AsyncValidateOptions(string? name, TDep1 dependency1, TDep2 dependency2, TDep3 dependency3, TDep4 dependency4, System.Func> validation, string failureMessage) { } + public TDep1 Dependency1 { get { throw null; } } + public TDep2 Dependency2 { get { throw null; } } + public TDep3 Dependency3 { get { throw null; } } + public TDep4 Dependency4 { get { throw null; } } + public string FailureMessage { get { throw null; } } + public string? Name { get { throw null; } } + public System.Func> Validation { get { throw null; } } + public System.Threading.Tasks.Task ValidateAsync(string? name, TOptions options, System.Threading.CancellationToken cancellationToken = default) { throw null; } + } + public partial class AsyncValidateOptions : Microsoft.Extensions.Options.IAsyncValidateOptions where TOptions : class + { + public AsyncValidateOptions(string? name, TDep1 dependency1, TDep2 dependency2, TDep3 dependency3, TDep4 dependency4, TDep5 dependency5, System.Func> validation, string failureMessage) { } + public TDep1 Dependency1 { get { throw null; } } + public TDep2 Dependency2 { get { throw null; } } + public TDep3 Dependency3 { get { throw null; } } + public TDep4 Dependency4 { get { throw null; } } + public TDep5 Dependency5 { get { throw null; } } + public string FailureMessage { get { throw null; } } + public string? Name { get { throw null; } } + public System.Func> Validation { get { throw null; } } + public System.Threading.Tasks.Task ValidateAsync(string? name, TOptions options, System.Threading.CancellationToken cancellationToken = default) { throw null; } + } } diff --git a/src/libraries/Microsoft.Extensions.Options/src/AsyncValidateOptions.cs b/src/libraries/Microsoft.Extensions.Options/src/AsyncValidateOptions.cs new file mode 100644 index 00000000000000..8dbdd76605ce20 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Options/src/AsyncValidateOptions.cs @@ -0,0 +1,455 @@ +// 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.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Options +{ + /// + /// Implementation of . + /// + /// The options type to validate. + public class AsyncValidateOptions : IAsyncValidateOptions where TOptions : class + { + /// + /// Initializes a new instance of . + /// + /// Options name. + /// Asynchronous validation function. + /// Validation failure message. + public AsyncValidateOptions(string? name, Func> validation, string failureMessage) + { + ArgumentNullException.ThrowIfNull(validation); + + Name = name; + Validation = validation; + FailureMessage = failureMessage; + } + + /// + /// Gets the options name. + /// + public string? Name { get; } + + /// + /// Gets the asynchronous validation function. + /// + public Func> Validation { get; } + + /// + /// Gets the error to return when validation fails. + /// + public string FailureMessage { get; } + + /// + /// Asynchronously validates a specific named options instance (or all when is null). + /// + /// The name of the options instance being validated. + /// The options instance. + /// The token to monitor for cancellation requests. + /// The result. + public async Task ValidateAsync(string? name, TOptions options, CancellationToken cancellationToken = default) + { + // null name is used to configure all named options + if (Name is null || name == Name) + { + if (await Validation(options, cancellationToken).ConfigureAwait(false)) + { + return ValidateOptionsResult.Success; + } + + return ValidateOptionsResult.Fail(FailureMessage); + } + + // ignored if not validating this instance + return ValidateOptionsResult.Skip; + } + } + + /// + /// Implementation of . + /// + /// The options type to validate. + /// Dependency type. + public class AsyncValidateOptions : IAsyncValidateOptions where TOptions : class + { + /// + /// Initializes a new instance of . + /// + /// Options name. + /// The dependency. + /// Asynchronous validation function. + /// Validation failure message. + public AsyncValidateOptions(string? name, TDep dependency, Func> validation, string failureMessage) + { + ArgumentNullException.ThrowIfNull(validation); + + Name = name; + Dependency = dependency; + Validation = validation; + FailureMessage = failureMessage; + } + + /// + /// Gets the options name. + /// + public string? Name { get; } + + /// + /// Gets the dependency. + /// + public TDep Dependency { get; } + + /// + /// Gets the asynchronous validation function. + /// + public Func> Validation { get; } + + /// + /// Gets the error to return when validation fails. + /// + public string FailureMessage { get; } + + /// + public async Task ValidateAsync(string? name, TOptions options, CancellationToken cancellationToken = default) + { + if (Name is null || name == Name) + { + if (await Validation(options, Dependency, cancellationToken).ConfigureAwait(false)) + { + return ValidateOptionsResult.Success; + } + + return ValidateOptionsResult.Fail(FailureMessage); + } + + return ValidateOptionsResult.Skip; + } + } + + /// + /// Implementation of . + /// + /// The options type to validate. + /// First dependency type. + /// Second dependency type. + public class AsyncValidateOptions : IAsyncValidateOptions where TOptions : class + { + /// + /// Initializes a new instance of . + /// + /// Options name. + /// The first dependency. + /// The second dependency. + /// Asynchronous validation function. + /// Validation failure message. + public AsyncValidateOptions(string? name, TDep1 dependency1, TDep2 dependency2, Func> validation, string failureMessage) + { + ArgumentNullException.ThrowIfNull(validation); + + Name = name; + Dependency1 = dependency1; + Dependency2 = dependency2; + Validation = validation; + FailureMessage = failureMessage; + } + + /// + /// Gets the options name. + /// + public string? Name { get; } + + /// + /// Gets the first dependency. + /// + public TDep1 Dependency1 { get; } + + /// + /// Gets the second dependency. + /// + public TDep2 Dependency2 { get; } + + /// + /// Gets the asynchronous validation function. + /// + public Func> Validation { get; } + + /// + /// Gets the error to return when validation fails. + /// + public string FailureMessage { get; } + + /// + public async Task ValidateAsync(string? name, TOptions options, CancellationToken cancellationToken = default) + { + if (Name is null || name == Name) + { + if (await Validation(options, Dependency1, Dependency2, cancellationToken).ConfigureAwait(false)) + { + return ValidateOptionsResult.Success; + } + + return ValidateOptionsResult.Fail(FailureMessage); + } + + return ValidateOptionsResult.Skip; + } + } + + /// + /// Implementation of . + /// + /// The options type to validate. + /// First dependency type. + /// Second dependency type. + /// Third dependency type. + public class AsyncValidateOptions : IAsyncValidateOptions where TOptions : class + { + /// + /// Initializes a new instance of . + /// + /// Options name. + /// The first dependency. + /// The second dependency. + /// The third dependency. + /// Asynchronous validation function. + /// Validation failure message. + public AsyncValidateOptions(string? name, TDep1 dependency1, TDep2 dependency2, TDep3 dependency3, Func> validation, string failureMessage) + { + ArgumentNullException.ThrowIfNull(validation); + + Name = name; + Dependency1 = dependency1; + Dependency2 = dependency2; + Dependency3 = dependency3; + Validation = validation; + FailureMessage = failureMessage; + } + + /// + /// Gets the options name. + /// + public string? Name { get; } + + /// + /// Gets the first dependency. + /// + public TDep1 Dependency1 { get; } + + /// + /// Gets the second dependency. + /// + public TDep2 Dependency2 { get; } + + /// + /// Gets the third dependency. + /// + public TDep3 Dependency3 { get; } + + /// + /// Gets the asynchronous validation function. + /// + public Func> Validation { get; } + + /// + /// Gets the error to return when validation fails. + /// + public string FailureMessage { get; } + + /// + public async Task ValidateAsync(string? name, TOptions options, CancellationToken cancellationToken = default) + { + if (Name is null || name == Name) + { + if (await Validation(options, Dependency1, Dependency2, Dependency3, cancellationToken).ConfigureAwait(false)) + { + return ValidateOptionsResult.Success; + } + + return ValidateOptionsResult.Fail(FailureMessage); + } + + return ValidateOptionsResult.Skip; + } + } + + /// + /// Implementation of . + /// + /// The options type to validate. + /// First dependency type. + /// Second dependency type. + /// Third dependency type. + /// Fourth dependency type. + public class AsyncValidateOptions : IAsyncValidateOptions where TOptions : class + { + /// + /// Initializes a new instance of . + /// + /// Options name. + /// The first dependency. + /// The second dependency. + /// The third dependency. + /// The fourth dependency. + /// Asynchronous validation function. + /// Validation failure message. + public AsyncValidateOptions(string? name, TDep1 dependency1, TDep2 dependency2, TDep3 dependency3, TDep4 dependency4, Func> validation, string failureMessage) + { + ArgumentNullException.ThrowIfNull(validation); + + Name = name; + Dependency1 = dependency1; + Dependency2 = dependency2; + Dependency3 = dependency3; + Dependency4 = dependency4; + Validation = validation; + FailureMessage = failureMessage; + } + + /// + /// Gets the options name. + /// + public string? Name { get; } + + /// + /// Gets the first dependency. + /// + public TDep1 Dependency1 { get; } + + /// + /// Gets the second dependency. + /// + public TDep2 Dependency2 { get; } + + /// + /// Gets the third dependency. + /// + public TDep3 Dependency3 { get; } + + /// + /// Gets the fourth dependency. + /// + public TDep4 Dependency4 { get; } + + /// + /// Gets the asynchronous validation function. + /// + public Func> Validation { get; } + + /// + /// Gets the error to return when validation fails. + /// + public string FailureMessage { get; } + + /// + public async Task ValidateAsync(string? name, TOptions options, CancellationToken cancellationToken = default) + { + if (Name is null || name == Name) + { + if (await Validation(options, Dependency1, Dependency2, Dependency3, Dependency4, cancellationToken).ConfigureAwait(false)) + { + return ValidateOptionsResult.Success; + } + + return ValidateOptionsResult.Fail(FailureMessage); + } + + return ValidateOptionsResult.Skip; + } + } + + /// + /// Implementation of . + /// + /// The options type to validate. + /// First dependency type. + /// Second dependency type. + /// Third dependency type. + /// Fourth dependency type. + /// Fifth dependency type. + public class AsyncValidateOptions : IAsyncValidateOptions where TOptions : class + { + /// + /// Initializes a new instance of . + /// + /// Options name. + /// The first dependency. + /// The second dependency. + /// The third dependency. + /// The fourth dependency. + /// The fifth dependency. + /// Asynchronous validation function. + /// Validation failure message. + public AsyncValidateOptions(string? name, TDep1 dependency1, TDep2 dependency2, TDep3 dependency3, TDep4 dependency4, TDep5 dependency5, Func> validation, string failureMessage) + { + ArgumentNullException.ThrowIfNull(validation); + + Name = name; + Dependency1 = dependency1; + Dependency2 = dependency2; + Dependency3 = dependency3; + Dependency4 = dependency4; + Dependency5 = dependency5; + Validation = validation; + FailureMessage = failureMessage; + } + + /// + /// Gets the options name. + /// + public string? Name { get; } + + /// + /// Gets the first dependency. + /// + public TDep1 Dependency1 { get; } + + /// + /// Gets the second dependency. + /// + public TDep2 Dependency2 { get; } + + /// + /// Gets the third dependency. + /// + public TDep3 Dependency3 { get; } + + /// + /// Gets the fourth dependency. + /// + public TDep4 Dependency4 { get; } + + /// + /// Gets the fifth dependency. + /// + public TDep5 Dependency5 { get; } + + /// + /// Gets the asynchronous validation function. + /// + public Func> Validation { get; } + + /// + /// Gets the error to return when validation fails. + /// + public string FailureMessage { get; } + + /// + public async Task ValidateAsync(string? name, TOptions options, CancellationToken cancellationToken = default) + { + if (Name is null || name == Name) + { + if (await Validation(options, Dependency1, Dependency2, Dependency3, Dependency4, Dependency5, cancellationToken).ConfigureAwait(false)) + { + return ValidateOptionsResult.Success; + } + + return ValidateOptionsResult.Fail(FailureMessage); + } + + return ValidateOptionsResult.Skip; + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Options/src/IAsyncStartupValidator.cs b/src/libraries/Microsoft.Extensions.Options/src/IAsyncStartupValidator.cs new file mode 100644 index 00000000000000..93267ec699c346 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Options/src/IAsyncStartupValidator.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Options +{ + /// + /// Used by hosts to asynchronously validate options during startup. + /// + public interface IAsyncStartupValidator + { + /// + /// Calls all registered validators. + /// + /// The token to monitor for cancellation requests. + /// + /// A single validator returns a failed when validating. + /// + /// + /// Multiple validators return failed results when validating. + /// + Task ValidateAsync(CancellationToken cancellationToken = default); + } +} diff --git a/src/libraries/Microsoft.Extensions.Options/src/IAsyncValidateOptions.cs b/src/libraries/Microsoft.Extensions.Options/src/IAsyncValidateOptions.cs new file mode 100644 index 00000000000000..66caef599340b1 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Options/src/IAsyncValidateOptions.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Options +{ + /// + /// Asynchronously validates options. + /// + /// The options type to validate. + public interface IAsyncValidateOptions where TOptions : class + { + /// + /// Asynchronously validates a specified named options instance (or all if is ). + /// + /// The name of the options instance being validated. + /// The options instance. + /// The token to monitor for cancellation requests. + /// The result. + Task ValidateAsync(string? name, TOptions options, CancellationToken cancellationToken = default); + } +} diff --git a/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilder.cs b/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilder.cs index a007a45a0fb7ea..3f7b914bf81ee9 100644 --- a/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilder.cs +++ b/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilder.cs @@ -3,6 +3,8 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; namespace Microsoft.Extensions.Options @@ -570,5 +572,222 @@ public virtual OptionsBuilder Validate + /// Registers an asynchronous validation action for an options type using a default failure message. + /// + /// The asynchronous validation function. + /// The current . + public virtual OptionsBuilder Validate(Func> validation) + => Validate(validation: validation, failureMessage: DefaultValidationFailureMessage); + + /// + /// Registers an asynchronous validation action for an options type. + /// + /// The asynchronous validation function. + /// The failure message to use when validation fails. + /// The current . + public virtual OptionsBuilder Validate(Func> validation, string failureMessage) + { + ArgumentNullException.ThrowIfNull(validation); + + Services.AddSingleton>(new AsyncValidateOptions(Name, validation, failureMessage)); + return this; + } + + /// + /// Registers an asynchronous validation action for an options type using a default failure message. + /// + /// The dependency used by the validation function. + /// The asynchronous validation function. + /// The current . + public virtual OptionsBuilder Validate(Func> validation) where TDep : notnull + => Validate(validation: validation, failureMessage: DefaultValidationFailureMessage); + + /// + /// Registers an asynchronous validation action for an options type. + /// + /// The dependency used by the validation function. + /// The asynchronous validation function. + /// The failure message to use when validation fails. + /// The current . + public virtual OptionsBuilder Validate(Func> validation, string failureMessage) where TDep : notnull + { + ArgumentNullException.ThrowIfNull(validation); + + Services.AddTransient>(sp => + new AsyncValidateOptions(Name, sp.GetRequiredService(), validation, failureMessage)); + return this; + } + + /// + /// Registers an asynchronous validation action for an options type using a default failure message. + /// + /// The first dependency used by the validation function. + /// The second dependency used by the validation function. + /// The asynchronous validation function. + /// The current . + public virtual OptionsBuilder Validate(Func> validation) + where TDep1 : notnull + where TDep2 : notnull + => Validate(validation: validation, failureMessage: DefaultValidationFailureMessage); + + /// + /// Registers an asynchronous validation action for an options type. + /// + /// The first dependency used by the validation function. + /// The second dependency used by the validation function. + /// The asynchronous validation function. + /// The failure message to use when validation fails. + /// The current . + public virtual OptionsBuilder Validate(Func> validation, string failureMessage) + where TDep1 : notnull + where TDep2 : notnull + { + ArgumentNullException.ThrowIfNull(validation); + + Services.AddTransient>(sp => + new AsyncValidateOptions(Name, + sp.GetRequiredService(), + sp.GetRequiredService(), + validation, + failureMessage)); + return this; + } + + /// + /// Registers an asynchronous validation action for an options type using a default failure message. + /// + /// The first dependency used by the validation function. + /// The second dependency used by the validation function. + /// The third dependency used by the validation function. + /// The asynchronous validation function. + /// The current . + public virtual OptionsBuilder Validate(Func> validation) + where TDep1 : notnull + where TDep2 : notnull + where TDep3 : notnull + => Validate(validation: validation, failureMessage: DefaultValidationFailureMessage); + + /// + /// Registers an asynchronous validation action for an options type. + /// + /// The first dependency used by the validation function. + /// The second dependency used by the validation function. + /// The third dependency used by the validation function. + /// The asynchronous validation function. + /// The failure message to use when validation fails. + /// The current . + public virtual OptionsBuilder Validate(Func> validation, string failureMessage) + where TDep1 : notnull + where TDep2 : notnull + where TDep3 : notnull + { + ArgumentNullException.ThrowIfNull(validation); + + Services.AddTransient>(sp => + new AsyncValidateOptions(Name, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + validation, + failureMessage)); + return this; + } + + /// + /// Registers an asynchronous validation action for an options type using a default failure message. + /// + /// The first dependency used by the validation function. + /// The second dependency used by the validation function. + /// The third dependency used by the validation function. + /// The fourth dependency used by the validation function. + /// The asynchronous validation function. + /// The current . + public virtual OptionsBuilder Validate(Func> validation) + where TDep1 : notnull + where TDep2 : notnull + where TDep3 : notnull + where TDep4 : notnull + => Validate(validation: validation, failureMessage: DefaultValidationFailureMessage); + + /// + /// Registers an asynchronous validation action for an options type. + /// + /// The first dependency used by the validation function. + /// The second dependency used by the validation function. + /// The third dependency used by the validation function. + /// The fourth dependency used by the validation function. + /// The asynchronous validation function. + /// The failure message to use when validation fails. + /// The current . + public virtual OptionsBuilder Validate(Func> validation, string failureMessage) + where TDep1 : notnull + where TDep2 : notnull + where TDep3 : notnull + where TDep4 : notnull + { + ArgumentNullException.ThrowIfNull(validation); + + Services.AddTransient>(sp => + new AsyncValidateOptions(Name, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + validation, + failureMessage)); + return this; + } + + /// + /// Registers an asynchronous validation action for an options type using a default failure message. + /// + /// The first dependency used by the validation function. + /// The second dependency used by the validation function. + /// The third dependency used by the validation function. + /// The fourth dependency used by the validation function. + /// The fifth dependency used by the validation function. + /// The asynchronous validation function. + /// The current . + public virtual OptionsBuilder Validate(Func> validation) + where TDep1 : notnull + where TDep2 : notnull + where TDep3 : notnull + where TDep4 : notnull + where TDep5 : notnull + => Validate(validation: validation, failureMessage: DefaultValidationFailureMessage); + + /// + /// Registers an asynchronous validation action for an options type. + /// + /// The first dependency used by the validation function. + /// The second dependency used by the validation function. + /// The third dependency used by the validation function. + /// The fourth dependency used by the validation function. + /// The fifth dependency used by the validation function. + /// The asynchronous validation function. + /// The failure message to use when validation fails. + /// The current . + public virtual OptionsBuilder Validate(Func> validation, string failureMessage) + where TDep1 : notnull + where TDep2 : notnull + where TDep3 : notnull + where TDep4 : notnull + where TDep5 : notnull + { + ArgumentNullException.ThrowIfNull(validation); + + Services.AddTransient>(sp => + new AsyncValidateOptions(Name, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + validation, + failureMessage)); + return this; + } } } diff --git a/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilderExtensions.cs b/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilderExtensions.cs index e893e9e6851da9..205adb41022949 100644 --- a/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilderExtensions.cs +++ b/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilderExtensions.cs @@ -2,7 +2,10 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; @@ -25,6 +28,7 @@ public static class OptionsBuilderExtensions ArgumentNullException.ThrowIfNull(optionsBuilder); optionsBuilder.Services.TryAddTransient(); + optionsBuilder.Services.TryAddTransient(); optionsBuilder.Services.AddOptions() .Configure>((vo, options) => { @@ -33,6 +37,39 @@ public static class OptionsBuilderExtensions vo._validators[(typeof(TOptions), optionsBuilder.Name)] = () => options.Get(optionsBuilder.Name); }); + // Register async validator entries if any IAsyncValidateOptions are registered + optionsBuilder.Services.AddOptions() + .Configure, IEnumerable>>((vo, options, asyncValidators) => + { + // Materialize the validators into a list to check if any are registered + var validators = new List>(asyncValidators); + if (validators.Count > 0) + { + vo._asyncValidators[(typeof(TOptions), optionsBuilder.Name)] = async (CancellationToken ct) => + { + // Retrieve the options value (already created by sync Validate() call) + TOptions optionsValue = options.Get(optionsBuilder.Name); + + // Run async validators + List? failures = null; + foreach (IAsyncValidateOptions validator in validators) + { + ValidateOptionsResult result = await validator.ValidateAsync(optionsBuilder.Name, optionsValue, ct).ConfigureAwait(false); + if (result is not null && result.Failed) + { + failures ??= new List(); + failures.AddRange(result.Failures); + } + } + + if (failures is not null && failures.Count > 0) + { + throw new OptionsValidationException(optionsBuilder.Name, typeof(TOptions), failures); + } + }; + } + }); + return optionsBuilder; } } diff --git a/src/libraries/Microsoft.Extensions.Options/src/StartupValidatorOptions.cs b/src/libraries/Microsoft.Extensions.Options/src/StartupValidatorOptions.cs index d1186f121dcf78..7dbb192de4858d 100644 --- a/src/libraries/Microsoft.Extensions.Options/src/StartupValidatorOptions.cs +++ b/src/libraries/Microsoft.Extensions.Options/src/StartupValidatorOptions.cs @@ -3,6 +3,8 @@ using System; using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; namespace Microsoft.Extensions.Options { @@ -10,5 +12,8 @@ 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.Get(name) public Dictionary<(Type, string), Action> _validators { get; } = new Dictionary<(Type, string), Action>(); + + // Maps each pair of a) options type and b) options name to an async method that forces evaluation and runs async validators + public Dictionary<(Type, string), Func> _asyncValidators { get; } = new Dictionary<(Type, string), Func>(); } } diff --git a/src/libraries/Microsoft.Extensions.Options/src/ValidateOnStart.cs b/src/libraries/Microsoft.Extensions.Options/src/ValidateOnStart.cs index d2e972974edcf3..6a611700c090fa 100644 --- a/src/libraries/Microsoft.Extensions.Options/src/ValidateOnStart.cs +++ b/src/libraries/Microsoft.Extensions.Options/src/ValidateOnStart.cs @@ -4,11 +4,13 @@ using System; using System.Collections.Generic; using System.Runtime.ExceptionServices; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.Options; namespace Microsoft.Extensions.Options { - internal sealed class StartupValidator : IStartupValidator + internal sealed class StartupValidator : IStartupValidator, IAsyncStartupValidator { private readonly StartupValidatorOptions _validatorOptions; @@ -50,5 +52,38 @@ public void Validate() } } } + + public async Task ValidateAsync(CancellationToken cancellationToken = default) + { + // Async validators only — sync validation is handled separately by + // IStartupValidator.Validate() in Host.StartAsync() (two-stage orchestration). + List? exceptions = null; + + foreach (Func asyncValidator in _validatorOptions._asyncValidators.Values) + { + try + { + await asyncValidator(cancellationToken).ConfigureAwait(false); + } + catch (OptionsValidationException ex) + { + exceptions ??= new(); + exceptions.Add(ex); + } + } + + if (exceptions is not null) + { + if (exceptions.Count == 1) + { + ExceptionDispatchInfo.Capture(exceptions[0]).Throw(); + } + + if (exceptions.Count > 1) + { + throw new AggregateException(exceptions); + } + } + } } } diff --git a/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/AsyncOptionsValidationTests.cs b/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/AsyncOptionsValidationTests.cs new file mode 100644 index 00000000000000..b8e514550f546a --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/AsyncOptionsValidationTests.cs @@ -0,0 +1,248 @@ +// 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.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.Extensions.Options.Tests +{ + public class AsyncOptionsValidationTests + { + [Fact] + public async Task AsyncValidateOptions_SkipsWhenNameDoesNotMatch() + { + var validator = new AsyncValidateOptions( + "expected", + (options, ct) => Task.FromResult(false), + "Should not run"); + + ValidateOptionsResult result = await validator.ValidateAsync("other", new FakeOptions(), CancellationToken.None); + + Assert.True(result.Skipped); + } + + [Fact] + public async Task AsyncValidateOptions_ValidatesWhenNameMatches() + { + var validator = new AsyncValidateOptions( + "expected", + (options, ct) => Task.FromResult(false), + "Validation failed"); + + ValidateOptionsResult result = await validator.ValidateAsync("expected", new FakeOptions(), CancellationToken.None); + + Assert.True(result.Failed); + Assert.Contains("Validation failed", result.Failures); + } + + [Fact] + public async Task AsyncValidateOptions_ValidatesAllWhenNameIsNull() + { + var validator = new AsyncValidateOptions( + null, + (options, ct) => Task.FromResult(true), + "fail"); + + ValidateOptionsResult result = await validator.ValidateAsync("any-name", new FakeOptions(), CancellationToken.None); + + Assert.True(result.Succeeded); + } + + [Fact] + public async Task OptionsBuilder_AsyncValidate_RegistersAndExecutes() + { + var services = new ServiceCollection(); + bool asyncRan = false; + + services.AddOptions() + .Configure(o => o.Message = "test") + .Validate(async (FakeOptions o, CancellationToken ct) => + { + asyncRan = true; + return await Task.FromResult(true); + }, "async fail") + .ValidateOnStart(); + + ServiceProvider sp = services.BuildServiceProvider(); + var validator = sp.GetRequiredService(); + + await validator.ValidateAsync(CancellationToken.None); + + Assert.True(asyncRan); + } + + [Fact] + public async Task StartupValidator_TwoStage_RunsBothSyncAndAsyncValidators() + { + var services = new ServiceCollection(); + bool syncRan = false; + bool asyncRan = false; + + services.AddOptions() + .Configure(o => o.Message = "test") + .Validate(o => { syncRan = true; return true; }, "sync fail") + .Validate(async (FakeOptions o, CancellationToken ct) => + { + asyncRan = true; + return await Task.FromResult(true); + }, "async fail") + .ValidateOnStart(); + + ServiceProvider sp = services.BuildServiceProvider(); + + // Two-stage orchestration: Host.cs calls Validate() then ValidateAsync() independently + var syncValidator = sp.GetRequiredService(); + syncValidator.Validate(); + Assert.True(syncRan); + + var asyncValidator = sp.GetRequiredService(); + await asyncValidator.ValidateAsync(CancellationToken.None); + Assert.True(asyncRan); + } + + [Fact] + public async Task StartupValidator_TwoStage_SyncFailureSkipsAsyncValidators() + { + var services = new ServiceCollection(); + bool asyncRan = false; + + services.AddOptions() + .Configure(o => o.Message = "test") + .Validate(o => false, "sync validation failed") + .Validate(async (FakeOptions o, CancellationToken ct) => + { + asyncRan = true; + return await Task.FromResult(true); + }, "async") + .ValidateOnStart(); + + ServiceProvider sp = services.BuildServiceProvider(); + + // Stage 1: Sync throws — in Host.cs, this prevents reaching Stage 2 + var syncValidator = sp.GetRequiredService(); + Assert.Throws(() => syncValidator.Validate()); + + // Stage 2 is never reached because the exception propagates. + // Verify async didn't run (simulating Host.cs short-circuit behavior). + Assert.False(asyncRan); + } + + [Fact] + public async Task StartupValidator_ValidateAsync_OnlyAsyncValidators() + { + var services = new ServiceCollection(); + bool asyncRan = false; + + services.AddOptions() + .Configure(o => o.Message = "test") + .Validate(async (FakeOptions o, CancellationToken ct) => + { + asyncRan = true; + return await Task.FromResult(true); + }, "async fail") + .ValidateOnStart(); + + ServiceProvider sp = services.BuildServiceProvider(); + var validator = sp.GetRequiredService(); + + await validator.ValidateAsync(CancellationToken.None); + + Assert.True(asyncRan); + } + + [Fact] + public async Task StartupValidator_ValidateAsync_AsyncFailureThrowsOptionsValidationException() + { + var services = new ServiceCollection(); + + services.AddOptions() + .Configure(o => o.Message = "test") + .Validate(async (FakeOptions o, CancellationToken ct) => + { + await Task.CompletedTask; + return false; + }, "async validation failed") + .ValidateOnStart(); + + ServiceProvider sp = services.BuildServiceProvider(); + var validator = sp.GetRequiredService(); + + OptionsValidationException ex = await Assert.ThrowsAsync( + () => validator.ValidateAsync(CancellationToken.None)); + Assert.Contains("async validation failed", ex.Failures); + } + + [Fact] + public async Task ValidateOnStart_CustomSyncOnlyValidator_DoesNotThrowInvalidCast() + { + var services = new ServiceCollection(); + + // Register a custom IStartupValidator that does NOT implement IAsyncStartupValidator + services.AddSingleton(new CustomSyncOnlyValidator()); + + services.AddOptions() + .Configure(o => o.Message = "test") + .Validate(async (FakeOptions o, CancellationToken ct) => await Task.FromResult(true), "async") + .ValidateOnStart(); + + ServiceProvider sp = services.BuildServiceProvider(); + + // Should NOT throw InvalidCastException — IAsyncStartupValidator gets its own StartupValidator instance + var asyncValidator = sp.GetRequiredService(); + await asyncValidator.ValidateAsync(CancellationToken.None); + } + + [Fact] + public async Task StartupValidator_ValidateAsync_CancellationTokenPropagated() + { + var services = new ServiceCollection(); + using var cts = new CancellationTokenSource(); + + services.AddOptions() + .Configure(o => o.Message = "test") + .Validate(async (FakeOptions o, CancellationToken ct) => + { + ct.ThrowIfCancellationRequested(); + return await Task.FromResult(true); + }, "async") + .ValidateOnStart(); + + ServiceProvider sp = services.BuildServiceProvider(); + var validator = sp.GetRequiredService(); + + cts.Cancel(); + await Assert.ThrowsAsync(() => validator.ValidateAsync(cts.Token)); + } + + [Theory] + [InlineData("named1")] + [InlineData(null)] + public async Task AsyncValidateOptions_NameMatching_DefaultAndNamed(string? registeredName) + { + var validator = new AsyncValidateOptions( + registeredName, + (options, ct) => Task.FromResult(false), + "fail"); + + ValidateOptionsResult defaultResult = await validator.ValidateAsync(Options.DefaultName, new FakeOptions(), CancellationToken.None); + + if (registeredName is null) + { + Assert.True(defaultResult.Failed); + } + else + { + Assert.True(defaultResult.Skipped); + } + } + + private class CustomSyncOnlyValidator : IStartupValidator + { + public void Validate() { } + } + } +} diff --git a/src/libraries/System.ComponentModel.Annotations/src/System.ComponentModel.Annotations.csproj b/src/libraries/System.ComponentModel.Annotations/src/System.ComponentModel.Annotations.csproj index 86cc5aaee55380..17cc6178d33579 100644 --- a/src/libraries/System.ComponentModel.Annotations/src/System.ComponentModel.Annotations.csproj +++ b/src/libraries/System.ComponentModel.Annotations/src/System.ComponentModel.Annotations.csproj @@ -14,8 +14,8 @@ - +