Support method validation for Lists in WebMvc and WebFlux
Closes gh-31120
This commit is contained in:
parent
6597727c86
commit
b068742ec8
|
@ -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]]
|
||||
|
|
|
@ -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<MergedAnnotation<? extends Annotation>> INPUT_PREDICATE =
|
||||
private static final Predicate<MergedAnnotation<? extends Annotation>> CONSTRAINT_PREDICATE =
|
||||
MergedAnnotationPredicates.typeIn("jakarta.validation.Constraint");
|
||||
|
||||
private static final Predicate<MergedAnnotation<? extends Annotation>> OUTPUT_PREDICATE =
|
||||
MergedAnnotationPredicates.typeIn("jakarta.validation.Valid", "jakarta.validation.Constraint");
|
||||
private static final Predicate<MergedAnnotation<? extends Annotation>> 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;
|
||||
}
|
||||
|
|
|
@ -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<Person> 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<Person> 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<Person> persons) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addPersonNotValidated(Person person) {
|
||||
}
|
||||
|
|
|
@ -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<Person> persons) {
|
||||
}
|
||||
|
||||
Mono<String> handleAsync(@Valid @ModelAttribute("student") Mono<Person> person,
|
||||
@RequestHeader @Size(min = 5, max = 10) String myHeader) {
|
||||
|
||||
|
|
|
@ -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<String, String>(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<Person> persons) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
Loading…
Reference in New Issue