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 +