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

@@ -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;
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
MissingServletRequestPartException.class,
ServletRequestBindingException.class,
MethodArgumentNotValidException.class,
HandlerMethodValidationException.class,
NoHandlerFoundException.class,
NoResourceFoundException.class,
AsyncRequestTimeoutException.class,
@@ -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
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
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
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
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

View File

@@ -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;
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;
* <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;
* <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
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
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
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
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

View File

@@ -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 {
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);

View File

@@ -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;
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;
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 {
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 {
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 {
private static class NestedExceptionThrowingController {
@RequestMapping("/")
public void handleRequest() throws Exception {
public void handleRequest() {
throw new IllegalStateException(new ServletRequestBindingException("message"));
}
}