From af9fb303abaedf404fe021f1a77183dc467fda91 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Sun, 29 Mar 2026 12:19:04 -0500 Subject: [PATCH] Fix FluentValidation not applying to AsParameters type when FromBody present When [AsParameters] was used with a [FromBody] property, the AsParametersBindingFrame overwrote chain.RequestType to the body property type. The FluentValidation policy only checked RequestType, so validators on the AsParameters type itself were never applied. The fix tracks the AsParameters type separately on HttpChain and updates the FluentValidation policy to also add validation middleware for the AsParameters type when it differs from RequestType and has a registered validator. Fixes #2358 Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/guide/http/validation.md | 49 +++++++++++++++++++ .../HttpChainFluentValidationPolicy.cs | 15 +++++- .../asparameters_binding.cs | 41 ++++++++++++++++ .../CodeGen/AsParametersBindingFrame.cs | 1 + src/Http/Wolverine.Http/HttpChain.cs | 7 +++ .../WolverineWebApi/Forms/FormEndpoints.cs | 40 +++++++++++++++ 6 files changed, 152 insertions(+), 1 deletion(-) diff --git a/docs/guide/http/validation.md b/docs/guide/http/validation.md index 22d90a9a5..51dfaa6c8 100644 --- a/docs/guide/http/validation.md +++ b/docs/guide/http/validation.md @@ -419,6 +419,55 @@ public class ValidatedQuery snippet source | anchor +### AsParameters with [FromBody] + +When using `[AsParameters]` with a `[FromBody]` property, the Fluent Validation middleware will validate +**both** the `[FromBody]` type (if it has a validator) and the `[AsParameters]` type itself. This ensures +that validation rules on the `[AsParameters]` type are always applied, even when a `[FromBody]` property +is present: + + + +```cs +public static class ValidatedAsParametersWithFromBodyEndpoint +{ + [WolverinePost("/asparameters/validated_with_from_body")] + public static string Post([AsParameters] ValidatedWithFromBody query) + { + return $"{query.Name} has dog: {query.Body?.HasDog}, has cat: {query.Body?.HasCat}"; + } +} + +public class ValidatedWithFromBody +{ + [FromQuery] + public string? Name { get; set; } + + [FromQuery] + public int Age { get; set; } + + [FromBody] + public ValidatedQueryBody? Body { get; set; } + + public class ValidatedWithFromBodyValidator : AbstractValidator + { + public ValidatedWithFromBodyValidator() + { + RuleFor(x => x.Name).NotNull(); + RuleFor(x => x.Body).NotNull(); + } + } + + public class ValidatedQueryBody + { + public bool HasDog { get; set; } + public bool HasCat { get; set; } + } +} +``` +snippet source | anchor + + ## QueryString Binding Wolverine.HTTP can apply the Fluent Validation middleware to complex types that are bound by the `[FromQuery]` behavior: diff --git a/src/Http/Wolverine.Http.FluentValidation/Internals/HttpChainFluentValidationPolicy.cs b/src/Http/Wolverine.Http.FluentValidation/Internals/HttpChainFluentValidationPolicy.cs index 7a226f744..5d0a9a77c 100644 --- a/src/Http/Wolverine.Http.FluentValidation/Internals/HttpChainFluentValidationPolicy.cs +++ b/src/Http/Wolverine.Http.FluentValidation/Internals/HttpChainFluentValidationPolicy.cs @@ -22,7 +22,20 @@ public void Apply(HttpChain chain, IServiceContainer container) { var validatedType = chain.HasRequestType ? chain.RequestType : chain.ComplexQueryStringType; if (validatedType == null) return; - + + addValidationMiddleware(chain, container, validatedType); + + // When using [AsParameters] with a [FromBody] property, the RequestType gets + // overwritten to the body property type. We still want to validate the + // AsParameters type itself if it has a validator registered. + if (chain.AsParametersType != null && chain.AsParametersType != validatedType) + { + addValidationMiddleware(chain, container, chain.AsParametersType); + } + } + + private static void addValidationMiddleware(HttpChain chain, IServiceContainer container, Type validatedType) + { var validatorInterface = typeof(IValidator<>).MakeGenericType(validatedType); var registered = container.RegistrationsFor(validatorInterface); diff --git a/src/Http/Wolverine.Http.Tests/asparameters_binding.cs b/src/Http/Wolverine.Http.Tests/asparameters_binding.cs index eb7218254..194faa421 100644 --- a/src/Http/Wolverine.Http.Tests/asparameters_binding.cs +++ b/src/Http/Wolverine.Http.Tests/asparameters_binding.cs @@ -184,4 +184,45 @@ await Scenario(x => x.ContentTypeShouldBe("application/problem+json"); }); } + + [Fact] + public async Task using_FluentValidation_with_AsParameters_and_FromBody_happy_path() + { + await Scenario(x => + { + x.Post.Json(new WolverineWebApi.Forms.ValidatedWithFromBody.ValidatedQueryBody { HasDog = true, HasCat = false }) + .ToUrl("/asparameters/validated_with_from_body") + .QueryString("Name", "Jeremy") + .QueryString("Age", "51"); + }); + } + + [Fact] + public async Task using_FluentValidation_with_AsParameters_and_FromBody_should_validate_asparameters_type() + { + // Missing Name (required by the ValidatedWithFromBody validator) + await Scenario(x => + { + x.Post.Json(new WolverineWebApi.Forms.ValidatedWithFromBody.ValidatedQueryBody { HasDog = true, HasCat = false }) + .ToUrl("/asparameters/validated_with_from_body") + .QueryString("Age", "51"); + + x.StatusCodeShouldBe(400); + x.ContentTypeShouldBe("application/problem+json"); + }); + } + + [Fact] + public async Task using_FluentValidation_with_AsParameters_and_FromBody_missing_body_should_fail() + { + // No body at all (required by the ValidatedWithFromBody validator) + await Scenario(x => + { + x.Post.Url("/asparameters/validated_with_from_body") + .QueryString("Name", "Jeremy") + .QueryString("Age", "51"); + + x.StatusCodeShouldBe(400); + }); + } } \ No newline at end of file diff --git a/src/Http/Wolverine.Http/CodeGen/AsParametersBindingFrame.cs b/src/Http/Wolverine.Http/CodeGen/AsParametersBindingFrame.cs index 65ab8b895..bb585b5f1 100644 --- a/src/Http/Wolverine.Http/CodeGen/AsParametersBindingFrame.cs +++ b/src/Http/Wolverine.Http/CodeGen/AsParametersBindingFrame.cs @@ -24,6 +24,7 @@ public bool TryMatch(HttpChain chain, IServiceContainer container, ParameterInfo if (IsClassOrNullableClassNotCollection(parameter.ParameterType)) { chain.RequestType = parameter.ParameterType; + chain.AsParametersType = parameter.ParameterType; chain.IsFormData = true; variable = new AsParametersBindingFrame(parameter.ParameterType, chain, container).Variable; return true; diff --git a/src/Http/Wolverine.Http/HttpChain.cs b/src/Http/Wolverine.Http/HttpChain.cs index 8f8d27183..4b094de06 100644 --- a/src/Http/Wolverine.Http/HttpChain.cs +++ b/src/Http/Wolverine.Http/HttpChain.cs @@ -706,6 +706,13 @@ public HttpElementVariable GetOrCreateHeaderVariable(IFromHeaderMetadata metadat public bool IsFormData { get; internal set; } public Type? ComplexQueryStringType { get; set; } + + /// + /// When using [AsParameters], this tracks the original AsParameters type even when + /// RequestType is overwritten by a [FromBody] property. This allows middleware like + /// FluentValidation to also validate the AsParameters type itself. + /// + public Type? AsParametersType { get; internal set; } public ServiceProviderSource ServiceProviderSource { get; set; } = ServiceProviderSource.IsolatedAndScoped; internal Variable BuildJsonDeserializationVariable() diff --git a/src/Http/WolverineWebApi/Forms/FormEndpoints.cs b/src/Http/WolverineWebApi/Forms/FormEndpoints.cs index 3a9d662b4..1d4ca416e 100644 --- a/src/Http/WolverineWebApi/Forms/FormEndpoints.cs +++ b/src/Http/WolverineWebApi/Forms/FormEndpoints.cs @@ -256,3 +256,43 @@ public ValidatedQueryValidator() #endregion +#region sample_using_fluent_validation_with_AsParameters_and_FromBody + +public static class ValidatedAsParametersWithFromBodyEndpoint +{ + [WolverinePost("/asparameters/validated_with_from_body")] + public static string Post([AsParameters] ValidatedWithFromBody query) + { + return $"{query.Name} has dog: {query.Body?.HasDog}, has cat: {query.Body?.HasCat}"; + } +} + +public class ValidatedWithFromBody +{ + [FromQuery] + public string? Name { get; set; } + + [FromQuery] + public int Age { get; set; } + + [FromBody] + public ValidatedQueryBody? Body { get; set; } + + public class ValidatedWithFromBodyValidator : AbstractValidator + { + public ValidatedWithFromBodyValidator() + { + RuleFor(x => x.Name).NotNull(); + RuleFor(x => x.Body).NotNull(); + } + } + + public class ValidatedQueryBody + { + public bool HasDog { get; set; } + public bool HasCat { get; set; } + } +} + +#endregion +