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 extends ConstraintViolation>> 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;
+ }
+
+ }
+
+}