Browse Source

Add default web handling of method validation errors

Closes gh-30644
pull/30798/head
rstoyanchev 3 years ago
parent
commit
7a79da589a
  1. 87
      spring-web/src/main/java/org/springframework/web/method/annotation/HandlerMethodValidationException.java
  2. 5
      spring-web/src/main/java/org/springframework/web/method/annotation/HandlerMethodValidator.java
  3. 107
      spring-web/src/test/java/org/springframework/web/ErrorResponseExceptionTests.java
  4. 48
      spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityExceptionHandler.java
  5. 4
      spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/MethodValidationTests.java
  6. 19
      spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityExceptionHandlerTests.java
  7. 51
      spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandler.java
  8. 57
      spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/support/DefaultHandlerExceptionResolver.java
  9. 5
      spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/MethodValidationTests.java
  10. 17
      spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandlerTests.java

87
spring-web/src/main/java/org/springframework/web/method/annotation/HandlerMethodValidationException.java

@ -0,0 +1,87 @@ @@ -0,0 +1,87 @@
/*
* 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.method.annotation;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Locale;
import org.springframework.context.MessageSource;
import org.springframework.http.HttpStatus;
import org.springframework.validation.beanvalidation.MethodValidationResult;
import org.springframework.validation.beanvalidation.ParameterValidationResult;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.util.BindErrorUtils;
/**
* {@link ResponseStatusException} that is also {@link MethodValidationResult}.
* Raised by {@link HandlerMethodValidator} in case of method validation errors
* on a web controller method.
*
* <p>The {@link #getStatusCode()} is 400 for input validation errors, and 500
* for validation errors on a return value.
*
* @author Rossen Stoyanchev
* @since 6.1
*/
@SuppressWarnings("serial")
public class HandlerMethodValidationException extends ResponseStatusException implements MethodValidationResult {
private final MethodValidationResult validationResult;
public HandlerMethodValidationException(MethodValidationResult validationResult) {
super(initHttpStatus(validationResult), "Validation failure", null, null, null);
this.validationResult = validationResult;
}
private static HttpStatus initHttpStatus(MethodValidationResult validationResult) {
return (!validationResult.isForReturnValue() ? HttpStatus.BAD_REQUEST : HttpStatus.INTERNAL_SERVER_ERROR);
}
@Override
public Object[] getDetailMessageArguments(MessageSource messageSource, Locale locale) {
return new Object[] { BindErrorUtils.resolveAndJoin(getAllErrors(), messageSource, locale) };
}
@Override
public Object[] getDetailMessageArguments() {
return new Object[] { BindErrorUtils.resolveAndJoin(getAllErrors()) };
}
@Override
public Object getTarget() {
return this.validationResult.getTarget();
}
@Override
public Method getMethod() {
return this.validationResult.getMethod();
}
@Override
public boolean isForReturnValue() {
return this.validationResult.isForReturnValue();
}
@Override
public List<ParameterValidationResult> getAllValidationResults() {
return this.validationResult.getAllValidationResults();
}
}

5
spring-web/src/main/java/org/springframework/web/method/annotation/HandlerMethodValidator.java

