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 index 8a7f365b92..533523d3d9 100644 --- a/spring-context/src/main/java/org/springframework/validation/beanvalidation/MethodValidationAdapter.java +++ b/spring-context/src/main/java/org/springframework/validation/beanvalidation/MethodValidationAdapter.java @@ -50,6 +50,7 @@ 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.Assert; import org.springframework.util.function.SingletonSupplier; import org.springframework.validation.BeanPropertyBindingResult; import org.springframework.validation.BindingResult; @@ -402,7 +403,7 @@ public class MethodValidationAdapter implements MethodValidator { 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()); + return new ViolationMessageSourceResolvable(codes, arguments, violation.getMessage(), violation); } private BindingResult createBindingResult(MethodParameter parameter, @Nullable Object argument) { @@ -472,7 +473,11 @@ public class MethodValidationAdapter implements MethodValidator { public ParameterValidationResult build() { return new ParameterValidationResult( this.parameter, this.value, this.resolvableErrors, this.container, - this.containerIndex, this.containerKey); + this.containerIndex, this.containerKey, + (error, sourceType) -> { + Assert.isTrue(sourceType.equals(ConstraintViolation.class), "Unexpected source type"); + return ((ViolationMessageSourceResolvable) error).getViolation(); + }); } } @@ -526,6 +531,24 @@ public class MethodValidationAdapter implements MethodValidator { } + @SuppressWarnings("serial") + private static class ViolationMessageSourceResolvable extends DefaultMessageSourceResolvable { + + private final transient ConstraintViolation violation; + + public ViolationMessageSourceResolvable( + String[] codes, Object[] arguments, String defaultMessage, ConstraintViolation violation) { + + super(codes, arguments, defaultMessage); + this.violation = violation; + } + + public ConstraintViolation getViolation() { + return this.violation; + } + } + + /** * Default algorithm to select an object name, as described in {@link #setObjectNameResolver}. */ diff --git a/spring-context/src/main/java/org/springframework/validation/method/ParameterErrors.java b/spring-context/src/main/java/org/springframework/validation/method/ParameterErrors.java index aa76afd0c2..ad3bba6b74 100644 --- a/spring-context/src/main/java/org/springframework/validation/method/ParameterErrors.java +++ b/spring-context/src/main/java/org/springframework/validation/method/ParameterErrors.java @@ -47,7 +47,9 @@ public class ParameterErrors extends ParameterValidationResult implements Errors MethodParameter parameter, @Nullable Object argument, Errors errors, @Nullable Object container, @Nullable Integer index, @Nullable Object key) { - super(parameter, argument, errors.getAllErrors(), container, index, key); + super(parameter, argument, errors.getAllErrors(), + container, index, key, (error, sourceType) -> ((FieldError) error).unwrap(sourceType)); + this.errors = errors; } diff --git a/spring-context/src/main/java/org/springframework/validation/method/ParameterValidationResult.java b/spring-context/src/main/java/org/springframework/validation/method/ParameterValidationResult.java index 6f14e1de2d..c1efed1fba 100644 --- a/spring-context/src/main/java/org/springframework/validation/method/ParameterValidationResult.java +++ b/spring-context/src/main/java/org/springframework/validation/method/ParameterValidationResult.java @@ -18,6 +18,8 @@ package org.springframework.validation.method; import java.util.Collection; import java.util.List; +import java.util.function.BiFunction; +import java.util.function.Function; import org.springframework.context.MessageSourceResolvable; import org.springframework.core.MethodParameter; @@ -62,13 +64,16 @@ public class ParameterValidationResult { @Nullable private final Object containerKey; + private final BiFunction, Object> sourceLookup; + /** * Create a {@code ParameterValidationResult}. */ public ParameterValidationResult( MethodParameter param, @Nullable Object arg, Collection errors, - @Nullable Object container, @Nullable Integer index, @Nullable Object key) { + @Nullable Object container, @Nullable Integer index, @Nullable Object key, + BiFunction, Object> sourceLookup) { Assert.notNull(param, "MethodParameter is required"); Assert.notEmpty(errors, "`resolvableErrors` must not be empty"); @@ -78,18 +83,36 @@ public class ParameterValidationResult { this.container = container; this.containerIndex = index; this.containerKey = key; + this.sourceLookup = sourceLookup; } /** * Create a {@code ParameterValidationResult}. * @deprecated in favor of - * {@link ParameterValidationResult#ParameterValidationResult(MethodParameter, Object, Collection, Object, Integer, Object)} + * {@link ParameterValidationResult#ParameterValidationResult(MethodParameter, Object, Collection, Object, Integer, Object, Function)} + */ + @Deprecated(since = "6.2", forRemoval = true) + public ParameterValidationResult( + MethodParameter param, @Nullable Object arg, Collection errors, + @Nullable Object container, @Nullable Integer index, @Nullable Object key) { + + this(param, arg, errors, container, index, key, (error, sourceType) -> { + throw new IllegalArgumentException("No source object of the given type"); + }); + } + + /** + * Create a {@code ParameterValidationResult}. + * @deprecated in favor of + * {@link ParameterValidationResult#ParameterValidationResult(MethodParameter, Object, Collection, Object, Integer, Object, Function)} */ @Deprecated(since = "6.1.3", forRemoval = true) public ParameterValidationResult( MethodParameter param, @Nullable Object arg, Collection errors) { - this(param, arg, errors, null, null, null); + this(param, arg, errors, null, null, null, (error, sourceType) -> { + throw new IllegalArgumentException("No source object of the given type"); + }); } @@ -164,6 +187,17 @@ public class ParameterValidationResult { return this.containerKey; } + /** + * Unwrap the source behind the given error. For Jakarta Bean validation the + * source is a {@link jakarta.validation.ConstraintViolation}. + * @param sourceType the expected source type + * @return the source object of the given type + * @since 6.2 + */ + @SuppressWarnings("unchecked") + public T unwrap(MessageSourceResolvable error, Class sourceType) { + return (T) this.sourceLookup.apply(error, sourceType); + } @Override public boolean equals(@Nullable Object other) { 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 index 3519210f7e..76a7ab7f20 100644 --- a/spring-context/src/test/java/org/springframework/validation/beanvalidation/MethodValidationAdapterTests.java +++ b/spring-context/src/test/java/org/springframework/validation/beanvalidation/MethodValidationAdapterTests.java @@ -22,6 +22,7 @@ import java.util.Locale; import java.util.Set; import java.util.function.Consumer; +import jakarta.validation.ConstraintViolation; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; @@ -99,7 +100,7 @@ class MethodValidationAdapterTests { default message [must not be blank]""")); assertValueResult(ex.getValueResults().get(0), 2, 3, List.of(""" - org.springframework.context.support.DefaultMessageSourceResolvable: \ + org.springframework.validation.beanvalidation.MethodValidationAdapter$ViolationMessageSourceResolvable: \ 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]; \ @@ -136,7 +137,7 @@ class MethodValidationAdapterTests { assertThat(ex.getAllValidationResults()).hasSize(1); assertValueResult(ex.getValueResults().get(0), -1, 4, List.of(""" - org.springframework.context.support.DefaultMessageSourceResolvable: \ + org.springframework.validation.beanvalidation.MethodValidationAdapter$ViolationMessageSourceResolvable: \ codes [Min.myService#getIntValue,Min,Min.int]; \ arguments [org.springframework.context.support.DefaultMessageSourceResolvable: \ codes [myService#getIntValue]; arguments []; default message [],5]; \ @@ -204,7 +205,7 @@ class MethodValidationAdapterTests { testArgs(target, method, new Object[] {List.of(" ")}, ex -> { assertThat(ex.getAllValidationResults()).hasSize(1); assertValueResult(ex.getValueResults().get(0), 0, " ", List.of(""" - org.springframework.context.support.DefaultMessageSourceResolvable: \ + org.springframework.validation.beanvalidation.MethodValidationAdapter$ViolationMessageSourceResolvable: \ codes [NotBlank.myService#addHobbies.hobbies,NotBlank.hobbies,NotBlank.java.util.List,NotBlank]; \ arguments [org.springframework.context.support.DefaultMessageSourceResolvable: \ codes [myService#addHobbies.hobbies,hobbies]; \ @@ -220,7 +221,7 @@ class MethodValidationAdapterTests { testArgs(target, method, new Object[] {Set.of("test", " ")}, ex -> { assertThat(ex.getAllValidationResults()).hasSize(1); assertValueResult(ex.getValueResults().get(0), 0, Set.of("test", " "), List.of(""" - org.springframework.context.support.DefaultMessageSourceResolvable: \ + org.springframework.validation.beanvalidation.MethodValidationAdapter$ViolationMessageSourceResolvable: \ codes [NotBlank.myService#addUniqueHobbies.hobbies,NotBlank.hobbies,NotBlank.java.util.Set,NotBlank]; \ arguments [org.springframework.context.support.DefaultMessageSourceResolvable: \ codes [myService#addUniqueHobbies.hobbies,hobbies]; \ @@ -254,9 +255,14 @@ class MethodValidationAdapterTests { assertThat(result.getMethodParameter().getParameterIndex()).isEqualTo(parameterIndex); assertThat(result.getArgument()).isEqualTo(argument); - assertThat(result.getResolvableErrors()) + + List resolvableErrors = result.getResolvableErrors(); + assertThat(resolvableErrors) .extracting(MessageSourceResolvable::toString) .containsExactlyInAnyOrderElementsOf(errors); + + resolvableErrors.forEach(error -> + assertThat(result.unwrap(error, ConstraintViolation.class)).isNotNull()); } private static Method getMethod(Object target, String methodName) { diff --git a/spring-web/src/test/java/org/springframework/web/method/support/HandlerMethodValidationExceptionTests.java b/spring-web/src/test/java/org/springframework/web/method/support/HandlerMethodValidationExceptionTests.java index c7aceb0c0b..c14791d772 100644 --- a/spring-web/src/test/java/org/springframework/web/method/support/HandlerMethodValidationExceptionTests.java +++ b/spring-web/src/test/java/org/springframework/web/method/support/HandlerMethodValidationExceptionTests.java @@ -128,7 +128,8 @@ class HandlerMethodValidationExceptionTests { } else { MessageSourceResolvable error = new DefaultMessageSourceResolvable("Size"); - return new ParameterValidationResult(param, "123", List.of(error), null, null, null); + return new ParameterValidationResult( + param, "123", List.of(error), null, null, null, (e, t) -> null); } }) .toList()); diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/MethodValidationTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/MethodValidationTests.java index 9c8bb19111..2e550b4d57 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/MethodValidationTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/MethodValidationTests.java @@ -220,7 +220,7 @@ class MethodValidationTests { assertValueResult(ex.getValueResults().get(0), 2, "123", Collections.singletonList( """ - org.springframework.context.support.DefaultMessageSourceResolvable: \ + org.springframework.validation.beanvalidation.MethodValidationAdapter$ViolationMessageSourceResolvable: \ codes [Size.validController#handle.myHeader,Size.myHeader,Size.java.lang.String,Size]; \ arguments [org.springframework.context.support.DefaultMessageSourceResolvable: \ codes [validController#handle.myHeader,myHeader]; arguments []; default message [myHeader],10,5]; \ @@ -360,9 +360,15 @@ class MethodValidationTests { assertThat(result.getMethodParameter().getParameterIndex()).isEqualTo(parameterIndex); assertThat(result.getArgument()).isEqualTo(argument); - assertThat(result.getResolvableErrors()) + + List resolvableErrors = result.getResolvableErrors(); + assertThat(resolvableErrors) .extracting(MessageSourceResolvable::toString) .containsExactlyInAnyOrderElementsOf(errors); + + resolvableErrors.forEach(error -> + assertThat(result.unwrap(error, ConstraintViolation.class)).isNotNull()); + } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/MethodValidationTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/MethodValidationTests.java index 8a2d35a954..f2f6d5a814 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/MethodValidationTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/MethodValidationTests.java @@ -183,7 +183,7 @@ class MethodValidationTests { )); assertValueResult(ex.getValueResults().get(0), 2, "123", List.of(""" - org.springframework.context.support.DefaultMessageSourceResolvable: \ + org.springframework.validation.beanvalidation.MethodValidationAdapter$ViolationMessageSourceResolvable: \ codes [Size.validController#handle.myHeader,Size.myHeader,Size.java.lang.String,Size]; \ arguments [org.springframework.context.support.DefaultMessageSourceResolvable: \ codes [validController#handle.myHeader,myHeader]; arguments []; default message [myHeader],10,5]; \