diff --git a/spring-web/src/main/java/org/springframework/web/bind/MethodArgumentNotValidException.java b/spring-web/src/main/java/org/springframework/web/bind/MethodArgumentNotValidException.java index 47af2576a28a..c8487e6f802f 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/MethodArgumentNotValidException.java +++ b/spring-web/src/main/java/org/springframework/web/bind/MethodArgumentNotValidException.java @@ -16,7 +16,6 @@ package org.springframework.web.bind; -import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -27,13 +26,11 @@ import org.springframework.http.HttpStatusCode; import org.springframework.http.ProblemDetail; import org.springframework.lang.Nullable; -import org.springframework.util.Assert; -import org.springframework.util.StringUtils; import org.springframework.validation.BindException; import org.springframework.validation.BindingResult; -import org.springframework.validation.FieldError; import org.springframework.validation.ObjectError; import org.springframework.web.ErrorResponse; +import org.springframework.web.util.BindErrorUtils; /** * Exception to be thrown when validation on an argument annotated with {@code @Valid} fails. @@ -82,57 +79,43 @@ public ProblemDetail getBody() { } @Override - public Object[] getDetailMessageArguments() { + public Object[] getDetailMessageArguments(MessageSource source, Locale locale) { return new Object[] { - join(errorsToStringList(getGlobalErrors())), - join(errorsToStringList(getFieldErrors()))}; + BindErrorUtils.resolveAndJoin(getGlobalErrors(), source, locale), + BindErrorUtils.resolveAndJoin(getFieldErrors(), source, locale)}; } @Override - public Object[] getDetailMessageArguments(MessageSource messageSource, Locale locale) { + public Object[] getDetailMessageArguments() { return new Object[] { - join(errorsToStringList(getGlobalErrors(), messageSource, locale)), - join(errorsToStringList(getFieldErrors(), messageSource, locale))}; - } - - private static String join(List errors) { - return String.join(", and ", errors); + BindErrorUtils.resolveAndJoin(getGlobalErrors()), + BindErrorUtils.resolveAndJoin(getFieldErrors())}; } /** - * Convert each given {@link ObjectError} to a String in single quotes, taking - * either the error's default message, or its error code. + * Convert each given {@link ObjectError} to a String. * @since 6.0 + * @deprecated in favor of using {@link BindErrorUtils} and + * {@link #getAllErrors()}, to be removed in 6.2 */ + @Deprecated(since = "6.1", forRemoval = true) public static List errorsToStringList(List errors) { - return errorsToStringList(errors, null, null); + return BindErrorUtils.resolve(errors).values().stream().toList(); } /** - * Variant of {@link #errorsToStringList(List)} that uses a - * {@link MessageSource} to resolve the message code of the error, or fall - * back on the error's default message. + * Convert each given {@link ObjectError} to a String, and use a + * {@link MessageSource} to resolve each error. * @since 6.0 + * @deprecated in favor of {@link BindErrorUtils}, to be removed in 6.2 */ + @Deprecated(since = "6.1", forRemoval = true) public static List errorsToStringList( - List errors, @Nullable MessageSource messageSource, @Nullable Locale locale) { + List errors, @Nullable MessageSource messageSource, Locale locale) { - return errors.stream() - .map(error -> formatError(error, messageSource, locale)) - .filter(StringUtils::hasText) - .toList(); - } - - private static String formatError( - ObjectError error, @Nullable MessageSource messageSource, @Nullable Locale locale) { - - if (messageSource != null) { - Assert.notNull(locale, "Expected MessageSource and locale"); - return messageSource.getMessage(error, locale); - } - String field = (error instanceof FieldError fieldError ? fieldError.getField() + ": " : ""); - String message = (error.getDefaultMessage() != null ? error.getDefaultMessage() : error.getCode()); - return (field + message); + return (messageSource != null ? + BindErrorUtils.resolve(errors, messageSource, locale).values().stream().toList() : + BindErrorUtils.resolve(errors).values().stream().toList()); } /** @@ -140,12 +123,12 @@ private static String formatError( * {@link MessageSource} and {@link Locale}. * @return a Map with errors as keys and resolved messages as values * @since 6.0.3 + * @deprecated in favor of using {@link BindErrorUtils} and + * {@link #getAllErrors()}, to be removed in 6.2 */ - public Map resolveErrorMessages(MessageSource source, Locale locale) { - Map map = new LinkedHashMap<>(getErrorCount()); - getGlobalErrors().forEach(error -> map.put(error, formatError(error, source, locale))); - getFieldErrors().forEach(error -> map.put(error, formatError(error, source, locale))); - return map; + @Deprecated(since = "6.1", forRemoval = true) + public Map resolveErrorMessages(MessageSource messageSource, Locale locale) { + return BindErrorUtils.resolve(getAllErrors(), messageSource, locale); } @Override diff --git a/spring-web/src/main/java/org/springframework/web/bind/support/WebExchangeBindException.java b/spring-web/src/main/java/org/springframework/web/bind/support/WebExchangeBindException.java index 57554cb71b40..9cfe9616a893 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/support/WebExchangeBindException.java +++ b/spring-web/src/main/java/org/springframework/web/bind/support/WebExchangeBindException.java @@ -17,7 +17,6 @@ package org.springframework.web.bind.support; import java.beans.PropertyEditor; -import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -32,8 +31,8 @@ import org.springframework.validation.Errors; import org.springframework.validation.FieldError; import org.springframework.validation.ObjectError; -import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.server.ServerWebInputException; +import org.springframework.web.util.BindErrorUtils; /** * {@link ServerWebInputException} subclass that indicates a data binding or @@ -68,20 +67,15 @@ public final BindingResult getBindingResult() { @Override public Object[] getDetailMessageArguments() { return new Object[] { - join(MethodArgumentNotValidException.errorsToStringList(getGlobalErrors())), - join(MethodArgumentNotValidException.errorsToStringList(getFieldErrors()))}; + BindErrorUtils.resolveAndJoin(getGlobalErrors()), + BindErrorUtils.resolveAndJoin(getFieldErrors())}; } @Override public Object[] getDetailMessageArguments(MessageSource source, Locale locale) { return new Object[] { - join(MethodArgumentNotValidException.errorsToStringList(getGlobalErrors(), source, locale)), - join(MethodArgumentNotValidException.errorsToStringList(getFieldErrors(), source, locale)) - }; - } - - private static String join(List errors) { - return String.join(", and ", errors); + BindErrorUtils.resolveAndJoin(getGlobalErrors(), source, locale), + BindErrorUtils.resolveAndJoin(getFieldErrors(), source, locale)}; } /** @@ -89,22 +83,12 @@ private static String join(List errors) { * {@link MessageSource} and {@link Locale}. * @return a Map with errors as key and resolves messages as value * @since 6.0.3 + * @deprecated in favor of using {@link BindErrorUtils} and + * {@link #getAllErrors()}, to be removed in 6.2 */ + @Deprecated(since = "6.1", forRemoval = true) public Map resolveErrorMessages(MessageSource messageSource, Locale locale) { - Map map = new LinkedHashMap<>(); - addMessages(map, getGlobalErrors(), messageSource, locale); - addMessages(map, getFieldErrors(), messageSource, locale); - return map; - } - - private static void addMessages( - Map map, List errors, - MessageSource messageSource, Locale locale) { - - List messages = MethodArgumentNotValidException.errorsToStringList(errors, messageSource, locale); - for (int i = 0; i < errors.size(); i++) { - map.put(errors.get(i), messages.get(i)); - } + return BindErrorUtils.resolve(getAllErrors(), messageSource, locale); } diff --git a/spring-web/src/main/java/org/springframework/web/util/BindErrorUtils.java b/spring-web/src/main/java/org/springframework/web/util/BindErrorUtils.java new file mode 100644 index 000000000000..013b267f467c --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/util/BindErrorUtils.java @@ -0,0 +1,123 @@ +/* + * Copyright 2002-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.web.util; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.context.MessageSource; +import org.springframework.context.MessageSourceResolvable; +import org.springframework.context.support.StaticMessageSource; +import org.springframework.util.StringUtils; +import org.springframework.validation.FieldError; + +/** + * Utility methods to resolve list of {@link MessageSourceResolvable}s, and + * optionally join them. + * + * @author Rossen Stoyanchev + * @since 6.1 + */ +public abstract class BindErrorUtils { + + private final static MessageSource defaultMessageSource = new MethodArgumentErrorMessageSource(); + + + /** + * Shortcut for {@link #resolveAndJoin(List, MessageSource, Locale)} with + * an empty * {@link MessageSource} that simply formats the default message, + * or first error code, also prepending the field name for field errors. + */ + public static String resolveAndJoin(List errors) { + return resolveAndJoin(errors, defaultMessageSource, Locale.getDefault()); + } + + /** + * Shortcut for {@link #resolveAndJoin(CharSequence, CharSequence, CharSequence, List, MessageSource, Locale)} + * with {@code ", and "} as delimiter, and an empty prefix and suffix. + */ + public static String resolveAndJoin( + List errors, MessageSource messageSource, Locale locale) { + + return resolveAndJoin(", and ", "", "", errors, messageSource, locale); + } + + /** + * Resolve all errors through the given {@link MessageSource} and join them. + * @param delimiter the delimiter to use between each error + * @param prefix characters to insert at the beginning + * @param suffix characters to insert at the end + * @param errors the errors to resolve and join + * @param messageSource the {@code MessageSource} to resolve with + * @param locale the locale to resolve with + * @return the resolved errors formatted as a string + */ + public static String resolveAndJoin( + CharSequence delimiter, CharSequence prefix, CharSequence suffix, + List errors, MessageSource messageSource, Locale locale) { + + return errors.stream() + .map(error -> messageSource.getMessage(error, locale)) + .filter(StringUtils::hasText) + .collect(Collectors.joining(delimiter, prefix, suffix)); + } + + /** + * Shortcut for {@link #resolve(List, MessageSource, Locale)} with an empty + * {@link MessageSource} that simply formats the default message, or first + * error code, also prepending the field name for field errors. + */ + public static Map resolve(List errors) { + return resolve(errors, defaultMessageSource, Locale.getDefault()); + } + + /** + * Resolve all errors through the given {@link MessageSource}. + * @param errors the errors to resolve + * @param messageSource the {@code MessageSource} to resolve with + * @param locale the locale to resolve with an empty {@link MessageSource} + * @return map with resolved errors as values, in the order of the input list + */ + public static Map resolve( + List errors, MessageSource messageSource, Locale locale) { + + Map map = new LinkedHashMap<>(errors.size()); + errors.forEach(error -> map.put(error, messageSource.getMessage(error, locale))); + return map; + } + + + /** + * {@code MessageSource} for default error formatting. + */ + private static class MethodArgumentErrorMessageSource extends StaticMessageSource { + + MethodArgumentErrorMessageSource() { + setUseCodeAsDefaultMessage(true); + } + + @Override + protected String getDefaultMessage(MessageSourceResolvable resolvable, Locale locale) { + String message = super.getDefaultMessage(resolvable, locale); + return (resolvable instanceof FieldError error ? error.getField() + ": " + message : message); + } + } + +} diff --git a/spring-web/src/test/java/org/springframework/web/ErrorResponseExceptionTests.java b/spring-web/src/test/java/org/springframework/web/ErrorResponseExceptionTests.java index 50eb3d7bd9b9..2570486832f9 100644 --- a/spring-web/src/test/java/org/springframework/web/ErrorResponseExceptionTests.java +++ b/spring-web/src/test/java/org/springframework/web/ErrorResponseExceptionTests.java @@ -56,6 +56,7 @@ import org.springframework.web.server.UnsatisfiedRequestParameterException; import org.springframework.web.server.UnsupportedMediaTypeStatusException; import org.springframework.web.testfixture.method.ResolvableMethod; +import org.springframework.web.util.BindErrorUtils; import static org.assertj.core.api.Assertions.assertThat; @@ -252,7 +253,8 @@ void methodArgumentNotValidException() { assertStatus(ex, HttpStatus.BAD_REQUEST); assertDetail(ex, "Invalid request content."); messageSourceHelper.assertDetailMessage(ex); - messageSourceHelper.assertErrorMessages(ex::resolveErrorMessages); + messageSourceHelper.assertErrorMessages( + (source, locale) -> BindErrorUtils.resolve(ex.getAllErrors(), source, locale)); assertThat(ex.getHeaders()).isEmpty(); } @@ -457,8 +459,8 @@ private void assertDetailMessage(ErrorResponse ex) { ex.getDetailMessageCode(), ex.getDetailMessageArguments(), Locale.UK); assertThat(message).isEqualTo( - "Failed because Invalid bean message, and bean.invalid.B. " + - "Also because name: must be provided, and age: age.min"); + "Failed because Invalid bean message, and bean.invalid.B.myBean. " + + "Also because name: must be provided, and age: age.min.myBean.age"); message = messageSource.getMessage( ex.getDetailMessageCode(), ex.getDetailMessageArguments(messageSource, Locale.UK), Locale.UK); diff --git a/spring-web/src/test/java/org/springframework/web/bind/MethodArgumentNotValidExceptionTests.java b/spring-web/src/test/java/org/springframework/web/bind/MethodArgumentNotValidExceptionTests.java index 3d71f1f133de..9317ea57ef3c 100644 --- a/spring-web/src/test/java/org/springframework/web/bind/MethodArgumentNotValidExceptionTests.java +++ b/spring-web/src/test/java/org/springframework/web/bind/MethodArgumentNotValidExceptionTests.java @@ -17,7 +17,7 @@ package org.springframework.web.bind; import java.lang.reflect.Method; -import java.util.List; +import java.util.Collection; import java.util.Locale; import jakarta.validation.constraints.Min; @@ -29,9 +29,9 @@ import org.springframework.core.MethodParameter; import org.springframework.validation.BeanPropertyBindingResult; import org.springframework.validation.BindingResult; -import org.springframework.validation.FieldError; import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; import org.springframework.validation.beanvalidation.SpringValidatorAdapter; +import org.springframework.web.util.BindErrorUtils; import static org.assertj.core.api.Assertions.assertThat; @@ -47,8 +47,7 @@ void errorsToStringList() throws Exception { Person frederick1234 = new Person("Frederick1234", 24); MethodArgumentNotValidException ex = createException(frederick1234); - List fieldErrors = ex.getFieldErrors(); - List errors = MethodArgumentNotValidException.errorsToStringList(fieldErrors); + Collection errors = BindErrorUtils.resolve(ex.getFieldErrors()).values(); assertThat(errors).containsExactlyInAnyOrder( "name: size must be between 0 and 10", "age: must be greater than or equal to 25"); @@ -63,8 +62,7 @@ void errorsToStringListWithMessageSource() throws Exception { source.addMessage("Size.name", Locale.UK, "name exceeds {1} characters"); source.addMessage("Min.age", Locale.UK, "age is under {1}"); - List fieldErrors = ex.getFieldErrors(); - List errors = MethodArgumentNotValidException.errorsToStringList(fieldErrors, source, Locale.UK); + Collection errors = BindErrorUtils.resolve(ex.getFieldErrors(), source, Locale.UK).values(); assertThat(errors).containsExactlyInAnyOrder("name exceeds 10 characters", "age is under 25"); }