Skip to content

Commit ba4d9a5

Browse files
committed
Add BindErrorUtils
This deprecates static methods in MethodArgumentNotValidException which is not a great vehicle for such methods. See gh-30644
1 parent e83594a commit ba4d9a5

File tree

5 files changed

+166
-76
lines changed

5 files changed

+166
-76
lines changed

spring-web/src/main/java/org/springframework/web/bind/MethodArgumentNotValidException.java

Lines changed: 25 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616

1717
package org.springframework.web.bind;
1818

19-
import java.util.LinkedHashMap;
2019
import java.util.List;
2120
import java.util.Locale;
2221
import java.util.Map;
@@ -27,13 +26,11 @@
2726
import org.springframework.http.HttpStatusCode;
2827
import org.springframework.http.ProblemDetail;
2928
import org.springframework.lang.Nullable;
30-
import org.springframework.util.Assert;
31-
import org.springframework.util.StringUtils;
3229
import org.springframework.validation.BindException;
3330
import org.springframework.validation.BindingResult;
34-
import org.springframework.validation.FieldError;
3531
import org.springframework.validation.ObjectError;
3632
import org.springframework.web.ErrorResponse;
33+
import org.springframework.web.util.BindErrorUtils;
3734

3835
/**
3936
* Exception to be thrown when validation on an argument annotated with {@code @Valid} fails.
@@ -82,70 +79,56 @@ public ProblemDetail getBody() {
8279
}
8380

8481
@Override
85-
public Object[] getDetailMessageArguments() {
82+
public Object[] getDetailMessageArguments(MessageSource source, Locale locale) {
8683
return new Object[] {
87-
join(errorsToStringList(getGlobalErrors())),
88-
join(errorsToStringList(getFieldErrors()))};
84+
BindErrorUtils.resolveAndJoin(getGlobalErrors(), source, locale),
85+
BindErrorUtils.resolveAndJoin(getFieldErrors(), source, locale)};
8986
}
9087

9188
@Override
92-
public Object[] getDetailMessageArguments(MessageSource messageSource, Locale locale) {
89+
public Object[] getDetailMessageArguments() {
9390
return new Object[] {
94-
join(errorsToStringList(getGlobalErrors(), messageSource, locale)),
95-
join(errorsToStringList(getFieldErrors(), messageSource, locale))};
96-
}
97-
98-
private static String join(List<String> errors) {
99-
return String.join(", and ", errors);
91+
BindErrorUtils.resolveAndJoin(getGlobalErrors()),
92+
BindErrorUtils.resolveAndJoin(getFieldErrors())};
10093
}
10194

10295
/**
103-
* Convert each given {@link ObjectError} to a String in single quotes, taking
104-
* either the error's default message, or its error code.
96+
* Convert each given {@link ObjectError} to a String.
10597
* @since 6.0
98+
* @deprecated in favor of using {@link BindErrorUtils} and
99+
* {@link #getAllErrors()}, to be removed in 6.2
106100
*/
101+
@Deprecated(since = "6.1", forRemoval = true)
107102
public static List<String> errorsToStringList(List<? extends ObjectError> errors) {
108-
return errorsToStringList(errors, null, null);
103+
return BindErrorUtils.resolve(errors).values().stream().toList();
109104
}
110105

111106
/**
112-
* Variant of {@link #errorsToStringList(List)} that uses a
113-
* {@link MessageSource} to resolve the message code of the error, or fall
114-
* back on the error's default message.
107+
* Convert each given {@link ObjectError} to a String, and use a
108+
* {@link MessageSource} to resolve each error.
115109
* @since 6.0
110+
* @deprecated in favor of {@link BindErrorUtils}, to be removed in 6.2
116111
*/
112+
@Deprecated(since = "6.1", forRemoval = true)
117113
public static List<String> errorsToStringList(
118-
List<? extends ObjectError> errors, @Nullable MessageSource messageSource, @Nullable Locale locale) {
114+
List<? extends ObjectError> errors, @Nullable MessageSource messageSource, Locale locale) {
119115

120-
return errors.stream()
121-
.map(error -> formatError(error, messageSource, locale))
122-
.filter(StringUtils::hasText)
123-
.toList();
124-
}
125-
126-
private static String formatError(
127-
ObjectError error, @Nullable MessageSource messageSource, @Nullable Locale locale) {
128-
129-
if (messageSource != null) {
130-
Assert.notNull(locale, "Expected MessageSource and locale");
131-
return messageSource.getMessage(error, locale);
132-
}
133-
String field = (error instanceof FieldError fieldError ? fieldError.getField() + ": " : "");
134-
String message = (error.getDefaultMessage() != null ? error.getDefaultMessage() : error.getCode());
135-
return (field + message);
116+
return (messageSource != null ?
117+
BindErrorUtils.resolve(errors, messageSource, locale).values().stream().toList() :
118+
BindErrorUtils.resolve(errors).values().stream().toList());
136119
}
137120

