Skip to content

Commit b068742

Browse files
committed
Support method validation for Lists in WebMvc and WebFlux
Closes gh-31120
1 parent 6597727 commit b068742

File tree

5 files changed

+120
-16
lines changed

5 files changed

+120
-16
lines changed

framework-docs/modules/ROOT/pages/core/validation/beanvalidation.adoc

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ As the preceding example shows, a `ConstraintValidator` implementation can have
282282

283283

284284
[[validation-beanvalidation-spring-method]]
285-
=== Spring-driven Method Validation
285+
== Spring-driven Method Validation
286286

287287
You can integrate the method validation feature of Bean Validation into a
288288
Spring context through a `MethodValidationPostProcessor` bean definition:
@@ -329,11 +329,12 @@ xref:core/aop/proxying.adoc#aop-understanding-aop-proxies[Understanding AOP Prox
329329
to always use methods and accessors on proxied classes; direct field access will not work.
330330
====
331331

332-
NOTE: Spring MVC and WebFlux have built-in support for method validation, and therefore
333-
for web controller methods there is no need for a class level `@Validated` and an AOP proxy.
334-
See the Spring MVC xref:web/webmvc/mvc-controller/ann-validation.adoc[Validation] section,
335-
the WebFlux xref:web/webflux/controller/ann-validation.adoc[Validation] section,
336-
and the xref:web/webmvc/mvc-controller/ann-validation.adoc[Error Responses] section.
332+
Spring MVC and WebFlux have built-in support for the same underlying method validation but without
333+
the need for AOP. Therefore, do check the rest of this section, and also see the Spring MVC
334+
xref:web/webmvc/mvc-controller/ann-validation.adoc[Validation] and
335+
xref:web/webmvc/mvc-ann-rest-exceptions.adoc[Error Responses] sections, and the WebFlux
336+
xref:web/webflux/controller/ann-validation.adoc[Validation] and
337+
xref:web/webflux/ann-rest-exceptions.adoc[Error Responses] sections.
337338

338339

339340
[[validation-beanvalidation-spring-method-exceptions]]

