From 234c46aab8091bf65e5e108fcd6242544eb3c30c Mon Sep 17 00:00:00 2001 From: maliming Date: Wed, 18 Mar 2026 09:56:08 +0800 Subject: [PATCH] Fix FluentValidation not working with ConventionalControllers When Application Services are registered via ConventionalControllers.Create(), their types are added to DynamicProxyIgnoreTypes, which disables the ValidationInterceptor. The AbpValidationActionFilter only checked ModelState (DataAnnotations), so FluentValidation rules were never executed. Add IValidationEnabled check in AbpValidationActionFilter to call IMethodInvocationValidator for conventional controllers, enabling FluentValidation support without duplicating validation for regular controllers. Resolve #23457 --- .../api-development/auto-controllers.md | 4 +- .../Validation/AbpValidationActionFilter.cs | 22 +++++++ .../Volo.Abp.AspNetCore.Mvc.Tests.csproj | 1 + .../Mvc/AbpAspNetCoreMvcTestModule.cs | 4 +- .../FluentValidationTestAppService_Tests.cs | 64 +++++++++++++++++++ .../FluentValidationTestInputValidator.cs | 12 ++++ .../Validation/ValidationTestController.cs | 11 ++++ .../ValidationTestController_Tests.cs | 18 ++++++ .../FluentValidationTestAppService.cs | 17 +++++ 9 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Validation/FluentValidationTestAppService_Tests.cs create mode 100644 framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Validation/FluentValidationTestInputValidator.cs create mode 100644 framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/FluentValidationTestAppService.cs diff --git a/docs/en/framework/api-development/auto-controllers.md b/docs/en/framework/api-development/auto-controllers.md index b40718b079..2c6f0ee39a 100644 --- a/docs/en/framework/api-development/auto-controllers.md +++ b/docs/en/framework/api-development/auto-controllers.md @@ -70,7 +70,7 @@ Route is calculated based on some conventions: * Continues with a **route path**. Default value is '**/app**' and can be configured as like below: ````csharp -Configure(options => +PreConfigure(options => { options.ConventionalControllers .Create(typeof(BookStoreApplicationModule).Assembly, opts => @@ -149,7 +149,7 @@ public class PersonAppService : ApplicationService You can further filter classes to become an API controller by providing the `TypePredicate` option: ````csharp -services.Configure(options => +PreConfigure(options => { options.ConventionalControllers .Create(typeof(BookStoreApplicationModule).Assembly, opts => diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Validation/AbpValidationActionFilter.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Validation/AbpValidationActionFilter.cs index f866c85585..ba2f0828cc 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Validation/AbpValidationActionFilter.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Validation/AbpValidationActionFilter.cs @@ -60,6 +60,28 @@ public class AbpValidationActionFilter : IAsyncActionFilter, IAbpFilter, ITransi } context.GetRequiredService().Validate(context.ModelState); + + if (context.Controller is IValidationEnabled) + { + await ValidateActionArgumentsAsync(context); + } + await next(); } + + protected virtual async Task ValidateActionArgumentsAsync(ActionExecutingContext context) + { + var methodInfo = context.ActionDescriptor.GetMethodInfo(); + var parameterValues = methodInfo.GetParameters() + .Select(p => context.ActionArguments.TryGetValue(p.Name!, out var value) ? value : null) + .ToArray(); + + await context.GetRequiredService().ValidateAsync( + new MethodInvocationValidationContext( + context.Controller, + methodInfo, + parameterValues + ) + ); + } } diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo.Abp.AspNetCore.Mvc.Tests.csproj b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo.Abp.AspNetCore.Mvc.Tests.csproj index 347da6f0bd..8a929dd38a 100644 --- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo.Abp.AspNetCore.Mvc.Tests.csproj +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo.Abp.AspNetCore.Mvc.Tests.csproj @@ -26,6 +26,7 @@ + diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcTestModule.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcTestModule.cs index 1c705d8df3..9154608680 100644 --- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcTestModule.cs +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcTestModule.cs @@ -23,6 +23,7 @@ using Volo.Abp.TestApp; using Volo.Abp.TestApp.Application; using Volo.Abp.Threading; using Volo.Abp.Validation.Localization; +using Volo.Abp.FluentValidation; using Volo.Abp.VirtualFileSystem; namespace Volo.Abp.AspNetCore.Mvc; @@ -31,7 +32,8 @@ namespace Volo.Abp.AspNetCore.Mvc; typeof(AbpAspNetCoreTestBaseModule), typeof(AbpMemoryDbTestModule), typeof(AbpAspNetCoreMvcModule), - typeof(AbpAutofacModule) + typeof(AbpAutofacModule), + typeof(AbpFluentValidationModule) )] public class AbpAspNetCoreMvcTestModule : AbpModule { diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Validation/FluentValidationTestAppService_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Validation/FluentValidationTestAppService_Tests.cs new file mode 100644 index 0000000000..b1d1f56ed5 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Validation/FluentValidationTestAppService_Tests.cs @@ -0,0 +1,64 @@ +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Shouldly; +using Volo.Abp.Http; +using Xunit; + +namespace Volo.Abp.AspNetCore.Mvc.Validation; + +public class FluentValidationTestAppService_Tests : AspNetCoreMvcTestBase +{ + [Fact] + public async Task Should_Validate_With_FluentValidation_On_ConventionalController() + { + // Name is "A" which is less than 3 characters, should fail FluentValidation + var response = await PostAsync("{\"name\": \"A\"}"); + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Should_Validate_With_FluentValidation_On_ConventionalController_EmptyName() + { + // Empty name should fail FluentValidation (NotEmpty rule) + var response = await PostAsync("{\"name\": \"\"}"); + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Should_Validate_With_FluentValidation_On_ConventionalController_MaxLength() + { + // Name exceeds 10 characters, should fail FluentValidation (MaximumLength rule) + var response = await PostAsync("{\"name\": \"12345678901\"}"); + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Should_Return_Validation_Errors_With_Details() + { + var response = await PostAsync("{\"name\": \"A\"}"); + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + + var content = await response.Content.ReadAsStringAsync(); + content.ShouldContain("Name"); + content.ShouldContain("validationErrors"); + } + + [Fact] + public async Task Should_Pass_Validation_With_Valid_Input() + { + var response = await PostAsync("{\"name\": \"Hello\"}"); + response.StatusCode.ShouldBe(HttpStatusCode.OK); + } + + private async Task PostAsync(string jsonContent) + { + var request = new HttpRequestMessage(HttpMethod.Post, "/api/app/fluent-validation-test") + { + Content = new StringContent(jsonContent, Encoding.UTF8, "application/json") + }; + return await Client.SendAsync(request); + } +} diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Validation/FluentValidationTestInputValidator.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Validation/FluentValidationTestInputValidator.cs new file mode 100644 index 0000000000..d99ee32179 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Validation/FluentValidationTestInputValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; +using Volo.Abp.TestApp.Application; + +namespace Volo.Abp.AspNetCore.Mvc.Validation; + +public class FluentValidationTestInputValidator : AbstractValidator +{ + public FluentValidationTestInputValidator() + { + RuleFor(x => x.Name).NotEmpty().MinimumLength(3).MaximumLength(10); + } +} diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Validation/ValidationTestController.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Validation/ValidationTestController.cs index 3a4fe19fc8..72fb5ccb7d 100644 --- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Validation/ValidationTestController.cs +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Validation/ValidationTestController.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Shouldly; using Volo.Abp.DependencyInjection; +using Volo.Abp.TestApp.Application; using Volo.Abp.Validation; namespace Volo.Abp.AspNetCore.Mvc.Validation; @@ -120,6 +121,16 @@ public class ValidationTestController : AbpController } } + [HttpPost] + [Route("fluent-validation-action")] + public Task FluentValidationAction([FromBody] FluentValidationTestInput input) + { + // This action uses a DTO that has a FluentValidator registered, + // but since this is a regular AbpController (not IValidationEnabled), + // FluentValidation should NOT be triggered by AbpValidationActionFilter. + return Task.FromResult(input.Name); + } + public class CustomValidateModel : IValidatableObject { public string Value1 { get; set; } diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Validation/ValidationTestController_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Validation/ValidationTestController_Tests.cs index 409a804fa2..a0d0040cc9 100644 --- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Validation/ValidationTestController_Tests.cs +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Validation/ValidationTestController_Tests.cs @@ -1,4 +1,6 @@ using System.Net; +using System.Net.Http; +using System.Text; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Shouldly; @@ -112,6 +114,22 @@ public class ValidationTestController_Tests : AspNetCoreMvcTestBase var result = await GetResponseAsStringAsync("/api/validation-test/object-result-action2"); result.ShouldBe("ModelState.IsValid: false"); } + + [Fact] + public async Task Should_Not_Trigger_FluentValidation_On_Regular_Controller() + { + // FluentValidationTestInput has a FluentValidator with MinimumLength(3) rule, + // but this is a regular AbpController (not IValidationEnabled), + // so FluentValidation should NOT be triggered. + var request = new HttpRequestMessage(HttpMethod.Post, "/api/validation-test/fluent-validation-action") + { + Content = new StringContent("{\"name\": \"A\"}", Encoding.UTF8, "application/json") + }; + var response = await Client.SendAsync(request); + + // Should return OK because FluentValidation is not triggered for regular controllers + response.StatusCode.ShouldBe(HttpStatusCode.OK); + } } public class DisableAutoModelValidationTestController_Tests : AspNetCoreMvcTestBase diff --git a/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/FluentValidationTestAppService.cs b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/FluentValidationTestAppService.cs new file mode 100644 index 0000000000..ea4fcf7480 --- /dev/null +++ b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/FluentValidationTestAppService.cs @@ -0,0 +1,17 @@ +using System.Threading.Tasks; +using Volo.Abp.Application.Services; + +namespace Volo.Abp.TestApp.Application; + +public class FluentValidationTestAppService : ApplicationService +{ + public virtual Task CreateAsync(FluentValidationTestInput input) + { + return Task.FromResult(input.Name); + } +} + +public class FluentValidationTestInput +{ + public string Name { get; set; } +}