diff --git a/spring-webflux/spring-webflux.gradle b/spring-webflux/spring-webflux.gradle
index 7a09ba8559..6ee35c3919 100644
--- a/spring-webflux/spring-webflux.gradle
+++ b/spring-webflux/spring-webflux.gradle
@@ -11,6 +11,7 @@ dependencies {
optional(project(":spring-context"))
optional(project(":spring-context-support")) // for FreeMarker support
optional("jakarta.servlet:jakarta.servlet-api")
+ optional("jakarta.validation:jakarta.validation-api")
optional("jakarta.websocket:jakarta.websocket-api")
optional("jakarta.websocket:jakarta.websocket-client-api")
optional("org.webjars:webjars-locator-core")
diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/BindingContext.java b/spring-webflux/src/main/java/org/springframework/web/reactive/BindingContext.java
index 0756cc8b58..96379aa2a8 100644
--- a/spring-webflux/src/main/java/org/springframework/web/reactive/BindingContext.java
+++ b/spring-webflux/src/main/java/org/springframework/web/reactive/BindingContext.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2002-2022 the original author or authors.
+ * Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -16,13 +16,17 @@
package org.springframework.web.reactive;
+import java.lang.annotation.Annotation;
import java.util.Collections;
import java.util.Map;
import reactor.core.publisher.Mono;
+import org.springframework.core.MethodParameter;
+import org.springframework.core.ReactiveAdapterRegistry;
import org.springframework.lang.Nullable;
import org.springframework.ui.Model;
+import org.springframework.validation.DataBinder;
import org.springframework.validation.support.BindingAwareConcurrentModel;
import org.springframework.web.bind.support.WebBindingInitializer;
import org.springframework.web.bind.support.WebExchangeDataBinder;
@@ -50,6 +54,8 @@ public class BindingContext {
private final Model model = new BindingAwareConcurrentModel();
+ private boolean methodValidationApplicable;
+
/**
* Create a new {@code BindingContext}.
@@ -74,6 +80,16 @@ public class BindingContext {
return this.model;
}
+ /**
+ * Configure flag to signal whether validation will be applied to handler
+ * method arguments, which is the case if Bean Validation is enabled in
+ * Spring MVC, and method parameters have {@code @Constraint} annotations.
+ * @since 6.1
+ */
+ public void setMethodValidationApplicable(boolean methodValidationApplicable) {
+ this.methodValidationApplicable = methodValidationApplicable;
+ }
+
/**
* Create a {@link WebExchangeDataBinder} to apply data binding and
@@ -112,6 +128,24 @@ public class BindingContext {
return createDataBinder(exchange, null, name);
}
+ /**
+ * Variant of {@link #createDataBinder(ServerWebExchange, Object, String)}
+ * with a {@link MethodParameter} for which the {@code DataBinder} is created.
+ * That may provide more insight to initialize the {@link WebExchangeDataBinder}.
+ *
By default, if the parameter has {@code @Valid}, Bean Validation is
+ * excluded, deferring to method validation.
+ * @since 6.1
+ */
+ public WebExchangeDataBinder createDataBinder(
+ ServerWebExchange exchange, @Nullable Object target, String name, MethodParameter parameter) {
+
+ WebExchangeDataBinder dataBinder = createDataBinder(exchange, target, name);
+ if (this.methodValidationApplicable) {
+ MethodValidationInitializer.updateBinder(dataBinder, parameter);
+ }
+ return dataBinder;
+ }
+
/**
* Extended variant of {@link WebExchangeDataBinder}, adding path variables.
@@ -130,4 +164,21 @@ public class BindingContext {
}
}
+
+ /**
+ * Excludes Bean Validation if the method parameter has {@code @Valid}.
+ */
+ private static class MethodValidationInitializer {
+
+ public static void updateBinder(DataBinder binder, MethodParameter parameter) {
+ if (ReactiveAdapterRegistry.getSharedInstance().getAdapter(parameter.getParameterType()) == null) {
+ for (Annotation annotation : parameter.getParameterAnnotations()) {
+ if (annotation.annotationType().getName().equals("jakarta.validation.Valid")) {
+ binder.setExcludedValidators(validator -> validator instanceof jakarta.validation.Validator);
+ }
+ }
+ }
+ }
+ }
+
}
diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/InvocableHandlerMethod.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/InvocableHandlerMethod.java
index 16eb34e777..8fd390ef9a 100644
--- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/InvocableHandlerMethod.java
+++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/InvocableHandlerMethod.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2002-2022 the original author or authors.
+ * Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -37,6 +37,7 @@ import org.springframework.http.HttpStatusCode;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.lang.Nullable;
import org.springframework.util.ObjectUtils;
+import org.springframework.validation.beanvalidation.MethodValidator;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.reactive.BindingContext;
import org.springframework.web.reactive.HandlerResult;
@@ -56,6 +57,8 @@ public class InvocableHandlerMethod extends HandlerMethod {
private static final Mono EMPTY_ARGS = Mono.just(new Object[0]);
+ private static final Class>[] EMPTY_GROUPS = new Class>[0];
+
private static final Object NO_ARG_VALUE = new Object();
@@ -65,6 +68,9 @@ public class InvocableHandlerMethod extends HandlerMethod {
private ReactiveAdapterRegistry reactiveAdapterRegistry = ReactiveAdapterRegistry.getSharedInstance();
+ @Nullable
+ private MethodValidator methodValidator;
+
/**
* Create an instance from a {@code HandlerMethod}.
@@ -121,6 +127,16 @@ public class InvocableHandlerMethod extends HandlerMethod {
this.reactiveAdapterRegistry = registry;
}
+ /**
+ * Set the {@link MethodValidator} to perform method validation with if the
+ * controller method {@link #shouldValidateArguments()} or
+ * {@link #shouldValidateReturnValue()}.
+ * @since 6.1
+ */
+ public void setMethodValidator(@Nullable MethodValidator methodValidator) {
+ this.methodValidator = methodValidator;
+ }
+
/**
* Invoke the method for the given exchange.
@@ -134,6 +150,10 @@ public class InvocableHandlerMethod extends HandlerMethod {
ServerWebExchange exchange, BindingContext bindingContext, Object... providedArgs) {
return getMethodArgumentValues(exchange, bindingContext, providedArgs).flatMap(args -> {
+ Class>[] groups = getValidationGroups();
+ if (shouldValidateArguments() && this.methodValidator != null) {
+ this.methodValidator.validateArguments(getBean(), getBridgedMethod(), args, groups);
+ }
Object value;
Method method = getBridgedMethod();
boolean isSuspendingFunction = KotlinDetector.isSuspendingFunction(method);
@@ -225,6 +245,11 @@ public class InvocableHandlerMethod extends HandlerMethod {
}
}
+ private Class>[] getValidationGroups() {
+ return ((shouldValidateArguments() || shouldValidateReturnValue()) && this.methodValidator != null ?
+ this.methodValidator.determineValidationGroups(getBean(), getBridgedMethod()) : EMPTY_GROUPS);
+ }
+
private static boolean isAsyncVoidReturnType(MethodParameter returnType, @Nullable ReactiveAdapter adapter) {
if (adapter != null && adapter.supportsEmpty()) {
if (adapter.isNoValue()) {
diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractMessageReaderArgumentResolver.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractMessageReaderArgumentResolver.java
index 753da53b87..cbb8db1ef2 100644
--- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractMessageReaderArgumentResolver.java
+++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractMessageReaderArgumentResolver.java
@@ -269,7 +269,7 @@ public abstract class AbstractMessageReaderArgumentResolver extends HandlerMetho
BindingContext binding, ServerWebExchange exchange) {
String name = Conventions.getVariableNameForParameter(param);
- WebExchangeDataBinder binder = binding.createDataBinder(exchange, target, name);
+ WebExchangeDataBinder binder = binding.createDataBinder(exchange, target, name, param);
try {
LocaleContextHolder.setLocaleContext(exchange.getLocaleContext());
binder.validate(validationHints);
diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ControllerMethodResolver.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ControllerMethodResolver.java
index 50a73f1a2d..98ddfc7a5e 100644
--- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ControllerMethodResolver.java
+++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ControllerMethodResolver.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2002-2022 the original author or authors.
+ * Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -39,6 +39,7 @@ import org.springframework.http.codec.HttpMessageReader;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ReflectionUtils.MethodFilter;
+import org.springframework.validation.beanvalidation.MethodValidator;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -94,6 +95,9 @@ class ControllerMethodResolver {
private final ReactiveAdapterRegistry reactiveAdapterRegistry;
+ @Nullable
+ private final MethodValidator methodValidator;
+
private final Map, Set> initBinderMethodCache = new ConcurrentHashMap<>(64);
private final Map, Set> modelAttributeMethodCache = new ConcurrentHashMap<>(64);
@@ -110,8 +114,10 @@ class ControllerMethodResolver {
private final Map, SessionAttributesHandler> sessionAttributesHandlerCache = new ConcurrentHashMap<>(64);
- ControllerMethodResolver(ArgumentResolverConfigurer customResolvers, ReactiveAdapterRegistry adapterRegistry,
- ConfigurableApplicationContext context, List> readers) {
+ ControllerMethodResolver(
+ ArgumentResolverConfigurer customResolvers, ReactiveAdapterRegistry adapterRegistry,
+ ConfigurableApplicationContext context, List> readers,
+ @Nullable MethodValidator methodValidator) {
Assert.notNull(customResolvers, "ArgumentResolverConfigurer is required");
Assert.notNull(adapterRegistry, "ReactiveAdapterRegistry is required");
@@ -123,6 +129,7 @@ class ControllerMethodResolver {
this.requestMappingResolvers = requestMappingResolvers(customResolvers, adapterRegistry, context, readers);
this.exceptionHandlerResolvers = exceptionHandlerResolvers(customResolvers, adapterRegistry, context);
this.reactiveAdapterRegistry = adapterRegistry;
+ this.methodValidator = methodValidator;
initControllerAdviceCaches(context);
}
@@ -260,6 +267,7 @@ class ControllerMethodResolver {
InvocableHandlerMethod invocable = new InvocableHandlerMethod(handlerMethod);
invocable.setArgumentResolvers(this.requestMappingResolvers);
invocable.setReactiveAdapterRegistry(this.reactiveAdapterRegistry);
+ invocable.setMethodValidator(this.methodValidator);
return invocable;
}
diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/InitBinderBindingContext.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/InitBinderBindingContext.java
index 3c35256d93..18b2b7ab49 100644
--- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/InitBinderBindingContext.java
+++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/InitBinderBindingContext.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2002-2018 the original author or authors.
+ * Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -50,12 +50,14 @@ class InitBinderBindingContext extends BindingContext {
private Runnable saveModelOperation;
- InitBinderBindingContext(@Nullable WebBindingInitializer initializer,
- List binderMethods) {
+ InitBinderBindingContext(
+ @Nullable WebBindingInitializer initializer, List binderMethods,
+ boolean methodValidationApplicable) {
super(initializer);
this.binderMethods = binderMethods;
this.binderMethodContext = new BindingContext(initializer);
+ setMethodValidationApplicable(methodValidationApplicable);
}
diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ModelAttributeMethodArgumentResolver.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ModelAttributeMethodArgumentResolver.java
index 841897c245..3081bf474a 100644
--- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ModelAttributeMethodArgumentResolver.java
+++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ModelAttributeMethodArgumentResolver.java
@@ -119,7 +119,7 @@ public class ModelAttributeMethodArgumentResolver extends HandlerMethodArgumentR
model.put(BindingResult.MODEL_KEY_PREFIX + name, bindingResultSink.asMono());
return valueMono.flatMap(value -> {
- WebExchangeDataBinder binder = context.createDataBinder(exchange, value, name);
+ WebExchangeDataBinder binder = context.createDataBinder(exchange, value, name, parameter);
return (bindingDisabled(parameter) ? Mono.empty() : bindRequestParameters(binder, exchange))
.doOnError(bindingResultSink::tryEmitError)
.doOnSuccess(aVoid -> {
diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerAdapter.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerAdapter.java
index ffecf6f21b..966d80047d 100644
--- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerAdapter.java
+++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerAdapter.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2002-2022 the original author or authors.
+ * Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -33,9 +33,12 @@ import org.springframework.http.codec.HttpMessageReader;
import org.springframework.http.codec.ServerCodecConfigurer;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
+import org.springframework.util.ClassUtils;
import org.springframework.util.CollectionUtils;
+import org.springframework.validation.beanvalidation.MethodValidator;
import org.springframework.web.bind.support.WebBindingInitializer;
import org.springframework.web.method.HandlerMethod;
+import org.springframework.web.method.support.HandlerMethodValidator;
import org.springframework.web.reactive.BindingContext;
import org.springframework.web.reactive.DispatchExceptionHandler;
import org.springframework.web.reactive.HandlerAdapter;
@@ -57,6 +60,9 @@ public class RequestMappingHandlerAdapter
private static final Log logger = LogFactory.getLog(RequestMappingHandlerAdapter.class);
+ private final static boolean BEAN_VALIDATION_PRESENT =
+ ClassUtils.isPresent("jakarta.validation.Validator", HandlerMethod.class.getClassLoader());
+
private List> messageReaders = Collections.emptyList();
@@ -69,6 +75,9 @@ public class RequestMappingHandlerAdapter
@Nullable
private ReactiveAdapterRegistry reactiveAdapterRegistry;
+ @Nullable
+ private MethodValidator methodValidator;
+
@Nullable
private ConfigurableApplicationContext applicationContext;
@@ -170,9 +179,12 @@ public class RequestMappingHandlerAdapter
if (this.reactiveAdapterRegistry == null) {
this.reactiveAdapterRegistry = ReactiveAdapterRegistry.getSharedInstance();
}
+ if (BEAN_VALIDATION_PRESENT) {
+ this.methodValidator = HandlerMethodValidator.from(this.webBindingInitializer, null);
+ }
this.methodResolver = new ControllerMethodResolver(this.argumentResolverConfigurer,
- this.reactiveAdapterRegistry, this.applicationContext, this.messageReaders);
+ this.reactiveAdapterRegistry, this.applicationContext, this.messageReaders, this.methodValidator);
this.modelInitializer = new ModelInitializer(this.methodResolver, this.reactiveAdapterRegistry);
}
@@ -189,7 +201,8 @@ public class RequestMappingHandlerAdapter
Assert.state(this.methodResolver != null && this.modelInitializer != null, "Not initialized");
InitBinderBindingContext bindingContext = new InitBinderBindingContext(
- getWebBindingInitializer(), this.methodResolver.getInitBinderMethods(handlerMethod));
+ this.webBindingInitializer, this.methodResolver.getInitBinderMethods(handlerMethod),
+ this.methodValidator != null && handlerMethod.shouldValidateArguments());
InvocableHandlerMethod invocableMethod = this.methodResolver.getRequestMappingMethod(handlerMethod);
diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ControllerMethodResolverTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ControllerMethodResolverTests.java
index 1c5c5aca62..8f39d0e242 100644
--- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ControllerMethodResolverTests.java
+++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ControllerMethodResolverTests.java
@@ -76,7 +76,8 @@ public class ControllerMethodResolverTests {
applicationContext.refresh();
this.methodResolver = new ControllerMethodResolver(
- resolvers, ReactiveAdapterRegistry.getSharedInstance(), applicationContext, codecs.getReaders());
+ resolvers, ReactiveAdapterRegistry.getSharedInstance(), applicationContext,
+ codecs.getReaders(), null);
Method method = ResolvableMethod.on(TestController.class).mockCall(TestController::handle).method();
this.handlerMethod = new HandlerMethod(new TestController(), method);
diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/InitBinderBindingContextTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/InitBinderBindingContextTests.java
index 5233a4a52e..96c45cac0d 100644
--- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/InitBinderBindingContextTests.java
+++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/InitBinderBindingContextTests.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2002-2022 the original author or authors.
+ * Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -133,7 +133,8 @@ public class InitBinderBindingContextTests {
handlerMethod.setArgumentResolvers(new ArrayList<>(this.argumentResolvers));
handlerMethod.setParameterNameDiscoverer(new DefaultParameterNameDiscoverer());
- return new InitBinderBindingContext(this.bindingInitializer, Collections.singletonList(handlerMethod));
+ return new InitBinderBindingContext(
+ this.bindingInitializer, Collections.singletonList(handlerMethod), false);
}
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
new file mode 100644
index 0000000000..704bbe1cb2
--- /dev/null
+++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/MethodValidationTests.java
@@ -0,0 +1,477 @@
+/*
+ * Copyright 2002-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.web.reactive.result.method.annotation;
+
+import java.lang.reflect.Method;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.function.Consumer;
+
+import jakarta.validation.ConstraintViolation;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.Size;
+import jakarta.validation.executable.ExecutableValidator;
+import jakarta.validation.metadata.BeanDescriptor;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+import org.springframework.context.MessageSourceResolvable;
+import org.springframework.http.MediaType;
+import org.springframework.util.Assert;
+import org.springframework.validation.Errors;
+import org.springframework.validation.FieldError;
+import org.springframework.validation.Validator;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
+import org.springframework.validation.beanvalidation.MethodValidationException;
+import org.springframework.validation.beanvalidation.ParameterValidationResult;
+import org.springframework.validation.beanvalidation.SpringValidatorAdapter;
+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.RequestHeader;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.bind.support.ConfigurableWebBindingInitializer;
+import org.springframework.web.bind.support.WebExchangeBindException;
+import org.springframework.web.context.support.GenericWebApplicationContext;
+import org.springframework.web.method.HandlerMethod;
+import org.springframework.web.reactive.HandlerResult;
+import org.springframework.web.server.ServerWebExchange;
+import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest;
+import org.springframework.web.testfixture.method.ResolvableMethod;
+import org.springframework.web.testfixture.server.MockServerWebExchange;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Method validation tests for Spring MVC controller methods.
+ * When adding tests, consider the following others:
+ *
+ * {@code HandlerMethodTests} -- detection if methods need validation
+ * {@code MethodValidationAdapterTests} -- method validation independent of Spring MVC
+ * {@code MethodValidationProxyTests} -- method validation with proxy scenarios
+ *
+ * @author Rossen Stoyanchev
+ */
+public class MethodValidationTests {
+
+ private static final Person mockPerson = mock(Person.class);
+
+ private static final Errors mockErrors = mock(Errors.class);
+
+
+ private RequestMappingHandlerAdapter handlerAdapter;
+
+ private InvocationCountingValidator jakartaValidator;
+
+
+ @BeforeEach
+ void setup() throws Exception {
+ LocalValidatorFactoryBean validatorBean = new LocalValidatorFactoryBean();
+ validatorBean.afterPropertiesSet();
+ this.jakartaValidator = new InvocationCountingValidator(validatorBean);
+ this.handlerAdapter = initHandlerAdapter(this.jakartaValidator);
+ }
+
+ private static RequestMappingHandlerAdapter initHandlerAdapter(Validator validator) throws Exception {
+ ConfigurableWebBindingInitializer bindingInitializer = new ConfigurableWebBindingInitializer();
+ bindingInitializer.setValidator(validator);
+
+ GenericWebApplicationContext context = new GenericWebApplicationContext();
+ context.refresh();
+
+ RequestMappingHandlerAdapter handlerAdapter = new RequestMappingHandlerAdapter();
+ handlerAdapter.setWebBindingInitializer(bindingInitializer);
+ handlerAdapter.setApplicationContext(context);
+ handlerAdapter.afterPropertiesSet();
+ return handlerAdapter;
+ }
+
+
+ @Test
+ void modelAttribute() {
+ HandlerMethod hm = handlerMethod(new ValidController(), c -> c.handle(mockPerson));
+ ServerWebExchange exchange = MockServerWebExchange.from(request().queryParam("name", "name=Faustino1234"));
+
+ StepVerifier.create(this.handlerAdapter.handle(exchange, hm))
+ .consumeErrorWith(throwable -> {
+ WebExchangeBindException ex = (WebExchangeBindException) throwable;
+
+ assertThat(this.jakartaValidator.getValidationCount()).isEqualTo(1);
+ assertThat(this.jakartaValidator.getMethodValidationCount()).as("Method validation unexpected").isEqualTo(0);
+
+ assertBeanResult(ex.getBindingResult(), "student", Collections.singletonList(
+ """
+ Field error in object 'student' on field 'name': rejected value [name=Faustino1234]; \
+ codes [Size.student.name,Size.name,Size.java.lang.String,Size]; \
+ arguments [org.springframework.context.support.DefaultMessageSourceResolvable: \
+ codes [student.name,name]; arguments []; default message [name],10,1]; \
+ default message [size must be between 1 and 10]"""));
+ })
+ .verify();
+ }
+
+ @Test
+ void modelAttributeAsync() {
+
+ // 1 for Mono argument validation + 1 for method validation of @RequestHeader
+ this.jakartaValidator.setMaxInvocationsExpected(2);
+
+ HandlerMethod hm = handlerMethod(new ValidController(), c -> c.handleAsync(Mono.empty(), ""));
+
+ ServerWebExchange exchange = MockServerWebExchange.from(
+ request().queryParam("name", "name=Faustino1234").header("myHeader", "12345"));
+
+ HandlerResult handlerResult = this.handlerAdapter.handle(exchange, hm).block();
+
+ StepVerifier.create(((Mono) handlerResult.getReturnValue()))
+ .consumeErrorWith(throwable -> {
+ WebExchangeBindException ex = (WebExchangeBindException) throwable;
+
+ assertThat(this.jakartaValidator.getValidationCount()).isEqualTo(2);
+ assertThat(this.jakartaValidator.getMethodValidationCount()).isEqualTo(1);
+
+ assertBeanResult(ex.getBindingResult(), "student", Collections.singletonList(
+ """
+ Field error in object 'student' on field 'name': rejected value [name=Faustino1234]; \
+ codes [Size.student.name,Size.name,Size.java.lang.String,Size]; \
+ arguments [org.springframework.context.support.DefaultMessageSourceResolvable: \
+ codes [student.name,name]; arguments []; default message [name],10,1]; \
+ default message [size must be between 1 and 10]"""));
+ })
+ .verify();
+ }
+
+ @Test
+ void modelAttributeWithBindingResult() {
+ HandlerMethod hm = handlerMethod(new ValidController(), c -> c.handle(mockPerson, mockErrors));
+ ServerWebExchange exchange = MockServerWebExchange.from(request().queryParam("name", "name=Faustino1234"));
+
+ HandlerResult handlerResult = this.handlerAdapter.handle(exchange, hm).block();
+
+ assertThat(this.jakartaValidator.getValidationCount()).isEqualTo(1);
+ assertThat(this.jakartaValidator.getMethodValidationCount()).as("Method validation unexpected").isEqualTo(0);
+
+ assertThat(handlerResult.getReturnValue()).isEqualTo(
+ """
+ org.springframework.validation.BeanPropertyBindingResult: 1 errors
+ Field error in object 'student' on field 'name': rejected value [name=Faustino1234]; \
+ codes [Size.student.name,Size.name,Size.java.lang.String,Size]; \
+ arguments [org.springframework.context.support.DefaultMessageSourceResolvable: \
+ codes [student.name,name]; arguments []; default message [name],10,1]; \
+ default message [size must be between 1 and 10]""");
+ }
+
+ @Test
+ void modelAttributeWithBindingResultAndRequestHeader() {
+ HandlerMethod hm = handlerMethod(new ValidController(), c -> c.handle(mockPerson, mockErrors, ""));
+
+ ServerWebExchange exchange = MockServerWebExchange.from(
+ request().queryParam("name", "name=Faustino1234").header("myHeader", "123"));
+
+ StepVerifier.create(this.handlerAdapter.handle(exchange, hm))
+ .consumeErrorWith(throwable -> {
+ MethodValidationException ex = (MethodValidationException) throwable;
+
+ assertThat(this.jakartaValidator.getValidationCount()).isEqualTo(1);
+ assertThat(this.jakartaValidator.getMethodValidationCount()).isEqualTo(1);
+
+ assertThat(ex.getConstraintViolations()).hasSize(2);
+ assertThat(ex.getAllValidationResults()).hasSize(2);
+
+ assertBeanResult(ex.getBeanResults().get(0), "student", Collections.singletonList(
+ """
+ Field error in object 'student' on field 'name': rejected value [name=Faustino1234]; \
+ codes [Size.student.name,Size.name,Size.java.lang.String,Size]; \
+ arguments [org.springframework.context.support.DefaultMessageSourceResolvable: \
+ codes [student.name,name]; arguments []; default message [name],10,1]; \
+ default message [size must be between 1 and 10]"""));
+
+ assertValueResult(ex.getValueResults().get(0), 2, "123", Collections.singletonList(
+ """
+ org.springframework.context.support.DefaultMessageSourceResolvable: \
+ codes [Size.validController#handle.myHeader,Size.myHeader,Size.java.lang.String,Size]; \
+ arguments [org.springframework.context.support.DefaultMessageSourceResolvable: \
+ codes [validController#handle.myHeader,myHeader]; arguments []; default message [myHeader],10,5]; \
+ default message [size must be between 5 and 10]"""
+ ));
+ })
+ .verify();
+ }
+
+ @Test
+ void validatedWithMethodValidation() {
+
+ // 1 for @Validated argument validation + 1 for method validation of @RequestHeader
+ this.jakartaValidator.setMaxInvocationsExpected(2);
+
+ HandlerMethod hm = handlerMethod(new ValidController(), c -> c.handleValidated(mockPerson, mockErrors, ""));
+
+ ServerWebExchange exchange = MockServerWebExchange.from(
+ request().queryParam("name", "name=Faustino1234").header("myHeader", "12345"));
+
+ HandlerResult handlerResult = this.handlerAdapter.handle(exchange, hm).block();
+
+ assertThat(jakartaValidator.getValidationCount()).isEqualTo(2);
+ assertThat(jakartaValidator.getMethodValidationCount()).isEqualTo(1);
+
+ assertThat(handlerResult.getReturnValue()).isEqualTo(
+ """
+ org.springframework.validation.BeanPropertyBindingResult: 1 errors
+ Field error in object 'person' on field 'name': rejected value [name=Faustino1234]; \
+ codes [Size.person.name,Size.name,Size.java.lang.String,Size]; \
+ arguments [org.springframework.context.support.DefaultMessageSourceResolvable: \
+ codes [person.name,name]; arguments []; default message [name],10,1]; \
+ default message [size must be between 1 and 10]""");
+ }
+
+ @Test
+ void jakartaAndSpringValidator() {
+ HandlerMethod hm = handlerMethod(new InitBinderController(), ibc -> ibc.handle(mockPerson, mockErrors, ""));
+
+ ServerWebExchange exchange = MockServerWebExchange.from(
+ request().queryParam("name", "name=Faustino1234").header("myHeader", "12345"));
+
+ HandlerResult handlerResult = this.handlerAdapter.handle(exchange, hm).block();
+
+ assertThat(jakartaValidator.getValidationCount()).isEqualTo(1);
+ assertThat(jakartaValidator.getMethodValidationCount()).isEqualTo(1);
+
+ assertThat(handlerResult.getReturnValue()).isEqualTo(
+ """
+ org.springframework.validation.BeanPropertyBindingResult: 2 errors
+ Field error in object 'person' on field 'name': rejected value [name=Faustino1234]; \
+ codes [TOO_LONG.person.name,TOO_LONG.name,TOO_LONG.java.lang.String,TOO_LONG]; \
+ arguments []; default message [length must be 10 or under]
+ Field error in object 'person' on field 'name': rejected value [name=Faustino1234]; \
+ codes [Size.person.name,Size.name,Size.java.lang.String,Size]; \
+ arguments [org.springframework.context.support.DefaultMessageSourceResolvable: \
+ codes [person.name,name]; arguments []; default message [name],10,1]; \
+ default message [size must be between 1 and 10]""");
+ }
+
+
+ @Test
+ void springValidator() throws Exception {
+ HandlerMethod hm = handlerMethod(new ValidController(), c -> c.handle(mockPerson, mockErrors));
+ ServerWebExchange exchange = MockServerWebExchange.from(request().queryParam("name", "name=Faustino1234"));
+
+ RequestMappingHandlerAdapter springValidatorHandlerAdapter = initHandlerAdapter(new PersonValidator());
+ HandlerResult handlerResult = springValidatorHandlerAdapter.handle(exchange, hm).block();
+
+ assertThat(handlerResult.getReturnValue()).isEqualTo(
+ """
+ org.springframework.validation.BeanPropertyBindingResult: 1 errors
+ Field error in object 'student' on field 'name': rejected value [name=Faustino1234]; \
+ codes [TOO_LONG.student.name,TOO_LONG.name,TOO_LONG.java.lang.String,TOO_LONG]; \
+ arguments []; default message [length must be 10 or under]""");
+ }
+
+
+ @SuppressWarnings("unchecked")
+ private static HandlerMethod handlerMethod(T controller, Consumer mockCallConsumer) {
+ Assert.isTrue(!(controller instanceof Class>), "Expected controller instance");
+ Method method = ResolvableMethod.on((Class) controller.getClass()).mockCall(mockCallConsumer).method();
+ return new HandlerMethod(controller, method);
+ }
+
+ private static MockServerHttpRequest.BodyBuilder request() {
+ return MockServerHttpRequest.post("").contentType(MediaType.APPLICATION_FORM_URLENCODED);
+ }
+
+ @SuppressWarnings("SameParameterValue")
+ private static void assertBeanResult(Errors errors, String objectName, List fieldErrors) {
+ assertThat(errors.getObjectName()).isEqualTo(objectName);
+ assertThat(errors.getFieldErrors())
+ .extracting(FieldError::toString)
+ .containsExactlyInAnyOrderElementsOf(fieldErrors);
+ }
+
+ @SuppressWarnings("SameParameterValue")
+ private static void assertValueResult(
+ ParameterValidationResult result, int parameterIndex, Object argument, List errors) {
+
+ assertThat(result.getMethodParameter().getParameterIndex()).isEqualTo(parameterIndex);
+ assertThat(result.getArgument()).isEqualTo(argument);
+ assertThat(result.getResolvableErrors())
+ .extracting(MessageSourceResolvable::toString)
+ .containsExactlyInAnyOrderElementsOf(errors);
+ }
+
+
+ @SuppressWarnings("unused")
+ private record Person(@Size(min = 1, max = 10) String name) {
+
+ @Override
+ public String name() {
+ return this.name;
+ }
+ }
+
+
+ @SuppressWarnings({"unused", "SameParameterValue", "UnusedReturnValue"})
+ @RestController
+ static class ValidController {
+
+ void handle(@Valid @ModelAttribute("student") Person person) {
+ }
+
+ String handle(@Valid @ModelAttribute("student") Person person, Errors errors) {
+ return errors.toString();
+ }
+
+ void handle(@Valid @ModelAttribute("student") Person person, Errors errors,
+ @RequestHeader @Size(min = 5, max = 10) String myHeader) {
+ }
+
+ String handleValidated(@Validated Person person, Errors errors,
+ @RequestHeader @Size(min = 5, max = 10) String myHeader) {
+
+ return errors.toString();
+ }
+
+ Mono handleAsync(@Valid @ModelAttribute("student") Mono person,
+ @RequestHeader @Size(min = 5, max = 10) String myHeader) {
+
+ return person.map(Person::toString);
+ }
+ }
+
+
+ @SuppressWarnings({"unused", "UnusedReturnValue", "SameParameterValue"})
+ @RestController
+ static class InitBinderController {
+
+ @InitBinder
+ void initBinder(WebDataBinder dataBinder) {
+ dataBinder.addValidators(new PersonValidator());
+ }
+
+ String handle(@Valid Person person, Errors errors, @RequestHeader @Size(min = 5, max = 10) String myHeader) {
+ return errors.toString();
+ }
+ }
+
+
+ private static class PersonValidator implements Validator {
+
+ @Override
+ public boolean supports(Class> clazz) {
+ return (clazz == Person.class);
+ }
+
+ @Override
+ public void validate(Object target, Errors errors) {
+ Person person = (Person) target;
+ if (person.name().length() > 10) {
+ errors.rejectValue("name", "TOO_LONG", "length must be 10 or under");
+ }
+ }
+ }
+
+
+ /**
+ * Intercept and count number of method validation calls.
+ */
+ private static class InvocationCountingValidator implements jakarta.validation.Validator, Validator {
+
+ private final SpringValidatorAdapter delegate;
+
+ private int maxInvocationsExpected = 1;
+
+ private int validationCount;
+
+ private int methodValidationCount;
+
+ /**
+ * Constructor with maxCount=1.
+ */
+ private InvocationCountingValidator(SpringValidatorAdapter delegate) {
+ this.delegate = delegate;
+ }
+
+ public void setMaxInvocationsExpected(int maxInvocationsExpected) {
+ this.maxInvocationsExpected = maxInvocationsExpected;
+ }
+
+ /**
+ * Total number of times Bean Validation was invoked.
+ */
+ public int getValidationCount() {
+ return this.validationCount;
+ }
+
+ /**
+ * Number of times method level Bean Validation was invoked.
+ */
+ public int getMethodValidationCount() {
+ return this.methodValidationCount;
+ }
+
+ @Override
+ public Set> validate(T object, Class>... groups) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Set> validateProperty(T object, String propertyName, Class>... groups) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Set> validateValue(Class beanType, String propertyName, Object value, Class>... groups) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public BeanDescriptor getConstraintsForClass(Class> clazz) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public T unwrap(Class type) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public ExecutableValidator forExecutables() {
+ this.methodValidationCount++;
+ assertCountAndIncrement();
+ return this.delegate.forExecutables();
+ }
+
+ @Override
+ public boolean supports(Class> clazz) {
+ return true;
+ }
+
+ @Override
+ public void validate(Object target, Errors errors) {
+ assertCountAndIncrement();
+ this.delegate.validate(target, errors);
+ }
+
+ private void assertCountAndIncrement() {
+ assertThat(this.validationCount++).as("Too many calls to Bean Validation").isLessThan(this.maxInvocationsExpected);
+ }
+ }
+
+}
diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ModelInitializerTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ModelInitializerTests.java
index 1cb6fd9634..54ca957494 100644
--- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ModelInitializerTests.java
+++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ModelInitializerTests.java
@@ -79,7 +79,8 @@ public class ModelInitializerTests {
resolverConfigurer.addCustomResolver(new ModelMethodArgumentResolver(adapterRegistry));
ControllerMethodResolver methodResolver = new ControllerMethodResolver(
- resolverConfigurer, adapterRegistry, new StaticApplicationContext(), Collections.emptyList());
+ resolverConfigurer, adapterRegistry, new StaticApplicationContext(),
+ Collections.emptyList(), null);
this.modelInitializer = new ModelInitializer(methodResolver, adapterRegistry);
}
@@ -210,7 +211,7 @@ public class ModelInitializerTests {
.toList();
WebBindingInitializer bindingInitializer = new ConfigurableWebBindingInitializer();
- return new InitBinderBindingContext(bindingInitializer, binderMethods);
+ return new InitBinderBindingContext(bindingInitializer, binderMethods, false);
}