@ -27,7 +27,6 @@ import org.springframework.lang.Nullable; @@ -27,7 +27,6 @@ import org.springframework.lang.Nullable;
import org.springframework.validation.BindingResult;
import org.springframework.validation.MessageCodesResolver;
import org.springframework.validation.beanvalidation.MethodValidationAdapter;
import org.springframework.validation.beanvalidation.MethodValidationException;
import org.springframework.validation.beanvalidation.MethodValidationResult;
import org.springframework.validation.beanvalidation.MethodValidator;
import org.springframework.validation.beanvalidation.ParameterErrors;
@ -91,7 +90,7 @@ public final class HandlerMethodValidator implements MethodValidator { @@ -91,7 +90,7 @@ public final class HandlerMethodValidator implements MethodValidator {
}
}
throw new MethodValidationException(result);
throw new HandlerMethodValidationException(result);
}
@Override
@ -109,7 +108,7 @@ public final class HandlerMethodValidator implements MethodValidator { @@ -109,7 +108,7 @@ public final class HandlerMethodValidator implements MethodValidator {
MethodValidationResult result = validateReturnValue(target, method, returnType, returnValue, groups);
if (result.hasErrors()) {
throw new MethodValidationException(result);
throw new HandlerMethodValidationException(result);
}
}

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

@ -20,13 +20,11 @@ import java.util.Arrays; @@ -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; @@ -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; @@ -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; @@ -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 { @@ -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 { @@ -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 { @@ -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) {
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;
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");
}
private void assertDetailMessage(ErrorResponse ex) {
public BindingResult bindingResult() {
return this.bindingResult;
}
StaticMessageSource messageSource = initMessageSource();
private void assertMessages(ErrorResponse ex, List<? extends MessageSourceResolvable> errors) {
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");
}
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");
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 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;
}
}
}

48
spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityExceptionHandler.java

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
/*
* Copyright 2002-2022 the original author or authors.
* 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.
@ -25,15 +25,18 @@ import reactor.core.publisher.Mono; @@ -25,15 +25,18 @@ import reactor.core.publisher.Mono;
import org.springframework.context.MessageSource;
import org.springframework.context.MessageSourceAware;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.lang.Nullable;
import org.springframework.validation.beanvalidation.MethodValidationException;
import org.springframework.web.ErrorResponse;
import org.springframework.web.ErrorResponseException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.support.WebExchangeBindException;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.server.MethodNotAllowedException;
import org.springframework.web.server.MissingRequestValueException;
import org.springframework.web.server.NotAcceptableStatusException;
@ -97,10 +100,12 @@ public abstract class ResponseEntityExceptionHandler implements MessageSourceAwa @@ -97,10 +100,12 @@ public abstract class ResponseEntityExceptionHandler implements MessageSourceAwa
MissingRequestValueException.class,
UnsatisfiedRequestParameterException.class,
WebExchangeBindException.class,
HandlerMethodValidationException.class,
ServerWebInputException.class,
ServerErrorException.class,
ResponseStatusException.class,
ErrorResponseException.class
ErrorResponseException.class,
MethodValidationException.class
})
public final Mono<ResponseEntity<Object>> handleException(Exception ex, ServerWebExchange exchange) {
if (ex instanceof MethodNotAllowedException theEx) {
@ -121,6 +126,9 @@ public abstract class ResponseEntityExceptionHandler implements MessageSourceAwa @@ -121,6 +126,9 @@ public abstract class ResponseEntityExceptionHandler implements MessageSourceAwa
else if (ex instanceof WebExchangeBindException theEx) {
return handleWebExchangeBindException(theEx, theEx.getHeaders(), theEx.getStatusCode(), exchange);
}
else if (ex instanceof HandlerMethodValidationException theEx) {
return handleHandlerMethodValidationException(theEx, theEx.getHeaders(), theEx.getStatusCode(), exchange);
}
else if (ex instanceof ServerWebInputException theEx) {
return handleServerWebInputException(theEx, theEx.getHeaders(), theEx.getStatusCode(), exchange);
}
@ -133,6 +141,9 @@ public abstract class ResponseEntityExceptionHandler implements MessageSourceAwa @@ -133,6 +141,9 @@ public abstract class ResponseEntityExceptionHandler implements MessageSourceAwa
else if (ex instanceof ErrorResponseException theEx) {
return handleErrorResponseException(theEx, theEx.getHeaders(), theEx.getStatusCode(), exchange);
}
else if (ex instanceof MethodValidationException theEx) {
return handleMethodValidationException(theEx, HttpStatus.INTERNAL_SERVER_ERROR, exchange);
}
else {
if (logger.isWarnEnabled()) {
logger.warn("Unexpected exception type: " + ex.getClass().getName());
@ -237,6 +248,23 @@ public abstract class ResponseEntityExceptionHandler implements MessageSourceAwa @@ -237,6 +248,23 @@ public abstract class ResponseEntityExceptionHandler implements MessageSourceAwa
return handleExceptionInternal(ex, null, headers, status, exchange);
}
/**
* Customize the handling of {@link HandlerMethodValidationException}.
* <p>This method delegates to {@link #handleExceptionInternal}.
* @param ex the exception to handle
* @param headers the headers to use for the response
* @param status the status code to use for the response
* @param exchange the current request and response
* @return a {@code Mono} with the {@code ResponseEntity} for the response
* @since 6.1
*/
protected Mono<ResponseEntity<Object>> handleHandlerMethodValidationException(
HandlerMethodValidationException ex, HttpHeaders headers, HttpStatusCode status,
ServerWebExchange exchange) {
return handleExceptionInternal(ex, null, headers, status, exchange);
}
/**
* Customize the handling of {@link ServerWebInputException}.
* <p>This method delegates to {@link #handleExceptionInternal}.
@ -301,6 +329,22 @@ public abstract class ResponseEntityExceptionHandler implements MessageSourceAwa @@ -301,6 +329,22 @@ public abstract class ResponseEntityExceptionHandler implements MessageSourceAwa
return handleExceptionInternal(ex, null, headers, status, exchange);
}
/**
* Customize the handling of {@link MethodValidationException}.
* <p>This method delegates to {@link #handleExceptionInternal}.
* @param ex the exception to handle
* @param status the status code to use for the response
* @param exchange the current request and response
* @return a {@code Mono} with the {@code ResponseEntity} for the response
* @since 6.1
*/
protected Mono<ResponseEntity<Object>> handleMethodValidationException(
MethodValidationException ex, HttpStatus status, ServerWebExchange exchange) {
ProblemDetail body = createProblemDetail(ex, status, "Validation failed", null, null, exchange);
return handleExceptionInternal(ex, body, null, status, exchange);
}
/**
* Convenience method to create a {@link ProblemDetail} for any exception
* that doesn't implement {@link ErrorResponse}, also performing a

4
spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/MethodValidationTests.java

@ -43,7 +43,6 @@ import org.springframework.validation.FieldError; @@ -43,7 +43,6 @@ import org.springframework.validation.FieldError;
import org.springframework.validation.Validator;
import org.springframework.validation.annotation.Validated;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
import org.springframework.validation.beanvalidation.MethodValidationException;
import org.springframework.validation.beanvalidation.ParameterValidationResult;
import org.springframework.validation.beanvalidation.SpringValidatorAdapter;
import org.springframework.web.bind.WebDataBinder;
@ -55,6 +54,7 @@ import org.springframework.web.bind.support.ConfigurableWebBindingInitializer; @@ -55,6 +54,7 @@ import org.springframework.web.bind.support.ConfigurableWebBindingInitializer;
import org.springframework.web.bind.support.WebExchangeBindException;
import org.springframework.web.context.support.GenericWebApplicationContext;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.reactive.HandlerResult;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest;
@ -202,7 +202,7 @@ public class MethodValidationTests { @@ -202,7 +202,7 @@ public class MethodValidationTests {
StepVerifier.create(this.handlerAdapter.handle(exchange, hm))
.consumeErrorWith(throwable -> {
MethodValidationException ex = (MethodValidationException) throwable;
HandlerMethodValidationException ex = (HandlerMethodValidationException) throwable;
assertThat(this.jakartaValidator.getValidationCount()).isEqualTo(1);
assertThat(this.jakartaValidator.getMethodValidationCount()).isEqualTo(1);

19
spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityExceptionHandlerTests.java

@ -37,9 +37,12 @@ import org.springframework.http.ProblemDetail; @@ -37,9 +37,12 @@ import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.validation.BeanPropertyBindingResult;
import org.springframework.validation.beanvalidation.MethodValidationException;
import org.springframework.validation.beanvalidation.MethodValidationResult;
import org.springframework.web.ErrorResponse;
import org.springframework.web.ErrorResponseException;
import org.springframework.web.bind.support.WebExchangeBindException;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.server.MethodNotAllowedException;
import org.springframework.web.server.MissingRequestValueException;
import org.springframework.web.server.NotAcceptableStatusException;
@ -53,6 +56,7 @@ import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRe @@ -53,6 +56,7 @@ import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRe
import org.springframework.web.testfixture.server.MockServerWebExchange;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.mock;
/**
* Unit tests for {@link ResponseEntityExceptionHandler}.
@ -105,6 +109,21 @@ public class ResponseEntityExceptionHandlerTests { @@ -105,6 +109,21 @@ public class ResponseEntityExceptionHandlerTests {
testException(new WebExchangeBindException(null, new BeanPropertyBindingResult(new Object(), "foo")));
}
@Test
public void handlerMethodValidationException() {
testException(new HandlerMethodValidationException(mock(MethodValidationResult.class)));
}
@Test
public void methodValidationException() {
MethodValidationException ex = new MethodValidationException(mock(MethodValidationResult.class));
ResponseEntity<?> entity = this.exceptionHandler.handleException(ex, this.exchange).block();
assertThat(entity).isNotNull();
assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
assertThat(entity.getBody()).isInstanceOf(ProblemDetail.class);
}
@Test
void handleServerWebInputException() {
testException(new ServerWebInputException(""));

51
spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandler.java

@ -34,6 +34,7 @@ import org.springframework.http.converter.HttpMessageNotReadableException; @@ -34,6 +34,7 @@ import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.http.converter.HttpMessageNotWritableException;
import org.springframework.lang.Nullable;
import org.springframework.validation.BindException;
import org.springframework.validation.beanvalidation.MethodValidationException;
import org.springframework.web.ErrorResponse;
import org.springframework.web.ErrorResponseException;
import org.springframework.web.HttpMediaTypeNotAcceptableException;
@ -48,6 +49,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; @@ -48,6 +49,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.context.request.WebRequest;
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.servlet.NoHandlerFoundException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
@ -121,6 +123,7 @@ public abstract class ResponseEntityExceptionHandler implements MessageSourceAwa @@ -121,6 +123,7 @@ public abstract class ResponseEntityExceptionHandler implements MessageSourceAwa
MissingServletRequestPartException.class,
ServletRequestBindingException.class,
MethodArgumentNotValidException.class,
HandlerMethodValidationException.class,
NoHandlerFoundException.class,
NoResourceFoundException.class,
AsyncRequestTimeoutException.class,
@ -129,6 +132,7 @@ public abstract class ResponseEntityExceptionHandler implements MessageSourceAwa @@ -129,6 +132,7 @@ public abstract class ResponseEntityExceptionHandler implements MessageSourceAwa
TypeMismatchException.class,
HttpMessageNotReadableException.class,
HttpMessageNotWritableException.class,
MethodValidationException.class,
BindException.class
})
@Nullable
@ -157,6 +161,9 @@ public abstract class ResponseEntityExceptionHandler implements MessageSourceAwa @@ -157,6 +161,9 @@ public abstract class ResponseEntityExceptionHandler implements MessageSourceAwa
else if (ex instanceof MethodArgumentNotValidException subEx) {
return handleMethodArgumentNotValid(subEx, subEx.getHeaders(), subEx.getStatusCode(), request);
}
else if (ex instanceof HandlerMethodValidationException subEx) {
return handleHandlerMethodValidationException(subEx, subEx.getHeaders(), subEx.getStatusCode(), request);
}
else if (ex instanceof NoHandlerFoundException subEx) {
return handleNoHandlerFoundException(subEx, subEx.getHeaders(), subEx.getStatusCode(), request);
}
@ -185,6 +192,9 @@ public abstract class ResponseEntityExceptionHandler implements MessageSourceAwa @@ -185,6 +192,9 @@ public abstract class ResponseEntityExceptionHandler implements MessageSourceAwa
else if (ex instanceof HttpMessageNotWritableException theEx) {
return handleHttpMessageNotWritable(theEx, headers, HttpStatus.INTERNAL_SERVER_ERROR, request);
}
else if (ex instanceof MethodValidationException subEx) {
return handleMethodValidationException(subEx, headers, HttpStatus.INTERNAL_SERVER_ERROR, request);
}
else if (ex instanceof BindException theEx) {
return handleBindException(theEx, headers, HttpStatus.BAD_REQUEST, request);
}
@ -335,6 +345,24 @@ public abstract class ResponseEntityExceptionHandler implements MessageSourceAwa @@ -335,6 +345,24 @@ public abstract class ResponseEntityExceptionHandler implements MessageSourceAwa
return handleExceptionInternal(ex, null, headers, status, request);
}
/**
* Customize the handling of {@link HandlerMethodValidationException}.
* <p>This method delegates to {@link #handleExceptionInternal}.
* @param ex the exception to handle
* @param headers the headers to be written to the response
* @param status the selected response status
* @param request the current request
* @return a {@code ResponseEntity} for the response to use, possibly
* {@code null} when the response is already committed
* @since 6.1
*/
@Nullable
protected ResponseEntity<Object> handleHandlerMethodValidationException(
HandlerMethodValidationException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
return handleExceptionInternal(ex, null, headers, status, request);
}
/**
* Customize the handling of {@link NoHandlerFoundException}.
* <p>This method delegates to {@link #handleExceptionInternal}.
@ -521,6 +549,29 @@ public abstract class ResponseEntityExceptionHandler implements MessageSourceAwa @@ -521,6 +549,29 @@ public abstract class ResponseEntityExceptionHandler implements MessageSourceAwa
return handleExceptionInternal(ex, body, headers, status, request);
}
/**
* Customize the handling of {@link MethodValidationException}.
* <p>By default this method creates a {@link ProblemDetail} with the status
* and a short detail message, and also looks up an override for the detail
* via {@link MessageSource}, before delegating to
* {@link #handleExceptionInternal}.
* @param ex the exception to handle
* @param headers the headers to use for the response
* @param status the status code to use for the response
* @param request the current request
* @return a {@code ResponseEntity} for the response to use, possibly
* {@code null} when the response is already committed
* @since 6.1
*/
@Nullable
protected ResponseEntity<Object> handleMethodValidationException(
MethodValidationException ex, HttpHeaders headers, HttpStatus status, WebRequest request) {
ProblemDetail body = createProblemDetail(ex, status, "Validation failed", null, null, request);
return handleExceptionInternal(ex, body, headers, status, request);
}
/**
* Convenience method to create a {@link ProblemDetail} for any exception
* that doesn't implement {@link ErrorResponse}, also performing a

57
spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/support/DefaultHandlerExceptionResolver.java

@ -33,6 +33,7 @@ import org.springframework.http.converter.HttpMessageNotWritableException; @@ -33,6 +33,7 @@ import org.springframework.http.converter.HttpMessageNotWritableException;
import org.springframework.lang.Nullable;
import org.springframework.validation.BindException;
import org.springframework.validation.BindingResult;
import org.springframework.validation.beanvalidation.MethodValidationException;
import org.springframework.web.ErrorResponse;
import org.springframework.web.HttpMediaTypeNotAcceptableException;
import org.springframework.web.HttpMediaTypeNotSupportedException;
@ -45,6 +46,7 @@ import org.springframework.web.bind.annotation.ModelAttribute; @@ -45,6 +46,7 @@ import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.support.MissingServletRequestPartException;
import org.springframework.web.servlet.ModelAndView;
@ -118,8 +120,8 @@ import org.springframework.web.util.WebUtils; @@ -118,8 +120,8 @@ import org.springframework.web.util.WebUtils;
* <td><div class="block">MethodArgumentNotValidException</div></td>
* <td><div class="block">400 (SC_BAD_REQUEST)</div></td>
* </tr>
* <tr class="even-row-color">
* <td><div class="block">BindException</div></td>
* <tr class="odd-row-color">
* <td><div class="block">{@link HandlerMethodValidationException}</div></td>
* <td><div class="block">400 (SC_BAD_REQUEST)</div></td>
* </tr>
* <tr class="odd-row-color">
@ -134,6 +136,10 @@ import org.springframework.web.util.WebUtils; @@ -134,6 +136,10 @@ import org.springframework.web.util.WebUtils;
* <td><div class="block">AsyncRequestTimeoutException</div></td>
* <td><div class="block">503 (SC_SERVICE_UNAVAILABLE)</div></td>
* </tr>
* <tr class="odd-row-color">
* <td><div class="block">{@link MethodValidationException}</div></td>
* <td><div class="block">500 (SC_INTERNAL_SERVER_ERROR)</div></td>
* </tr>
* </tbody>
* </table>
*
@ -200,6 +206,9 @@ public class DefaultHandlerExceptionResolver extends AbstractHandlerExceptionRes @@ -200,6 +206,9 @@ public class DefaultHandlerExceptionResolver extends AbstractHandlerExceptionRes
else if (ex instanceof MethodArgumentNotValidException theEx) {
mav = handleMethodArgumentNotValidException(theEx, request, response, handler);
}
else if (ex instanceof HandlerMethodValidationException theEx) {
mav = handleHandlerMethodValidationException(theEx, request, response, handler);
}
else if (ex instanceof NoHandlerFoundException theEx) {
mav = handleNoHandlerFoundException(theEx, request, response, handler);
}
@ -228,6 +237,9 @@ public class DefaultHandlerExceptionResolver extends AbstractHandlerExceptionRes @@ -228,6 +237,9 @@ public class DefaultHandlerExceptionResolver extends AbstractHandlerExceptionRes
else if (ex instanceof HttpMessageNotWritableException theEx) {
return handleHttpMessageNotWritable(theEx, request, response, handler);
}
else if (ex instanceof MethodValidationException theEx) {
return handleMethodValidationException(theEx, request, response, handler);
}
else if (ex instanceof BindException theEx) {
return handleBindException(theEx, request, response, handler);
}
@ -399,6 +411,26 @@ public class DefaultHandlerExceptionResolver extends AbstractHandlerExceptionRes @@ -399,6 +411,26 @@ public class DefaultHandlerExceptionResolver extends AbstractHandlerExceptionRes
return null;
}
/**
* Handle the case where method validation for a controller method failed.
* <p>The default implementation returns {@code null} in which case the
* exception is handled in {@link #handleErrorResponse}.
* @param ex the exception to be handled
* @param request current HTTP request
* @param response current HTTP response
* @param handler the executed handler
* @return an empty {@code ModelAndView} indicating the exception was handled, or
* {@code null} indicating the exception should be handled in {@link #handleErrorResponse}
* @throws IOException potentially thrown from {@link HttpServletResponse#sendError}
* @since 6.1
*/
@Nullable
protected ModelAndView handleHandlerMethodValidationException(HandlerMethodValidationException ex,
HttpServletRequest request, HttpServletResponse response, @Nullable Object handler) throws IOException {
return null;
}
/**
* Handle the case where no handler was found during the dispatch.
* <p>The default implementation returns {@code null} in which case the
@ -577,6 +609,27 @@ public class DefaultHandlerExceptionResolver extends AbstractHandlerExceptionRes @@ -577,6 +609,27 @@ public class DefaultHandlerExceptionResolver extends AbstractHandlerExceptionRes
return new ModelAndView();
}
/**
* Handle the case where method validation failed on a component that is
* not a web controller, e.g. on some underlying service.
* <p>The default implementation sends an HTTP 500 error, and returns an empty {@code ModelAndView}.
* Alternatively, a fallback view could be chosen, or the HttpMessageNotWritableException could
* be rethrown as-is.
* @param ex the exception to be handled
* @param request current HTTP request
* @param response current HTTP response
* @param handler the executed handler
* @return an empty {@code ModelAndView} indicating the exception was handled
* @throws IOException potentially thrown from {@link HttpServletResponse#sendError}
* @since 6.1
*/
protected ModelAndView handleMethodValidationException(MethodValidationException ex,
HttpServletRequest request, HttpServletResponse response, @Nullable Object handler) throws IOException {
sendServerError(ex, request, response);
return new ModelAndView();
}
/**
* Handle the case where an {@linkplain ModelAttribute @ModelAttribute} method
* argument has binding or validation errors and is not followed by another

5
spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/MethodValidationTests.java

@ -39,7 +39,6 @@ import org.springframework.validation.FieldError; @@ -39,7 +39,6 @@ import org.springframework.validation.FieldError;
import org.springframework.validation.Validator;
import org.springframework.validation.annotation.Validated;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
import org.springframework.validation.beanvalidation.MethodValidationException;
import org.springframework.validation.beanvalidation.ParameterValidationResult;
import org.springframework.validation.beanvalidation.SpringValidatorAdapter;
import org.springframework.web.bind.MethodArgumentNotValidException;
@ -164,9 +163,9 @@ public class MethodValidationTests { @@ -164,9 +163,9 @@ public class MethodValidationTests {
this.request.addParameter("name", "name=Faustino1234");
this.request.addHeader("myHeader", "123");
MethodValidationException ex = catchThrowableOfType(
HandlerMethodValidationException ex = catchThrowableOfType(
() -> this.handlerAdapter.handle(this.request, this.response, hm),
MethodValidationException.class);
HandlerMethodValidationException.class);
assertThat(this.jakartaValidator.getValidationCount()).isEqualTo(1);
assertThat(this.jakartaValidator.getMethodValidationCount()).isEqualTo(1);

17
spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandlerTests.java

@ -43,6 +43,8 @@ import org.springframework.http.converter.HttpMessageNotWritableException; @@ -43,6 +43,8 @@ import org.springframework.http.converter.HttpMessageNotWritableException;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindException;
import org.springframework.validation.MapBindingResult;
import org.springframework.validation.beanvalidation.MethodValidationException;
import org.springframework.validation.beanvalidation.MethodValidationResult;
import org.springframework.web.ErrorResponse;
import org.springframework.web.HttpMediaTypeNotAcceptableException;
import org.springframework.web.HttpMediaTypeNotSupportedException;
@ -58,6 +60,7 @@ import org.springframework.web.context.request.ServletWebRequest; @@ -58,6 +60,7 @@ import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
import org.springframework.web.context.support.StaticWebApplicationContext;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.multipart.support.MissingServletRequestPartException;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.ModelAndView;
@ -69,6 +72,7 @@ import org.springframework.web.testfixture.servlet.MockHttpServletResponse; @@ -69,6 +72,7 @@ import org.springframework.web.testfixture.servlet.MockHttpServletResponse;
import org.springframework.web.testfixture.servlet.MockServletConfig;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.mock;
/**
* Unit tests for {@link ResponseEntityExceptionHandler}.
@ -245,6 +249,16 @@ public class ResponseEntityExceptionHandlerTests { @@ -245,6 +249,16 @@ public class ResponseEntityExceptionHandlerTests {
new MapBindingResult(Collections.emptyMap(), "name")));
}
@Test
public void handlerMethodValidationException() {
testException(new HandlerMethodValidationException(mock(MethodValidationResult.class)));
}
@Test
public void methodValidationException() {
testException(new MethodValidationException(mock(MethodValidationResult.class)));
}
@Test
public void missingServletRequestPart() {
testException(new MissingServletRequestPartException("partName"));
@ -351,6 +365,7 @@ public class ResponseEntityExceptionHandlerTests { @@ -351,6 +365,7 @@ public class ResponseEntityExceptionHandlerTests {
private ResponseEntity<Object> testException(Exception ex) {
try {
ResponseEntity<Object> entity = this.exceptionHandler.handleException(ex, this.request);
assertThat(entity).isNotNull();
// SPR-9653
if (HttpStatus.INTERNAL_SERVER_ERROR.equals(entity.getStatusCode())) {
@ -383,7 +398,7 @@ public class ResponseEntityExceptionHandlerTests { @@ -383,7 +398,7 @@ public class ResponseEntityExceptionHandlerTests {
private static class NestedExceptionThrowingController {
@RequestMapping("/")
public void handleRequest() throws Exception {
public void handleRequest() {
throw new IllegalStateException(new ServletRequestBindingException("message"));
}
}

Loading…
Cancel
Save