Skip to content

Commit

Permalink
Add BindErrorUtils
Browse files Browse the repository at this point in the history
This deprecates static methods in MethodArgumentNotValidException
which is not a great vehicle for such methods.

See gh-30644
  • Loading branch information
rstoyanchev committed Jul 3, 2023
1 parent e83594a commit ba4d9a5
Show file tree
Hide file tree
Showing 5 changed files with 166 additions and 76 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -82,70 +79,56 @@ 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<String> 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<String> errorsToStringList(List<? extends ObjectError> 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<String> errorsToStringList(
List<? extends ObjectError> errors, @Nullable MessageSource messageSource, @Nullable Locale locale) {
List<? extends ObjectError> 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());
}

/**
* Resolve global and field errors to messages with the given
* {@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<ObjectError, String> resolveErrorMessages(MessageSource source, Locale locale) {
Map<ObjectError, String> 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<ObjectError, String> resolveErrorMessages(MessageSource messageSource, Locale locale) {
return BindErrorUtils.resolve(getAllErrors(), messageSource, locale);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -68,43 +67,28 @@ 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<String> errors) {
return String.join(", and ", errors);
BindErrorUtils.resolveAndJoin(getGlobalErrors(), source, locale),
BindErrorUtils.resolveAndJoin(getFieldErrors(), source, locale)};
}

/**
* Resolve global and field errors to messages with the given
* {@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<ObjectError, String> resolveErrorMessages(MessageSource messageSource, Locale locale) {
Map<ObjectError, String> map = new LinkedHashMap<>();
addMessages(map, getGlobalErrors(), messageSource, locale);
addMessages(map, getFieldErrors(), messageSource, locale);
return map;
}

private static void addMessages(
Map<ObjectError, String> map, List<? extends ObjectError> errors,
MessageSource messageSource, Locale locale) {

List<String> 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);
}


Expand Down
Original file line number Diff line number Diff line change
@@ -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<? extends MessageSourceResolvable> 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<? extends MessageSourceResolvable> 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<? extends MessageSourceResolvable> 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 <E extends MessageSourceResolvable> Map<E, String> resolve(List<E> 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 <E extends MessageSourceResolvable> Map<E, String> resolve(
List<E> errors, MessageSource messageSource, Locale locale) {

Map<E, String> 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);
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

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

List<FieldError> fieldErrors = ex.getFieldErrors();
List<String> errors = MethodArgumentNotValidException.errorsToStringList(fieldErrors);
Collection<String> 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");
Expand All @@ -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<FieldError> fieldErrors = ex.getFieldErrors();
List<String> errors = MethodArgumentNotValidException.errorsToStringList(fieldErrors, source, Locale.UK);
Collection<String> errors = BindErrorUtils.resolve(ex.getFieldErrors(), source, Locale.UK).values();

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

0 comments on commit ba4d9a5

Please sign in to comment.