138121
/**
139122
* Resolve global and field errors to messages with the given
140123
* {@link MessageSource} and {@link Locale}.
141124
* @return a Map with errors as keys and resolved messages as values
142125
* @since 6.0.3
126+
* @deprecated in favor of using {@link BindErrorUtils} and
127+
* {@link #getAllErrors()}, to be removed in 6.2
143128
*/
144-
public Map<ObjectError, String> resolveErrorMessages(MessageSource source, Locale locale) {
145-
Map<ObjectError, String> map = new LinkedHashMap<>(getErrorCount());
146-
getGlobalErrors().forEach(error -> map.put(error, formatError(error, source, locale)));
147-
getFieldErrors().forEach(error -> map.put(error, formatError(error, source, locale)));
148-
return map;
129+
@Deprecated(since = "6.1", forRemoval = true)
130+
public Map<ObjectError, String> resolveErrorMessages(MessageSource messageSource, Locale locale) {
131+
return BindErrorUtils.resolve(getAllErrors(), messageSource, locale);
149132
}
150133

151134
@Override

spring-web/src/main/java/org/springframework/web/bind/support/WebExchangeBindException.java

Lines changed: 9 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
package org.springframework.web.bind.support;
1818

1919
import java.beans.PropertyEditor;
20-
import java.util.LinkedHashMap;
2120
import java.util.List;
2221
import java.util.Locale;
2322
import java.util.Map;
@@ -32,8 +31,8 @@
3231
import org.springframework.validation.Errors;
3332
import org.springframework.validation.FieldError;
3433
import org.springframework.validation.ObjectError;
35-
import org.springframework.web.bind.MethodArgumentNotValidException;
3634
import org.springframework.web.server.ServerWebInputException;
35+
import org.springframework.web.util.BindErrorUtils;
3736

3837
/**
3938
* {@link ServerWebInputException} subclass that indicates a data binding or
@@ -68,43 +67,28 @@ public final BindingResult getBindingResult() {
6867
@Override
6968
public Object[] getDetailMessageArguments() {
7069
return new Object[] {
71-
join(MethodArgumentNotValidException.errorsToStringList(getGlobalErrors())),
72-
join(MethodArgumentNotValidException.errorsToStringList(getFieldErrors()))};
70+
BindErrorUtils.resolveAndJoin(getGlobalErrors()),
71+
BindErrorUtils.resolveAndJoin(getFieldErrors())};
7372
}
7473

7574
@Override
7675
public Object[] getDetailMessageArguments(MessageSource source, Locale locale) {
7776
return new Object[] {
78-
join(MethodArgumentNotValidException.errorsToStringList(getGlobalErrors(), source, locale)),
79-
join(MethodArgumentNotValidException.errorsToStringList(getFieldErrors(), source, locale))
80-
};
81-
}
82-
83-
private static String join(List<String> errors) {
84-
return String.join(", and ", errors);
77+
BindErrorUtils.resolveAndJoin(getGlobalErrors(), source, locale),
78+
BindErrorUtils.resolveAndJoin(getFieldErrors(), source, locale)};
8579
}
8680

8781
/**
8882
* Resolve global and field errors to messages with the given
8983
* {@link MessageSource} and {@link Locale}.
9084
* @return a Map with errors as key and resolves messages as value
9185
* @since 6.0.3
86+
* @deprecated in favor of using {@link BindErrorUtils} and
87+
* {@link #getAllErrors()}, to be removed in 6.2
9288
*/
89+
@Deprecated(since = "6.1", forRemoval = true)
9390
public Map<ObjectError, String> resolveErrorMessages(MessageSource messageSource, Locale locale) {
94-
Map<ObjectError, String> map = new LinkedHashMap<>();
95-
addMessages(map, getGlobalErrors(), messageSource, locale);
96-
addMessages(map, getFieldErrors(), messageSource, locale);
97-
return map;
98-
}
99-
100-
private static void addMessages(
101-
Map<ObjectError, String> map, List<? extends ObjectError> errors,
102-
MessageSource messageSource, Locale locale) {
103-
104-
List<String> messages = MethodArgumentNotValidException.errorsToStringList(errors, messageSource, locale);
105-
for (int i = 0; i < errors.size(); i++) {
106-
map.put(errors.get(i), messages.get(i));
107-
}
91+
return BindErrorUtils.resolve(getAllErrors(), messageSource, locale);
10892
}
10993

11094

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/*
2+
* Copyright 2002-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.web.util;
18+
19+
import java.util.LinkedHashMap;
20+
import java.util.List;
21+
import java.util.Locale;
22+
import java.util.Map;
23+
import java.util.stream.Collectors;
24+
25+
import org.springframework.context.MessageSource;
26+
import org.springframework.context.MessageSourceResolvable;
27+
import org.springframework.context.support.StaticMessageSource;
28+
import org.springframework.util.StringUtils;
29+
import org.springframework.validation.FieldError;
30+
31+
/**
32+
* Utility methods to resolve list of {@link MessageSourceResolvable}s, and
33+
* optionally join them.
34+
*
35+
* @author Rossen Stoyanchev
36+
* @since 6.1
37+
*/
38+
public abstract class BindErrorUtils {
39+
40+
private final static MessageSource defaultMessageSource = new MethodArgumentErrorMessageSource();
41+
42+
43+
/**
44+
* Shortcut for {@link #resolveAndJoin(List, MessageSource, Locale)} with
45+
* an empty * {@link MessageSource} that simply formats the default message,
46+
* or first error code, also prepending the field name for field errors.
47+
*/
48+
public static String resolveAndJoin(List<? extends MessageSourceResolvable> errors) {
49+
return resolveAndJoin(errors, defaultMessageSource, Locale.getDefault());
50+
}
51+
52+
/**
53+
* Shortcut for {@link #resolveAndJoin(CharSequence, CharSequence, CharSequence, List, MessageSource, Locale)}
54+
* with {@code ", and "} as delimiter, and an empty prefix and suffix.
55+
*/
56+
public static String resolveAndJoin(
57+
List<? extends MessageSourceResolvable> errors, MessageSource messageSource, Locale locale) {
58+
59+
return resolveAndJoin(", and ", "", "", errors, messageSource, locale);
60+
}
61+
62+
/**
63+
* Resolve all errors through the given {@link MessageSource} and join them.
64+
* @param delimiter the delimiter to use between each error
65+
* @param prefix characters to insert at the beginning
66+
* @param suffix characters to insert at the end
67+
* @param errors the errors to resolve and join
68+
* @param messageSource the {@code MessageSource} to resolve with
69+
* @param locale the locale to resolve with
70+
* @return the resolved errors formatted as a string
71+
*/
72+
public static String resolveAndJoin(
73+
CharSequence delimiter, CharSequence prefix, CharSequence suffix,
74+
List<? extends MessageSourceResolvable> errors, MessageSource messageSource, Locale locale) {
75+
76+
return errors.stream()
77+
.map(error -> messageSource.getMessage(error, locale))
78+
.filter(StringUtils::hasText)
79+
.collect(Collectors.joining(delimiter, prefix, suffix));
80+
}
81+
82+
/**
83+
* Shortcut for {@link #resolve(List, MessageSource, Locale)} with an empty
84+
* {@link MessageSource} that simply formats the default message, or first
85+
* error code, also prepending the field name for field errors.
86+
*/
87+
public static <E extends MessageSourceResolvable> Map<E, String> resolve(List<E> errors) {
88+
return resolve(errors, defaultMessageSource, Locale.getDefault());
89+
}
90+
91+
/**
92+
* Resolve all errors through the given {@link MessageSource}.
93+
* @param errors the errors to resolve
94+
* @param messageSource the {@code MessageSource} to resolve with
95+
* @param locale the locale to resolve with an empty {@link MessageSource}
96+
* @return map with resolved errors as values, in the order of the input list
97+
*/
98+
public static <E extends MessageSourceResolvable> Map<E, String> resolve(
99+
List<E> errors, MessageSource messageSource, Locale locale) {
100+
101+
Map<E, String> map = new LinkedHashMap<>(errors.size());
102+
errors.forEach(error -> map.put(error, messageSource.getMessage(error, locale)));
103+
return map;
104+
}
105+
106+
107+
/**
108+
* {@code MessageSource} for default error formatting.
109+
*/
110+
private static class MethodArgumentErrorMessageSource extends StaticMessageSource {
111+
112+
MethodArgumentErrorMessageSource() {
113+
setUseCodeAsDefaultMessage(true);
114+
}
115+
116+
@Override
117+
protected String getDefaultMessage(MessageSourceResolvable resolvable, Locale locale) {
118+
String message = super.getDefaultMessage(resolvable, locale);
119+
return (resolvable instanceof FieldError error ? error.getField() + ": " + message : message);
120+
}
121+
}
122+
123+
}

spring-web/src/test/java/org/springframework/web/ErrorResponseExceptionTests.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
import org.springframework.web.server.UnsatisfiedRequestParameterException;
5757
import org.springframework.web.server.UnsupportedMediaTypeStatusException;
5858
import org.springframework.web.testfixture.method.ResolvableMethod;
59+
import org.springframework.web.util.BindErrorUtils;
5960

6061
import static org.assertj.core.api.Assertions.assertThat;
6162

@@ -252,7 +253,8 @@ void methodArgumentNotValidException() {
252253
assertStatus(ex, HttpStatus.BAD_REQUEST);
253254
assertDetail(ex, "Invalid request content.");
254255
messageSourceHelper.assertDetailMessage(ex);
255-
messageSourceHelper.assertErrorMessages(ex::resolveErrorMessages);
256+
messageSourceHelper.assertErrorMessages(
257+
(source, locale) -> BindErrorUtils.resolve(ex.getAllErrors(), source, locale));
256258

257259
assertThat(ex.getHeaders()).isEmpty();
258260
}
@@ -457,8 +459,8 @@ private void assertDetailMessage(ErrorResponse ex) {
457459
ex.getDetailMessageCode(), ex.getDetailMessageArguments(), Locale.UK);
458460

459461
assertThat(message).isEqualTo(
460-
"Failed because Invalid bean message, and bean.invalid.B. " +
461-
"Also because name: must be provided, and age: age.min");
462+
"Failed because Invalid bean message, and bean.invalid.B.myBean. " +
463+
"Also because name: must be provided, and age: age.min.myBean.age");
462464

