From bd054a49185fe24da7703c4163d3645e53d8accf Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Thu, 8 Jun 2023 15:39:22 +0100 Subject: [PATCH] Add method validation to Spring MVC See gh-29825 --- .../validation/DataBinder.java | 28 +- .../support/DefaultDataBinderFactory.java | 51 +- .../bind/support/WebDataBinderFactory.java | 18 +- .../web/method/HandlerMethod.java | 83 +++- .../ModelAttributeMethodProcessor.java | 4 +- .../support/HandlerMethodValidator.java | 124 +++++ .../support/InvocableHandlerMethod.java | 37 +- .../web/method/HandlerMethodTests.java | 191 ++++++++ .../ModelAttributeMethodProcessorTests.java | 22 +- ...odelAttributeMethodProcessorKotlinTests.kt | 4 +- spring-webmvc/spring-webmvc.gradle | 1 + .../RequestMappingHandlerAdapter.java | 19 +- .../RequestPartMethodArgumentResolver.java | 4 +- .../RequestResponseBodyMethodProcessor.java | 2 +- .../annotation/MethodValidationTests.java | 439 ++++++++++++++++++ 15 files changed, 1000 insertions(+), 27 deletions(-) create mode 100644 spring-web/src/main/java/org/springframework/web/method/support/HandlerMethodValidator.java create mode 100644 spring-web/src/test/java/org/springframework/web/method/HandlerMethodTests.java create mode 100644 spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/MethodValidationTests.java diff --git a/spring-context/src/main/java/org/springframework/validation/DataBinder.java b/spring-context/src/main/java/org/springframework/validation/DataBinder.java index c2e5ecfa3b..9b1b5e014e 100644 --- a/spring-context/src/main/java/org/springframework/validation/DataBinder.java +++ b/spring-context/src/main/java/org/springframework/validation/DataBinder.java @@ -24,6 +24,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.function.Predicate; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -164,6 +165,9 @@ public class DataBinder implements PropertyEditorRegistry, TypeConverter { private final List validators = new ArrayList<>(); + @Nullable + private Predicate excludedValidators; + /** * Create a new DataBinder instance, with default object name. @@ -580,6 +584,14 @@ public class DataBinder implements PropertyEditorRegistry, TypeConverter { } } + /** + * Configure a predicate to exclude validators. + * @since 6.1 + */ + public void setExcludedValidators(Predicate predicate) { + this.excludedValidators = predicate; + } + /** * Add Validators to apply after each binding step. * @see #setValidator(Validator) @@ -616,6 +628,18 @@ public class DataBinder implements PropertyEditorRegistry, TypeConverter { return Collections.unmodifiableList(this.validators); } + /** + * Return the Validators to apply after data binding. This includes the + * configured {@link #getValidators() validators} filtered by the + * {@link #setExcludedValidators(Predicate) exclude predicate}. + * @since 6.1 + */ + public List getValidatorsToApply() { + return (this.excludedValidators != null ? + this.validators.stream().filter(validator -> !this.excludedValidators.test(validator)).toList() : + Collections.unmodifiableList(this.validators)); + } + //--------------------------------------------------------------------- // Implementation of PropertyEditorRegistry/TypeConverter interface @@ -906,7 +930,7 @@ public class DataBinder implements PropertyEditorRegistry, TypeConverter { Assert.state(target != null, "No target to validate"); BindingResult bindingResult = getBindingResult(); // Call each validator with the same binding result - for (Validator validator : getValidators()) { + for (Validator validator : getValidatorsToApply()) { validator.validate(target, bindingResult); } } @@ -924,7 +948,7 @@ public class DataBinder implements PropertyEditorRegistry, TypeConverter { Assert.state(target != null, "No target to validate"); BindingResult bindingResult = getBindingResult(); // Call each validator with the same binding result - for (Validator validator : getValidators()) { + for (Validator validator : getValidatorsToApply()) { if (!ObjectUtils.isEmpty(validationHints) && validator instanceof SmartValidator smartValidator) { smartValidator.validate(target, bindingResult, validationHints); } diff --git a/spring-web/src/main/java/org/springframework/web/bind/support/DefaultDataBinderFactory.java b/spring-web/src/main/java/org/springframework/web/bind/support/DefaultDataBinderFactory.java index abca503604..e1551e3891 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/support/DefaultDataBinderFactory.java +++ b/spring-web/src/main/java/org/springframework/web/bind/support/DefaultDataBinderFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 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,7 +16,11 @@ package org.springframework.web.bind.support; +import java.lang.annotation.Annotation; + +import org.springframework.core.MethodParameter; import org.springframework.lang.Nullable; +import org.springframework.validation.DataBinder; import org.springframework.web.bind.WebDataBinder; import org.springframework.web.context.request.NativeWebRequest; @@ -32,6 +36,8 @@ public class DefaultDataBinderFactory implements WebDataBinderFactory { @Nullable private final WebBindingInitializer initializer; + private boolean methodValidationApplicable; + /** * Create a new {@code DefaultDataBinderFactory} instance. @@ -43,6 +49,17 @@ public class DefaultDataBinderFactory implements WebDataBinderFactory { } + /** + * 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 new {@link WebDataBinder} for the given target object and * initialize it through a {@link WebBindingInitializer}. @@ -87,4 +104,36 @@ public class DefaultDataBinderFactory implements WebDataBinderFactory { } + /** + * {@inheritDoc}. + *

By default, if the parameter has {@code @Valid}, Bean Validation is + * excluded, deferring to method validation. + */ + @Override + public WebDataBinder createBinder( + NativeWebRequest webRequest, @Nullable Object target, String objectName, + MethodParameter parameter) throws Exception { + + WebDataBinder dataBinder = createBinder(webRequest, target, objectName); + if (this.methodValidationApplicable) { + MethodValidationInitializer.updateBinder(dataBinder, parameter); + } + return dataBinder; + } + + + /** + * Excludes Bean Validation if the method parameter has {@code @Valid}. + */ + private static class MethodValidationInitializer { + + public static void updateBinder(DataBinder binder, MethodParameter parameter) { + 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-web/src/main/java/org/springframework/web/bind/support/WebDataBinderFactory.java b/spring-web/src/main/java/org/springframework/web/bind/support/WebDataBinderFactory.java index e513d1094a..bf1981a99b 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/support/WebDataBinderFactory.java +++ b/spring-web/src/main/java/org/springframework/web/bind/support/WebDataBinderFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2017 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,6 +16,7 @@ package org.springframework.web.bind.support; +import org.springframework.core.MethodParameter; import org.springframework.lang.Nullable; import org.springframework.web.bind.WebDataBinder; import org.springframework.web.context.request.NativeWebRequest; @@ -24,6 +25,7 @@ import org.springframework.web.context.request.NativeWebRequest; * A factory for creating a {@link WebDataBinder} instance for a named target object. * * @author Arjen Poutsma + * @author Rossen Stoyanchev * @since 3.1 */ public interface WebDataBinderFactory { @@ -40,4 +42,18 @@ public interface WebDataBinderFactory { WebDataBinder createBinder(NativeWebRequest webRequest, @Nullable Object target, String objectName) throws Exception; + /** + * Variant of {@link #createBinder(NativeWebRequest, Object, String)} with a + * {@link MethodParameter} for which the {@code DataBinder} is created. This + * may provide more insight to initialize the {@link WebDataBinder}. + * @since 6.1 + */ + default WebDataBinder createBinder( + NativeWebRequest webRequest, @Nullable Object target, String objectName, + MethodParameter parameter) throws Exception { + + return createBinder(webRequest, target, objectName); + } + + } diff --git a/spring-web/src/main/java/org/springframework/web/method/HandlerMethod.java b/spring-web/src/main/java/org/springframework/web/method/HandlerMethod.java index 6a3c587b13..885b0afb3b 100644 --- a/spring-web/src/main/java/org/springframework/web/method/HandlerMethod.java +++ b/spring-web/src/main/java/org/springframework/web/method/HandlerMethod.java @@ -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. @@ -22,6 +22,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.StringJoiner; +import java.util.function.Predicate; import java.util.stream.Collectors; import java.util.stream.IntStream; @@ -35,6 +36,10 @@ import org.springframework.core.BridgeMethodResolver; import org.springframework.core.MethodParameter; import org.springframework.core.ResolvableType; import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.core.annotation.MergedAnnotationPredicates; +import org.springframework.core.annotation.MergedAnnotations; import org.springframework.core.annotation.SynthesizingMethodParameter; import org.springframework.http.HttpStatusCode; import org.springframework.lang.NonNull; @@ -44,6 +49,7 @@ import org.springframework.util.ClassUtils; import org.springframework.util.ObjectUtils; import org.springframework.util.ReflectionUtils; import org.springframework.util.StringUtils; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.ResponseStatus; /** @@ -84,6 +90,10 @@ public class HandlerMethod { private final MethodParameter[] parameters; + private final boolean validateArguments; + + private final boolean validateReturnValue; + @Nullable private HttpStatusCode responseStatus; @@ -122,6 +132,8 @@ public class HandlerMethod { this.bridgedMethod = BridgeMethodResolver.findBridgedMethod(method); ReflectionUtils.makeAccessible(this.bridgedMethod); this.parameters = initMethodParameters(); + this.validateArguments = MethodValidationInitializer.checkArguments(this.beanType, this.parameters); + this.validateReturnValue = MethodValidationInitializer.checkReturnValue(this.beanType, this.bridgedMethod); evaluateResponseStatus(); this.description = initDescription(this.beanType, this.method); } @@ -141,6 +153,8 @@ public class HandlerMethod { this.bridgedMethod = BridgeMethodResolver.findBridgedMethod(this.method); ReflectionUtils.makeAccessible(this.bridgedMethod); this.parameters = initMethodParameters(); + this.validateArguments = MethodValidationInitializer.checkArguments(this.beanType, this.parameters); + this.validateReturnValue = MethodValidationInitializer.checkReturnValue(this.beanType, this.bridgedMethod); evaluateResponseStatus(); this.description = initDescription(this.beanType, this.method); } @@ -177,6 +191,8 @@ public class HandlerMethod { this.bridgedMethod = BridgeMethodResolver.findBridgedMethod(method); ReflectionUtils.makeAccessible(this.bridgedMethod); this.parameters = initMethodParameters(); + this.validateArguments = MethodValidationInitializer.checkArguments(this.beanType, this.parameters); + this.validateReturnValue = MethodValidationInitializer.checkReturnValue(this.beanType, this.bridgedMethod); evaluateResponseStatus(); this.description = initDescription(this.beanType, this.method); } @@ -193,6 +209,8 @@ public class HandlerMethod { this.method = handlerMethod.method; this.bridgedMethod = handlerMethod.bridgedMethod; this.parameters = handlerMethod.parameters; + this.validateArguments = handlerMethod.validateArguments; + this.validateReturnValue = handlerMethod.validateReturnValue; this.responseStatus = handlerMethod.responseStatus; this.responseStatusReason = handlerMethod.responseStatusReason; this.description = handlerMethod.description; @@ -212,6 +230,8 @@ public class HandlerMethod { this.method = handlerMethod.method; this.bridgedMethod = handlerMethod.bridgedMethod; this.parameters = handlerMethod.parameters; + this.validateArguments = handlerMethod.validateArguments; + this.validateReturnValue = handlerMethod.validateReturnValue; this.responseStatus = handlerMethod.responseStatus; this.responseStatusReason = handlerMethod.responseStatusReason; this.resolvedFromHandlerMethod = handlerMethod; @@ -290,6 +310,33 @@ public class HandlerMethod { return this.parameters; } + /** + * Whether the method arguments are a candidate for method validation, which + * is the case when there are parameter {@code jakarta.validation.Constraint} + * annotations. + *

The presence of {@code jakarta.validation.Valid} by itself does not + * trigger method validation since such parameters are already validated at + * the level of argument resolvers. + *

Note: if the class is annotated with {@link Validated}, + * this method returns false, deferring to method validation via AOP proxy. + * @since 6.1 + */ + public boolean shouldValidateArguments() { + return this.validateArguments; + } + + /** + * Whether the method return value is a candidate for method validation, which + * is the case when there are method {@code jakarta.validation.Constraint} + * or {@code jakarta.validation.Valid} annotations. + *

Note: if the class is annotated with {@link Validated}, + * this method returns false, deferring to method validation via AOP proxy. + * @since 6.1 + */ + public boolean shouldValidateReturnValue() { + return this.validateReturnValue; + } + /** * Return the specified response status, if any. * @since 4.3.8 @@ -603,4 +650,38 @@ public class HandlerMethod { } } + + /** + * Checks for the presence of {@code @Constraint} and {@code @Valid} + * annotations on the method and method parameters. + */ + private static class MethodValidationInitializer { + + private static final Predicate> INPUT_PREDICATE = + MergedAnnotationPredicates.typeIn("jakarta.validation.Constraint"); + + private static final Predicate> OUTPUT_PREDICATE = + MergedAnnotationPredicates.typeIn("jakarta.validation.Valid", "jakarta.validation.Constraint"); + + public static boolean checkArguments(Class beanType, MethodParameter[] parameters) { + if (AnnotationUtils.findAnnotation(beanType, Validated.class) == null) { + for (MethodParameter parameter : parameters) { + MergedAnnotations merged = MergedAnnotations.from(parameter.getParameterAnnotations()); + if (merged.stream().anyMatch(INPUT_PREDICATE)) { + return true; + } + } + } + return false; + } + + public static boolean checkReturnValue(Class beanType, Method method) { + if (AnnotationUtils.findAnnotation(beanType, Validated.class) == null) { + MergedAnnotations merged = MergedAnnotations.from(method, MergedAnnotations.SearchStrategy.TYPE_HIERARCHY); + return merged.stream().anyMatch(OUTPUT_PREDICATE); + } + return false; + } + } + } diff --git a/spring-web/src/main/java/org/springframework/web/method/annotation/ModelAttributeMethodProcessor.java b/spring-web/src/main/java/org/springframework/web/method/annotation/ModelAttributeMethodProcessor.java index bc784a8f5b..54fbfef65c 100644 --- a/spring-web/src/main/java/org/springframework/web/method/annotation/ModelAttributeMethodProcessor.java +++ b/spring-web/src/main/java/org/springframework/web/method/annotation/ModelAttributeMethodProcessor.java @@ -164,7 +164,7 @@ public class ModelAttributeMethodProcessor implements HandlerMethodArgumentResol if (bindingResult == null) { // Bean property binding and validation; // skipped in case of binding failure on construction. - WebDataBinder binder = binderFactory.createBinder(webRequest, attribute, name); + WebDataBinder binder = binderFactory.createBinder(webRequest, attribute, name, parameter); if (binder.getTarget() != null) { if (!mavContainer.isBindingDisabled(name)) { bindRequestParameters(binder, webRequest); @@ -251,7 +251,7 @@ public class ModelAttributeMethodProcessor implements HandlerMethodArgumentResol String[] paramNames = BeanUtils.getParameterNames(ctor); Class[] paramTypes = ctor.getParameterTypes(); Object[] args = new Object[paramTypes.length]; - WebDataBinder binder = binderFactory.createBinder(webRequest, null, attributeName); + WebDataBinder binder = binderFactory.createBinder(webRequest, null, attributeName, parameter); String fieldDefaultPrefix = binder.getFieldDefaultPrefix(); String fieldMarkerPrefix = binder.getFieldMarkerPrefix(); boolean bindingFailure = false; diff --git a/spring-web/src/main/java/org/springframework/web/method/support/HandlerMethodValidator.java b/spring-web/src/main/java/org/springframework/web/method/support/HandlerMethodValidator.java new file mode 100644 index 0000000000..f04efcb270 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/method/support/HandlerMethodValidator.java @@ -0,0 +1,124 @@ +/* + * 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.method.support; + +import java.lang.reflect.Method; + +import jakarta.validation.Validator; + +import org.springframework.core.Conventions; +import org.springframework.core.MethodParameter; +import org.springframework.core.ParameterNameDiscoverer; +import org.springframework.lang.Nullable; +import org.springframework.validation.BindingResult; +import org.springframework.validation.MessageCodesResolver; +import org.springframework.validation.beanvalidation.DefaultMethodValidator; +import org.springframework.validation.beanvalidation.MethodValidationAdapter; +import org.springframework.validation.beanvalidation.MethodValidationResult; +import org.springframework.validation.beanvalidation.MethodValidator; +import org.springframework.validation.beanvalidation.ParameterErrors; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.support.ConfigurableWebBindingInitializer; +import org.springframework.web.bind.support.WebBindingInitializer; +import org.springframework.web.method.annotation.ModelFactory; + +/** + * {@link org.springframework.validation.beanvalidation.MethodValidator} for + * use with {@code @RequestMapping} methods. Helps to determine object names + * and populates {@link BindingResult} method arguments with errors from + * {@link MethodValidationResult#getBeanResults() beanResults}. + * + * @author Rossen Stoyanchev + * @since 6.1 + */ +public class HandlerMethodValidator extends DefaultMethodValidator { + + + public HandlerMethodValidator(MethodValidationAdapter adapter) { + super(adapter); + adapter.setBindingResultNameResolver(this::determineObjectName); + } + + private String determineObjectName(MethodParameter param, @Nullable Object argument) { + if (param.hasParameterAnnotation(RequestBody.class) || param.hasParameterAnnotation(RequestPart.class)) { + return Conventions.getVariableNameForParameter(param); + } + else { + return ((param.getParameterIndex() != -1) ? + ModelFactory.getNameForParameter(param) : + ModelFactory.getNameForReturnValue(argument, param)); + } + } + + + @Override + protected void handleArgumentsResult( + Object bean, Method method, Object[] arguments, Class[] groups, MethodValidationResult result) { + + if (result.getConstraintViolations().isEmpty()) { + return; + } + if (!result.getBeanResults().isEmpty()) { + int bindingResultCount = 0; + for (ParameterErrors errors : result.getBeanResults()) { + for (Object arg : arguments) { + if (arg instanceof BindingResult bindingResult) { + if (bindingResult.getObjectName().equals(errors.getObjectName())) { + bindingResult.addAllErrors(errors); + bindingResultCount++; + break; + } + } + } + } + if (result.getAllValidationResults().size() == bindingResultCount) { + return; + } + } + result.throwIfViolationsPresent(); + } + + + /** + * Create a {@link MethodValidator} if Bean Validation is enabled in Spring MVC or WebFlux. + * @param bindingInitializer for the configured Validator and MessageCodesResolver + * @param parameterNameDiscoverer the {@code ParameterNameDiscoverer} to use + * for {@link MethodValidationAdapter#setParameterNameDiscoverer} + */ + @Nullable + public static MethodValidator from( + @Nullable WebBindingInitializer bindingInitializer, + @Nullable ParameterNameDiscoverer parameterNameDiscoverer) { + + if (bindingInitializer instanceof ConfigurableWebBindingInitializer configurableInitializer) { + if (configurableInitializer.getValidator() instanceof Validator validator) { + MethodValidationAdapter validationAdapter = new MethodValidationAdapter(validator); + if (parameterNameDiscoverer != null) { + validationAdapter.setParameterNameDiscoverer(parameterNameDiscoverer); + } + MessageCodesResolver codesResolver = configurableInitializer.getMessageCodesResolver(); + if (codesResolver != null) { + validationAdapter.setMessageCodesResolver(codesResolver); + } + return new HandlerMethodValidator(validationAdapter); + } + } + return null; + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/method/support/InvocableHandlerMethod.java b/spring-web/src/main/java/org/springframework/web/method/support/InvocableHandlerMethod.java index 99e7ea079c..f2c38698df 100644 --- a/spring-web/src/main/java/org/springframework/web/method/support/InvocableHandlerMethod.java +++ b/spring-web/src/main/java/org/springframework/web/method/support/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. @@ -30,6 +30,7 @@ import org.springframework.core.MethodParameter; import org.springframework.core.ParameterNameDiscoverer; import org.springframework.lang.Nullable; import org.springframework.util.ObjectUtils; +import org.springframework.validation.beanvalidation.MethodValidator; import org.springframework.web.bind.WebDataBinder; import org.springframework.web.bind.support.SessionStatus; import org.springframework.web.bind.support.WebDataBinderFactory; @@ -50,6 +51,8 @@ public class InvocableHandlerMethod extends HandlerMethod { private static final Object[] EMPTY_ARGS = new Object[0]; + private static final Class[] EMPTY_GROUPS = new Class[0]; + private HandlerMethodArgumentResolverComposite resolvers = new HandlerMethodArgumentResolverComposite(); @@ -58,6 +61,9 @@ public class InvocableHandlerMethod extends HandlerMethod { @Nullable private WebDataBinderFactory dataBinderFactory; + @Nullable + private MethodValidator methodValidator; + /** * Create an instance from a {@code HandlerMethod}. @@ -121,6 +127,16 @@ public class InvocableHandlerMethod extends HandlerMethod { this.dataBinderFactory = dataBinderFactory; } + /** + * 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 after resolving its argument values in the context of the given request. @@ -149,7 +165,19 @@ public class InvocableHandlerMethod extends HandlerMethod { if (logger.isTraceEnabled()) { logger.trace("Arguments: " + Arrays.toString(args)); } - return doInvoke(args); + + Class[] groups = getValidationGroups(); + if (shouldValidateArguments() && this.methodValidator != null) { + this.methodValidator.validateArguments(getBean(), getBridgedMethod(), args, groups); + } + + Object returnValue = doInvoke(args); + + if (shouldValidateReturnValue() && this.methodValidator != null) { + this.methodValidator.validateReturnValue(getBean(), getBridgedMethod(), returnValue, groups); + } + + return returnValue; } /** @@ -194,6 +222,11 @@ public class InvocableHandlerMethod extends HandlerMethod { return args; } + private Class[] getValidationGroups() { + return ((shouldValidateArguments() || shouldValidateReturnValue()) && this.methodValidator != null ? + this.methodValidator.determineValidationGroups(getBean(), getBridgedMethod()) : EMPTY_GROUPS); + } + /** * Invoke the handler method with the given argument values. */ diff --git a/spring-web/src/test/java/org/springframework/web/method/HandlerMethodTests.java b/spring-web/src/test/java/org/springframework/web/method/HandlerMethodTests.java new file mode 100644 index 0000000000..7f88ca96d7 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/method/HandlerMethodTests.java @@ -0,0 +1,191 @@ +/* + * 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.method; + +import java.lang.reflect.Method; +import java.util.List; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Size; +import org.junit.jupiter.api.Test; + +import org.springframework.util.ClassUtils; +import org.springframework.validation.annotation.Validated; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link HandlerMethod}. + * @author Rossen Stoyanchev + */ +public class HandlerMethodTests { + + @Test + void shouldValidateArgsWithConstraintsDirectlyOnClass() { + Object target = new MyClass(); + testShouldValidateArguments(target, List.of("addIntValue", "addPersonAndIntValue"), true); + testShouldValidateArguments(target, List.of("addPerson", "getPerson", "getIntValue", "addPersonNotValidated"), false); + } + + @Test + void shouldValidateArgsWithConstraintsOnInterface() { + Object target = new MyInterfaceImpl(); + testShouldValidateArguments(target, List.of("addIntValue", "addPersonAndIntValue"), true); + testShouldValidateArguments(target, List.of("addPerson", "addPersonNotValidated", "getPerson", "getIntValue"), false); + } + + @Test + void shouldValidateReturnValueWithConstraintsDirectlyOnClass() { + Object target = new MyClass(); + testShouldValidateReturnValue(target, List.of("getPerson", "getIntValue"), true); + testShouldValidateReturnValue(target, List.of("addPerson", "addIntValue", "addPersonNotValidated"), false); + } + + @Test + void shouldValidateReturnValueWithConstraintsOnInterface() { + Object target = new MyInterfaceImpl(); + testShouldValidateReturnValue(target, List.of("getPerson", "getIntValue"), true); + testShouldValidateReturnValue(target, List.of("addPerson", "addIntValue", "addPersonNotValidated"), false); + } + + @Test + void classLevelValidatedAnnotation() { + Object target = new MyValidatedClass(); + testShouldValidateArguments(target, List.of("addPerson"), false); + testShouldValidateReturnValue(target, List.of("getPerson"), false); + } + + private static void testShouldValidateArguments(Object target, List methodNames, boolean expected) { + for (String methodName : methodNames) { + assertThat(getHandlerMethod(target, methodName).shouldValidateArguments()).isEqualTo(expected); + } + } + + private static void testShouldValidateReturnValue(Object target, List methodNames, boolean expected) { + for (String methodName : methodNames) { + assertThat(getHandlerMethod(target, methodName).shouldValidateReturnValue()).isEqualTo(expected); + } + } + + private static HandlerMethod getHandlerMethod(Object target, String methodName) { + Method method = ClassUtils.getMethod(target.getClass(), methodName, (Class[]) null); + return new HandlerMethod(target, method); + } + + + @SuppressWarnings("unused") + private record Person(@Size(min = 1, max = 10) String name) { + + @Override + public String name() { + return this.name; + } + } + + + @SuppressWarnings("unused") + private static class MyClass { + + public void addPerson(@Valid Person person) { + } + + public void addIntValue(@Max(10) int value) { + } + + public void addPersonAndIntValue(@Valid Person person, @Max(10) int value) { + } + + public void addPersonNotValidated(Person person) { + } + + @Valid + public Person getPerson() { + throw new UnsupportedOperationException(); + } + + @Max(10) + public int getIntValue() { + throw new UnsupportedOperationException(); + } + } + + + @SuppressWarnings("unused") + private interface MyInterface { + + void addPerson(@Valid Person person); + + void addIntValue(@Max(10) int value); + + void addPersonAndIntValue(@Valid Person person, @Max(10) int value); + + void addPersonNotValidated(Person person); + + @Valid + Person getPerson(); + + @Max(10) + int getIntValue(); + } + + + @SuppressWarnings("unused") + private static class MyInterfaceImpl implements MyInterface { + + @Override + public void addPerson(Person person) { + } + + @Override + public void addIntValue(int value) { + } + + @Override + public void addPersonAndIntValue(Person person, int value) { + } + + @Override + public void addPersonNotValidated(Person person) { + } + + @Override + public Person getPerson() { + throw new UnsupportedOperationException(); + } + + @Override + public int getIntValue() { + throw new UnsupportedOperationException(); + } + } + + + @SuppressWarnings("unused") + @Validated + private static class MyValidatedClass { + + public void addPerson(@Valid Person person) { + } + + @Valid + public Person getPerson() { + throw new UnsupportedOperationException(); + } + } + +} diff --git a/spring-web/src/test/java/org/springframework/web/method/annotation/ModelAttributeMethodProcessorTests.java b/spring-web/src/test/java/org/springframework/web/method/annotation/ModelAttributeMethodProcessorTests.java index 9742c1c0cf..9f6d6cb64a 100644 --- a/spring-web/src/test/java/org/springframework/web/method/annotation/ModelAttributeMethodProcessorTests.java +++ b/spring-web/src/test/java/org/springframework/web/method/annotation/ModelAttributeMethodProcessorTests.java @@ -162,10 +162,10 @@ public class ModelAttributeMethodProcessorTests { public void resolveArgumentViaDefaultConstructor() throws Exception { WebDataBinder dataBinder = new WebRequestDataBinder(null); WebDataBinderFactory factory = mock(); - given(factory.createBinder(any(), notNull(), eq("attrName"))).willReturn(dataBinder); + given(factory.createBinder(any(), notNull(), eq("attrName"), any())).willReturn(dataBinder); this.processor.resolveArgument(this.paramNamedValidModelAttr, this.container, this.request, factory); - verify(factory).createBinder(any(), notNull(), eq("attrName")); + verify(factory).createBinder(any(), notNull(), eq("attrName"), any()); } @Test @@ -176,7 +176,7 @@ public class ModelAttributeMethodProcessorTests { StubRequestDataBinder dataBinder = new StubRequestDataBinder(target, name); WebDataBinderFactory factory = mock(); - given(factory.createBinder(this.request, target, name)).willReturn(dataBinder); + given(factory.createBinder(this.request, target, name, this.paramNamedValidModelAttr)).willReturn(dataBinder); this.processor.resolveArgument(this.paramNamedValidModelAttr, this.container, this.request, factory); @@ -195,7 +195,7 @@ public class ModelAttributeMethodProcessorTests { StubRequestDataBinder dataBinder = new StubRequestDataBinder(target, name); WebDataBinderFactory factory = mock(); - given(factory.createBinder(this.request, target, name)).willReturn(dataBinder); + given(factory.createBinder(this.request, target, name, this.paramNamedValidModelAttr)).willReturn(dataBinder); this.processor.resolveArgument(this.paramNamedValidModelAttr, this.container, this.request, factory); @@ -211,7 +211,7 @@ public class ModelAttributeMethodProcessorTests { StubRequestDataBinder dataBinder = new StubRequestDataBinder(target, name); WebDataBinderFactory factory = mock(); - given(factory.createBinder(this.request, target, name)).willReturn(dataBinder); + given(factory.createBinder(this.request, target, name, this.paramBindingDisabledAttr)).willReturn(dataBinder); this.processor.resolveArgument(this.paramBindingDisabledAttr, this.container, this.request, factory); @@ -229,12 +229,12 @@ public class ModelAttributeMethodProcessorTests { dataBinder.getBindingResult().reject("error"); WebDataBinderFactory binderFactory = mock(); - given(binderFactory.createBinder(this.request, target, name)).willReturn(dataBinder); + given(binderFactory.createBinder(this.request, target, name, this.paramNonSimpleType)).willReturn(dataBinder); assertThatExceptionOfType(MethodArgumentNotValidException.class).isThrownBy(() -> this.processor.resolveArgument(this.paramNonSimpleType, this.container, this.request, binderFactory)); - verify(binderFactory).createBinder(this.request, target, name); + verify(binderFactory).createBinder(this.request, target, name, this.paramNonSimpleType); } @Test // SPR-9378 @@ -249,7 +249,7 @@ public class ModelAttributeMethodProcessorTests { StubRequestDataBinder dataBinder = new StubRequestDataBinder(testBean, name); WebDataBinderFactory binderFactory = mock(); - given(binderFactory.createBinder(this.request, testBean, name)).willReturn(dataBinder); + given(binderFactory.createBinder(this.request, testBean, name, this.paramModelAttr)).willReturn(dataBinder); this.processor.resolveArgument(this.paramModelAttr, this.container, this.request, binderFactory); @@ -278,7 +278,7 @@ public class ModelAttributeMethodProcessorTests { ServletWebRequest requestWithParam = new ServletWebRequest(mockRequest); WebDataBinderFactory factory = mock(); - given(factory.createBinder(any(), any(), eq("testBeanWithConstructorArgs"))) + given(factory.createBinder(any(), any(), eq("testBeanWithConstructorArgs"), any())) .willAnswer(invocation -> { WebRequestDataBinder binder = new WebRequestDataBinder(invocation.getArgument(1)); // Add conversion service which will convert "1,2" to a list @@ -297,10 +297,10 @@ public class ModelAttributeMethodProcessorTests { WebDataBinder dataBinder = new WebRequestDataBinder(target); WebDataBinderFactory factory = mock(); - given(factory.createBinder(this.request, target, expectedAttrName)).willReturn(dataBinder); + given(factory.createBinder(this.request, target, expectedAttrName, param)).willReturn(dataBinder); this.processor.resolveArgument(param, this.container, this.request, factory); - verify(factory).createBinder(this.request, target, expectedAttrName); + verify(factory).createBinder(this.request, target, expectedAttrName, param); } diff --git a/spring-web/src/test/kotlin/org/springframework/web/method/annotation/ModelAttributeMethodProcessorKotlinTests.kt b/spring-web/src/test/kotlin/org/springframework/web/method/annotation/ModelAttributeMethodProcessorKotlinTests.kt index 62047b74a9..7330edf563 100644 --- a/spring-web/src/test/kotlin/org/springframework/web/method/annotation/ModelAttributeMethodProcessorKotlinTests.kt +++ b/spring-web/src/test/kotlin/org/springframework/web/method/annotation/ModelAttributeMethodProcessorKotlinTests.kt @@ -60,7 +60,7 @@ class ModelAttributeMethodProcessorKotlinTests { val mockRequest = MockHttpServletRequest().apply { addParameter("a", "b") } val requestWithParam = ServletWebRequest(mockRequest) val factory = mock() - given(factory.createBinder(any(), any(), eq("param"))) + given(factory.createBinder(any(), any(), eq("param"), any())) .willAnswer { WebRequestDataBinder(it.getArgument(1)) } assertThat(processor.resolveArgument(this.param, container, requestWithParam, factory)).isEqualTo(Param("b")) } @@ -70,7 +70,7 @@ class ModelAttributeMethodProcessorKotlinTests { val mockRequest = MockHttpServletRequest().apply { addParameter("a", null) } val requestWithParam = ServletWebRequest(mockRequest) val factory = mock() - given(factory.createBinder(any(), any(), eq("param"))) + given(factory.createBinder(any(), any(), eq("param"), any())) .willAnswer { WebRequestDataBinder(it.getArgument(1)) } assertThatThrownBy { processor.resolveArgument(this.param, container, requestWithParam, factory) diff --git a/spring-webmvc/spring-webmvc.gradle b/spring-webmvc/spring-webmvc.gradle index 96ea41bc76..1c48d53328 100644 --- a/spring-webmvc/spring-webmvc.gradle +++ b/spring-webmvc/spring-webmvc.gradle @@ -18,6 +18,7 @@ dependencies { optional("jakarta.servlet.jsp:jakarta.servlet.jsp-api") optional("jakarta.servlet.jsp.jstl:jakarta.servlet.jsp.jstl-api") optional("jakarta.el:jakarta.el-api") + optional("jakarta.validation:jakarta.validation-api") optional("jakarta.xml.bind:jakarta.xml.bind-api") optional('io.micrometer:context-propagation') optional("org.webjars:webjars-locator-core") diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerAdapter.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerAdapter.java index b9429cc841..3ce42df0c2 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerAdapter.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/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. @@ -48,8 +48,10 @@ import org.springframework.http.converter.StringHttpMessageConverter; import org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter; import org.springframework.lang.Nullable; import org.springframework.ui.ModelMap; +import org.springframework.util.ClassUtils; import org.springframework.util.CollectionUtils; import org.springframework.util.ReflectionUtils.MethodFilter; +import org.springframework.validation.beanvalidation.MethodValidator; import org.springframework.web.accept.ContentNegotiationManager; import org.springframework.web.bind.annotation.InitBinder; import org.springframework.web.bind.annotation.ModelAttribute; @@ -86,6 +88,7 @@ import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.method.support.HandlerMethodArgumentResolverComposite; import org.springframework.web.method.support.HandlerMethodReturnValueHandler; import org.springframework.web.method.support.HandlerMethodReturnValueHandlerComposite; +import org.springframework.web.method.support.HandlerMethodValidator; import org.springframework.web.method.support.InvocableHandlerMethod; import org.springframework.web.method.support.ModelAndViewContainer; import org.springframework.web.servlet.ModelAndView; @@ -128,6 +131,9 @@ public class RequestMappingHandlerAdapter extends AbstractHandlerMethodAdapter (!AnnotatedElementUtils.hasAnnotation(method, RequestMapping.class) && AnnotatedElementUtils.hasAnnotation(method, ModelAttribute.class)); + private final static boolean BEAN_VALIDATION_PRESENT = + ClassUtils.isPresent("jakarta.validation.Validator", HandlerMethod.class.getClassLoader()); + @Nullable private List customArgumentResolvers; @@ -156,6 +162,9 @@ public class RequestMappingHandlerAdapter extends AbstractHandlerMethodAdapter @Nullable private WebBindingInitializer webBindingInitializer; + @Nullable + private MethodValidator methodValidator; + private AsyncTaskExecutor taskExecutor = new SimpleAsyncTaskExecutor("MvcAsync"); @Nullable @@ -559,6 +568,9 @@ public class RequestMappingHandlerAdapter extends AbstractHandlerMethodAdapter List handlers = getDefaultReturnValueHandlers(); this.returnValueHandlers = new HandlerMethodReturnValueHandlerComposite().addHandlers(handlers); } + if (BEAN_VALIDATION_PRESENT) { + this.methodValidator = HandlerMethodValidator.from(this.webBindingInitializer, this.parameterNameDiscoverer); + } } private void initMessageConverters() { @@ -855,6 +867,7 @@ public class RequestMappingHandlerAdapter extends AbstractHandlerMethodAdapter } invocableMethod.setDataBinderFactory(binderFactory); invocableMethod.setParameterNameDiscoverer(this.parameterNameDiscoverer); + invocableMethod.setMethodValidator(this.methodValidator); ModelAndViewContainer mavContainer = new ModelAndViewContainer(); mavContainer.addAllAttributes(RequestContextUtils.getInputFlashMap(request)); @@ -955,7 +968,9 @@ public class RequestMappingHandlerAdapter extends AbstractHandlerMethodAdapter Object bean = handlerMethod.getBean(); initBinderMethods.add(createInitBinderMethod(bean, method)); } - return createDataBinderFactory(initBinderMethods); + DefaultDataBinderFactory factory = createDataBinderFactory(initBinderMethods); + factory.setMethodValidationApplicable(this.methodValidator != null && handlerMethod.shouldValidateArguments()); + return factory; } private InvocableHandlerMethod createInitBinderMethod(Object bean, Method method) { diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestPartMethodArgumentResolver.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestPartMethodArgumentResolver.java index b23ae8912c..0059343764 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestPartMethodArgumentResolver.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestPartMethodArgumentResolver.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. @@ -139,7 +139,7 @@ public class RequestPartMethodArgumentResolver extends AbstractMessageConverterM HttpInputMessage inputMessage = new RequestPartServletServerHttpRequest(servletRequest, name); arg = readWithMessageConverters(inputMessage, parameter, parameter.getNestedGenericParameterType()); if (binderFactory != null) { - WebDataBinder binder = binderFactory.createBinder(request, arg, name); + WebDataBinder binder = binderFactory.createBinder(request, arg, name, parameter); if (arg != null) { validateIfApplicable(binder, parameter); if (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter)) { diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessor.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessor.java index c32319503c..503d667f51 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessor.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessor.java @@ -134,7 +134,7 @@ public class RequestResponseBodyMethodProcessor extends AbstractMessageConverter String name = Conventions.getVariableNameForParameter(parameter); if (binderFactory != null) { - WebDataBinder binder = binderFactory.createBinder(webRequest, arg, name); + WebDataBinder binder = binderFactory.createBinder(webRequest, arg, name, parameter); if (arg != null) { validateIfApplicable(binder, parameter); if (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter)) { diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/MethodValidationTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/MethodValidationTests.java new file mode 100644 index 0000000000..719ea21ae4 --- /dev/null +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/MethodValidationTests.java @@ -0,0 +1,439 @@ +/* + * 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.servlet.mvc.method.annotation; + +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.HashMap; +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 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.MethodArgumentNotValidException; +import org.springframework.web.bind.WebDataBinder; +import org.springframework.web.bind.annotation.InitBinder; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.support.ConfigurableWebBindingInitializer; +import org.springframework.web.context.support.GenericWebApplicationContext; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerMapping; +import org.springframework.web.testfixture.method.ResolvableMethod; +import org.springframework.web.testfixture.servlet.MockHttpServletRequest; +import org.springframework.web.testfixture.servlet.MockHttpServletResponse; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; +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 final MockHttpServletRequest request = new MockHttpServletRequest(); + + private final MockHttpServletResponse response = new MockHttpServletResponse(); + + 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); + + this.request.setMethod("POST"); + this.request.setContentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE); + this.request.setAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, new HashMap(0)); + } + + private static RequestMappingHandlerAdapter initHandlerAdapter(Validator validator) { + ConfigurableWebBindingInitializer bindingInitializer = new ConfigurableWebBindingInitializer(); + bindingInitializer.setValidator(validator); + + GenericWebApplicationContext context = new GenericWebApplicationContext(); + context.refresh(); + + RequestMappingHandlerAdapter handlerAdapter = new RequestMappingHandlerAdapter(); + handlerAdapter.setWebBindingInitializer(bindingInitializer); + handlerAdapter.setApplicationContext(context); + handlerAdapter.setBeanFactory(context.getBeanFactory()); + handlerAdapter.afterPropertiesSet(); + return handlerAdapter; + } + + + @Test + void modelAttribute() { + HandlerMethod hm = handlerMethod(new ValidController(), c -> c.handle(mockPerson)); + this.request.addParameter("name", "name=Faustino1234"); + + MethodArgumentNotValidException ex = catchThrowableOfType( + () -> this.handlerAdapter.handle(this.request, this.response, hm), + MethodArgumentNotValidException.class); + + 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]""")); + + } + + @Test + void modelAttributeWithBindingResult() throws Exception { + HandlerMethod hm = handlerMethod(new ValidController(), c -> c.handle(mockPerson, mockErrors)); + this.request.addParameter("name", "name=Faustino1234"); + + this.handlerAdapter.handle(this.request, this.response, hm); + + assertThat(this.jakartaValidator.getValidationCount()).isEqualTo(1); + assertThat(this.jakartaValidator.getMethodValidationCount()).as("Method validation unexpected").isEqualTo(0); + + assertThat(response.getContentAsString()).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, "")); + this.request.addParameter("name", "name=Faustino1234"); + this.request.addHeader("myHeader", "123"); + + MethodValidationException ex = catchThrowableOfType( + () -> this.handlerAdapter.handle(this.request, this.response, hm), + MethodValidationException.class); + + 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]""" + )); + } + + @Test + void validatedWithMethodValidation() throws Exception { + + // 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, "")); + this.request.addParameter("name", "name=Faustino1234"); + this.request.addHeader("myHeader", "12345"); + + this.handlerAdapter.handle(this.request, this.response, hm); + + assertThat(jakartaValidator.getValidationCount()).isEqualTo(2); + assertThat(jakartaValidator.getMethodValidationCount()).isEqualTo(1); + + assertThat(response.getContentAsString()).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() throws Exception { + HandlerMethod hm = handlerMethod(new InitBinderController(), ibc -> ibc.handle(mockPerson, mockErrors, "")); + this.request.addParameter("name", "name=Faustino1234"); + this.request.addHeader("myHeader", "12345"); + + this.handlerAdapter.handle(this.request, this.response, hm); + + assertThat(jakartaValidator.getValidationCount()).isEqualTo(1); + assertThat(jakartaValidator.getMethodValidationCount()).isEqualTo(1); + + assertThat(response.getContentAsString()).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)); + this.request.addParameter("name", "name=Faustino1234"); + + RequestMappingHandlerAdapter springValidatorHandlerAdapter = initHandlerAdapter(new PersonValidator()); + springValidatorHandlerAdapter.handle(this.request, this.response, hm); + + assertThat(response.getContentAsString()).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); + } + + @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(); + } + } + + + @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); + } + } + +}