Add default web handling of method validation errors

Closes gh-30644
This commit is contained in:
rstoyanchev
2023-06-30 16:09:33 +01:00
parent a481c7649f
commit 7a79da589a
10 changed files with 339 additions and 63 deletions

View File

@@ -20,13 +20,11 @@ import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.function.BiFunction;
import org.junit.jupiter.api.Test;
import org.springframework.beans.testfixture.beans.TestBean;
import org.springframework.context.MessageSource;
import org.springframework.context.MessageSourceResolvable;
import org.springframework.context.support.StaticMessageSource;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpHeaders;
@@ -36,9 +34,9 @@ import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.lang.Nullable;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.validation.BindException;
import org.springframework.validation.BeanPropertyBindingResult;
import org.springframework.validation.BindingResult;
import org.springframework.validation.ObjectError;
import org.springframework.validation.beanvalidation.MethodValidationResult;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingMatrixVariableException;
import org.springframework.web.bind.MissingPathVariableException;
@@ -48,6 +46,7 @@ import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.UnsatisfiedServletRequestParameterException;
import org.springframework.web.bind.support.WebExchangeBindException;
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.multipart.support.MissingServletRequestPartException;
import org.springframework.web.server.MethodNotAllowedException;
import org.springframework.web.server.MissingRequestValueException;
@@ -59,6 +58,9 @@ import org.springframework.web.testfixture.method.ResolvableMethod;
import org.springframework.web.util.BindErrorUtils;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.mock;
import static org.mockito.BDDMockito.reset;
import static org.mockito.BDDMockito.when;
/**
* Unit tests that verify the HTTP response details exposed by exceptions in the
@@ -245,20 +247,35 @@ public class ErrorResponseExceptionTests {
@Test
void methodArgumentNotValidException() {
MessageSourceTestHelper messageSourceHelper = new MessageSourceTestHelper(MethodArgumentNotValidException.class);
BindingResult bindingResult = messageSourceHelper.initBindingResult();
ValidationTestHelper testHelper = new ValidationTestHelper(MethodArgumentNotValidException.class);
BindingResult result = testHelper.bindingResult();
MethodArgumentNotValidException ex = new MethodArgumentNotValidException(this.methodParameter, bindingResult);
MethodArgumentNotValidException ex = new MethodArgumentNotValidException(this.methodParameter, result);
assertStatus(ex, HttpStatus.BAD_REQUEST);
assertDetail(ex, "Invalid request content.");
messageSourceHelper.assertDetailMessage(ex);
messageSourceHelper.assertErrorMessages(
(source, locale) -> BindErrorUtils.resolve(ex.getAllErrors(), source, locale));
testHelper.assertMessages(ex, ex.getAllErrors());
assertThat(ex.getHeaders()).isEmpty();
}
@Test
void handlerMethodValidationException() {
MethodValidationResult result = mock(MethodValidationResult.class);
when(result.isForReturnValue()).thenReturn(false);
HandlerMethodValidationException ex = new HandlerMethodValidationException(result);
assertStatus(ex, HttpStatus.BAD_REQUEST);
assertDetail(ex, "Validation failure");
reset(result);
when(result.isForReturnValue()).thenReturn(true);
ex = new HandlerMethodValidationException(result);
assertStatus(ex, HttpStatus.INTERNAL_SERVER_ERROR);
assertDetail(ex, "Validation failure");
}
@Test
void unsupportedMediaTypeStatusException() {
@@ -360,15 +377,14 @@ public class ErrorResponseExceptionTests {
@Test
void webExchangeBindException() {
MessageSourceTestHelper messageSourceHelper = new MessageSourceTestHelper(WebExchangeBindException.class);
BindingResult bindingResult = messageSourceHelper.initBindingResult();
ValidationTestHelper testHelper = new ValidationTestHelper(WebExchangeBindException.class);
BindingResult result = testHelper.bindingResult();
WebExchangeBindException ex = new WebExchangeBindException(this.methodParameter, bindingResult);
WebExchangeBindException ex = new WebExchangeBindException(this.methodParameter, result);
assertStatus(ex, HttpStatus.BAD_REQUEST);
assertDetail(ex, "Invalid request content.");
messageSourceHelper.assertDetailMessage(ex);
messageSourceHelper.assertErrorMessages(ex::resolveErrorMessages);
testHelper.assertMessages(ex, ex.getAllErrors());
assertThat(ex.getHeaders()).isEmpty();
}
@@ -434,59 +450,52 @@ public class ErrorResponseExceptionTests {
private void handle(String arg) {}
private static class MessageSourceTestHelper {
private static class ValidationTestHelper {
private final String code;
private final BindingResult bindingResult;
public MessageSourceTestHelper(Class<? extends ErrorResponse> exceptionType) {
this.code = "problemDetail." + exceptionType.getName();
private final StaticMessageSource messageSource = new StaticMessageSource();
public ValidationTestHelper(Class<? extends ErrorResponse> exceptionType) {
this.bindingResult = new BeanPropertyBindingResult(new TestBean(), "myBean");
this.bindingResult.reject("bean.invalid.A", "Invalid bean message");
this.bindingResult.reject("bean.invalid.B");
this.bindingResult.rejectValue("name", "name.required", "must be provided");
this.bindingResult.rejectValue("age", "age.min");
String code = "problemDetail." + exceptionType.getName();
this.messageSource.addMessage(code, Locale.UK, "Failed because {0}. Also because {1}");
this.messageSource.addMessage("bean.invalid.A", Locale.UK, "Bean A message");
this.messageSource.addMessage("bean.invalid.B", Locale.UK, "Bean B message");
this.messageSource.addMessage("name.required", Locale.UK, "name is required");
this.messageSource.addMessage("age.min", Locale.UK, "age is below minimum");
}
public BindingResult initBindingResult() {
BindingResult bindingResult = new BindException(new TestBean(), "myBean");
bindingResult.reject("bean.invalid.A", "Invalid bean message");
bindingResult.reject("bean.invalid.B");
bindingResult.rejectValue("name", "name.required", "must be provided");
bindingResult.rejectValue("age", "age.min");
return bindingResult;
public BindingResult bindingResult() {
return this.bindingResult;
}
private void assertDetailMessage(ErrorResponse ex) {
private void assertMessages(ErrorResponse ex, List<? extends MessageSourceResolvable> errors) {
StaticMessageSource messageSource = initMessageSource();
String message = messageSource.getMessage(
String message = this.messageSource.getMessage(
ex.getDetailMessageCode(), ex.getDetailMessageArguments(), Locale.UK);
assertThat(message).isEqualTo(
"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);
message = this.messageSource.getMessage(
ex.getDetailMessageCode(), ex.getDetailMessageArguments(this.messageSource, Locale.UK), Locale.UK);
assertThat(message).isEqualTo(
"Failed because Bean A message, and Bean B message. " +
"Also because name is required, and age is below minimum");
assertThat(BindErrorUtils.resolve(errors, this.messageSource, Locale.UK)).hasSize(4)
.containsValues("Bean A message", "Bean B message", "name is required", "age is below minimum");
}
private void assertErrorMessages(BiFunction<MessageSource, Locale, Map<ObjectError, String>> expectedMessages) {
StaticMessageSource messageSource = initMessageSource();
Map<ObjectError, String> map = expectedMessages.apply(messageSource, Locale.UK);
assertThat(map).hasSize(4).containsValues(
"Bean A message", "Bean B message", "name is required", "age is below minimum");
}
private StaticMessageSource initMessageSource() {
StaticMessageSource messageSource = new StaticMessageSource();
messageSource.addMessage(this.code, Locale.UK, "Failed because {0}. Also because {1}");
messageSource.addMessage("bean.invalid.A", Locale.UK, "Bean A message");
messageSource.addMessage("bean.invalid.B", Locale.UK, "Bean B message");
messageSource.addMessage("name.required", Locale.UK, "name is required");
messageSource.addMessage("age.min", Locale.UK, "age is below minimum");
return messageSource;
}
}
}