diff --git a/framework-docs/modules/ROOT/pages/core/validation/beanvalidation.adoc b/framework-docs/modules/ROOT/pages/core/validation/beanvalidation.adoc index 4f78210106..028a725461 100644 --- a/framework-docs/modules/ROOT/pages/core/validation/beanvalidation.adoc +++ b/framework-docs/modules/ROOT/pages/core/validation/beanvalidation.adoc @@ -282,7 +282,7 @@ As the preceding example shows, a `ConstraintValidator` implementation can have [[validation-beanvalidation-spring-method]] -=== Spring-driven Method Validation +== Spring-driven Method Validation You can integrate the method validation feature of Bean Validation into a Spring context through a `MethodValidationPostProcessor` bean definition: @@ -329,11 +329,12 @@ xref:core/aop/proxying.adoc#aop-understanding-aop-proxies[Understanding AOP Prox to always use methods and accessors on proxied classes; direct field access will not work. ==== -NOTE: Spring MVC and WebFlux have built-in support for method validation, and therefore -for web controller methods there is no need for a class level `@Validated` and an AOP proxy. -See the Spring MVC xref:web/webmvc/mvc-controller/ann-validation.adoc[Validation] section, -the WebFlux xref:web/webflux/controller/ann-validation.adoc[Validation] section, -and the xref:web/webmvc/mvc-controller/ann-validation.adoc[Error Responses] section. +Spring MVC and WebFlux have built-in support for the same underlying method validation but without +the need for AOP. Therefore, do check the rest of this section, and also see the Spring MVC +xref:web/webmvc/mvc-controller/ann-validation.adoc[Validation] and +xref:web/webmvc/mvc-ann-rest-exceptions.adoc[Error Responses] sections, and the WebFlux +xref:web/webflux/controller/ann-validation.adoc[Validation] and +xref:web/webflux/ann-rest-exceptions.adoc[Error Responses] sections. [[validation-beanvalidation-spring-method-exceptions]] diff --git a/spring-web/src/main/java/org/springframework/web/method/HandlerMethod.java b/spring-web/src/main/java/org/springframework/web/method/HandlerMethod.java index 5c737d13f6..8ab32964e1 100644 --- a/spring-web/src/main/java/org/springframework/web/method/HandlerMethod.java +++ b/spring-web/src/main/java/org/springframework/web/method/HandlerMethod.java @@ -18,6 +18,7 @@ package org.springframework.web.method; import java.lang.annotation.Annotation; import java.lang.reflect.Method; +import java.util.List; import java.util.StringJoiner; import java.util.function.Predicate; import java.util.stream.Collectors; @@ -383,19 +384,25 @@ public class HandlerMethod extends AnnotatedMethod { */ private static class MethodValidationInitializer { - private static final Predicate> INPUT_PREDICATE = + private static final Predicate> CONSTRAINT_PREDICATE = MergedAnnotationPredicates.typeIn("jakarta.validation.Constraint"); - private static final Predicate> OUTPUT_PREDICATE = - MergedAnnotationPredicates.typeIn("jakarta.validation.Valid", "jakarta.validation.Constraint"); + private static final Predicate> VALID_PREDICATE = + MergedAnnotationPredicates.typeIn("jakarta.validation.Valid"); public static boolean checkArguments(Class beanType, MethodParameter[] parameters) { if (AnnotationUtils.findAnnotation(beanType, Validated.class) == null) { for (MethodParameter parameter : parameters) { MergedAnnotations merged = MergedAnnotations.from(parameter.getParameterAnnotations()); - if (merged.stream().anyMatch(INPUT_PREDICATE)) { + if (merged.stream().anyMatch(CONSTRAINT_PREDICATE)) { return true; } + else { + Class type = parameter.getParameterType(); + if (merged.stream().anyMatch(VALID_PREDICATE) && List.class.isAssignableFrom(type)) { + return true; + } + } } } return false; @@ -404,7 +411,7 @@ public class HandlerMethod extends AnnotatedMethod { public static boolean checkReturnValue(Class beanType, Method method) { if (AnnotationUtils.findAnnotation(beanType, Validated.class) == null) { MergedAnnotations merged = MergedAnnotations.from(method, MergedAnnotations.SearchStrategy.TYPE_HIERARCHY); - return merged.stream().anyMatch(OUTPUT_PREDICATE); + return merged.stream().anyMatch(CONSTRAINT_PREDICATE.or(VALID_PREDICATE)); } return false; } diff --git a/spring-web/src/test/java/org/springframework/web/method/HandlerMethodTests.java b/spring-web/src/test/java/org/springframework/web/method/HandlerMethodTests.java index 7f88ca96d7..14ef967ae3 100644 --- a/spring-web/src/test/java/org/springframework/web/method/HandlerMethodTests.java +++ b/spring-web/src/test/java/org/springframework/web/method/HandlerMethodTests.java @@ -38,14 +38,14 @@ public class HandlerMethodTests { @Test void shouldValidateArgsWithConstraintsDirectlyOnClass() { Object target = new MyClass(); - testShouldValidateArguments(target, List.of("addIntValue", "addPersonAndIntValue"), true); + testShouldValidateArguments(target, List.of("addIntValue", "addPersonAndIntValue", "addPersons"), true); testShouldValidateArguments(target, List.of("addPerson", "getPerson", "getIntValue", "addPersonNotValidated"), false); } @Test void shouldValidateArgsWithConstraintsOnInterface() { Object target = new MyInterfaceImpl(); - testShouldValidateArguments(target, List.of("addIntValue", "addPersonAndIntValue"), true); + testShouldValidateArguments(target, List.of("addIntValue", "addPersonAndIntValue", "addPersons"), true); testShouldValidateArguments(target, List.of("addPerson", "addPersonNotValidated", "getPerson", "getIntValue"), false); } @@ -110,6 +110,9 @@ public class HandlerMethodTests { public void addPersonAndIntValue(@Valid Person person, @Max(10) int value) { } + public void addPersons(@Valid List persons) { + } + public void addPersonNotValidated(Person person) { } @@ -134,6 +137,8 @@ public class HandlerMethodTests { void addPersonAndIntValue(@Valid Person person, @Max(10) int value); + void addPersons(@Valid List persons); + void addPersonNotValidated(Person person); @Valid @@ -159,6 +164,10 @@ public class HandlerMethodTests { public void addPersonAndIntValue(Person person, int value) { } + @Override + public void addPersons(List persons) { + } + @Override public void addPersonNotValidated(Person person) { } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/MethodValidationTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/MethodValidationTests.java index 8f36d836e8..818a0cad60 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/MethodValidationTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/MethodValidationTests.java @@ -23,6 +23,7 @@ import java.util.Locale; import java.util.Set; import java.util.function.Consumer; +import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.validation.ConstraintViolation; import jakarta.validation.Valid; import jakarta.validation.constraints.Size; @@ -47,6 +48,7 @@ import org.springframework.validation.method.ParameterValidationResult; import org.springframework.web.bind.WebDataBinder; import org.springframework.web.bind.annotation.InitBinder; import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.support.ConfigurableWebBindingInitializer; @@ -228,6 +230,43 @@ public class MethodValidationTests { .verify(); } + + @Test + void validateList() { + HandlerMethod hm = handlerMethod(new ValidController(), c -> c.handle(List.of(mockPerson, mockPerson))); + ServerWebExchange exchange = MockServerWebExchange.from(request() + .contentType(MediaType.APPLICATION_JSON) + .body("[{\"name\":\"Faustino1234\"},{\"name\":\"Cayetana6789\"}]")); + + StepVerifier.create(this.handlerAdapter.handle(exchange, hm)) + .consumeErrorWith(throwable -> { + HandlerMethodValidationException ex = (HandlerMethodValidationException) throwable; + + assertThat(this.jakartaValidator.getValidationCount()).isEqualTo(1); + assertThat(this.jakartaValidator.getMethodValidationCount()).isEqualTo(1); + + assertThat(ex.getAllValidationResults()).hasSize(2); + + assertBeanResult(ex.getBeanResults().get(0), "personList", Collections.singletonList( + """ + Field error in object 'personList' on field 'name': rejected value [Faustino1234]; \ + codes [Size.personList.name,Size.name,Size.java.lang.String,Size]; \ + arguments [org.springframework.context.support.DefaultMessageSourceResolvable: \ + codes [personList.name,name]; arguments []; default message [name],10,1]; \ + default message [size must be between 1 and 10]""")); + + assertBeanResult(ex.getBeanResults().get(1), "personList", Collections.singletonList( + """ + Field error in object 'personList' on field 'name': rejected value [Cayetana6789]; \ + codes [Size.personList.name,Size.name,Size.java.lang.String,Size]; \ + arguments [org.springframework.context.support.DefaultMessageSourceResolvable: \ + codes [personList.name,name]; arguments []; default message [name],10,1]; \ + default message [size must be between 1 and 10]""" + )); + }) + .verify(); + } + @Test void validatedWithMethodValidation() { @@ -328,7 +367,7 @@ public class MethodValidationTests { @SuppressWarnings("unused") - private record Person(@Size(min = 1, max = 10) String name) { + private record Person(@Size(min = 1, max = 10) @JsonProperty("name") String name) { @Override public String name() { @@ -358,6 +397,9 @@ public class MethodValidationTests { return errors.toString(); } + void handle(@Valid @RequestBody List persons) { + } + Mono handleAsync(@Valid @ModelAttribute("student") Mono person, @RequestHeader @Size(min = 5, max = 10) String myHeader) { diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/MethodValidationTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/MethodValidationTests.java index b67080b62c..4cdace9581 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/MethodValidationTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/MethodValidationTests.java @@ -23,6 +23,7 @@ import java.util.List; import java.util.Set; import java.util.function.Consumer; +import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.validation.ConstraintViolation; import jakarta.validation.Valid; import jakarta.validation.constraints.Size; @@ -33,6 +34,8 @@ import org.junit.jupiter.api.Test; import org.springframework.context.MessageSourceResolvable; import org.springframework.http.MediaType; +import org.springframework.http.converter.StringHttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.validation.Errors; import org.springframework.validation.FieldError; import org.springframework.validation.Validator; @@ -44,6 +47,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.WebDataBinder; import org.springframework.web.bind.annotation.InitBinder; import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.support.ConfigurableWebBindingInitializer; @@ -55,6 +59,7 @@ import org.springframework.web.testfixture.method.ResolvableMethod; import org.springframework.web.testfixture.servlet.MockHttpServletRequest; import org.springframework.web.testfixture.servlet.MockHttpServletResponse; +import static java.nio.charset.StandardCharsets.UTF_8; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.catchThrowableOfType; import static org.mockito.Mockito.mock; @@ -95,6 +100,7 @@ public class MethodValidationTests { this.request.setMethod("POST"); this.request.setContentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE); + this.request.addHeader("Accept", "text/plain"); this.request.setAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, new HashMap(0)); } @@ -109,6 +115,8 @@ public class MethodValidationTests { handlerAdapter.setWebBindingInitializer(bindingInitializer); handlerAdapter.setApplicationContext(context); handlerAdapter.setBeanFactory(context.getBeanFactory()); + handlerAdapter.setMessageConverters( + List.of(new StringHttpMessageConverter(), new MappingJackson2HttpMessageConverter())); handlerAdapter.afterPropertiesSet(); return handlerAdapter; } @@ -214,6 +222,40 @@ public class MethodValidationTests { default message [size must be between 1 and 10]"""); } + @Test + void validateList() { + HandlerMethod hm = handlerMethod(new ValidController(), c -> c.handle(List.of(mockPerson, mockPerson))); + this.request.setContentType(MediaType.APPLICATION_JSON_VALUE); + this.request.setContent("[{\"name\":\"Faustino1234\"},{\"name\":\"Cayetana6789\"}]".getBytes(UTF_8)); + + HandlerMethodValidationException ex = catchThrowableOfType( + () -> this.handlerAdapter.handle(this.request, this.response, hm), + HandlerMethodValidationException.class); + + assertThat(this.jakartaValidator.getValidationCount()).isEqualTo(1); + assertThat(this.jakartaValidator.getMethodValidationCount()).isEqualTo(1); + + assertThat(ex.getAllValidationResults()).hasSize(2); + + assertBeanResult(ex.getBeanResults().get(0), "personList", Collections.singletonList( + """ + Field error in object 'personList' on field 'name': rejected value [Faustino1234]; \ + codes [Size.personList.name,Size.name,Size.java.lang.String,Size]; \ + arguments [org.springframework.context.support.DefaultMessageSourceResolvable: \ + codes [personList.name,name]; arguments []; default message [name],10,1]; \ + default message [size must be between 1 and 10]""")); + + assertBeanResult(ex.getBeanResults().get(1), "personList", Collections.singletonList( + """ + Field error in object 'personList' on field 'name': rejected value [Cayetana6789]; \ + codes [Size.personList.name,Size.name,Size.java.lang.String,Size]; \ + arguments [org.springframework.context.support.DefaultMessageSourceResolvable: \ + codes [personList.name,name]; arguments []; default message [name],10,1]; \ + default message [size must be between 1 and 10]""" + )); + + } + @Test void jakartaAndSpringValidator() throws Exception { HandlerMethod hm = handlerMethod(new InitBinderController(), ibc -> ibc.handle(mockPerson, mockErrors, "")); @@ -247,7 +289,7 @@ public class MethodValidationTests { RequestMappingHandlerAdapter springValidatorHandlerAdapter = initHandlerAdapter(new PersonValidator()); springValidatorHandlerAdapter.handle(this.request, this.response, hm); - assertThat(response.getContentAsString()).isEqualTo( + assertThat(response.getContentAsString()).isEqualTo( """ org.springframework.validation.BeanPropertyBindingResult: 1 errors Field error in object 'student' on field 'name': rejected value [name=Faustino1234]; \ @@ -283,7 +325,7 @@ public class MethodValidationTests { @SuppressWarnings("unused") - private record Person(@Size(min = 1, max = 10) String name) { + private record Person(@Size(min = 1, max = 10) @JsonProperty("name") String name) { @Override public String name() { @@ -312,6 +354,9 @@ public class MethodValidationTests { return errors.toString(); } + + void handle(@Valid @RequestBody List persons) { + } }