diff --git a/src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs b/src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs index efeb09405ff2ba..33e4916cf6e770 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs @@ -96,9 +96,17 @@ public async Task StartAsync(CancellationToken cancellationToken = default) _hostedServices ??= Services.GetRequiredService>(); _hostedLifecycleServices = GetHostLifecycles(_hostedServices); - // Call startup validators. - IStartupValidator? validator = Services.GetService(); - validator?.Validate(); + // Call startup validators (prefer async if available). + IAsyncStartupValidator? asyncValidator = Services.GetService(); + if (asyncValidator is not null) + { + await asyncValidator.ValidateAsync(cancellationToken).ConfigureAwait(false); + } + else + { + IStartupValidator? validator = Services.GetService(); + validator?.Validate(); + } } catch (Exception ex) { diff --git a/src/libraries/Microsoft.Extensions.Options.DataAnnotations/ref/Microsoft.Extensions.Options.DataAnnotations.Async.cs b/src/libraries/Microsoft.Extensions.Options.DataAnnotations/ref/Microsoft.Extensions.Options.DataAnnotations.Async.cs new file mode 100644 index 00000000000000..ae34ebc224c480 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Options.DataAnnotations/ref/Microsoft.Extensions.Options.DataAnnotations.Async.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. +// ------------------------------------------------------------------------------ +// Changes to this file must follow the https://aka.ms/api-review process. +// ------------------------------------------------------------------------------ + +namespace Microsoft.Extensions.DependencyInjection +{ + public static partial class OptionsBuilderDataAnnotationsExtensions + { + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCodeAttribute("Uses DataAnnotationValidateOptionsAsync which is unsafe given that the options type passed in when calling Validate cannot be statically analyzed so its members may be trimmed.")] + public static Microsoft.Extensions.Options.OptionsBuilder ValidateDataAnnotationsAsync<[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.NonPublicProperties)] TOptions>(this Microsoft.Extensions.Options.OptionsBuilder optionsBuilder) where TOptions : class { throw null; } + } +} +namespace Microsoft.Extensions.Options +{ + public partial class DataAnnotationValidateOptionsAsync<[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.NonPublicProperties)] TOptions> : Microsoft.Extensions.Options.IAsyncValidateOptions where TOptions : class + { + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCodeAttribute("The implementation of ValidateAsync method on this type will walk through all properties of the passed in options object, and its type cannot be statically analyzed so its members may be trimmed.")] + public DataAnnotationValidateOptionsAsync(string? name) { } + public string? Name { 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.DataAnnotations/ref/Microsoft.Extensions.Options.DataAnnotations.csproj b/src/libraries/Microsoft.Extensions.Options.DataAnnotations/ref/Microsoft.Extensions.Options.DataAnnotations.csproj index c5420188cbc6fe..ca2147b5729a0c 100644 --- a/src/libraries/Microsoft.Extensions.Options.DataAnnotations/ref/Microsoft.Extensions.Options.DataAnnotations.csproj +++ b/src/libraries/Microsoft.Extensions.Options.DataAnnotations/ref/Microsoft.Extensions.Options.DataAnnotations.csproj @@ -7,6 +7,10 @@ + + + + diff --git a/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/DataAnnotationValidateOptionsAsync.cs b/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/DataAnnotationValidateOptionsAsync.cs new file mode 100644 index 00000000000000..5d26abcfd2699c --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/DataAnnotationValidateOptionsAsync.cs @@ -0,0 +1,156 @@ +// 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; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Options +{ + /// + /// Implementation of that uses DataAnnotation's + /// for asynchronous validation. + /// + /// The instance being validated. + /// + /// Async validators run only at startup when used with ValidateOnStart. + /// reload validation uses only synchronous validators. + /// + public class DataAnnotationValidateOptionsAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] TOptions> + : IAsyncValidateOptions where TOptions : class + { + /// + /// Initializes a new instance of . + /// + /// The name of the option. + [RequiresUnreferencedCode("The implementation of ValidateAsync method on this type will walk through all properties of the passed in options object, and its type cannot be " + + "statically analyzed so its members may be trimmed.")] + public DataAnnotationValidateOptionsAsync(string? name) + { + Name = name; + } + + /// + /// Gets the options name. + /// + public string? Name { 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. + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026:RequiresUnreferencedCode", + Justification = "Suppressing the warnings on this method since the constructor of the type is annotated as RequiresUnreferencedCode.")] + public async Task ValidateAsync(string? name, TOptions options, CancellationToken cancellationToken = default) + { + // Null name is used to configure all named options. + if (Name is not null && Name != name) + { + // Ignored if not validating this instance. + return ValidateOptionsResult.Skip; + } + + // Ensure options are provided to validate against + ArgumentNullException.ThrowIfNull(options); + + var validationResults = new List(); + HashSet? visited = null; + List? errors = null; + + (bool success, errors) = await TryValidateOptionsAsync(options, options.GetType().Name, validationResults, errors, visited, cancellationToken).ConfigureAwait(false); + + if (success) + { + return ValidateOptionsResult.Success; + } + + Debug.Assert(errors is not null && errors.Count > 0); + + return ValidateOptionsResult.Fail(errors); + } + + [RequiresUnreferencedCode("This method on this type will walk through all properties of the passed in options object, and its type cannot be " + + "statically analyzed so its members may be trimmed.")] + private static async Task<(bool success, List? errors)> TryValidateOptionsAsync( + object options, + string qualifiedName, + List results, + List? errors, + HashSet? visited, + CancellationToken cancellationToken) + { + Debug.Assert(options is not null); + + if (visited is not null && visited.Contains(options)) + { + return (true, errors); + } + + results.Clear(); + + bool res = await Validator.TryValidateObjectAsync(options, new ValidationContext(options), results, validateAllProperties: true, cancellationToken).ConfigureAwait(false); + if (!res) + { + errors ??= new List(); + + foreach (ValidationResult result in results!) + { + errors.Add($"DataAnnotation validation failed for '{qualifiedName}' members: '{string.Join(",", result.MemberNames)}' with the error: '{result.ErrorMessage}'."); + } + } + + foreach (PropertyInfo propertyInfo in options.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + // Indexers are properties which take parameters. Ignore them. + if (propertyInfo.GetMethod is null || propertyInfo.GetMethod.GetParameters().Length > 0) + { + continue; + } + + object? value = propertyInfo!.GetValue(options); + + if (value is null) + { + continue; + } + + if (propertyInfo.GetCustomAttribute() is not null) + { + visited ??= new HashSet(ReferenceEqualityComparer.Instance); + visited.Add(options); + + results ??= new List(); + bool innerRes; + (innerRes, errors) = await TryValidateOptionsAsync(value, $"{qualifiedName}.{propertyInfo.Name}", results, errors, visited, cancellationToken).ConfigureAwait(false); + res = innerRes && res; + } + else if (value is IEnumerable enumerable && + propertyInfo.GetCustomAttribute() is not null) + { + visited ??= new HashSet(ReferenceEqualityComparer.Instance); + visited.Add(options); + results ??= new List(); + + int index = 0; + foreach (object item in enumerable) + { + bool innerRes; + (innerRes, errors) = await TryValidateOptionsAsync(item, $"{qualifiedName}.{propertyInfo.Name}[{index++}]", results, errors, visited, cancellationToken).ConfigureAwait(false); + res = innerRes && res; + } + } + } + + return (res, errors); + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/Microsoft.Extensions.Options.DataAnnotations.csproj b/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/Microsoft.Extensions.Options.DataAnnotations.csproj index 4d53e1b452d9db..550bb84c0edd80 100644 --- a/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/Microsoft.Extensions.Options.DataAnnotations.csproj +++ b/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/Microsoft.Extensions.Options.DataAnnotations.csproj @@ -16,6 +16,11 @@ + + + + + diff --git a/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/OptionsBuilderDataAnnotationsExtensions.Async.cs b/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/OptionsBuilderDataAnnotationsExtensions.Async.cs new file mode 100644 index 00000000000000..8619814167c492 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/OptionsBuilderDataAnnotationsExtensions.Async.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.DependencyInjection +{ + public static partial class OptionsBuilderDataAnnotationsExtensions + { + /// + /// Register this options instance for asynchronous validation of its DataAnnotations. + /// + /// The options type to be configured. + /// The options builder to add the services to. + /// The so that additional calls can be chained. + /// + /// Async validators run only at startup when used with ValidateOnStart. + /// reload validation uses only synchronous validators. + /// + [RequiresUnreferencedCode("Uses DataAnnotationValidateOptionsAsync which is unsafe given that the options type passed in when calling Validate cannot be statically analyzed so its" + + " members may be trimmed.")] + public static OptionsBuilder ValidateDataAnnotationsAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] TOptions>(this OptionsBuilder optionsBuilder) where TOptions : class + { + optionsBuilder.Services.AddSingleton>(new DataAnnotationValidateOptionsAsync(optionsBuilder.Name)); + return optionsBuilder; + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/OptionsBuilderDataAnnotationsExtensions.cs b/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/OptionsBuilderDataAnnotationsExtensions.cs index 55f8b33a70311d..b5a4621b8700b3 100644 --- a/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/OptionsBuilderDataAnnotationsExtensions.cs +++ b/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/OptionsBuilderDataAnnotationsExtensions.cs @@ -9,7 +9,7 @@ namespace Microsoft.Extensions.DependencyInjection /// /// Extension methods for adding configuration related options services to the DI container via . /// - public static class OptionsBuilderDataAnnotationsExtensions + public static partial class OptionsBuilderDataAnnotationsExtensions { /// /// Register this options instance for validation of its DataAnnotations. 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..b2637779d3b4e4 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Options/src/IAsyncStartupValidator.cs @@ -0,0 +1,23 @@ +// 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. + /// + /// One or more validators return failed 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..b435a048485c3e 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(sp => (IAsyncStartupValidator)sp.GetRequiredService()); 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..e9a248ebaa3305 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,40 @@ public void Validate() } } } + + public async Task ValidateAsync(CancellationToken cancellationToken = default) + { + // Run sync validators first (this triggers options creation + sync validation) + Validate(); + + // Then run async validators + 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/System.ComponentModel.Annotations/ref/System.ComponentModel.Annotations.cs b/src/libraries/System.ComponentModel.Annotations/ref/System.ComponentModel.Annotations.cs index c35c20bc39ea48..04696683bc93e2 100644 --- a/src/libraries/System.ComponentModel.Annotations/ref/System.ComponentModel.Annotations.cs +++ b/src/libraries/System.ComponentModel.Annotations/ref/System.ComponentModel.Annotations.cs @@ -32,6 +32,16 @@ public AssociationAttribute(string name, string thisKey, string otherKey) { } public string ThisKey { get { throw null; } } public System.Collections.Generic.IEnumerable ThisKeyMembers { get { throw null; } } } + public abstract partial class AsyncValidationAttribute : System.ComponentModel.DataAnnotations.ValidationAttribute + { + protected AsyncValidationAttribute() { } + protected AsyncValidationAttribute(System.Func errorMessageAccessor) { } + protected AsyncValidationAttribute(string errorMessage) { } + public System.Threading.Tasks.Task GetValidationResultAsync(object? value, System.ComponentModel.DataAnnotations.ValidationContext validationContext, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public sealed override bool IsValid(object? value) { throw null; } + protected abstract override System.ComponentModel.DataAnnotations.ValidationResult? IsValid(object? value, System.ComponentModel.DataAnnotations.ValidationContext validationContext); + protected abstract System.Threading.Tasks.Task IsValidAsync(object? value, System.ComponentModel.DataAnnotations.ValidationContext validationContext, System.Threading.CancellationToken cancellationToken); + } [System.AttributeUsageAttribute(System.AttributeTargets.Field | System.AttributeTargets.Parameter | System.AttributeTargets.Property, AllowMultiple=false)] public partial class Base64StringAttribute : System.ComponentModel.DataAnnotations.ValidationAttribute { @@ -196,6 +206,10 @@ public FilterUIHintAttribute(string filterUIHint, string? presentationLayer, par public override bool Equals([System.Diagnostics.CodeAnalysis.NotNullWhenAttribute(true)] object? obj) { throw null; } public override int GetHashCode() { throw null; } } + public partial interface IAsyncValidatableObject : System.ComponentModel.DataAnnotations.IValidatableObject + { + System.Collections.Generic.IAsyncEnumerable ValidateAsync(System.ComponentModel.DataAnnotations.ValidationContext validationContext, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + } public partial interface IValidatableObject { System.Collections.Generic.IEnumerable Validate(System.ComponentModel.DataAnnotations.ValidationContext validationContext); @@ -386,16 +400,30 @@ public static partial class Validator public static bool TryValidateObject(object instance, System.ComponentModel.DataAnnotations.ValidationContext validationContext, System.Collections.Generic.ICollection? validationResults) { throw null; } [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCodeAttribute("Constructing a ValidationContext without a display name is not trim-safe because it uses reflection to discover the type of the instance being validated in order to resolve the DisplayNameAttribute when a display name is not provided.")] public static bool TryValidateObject(object instance, System.ComponentModel.DataAnnotations.ValidationContext validationContext, System.Collections.Generic.ICollection? validationResults, bool validateAllProperties) { throw null; } + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCodeAttribute("Constructing a ValidationContext without a display name is not trim-safe because it uses reflection to discover the type of the instance being validated in order to resolve the DisplayNameAttribute when a display name is not provided.")] + public static System.Threading.Tasks.Task TryValidateObjectAsync(object instance, System.ComponentModel.DataAnnotations.ValidationContext validationContext, System.Collections.Generic.ICollection? validationResults, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCodeAttribute("Constructing a ValidationContext without a display name is not trim-safe because it uses reflection to discover the type of the instance being validated in order to resolve the DisplayNameAttribute when a display name is not provided.")] + public static System.Threading.Tasks.Task TryValidateObjectAsync(object instance, System.ComponentModel.DataAnnotations.ValidationContext validationContext, System.Collections.Generic.ICollection? validationResults, bool validateAllProperties, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCodeAttribute("The Type of validationContext.ObjectType cannot be statically discovered.")] public static bool TryValidateProperty(object? value, System.ComponentModel.DataAnnotations.ValidationContext validationContext, System.Collections.Generic.ICollection? validationResults) { throw null; } + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCodeAttribute("The Type of validationContext.ObjectType cannot be statically discovered.")] + public static System.Threading.Tasks.Task TryValidatePropertyAsync(object? value, System.ComponentModel.DataAnnotations.ValidationContext validationContext, System.Collections.Generic.ICollection? validationResults, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static bool TryValidateValue(object? value, System.ComponentModel.DataAnnotations.ValidationContext validationContext, System.Collections.Generic.ICollection? validationResults, System.Collections.Generic.IEnumerable validationAttributes) { throw null; } + public static System.Threading.Tasks.Task TryValidateValueAsync(object? value, System.ComponentModel.DataAnnotations.ValidationContext validationContext, System.Collections.Generic.ICollection? validationResults, System.Collections.Generic.IEnumerable validationAttributes, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCodeAttribute("Constructing a ValidationContext without a display name is not trim-safe because it uses reflection to discover the type of the instance being validated in order to resolve the DisplayNameAttribute when a display name is not provided.")] public static void ValidateObject(object instance, System.ComponentModel.DataAnnotations.ValidationContext validationContext) { } [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCodeAttribute("Constructing a ValidationContext without a display name is not trim-safe because it uses reflection to discover the type of the instance being validated in order to resolve the DisplayNameAttribute when a display name is not provided.")] public static void ValidateObject(object instance, System.ComponentModel.DataAnnotations.ValidationContext validationContext, bool validateAllProperties) { } + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCodeAttribute("Constructing a ValidationContext without a display name is not trim-safe because it uses reflection to discover the type of the instance being validated in order to resolve the DisplayNameAttribute when a display name is not provided.")] + public static System.Threading.Tasks.Task ValidateObjectAsync(object instance, System.ComponentModel.DataAnnotations.ValidationContext validationContext, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCodeAttribute("Constructing a ValidationContext without a display name is not trim-safe because it uses reflection to discover the type of the instance being validated in order to resolve the DisplayNameAttribute when a display name is not provided.")] + public static System.Threading.Tasks.Task ValidateObjectAsync(object instance, System.ComponentModel.DataAnnotations.ValidationContext validationContext, bool validateAllProperties, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCodeAttribute("The Type of validationContext.ObjectType cannot be statically discovered.")] public static void ValidateProperty(object? value, System.ComponentModel.DataAnnotations.ValidationContext validationContext) { } + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCodeAttribute("The Type of validationContext.ObjectType cannot be statically discovered.")] + public static System.Threading.Tasks.Task ValidatePropertyAsync(object? value, System.ComponentModel.DataAnnotations.ValidationContext validationContext, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static void ValidateValue(object? value, System.ComponentModel.DataAnnotations.ValidationContext validationContext, System.Collections.Generic.IEnumerable validationAttributes) { } + public static System.Threading.Tasks.Task ValidateValueAsync(object? value, System.ComponentModel.DataAnnotations.ValidationContext validationContext, System.Collections.Generic.IEnumerable validationAttributes, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } } } namespace System.ComponentModel.DataAnnotations.Schema 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 cb998febaaab08..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,6 +14,7 @@ + @@ -31,6 +32,7 @@ + diff --git a/src/libraries/System.ComponentModel.Annotations/src/System/ComponentModel/DataAnnotations/AsyncValidationAttribute.cs b/src/libraries/System.ComponentModel.Annotations/src/System/ComponentModel/DataAnnotations/AsyncValidationAttribute.cs new file mode 100644 index 00000000000000..7939393a1a7f8b --- /dev/null +++ b/src/libraries/System.ComponentModel.Annotations/src/System/ComponentModel/DataAnnotations/AsyncValidationAttribute.cs @@ -0,0 +1,118 @@ +// 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 System.ComponentModel.DataAnnotations +{ + /// + /// Base class for validation attributes that require asynchronous operations, such as database lookups or API calls. + /// + public abstract class AsyncValidationAttribute : ValidationAttribute + { + /// + /// Default constructor for any async validation attribute. + /// + protected AsyncValidationAttribute() + { + } + + /// + /// Constructor that accepts a fixed validation error message. + /// + /// A non-localized error message to use in . + protected AsyncValidationAttribute(string errorMessage) + : base(errorMessage) + { + } + + /// + /// Allows for providing a resource accessor function that will be used by the + /// property to retrieve the error message. + /// + /// The that will return an error message. + protected AsyncValidationAttribute(Func errorMessageAccessor) + : base(errorMessageAccessor) + { + } + + /// + /// Override of the base class method. + /// Subclasses must provide a synchronous validation implementation or throw an appropriate exception + /// to indicate that synchronous validation is not supported. + /// + /// The value to validate. + /// + /// A instance that provides context about the validation operation, + /// such as the object and member being validated. + /// + /// + /// when validation is valid. + /// An instance of when validation is invalid. + /// + protected abstract override ValidationResult? IsValid(object? value, ValidationContext validationContext); + + /// + /// Override this method in subclasses to implement asynchronous validation logic. + /// + /// The value to validate. + /// + /// A instance that provides context about the validation operation, + /// such as the object and member being validated. + /// + /// A to observe while waiting for the task to complete. + /// + /// A representing the asynchronous validation operation. + /// When validation is valid, the result is (i.e. ). + /// When validation is invalid, the result is an instance of . + /// + protected abstract Task IsValidAsync( + object? value, + ValidationContext validationContext, + CancellationToken cancellationToken); + + /// + /// Sealed override of that delegates to the + /// overload so that implementations + /// only need to provide a single synchronous fallback via + /// . + /// + /// The value to validate. + /// + /// if the value is valid; otherwise, . + /// + public sealed override bool IsValid(object? value) + => IsValid(value, null!) == ValidationResult.Success; + + /// + /// Tests whether the given is valid asynchronously with respect to the current + /// validation attribute without throwing a . + /// + /// The value to validate. + /// + /// A instance that provides context about the validation operation, + /// such as the object and member being validated. + /// + /// A to observe while waiting for the task to complete. + /// + /// A representing the asynchronous validation operation. + /// When validation is valid, the result is (i.e. ). + /// When validation is invalid, the result is an instance of . + /// + /// is thrown if the current attribute is malformed. + /// When is null. + public async Task GetValidationResultAsync( + object? value, + ValidationContext validationContext, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(validationContext); + + ValidationResult? result = await IsValidAsync(value, validationContext, cancellationToken).ConfigureAwait(false); + + return EnsureValidationResultErrorMessage(result, validationContext); + } + + } +} diff --git a/src/libraries/System.ComponentModel.Annotations/src/System/ComponentModel/DataAnnotations/IAsyncValidatableObject.cs b/src/libraries/System.ComponentModel.Annotations/src/System/ComponentModel/DataAnnotations/IAsyncValidatableObject.cs new file mode 100644 index 00000000000000..a5f55523aa1813 --- /dev/null +++ b/src/libraries/System.ComponentModel.Annotations/src/System/ComponentModel/DataAnnotations/IAsyncValidatableObject.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Threading; + +namespace System.ComponentModel.DataAnnotations +{ + /// + /// Provides a way for an object to be validated asynchronously. + /// Inherits from . Implementors must provide both + /// and . + /// + public interface IAsyncValidatableObject : IValidatableObject + { + /// + /// Determines whether the specified object is valid asynchronously, yielding + /// validation results as each check completes. + /// + /// + /// A instance that provides context about the validation operation, + /// such as the object and member being validated. + /// + /// A to observe while waiting for the task to complete. + /// + /// An that yields instances + /// as each validation check completes. + /// + IAsyncEnumerable ValidateAsync( + ValidationContext validationContext, + CancellationToken cancellationToken = default); + } +} diff --git a/src/libraries/System.ComponentModel.Annotations/src/System/ComponentModel/DataAnnotations/ValidationAttribute.cs b/src/libraries/System.ComponentModel.Annotations/src/System/ComponentModel/DataAnnotations/ValidationAttribute.cs index fbb87ed8d33bc8..d062e0237181af 100644 --- a/src/libraries/System.ComponentModel.Annotations/src/System/ComponentModel/DataAnnotations/ValidationAttribute.cs +++ b/src/libraries/System.ComponentModel.Annotations/src/System/ComponentModel/DataAnnotations/ValidationAttribute.cs @@ -136,6 +136,13 @@ protected string ErrorMessageString /// to perform validation. /// Base class returns false. Override in child classes as appropriate. /// + /// + /// This property is only consulted by the synchronous validation path. + /// The asynchronous validation entry point + /// + /// always requires a non-null parameter, + /// so this property has no effect when validating via the asynchronous pipeline. + /// public virtual bool RequiresValidationContext => false; #endregion @@ -304,6 +311,20 @@ private protected ValidationResult CreateFailedValidationResult(ValidationContex return new ValidationResult(FormatErrorMessage(validationContext.DisplayName), memberNames); } + private protected ValidationResult? EnsureValidationResultErrorMessage( + ValidationResult? result, + ValidationContext validationContext) + { + if (result is not null && string.IsNullOrEmpty(result.ErrorMessage)) + { + string errorMessage = FormatErrorMessage(validationContext.DisplayName); + + return new ValidationResult(errorMessage, result.MemberNames); + } + + return result; + } + #endregion #region Protected & Public Methods @@ -433,17 +454,7 @@ public virtual bool IsValid(object? value) var result = IsValid(value, validationContext); - // If validation fails, we want to ensure we have a ValidationResult that guarantees it has an ErrorMessage - if (result != null) - { - if (string.IsNullOrEmpty(result.ErrorMessage)) - { - var errorMessage = FormatErrorMessage(validationContext.DisplayName); - result = new ValidationResult(errorMessage, result.MemberNames); - } - } - - return result; + return EnsureValidationResultErrorMessage(result, validationContext); } /// diff --git a/src/libraries/System.ComponentModel.Annotations/src/System/ComponentModel/DataAnnotations/ValidationContext.cs b/src/libraries/System.ComponentModel.Annotations/src/System/ComponentModel/DataAnnotations/ValidationContext.cs index 75ef95a7cd6416..45fce10a86b028 100644 --- a/src/libraries/System.ComponentModel.Annotations/src/System/ComponentModel/DataAnnotations/ValidationContext.cs +++ b/src/libraries/System.ComponentModel.Annotations/src/System/ComponentModel/DataAnnotations/ValidationContext.cs @@ -205,6 +205,14 @@ public string DisplayName /// This property will never be null, but the dictionary may be empty. Changes made /// to items in this dictionary will never affect the original dictionary specified in the constructor. /// + /// + /// is designed as a read-only input channel populated before validation + /// begins. The validation pipeline does not guarantee attribute execution order (beyond + /// priority), and no built-in attribute mutates + /// during validation. Custom validators should treat + /// as read-only during validation execution. Mutating from within a + /// validator is unsupported and may produce race conditions under parallel async validation. + /// public IDictionary Items => _items; #endregion diff --git a/src/libraries/System.ComponentModel.Annotations/src/System/ComponentModel/DataAnnotations/Validator.cs b/src/libraries/System.ComponentModel.Annotations/src/System/ComponentModel/DataAnnotations/Validator.cs index 6b888d2a2691b9..7a1fafd3e13ff9 100644 --- a/src/libraries/System.ComponentModel.Annotations/src/System/ComponentModel/DataAnnotations/Validator.cs +++ b/src/libraries/System.ComponentModel.Annotations/src/System/ComponentModel/DataAnnotations/Validator.cs @@ -5,6 +5,8 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Threading; +using System.Threading.Tasks; namespace System.ComponentModel.DataAnnotations { @@ -321,6 +323,606 @@ public static void ValidateValue(object? value, ValidationContext validationCont } } + /// + /// Asynchronously tests whether the given property value is valid. + /// + /// + /// This method will test each associated with the property + /// identified by . If is non-null, + /// this method will add a to it for each validation failure. + /// + /// If there is a found on the property, it will be evaluated before all other + /// validation attributes. If the required validator fails then validation will abort, adding that single + /// failure into the when applicable, returning a value of false. + /// + /// + /// Any instances will be evaluated asynchronously after all synchronous + /// validation attributes have passed. + /// + /// + /// The value to test. + /// + /// Describes the property member to validate and provides services and context for the validators. + /// + /// Optional collection to receive s for the failures. + /// A to observe while waiting for the task to complete. + /// A that is true if the value is valid, false if any validation errors are encountered. + /// + /// When the of is not a valid property. + /// + [RequiresUnreferencedCode("The Type of validationContext.ObjectType cannot be statically discovered.")] + public static async Task TryValidatePropertyAsync( + object? value, + ValidationContext validationContext, + ICollection? validationResults, + CancellationToken cancellationToken = default) + { + var propertyType = _store.GetPropertyType(validationContext); + var propertyName = validationContext.MemberName!; + EnsureValidPropertyType(propertyName, propertyType, value); + + var result = true; + var breakOnFirstError = (validationResults == null); + + var attributes = _store.GetPropertyValidationAttributes(validationContext); + + foreach (var err in await GetValidationErrorsAsync(value, validationContext, attributes, breakOnFirstError, cancellationToken).ConfigureAwait(false)) + { + result = false; + + validationResults?.Add(err.ValidationResult); + } + + return result; + } + + /// + /// Asynchronously tests whether the given object instance is valid. + /// + /// + /// This method evaluates all s attached to the object instance's type. It also + /// checks to ensure all properties marked with are set. It does not validate the + /// property values of the object. + /// + /// The object instance to test. It cannot be null. + /// Describes the object to validate and provides services and context for the validators. + /// Optional collection to receive s for the failures. + /// A to observe while waiting for the task to complete. + /// A that is true if the object is valid, false if any validation errors are encountered. + /// When is null. + /// + /// When doesn't match the + /// on . + /// + [RequiresUnreferencedCode(ValidationContext.InstanceTypeNotStaticallyDiscovered)] + public static Task TryValidateObjectAsync( + object instance, + ValidationContext validationContext, + ICollection? validationResults, + CancellationToken cancellationToken = default) => + TryValidateObjectAsync(instance, validationContext, validationResults, validateAllProperties: false, cancellationToken); + + /// + /// Asynchronously tests whether the given object instance is valid. + /// + /// + /// This method evaluates all s attached to the object instance's type. It also + /// checks to ensure all properties marked with are set. If + /// + /// is true, this method will also evaluate the s for all the immediate + /// properties of this object. This process is not recursive. + /// + /// When is true, properties are validated + /// in parallel. Within each property, synchronous attributes run first; asynchronous + /// attributes run only if all synchronous attributes pass. + /// + /// + /// When is null, validation stops after the + /// first property error (cross-property short-circuit). Any in-flight async validators on + /// other properties are cancelled cooperatively. When + /// is non-null, all properties complete and all errors are collected. + /// + /// + /// Returns for interoperability with standard async + /// composition patterns such as Task.WhenAll and Task.WhenAny. + /// + /// + /// The object instance to test. It cannot be null. + /// Describes the object to validate and provides services and context for the validators. + /// Optional collection to receive s for the failures. + /// + /// If true, also evaluates all properties of the object (this process is not recursive over properties of the properties). + /// + /// A to observe while waiting for the task to complete. + /// A that is true if the object is valid, false if any validation errors are encountered. + /// When is null. + /// + /// When doesn't match the + /// on . + /// + [RequiresUnreferencedCode(ValidationContext.InstanceTypeNotStaticallyDiscovered)] + public static async Task TryValidateObjectAsync( + object instance, + ValidationContext validationContext, + ICollection? validationResults, + bool validateAllProperties, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(instance); + + if (validationContext != null && instance != validationContext.ObjectInstance) + { + throw new ArgumentException(SR.Validator_InstanceMustMatchValidationContextInstance, nameof(instance)); + } + + var result = true; + var breakOnFirstError = (validationResults == null); + + foreach (ValidationError err in await GetObjectValidationErrorsAsync(instance, validationContext!, validateAllProperties, breakOnFirstError, cancellationToken).ConfigureAwait(false)) + { + result = false; + + validationResults?.Add(err.ValidationResult); + } + + return result; + } + + /// + /// Asynchronously tests whether the given value is valid against a specified list of s. + /// + /// + /// This method will test each s specified. If + /// is non-null, this method will add a + /// to it for each validation failure. + /// + /// Any instances will be evaluated asynchronously after all synchronous + /// validation attributes have passed. + /// + /// + /// The value to test. + /// Describes the object being validated and provides services and context for the validators. + /// Optional collection to receive s for the failures. + /// The list of s to validate this against. + /// A to observe while waiting for the task to complete. + /// A that is true if the object is valid, false if any validation errors are encountered. + public static async Task TryValidateValueAsync( + object? value, + ValidationContext validationContext, + ICollection? validationResults, + IEnumerable validationAttributes, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(validationAttributes); + + var result = true; + var breakOnFirstError = validationResults == null; + + foreach (var err in await GetValidationErrorsAsync(value, validationContext, validationAttributes, breakOnFirstError, cancellationToken).ConfigureAwait(false)) + { + result = false; + + validationResults?.Add(err.ValidationResult); + } + + return result; + } + + /// + /// Asynchronously throws a if the given property is not valid. + /// + /// The value to test. + /// Describes the object being validated and provides services and context for the validators. + /// A to observe while waiting for the task to complete. + /// A representing the asynchronous validation operation. + /// When is null. + /// When is invalid for this property. + [RequiresUnreferencedCode("The Type of validationContext.ObjectType cannot be statically discovered.")] + public static async Task ValidatePropertyAsync( + object? value, + ValidationContext validationContext, + CancellationToken cancellationToken = default) + { + var propertyType = _store.GetPropertyType(validationContext); + EnsureValidPropertyType(validationContext.MemberName!, propertyType, value); + + var attributes = _store.GetPropertyValidationAttributes(validationContext); + + List errors = await GetValidationErrorsAsync(value, validationContext, attributes, false, cancellationToken).ConfigureAwait(false); + if (errors.Count > 0) + { + errors[0].ThrowValidationException(); + } + } + + /// + /// Asynchronously throws a if the given is not valid. + /// + /// + /// This method evaluates all s attached to the object's type. + /// + /// The object instance to test. It cannot be null. + /// Describes the object being validated and provides services and context for the validators. + /// A to observe while waiting for the task to complete. + /// A representing the asynchronous validation operation. + /// When is null. + /// When is null. + /// + /// When doesn't match the + /// on . + /// + /// When is found to be invalid. + [RequiresUnreferencedCode(ValidationContext.InstanceTypeNotStaticallyDiscovered)] + public static Task ValidateObjectAsync( + object instance, + ValidationContext validationContext, + CancellationToken cancellationToken = default) + { + return ValidateObjectAsync(instance, validationContext, false, cancellationToken); + } + + /// + /// Asynchronously throws a if the given object instance is not valid. + /// + /// + /// This method evaluates all s attached to the object's type. + /// If is true it also validates all the object's properties. + /// + /// The object instance to test. It cannot be null. + /// Describes the object being validated and provides services and context for the validators. + /// If true, also validates all the 's properties. + /// A to observe while waiting for the task to complete. + /// A representing the asynchronous validation operation. + /// When is null. + /// When is null. + /// + /// When doesn't match the + /// on . + /// + /// When is found to be invalid. + [RequiresUnreferencedCode(ValidationContext.InstanceTypeNotStaticallyDiscovered)] + public static async Task ValidateObjectAsync( + object instance, + ValidationContext validationContext, + bool validateAllProperties, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(instance); + ArgumentNullException.ThrowIfNull(validationContext); + + if (instance != validationContext.ObjectInstance) + { + throw new ArgumentException(SR.Validator_InstanceMustMatchValidationContextInstance, nameof(instance)); + } + + List errors = await GetObjectValidationErrorsAsync(instance, validationContext, validateAllProperties, false, cancellationToken).ConfigureAwait(false); + if (errors.Count > 0) + { + errors[0].ThrowValidationException(); + } + } + + /// + /// Asynchronously throws a if the given value is not valid for the + /// s. + /// + /// The value to test. + /// Describes the object being tested. + /// The list of s to validate against this instance. + /// A to observe while waiting for the task to complete. + /// A representing the asynchronous validation operation. + /// When is null. + /// When is found to be invalid. + public static async Task ValidateValueAsync( + object? value, + ValidationContext validationContext, + IEnumerable validationAttributes, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(validationContext); + ArgumentNullException.ThrowIfNull(validationAttributes); + + List errors = await GetValidationErrorsAsync(value, validationContext, validationAttributes, false, cancellationToken).ConfigureAwait(false); + if (errors.Count > 0) + { + errors[0].ThrowValidationException(); + } + } + + /// + /// Asynchronous version of . + /// Implements a 3-step pipeline: properties → type attrs → IAsyncValidatableObject or IValidatableObject. + /// + [RequiresUnreferencedCode(ValidationContext.InstanceTypeNotStaticallyDiscovered)] + private static async Task> GetObjectValidationErrorsAsync( + object instance, + ValidationContext validationContext, + bool validateAllProperties, + bool breakOnFirstError, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(validationContext); + + Debug.Assert(instance != null); + + // Step 1: Validate the object properties' validation attributes + List errors = await GetObjectPropertyValidationErrorsAsync(instance, validationContext, validateAllProperties, breakOnFirstError, cancellationToken).ConfigureAwait(false); + + // We only proceed to Step 2 if there are no errors + if (errors.Count > 0) + { + return errors; + } + + // Step 2: Validate the object's validation attributes + var attributes = _store.GetTypeValidationAttributes(validationContext); + errors.AddRange(await GetValidationErrorsAsync(instance, validationContext, attributes, breakOnFirstError, cancellationToken).ConfigureAwait(false)); + + // We only proceed to Step 3 if there are no errors + if (errors.Count > 0) + { + return errors; + } + + // Step 3: Test for IAsyncValidatableObject implementation (preferred), fall back to IValidatableObject + if (instance is IAsyncValidatableObject asyncValidatable) + { + await foreach (ValidationResult result in asyncValidatable.ValidateAsync(validationContext, cancellationToken).ConfigureAwait(false)) + { + if (result != ValidationResult.Success) + { + errors.Add(new ValidationError(null, instance, result)); + } + } + } + else if (instance is IValidatableObject validatable) + { + var results = validatable.Validate(validationContext); + + if (results != null) + { + foreach (ValidationResult result in results) + { + if (result != ValidationResult.Success) + { + errors.Add(new ValidationError(null, instance, result)); + } + } + } + } + + return errors; + } + + /// + /// Asynchronous version of . + /// Iterates all properties and validates each using . + /// + [RequiresUnreferencedCode(ValidationContext.InstanceTypeNotStaticallyDiscovered)] + private static async Task> GetObjectPropertyValidationErrorsAsync( + object instance, + ValidationContext validationContext, + bool validateAllProperties, + bool breakOnFirstError, + CancellationToken cancellationToken) + { + var properties = GetPropertyValues(instance, validationContext); + var errors = new List(); + + if (validateAllProperties) + { + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + var tasks = new List>>(properties.Count); + foreach (var property in properties) + { + var attributes = _store.GetPropertyValidationAttributes(property.Key); + tasks.Add(GetValidationErrorsAsync( + property.Value, property.Key, attributes, + breakOnFirstError, linkedCts.Token)); + } + + try + { + while (tasks.Count > 0) + { + Task> completed = await Task.WhenAny(tasks).ConfigureAwait(false); + tasks.Remove(completed); + + List propertyErrors; + try + { + propertyErrors = await completed.ConfigureAwait(false); + } + catch (OperationCanceledException) when (linkedCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + continue; + } + + if (propertyErrors.Count > 0) + { + errors.AddRange(propertyErrors); + + if (breakOnFirstError) + { + linkedCts.Cancel(); + break; + } + } + } + } + finally + { + // Observe any remaining in-flight tasks on every exit path + // (success short-circuit, external cancellation, or unexpected exception) + // to prevent UnobservedTaskException from the finalizer thread. + if (tasks.Count > 0) + { + linkedCts.Cancel(); + foreach (Task> remaining in tasks) + { + try { await remaining.ConfigureAwait(false); } + catch { } + } + } + } + } + else + { + foreach (var property in properties) + { + var attributes = _store.GetPropertyValidationAttributes(property.Key); + foreach (ValidationAttribute attribute in attributes) + { + if (attribute is RequiredAttribute reqAttr) + { + var validationResult = reqAttr.GetValidationResult(property.Value, property.Key); + if (validationResult != ValidationResult.Success) + { + errors.Add(new ValidationError(reqAttr, property.Value, validationResult!)); + } + break; + } + } + + if (breakOnFirstError && errors.Count > 0) + { + break; + } + } + } + + return errors; + } + + /// + /// Asynchronous version of . + /// Implements two-phase validation: sync attributes first, async attributes only if no sync errors. + /// + private static async Task> GetValidationErrorsAsync( + object? value, + ValidationContext validationContext, + IEnumerable attributes, + bool breakOnFirstError, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(validationContext); + + var errors = new List(); + List? asyncAttributes = null; + ValidationError? validationError; + + // Get the required validator if there is one and test it first, aborting on failure + RequiredAttribute? required = null; + foreach (ValidationAttribute attribute in attributes) + { + required = attribute as RequiredAttribute; + if (required is not null) + { + if (!TryValidate(value, validationContext, required, out validationError)) + { + errors.Add(validationError); + return errors; + } + break; + } + } + + // Phase 1: Iterate through sync validators, collecting async ones for later + foreach (ValidationAttribute attr in attributes) + { + if (attr != required) + { + if (attr is AsyncValidationAttribute asyncAttr) + { + (asyncAttributes ??= new List()).Add(asyncAttr); + } + else + { + if (!TryValidate(value, validationContext, attr, out validationError)) + { + errors.Add(validationError); + + if (breakOnFirstError) + { + break; + } + } + } + } + } + + // Phase 2: Only run async validators if all sync validators passed — run in parallel + if (errors.Count == 0 && asyncAttributes is not null) + { + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + var tasks = new List>(asyncAttributes.Count); + foreach (AsyncValidationAttribute asyncAttr in asyncAttributes) + { + tasks.Add(RunAsyncValidation(asyncAttr, value, validationContext, linkedCts.Token)); + } + + try + { + while (tasks.Count > 0) + { + Task<(AsyncValidationAttribute Attr, ValidationResult? Result)> completed = + await Task.WhenAny(tasks).ConfigureAwait(false); + tasks.Remove(completed); + + (AsyncValidationAttribute attr, ValidationResult? result) completedResult; + try + { + completedResult = await completed.ConfigureAwait(false); + } + catch (OperationCanceledException) when (linkedCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + continue; + } + + if (completedResult.result != ValidationResult.Success) + { + errors.Add(new ValidationError(completedResult.attr, value, completedResult.result!)); + + if (breakOnFirstError) + { + linkedCts.Cancel(); + break; + } + } + } + } + finally + { + // Observe any remaining in-flight tasks on every exit path + // (success short-circuit, external cancellation, or unexpected exception) + // to prevent UnobservedTaskException from the finalizer thread. + if (tasks.Count > 0) + { + linkedCts.Cancel(); + foreach (var remaining in tasks) + { + try { await remaining.ConfigureAwait(false); } + catch { } + } + } + } + } + + return errors; + } + + private static async Task<(AsyncValidationAttribute Attr, ValidationResult? Result)> RunAsyncValidation( + AsyncValidationAttribute attr, + object? value, + ValidationContext validationContext, + CancellationToken cancellationToken) + { + ValidationResult? result = await attr.GetValidationResultAsync(value, validationContext, cancellationToken).ConfigureAwait(false); + return (attr, result); + } + /// /// Creates a new to use to validate the type or a member of /// the given object instance. diff --git a/src/libraries/System.ComponentModel.Annotations/tests/System/ComponentModel/DataAnnotations/ValidationAttributeTests.cs b/src/libraries/System.ComponentModel.Annotations/tests/System/ComponentModel/DataAnnotations/ValidationAttributeTests.cs index 20fe45b5fe23ad..0a87ac471ce725 100644 --- a/src/libraries/System.ComponentModel.Annotations/tests/System/ComponentModel/DataAnnotations/ValidationAttributeTests.cs +++ b/src/libraries/System.ComponentModel.Annotations/tests/System/ComponentModel/DataAnnotations/ValidationAttributeTests.cs @@ -1,6 +1,8 @@ // 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; using Xunit; namespace System.ComponentModel.DataAnnotations.Tests @@ -393,6 +395,241 @@ public class ValidationAttributeAlwaysInvalidEmptyErrorMessage : ValidationAttri protected override ValidationResult IsValid(object value, ValidationContext validationContext) => new ValidationResult(string.Empty); } + [Fact] + public static void AsyncValidationAttribute_IsValid_SubclassCanThrowForSyncUse() + { + var attribute = new TestAsyncAlwaysFailsAttribute(); + var context = new ValidationContext(new object()); + Assert.Throws(() => attribute.GetValidationResult("test", context)); + } + + [Fact] + public static void AsyncValidationAttribute_IsValid_SubclassCanProvideSyncFallback() + { + var attribute = new TestAsyncWithSyncFallbackAttribute(); + var context = new ValidationContext(new object()); + var result = attribute.GetValidationResult("sync-valid", context); + Assert.Equal(ValidationResult.Success, result); + } + + [Fact] + public static async Task AsyncValidationAttribute_GetValidationResultAsync_ReturnsFailure() + { + var attribute = new TestAsyncAlwaysFailsAttribute(); + var context = new ValidationContext(new object()); + var result = await attribute.GetValidationResultAsync("test", context); + Assert.NotNull(result); + Assert.NotEqual(ValidationResult.Success, result); + } + + [Fact] + public static async Task AsyncValidationAttribute_GetValidationResultAsync_ReturnsSuccess() + { + var attribute = new TestAsyncAlwaysSucceedsAttribute(); + var context = new ValidationContext(new object()); + var result = await attribute.GetValidationResultAsync("test", context); + Assert.Equal(ValidationResult.Success, result); + } + + [Fact] + public static async Task AsyncValidationAttribute_GetValidationResultAsync_FormatsErrorMessage() + { + var attribute = new TestAsyncAlwaysFailsAttribute(); + var context = new ValidationContext(new object()) { DisplayName = "TestField" }; + var result = await attribute.GetValidationResultAsync("test", context); + Assert.NotNull(result); + Assert.Contains("TestField", result.ErrorMessage); + } + + [Fact] + public static async Task AsyncValidationAttribute_GetValidationResultAsync_CustomErrorMessage() + { + var attribute = new TestAsyncAlwaysFailsAttribute { ErrorMessage = "Custom: {0}" }; + var context = new ValidationContext(new object()) { DisplayName = "MyProp" }; + var result = await attribute.GetValidationResultAsync("test", context); + Assert.NotNull(result); + Assert.Equal("Custom: MyProp", result.ErrorMessage); + } + + [Fact] + public static async Task AsyncValidationAttribute_GetValidationResultAsync_ErrorMessageAccessor() + { + var attribute = new TestAsyncAlwaysFailsWithAccessorAttribute(); + var context = new ValidationContext(new object()) { DisplayName = "Field1" }; + var result = await attribute.GetValidationResultAsync("test", context); + Assert.NotNull(result); + Assert.Contains("Field1", result.ErrorMessage); + } + + [Fact] + public static async Task AsyncValidationAttribute_GetValidationResultAsync_ThrowsOnNullContext() + { + var attribute = new TestAsyncAlwaysSucceedsAttribute(); + await Assert.ThrowsAsync( + async () => await attribute.GetValidationResultAsync("test", null)); + } + + [Fact] + public static async Task AsyncValidationAttribute_GetValidationResultAsync_PreservesExplicitErrorMessage() + { + var attribute = new TestAsyncFailsWithExplicitMessageAttribute(); + var context = new ValidationContext(new object()) { DisplayName = "Ignored" }; + var result = await attribute.GetValidationResultAsync("test", context); + Assert.NotNull(result); + Assert.Equal("Explicit error message", result.ErrorMessage); + } + + [Fact] + public static async Task AsyncValidationAttribute_GetValidationResultAsync_TrulyAsync() + { + var attribute = new TestAsyncDelayedValidationAttribute(); + var context = new ValidationContext(new object()); + var result = await attribute.GetValidationResultAsync("test", context); + Assert.NotNull(result); + Assert.NotEqual(ValidationResult.Success, result); + } + + public class TestAsyncAlwaysFailsAttribute : AsyncValidationAttribute + { + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + => throw new InvalidOperationException("Use async validation"); + + protected override Task IsValidAsync(object? value, ValidationContext validationContext, CancellationToken cancellationToken) + { + return Task.FromResult(new ValidationResult(null)); + } + } + + public class TestAsyncAlwaysSucceedsAttribute : AsyncValidationAttribute + { + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + => throw new InvalidOperationException("Use async validation"); + + protected override Task IsValidAsync(object? value, ValidationContext validationContext, CancellationToken cancellationToken) + { + return Task.FromResult(ValidationResult.Success); + } + } + + public class TestAsyncAlwaysFailsWithAccessorAttribute : AsyncValidationAttribute + { + public TestAsyncAlwaysFailsWithAccessorAttribute() : base(() => "Accessor error for {0}") { } + + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + => throw new InvalidOperationException("Use async validation"); + + protected override Task IsValidAsync(object? value, ValidationContext validationContext, CancellationToken cancellationToken) + { + return Task.FromResult(new ValidationResult(null)); + } + } + + public class TestAsyncFailsWithExplicitMessageAttribute : AsyncValidationAttribute + { + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + => throw new InvalidOperationException("Use async validation"); + + protected override Task IsValidAsync(object? value, ValidationContext validationContext, CancellationToken cancellationToken) + { + return Task.FromResult(new ValidationResult("Explicit error message")); + } + } + + public class TestAsyncDelayedValidationAttribute : AsyncValidationAttribute + { + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + => throw new InvalidOperationException("Use async validation"); + + protected override async Task IsValidAsync(object? value, ValidationContext validationContext, CancellationToken cancellationToken) + { + await Task.Yield(); + return new ValidationResult(null); + } + } + + public class TestAsyncWithSyncFallbackAttribute : AsyncValidationAttribute + { + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + { + if (value is string s && s == "sync-valid") + { + return ValidationResult.Success; + } + return new ValidationResult("Sync fallback failed"); + } + + protected override Task IsValidAsync(object? value, ValidationContext validationContext, CancellationToken cancellationToken) + { + return Task.FromResult(ValidationResult.Success); + } + } + + private class TestAsyncCancellable : AsyncValidationAttribute + { + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + => throw new InvalidOperationException("Use async validation"); + + protected override async Task IsValidAsync( + object? value, ValidationContext validationContext, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + await Task.Yield(); + + return ValidationResult.Success; + } + } + + private class TestAsyncWithMessage : AsyncValidationAttribute + { + public TestAsyncWithMessage(string errorMessage) : base(errorMessage) { } + + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + => throw new InvalidOperationException("Use async validation"); + + protected override Task IsValidAsync( + object? value, ValidationContext validationContext, CancellationToken cancellationToken) + { + return Task.FromResult(null); + } + } + + [Fact] + public static void AsyncValidationAttribute_SyncFallbackOverride_Works() + { + var attribute = new TestAsyncWithSyncFallbackAttribute(); + var context = new ValidationContext(new object()); + var result = attribute.GetValidationResult("sync-valid", context); + Assert.Equal(ValidationResult.Success, result); + } + + [Fact] + public static void AsyncValidationAttribute_SyncFallbackOverride_FailsCorrectly() + { + var attribute = new TestAsyncWithSyncFallbackAttribute(); + var context = new ValidationContext(new object()); + var result = attribute.GetValidationResult("other", context); + Assert.NotNull(result); + Assert.Equal("Sync fallback failed", result.ErrorMessage); + } + + [Fact] + public static async Task AsyncValidationAttribute_CancellationToken_Propagated() + { + var attribute = new TestAsyncCancellable(); + var cts = new CancellationTokenSource(); + cts.Cancel(); + await Assert.ThrowsAsync( + async () => await attribute.GetValidationResultAsync("value", s_testValidationContext, cts.Token)); + } + + [Fact] + public static void AsyncValidationAttribute_Constructor_WithErrorMessage() + { + var attribute = new TestAsyncWithMessage("Custom error"); + Assert.Throws( + () => attribute.GetValidationResult("any", s_testValidationContext)); + } + public class ToBeTested { public string PropertyToBeTested diff --git a/src/libraries/System.ComponentModel.Annotations/tests/System/ComponentModel/DataAnnotations/ValidatorTests.cs b/src/libraries/System.ComponentModel.Annotations/tests/System/ComponentModel/DataAnnotations/ValidatorTests.cs index 155c200a74e1d0..c13165d45e6fc4 100644 --- a/src/libraries/System.ComponentModel.Annotations/tests/System/ComponentModel/DataAnnotations/ValidatorTests.cs +++ b/src/libraries/System.ComponentModel.Annotations/tests/System/ComponentModel/DataAnnotations/ValidatorTests.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using Newtonsoft.Json.Linq; using Xunit; @@ -1450,5 +1452,1293 @@ public static ValidationResult Validate(HasMetadataTypeToBeValidated value) ? new ValidationResult("The SecondPropertyToBeTested field mustn't be \"TypeInvalid\".") : ValidationResult.Success; } + + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)] + public class AsyncAlwaysFailsAttribute : AsyncValidationAttribute + { + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + => throw new InvalidOperationException("Use async validation"); + + protected override Task IsValidAsync( + object? value, ValidationContext validationContext, CancellationToken cancellationToken) + { + return Task.FromResult(new ValidationResult("Async validation always fails")); + } + } + + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)] + public class AsyncAlwaysSucceedsAttribute : AsyncValidationAttribute + { + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + => throw new InvalidOperationException("Use async validation"); + + protected override Task IsValidAsync( + object? value, ValidationContext validationContext, CancellationToken cancellationToken) + { + return Task.FromResult(null); + } + } + + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)] + public class AsyncDelayedAttribute : AsyncValidationAttribute + { + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + => throw new InvalidOperationException("Use async validation"); + + protected override async Task IsValidAsync( + object? value, ValidationContext validationContext, CancellationToken cancellationToken) + { + await Task.Yield(); + if (value is string s && s == "Valid Value") + return ValidationResult.Success; + + return new ValidationResult("Async delayed validation failed"); + } + } + + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)] + public class AsyncCancellableAttribute : AsyncValidationAttribute + { + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + => throw new InvalidOperationException("Use async validation"); + + protected override async Task IsValidAsync( + object? value, ValidationContext validationContext, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + await Task.Yield(); + + return ValidationResult.Success; + } + } + + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] + public class AsyncClassAlwaysFailsAttribute : AsyncValidationAttribute + { + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + => throw new InvalidOperationException("Use async validation"); + + protected override Task IsValidAsync( + object? value, ValidationContext validationContext, CancellationToken cancellationToken) + { + return Task.FromResult(new ValidationResult("Async class validation failed")); + } + } + + public class HasAsyncProperty + { + [AsyncAlwaysFails] + public string AsyncProp { get; set; } + } + + public class HasAsyncSucceedingProperty + { + [AsyncAlwaysSucceeds] + public string AsyncProp { get; set; } + } + + public class HasTrulyAsyncProperty + { + [AsyncDelayed] + public string AsyncProp { get; set; } + } + + public class HasAsyncCancellableProperty + { + [AsyncCancellable] + public string CancellableProp { get; set; } + } + + public class HasMixedValidation + { + [ValidValueStringProperty] + [AsyncAlwaysFails] + public string MixedProp { get; set; } + } + + public class HasMixedPassingValidation + { + [ValidValueStringProperty] + [AsyncAlwaysSucceeds] + public string MixedProp { get; set; } + } + + public class HasRequiredAndAsyncProperty + { + [Required] + [AsyncAlwaysFails] + public string Prop { get; set; } + } + + [AsyncClassAlwaysFails] + public class HasAsyncClassLevelAttr + { + } + + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = true)] + public class AsyncDelayedSucceedsAttribute : AsyncValidationAttribute + { + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + => throw new InvalidOperationException("Use async validation"); + + protected override async Task IsValidAsync( + object? value, ValidationContext validationContext, CancellationToken cancellationToken) + { + await Task.Delay(100, cancellationToken); + + return ValidationResult.Success; + } + } + + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = true)] + public class AsyncDelayedFailsAttribute : AsyncValidationAttribute + { + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + => throw new InvalidOperationException("Use async validation"); + + protected override async Task IsValidAsync( + object? value, ValidationContext validationContext, CancellationToken cancellationToken) + { + await Task.Delay(100, cancellationToken); + + return new ValidationResult("Async delayed validation failed"); + } + } + + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = true)] + public class AsyncConcurrencyProbeAttribute : AsyncValidationAttribute + { + public static int ConcurrentCount; + public static TaskCompletionSource AllRunningGate = new(); + public static int ExpectedCount; + + public static void Reset(int expectedCount) + { + ConcurrentCount = 0; + AllRunningGate = new TaskCompletionSource(); + ExpectedCount = expectedCount; + } + + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + => throw new InvalidOperationException("Use async validation"); + + protected override async Task IsValidAsync( + object? value, ValidationContext validationContext, CancellationToken cancellationToken) + { + int current = Interlocked.Increment(ref ConcurrentCount); + if (current >= ExpectedCount) + AllRunningGate.TrySetResult(true); + + await AllRunningGate.Task.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); + Interlocked.Decrement(ref ConcurrentCount); + + return ValidationResult.Success; + } + } + + public class HasMultipleAsyncProperties + { + [AsyncDelayedSucceeds] + public string Prop1 { get; set; } = "value"; + + [AsyncDelayedSucceeds] + public string Prop2 { get; set; } = "value"; + } + + public class HasMultipleConcurrencyProbeProperties + { + [AsyncConcurrencyProbe] + public string Prop1 { get; set; } = "value"; + + [AsyncConcurrencyProbe] + public string Prop2 { get; set; } = "value"; + } + + public class HasMultipleFailingAsyncProperties + { + [AsyncDelayedFails] + public string Prop1 { get; set; } = "value"; + + [AsyncDelayedFails] + public string Prop2 { get; set; } = "value"; + } + + public class AsyncValidatableSuccess : IAsyncValidatableObject + { + IEnumerable IValidatableObject.Validate(ValidationContext validationContext) + => throw new InvalidOperationException("Use async validation"); + + public async IAsyncEnumerable ValidateAsync( + ValidationContext validationContext, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.CompletedTask; + yield return ValidationResult.Success; + } + } + + public class AsyncValidatableError : IAsyncValidatableObject + { + IEnumerable IValidatableObject.Validate(ValidationContext validationContext) + => throw new InvalidOperationException("Use async validation"); + + public async IAsyncEnumerable ValidateAsync( + ValidationContext validationContext, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.CompletedTask; + yield return new ValidationResult("async object error"); + } + } + + public class AsyncValidatableNull : IAsyncValidatableObject + { + IEnumerable IValidatableObject.Validate(ValidationContext validationContext) + => throw new InvalidOperationException("Use async validation"); + +#pragma warning disable CS1998 + public async IAsyncEnumerable ValidateAsync( + ValidationContext validationContext, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + yield break; + } +#pragma warning restore CS1998 + } + + public class DualValidatableModel : IAsyncValidatableObject + { + IEnumerable IValidatableObject.Validate(ValidationContext validationContext) + { + return new ValidationResult[] { new ValidationResult("sync error from dual model") }; + } + + public async IAsyncEnumerable ValidateAsync( + ValidationContext validationContext, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.CompletedTask; + yield return new ValidationResult("async error from dual model"); + } + } + + public class AsyncValidatableWithRequired : IAsyncValidatableObject + { + [Required] + public string RequiredProp { get; set; } + + IEnumerable IValidatableObject.Validate(ValidationContext validationContext) + => throw new InvalidOperationException("Use async validation"); + + public async IAsyncEnumerable ValidateAsync( + ValidationContext validationContext, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.CompletedTask; + yield return new ValidationResult("async object error"); + } + } + + [Fact] + public static async Task TryValidateObjectAsyncThrowsIf_instance_is_null() + { + await Assert.ThrowsAsync( + async () => await Validator.TryValidateObjectAsync(null, s_estValidationContext, null)); + + await Assert.ThrowsAsync( + async () => await Validator.TryValidateObjectAsync(null, s_estValidationContext, null, false)); + } + + [Fact] + public static async Task TryValidateObjectAsyncThrowsIf_ValidationContext_is_null() + { + await Assert.ThrowsAsync( + async () => await Validator.TryValidateObjectAsync(new object(), null, null)); + + await Assert.ThrowsAsync( + async () => await Validator.TryValidateObjectAsync(new object(), null, null, false)); + } + + [Fact] + public static async Task TryValidateObjectAsync_ThrowsIf_instance_does_not_match_ValidationContext() + { + await AssertExtensions.ThrowsAsync("instance", + async () => await Validator.TryValidateObjectAsync(new object(), s_estValidationContext, null)); + } + + [Fact] + public static async Task TryValidateObjectAsync_returns_true_if_no_errors() + { + var objectToBeValidated = "ToBeValidated"; + var validationContext = new ValidationContext(objectToBeValidated); + Assert.True(await Validator.TryValidateObjectAsync(objectToBeValidated, validationContext, null)); + Assert.True(await Validator.TryValidateObjectAsync(objectToBeValidated, validationContext, null, true)); + } + + [Fact] + public static async Task TryValidateObjectAsync_returns_false_with_sync_errors() + { + var objectToBeValidated = new ToBeValidated() + { + PropertyToBeTested = "Invalid Value", + PropertyWithRequiredAttribute = "Valid Value" + }; + var validationContext = new ValidationContext(objectToBeValidated); + var validationResults = new List(); + Assert.False(await Validator.TryValidateObjectAsync(objectToBeValidated, validationContext, validationResults, true)); + Assert.Equal(1, validationResults.Count); + Assert.Equal("ValidValueStringPropertyAttribute.IsValid failed for value Invalid Value", validationResults[0].ErrorMessage); + } + + [Fact] + public static async Task TryValidateObjectAsync_returns_false_with_async_attr_failure() + { + var obj = new HasAsyncProperty { AsyncProp = "anything" }; + var ctx = new ValidationContext(obj); + var results = new List(); + Assert.False(await Validator.TryValidateObjectAsync(obj, ctx, results, true)); + Assert.Equal(1, results.Count); + Assert.Equal("Async validation always fails", results[0].ErrorMessage); + } + + [Fact] + public static async Task TryValidateObjectAsync_returns_true_with_async_attr_success() + { + var obj = new HasAsyncSucceedingProperty { AsyncProp = "anything" }; + var ctx = new ValidationContext(obj); + var results = new List(); + Assert.True(await Validator.TryValidateObjectAsync(obj, ctx, results, true)); + Assert.Empty(results); + } + + [Fact] + public static async Task TryValidateObjectAsync_validateAllProperties_false_only_checks_Required() + { + var obj = new HasAsyncProperty { AsyncProp = "anything" }; + var ctx = new ValidationContext(obj); + var results = new List(); + Assert.True(await Validator.TryValidateObjectAsync(obj, ctx, results, false)); + Assert.Empty(results); + } + + [Fact] + public static async Task TryValidateObjectAsync_Required_fails_before_async() + { + var obj = new HasRequiredAndAsyncProperty { Prop = null }; + var ctx = new ValidationContext(obj); + var results = new List(); + Assert.False(await Validator.TryValidateObjectAsync(obj, ctx, results, true)); + Assert.Equal(1, results.Count); + } + + [Fact] + public static async Task TryValidateObjectAsync_collection_can_have_multiple_results() + { + HasDoubleFailureProperty obj = new HasDoubleFailureProperty(); + var ctx = new ValidationContext(obj); + var results = new List(); + Assert.False(await Validator.TryValidateObjectAsync(obj, ctx, results, true)); + Assert.Equal(2, results.Count); + } + + [Fact] + public static async Task TryValidateObjectAsync_returns_false_if_class_level_attribute_fails() + { + var obj = new InvalidToBeValidated() { PropertyWithRequiredAttribute = "Valid Value" }; + var ctx = new ValidationContext(obj); + Assert.False(await Validator.TryValidateObjectAsync(obj, ctx, null, true)); + + var results = new List(); + Assert.False(await Validator.TryValidateObjectAsync(obj, ctx, results, true)); + Assert.Equal(1, results.Count); + Assert.Equal("ValidClassAttribute.IsValid failed for class of type " + typeof(InvalidToBeValidated).FullName, results[0].ErrorMessage); + } + + [Fact] + public static async Task TryValidateObjectAsync_IValidatableObject_Null() + { + var instance = new ValidatableNull(); + var ctx = new ValidationContext(instance); + var results = new List(); + Assert.True(await Validator.TryValidateObjectAsync(instance, ctx, results)); + Assert.Equal(0, results.Count); + } + + [Fact] + public static async Task ValidateObjectAsyncThrowsIf_instance_is_null() + { + await Assert.ThrowsAsync( + async () => await Validator.ValidateObjectAsync(null, s_estValidationContext)); + + await Assert.ThrowsAsync( + async () => await Validator.ValidateObjectAsync(null, s_estValidationContext, false)); + } + + [Fact] + public static async Task ValidateObjectAsyncThrowsIf_ValidationContext_is_null() + { + await Assert.ThrowsAsync( + async () => await Validator.ValidateObjectAsync(new object(), null)); + + await Assert.ThrowsAsync( + async () => await Validator.ValidateObjectAsync(new object(), null, false)); + } + + [Fact] + public static async Task ValidateObjectAsync_ThrowsIf_instance_does_not_match() + { + await AssertExtensions.ThrowsAsync("instance", + async () => await Validator.ValidateObjectAsync(new object(), s_estValidationContext)); + } + + [Fact] + public static async Task ValidateObjectAsync_succeeds_if_no_errors() + { + var obj = "ToBeValidated"; + var ctx = new ValidationContext(obj); + await Validator.ValidateObjectAsync(obj, ctx); + await Validator.ValidateObjectAsync(obj, ctx, true); + } + + [Fact] + public static async Task ValidateObjectAsync_throws_ValidationException_if_sync_errors() + { + var obj = new ToBeValidated() + { + PropertyToBeTested = "Invalid Value", + PropertyWithRequiredAttribute = "Valid Value" + }; + var ctx = new ValidationContext(obj); + var ex = await Assert.ThrowsAsync( + async () => await Validator.ValidateObjectAsync(obj, ctx, true)); + Assert.IsType(ex.ValidationAttribute); + } + + [Fact] + public static async Task ValidateObjectAsync_throws_ValidationException_if_async_attr_fails() + { + var obj = new HasAsyncProperty { AsyncProp = "anything" }; + var ctx = new ValidationContext(obj); + var ex = await Assert.ThrowsAsync( + async () => await Validator.ValidateObjectAsync(obj, ctx, true)); + Assert.Equal("Async validation always fails", ex.ValidationResult.ErrorMessage); + } + + [Fact] + public static async Task ValidateObjectAsync_succeeds_validateAllProperties_false() + { + var obj = new HasAsyncProperty { AsyncProp = "anything" }; + var ctx = new ValidationContext(obj); + await Validator.ValidateObjectAsync(obj, ctx, false); + } + + [Fact] + public static async Task ValidateObjectAsync_throws_if_class_level_sync_attribute_fails() + { + var obj = new InvalidToBeValidated() { PropertyWithRequiredAttribute = "Valid Value" }; + var ctx = new ValidationContext(obj); + var ex = await Assert.ThrowsAsync( + async () => await Validator.ValidateObjectAsync(obj, ctx, true)); + Assert.IsType(ex.ValidationAttribute); + Assert.Equal("ValidClassAttribute.IsValid failed for class of type " + typeof(InvalidToBeValidated).FullName, ex.ValidationResult.ErrorMessage); + } + + [Fact] + public static async Task TryValidatePropertyAsync_ThrowsIf_ValidationContext_is_null() + { + await Assert.ThrowsAsync( + async () => await Validator.TryValidatePropertyAsync(new object(), null, null)); + } + + [Fact] + public static async Task TryValidatePropertyAsync_returns_true_if_no_errors() + { + var ctx = new ValidationContext(new ToBeValidated()); + ctx.MemberName = "NoAttributesProperty"; + Assert.True(await Validator.TryValidatePropertyAsync("Any Value", ctx, null)); + } + + [Fact] + public static async Task TryValidatePropertyAsync_returns_false_with_sync_attr_failure() + { + var ctx = new ValidationContext(new ToBeValidated()); + ctx.MemberName = "PropertyToBeTested"; + var results = new List(); + Assert.False(await Validator.TryValidatePropertyAsync("Invalid Value", ctx, results)); + Assert.Equal(1, results.Count); + Assert.Equal("ValidValueStringPropertyAttribute.IsValid failed for value Invalid Value", results[0].ErrorMessage); + } + + [Fact] + public static async Task TryValidatePropertyAsync_returns_false_with_async_attr_failure() + { + var obj = new HasAsyncProperty(); + var ctx = new ValidationContext(obj); + ctx.MemberName = nameof(HasAsyncProperty.AsyncProp); + var results = new List(); + Assert.False(await Validator.TryValidatePropertyAsync("anything", ctx, results)); + Assert.Equal(1, results.Count); + Assert.Equal("Async validation always fails", results[0].ErrorMessage); + } + + [Fact] + public static async Task TryValidatePropertyAsync_returns_true_with_async_attr_success() + { + var obj = new HasAsyncSucceedingProperty(); + var ctx = new ValidationContext(obj); + ctx.MemberName = nameof(HasAsyncSucceedingProperty.AsyncProp); + var results = new List(); + Assert.True(await Validator.TryValidatePropertyAsync("anything", ctx, results)); + Assert.Empty(results); + } + + [Fact] + public static async Task TryValidatePropertyAsync_ThrowsIf_value_is_null() + { + await Assert.ThrowsAsync( + async () => await Validator.TryValidatePropertyAsync(null, s_estValidationContext, null)); + } + + [Fact] + public static async Task TryValidatePropertyAsync_ThrowsIf_MemberName_is_null_or_empty() + { + var ctx = new ValidationContext(new ToBeValidated()); + ctx.MemberName = null; + await Assert.ThrowsAsync( + async () => await Validator.TryValidatePropertyAsync(null, ctx, null)); + + ctx.MemberName = string.Empty; + await Assert.ThrowsAsync( + async () => await Validator.TryValidatePropertyAsync(null, ctx, null)); + } + + [Fact] + public static async Task TryValidatePropertyAsync_ThrowsIf_MemberName_does_not_exist() + { + var ctx = new ValidationContext(new ToBeValidated()); + ctx.MemberName = "NonExist"; + await AssertExtensions.ThrowsAsync("propertyName", + async () => await Validator.TryValidatePropertyAsync(null, ctx, null)); + } + + [Fact] + public static async Task TryValidatePropertyAsync_ThrowsIf_MemberName_is_not_public() + { + var ctx = new ValidationContext(new ToBeValidated()); + ctx.MemberName = "InternalProperty"; + await AssertExtensions.ThrowsAsync("propertyName", + async () => await Validator.TryValidatePropertyAsync(null, ctx, null)); + + ctx.MemberName = "ProtectedProperty"; + await AssertExtensions.ThrowsAsync("propertyName", + async () => await Validator.TryValidatePropertyAsync(null, ctx, null)); + + ctx.MemberName = "PrivateProperty"; + await AssertExtensions.ThrowsAsync("propertyName", + async () => await Validator.TryValidatePropertyAsync(null, ctx, null)); + } + + [Fact] + public static async Task TryValidatePropertyAsync_ThrowsIf_MemberName_is_indexer() + { + var ctx = new ValidationContext(new ToBeValidated()); + ctx.MemberName = "Item"; + await AssertExtensions.ThrowsAsync("propertyName", + async () => await Validator.TryValidatePropertyAsync(null, ctx, null)); + } + + [Fact] + public static async Task TryValidatePropertyAsync_ThrowsIf_value_is_wrong_type() + { + var ctx = new ValidationContext(new ToBeValidated()); + ctx.MemberName = "NoAttributesProperty"; + await AssertExtensions.ThrowsAsync("value", + async () => await Validator.TryValidatePropertyAsync(123, ctx, null)); + } + + [Fact] + public static async Task TryValidatePropertyAsync_ThrowsIf_null_passed_to_non_nullable_property() + { + var ctx = new ValidationContext(new ToBeValidated()); + + ctx.MemberName = "EnumProperty"; + await AssertExtensions.ThrowsAsync("value", + async () => await Validator.TryValidatePropertyAsync(null, ctx, null)); + + ctx.MemberName = "NonNullableProperty"; + await AssertExtensions.ThrowsAsync("value", + async () => await Validator.TryValidatePropertyAsync(null, ctx, null)); + } + + [Fact] + public static async Task TryValidatePropertyAsync_returns_true_if_null_passed_to_nullable_property() + { + var ctx = new ValidationContext(new ToBeValidated()); + ctx.MemberName = "NullableProperty"; + Assert.True(await Validator.TryValidatePropertyAsync(null, ctx, null)); + } + + [Fact] + public static async Task TryValidatePropertyAsync_returns_false_if_Required_fails() + { + var ctx = new ValidationContext(new ToBeValidated()); + ctx.MemberName = "PropertyWithRequiredAttribute"; + Assert.False(await Validator.TryValidatePropertyAsync(null, ctx, null)); + + var results = new List(); + Assert.False(await Validator.TryValidatePropertyAsync(null, ctx, results)); + Assert.Equal(1, results.Count); + } + + [Fact] + public static async Task TryValidatePropertyAsync_collection_can_have_multiple_results() + { + var ctx = new ValidationContext(new HasDoubleFailureProperty()); + ctx.MemberName = nameof(HasDoubleFailureProperty.WillAlwaysFailTwice); + var results = new List(); + Assert.False(await Validator.TryValidatePropertyAsync("Nope", ctx, results)); + Assert.Equal(2, results.Count); + } + + [Fact] + public static async Task TryValidatePropertyAsync_returns_true_if_all_attributes_are_valid() + { + var ctx = new ValidationContext(new ToBeValidated()); + ctx.MemberName = "PropertyWithRequiredAttribute"; + Assert.True(await Validator.TryValidatePropertyAsync("Valid Value", ctx, null)); + + var results = new List(); + Assert.True(await Validator.TryValidatePropertyAsync("Valid Value", ctx, results)); + Assert.Equal(0, results.Count); + } + + [Fact] + public static async Task ValidatePropertyAsync_ThrowsIf_ValidationContext_is_null() + { + await Assert.ThrowsAsync( + async () => await Validator.ValidatePropertyAsync(new object(), null)); + } + + [Fact] + public static async Task ValidatePropertyAsync_succeeds_if_no_errors() + { + var ctx = new ValidationContext(new ToBeValidated()); + ctx.MemberName = "NoAttributesProperty"; + await Validator.ValidatePropertyAsync("Any Value", ctx); + } + + [Fact] + public static async Task ValidatePropertyAsync_throws_ValidationException_with_sync_attr_failure() + { + var ctx = new ValidationContext(new ToBeValidated()); + ctx.MemberName = "PropertyToBeTested"; + var ex = await Assert.ThrowsAsync( + async () => await Validator.ValidatePropertyAsync("Invalid Value", ctx)); + Assert.IsType(ex.ValidationAttribute); + } + + [Fact] + public static async Task ValidatePropertyAsync_throws_ValidationException_with_async_attr_failure() + { + var obj = new HasAsyncProperty(); + var ctx = new ValidationContext(obj); + ctx.MemberName = nameof(HasAsyncProperty.AsyncProp); + var ex = await Assert.ThrowsAsync( + async () => await Validator.ValidatePropertyAsync("anything", ctx)); + Assert.Equal("Async validation always fails", ex.ValidationResult.ErrorMessage); + } + + [Fact] + public static async Task ValidatePropertyAsync_ThrowsIf_value_is_null() + { + await Assert.ThrowsAsync( + async () => await Validator.ValidatePropertyAsync(null, s_estValidationContext)); + } + + [Fact] + public static async Task ValidatePropertyAsync_ThrowsIf_MemberName_is_null_or_empty() + { + var ctx = new ValidationContext(new ToBeValidated()); + ctx.MemberName = null; + await Assert.ThrowsAsync( + async () => await Validator.ValidatePropertyAsync(null, ctx)); + + ctx.MemberName = string.Empty; + await Assert.ThrowsAsync( + async () => await Validator.ValidatePropertyAsync(null, ctx)); + } + + [Fact] + public static async Task ValidatePropertyAsync_ThrowsIf_MemberName_does_not_exist() + { + var ctx = new ValidationContext(new ToBeValidated()); + ctx.MemberName = "NonExist"; + await AssertExtensions.ThrowsAsync("propertyName", + async () => await Validator.ValidatePropertyAsync(null, ctx)); + } + + [Fact] + public static async Task ValidatePropertyAsync_ThrowsIf_MemberName_is_not_public() + { + var ctx = new ValidationContext(new ToBeValidated()); + ctx.MemberName = "InternalProperty"; + await AssertExtensions.ThrowsAsync("propertyName", + async () => await Validator.ValidatePropertyAsync(null, ctx)); + + ctx.MemberName = "ProtectedProperty"; + await AssertExtensions.ThrowsAsync("propertyName", + async () => await Validator.ValidatePropertyAsync(null, ctx)); + + ctx.MemberName = "PrivateProperty"; + await AssertExtensions.ThrowsAsync("propertyName", + async () => await Validator.ValidatePropertyAsync(null, ctx)); + } + + [Fact] + public static async Task ValidatePropertyAsync_ThrowsIf_MemberName_is_indexer() + { + var ctx = new ValidationContext(new ToBeValidated()); + ctx.MemberName = "Item"; + await AssertExtensions.ThrowsAsync("propertyName", + async () => await Validator.ValidatePropertyAsync(null, ctx)); + } + + [Fact] + public static async Task ValidatePropertyAsync_ThrowsIf_value_is_wrong_type() + { + var ctx = new ValidationContext(new ToBeValidated()); + ctx.MemberName = "NoAttributesProperty"; + await AssertExtensions.ThrowsAsync("value", + async () => await Validator.ValidatePropertyAsync(123, ctx)); + } + + [Fact] + public static async Task ValidatePropertyAsync_ThrowsIf_null_passed_to_non_nullable() + { + var ctx = new ValidationContext(new ToBeValidated()); + + ctx.MemberName = "EnumProperty"; + await AssertExtensions.ThrowsAsync("value", + async () => await Validator.ValidatePropertyAsync(null, ctx)); + + ctx.MemberName = "NonNullableProperty"; + await AssertExtensions.ThrowsAsync("value", + async () => await Validator.ValidatePropertyAsync(null, ctx)); + } + + [Fact] + public static async Task ValidatePropertyAsync_succeeds_if_null_passed_to_nullable() + { + var ctx = new ValidationContext(new ToBeValidated()); + ctx.MemberName = "NullableProperty"; + await Validator.ValidatePropertyAsync(null, ctx); + } + + [Fact] + public static async Task ValidatePropertyAsync_throws_if_Required_fails() + { + var ctx = new ValidationContext(new ToBeValidated()); + ctx.MemberName = "PropertyWithRequiredAttribute"; + var ex = await Assert.ThrowsAsync( + async () => await Validator.ValidatePropertyAsync(null, ctx)); + Assert.IsType(ex.ValidationAttribute); + Assert.Null(ex.Value); + } + + [Fact] + public static async Task ValidatePropertyAsync_succeeds_if_all_attributes_valid() + { + var ctx = new ValidationContext(new ToBeValidated()); + ctx.MemberName = "PropertyWithRequiredAttribute"; + await Validator.ValidatePropertyAsync("Valid Value", ctx); + } + + [Fact] + public static async Task TryValidateValueAsync_ThrowsIf_ValidationContext_is_null() + { + await Assert.ThrowsAsync( + async () => await Validator.TryValidateValueAsync( + new object(), null, null, Enumerable.Empty())); + } + + [Fact] + public static async Task TryValidateValueAsync_ThrowsIf_attributes_is_null() + { + var ctx = new ValidationContext(new ToBeValidated()); + await Assert.ThrowsAsync( + async () => await Validator.TryValidateValueAsync(new object(), ctx, null, null)); + } + + [Fact] + public static async Task TryValidateValueAsync_returns_true_if_no_attributes() + { + var ctx = new ValidationContext(new ToBeValidated()); + Assert.True(await Validator.TryValidateValueAsync( + "any", ctx, null, Enumerable.Empty())); + } + + [Fact] + public static async Task TryValidateValueAsync_returns_false_with_async_attr_failure() + { + var ctx = new ValidationContext(new object()); + var attrs = new ValidationAttribute[] { new AsyncAlwaysFailsAttribute() }; + var results = new List(); + Assert.False(await Validator.TryValidateValueAsync("anything", ctx, results, attrs)); + Assert.Equal(1, results.Count); + Assert.Equal("Async validation always fails", results[0].ErrorMessage); + } + + [Fact] + public static async Task TryValidateValueAsync_returns_true_with_async_attr_success() + { + var ctx = new ValidationContext(new object()); + var attrs = new ValidationAttribute[] { new AsyncAlwaysSucceedsAttribute() }; + var results = new List(); + Assert.True(await Validator.TryValidateValueAsync("anything", ctx, results, attrs)); + Assert.Empty(results); + } + + [Fact] + public static async Task TryValidateValueAsync_sync_Required_failure_blocks_async() + { + var ctx = new ValidationContext(new object()); + var attrs = new ValidationAttribute[] { new RequiredAttribute(), new AsyncAlwaysFailsAttribute() }; + var results = new List(); + Assert.False(await Validator.TryValidateValueAsync(null, ctx, results, attrs)); + Assert.Equal(1, results.Count); + } + + [Fact] + public static async Task TryValidateValueAsync_sync_attr_failure_blocks_async() + { + var ctx = new ValidationContext(new object()); + var attrs = new ValidationAttribute[] { new ValidValueStringPropertyAttribute(), new AsyncAlwaysFailsAttribute() }; + var results = new List(); + Assert.False(await Validator.TryValidateValueAsync("Invalid Value", ctx, results, attrs)); + Assert.Equal(1, results.Count); + Assert.Equal("ValidValueStringPropertyAttribute.IsValid failed for value Invalid Value", results[0].ErrorMessage); + } + + [Fact] + public static async Task TryValidateValueAsync_sync_passes_then_async_runs() + { + var ctx = new ValidationContext(new object()); + var attrs = new ValidationAttribute[] { new ValidValueStringPropertyAttribute(), new AsyncAlwaysFailsAttribute() }; + var results = new List(); + Assert.False(await Validator.TryValidateValueAsync("Valid Value", ctx, results, attrs)); + Assert.Equal(1, results.Count); + Assert.Equal("Async validation always fails", results[0].ErrorMessage); + } + + [Fact] + public static async Task TryValidateValueAsync_returns_true_if_Required_and_valid() + { + var ctx = new ValidationContext(new ToBeValidated()); + ctx.MemberName = "PropertyWithRequiredAttribute"; + var attrs = new ValidationAttribute[] { new RequiredAttribute(), new ValidValueStringPropertyAttribute() }; + Assert.True(await Validator.TryValidateValueAsync("Valid Value", ctx, null, attrs)); + + var results = new List(); + Assert.True(await Validator.TryValidateValueAsync("Valid Value", ctx, results, attrs)); + Assert.Equal(0, results.Count); + } + + [Fact] + public static async Task TryValidateValueAsync_returns_false_if_Required_and_invalid() + { + var ctx = new ValidationContext(new ToBeValidated()); + ctx.MemberName = "PropertyWithRequiredAttribute"; + var attrs = new ValidationAttribute[] { new RequiredAttribute(), new ValidValueStringPropertyAttribute() }; + Assert.False(await Validator.TryValidateValueAsync("Invalid Value", ctx, null, attrs)); + + var results = new List(); + Assert.False(await Validator.TryValidateValueAsync("Invalid Value", ctx, results, attrs)); + Assert.Equal(1, results.Count); + Assert.Equal("ValidValueStringPropertyAttribute.IsValid failed for value Invalid Value", results[0].ErrorMessage); + } + + [Fact] + public static async Task TryValidateValueAsync_returns_true_if_no_Required_and_valid() + { + var ctx = new ValidationContext(new ToBeValidated()); + ctx.MemberName = "PropertyToBeTested"; + var attrs = new ValidationAttribute[] { new ValidValueStringPropertyAttribute() }; + Assert.True(await Validator.TryValidateValueAsync("Valid Value", ctx, null, attrs)); + + var results = new List(); + Assert.True(await Validator.TryValidateValueAsync("Valid Value", ctx, results, attrs)); + Assert.Equal(0, results.Count); + } + + [Fact] + public static async Task TryValidateValueAsync_returns_false_if_no_Required_and_invalid() + { + var ctx = new ValidationContext(new ToBeValidated()); + ctx.MemberName = "PropertyToBeTested"; + var attrs = new ValidationAttribute[] { new ValidValueStringPropertyAttribute() }; + Assert.False(await Validator.TryValidateValueAsync("Invalid Value", ctx, null, attrs)); + + var results = new List(); + Assert.False(await Validator.TryValidateValueAsync("Invalid Value", ctx, results, attrs)); + Assert.Equal(1, results.Count); + Assert.Equal("ValidValueStringPropertyAttribute.IsValid failed for value Invalid Value", results[0].ErrorMessage); + } + + [Fact] + public static async Task TryValidateValueAsync_collection_can_have_multiple_results() + { + var ctx = new ValidationContext(new HasDoubleFailureProperty()); + ctx.MemberName = nameof(HasDoubleFailureProperty.WillAlwaysFailTwice); + var attrs = new ValidationAttribute[] { new ValidValueStringPropertyAttribute(), new ValidValueStringPropertyDuplicateAttribute() }; + var results = new List(); + Assert.False(await Validator.TryValidateValueAsync("Not Valid", ctx, results, attrs)); + Assert.Equal(2, results.Count); + } + + [Fact] + public static async Task ValidateValueAsync_ThrowsIf_ValidationContext_is_null() + { + await Assert.ThrowsAsync( + async () => await Validator.ValidateValueAsync( + new object(), null, Enumerable.Empty())); + } + + [Fact] + public static async Task ValidateValueAsync_ThrowsIf_attributes_is_null() + { + var ctx = new ValidationContext(new ToBeValidated()); + await Assert.ThrowsAsync( + async () => await Validator.ValidateValueAsync(new object(), ctx, null)); + } + + [Fact] + public static async Task ValidateValueAsync_succeeds_if_no_attributes() + { + var ctx = new ValidationContext(new ToBeValidated()); + await Validator.ValidateValueAsync("any", ctx, Enumerable.Empty()); + } + + [Fact] + public static async Task ValidateValueAsync_throws_ValidationException_with_async_attr_failure() + { + var ctx = new ValidationContext(new object()); + var attrs = new ValidationAttribute[] { new AsyncAlwaysFailsAttribute() }; + var ex = await Assert.ThrowsAsync( + async () => await Validator.ValidateValueAsync("anything", ctx, attrs)); + Assert.Equal("Async validation always fails", ex.ValidationResult.ErrorMessage); + } + + [Fact] + public static async Task ValidateValueAsync_succeeds_with_async_attr_success() + { + var ctx = new ValidationContext(new object()); + var attrs = new ValidationAttribute[] { new AsyncAlwaysSucceedsAttribute() }; + await Validator.ValidateValueAsync("anything", ctx, attrs); + } + + [Fact] + public static async Task ValidateValueAsync_throws_if_Required_and_null() + { + var ctx = new ValidationContext(new ToBeValidated()); + ctx.MemberName = "PropertyWithRequiredAttribute"; + var attrs = new ValidationAttribute[] { new RequiredAttribute(), new ValidValueStringPropertyAttribute() }; + var ex = await Assert.ThrowsAsync( + async () => await Validator.ValidateValueAsync(null, ctx, attrs)); + Assert.IsType(ex.ValidationAttribute); + Assert.Null(ex.Value); + } + + [Fact] + public static async Task ValidateValueAsync_throws_if_Required_and_invalid() + { + var ctx = new ValidationContext(new ToBeValidated()); + ctx.MemberName = "PropertyWithRequiredAttribute"; + var attrs = new ValidationAttribute[] { new RequiredAttribute(), new ValidValueStringPropertyAttribute() }; + var ex = await Assert.ThrowsAsync( + async () => await Validator.ValidateValueAsync("Invalid Value", ctx, attrs)); + Assert.IsType(ex.ValidationAttribute); + Assert.Equal("ValidValueStringPropertyAttribute.IsValid failed for value Invalid Value", ex.ValidationResult.ErrorMessage); + Assert.Equal("Invalid Value", ex.Value); + } + + [Fact] + public static async Task ValidateValueAsync_succeeds_if_Required_and_valid() + { + var ctx = new ValidationContext(new ToBeValidated()); + ctx.MemberName = "PropertyWithRequiredAttribute"; + var attrs = new ValidationAttribute[] { new RequiredAttribute(), new ValidValueStringPropertyAttribute() }; + await Validator.ValidateValueAsync("Valid Value", ctx, attrs); + } + + [Fact] + public static async Task ValidateValueAsync_throws_if_no_Required_and_invalid() + { + var ctx = new ValidationContext(new ToBeValidated()); + ctx.MemberName = "PropertyWithRequiredAttribute"; + var attrs = new ValidationAttribute[] { new ValidValueStringPropertyAttribute() }; + var ex = await Assert.ThrowsAsync( + async () => await Validator.ValidateValueAsync("Invalid Value", ctx, attrs)); + Assert.IsType(ex.ValidationAttribute); + Assert.Equal("ValidValueStringPropertyAttribute.IsValid failed for value Invalid Value", ex.ValidationResult.ErrorMessage); + Assert.Equal("Invalid Value", ex.Value); + } + + [Fact] + public static async Task ValidateValueAsync_succeeds_if_no_Required_and_valid() + { + var ctx = new ValidationContext(new ToBeValidated()); + ctx.MemberName = "PropertyToBeTested"; + var attrs = new ValidationAttribute[] { new ValidValueStringPropertyAttribute() }; + await Validator.ValidateValueAsync("Valid Value", ctx, attrs); + } + + [Fact] + public static async Task TryValidateObjectAsync_IAsyncValidatableObject_Success() + { + var instance = new AsyncValidatableSuccess(); + var ctx = new ValidationContext(instance); + var results = new List(); + Assert.True(await Validator.TryValidateObjectAsync(instance, ctx, results)); + Assert.Empty(results); + } + + [Fact] + public static async Task TryValidateObjectAsync_IAsyncValidatableObject_Error() + { + var instance = new AsyncValidatableError(); + var ctx = new ValidationContext(instance); + var results = new List(); + Assert.False(await Validator.TryValidateObjectAsync(instance, ctx, results)); + Assert.Equal("async object error", Assert.Single(results).ErrorMessage); + } + + [Fact] + public static async Task TryValidateObjectAsync_IAsyncValidatableObject_Null_Result() + { + var instance = new AsyncValidatableNull(); + var ctx = new ValidationContext(instance); + var results = new List(); + Assert.True(await Validator.TryValidateObjectAsync(instance, ctx, results)); + Assert.Empty(results); + } + + [Fact] + public static async Task TryValidateObjectAsync_IAsyncValidatableObject_Preferred_Over_IValidatableObject() + { + var instance = new DualValidatableModel(); + var ctx = new ValidationContext(instance); + var results = new List(); + Assert.False(await Validator.TryValidateObjectAsync(instance, ctx, results)); + Assert.Equal("async error from dual model", Assert.Single(results).ErrorMessage); + } + + [Fact] + public static async Task TryValidateObjectAsync_IValidatableObject_Fallback() + { + var instance = new ValidatableError(); + var ctx = new ValidationContext(instance); + var results = new List(); + Assert.False(await Validator.TryValidateObjectAsync(instance, ctx, results)); + Assert.Equal("error", Assert.Single(results).ErrorMessage); + } + + [Fact] + public static async Task TryValidateObjectAsync_IAsyncValidatableObject_SkippedIfPropertyErrors() + { + var instance = new AsyncValidatableWithRequired { RequiredProp = null }; + var ctx = new ValidationContext(instance); + var results = new List(); + Assert.False(await Validator.TryValidateObjectAsync(instance, ctx, results, true)); + Assert.Equal(1, results.Count); + Assert.DoesNotContain(results, r => r.ErrorMessage == "async object error"); + } + + [Fact] + public static async Task TryValidateObjectAsync_CancellationToken_Propagated() + { + var obj = new HasAsyncCancellableProperty { CancellableProp = "value" }; + var ctx = new ValidationContext(obj); + var cts = new CancellationTokenSource(); + cts.Cancel(); + await Assert.ThrowsAsync( + async () => await Validator.TryValidateObjectAsync(obj, ctx, null, true, cts.Token)); + } + + [Fact] + public static async Task TryValidateValueAsync_CancellationToken_Propagated() + { + var ctx = new ValidationContext(new object()); + var attrs = new ValidationAttribute[] { new AsyncCancellableAttribute() }; + var cts = new CancellationTokenSource(); + cts.Cancel(); + await Assert.ThrowsAsync( + async () => await Validator.TryValidateValueAsync("value", ctx, null, attrs, cts.Token)); + } + + [Fact] + public static async Task ValidateObjectAsync_CancellationToken_Propagated() + { + var obj = new HasAsyncCancellableProperty { CancellableProp = "value" }; + var ctx = new ValidationContext(obj); + var cts = new CancellationTokenSource(); + cts.Cancel(); + await Assert.ThrowsAsync( + async () => await Validator.ValidateObjectAsync(obj, ctx, true, cts.Token)); + } + + [Fact] + public static async Task TwoPhase_SyncFailure_BlocksAsyncExecution() + { + var obj = new HasMixedValidation { MixedProp = "Invalid Value" }; + var ctx = new ValidationContext(obj); + var results = new List(); + Assert.False(await Validator.TryValidateObjectAsync(obj, ctx, results, true)); + Assert.Equal(1, results.Count); + Assert.Equal("ValidValueStringPropertyAttribute.IsValid failed for value Invalid Value", results[0].ErrorMessage); + } + + [Fact] + public static async Task TwoPhase_SyncPasses_AsyncRuns() + { + var obj = new HasMixedValidation { MixedProp = "Valid Value" }; + var ctx = new ValidationContext(obj); + var results = new List(); + Assert.False(await Validator.TryValidateObjectAsync(obj, ctx, results, true)); + Assert.Equal(1, results.Count); + Assert.Equal("Async validation always fails", results[0].ErrorMessage); + } + + [Fact] + public static async Task TwoPhase_AllPass() + { + var obj = new HasMixedPassingValidation { MixedProp = "Valid Value" }; + var ctx = new ValidationContext(obj); + var results = new List(); + Assert.True(await Validator.TryValidateObjectAsync(obj, ctx, results, true)); + Assert.Empty(results); + } + + [Fact] + public static async Task TwoPhase_Required_Fails_AsyncSkipped() + { + var obj = new HasRequiredAndAsyncProperty { Prop = null }; + var ctx = new ValidationContext(obj); + var results = new List(); + Assert.False(await Validator.TryValidateObjectAsync(obj, ctx, results, true)); + Assert.Equal(1, results.Count); + Assert.DoesNotContain(results, r => r.ErrorMessage == "Async validation always fails"); + } + + [Fact] + public static async Task TryValidateObjectAsync_EmptyObject_ReturnsTrue() + { + var obj = new object(); + var ctx = new ValidationContext(obj); + Assert.True(await Validator.TryValidateObjectAsync(obj, ctx, null)); + } + + [Fact] + public static async Task TryValidateObjectAsync_OnlyAsyncAttrs_Success() + { + var obj = new HasAsyncSucceedingProperty { AsyncProp = "anything" }; + var ctx = new ValidationContext(obj); + var results = new List(); + Assert.True(await Validator.TryValidateObjectAsync(obj, ctx, results, true)); + Assert.Empty(results); + } + + [Fact] + public static async Task TryValidateObjectAsync_TrulyAsyncAttr_Success() + { + var obj = new HasTrulyAsyncProperty { AsyncProp = "Valid Value" }; + var ctx = new ValidationContext(obj); + var results = new List(); + Assert.True(await Validator.TryValidateObjectAsync(obj, ctx, results, true)); + Assert.Empty(results); + } + + [Fact] + public static async Task TryValidateObjectAsync_TrulyAsyncAttr_Failure() + { + var obj = new HasTrulyAsyncProperty { AsyncProp = "Invalid" }; + var ctx = new ValidationContext(obj); + var results = new List(); + Assert.False(await Validator.TryValidateObjectAsync(obj, ctx, results, true)); + Assert.Equal(1, results.Count); + } + + [Fact] + public static async Task ValidateObjectAsync_ClassLevel_AsyncAttr() + { + var obj = new HasAsyncClassLevelAttr(); + var ctx = new ValidationContext(obj); + var ex = await Assert.ThrowsAsync( + async () => await Validator.ValidateObjectAsync(obj, ctx, true)); + Assert.Equal("Async class validation failed", ex.ValidationResult.ErrorMessage); + } + + [Fact] + public static async Task TryValidateObjectAsync_MultipleAsyncProperties_RunInParallel() + { + AsyncConcurrencyProbeAttribute.Reset(expectedCount: 2); + var obj = new HasMultipleConcurrencyProbeProperties(); + var ctx = new ValidationContext(obj); + var results = new List(); + Assert.True(await Validator.TryValidateObjectAsync(obj, ctx, results, true)); + Assert.Empty(results); + } + + [Fact] + public static async Task TryValidateObjectAsync_MultipleAsyncProperties_CollectsAllFailures() + { + var obj = new HasMultipleFailingAsyncProperties(); + var ctx = new ValidationContext(obj); + var results = new List(); + Assert.False(await Validator.TryValidateObjectAsync(obj, ctx, results, true)); + Assert.Equal(2, results.Count); + } + + [Fact] + public static async Task TryValidateValueAsync_MultipleAsyncAttrs_RunInParallel() + { + AsyncConcurrencyProbeAttribute.Reset(expectedCount: 2); + var ctx = new ValidationContext(new object()) { MemberName = "TestProp" }; + var results = new List(); + var attrs = new ValidationAttribute[] { new AsyncConcurrencyProbeAttribute(), new AsyncConcurrencyProbeAttribute() }; + Assert.True(await Validator.TryValidateValueAsync("Valid Value", ctx, results, attrs)); + Assert.Empty(results); + } + + [Fact] + public static async Task TryValidateValueAsync_MultipleAsyncAttrs_CollectsAllFailures() + { + var ctx = new ValidationContext(new object()) { MemberName = "TestProp" }; + var results = new List(); + var attrs = new ValidationAttribute[] { new AsyncAlwaysFailsAttribute(), new AsyncAlwaysFailsAttribute() }; + Assert.False(await Validator.TryValidateValueAsync("anything", ctx, results, attrs)); + Assert.Equal(2, results.Count); + } + + [Fact] + public static void TryValidateObject_IAsyncValidatableObject_SyncPath_ThrowsInvalidOperation() + { + var instance = new AsyncValidatableError(); + var ctx = new ValidationContext(instance); + var results = new List(); + Assert.Throws( + () => Validator.TryValidateObject(instance, ctx, results)); + } + + [Fact] + public static void TryValidateObject_DualModel_SyncPath_UsesExplicitValidate() + { + var instance = new DualValidatableModel(); + var ctx = new ValidationContext(instance); + var results = new List(); + Assert.False(Validator.TryValidateObject(instance, ctx, results)); + Assert.Equal("sync error from dual model", Assert.Single(results).ErrorMessage); + } + + [Fact] + public static void IAsyncValidatableObject_InheritsIValidatableObject() + { + var instance = new AsyncValidatableSuccess(); + Assert.IsAssignableFrom(instance); + } } }