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..ad1f7dc1f7 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 @@ -1,4 +1,5 @@ using System.Linq; +using System.Reflection; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Filters; @@ -39,27 +40,55 @@ public class AbpValidationActionFilter : IAsyncActionFilter, IAbpFilter, ITransi return; } - if (context.ActionDescriptor.GetMethodInfo().DeclaringType != context.Controller.GetType()) + var effectiveMethod = GetEffectiveMethodInfo(context); + if (effectiveMethod != null) { - var baseMethod = context.ActionDescriptor.GetMethodInfo(); - - var overrideMethod = context.Controller.GetType().GetMethods().FirstOrDefault(x => - x.DeclaringType == context.Controller.GetType() && - x.Name == baseMethod.Name && - x.ReturnType == baseMethod.ReturnType && - x.GetParameters().Select(p => p.ToString()).SequenceEqual(baseMethod.GetParameters().Select(p => p.ToString()))); - - if (overrideMethod != null) + if (ReflectionHelper.GetSingleAttributeOfMemberOrDeclaringTypeOrDefault(effectiveMethod) != null) { - if (ReflectionHelper.GetSingleAttributeOfMemberOrDeclaringTypeOrDefault(overrideMethod) != null) - { - await next(); - return; - } + await next(); + return; } } context.GetRequiredService().Validate(context.ModelState); + + if (context.Controller is IValidationEnabled) + { + await ValidateActionArgumentsAsync(context, effectiveMethod); + } + await next(); } + + protected virtual MethodInfo? GetEffectiveMethodInfo(ActionExecutingContext context) + { + var baseMethod = context.ActionDescriptor.GetMethodInfo(); + if (baseMethod.DeclaringType == context.Controller.GetType()) + { + return null; + } + + return context.Controller.GetType().GetMethods().FirstOrDefault(x => + x.DeclaringType == context.Controller.GetType() && + x.Name == baseMethod.Name && + x.ReturnType == baseMethod.ReturnType && + x.GetParameters().Select(p => p.ToString()).SequenceEqual(baseMethod.GetParameters().Select(p => p.ToString()))); + } + + protected virtual async Task ValidateActionArgumentsAsync(ActionExecutingContext context, MethodInfo? effectiveMethod = null) + { + var methodInfo = effectiveMethod ?? 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..24360b1254 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Validation/FluentValidationTestAppService_Tests.cs @@ -0,0 +1,62 @@ +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Shouldly; +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) + { + using 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..651a2650dc 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. + using 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; } +}