diff --git a/spring-context/src/main/java/org/springframework/validation/beanvalidation/MethodValidationAdapter.java b/spring-context/src/main/java/org/springframework/validation/beanvalidation/MethodValidationAdapter.java new file mode 100644 index 0000000000..022c2015c1 --- /dev/null +++ b/spring-context/src/main/java/org/springframework/validation/beanvalidation/MethodValidationAdapter.java @@ -0,0 +1,459 @@ +/* + * 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.validation.beanvalidation; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Supplier; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ElementKind; +import jakarta.validation.Path; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import jakarta.validation.executable.ExecutableValidator; +import jakarta.validation.metadata.ConstraintDescriptor; + +import org.springframework.aop.framework.AopProxyUtils; +import org.springframework.aop.support.AopUtils; +import org.springframework.context.MessageSourceResolvable; +import org.springframework.context.support.DefaultMessageSourceResolvable; +import org.springframework.core.BridgeMethodResolver; +import org.springframework.core.Conventions; +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.core.GenericTypeResolver; +import org.springframework.core.MethodParameter; +import org.springframework.core.ParameterNameDiscoverer; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.lang.Nullable; +import org.springframework.util.ClassUtils; +import org.springframework.util.function.SingletonSupplier; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.BindingResult; +import org.springframework.validation.DefaultMessageCodesResolver; +import org.springframework.validation.Errors; +import org.springframework.validation.MessageCodesResolver; +import org.springframework.validation.annotation.Validated; + +/** + * Assist with applying method-level validation via + * {@link jakarta.validation.Validator}, adapt each resulting + * {@link ConstraintViolation} to {@link ParameterValidationResult}, and + * raise {@link MethodValidationException}. + * + *

Used by {@link MethodValidationInterceptor}. + * + * @author Rossen Stoyanchev + * @since 6.1 + */ +public class MethodValidationAdapter { + + private static final Comparator RESULT_COMPARATOR = new ResultComparator(); + + + private final Supplier validator; + + private final Supplier validatorAdapter; + + private MessageCodesResolver messageCodesResolver = new DefaultMessageCodesResolver(); + + private ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer(); + + + /** + * Create an instance using a default JSR-303 validator underneath. + */ + @SuppressWarnings("DataFlowIssue") + public MethodValidationAdapter() { + this.validator = SingletonSupplier.of(() -> Validation.buildDefaultValidatorFactory().getValidator()); + this.validatorAdapter = SingletonSupplier.of(() -> new SpringValidatorAdapter(this.validator.get())); + } + + /** + * Create an instance using the given JSR-303 ValidatorFactory. + * @param validatorFactory the JSR-303 ValidatorFactory to use + */ + @SuppressWarnings("DataFlowIssue") + public MethodValidationAdapter(ValidatorFactory validatorFactory) { + this.validator = SingletonSupplier.of(validatorFactory::getValidator); + this.validatorAdapter = SingletonSupplier.of(() -> new SpringValidatorAdapter(this.validator.get())); + } + + /** + * Create an instance using the given JSR-303 Validator. + * @param validator the JSR-303 Validator to use + */ + public MethodValidationAdapter(Validator validator) { + this.validator = () -> validator; + this.validatorAdapter = () -> new SpringValidatorAdapter(validator); + } + + /** + * Create an instance for the supplied (potentially lazily initialized) Validator. + * @param validator a Supplier for the Validator to use + */ + public MethodValidationAdapter(Supplier validator) { + this.validator = validator; + this.validatorAdapter = () -> new SpringValidatorAdapter(this.validator.get()); + } + + + /** + * Set the strategy to use to determine message codes for violations. + *

Default is a DefaultMessageCodesResolver. + */ + public void setMessageCodesResolver(MessageCodesResolver messageCodesResolver) { + this.messageCodesResolver = messageCodesResolver; + } + + /** + * Return the {@link #setMessageCodesResolver(MessageCodesResolver) configured} + * {@code MessageCodesResolver}. + */ + public MessageCodesResolver getMessageCodesResolver() { + return this.messageCodesResolver; + } + + /** + * Set the ParameterNameDiscoverer to use to resolve method parameter names + * that is in turn used to create error codes for {@link MessageSourceResolvable}. + *

Default is {@link org.springframework.core.DefaultParameterNameDiscoverer}. + */ + public void setParameterNameDiscoverer(ParameterNameDiscoverer parameterNameDiscoverer) { + this.parameterNameDiscoverer = parameterNameDiscoverer; + } + + /** + * Return the {@link #setParameterNameDiscoverer(ParameterNameDiscoverer) configured} + * {@code ParameterNameDiscoverer}. + */ + public ParameterNameDiscoverer getParameterNameDiscoverer() { + return this.parameterNameDiscoverer; + } + + + /** + * Use this method determine the validation groups to pass into + * {@link #validateMethodArguments(Object, Method, Object[], Class[])} and + * {@link #validateMethodReturnValue(Object, Method, Object, Class[])}. + *

Default are the validation groups as specified in the {@link Validated} + * annotation on the method, or on the containing target class of the method, + * or for an AOP proxy without a target (with all behavior in advisors), also + * check on proxied interfaces. + * @param target the target Object + * @param method the target method + * @return the applicable validation groups as a {@code Class} array + */ + public static Class[] determineValidationGroups(Object target, Method method) { + Validated validatedAnn = AnnotationUtils.findAnnotation(method, Validated.class); + if (validatedAnn == null) { + if (AopUtils.isAopProxy(target)) { + for (Class type : AopProxyUtils.proxiedUserInterfaces(target)) { + validatedAnn = AnnotationUtils.findAnnotation(type, Validated.class); + if (validatedAnn != null) { + break; + } + } + } + else { + validatedAnn = AnnotationUtils.findAnnotation(target.getClass(), Validated.class); + } + } + return (validatedAnn != null ? validatedAnn.value() : new Class[0]); + } + + /** + * Validate the given method arguments and raise {@link ConstraintViolation} + * in case of any errors. + * @param target the target Object + * @param method the target method + * @param arguments candidate arguments for a method invocation + * @param groups groups for validation determined via + * {@link #determineValidationGroups(Object, Method)} + */ + public void validateMethodArguments(Object target, Method method, Object[] arguments, Class[] groups) { + ExecutableValidator execVal = this.validator.get().forExecutables(); + Set> result; + try { + result = execVal.validateParameters(target, method, arguments, groups); + } + catch (IllegalArgumentException ex) { + // Probably a generic type mismatch between interface and impl as reported in SPR-12237 / HV-1011 + // Let's try to find the bridged method on the implementation class... + Method mostSpecificMethod = ClassUtils.getMostSpecificMethod(method, target.getClass()); + Method bridgedMethod = BridgeMethodResolver.findBridgedMethod(mostSpecificMethod); + result = execVal.validateParameters(target, bridgedMethod, arguments, groups); + } + if (!result.isEmpty()) { + throw createException(target, method, result, i -> arguments[i]); + } + } + + /** + * Validate the given return value and raise {@link ConstraintViolation} + * in case of any errors. + * @param target the target Object + * @param method the target method + * @param returnValue value returned from invoking the target method + * @param groups groups for validation determined via + * {@link #determineValidationGroups(Object, Method)} + */ + public void validateMethodReturnValue( + Object target, Method method, @Nullable Object returnValue, Class[] groups) { + + ExecutableValidator execVal = this.validator.get().forExecutables(); + Set> result = execVal.validateReturnValue(target, method, returnValue, groups); + if (!result.isEmpty()) { + throw createException(target, method, result, i -> returnValue); + } + } + + private MethodValidationException createException( + Object target, Method method, Set> violations, + Function argumentFunction) { + + Map parameterViolations = new LinkedHashMap<>(); + Map cascadedViolations = new LinkedHashMap<>(); + + for (ConstraintViolation violation : violations) { + Iterator itr = violation.getPropertyPath().iterator(); + while (itr.hasNext()) { + Path.Node node = itr.next(); + + MethodParameter parameter; + if (node.getKind().equals(ElementKind.PARAMETER)) { + parameter = new MethodParameter(method, node.as(Path.ParameterNode.class).getParameterIndex()); + } + else if (node.getKind().equals(ElementKind.RETURN_VALUE)) { + parameter = new MethodParameter(method, -1); + } + else { + continue; + } + parameter.initParameterNameDiscovery(this.parameterNameDiscoverer); + + Object argument = argumentFunction.apply(parameter.getParameterIndex()); + if (!itr.hasNext()) { + parameterViolations + .computeIfAbsent(parameter, p -> new ValueResultBuilder(target, parameter, argument)) + .addViolation(violation); + } + else { + cascadedViolations + .computeIfAbsent(node, n -> new BeanResultBuilder(parameter, argument, itr.next())) + .addViolation(violation); + } + break; + } + } + + List validatonResultList = new ArrayList<>(); + parameterViolations.forEach((parameter, builder) -> validatonResultList.add(builder.build())); + cascadedViolations.forEach((node, builder) -> validatonResultList.add(builder.build())); + validatonResultList.sort(RESULT_COMPARATOR); + + return new MethodValidationException(target, method, validatonResultList, violations); + } + + /** + * Create a {@link MessageSourceResolvable} for the given violation. + * @param target target of the method invocation to which validation was applied + * @param parameter the method parameter associated with the violation + * @param violation the violation + * @return the created {@code MessageSourceResolvable} + */ + private MessageSourceResolvable createMessageSourceResolvable( + Object target, MethodParameter parameter, ConstraintViolation violation) { + + String objectName = Conventions.getVariableName(target) + "#" + parameter.getExecutable().getName(); + String paramName = (parameter.getParameterName() != null ? parameter.getParameterName() : ""); + Class parameterType = parameter.getParameterType(); + + ConstraintDescriptor descriptor = violation.getConstraintDescriptor(); + String code = descriptor.getAnnotation().annotationType().getSimpleName(); + String[] codes = this.messageCodesResolver.resolveMessageCodes(code, objectName, paramName, parameterType); + Object[] arguments = this.validatorAdapter.get().getArgumentsForConstraint(objectName, paramName, descriptor); + + return new DefaultMessageSourceResolvable(codes, arguments, violation.getMessage()); + } + + /** + * Select an object name and create a {@link BindingResult} for the argument. + *

By default, the name is based on the parameter name, or for a return type on + * {@link Conventions#getVariableNameForReturnType(Method, Class, Object)}. + *

If a name cannot be determined for any reason, e.g. a return value with + * insufficient type information, then {@code "{methodName}.arg{index}"} is used. + * @param parameter the method parameter + * @param argument the argument value + * @return the determined name + */ + private BindingResult createBindingResult(MethodParameter parameter, @Nullable Object argument) { + // TODO: allow external customization via Function (e.g. from @ModelAttribute + Conventions based on type) + String objectName = parameter.getParameterName(); + int index = parameter.getParameterIndex(); + if (index == -1) { + try { + Method method = parameter.getMethod(); + if (method != null) { + Class resolvedType = GenericTypeResolver.resolveReturnType(method, parameter.getContainingClass()); + objectName = Conventions.getVariableNameForReturnType(method, resolvedType, argument); + } + } + catch (IllegalArgumentException ex) { + // insufficient type information + } + } + if (objectName == null) { + objectName = (parameter.getExecutable().getName() + (index != -1 ? ".arg" + index : "")); + } + BeanPropertyBindingResult result = new BeanPropertyBindingResult(argument, objectName); + result.setMessageCodesResolver(this.messageCodesResolver); + return result; + } + + + /** + * Builds a validation result for a value method parameter with constraints + * declared directly on it. + */ + private final class ValueResultBuilder { + + private final Object target; + + private final MethodParameter parameter; + + @Nullable + private final Object argument; + + private final List resolvableErrors = new ArrayList<>(); + + private final Set> violations = new LinkedHashSet<>(); + + public ValueResultBuilder(Object target, MethodParameter parameter, @Nullable Object argument) { + this.target = target; + this.parameter = parameter; + this.argument = argument; + } + + public void addViolation(ConstraintViolation violation) { + this.violations.add(violation); + this.resolvableErrors.add(createMessageSourceResolvable(this.target, this.parameter, violation)); + } + + public ParameterValidationResult build() { + return new ParameterValidationResult( + this.parameter, this.argument, this.resolvableErrors, this.violations); + } + + } + + + /** + * Builds a validation result for an {@link jakarta.validation.Valid @Valid} + * annotated bean method parameter with cascaded constraints. + */ + private final class BeanResultBuilder { + + private final MethodParameter parameter; + + @Nullable + private final Object argument; + + @Nullable + private final Object container; + + @Nullable + private final Integer containerIndex; + + @Nullable + private final Object containerKey; + + private final Errors errors; + + private final Set> violations = new LinkedHashSet<>(); + + public BeanResultBuilder(MethodParameter parameter, @Nullable Object argument, Path.Node node) { + this.parameter = parameter; + + this.containerIndex = node.getIndex(); + this.containerKey = node.getKey(); + if (argument instanceof List list && this.containerIndex != null) { + this.container = list; + argument = list.get(this.containerIndex); + } + else if (argument instanceof Map map && this.containerKey != null) { + this.container = map; + argument = map.get(this.containerKey); + } + else { + this.container = null; + } + + this.argument = argument; + this.errors = createBindingResult(parameter, argument); + } + + public void addViolation(ConstraintViolation violation) { + this.violations.add(violation); + } + + public ParameterErrors build() { + validatorAdapter.get().processConstraintViolations(this.violations, this.errors); + return new ParameterErrors( + this.parameter, this.argument, this.errors, this.violations, + this.container, this.containerIndex, this.containerKey); + } + } + + + /** + * Comparator for validation results, sorted by method parameter index first, + * also falling back on container indexes if necessary for cascaded + * constraints on a List container. + */ + private final static class ResultComparator implements Comparator { + + @Override + public int compare(ParameterValidationResult result1, ParameterValidationResult result2) { + int index1 = result1.getMethodParameter().getParameterIndex(); + int index2 = result2.getMethodParameter().getParameterIndex(); + int i = Integer.compare(index1, index2); + if (i != 0) { + return i; + } + if (result1 instanceof ParameterErrors errors1 && result2 instanceof ParameterErrors errors2) { + Integer containerIndex1 = errors1.getContainerIndex(); + Integer containerIndex2 = errors2.getContainerIndex(); + if (containerIndex1 != null && containerIndex2 != null) { + i = Integer.compare(containerIndex1, containerIndex2); + return i; + } + } + return 0; + } + } + +} diff --git a/spring-context/src/main/java/org/springframework/validation/beanvalidation/MethodValidationDelegate.java b/spring-context/src/main/java/org/springframework/validation/beanvalidation/MethodValidationDelegate.java deleted file mode 100644 index 5509d23eab..0000000000 --- a/spring-context/src/main/java/org/springframework/validation/beanvalidation/MethodValidationDelegate.java +++ /dev/null @@ -1,161 +0,0 @@ -/* - * 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.validation.beanvalidation; - -import java.lang.reflect.Method; -import java.util.Set; -import java.util.function.Supplier; - -import jakarta.validation.ConstraintViolation; -import jakarta.validation.ConstraintViolationException; -import jakarta.validation.Validation; -import jakarta.validation.Validator; -import jakarta.validation.ValidatorFactory; -import jakarta.validation.executable.ExecutableValidator; - -import org.springframework.aop.framework.AopProxyUtils; -import org.springframework.aop.support.AopUtils; -import org.springframework.core.BridgeMethodResolver; -import org.springframework.core.annotation.AnnotationUtils; -import org.springframework.lang.Nullable; -import org.springframework.util.ClassUtils; -import org.springframework.util.function.SingletonSupplier; -import org.springframework.validation.annotation.Validated; - -/** - * Helper class to apply method-level validation on annotated methods via - * {@link jakarta.validation.Valid}. - * - *

Used by {@link MethodValidationInterceptor}. - * - * @author Rossen Stoyanchev - * @since 6.1.0 - */ -public class MethodValidationDelegate { - - private final Supplier validator; - - - /** - * Create an instance using a default JSR-303 validator underneath. - */ - public MethodValidationDelegate() { - this.validator = SingletonSupplier.of(() -> Validation.buildDefaultValidatorFactory().getValidator()); - } - - /** - * Create an instance using the given JSR-303 ValidatorFactory. - * @param validatorFactory the JSR-303 ValidatorFactory to use - */ - public MethodValidationDelegate(ValidatorFactory validatorFactory) { - this.validator = SingletonSupplier.of(validatorFactory::getValidator); - } - - /** - * Create an instance using the given JSR-303 Validator. - * @param validator the JSR-303 Validator to use - */ - public MethodValidationDelegate(Validator validator) { - this.validator = () -> validator; - } - - /** - * Create an instance for the supplied (potentially lazily initialized) Validator. - * @param validator a Supplier for the Validator to use - */ - public MethodValidationDelegate(Supplier validator) { - this.validator = validator; - } - - - /** - * Use this method determine the validation groups to pass into - * {@link #validateMethodArguments(Object, Method, Object[], Class[])} and - * {@link #validateMethodReturnValue(Object, Method, Object, Class[])}. - *

Default are the validation groups as specified in the {@link Validated} - * annotation on the method, or on the containing target class of the method, - * or for an AOP proxy without a target (with all behavior in advisors), also - * check on proxied interfaces. - * @param target the target Object - * @param method the target method - * @return the applicable validation groups as a {@code Class} array - */ - public Class[] determineValidationGroups(Object target, Method method) { - Validated validatedAnn = AnnotationUtils.findAnnotation(method, Validated.class); - if (validatedAnn == null) { - if (AopUtils.isAopProxy(target)) { - for (Class type : AopProxyUtils.proxiedUserInterfaces(target)) { - validatedAnn = AnnotationUtils.findAnnotation(type, Validated.class); - if (validatedAnn != null) { - break; - } - } - } - else { - validatedAnn = AnnotationUtils.findAnnotation(target.getClass(), Validated.class); - } - } - return (validatedAnn != null ? validatedAnn.value() : new Class[0]); - } - - /** - * Validate the given method arguments and raise {@link ConstraintViolation} - * in case of any errors. - * @param target the target Object - * @param method the target method - * @param arguments candidate arguments for a method invocation - * @param groups groups for validation determined via - * {@link #determineValidationGroups(Object, Method)} - */ - public void validateMethodArguments(Object target, Method method, Object[] arguments, Class[] groups) { - ExecutableValidator execVal = this.validator.get().forExecutables(); - Set> result; - try { - result = execVal.validateParameters(target, method, arguments, groups); - } - catch (IllegalArgumentException ex) { - // Probably a generic type mismatch between interface and impl as reported in SPR-12237 / HV-1011 - // Let's try to find the bridged method on the implementation class... - Method mostSpecificMethod = ClassUtils.getMostSpecificMethod(method, target.getClass()); - Method bridgedMethod = BridgeMethodResolver.findBridgedMethod(mostSpecificMethod); - result = execVal.validateParameters(target, bridgedMethod, arguments, groups); - } - if (!result.isEmpty()) { - throw new ConstraintViolationException(result); - } - } - - /** - * Validate the given return value and raise {@link ConstraintViolation} - * in case of any errors. - * @param target the target Object - * @param method the target method - * @param returnValue value returned from invoking the target method - * @param groups groups for validation determined via - * {@link #determineValidationGroups(Object, Method)} - */ - public void validateMethodReturnValue( - Object target, Method method, @Nullable Object returnValue, Class[] groups) { - - ExecutableValidator execVal = this.validator.get().forExecutables(); - Set> result = execVal.validateReturnValue(target, method, returnValue, groups); - if (!result.isEmpty()) { - throw new ConstraintViolationException(result); - } - } - -} diff --git a/spring-context/src/main/java/org/springframework/validation/beanvalidation/MethodValidationException.java b/spring-context/src/main/java/org/springframework/validation/beanvalidation/MethodValidationException.java new file mode 100644 index 0000000000..89b4460429 --- /dev/null +++ b/spring-context/src/main/java/org/springframework/validation/beanvalidation/MethodValidationException.java @@ -0,0 +1,118 @@ +/* + * 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.validation.beanvalidation; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Set; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; + +/** + * Extension of {@link ConstraintViolationException} that exposes an additional + * list of {@link ParameterValidationResult} with violations adapted to + * {@link org.springframework.context.MessageSourceResolvable} and grouped by + * method parameter. + * + *

For {@link jakarta.validation.Valid @Valid}-annotated, Object method + * parameters or return types with cascaded violations, the {@link ParameterErrors} + * subclass of {@link ParameterValidationResult} implements + * {@link org.springframework.validation.Errors} and exposes + * {@link org.springframework.validation.FieldError field errors}. + * + * @author Rossen Stoyanchev + * @since 6.1 + * @see ParameterValidationResult + * @see ParameterErrors + * @see MethodValidationAdapter + */ +@SuppressWarnings("serial") +public class MethodValidationException extends ConstraintViolationException { + + private final Object target; + + private final Method method; + + private final List allValidationResults; + + + public MethodValidationException( + Object target, Method method, + List validationResults, + Set> violations) { + + super(violations); + this.target = target; + this.method = method; + this.allValidationResults = validationResults; + } + + + /** + * Return the target of the method invocation to which validation was applied. + */ + public Object getTarget() { + return this.target; + } + + /** + * Return the method to which validation was applied. + */ + public Method getMethod() { + return this.method; + } + + /** + * Return all validation results. This includes method parameters with + * constraints declared on them, as well as + * {@link jakarta.validation.Valid @Valid} method parameters with + * cascaded constraints. + * @see #getValueResults() + * @see #getBeanResults() + */ + public List getAllValidationResults() { + return this.allValidationResults; + } + + /** + * Return only validation results for method parameters with constraints + * declared directly on them. This excludes + * {@link jakarta.validation.Valid @Valid} method parameters with cascaded + * constraints. + * @see #getAllValidationResults() + */ + public List getValueResults() { + return this.allValidationResults.stream() + .filter(result -> !(result instanceof ParameterErrors)) + .toList(); + } + + /** + * Return only validation results for {@link jakarta.validation.Valid @Valid} + * method parameters with cascaded constraints. This excludes method + * parameters with constraints declared directly on them. + * @see #getAllValidationResults() + */ + public List getBeanResults() { + return this.allValidationResults.stream() + .filter(result -> result instanceof ParameterErrors) + .map(result -> (ParameterErrors) result) + .toList(); + } + +} diff --git a/spring-context/src/main/java/org/springframework/validation/beanvalidation/MethodValidationInterceptor.java b/spring-context/src/main/java/org/springframework/validation/beanvalidation/MethodValidationInterceptor.java index b6dc9fbbe8..3a07fed041 100644 --- a/spring-context/src/main/java/org/springframework/validation/beanvalidation/MethodValidationInterceptor.java +++ b/spring-context/src/main/java/org/springframework/validation/beanvalidation/MethodValidationInterceptor.java @@ -55,14 +55,14 @@ import org.springframework.validation.annotation.Validated; */ public class MethodValidationInterceptor implements MethodInterceptor { - private final MethodValidationDelegate delegate; + private final MethodValidationAdapter delegate; /** * Create a new MethodValidationInterceptor using a default JSR-303 validator underneath. */ public MethodValidationInterceptor() { - this.delegate = new MethodValidationDelegate(); + this.delegate = new MethodValidationAdapter(); } /** @@ -70,7 +70,7 @@ public class MethodValidationInterceptor implements MethodInterceptor { * @param validatorFactory the JSR-303 ValidatorFactory to use */ public MethodValidationInterceptor(ValidatorFactory validatorFactory) { - this.delegate = new MethodValidationDelegate(validatorFactory); + this.delegate = new MethodValidationAdapter(validatorFactory); } /** @@ -78,7 +78,7 @@ public class MethodValidationInterceptor implements MethodInterceptor { * @param validator the JSR-303 Validator to use */ public MethodValidationInterceptor(Validator validator) { - this.delegate = new MethodValidationDelegate(validator); + this.delegate = new MethodValidationAdapter(validator); } /** @@ -88,7 +88,7 @@ public class MethodValidationInterceptor implements MethodInterceptor { * @since 6.0 */ public MethodValidationInterceptor(Supplier validator) { - this.delegate = new MethodValidationDelegate(validator); + this.delegate = new MethodValidationAdapter(validator); } @@ -153,7 +153,7 @@ public class MethodValidationInterceptor implements MethodInterceptor { protected Class[] determineValidationGroups(MethodInvocation invocation) { Object target = getTarget(invocation); Method method = invocation.getMethod(); - return this.delegate.determineValidationGroups(target, method); + return MethodValidationAdapter.determineValidationGroups(target, method); } } diff --git a/spring-context/src/main/java/org/springframework/validation/beanvalidation/ParameterErrors.java b/spring-context/src/main/java/org/springframework/validation/beanvalidation/ParameterErrors.java new file mode 100644 index 0000000000..3c7d129a95 --- /dev/null +++ b/spring-context/src/main/java/org/springframework/validation/beanvalidation/ParameterErrors.java @@ -0,0 +1,263 @@ +/* + * 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.validation.beanvalidation; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import jakarta.validation.ConstraintViolation; + +import org.springframework.core.MethodParameter; +import org.springframework.lang.Nullable; +import org.springframework.validation.Errors; +import org.springframework.validation.FieldError; +import org.springframework.validation.ObjectError; + +/** + * Extension of {@link ParameterValidationResult} that's created for Object + * method arguments or return values with cascaded violations on their properties. + * Such method parameters are annotated with {@link jakarta.validation.Valid @Valid}, + * or in the case of return values, the annotation is on the method. + * + *

In addition to the (generic) {@link #getResolvableErrors() + * MessageSourceResolvable errors} from the base class, this subclass implements + * {@link Errors} to expose convenient access to the same as {@link FieldError}s. + * + *

When {@code @Valid} is declared on a {@link List} or {@link java.util.Map} + * parameter, a separate {@link ParameterErrors} is created for each list or map + * value for which there are constraint violations. In such cases, the + * {@link #getContainer()} is the list or map, while {@link #getContainerIndex()} + * and {@link #getContainerKey()} reflect the index or key of the value. + * + * @author Rossen Stoyanchev + * @since 6.1 + */ +public class ParameterErrors extends ParameterValidationResult implements Errors { + + private final Errors errors; + + @Nullable + private final Object container; + + @Nullable + private final Integer containerIndex; + + @Nullable + private final Object containerKey; + + + /** + * Create a {@code ParameterErrors}. + */ + public ParameterErrors( + MethodParameter parameter, @Nullable Object argument, Errors errors, + Set> violations, + @Nullable Object container, @Nullable Integer index, @Nullable Object key) { + + super(parameter, argument, new ArrayList<>(errors.getAllErrors()), violations); + + this.errors = errors; + this.container = container; + this.containerIndex = index; + this.containerKey = key; + } + + + /** + * When {@code @Valid} is declared on a {@link List} or {@link java.util.Map} + * method parameter, this method returns the list or map that contained the + * validated object {@link #getArgument() argument}, while + * {@link #getContainerIndex()} and {@link #getContainerKey()} returns the + * respective index or key. + */ + @Nullable + public Object getContainer() { + return this.container; + } + + /** + * When {@code @Valid} is declared on a {@link List}, this method returns + * the index under which the validated object {@link #getArgument() argument} + * is stored in the list {@link #getContainer() container}. + */ + @Nullable + public Integer getContainerIndex() { + return this.containerIndex; + } + + /** + * When {@code @Valid} is declared on a {@link java.util.Map}, this method + * returns the key under which the validated object {@link #getArgument() + * argument} is stored in the map {@link #getContainer()}. + */ + @Nullable + public Object getContainerKey() { + return this.containerKey; + } + + + // Errors implementation + + @Override + public String getObjectName() { + return this.errors.getObjectName(); + } + + @Override + public void setNestedPath(String nestedPath) { + this.errors.setNestedPath(nestedPath); + } + + @Override + public String getNestedPath() { + return this.errors.getNestedPath(); + } + + @Override + public void pushNestedPath(String subPath) { + this.errors.pushNestedPath(subPath); + } + + @Override + public void popNestedPath() throws IllegalStateException { + this.errors.popNestedPath(); + } + + @Override + public void reject(String errorCode) { + this.errors.reject(errorCode); + } + + @Override + public void reject(String errorCode, String defaultMessage) { + this.errors.reject(errorCode, defaultMessage); + } + + @Override + public void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage) { + this.errors.reject(errorCode, errorArgs, defaultMessage); + } + + @Override + public void rejectValue(@Nullable String field, String errorCode) { + this.errors.rejectValue(field, errorCode); + } + + @Override + public void rejectValue(@Nullable String field, String errorCode, String defaultMessage) { + this.errors.rejectValue(field, errorCode, defaultMessage); + } + + @Override + public void rejectValue(@Nullable String field, String errorCode, + @Nullable Object[] errorArgs, @Nullable String defaultMessage) { + + this.errors.rejectValue(field, errorCode, errorArgs, defaultMessage); + } + + @Override + public void addAllErrors(Errors errors) { + this.errors.addAllErrors(errors); + } + + @Override + public boolean hasErrors() { + return this.errors.hasErrors(); + } + + @Override + public int getErrorCount() { + return this.errors.getErrorCount(); + } + + @Override + public List getAllErrors() { + return this.errors.getAllErrors(); + } + + @Override + public boolean hasGlobalErrors() { + return this.errors.hasGlobalErrors(); + } + + @Override + public int getGlobalErrorCount() { + return this.errors.getGlobalErrorCount(); + } + + @Override + public List getGlobalErrors() { + return this.errors.getGlobalErrors(); + } + + @Override + public ObjectError getGlobalError() { + return this.errors.getGlobalError(); + } + + @Override + public boolean hasFieldErrors() { + return this.errors.hasFieldErrors(); + } + + @Override + public int getFieldErrorCount() { + return this.errors.getFieldErrorCount(); + } + + @Override + public List getFieldErrors() { + return this.errors.getFieldErrors(); + } + + @Override + public FieldError getFieldError() { + return this.errors.getFieldError(); + } + + @Override + public boolean hasFieldErrors(String field) { + return this.errors.hasFieldErrors(field); + } + + @Override + public int getFieldErrorCount(String field) { + return this.errors.getFieldErrorCount(field); + } + + @Override + public List getFieldErrors(String field) { + return this.errors.getFieldErrors(field); + } + + @Override + public FieldError getFieldError(String field) { + return this.errors.getFieldError(field); + } + + @Override + public Object getFieldValue(String field) { + return this.errors.getFieldError(field); + } + + @Override + public Class getFieldType(String field) { + return this.errors.getFieldType(field); + } + +} diff --git a/spring-context/src/main/java/org/springframework/validation/beanvalidation/ParameterValidationResult.java b/spring-context/src/main/java/org/springframework/validation/beanvalidation/ParameterValidationResult.java new file mode 100644 index 0000000000..d38dbd73e3 --- /dev/null +++ b/spring-context/src/main/java/org/springframework/validation/beanvalidation/ParameterValidationResult.java @@ -0,0 +1,150 @@ +/* + * 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.validation.beanvalidation; + +import java.util.List; +import java.util.Set; + +import jakarta.validation.ConstraintViolation; + +import org.springframework.context.MessageSourceResolvable; +import org.springframework.core.MethodParameter; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; + +/** + * Store and expose the results of method validation via + * {@link jakarta.validation.Validator} for a specific method parameter. + *

    + *
  • For a constraints directly on a method parameter, each + * {@link ConstraintViolation} is adapted to {@link MessageSourceResolvable}. + *
  • For cascaded constraints via {@link jakarta.validation.Validator @Valid} + * on a bean method parameter, {@link SpringValidatorAdapter} is used to initialize + * an {@link org.springframework.validation.Errors} with field errors, and create + * the {@link ParameterErrors} sub-class. + *
+ * + * @author Rossen Stoyanchev + * @since 6.1 + */ +public class ParameterValidationResult { + + private final MethodParameter methodParameter; + + @Nullable + private final Object argument; + + private final List resolvableErrors; + + private final Set> violations; + + + /** + * Create a {@code ParameterValidationResult}. + */ + public ParameterValidationResult( + MethodParameter methodParameter, @Nullable Object argument, List errors, + Set> violations) { + + Assert.notNull(methodParameter, "`MethodParameter` is required"); + Assert.notEmpty(errors, "`resolvableErrors` must not be empty"); + Assert.notEmpty(violations, "'violations' must not be empty"); + this.methodParameter = methodParameter; + this.argument = argument; + this.resolvableErrors = List.copyOf(errors); + this.violations = violations; + } + + + /** + * The method parameter the validation results are for. + */ + public MethodParameter getMethodParameter() { + return this.methodParameter; + } + + /** + * The method argument value that was validated. + */ + @Nullable + public Object getArgument() { + return this.argument; + } + + /** + * List of {@link MessageSourceResolvable} representations adapted from the + * underlying {@link #getViolations() violations}. + *
    + *
  • For a constraints directly on a method parameter, error codes are + * based on the names of the constraint annotation, the object, the method, + * the parameter, and parameter type, e.g. + * {@code ["Max.myObject#myMethod.myParameter", "Max.myParameter", "Max.int", "Max"]}. + * Arguments include the parameter itself as a {@link MessageSourceResolvable}, e.g. + * {@code ["myObject#myMethod.myParameter", "myParameter"]}, followed by actual + * constraint annotation attributes (i.e. excluding "message", "groups" and + * "payload") in alphabetical order of attribute names. + *
  • For cascaded constraints via {@link jakarta.validation.Validator @Valid} + * on a bean method parameter, this method returns + * {@link org.springframework.validation.FieldError field errors} that you + * can also access more conveniently through methods of the + * {@link ParameterErrors} sub-class. + *
+ */ + public List getResolvableErrors() { + return this.resolvableErrors; + } + + /** + * The violations associated with the method parameter. + */ + public Set> getViolations() { + return this.violations; + } + + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (!super.equals(other)) { + return false; + } + ParameterValidationResult otherResult = (ParameterValidationResult) other; + return (getMethodParameter().equals(otherResult.getMethodParameter()) && + ObjectUtils.nullSafeEquals(getArgument(), otherResult.getArgument()) && + getViolations().equals(otherResult.getViolations())); + } + + @Override + public int hashCode() { + int hashCode = super.hashCode(); + hashCode = 29 * hashCode + getMethodParameter().hashCode(); + hashCode = 29 * hashCode + ObjectUtils.nullSafeHashCode(getArgument()); + hashCode = 29 * hashCode + (getViolations().hashCode()); + return hashCode; + } + + @Override + public String toString() { + return "Validation results for method parameter '" + this.methodParameter + + "': argument [" + ObjectUtils.nullSafeConciseToString(this.argument) + "]; " + + getResolvableErrors(); + } + +} diff --git a/spring-context/src/main/java/org/springframework/validation/beanvalidation/SpringValidatorAdapter.java b/spring-context/src/main/java/org/springframework/validation/beanvalidation/SpringValidatorAdapter.java index 7fae422343..696f1c4e57 100644 --- a/spring-context/src/main/java/org/springframework/validation/beanvalidation/SpringValidatorAdapter.java +++ b/spring-context/src/main/java/org/springframework/validation/beanvalidation/SpringValidatorAdapter.java @@ -38,6 +38,7 @@ import org.springframework.context.support.DefaultMessageSourceResolvable; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; import org.springframework.validation.BindingResult; import org.springframework.validation.Errors; import org.springframework.validation.FieldError; @@ -201,7 +202,7 @@ public class SpringValidatorAdapter implements SmartValidator, jakarta.validatio StringBuilder sb = new StringBuilder(); boolean first = true; for (Path.Node node : path) { - if (node.isInIterable()) { + if (node.isInIterable() && !first) { sb.append('['); Object index = node.getIndex(); if (index == null) { @@ -286,7 +287,9 @@ public class SpringValidatorAdapter implements SmartValidator, jakarta.validatio * @see #getArgumentsForConstraint */ protected MessageSourceResolvable getResolvableField(String objectName, String field) { - String[] codes = new String[] {objectName + Errors.NESTED_PATH_SEPARATOR + field, field}; + String[] codes = (StringUtils.hasText(field) ? + new String[] {objectName + Errors.NESTED_PATH_SEPARATOR + field, field} : + new String[] {objectName}); return new DefaultMessageSourceResolvable(codes, field); } diff --git a/spring-context/src/test/java/org/springframework/validation/beanvalidation/MethodValidationAdapterTests.java b/spring-context/src/test/java/org/springframework/validation/beanvalidation/MethodValidationAdapterTests.java new file mode 100644 index 0000000000..cd0f3512bb --- /dev/null +++ b/spring-context/src/test/java/org/springframework/validation/beanvalidation/MethodValidationAdapterTests.java @@ -0,0 +1,236 @@ +/* + * 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.validation.beanvalidation; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.function.Consumer; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; +import org.junit.jupiter.api.Test; + +import org.springframework.context.MessageSourceResolvable; +import org.springframework.lang.Nullable; +import org.springframework.util.ClassUtils; +import org.springframework.validation.FieldError; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Unit tests for {@link MethodValidationAdapter}. + * @author Rossen Stoyanchev + */ +public class MethodValidationAdapterTests { + + private static final Person faustino1234 = new Person("Faustino1234"); + + private static final Person cayetana6789 = new Person("Cayetana6789"); + + + @Test + void validateArguments() { + MyService target = new MyService(); + Method method = getMethod(target, "addStudent"); + + validateArguments(target, method, new Object[] {faustino1234, cayetana6789, 3}, ex -> { + + assertThat(ex.getConstraintViolations()).hasSize(3); + assertThat(ex.getAllValidationResults()).hasSize(3); + + assertBeanResult(ex.getBeanResults().get(0), 0, "student", faustino1234, List.of( + """ + Field error in object 'student' on field 'name': rejected value [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]""")); + + assertBeanResult(ex.getBeanResults().get(1), 1, "guardian", cayetana6789, List.of( + """ + Field error in object 'guardian' on field 'name': rejected value [Cayetana6789]; \ + codes [Size.guardian.name,Size.name,Size.java.lang.String,Size]; \ + arguments [org.springframework.context.support.DefaultMessageSourceResolvable: \ + codes [guardian.name,name]; arguments []; default message [name],10,1]; \ + default message [size must be between 1 and 10]""")); + + assertValueResult(ex.getValueResults().get(0), 2, 3, List.of( + """ + org.springframework.context.support.DefaultMessageSourceResolvable: \ + codes [Max.myService#addStudent.degrees,Max.degrees,Max.int,Max]; \ + arguments [org.springframework.context.support.DefaultMessageSourceResolvable: \ + codes [myService#addStudent.degrees,degrees]; arguments []; default message [degrees],2]; \ + default message [must be less than or equal to 2]""" + )); + }); + } + + @Test + void validateReturnValue() { + MyService target = new MyService(); + + validateReturnValue(target, getMethod(target, "getIntValue"), 4, ex -> { + + assertThat(ex.getConstraintViolations()).hasSize(1); + assertThat(ex.getAllValidationResults()).hasSize(1); + + assertValueResult(ex.getValueResults().get(0), -1, 4, List.of( + """ + org.springframework.context.support.DefaultMessageSourceResolvable: \ + codes [Min.myService#getIntValue,Min,Min.int]; \ + arguments [org.springframework.context.support.DefaultMessageSourceResolvable: \ + codes [myService#getIntValue]; arguments []; default message [],5]; \ + default message [must be greater than or equal to 5]""" + )); + }); + } + + @Test + void validateReturnValueBean() { + MyService target = new MyService(); + + validateReturnValue(target, getMethod(target, "getPerson"), faustino1234, ex -> { + + assertThat(ex.getConstraintViolations()).hasSize(1); + assertThat(ex.getAllValidationResults()).hasSize(1); + + assertBeanResult(ex.getBeanResults().get(0), -1, "person", faustino1234, List.of( + """ + Field error in object 'person' on field 'name': rejected value [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 validateListArgument() { + MyService target = new MyService(); + Method method = getMethod(target, "addPeople"); + + validateArguments(target, method, new Object[] {List.of(faustino1234, cayetana6789)}, ex -> { + + assertThat(ex.getConstraintViolations()).hasSize(2); + assertThat(ex.getAllValidationResults()).hasSize(2); + + int paramIndex = 0; + String objectName = "people"; + List results = ex.getBeanResults(); + + assertBeanResult(results.get(0), paramIndex, objectName, faustino1234, List.of( + """ + Field error in object 'people' on field 'name': rejected value [Faustino1234]; \ + codes [Size.people.name,Size.name,Size.java.lang.String,Size]; \ + arguments [org.springframework.context.support.DefaultMessageSourceResolvable: \ + codes [people.name,name]; arguments []; default message [name],10,1]; \ + default message [size must be between 1 and 10]""")); + + assertBeanResult(results.get(1), paramIndex, objectName, cayetana6789, List.of( + """ + Field error in object 'people' on field 'name': rejected value [Cayetana6789]; \ + codes [Size.people.name,Size.name,Size.java.lang.String,Size]; \ + arguments [org.springframework.context.support.DefaultMessageSourceResolvable: \ + codes [people.name,name]; arguments []; default message [name],10,1]; \ + default message [size must be between 1 and 10]""")); + }); + } + + private void validateArguments( + Object target, Method method, Object[] arguments, Consumer assertions) { + + MethodValidationAdapter adapter = new MethodValidationAdapter(); + + assertThatExceptionOfType(MethodValidationException.class) + .isThrownBy(() -> adapter.validateMethodArguments(target, method, arguments, new Class[0])) + .satisfies(assertions); + } + + private void validateReturnValue( + Object target, Method method, @Nullable Object returnValue, Consumer assertions) { + + MethodValidationAdapter adapter = new MethodValidationAdapter(); + + assertThatExceptionOfType(MethodValidationException.class) + .isThrownBy(() -> adapter.validateMethodReturnValue(target, method, returnValue, new Class[0])) + .satisfies(assertions); + } + + private static void assertBeanResult( + ParameterErrors errors, int parameterIndex, String objectName, Object argument, + List fieldErrors) { + + assertThat(errors.getMethodParameter().getParameterIndex()).isEqualTo(parameterIndex); + assertThat(errors.getObjectName()).isEqualTo(objectName); + assertThat(errors.getArgument()).isSameAs(argument); + + assertThat(errors.getFieldErrors()) + .extracting(FieldError::toString) + .containsExactlyInAnyOrderElementsOf(fieldErrors); + } + + 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); + } + + private static Method getMethod(Object target, String methodName) { + return ClassUtils.getMethod(target.getClass(), methodName, (Class[]) null); + } + + + @SuppressWarnings("unused") + private static class MyService { + + public void addStudent(@Valid Person student, @Valid Person guardian, @Max(2) int degrees) { + } + + @Min(5) + public int getIntValue() { + throw new UnsupportedOperationException(); + } + + @Valid + public Person getPerson() { + throw new UnsupportedOperationException(); + } + + public void addPeople(@Valid List people) { + } + + } + + + @SuppressWarnings("unused") + private record Person(@Size(min = 1, max = 10) String name) { + + @Override + public String name() { + return this.name; + } + + } + +}