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: + *

+ * @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); }