spring-web/src/main/java/org/springframework/web/method/HandlerMethod.java

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import java.lang.annotation.Annotation;
2020
import java.lang.reflect.Method;
21+
import java.util.List;
2122
import java.util.StringJoiner;
2223
import java.util.function.Predicate;
2324
import java.util.stream.Collectors;
@@ -383,19 +384,25 @@ protected String formatInvokeError(String text, Object[] args) {
383384
*/
384385
private static class MethodValidationInitializer {
385386

386-
private static final Predicate<MergedAnnotation<? extends Annotation>> INPUT_PREDICATE =
387+
private static final Predicate<MergedAnnotation<? extends Annotation>> CONSTRAINT_PREDICATE =
387388
MergedAnnotationPredicates.typeIn("jakarta.validation.Constraint");
388389

389-
private static final Predicate<MergedAnnotation<? extends Annotation>> OUTPUT_PREDICATE =
390-
MergedAnnotationPredicates.typeIn("jakarta.validation.Valid", "jakarta.validation.Constraint");
390+
private static final Predicate<MergedAnnotation<? extends Annotation>> VALID_PREDICATE =
391+
MergedAnnotationPredicates.typeIn("jakarta.validation.Valid");
391392

392393
public static boolean checkArguments(Class<?> beanType, MethodParameter[] parameters) {
393394
if (AnnotationUtils.findAnnotation(beanType, Validated.class) == null) {
394395
for (MethodParameter parameter : parameters) {
395396
MergedAnnotations merged = MergedAnnotations.from(parameter.getParameterAnnotations());
396-
if (merged.stream().anyMatch(INPUT_PREDICATE)) {
397+
if (merged.stream().anyMatch(CONSTRAINT_PREDICATE)) {
397398
return true;
398399
}
400+
else {
401+
Class<?> type = parameter.getParameterType();
402+
if (merged.stream().anyMatch(VALID_PREDICATE) && List.class.isAssignableFrom(type)) {
403+
return true;
404+
}
405+
}
399406
}
400407
}
401408
return false;
@@ -404,7 +411,7 @@ public static boolean checkArguments(Class<?> beanType, MethodParameter[] parame
404411
public static boolean checkReturnValue(Class<?> beanType, Method method) {
405412
if (AnnotationUtils.findAnnotation(beanType, Validated.class) == null) {
406413
MergedAnnotations merged = MergedAnnotations.from(method, MergedAnnotations.SearchStrategy.TYPE_HIERARCHY);
407-
return merged.stream().anyMatch(OUTPUT_PREDICATE);
414+
return merged.stream().anyMatch(CONSTRAINT_PREDICATE.or(VALID_PREDICATE));
408415
}
409416
return false;
410417
}

spring-web/src/test/java/org/springframework/web/method/HandlerMethodTests.java

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,14 @@ public class HandlerMethodTests {
3838
@Test
3939
void shouldValidateArgsWithConstraintsDirectlyOnClass() {
4040
Object target = new MyClass();
41-
testShouldValidateArguments(target, List.of("addIntValue", "addPersonAndIntValue"), true);
41+
testShouldValidateArguments(target, List.of("addIntValue", "addPersonAndIntValue", "addPersons"), true);
4242
testShouldValidateArguments(target, List.of("addPerson", "getPerson", "getIntValue", "addPersonNotValidated"), false);
4343
}
4444

4545
@Test
4646
void shouldValidateArgsWithConstraintsOnInterface() {
4747
Object target = new MyInterfaceImpl();
48-
testShouldValidateArguments(target, List.of("addIntValue", "addPersonAndIntValue"), true);
48+
testShouldValidateArguments(target, List.of("addIntValue", "addPersonAndIntValue", "addPersons"), true);
4949
testShouldValidateArguments(target, List.of("addPerson", "addPersonNotValidated", "getPerson", "getIntValue"), false);
5050
}
5151

@@ -110,6 +110,9 @@ public void addIntValue(@Max(10) int value) {
110110
public void addPersonAndIntValue(@Valid Person person, @Max(10) int value) {
111111
}
112112

113+
public void addPersons(@Valid List<Person> persons) {
114+
}
115+
113116
public void addPersonNotValidated(Person person) {
114117
}
115118

@@ -134,6 +137,8 @@ private interface MyInterface {
134137

135138
void addPersonAndIntValue(@Valid Person person, @Max(10) int value);
136139

140+
void addPersons(@Valid List<Person> persons);
141+
137142
void addPersonNotValidated(Person person);
138143

139144
@Valid
@@ -159,6 +164,10 @@ public void addIntValue(int value) {
159164
public void addPersonAndIntValue(Person person, int value) {
160165
}
161166

167+
@Override
168+
public void addPersons(List<Person> persons) {
169+
}
170+
162171
@Override
163172
public void addPersonNotValidated(Person person) {
164173
}

spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/MethodValidationTests.java

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import java.util.Set;
2424
import java.util.function.Consumer;
2525

26+
import com.fasterxml.jackson.annotation.JsonProperty;
2627
import jakarta.validation.ConstraintViolation;
2728
import jakarta.validation.Valid;
2829
import jakarta.validation.constraints.Size;
@@ -47,6 +48,7 @@
4748
import org.springframework.web.bind.WebDataBinder;
4849
import org.springframework.web.bind.annotation.InitBinder;
4950
import org.springframework.web.bind.annotation.ModelAttribute;
51+
import org.springframework.web.bind.annotation.RequestBody;
5052
import org.springframework.web.bind.annotation.RequestHeader;
5153
import org.springframework.web.bind.annotation.RestController;
5254
import org.springframework.web.bind.support.ConfigurableWebBindingInitializer;
@@ -228,6 +230,43 @@ void modelAttributeWithBindingResultAndRequestHeader() {
228230
.verify();
229231
}
230232

233+
234+
@Test
235+
void validateList() {
236+
HandlerMethod hm = handlerMethod(new ValidController(), c -> c.handle(List.of(mockPerson, mockPerson)));
237+
ServerWebExchange exchange = MockServerWebExchange.from(request()
238+
.contentType(MediaType.APPLICATION_JSON)
239+
.body("[{\"name\":\"Faustino1234\"},{\"name\":\"Cayetana6789\"}]"));
240+
241+
StepVerifier.create(this.handlerAdapter.handle(exchange, hm))
242+
.consumeErrorWith(throwable -> {
243+
HandlerMethodValidationException ex = (HandlerMethodValidationException) throwable;
244+
245+
assertThat(this.jakartaValidator.getValidationCount()).isEqualTo(1);
246+
assertThat(this.jakartaValidator.getMethodValidationCount()).isEqualTo(1);
247+
248+
assertThat(ex.getAllValidationResults()).hasSize(2);
249+
250+
assertBeanResult(ex.getBeanResults().get(0), "personList", Collections.singletonList(
251+
"""
252+
Field error in object 'personList' on field 'name': rejected value [Faustino1234]; \
253+
codes [Size.personList.name,Size.name,Size.java.lang.String,Size]; \
254+
arguments [org.springframework.context.support.DefaultMessageSourceResolvable: \
255+
codes [personList.name,name]; arguments []; default message [name],10,1]; \
256+
default message [size must be between 1 and 10]"""));
257+
258+
assertBeanResult(ex.getBeanResults().get(1), "personList", Collections.singletonList(
259+
"""
260+
Field error in object 'personList' on field 'name': rejected value [Cayetana6789]; \
261+
codes [Size.personList.name,Size.name,Size.java.lang.String,Size]; \
262+
arguments [org.springframework.context.support.DefaultMessageSourceResolvable: \
263+
codes [personList.name,name]; arguments []; default message [name],10,1]; \
264+
default message [size must be between 1 and 10]"""
265+
));
266+
})
267+
.verify();
268+
}
269+
231270
@Test
232271
void validatedWithMethodValidation() {
233272

@@ -328,7 +367,7 @@ private static void assertValueResult(
328367

329368

330369
@SuppressWarnings("unused")
331-
private record Person(@Size(min = 1, max = 10) String name) {
370+
private record Person(@Size(min = 1, max = 10) @JsonProperty("name") String name) {
332371

333372
@Override
334373
public String name() {
@@ -358,6 +397,9 @@ String handleValidated(@Validated Person person, Errors errors,
358397
return errors.toString();
359398
}
360399

400+
void handle(@Valid @RequestBody List<Person> persons) {
401+
}
402+
361403
Mono<String> handleAsync(@Valid @ModelAttribute("student") Mono<Person> person,
362404
@RequestHeader @Size(min = 5, max = 10) String myHeader) {
363405

spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/MethodValidationTests.java

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import java.util.Set;
2424
import java.util.function.Consumer;
2525

26+
import com.fasterxml.jackson.annotation.JsonProperty;
2627
import jakarta.validation.ConstraintViolation;
2728
import jakarta.validation.Valid;
2829
import jakarta.validation.constraints.Size;
@@ -33,6 +34,8 @@
3334

3435
import org.springframework.context.MessageSourceResolvable;
3536
import org.springframework.http.MediaType;
37+
import org.springframework.http.converter.StringHttpMessageConverter;
38+
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
3639
import org.springframework.validation.Errors;
3740
import org.springframework.validation.FieldError;
3841
import org.springframework.validation.Validator;
@@ -44,6 +47,7 @@
4447
import org.springframework.web.bind.WebDataBinder;
4548
import org.springframework.web.bind.annotation.InitBinder;
4649
import org.springframework.web.bind.annotation.ModelAttribute;
50+
import org.springframework.web.bind.annotation.RequestBody;
4751
import org.springframework.web.bind.annotation.RequestHeader;
4852
import org.springframework.web.bind.annotation.RestController;
4953
import org.springframework.web.bind.support.ConfigurableWebBindingInitializer;
@@ -55,6 +59,7 @@
5559
import org.springframework.web.testfixture.servlet.MockHttpServletRequest;
5660
import org.springframework.web.testfixture.servlet.MockHttpServletResponse;
5761

62+
import static java.nio.charset.StandardCharsets.UTF_8;
5863
import static org.assertj.core.api.Assertions.assertThat;
5964
import static org.assertj.core.api.Assertions.catchThrowableOfType;
6065
import static org.mockito.Mockito.mock;
@@ -95,6 +100,7 @@ void setup() throws Exception {
95100

96101
this.request.setMethod("POST");
97102
this.request.setContentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE);
103+
this.request.addHeader("Accept", "text/plain");
98104
this.request.setAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, new HashMap<String, String>(0));
99105
}
100106

@@ -109,6 +115,8 @@ private static RequestMappingHandlerAdapter initHandlerAdapter(Validator validat
109115
handlerAdapter.setWebBindingInitializer(bindingInitializer);
110116
handlerAdapter.setApplicationContext(context);
111117
handlerAdapter.setBeanFactory(context.getBeanFactory());
118+
handlerAdapter.setMessageConverters(
119+
List.of(new StringHttpMessageConverter(), new MappingJackson2HttpMessageConverter()));
112120
handlerAdapter.afterPropertiesSet();
113121
return handlerAdapter;
114122
}
@@ -214,6 +222,40 @@ void validatedWithMethodValidation() throws Exception {
214222
default message [size must be between 1 and 10]""");
215223
}
216224

225+
@Test
226+
void validateList() {
227+
HandlerMethod hm = handlerMethod(new ValidController(), c -> c.handle(List.of(mockPerson, mockPerson)));
228+
this.request.setContentType(MediaType.APPLICATION_JSON_VALUE);
229+
this.request.setContent("[{\"name\":\"Faustino1234\"},{\"name\":\"Cayetana6789\"}]".getBytes(UTF_8));
230+
231+
HandlerMethodValidationException ex = catchThrowableOfType(
232+
() -> this.handlerAdapter.handle(this.request, this.response, hm),
233+
HandlerMethodValidationException.class);
234+
235+
assertThat(this.jakartaValidator.getValidationCount()).isEqualTo(1);
236+
assertThat(this.jakartaValidator.getMethodValidationCount()).isEqualTo(1);
237+
238+
assertThat(ex.getAllValidationResults()).hasSize(2);
239+
240+
assertBeanResult(ex.getBeanResults().get(0), "personList", Collections.singletonList(
241+
"""
242+
Field error in object 'personList' on field 'name': rejected value [Faustino1234]; \
243+
codes [Size.personList.name,Size.name,Size.java.lang.String,Size]; \
244+
arguments [org.springframework.context.support.DefaultMessageSourceResolvable: \
245+
codes [personList.name,name]; arguments []; default message [name],10,1]; \
246+
default message [size must be between 1 and 10]"""));
247+
248+
assertBeanResult(ex.getBeanResults().get(1), "personList", Collections.singletonList(
249+
"""
250+
Field error in object 'personList' on field 'name': rejected value [Cayetana6789]; \
251+
codes [Size.personList.name,Size.name,Size.java.lang.String,Size]; \
252+
arguments [org.springframework.context.support.DefaultMessageSourceResolvable: \
253+
codes [personList.name,name]; arguments []; default message [name],10,1]; \
254+
default message [size must be between 1 and 10]"""
255+
));
256+
257+
}
258+
217259
@Test
218260
void jakartaAndSpringValidator() throws Exception {
219261
HandlerMethod hm = handlerMethod(new InitBinderController(), ibc -> ibc.handle(mockPerson, mockErrors, ""));
@@ -247,7 +289,7 @@ void springValidator() throws Exception {
247289
RequestMappingHandlerAdapter springValidatorHandlerAdapter = initHandlerAdapter(new PersonValidator());
248290
springValidatorHandlerAdapter.handle(this.request, this.response, hm);
249291

250-
assertThat(response.getContentAsString()).isEqualTo(
292+
assertThat(response.getContentAsString()).isEqualTo(
251293
"""
252294
org.springframework.validation.BeanPropertyBindingResult: 1 errors
253295
Field error in object 'student' on field 'name': rejected value [name=Faustino1234]; \
@@ -283,7 +325,7 @@ private static void assertValueResult(
283325

284326

285327
@SuppressWarnings("unused")
286-
private record Person(@Size(min = 1, max = 10) String name) {
328+
private record Person(@Size(min = 1, max = 10) @JsonProperty("name") String name) {
287329

288330
@Override
289331
public String name() {
@@ -312,6 +354,9 @@ String handleValidated(@Validated Person person, Errors errors,
312354

313355
return errors.toString();
314356
}
357+
358+
void handle(@Valid @RequestBody List<Person> persons) {
359+
}
315360
}
316361

317362

0 commit comments

Comments
 (0)