463465
message = messageSource.getMessage(
464466
ex.getDetailMessageCode(), ex.getDetailMessageArguments(messageSource, Locale.UK), Locale.UK);

spring-web/src/test/java/org/springframework/web/bind/MethodArgumentNotValidExceptionTests.java

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
package org.springframework.web.bind;
1818

1919
import java.lang.reflect.Method;
20-
import java.util.List;
20+
import java.util.Collection;
2121
import java.util.Locale;
2222

2323
import jakarta.validation.constraints.Min;
@@ -29,9 +29,9 @@
2929
import org.springframework.core.MethodParameter;
3030
import org.springframework.validation.BeanPropertyBindingResult;
3131
import org.springframework.validation.BindingResult;
32-
import org.springframework.validation.FieldError;
3332
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
3433
import org.springframework.validation.beanvalidation.SpringValidatorAdapter;
34+
import org.springframework.web.util.BindErrorUtils;
3535

3636
import static org.assertj.core.api.Assertions.assertThat;
3737

@@ -47,8 +47,7 @@ void errorsToStringList() throws Exception {
4747
Person frederick1234 = new Person("Frederick1234", 24);
4848
MethodArgumentNotValidException ex = createException(frederick1234);
4949

50-
List<FieldError> fieldErrors = ex.getFieldErrors();
51-
List<String> errors = MethodArgumentNotValidException.errorsToStringList(fieldErrors);
50+
Collection<String> errors = BindErrorUtils.resolve(ex.getFieldErrors()).values();
5251

5352
assertThat(errors).containsExactlyInAnyOrder(
5453
"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 {
6362
source.addMessage("Size.name", Locale.UK, "name exceeds {1} characters");
6463
source.addMessage("Min.age", Locale.UK, "age is under {1}");
6564

66-
List<FieldError> fieldErrors = ex.getFieldErrors();
67-
List<String> errors = MethodArgumentNotValidException.errorsToStringList(fieldErrors, source, Locale.UK);
65+
Collection<String> errors = BindErrorUtils.resolve(ex.getFieldErrors(), source, Locale.UK).values();
6866

6967
assertThat(errors).containsExactlyInAnyOrder("name exceeds 10 characters", "age is under 25");
7068
}

0 commit comments

Comments
 (0)