From dd53c4efed7ff5267828e860cbfc50a12db410a9 Mon Sep 17 00:00:00 2001 From: maliming Date: Thu, 10 Sep 2026 12:22:04 +0800 Subject: [PATCH] Support async data annotations validation on .NET 11 --- ...AbpMvcAttributeValidationResultProvider.cs | 22 +++- ...taAnnotationObjectValidationContributor.cs | 120 ++++++++++++++++++ ...efaultAttributeValidationResultProvider.cs | 22 +++- .../IAttributeValidationResultProvider.cs | 20 ++- .../AsyncDataAnnotations_Validation_Tests.cs | 120 ++++++++++++++++++ 5 files changed, 299 insertions(+), 5 deletions(-) create mode 100644 framework/test/Volo.Abp.Validation.Tests/Volo/Abp/Validation/AsyncDataAnnotations_Validation_Tests.cs diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpMvcAttributeValidationResultProvider.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpMvcAttributeValidationResultProvider.cs index 34aeb2c44c..cc2d1eed1d 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpMvcAttributeValidationResultProvider.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpMvcAttributeValidationResultProvider.cs @@ -1,5 +1,7 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Options; using Volo.Abp.AspNetCore.Mvc.Validation; @@ -23,11 +25,27 @@ public class AbpMvcAttributeValidationResultProvider : DefaultAttributeValidatio } public override ValidationResult? GetOrDefault(ValidationAttribute validationAttribute, object? validatingObject, ValidationContext validationContext) + { + LocalizeErrorMessage(validationAttribute, validationContext); + return base.GetOrDefault(validationAttribute, validatingObject, validationContext); + } + + public override Task GetOrDefaultAsync( + ValidationAttribute validationAttribute, + object? validatingObject, + ValidationContext validationContext, + CancellationToken cancellationToken = default) + { + LocalizeErrorMessage(validationAttribute, validationContext); + return base.GetOrDefaultAsync(validationAttribute, validatingObject, validationContext, cancellationToken); + } + + protected virtual void LocalizeErrorMessage(ValidationAttribute validationAttribute, ValidationContext validationContext) { var resourceSource = _abpMvcDataAnnotationsLocalizationOptions.AssemblyResources.GetOrDefault(validationContext.ObjectType.Assembly); if (resourceSource == null) { - return base.GetOrDefault(validationAttribute, validatingObject, validationContext); + return; } if (validationAttribute.ErrorMessage == null) @@ -39,7 +57,5 @@ public class AbpMvcAttributeValidationResultProvider : DefaultAttributeValidatio { validationAttribute.ErrorMessage = _stringLocalizerFactory.Create(resourceSource)[validationAttribute.ErrorMessage]; } - - return base.GetOrDefault(validationAttribute, validatingObject, validationContext); } } diff --git a/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/DataAnnotationObjectValidationContributor.cs b/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/DataAnnotationObjectValidationContributor.cs index 90afe4d60e..986dc309e1 100644 --- a/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/DataAnnotationObjectValidationContributor.cs +++ b/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/DataAnnotationObjectValidationContributor.cs @@ -29,8 +29,12 @@ public class DataAnnotationObjectValidationContributor : IObjectValidationContri public Task AddErrorsAsync(ObjectValidationContext context) { +#if NET11_0_OR_GREATER + return ValidateObjectRecursivelyAsync(context.Errors, context.ValidatingObject, currentDepth: 1); +#else ValidateObjectRecursively(context.Errors, context.ValidatingObject, currentDepth: 1); return Task.CompletedTask; +#endif } protected virtual void ValidateObjectRecursively(List errors, object? validatingObject, int currentDepth) @@ -133,4 +137,120 @@ public class DataAnnotationObjectValidationContributor : IObjectValidationContri } } } + +#if NET11_0_OR_GREATER + /* Mirrors the synchronous walk above. Attributes deriving from AsyncValidationAttribute and + * objects implementing IAsyncValidatableObject only produce results when awaited, so the + * synchronous path either throws or silently validates nothing for them. */ + protected virtual async Task ValidateObjectRecursivelyAsync(List errors, object? validatingObject, int currentDepth) + { + if (currentDepth > MaxRecursiveParameterValidationDepth) + { + return; + } + + if (validatingObject == null) + { + return; + } + + await AddErrorsAsync(errors, validatingObject); + + //Validate items of enumerable + if (validatingObject is IEnumerable enumerable) + { + if (!(enumerable is IQueryable)) + { + foreach (var item in enumerable) + { + //Do not recursively validate for primitive objects + if (item == null || TypeHelper.IsPrimitiveExtended(item.GetType())) + { + break; + } + + await ValidateObjectRecursivelyAsync(errors, item, currentDepth + 1); + } + } + + return; + } + + var validatingObjectType = validatingObject.GetType(); + + //Do not recursively validate for primitive objects + if (TypeHelper.IsPrimitiveExtended(validatingObjectType)) + { + return; + } + + if (Options.IgnoredTypes.Any(t => t.IsInstanceOfType(validatingObject))) + { + return; + } + + var properties = TypeDescriptor.GetProperties(validatingObject).Cast(); + foreach (var property in properties) + { + if (property.Attributes.OfType().Any()) + { + continue; + } + + await ValidateObjectRecursivelyAsync(errors, property.GetValue(validatingObject), currentDepth + 1); + } + } + + public virtual async Task AddErrorsAsync(List errors, object validatingObject) + { + var properties = TypeDescriptor.GetProperties(validatingObject).Cast(); + + foreach (var property in properties) + { + await AddPropertyErrorsAsync(validatingObject, property, errors); + } + + /* IAsyncValidatableObject extends IValidatableObject, so the asynchronous overload has to win + * to avoid running the object twice and to avoid the synchronous Validate that such a type is + * expected to throw from. */ + if (validatingObject is IAsyncValidatableObject asyncValidatableObject) + { + await foreach (var result in asyncValidatableObject.ValidateAsync(new ValidationContext(asyncValidatableObject, ServiceProvider, null))) + { + errors.Add(result); + } + } + else if (validatingObject is IValidatableObject validatableObject) + { + errors.AddRange( + validatableObject.Validate(new ValidationContext(validatableObject, ServiceProvider, null)) + ); + } + } + + protected virtual async Task AddPropertyErrorsAsync(object validatingObject, PropertyDescriptor property, List errors) + { + var validationAttributes = property.Attributes.OfType().ToArray(); + if (validationAttributes.IsNullOrEmpty()) + { + return; + } + + var validationContext = new ValidationContext(validatingObject, ServiceProvider, null) + { + DisplayName = property.DisplayName, + MemberName = property.Name + }; + + var attributeValidationResultProvider = ServiceProvider.GetRequiredService(); + foreach (var attribute in validationAttributes) + { + var result = await attributeValidationResultProvider.GetOrDefaultAsync(attribute, property.GetValue(validatingObject), validationContext); + if (result != null) + { + errors.Add(result); + } + } + } +#endif } diff --git a/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/DefaultAttributeValidationResultProvider.cs b/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/DefaultAttributeValidationResultProvider.cs index ced3615d04..1b3a821b72 100644 --- a/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/DefaultAttributeValidationResultProvider.cs +++ b/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/DefaultAttributeValidationResultProvider.cs @@ -1,5 +1,9 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using Volo.Abp.DependencyInjection; +#if NET11_0_OR_GREATER +using System.Threading; +using System.Threading.Tasks; +#endif namespace Volo.Abp.Validation; @@ -9,4 +13,20 @@ public class DefaultAttributeValidationResultProvider : IAttributeValidationResu { return validationAttribute.GetValidationResult(validatingObject, validationContext); } + +#if NET11_0_OR_GREATER + public virtual Task GetOrDefaultAsync( + ValidationAttribute validationAttribute, + object? validatingObject, + ValidationContext validationContext, + CancellationToken cancellationToken = default) + { + if (validationAttribute is AsyncValidationAttribute asyncValidationAttribute) + { + return asyncValidationAttribute.GetValidationResultAsync(validatingObject, validationContext, cancellationToken); + } + + return Task.FromResult(GetOrDefault(validationAttribute, validatingObject, validationContext)); + } +#endif } diff --git a/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/IAttributeValidationResultProvider.cs b/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/IAttributeValidationResultProvider.cs index 5cc919b967..aaa1f49d80 100644 --- a/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/IAttributeValidationResultProvider.cs +++ b/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/IAttributeValidationResultProvider.cs @@ -1,8 +1,26 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; +#if NET11_0_OR_GREATER +using System.Threading; +using System.Threading.Tasks; +#endif namespace Volo.Abp.Validation; public interface IAttributeValidationResultProvider { ValidationResult? GetOrDefault(ValidationAttribute validationAttribute, object? validatingObject, ValidationContext validationContext); + +#if NET11_0_OR_GREATER + /* AsyncValidationAttribute seals IsValid and routes it through the synchronous overload, which + * attributes that only validate asynchronously are expected to throw from. They have to be + * awaited here instead. The default implementation keeps existing providers working. */ + Task GetOrDefaultAsync( + ValidationAttribute validationAttribute, + object? validatingObject, + ValidationContext validationContext, + CancellationToken cancellationToken = default) + { + return Task.FromResult(GetOrDefault(validationAttribute, validatingObject, validationContext)); + } +#endif } diff --git a/framework/test/Volo.Abp.Validation.Tests/Volo/Abp/Validation/AsyncDataAnnotations_Validation_Tests.cs b/framework/test/Volo.Abp.Validation.Tests/Volo/Abp/Validation/AsyncDataAnnotations_Validation_Tests.cs new file mode 100644 index 0000000000..b14e5be0fb --- /dev/null +++ b/framework/test/Volo.Abp.Validation.Tests/Volo/Abp/Validation/AsyncDataAnnotations_Validation_Tests.cs @@ -0,0 +1,120 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Shouldly; +using Volo.Abp.Autofac; +using Volo.Abp.Modularity; +using Volo.Abp.Testing; +using Xunit; + +namespace Volo.Abp.Validation; + +public class AsyncDataAnnotations_Validation_Tests : AbpIntegratedTest +{ + private readonly IObjectValidator _objectValidator; + + public AsyncDataAnnotations_Validation_Tests() + { + _objectValidator = GetRequiredService(); + } + + protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options) + { + options.UseAutofac(); + } + + [Fact] + public async Task Should_Run_Async_Validation_Attribute() + { + var errors = await _objectValidator.GetErrorsAsync(new AsyncAttributeInput { Name = "taken" }); + + errors.ShouldContain(e => e.ErrorMessage == "Name is already taken"); + } + + [Fact] + public async Task Should_Not_Report_Error_When_Async_Validation_Attribute_Passes() + { + var errors = await _objectValidator.GetErrorsAsync(new AsyncAttributeInput { Name = "free" }); + + errors.ShouldBeEmpty(); + } + + [Fact] + public async Task Should_Run_IAsyncValidatableObject() + { + var errors = await _objectValidator.GetErrorsAsync(new AsyncValidatableInput { Value = -1 }); + + errors.ShouldContain(e => e.ErrorMessage == "Value must not be negative"); + } + + [Fact] + public async Task Should_Not_Run_Synchronous_Validate_Of_IAsyncValidatableObject() + { + var input = new AsyncValidatableInput { Value = 1 }; + + await _objectValidator.ValidateAsync(input); + + /* IAsyncValidatableObject extends IValidatableObject. Running both would validate twice and + * the synchronous overload is the one such a type is expected to throw from. */ + input.SynchronousValidateCallCount.ShouldBe(0); + } + + public class AsyncAttributeInput + { + [NameNotTaken] + public string? Name { get; set; } + } + + public class NameNotTakenAttribute : AsyncValidationAttribute + { + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + { + throw new AbpException("This attribute only validates asynchronously."); + } + + protected override async Task IsValidAsync( + object? value, + ValidationContext validationContext, + CancellationToken cancellationToken) + { + await Task.Yield(); + + return value as string == "taken" + ? new ValidationResult("Name is already taken", new[] { validationContext.MemberName! }) + : ValidationResult.Success; + } + } + + public class AsyncValidatableInput : IAsyncValidatableObject + { + public int Value { get; set; } + + public int SynchronousValidateCallCount { get; private set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + SynchronousValidateCallCount++; + return Enumerable.Empty(); + } + + public async IAsyncEnumerable ValidateAsync( + ValidationContext validationContext, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + + if (Value < 0) + { + yield return new ValidationResult("Value must not be negative", new[] { nameof(Value) }); + } + } + } + + [DependsOn(typeof(AbpValidationModule), typeof(AbpAutofacModule))] + public class TestModule : AbpModule + { + } +}