From 263811ecfa912a55bce8d0371ed20202abcf5dd5 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Wed, 13 Jul 2022 10:52:35 +0100 Subject: [PATCH] Add WebFlux equivalent of ResponseEntityExceptionHandler Closes gh-28665 --- .../ResponseEntityExceptionHandler.java | 335 ++++++++++++++++++ .../ResponseEntityExceptionHandlerTests.java | 231 ++++++++++++ .../ResponseEntityExceptionHandler.java | 28 +- .../ResponseEntityExceptionHandlerTests.java | 105 +++--- src/docs/asciidoc/web/webflux.adoc | 12 +- 5 files changed, 642 insertions(+), 69 deletions(-) create mode 100644 spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityExceptionHandler.java create mode 100644 spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityExceptionHandlerTests.java diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityExceptionHandler.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityExceptionHandler.java new file mode 100644 index 00000000000..a85c2b6be56 --- /dev/null +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityExceptionHandler.java @@ -0,0 +1,335 @@ +/* + * Copyright 2002-2022 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.reactive.result.method.annotation; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.publisher.Mono; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.springframework.lang.Nullable; +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.server.MethodNotAllowedException; +import org.springframework.web.server.MissingRequestValueException; +import org.springframework.web.server.NotAcceptableStatusException; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.server.ServerErrorException; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.ServerWebInputException; +import org.springframework.web.server.UnsatisfiedRequestParameterException; +import org.springframework.web.server.UnsupportedMediaTypeStatusException; + +/** + * A class with an {@code @ExceptionHandler} method that handles all Spring + * WebFlux raised exceptions by returning a {@link ResponseEntity} with + * RFC 7807 formatted error details in the body. + * + *

Convenient as a base class of an {@link ControllerAdvice @ControllerAdvice} + * for global exception handling in an application. Subclasses can override + * individual methods that handle a specific exception, override + * {@link #handleExceptionInternal} to override common handling of all exceptions, + * or {@link #createResponseEntity} to intercept the final step of creating the + * @link ResponseEntity} from the selected HTTP status code, headers, and body. + * + * @author Rossen Stoyanchev + * @since 6.0 + */ +public abstract class ResponseEntityExceptionHandler { + + /** + * Common logger for use in subclasses. + */ + protected final Log logger = LogFactory.getLog(getClass()); + + + /** + * Handle all exceptions raised within Spring MVC handling of the request . + * @param ex the exception to handle + * @param exchange the current request-response + */ + @ExceptionHandler({ + MethodNotAllowedException.class, + NotAcceptableStatusException.class, + UnsupportedMediaTypeStatusException.class, + MissingRequestValueException.class, + UnsatisfiedRequestParameterException.class, + WebExchangeBindException.class, + ServerWebInputException.class, + ServerErrorException.class, + ResponseStatusException.class, + ErrorResponseException.class + }) + public final Mono> handleException(Exception ex, ServerWebExchange exchange) { + HttpHeaders headers = new HttpHeaders(); + + if (ex instanceof MethodNotAllowedException theEx) { + return handleMethodNotAllowedException(theEx, theEx.getHeaders(), theEx.getStatusCode(), exchange); + } + else if (ex instanceof NotAcceptableStatusException theEx) { + return handleNotAcceptableStatusException(theEx, theEx.getHeaders(), theEx.getStatusCode(), exchange); + } + else if (ex instanceof UnsupportedMediaTypeStatusException theEx) { + return handleUnsupportedMediaTypeStatusException(theEx, theEx.getHeaders(), theEx.getStatusCode(), exchange); + } + else if (ex instanceof MissingRequestValueException theEx) { + return handleMissingRequestValueException(theEx, theEx.getHeaders(), theEx.getStatusCode(), exchange); + } + else if (ex instanceof UnsatisfiedRequestParameterException theEx) { + return handleUnsatisfiedRequestParameterException(theEx, theEx.getHeaders(), theEx.getStatusCode(), exchange); + } + else if (ex instanceof WebExchangeBindException theEx) { + return handleWebExchangeBindException(theEx, theEx.getHeaders(), theEx.getStatusCode(), exchange); + } + else if (ex instanceof ServerWebInputException theEx) { + return handleServerWebInputException(theEx, theEx.getHeaders(), theEx.getStatusCode(), exchange); + } + else if (ex instanceof ServerErrorException theEx) { + return handleServerErrorException(theEx, theEx.getHeaders(), theEx.getStatusCode(), exchange); + } + else if (ex instanceof ResponseStatusException theEx) { + return handleResponseStatusException(theEx, theEx.getHeaders(), theEx.getStatusCode(), exchange); + } + else if (ex instanceof ErrorResponseException theEx) { + return handleErrorResponseException(theEx, theEx.getHeaders(), theEx.getStatusCode(), exchange); + } + else { + if (logger.isWarnEnabled()) { + logger.warn("Unexpected exception type: " + ex.getClass().getName()); + } + return Mono.error(ex); + } + } + + /** + * Customize the handling of {@link MethodNotAllowedException}. + *

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 + */ + protected Mono> handleMethodNotAllowedException( + MethodNotAllowedException ex, HttpHeaders headers, HttpStatusCode status, + ServerWebExchange exchange) { + + return handleExceptionInternal(ex, null, headers, status, exchange); + } + + /** + * Customize the handling of {@link NotAcceptableStatusException}. + *

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 + */ + protected Mono> handleNotAcceptableStatusException( + NotAcceptableStatusException ex, HttpHeaders headers, HttpStatusCode status, + ServerWebExchange exchange) { + + return handleExceptionInternal(ex, null, headers, status, exchange); + } + + /** + * Customize the handling of {@link UnsupportedMediaTypeStatusException}. + *

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 + */ + protected Mono> handleUnsupportedMediaTypeStatusException( + UnsupportedMediaTypeStatusException ex, HttpHeaders headers, HttpStatusCode status, + ServerWebExchange exchange) { + + return handleExceptionInternal(ex, null, headers, status, exchange); + } + + /** + * Customize the handling of {@link MissingRequestValueException}. + *

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 + */ + protected Mono> handleMissingRequestValueException( + MissingRequestValueException ex, HttpHeaders headers, HttpStatusCode status, + ServerWebExchange exchange) { + + return handleExceptionInternal(ex, null, headers, status, exchange); + } + + /** + * Customize the handling of {@link UnsatisfiedRequestParameterException}. + *

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 + */ + protected Mono> handleUnsatisfiedRequestParameterException( + UnsatisfiedRequestParameterException ex, HttpHeaders headers, HttpStatusCode status, + ServerWebExchange exchange) { + + return handleExceptionInternal(ex, null, headers, status, exchange); + } + + /** + * Customize the handling of {@link WebExchangeBindException}. + *

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 + */ + protected Mono> handleWebExchangeBindException( + WebExchangeBindException ex, HttpHeaders headers, HttpStatusCode status, + ServerWebExchange exchange) { + + return handleExceptionInternal(ex, null, headers, status, exchange); + } + + /** + * Customize the handling of {@link ServerWebInputException}. + *

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 + */ + protected Mono> handleServerWebInputException( + ServerWebInputException ex, HttpHeaders headers, HttpStatusCode status, + ServerWebExchange exchange) { + + return handleExceptionInternal(ex, null, headers, status, exchange); + } + + /** + * Customize the handling of any {@link ResponseStatusException}. + *

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 + */ + protected Mono> handleResponseStatusException( + ResponseStatusException ex, HttpHeaders headers, HttpStatusCode status, + ServerWebExchange exchange) { + + return handleExceptionInternal(ex, null, headers, status, exchange); + } + + /** + * Customize the handling of {@link ServerErrorException}. + *

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 + */ + protected Mono> handleServerErrorException( + ServerErrorException ex, HttpHeaders headers, HttpStatusCode status, + ServerWebExchange exchange) { + + return handleExceptionInternal(ex, null, headers, status, exchange); + } + + /** + * Customize the handling of any {@link ErrorResponseException}. + *

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 + */ + protected Mono> handleErrorResponseException( + ErrorResponseException ex, HttpHeaders headers, HttpStatusCode status, + ServerWebExchange exchange) { + + return handleExceptionInternal(ex, null, headers, status, exchange); + } + + /** + * Internal handler method that all others in this class delegate to, for + * common handling, and for the creation of a {@link ResponseEntity}. + *

The default implementation does the following: + *

+ * @param ex the exception to handle + * @param body the body to use for the response + * @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 + */ + protected Mono> handleExceptionInternal( + Exception ex, @Nullable Object body, HttpHeaders headers, HttpStatusCode status, + ServerWebExchange exchange) { + + if (exchange.getResponse().isCommitted()) { + return Mono.error(ex); + } + + if (body == null && ex instanceof ErrorResponse errorResponse) { + body = errorResponse.getBody(); + } + + return createResponseEntity(body, headers, status, exchange); + } + + /** + * Create the {@link ResponseEntity} to use from the given body, headers, + * and statusCode. Subclasses can override this method to inspect and possibly + * modify the body, headers, or statusCode, e.g. to re-create an instance of + * {@link ProblemDetail} as an extension of {@link ProblemDetail}. + * @param body the body to use for the response + * @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 created {@code ResponseEntity} + * @since 6.0 + */ + protected Mono> createResponseEntity( + @Nullable Object body, HttpHeaders headers, HttpStatusCode status, ServerWebExchange exchange) { + + return Mono.just(new ResponseEntity<>(body, headers, status)); + } + +} diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityExceptionHandlerTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityExceptionHandlerTests.java new file mode 100644 index 00000000000..ee3ff6d156d --- /dev/null +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityExceptionHandlerTests.java @@ -0,0 +1,231 @@ +/* + * Copyright 2002-2022 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.reactive.result.method.annotation; + + +import java.lang.reflect.Method; +import java.net.URI; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.web.ErrorResponseException; +import org.springframework.web.bind.support.WebExchangeBindException; +import org.springframework.web.server.MethodNotAllowedException; +import org.springframework.web.server.MissingRequestValueException; +import org.springframework.web.server.NotAcceptableStatusException; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.server.ServerErrorException; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.ServerWebInputException; +import org.springframework.web.server.UnsatisfiedRequestParameterException; +import org.springframework.web.server.UnsupportedMediaTypeStatusException; +import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest; +import org.springframework.web.testfixture.server.MockServerWebExchange; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link ResponseEntityExceptionHandler}. + * + * @author Rossen Stoyanchev + */ +public class ResponseEntityExceptionHandlerTests { + + private final ResponseEntityExceptionHandler exceptionHandler = new GlobalExceptionHandler(); + + private final MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/").build()); + + + @Test + void handleMethodNotAllowedException() { + ResponseEntity entity = testException( + new MethodNotAllowedException(HttpMethod.PATCH, List.of(HttpMethod.GET, HttpMethod.POST))); + + assertThat(entity.getHeaders().getFirst(HttpHeaders.ALLOW)).isEqualTo("GET,POST"); + } + + @Test + void handleNotAcceptableStatusException() { + ResponseEntity entity = testException( + new NotAcceptableStatusException(List.of(MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML))); + + assertThat(entity.getHeaders().getFirst(HttpHeaders.ACCEPT)).isEqualTo("application/json, application/xml"); + } + + @Test + void handleUnsupportedMediaTypeStatusException() { + ResponseEntity entity = testException( + new UnsupportedMediaTypeStatusException(MediaType.APPLICATION_JSON, List.of(MediaType.APPLICATION_XML))); + + assertThat(entity.getHeaders().getFirst(HttpHeaders.ACCEPT)).isEqualTo("application/xml"); + } + + @Test + void handleMissingRequestValueException() { + testException(new MissingRequestValueException("id", String.class, "cookie", null)); + } + + @Test + void handleUnsatisfiedRequestParameterException() { + testException(new UnsatisfiedRequestParameterException(Collections.emptyList(), new LinkedMultiValueMap<>())); + } + + @Test + void handleWebExchangeBindException() { + testException(new WebExchangeBindException(null, null)); + } + + @Test + void handleServerWebInputException() { + testException(new ServerWebInputException("")); + } + + @Test + void handleServerErrorException() { + testException(new ServerErrorException("", (Method) null, null)); + } + + @Test + void handleResponseStatusException() { + testException(new ResponseStatusException(HttpStatus.BANDWIDTH_LIMIT_EXCEEDED)); + } + + @Test + void handleErrorResponseException() { + testException(new ErrorResponseException(HttpStatus.CONFLICT)); + } + + + @SuppressWarnings("unchecked") + private ResponseEntity testException(ErrorResponseException exception) { + ResponseEntity responseEntity = + this.exceptionHandler.handleException(exception, this.exchange).block(); + + assertThat(responseEntity).isNotNull(); + assertThat(responseEntity.getStatusCode()).isEqualTo(exception.getStatusCode()); + + assertThat(responseEntity.getBody()).isNotNull().isInstanceOf(ProblemDetail.class); + ProblemDetail body = (ProblemDetail) responseEntity.getBody(); + assertThat(body.getType()).isEqualTo(URI.create(exception.getClass().getName())); + + return (ResponseEntity) responseEntity; + } + + + private static class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + + private Mono> handleAndSetTypeToExceptionName( + ErrorResponseException ex, HttpHeaders headers, HttpStatusCode status, ServerWebExchange exchange) { + + ProblemDetail body = ex.getBody(); + body.setType(URI.create(ex.getClass().getName())); + return handleExceptionInternal(ex, body, headers, status, exchange); + } + + @Override + protected Mono> handleMethodNotAllowedException( + MethodNotAllowedException ex, HttpHeaders headers, HttpStatusCode status, + ServerWebExchange exchange) { + + return handleAndSetTypeToExceptionName(ex, headers, status, exchange); + } + + @Override + protected Mono> handleNotAcceptableStatusException( + NotAcceptableStatusException ex, HttpHeaders headers, HttpStatusCode status, + ServerWebExchange exchange) { + + return handleAndSetTypeToExceptionName(ex, headers, status, exchange); + } + + @Override + protected Mono> handleUnsupportedMediaTypeStatusException( + UnsupportedMediaTypeStatusException ex, HttpHeaders headers, HttpStatusCode status, + ServerWebExchange exchange) { + + return handleAndSetTypeToExceptionName(ex, headers, status, exchange); + } + + @Override + protected Mono> handleMissingRequestValueException( + MissingRequestValueException ex, HttpHeaders headers, HttpStatusCode status, + ServerWebExchange exchange) { + + return handleAndSetTypeToExceptionName(ex, headers, status, exchange); + } + + @Override + protected Mono> handleUnsatisfiedRequestParameterException( + UnsatisfiedRequestParameterException ex, HttpHeaders headers, HttpStatusCode status, + ServerWebExchange exchange) { + + return handleAndSetTypeToExceptionName(ex, headers, status, exchange); + } + + @Override + protected Mono> handleWebExchangeBindException( + WebExchangeBindException ex, HttpHeaders headers, HttpStatusCode status, + ServerWebExchange exchange) { + + return handleAndSetTypeToExceptionName(ex, headers, status, exchange); + } + + @Override + protected Mono> handleServerWebInputException( + ServerWebInputException ex, HttpHeaders headers, HttpStatusCode status, + ServerWebExchange exchange) { + + return handleAndSetTypeToExceptionName(ex, headers, status, exchange); + } + + @Override + protected Mono> handleResponseStatusException( + ResponseStatusException ex, HttpHeaders headers, HttpStatusCode status, + ServerWebExchange exchange) { + + return handleAndSetTypeToExceptionName(ex, headers, status, exchange); + } + + @Override + protected Mono> handleServerErrorException( + ServerErrorException ex, HttpHeaders headers, HttpStatusCode status, + ServerWebExchange exchange) { + + return handleAndSetTypeToExceptionName(ex, headers, status, exchange); + } + + @Override + protected Mono> handleErrorResponseException( + ErrorResponseException ex, HttpHeaders headers, HttpStatusCode status, + ServerWebExchange exchange) { + + return handleAndSetTypeToExceptionName(ex, headers, status, exchange); + } + } + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandler.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandler.java index 3a4ac5a19ee..f529aa69876 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandler.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandler.java @@ -51,7 +51,7 @@ import org.springframework.web.util.WebUtils; /** * A class with an {@code @ExceptionHandler} method that handles all Spring MVC - * raised exceptions by returning a {@link ResponseEntity} with RFC-7807 + * raised exceptions by returning a {@link ResponseEntity} with RFC 7807 * formatted error details in the body. * *

Convenient as a base class of an {@link ControllerAdvice @ControllerAdvice} @@ -63,8 +63,6 @@ import org.springframework.web.util.WebUtils; * * @author Rossen Stoyanchev * @since 3.2 - * @see #handleException(Exception, WebRequest) - * @see org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver */ public abstract class ResponseEntityExceptionHandler { @@ -143,8 +141,8 @@ public abstract class ResponseEntityExceptionHandler { else if (ex instanceof AsyncRequestTimeoutException subEx) { return handleAsyncRequestTimeoutException(subEx, subEx.getHeaders(), subEx.getStatusCode(), request); } - else if (ex instanceof ErrorResponse errorEx) { - return handleExceptionInternal(ex, null, errorEx.getHeaders(), errorEx.getStatusCode(), request); + else if (ex instanceof ErrorResponseException subEx) { + return handleErrorResponseException(subEx, subEx.getHeaders(), subEx.getStatusCode(), request); } // Lower level exceptions, and exceptions used symmetrically on client and server @@ -166,7 +164,7 @@ public abstract class ResponseEntityExceptionHandler { } else { // Unknown exception, typically a wrapper with a common MVC exception as cause - // (since @ExceptionHandler type declarations also match first-level causes): + // (since @ExceptionHandler type declarations also match nested causes): // We only deal with top-level MVC exceptions here, so let's rethrow the given // exception for further processing through the HandlerExceptionResolver chain. throw ex; @@ -347,6 +345,24 @@ public abstract class ResponseEntityExceptionHandler { return handleExceptionInternal(ex, null, headers, status, request); } + /** + * Customize the handling of any {@link ErrorResponseException}. + *

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 request the current request + * @return a {@code ResponseEntity} for the response to use, possibly + * {@code null} when the response is already committed + * @since 6.0 + */ + @Nullable + protected ResponseEntity handleErrorResponseException( + ErrorResponseException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) { + + return handleExceptionInternal(ex, null, headers, status, request); + } + /** * Customize the handling of {@link ConversionNotSupportedException}. *

By default this method creates a {@link ProblemDetail} with the status diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandlerTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandlerTests.java index 7805336c984..999c6093cf9 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandlerTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandlerTests.java @@ -16,11 +16,9 @@ package org.springframework.web.servlet.mvc.method.annotation; -import java.lang.reflect.Method; import java.util.Arrays; import java.util.Collections; import java.util.List; -import java.util.Set; import jakarta.servlet.ServletException; import org.junit.jupiter.api.Test; @@ -36,7 +34,6 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.http.converter.HttpMessageNotWritableException; -import org.springframework.http.server.ServletServerHttpRequest; import org.springframework.stereotype.Controller; import org.springframework.validation.BindException; import org.springframework.validation.MapBindingResult; @@ -56,6 +53,7 @@ import org.springframework.web.context.request.async.AsyncRequestTimeoutExceptio import org.springframework.web.context.support.StaticWebApplicationContext; import org.springframework.web.multipart.support.MissingServletRequestPartException; import org.springframework.web.servlet.DispatcherServlet; +import org.springframework.web.servlet.ModelAndView; import org.springframework.web.servlet.NoHandlerFoundException; import org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver; import org.springframework.web.testfixture.servlet.MockHttpServletRequest; @@ -65,19 +63,19 @@ import org.springframework.web.testfixture.servlet.MockServletConfig; import static org.assertj.core.api.Assertions.assertThat; /** - * Test fixture for {@link ResponseEntityExceptionHandler}. + * Unit tests for {@link ResponseEntityExceptionHandler}. * * @author Rossen Stoyanchev */ public class ResponseEntityExceptionHandlerTests { - private ResponseEntityExceptionHandler exceptionHandlerSupport = new ApplicationExceptionHandler(); + private final ResponseEntityExceptionHandler exceptionHandler = new ApplicationExceptionHandler(); - private DefaultHandlerExceptionResolver defaultExceptionResolver = new DefaultHandlerExceptionResolver(); + private final DefaultHandlerExceptionResolver exceptionResolver = new DefaultHandlerExceptionResolver(); private MockHttpServletRequest servletRequest = new MockHttpServletRequest("GET", "/"); - private MockHttpServletResponse servletResponse = new MockHttpServletResponse(); + private final MockHttpServletResponse servletResponse = new MockHttpServletResponse(); private WebRequest request = new ServletWebRequest(this.servletRequest, this.servletResponse); @@ -101,21 +99,19 @@ public class ResponseEntityExceptionHandlerTests { @Test public void httpRequestMethodNotSupported() { - List supported = Arrays.asList("POST", "DELETE"); - Exception ex = new HttpRequestMethodNotSupportedException("GET", supported); + ResponseEntity entity = + testException(new HttpRequestMethodNotSupportedException("GET", List.of("POST", "DELETE"))); - ResponseEntity responseEntity = testException(ex); - assertThat(responseEntity.getHeaders().getAllow()).isEqualTo(Set.of(HttpMethod.POST, HttpMethod.DELETE)); + assertThat(entity.getHeaders().getFirst(HttpHeaders.ALLOW)).isEqualTo("POST, DELETE"); } @Test public void handleHttpMediaTypeNotSupported() { - List acceptable = Arrays.asList(MediaType.APPLICATION_ATOM_XML, MediaType.APPLICATION_XML); - Exception ex = new HttpMediaTypeNotSupportedException(MediaType.APPLICATION_JSON, acceptable); + ResponseEntity entity = testException(new HttpMediaTypeNotSupportedException( + MediaType.APPLICATION_JSON, List.of(MediaType.APPLICATION_ATOM_XML, MediaType.APPLICATION_XML))); - ResponseEntity responseEntity = testException(ex); - assertThat(responseEntity.getHeaders().getAccept()).isEqualTo(acceptable); - assertThat(responseEntity.getHeaders().getAcceptPatch()).isEmpty(); + assertThat(entity.getHeaders().getFirst(HttpHeaders.ACCEPT)).isEqualTo("application/atom+xml, application/xml"); + assertThat(entity.getHeaders().getAcceptPatch()).isEmpty(); } @Test @@ -123,92 +119,80 @@ public class ResponseEntityExceptionHandlerTests { this.servletRequest = new MockHttpServletRequest("PATCH", "/"); this.request = new ServletWebRequest(this.servletRequest, this.servletResponse); - List acceptable = Arrays.asList(MediaType.APPLICATION_ATOM_XML, MediaType.APPLICATION_XML); - Exception ex = new HttpMediaTypeNotSupportedException(MediaType.APPLICATION_JSON, acceptable, HttpMethod.PATCH); + ResponseEntity entity = testException( + new HttpMediaTypeNotSupportedException( + MediaType.APPLICATION_JSON, + List.of(MediaType.APPLICATION_ATOM_XML, MediaType.APPLICATION_XML), + HttpMethod.PATCH)); - ResponseEntity responseEntity = testException(ex); - assertThat(responseEntity.getHeaders().getAccept()).isEqualTo(acceptable); - assertThat(responseEntity.getHeaders().getAcceptPatch()).isEqualTo(acceptable); + HttpHeaders headers = entity.getHeaders(); + assertThat(headers.getFirst(HttpHeaders.ACCEPT)).isEqualTo("application/atom+xml, application/xml"); + assertThat(headers.getFirst(HttpHeaders.ACCEPT)).isEqualTo("application/atom+xml, application/xml"); + assertThat(headers.getFirst(HttpHeaders.ACCEPT_PATCH)).isEqualTo("application/atom+xml, application/xml"); } @Test public void httpMediaTypeNotAcceptable() { - Exception ex = new HttpMediaTypeNotAcceptableException(""); - testException(ex); + testException(new HttpMediaTypeNotAcceptableException("")); } @Test public void missingPathVariable() throws NoSuchMethodException { - Method method = getClass().getDeclaredMethod("handle", String.class); - MethodParameter parameter = new MethodParameter(method, 0); - Exception ex = new MissingPathVariableException("param", parameter); - testException(ex); + testException(new MissingPathVariableException("param", + new MethodParameter(getClass().getDeclaredMethod("handle", String.class), 0))); } @Test public void missingServletRequestParameter() { - Exception ex = new MissingServletRequestParameterException("param", "type"); - testException(ex); + testException(new MissingServletRequestParameterException("param", "type")); } @Test public void servletRequestBindingException() { - Exception ex = new ServletRequestBindingException("message"); - testException(ex); + testException(new ServletRequestBindingException("message")); } @Test public void conversionNotSupported() { - Exception ex = new ConversionNotSupportedException(new Object(), Object.class, null); - testException(ex); + testException(new ConversionNotSupportedException(new Object(), Object.class, null)); } @Test public void typeMismatch() { - Exception ex = new TypeMismatchException("foo", String.class); - testException(ex); + testException(new TypeMismatchException("foo", String.class)); } @Test @SuppressWarnings("deprecation") public void httpMessageNotReadable() { - Exception ex = new HttpMessageNotReadableException("message"); - testException(ex); + testException(new HttpMessageNotReadableException("message")); } @Test public void httpMessageNotWritable() { - Exception ex = new HttpMessageNotWritableException(""); - testException(ex); + testException(new HttpMessageNotWritableException("")); } @Test public void methodArgumentNotValid() throws Exception { - Exception ex = new MethodArgumentNotValidException( + testException(new MethodArgumentNotValidException( new MethodParameter(getClass().getDeclaredMethod("handle", String.class), 0), - new MapBindingResult(Collections.emptyMap(), "name")); - testException(ex); + new MapBindingResult(Collections.emptyMap(), "name"))); } @Test public void missingServletRequestPart() { - Exception ex = new MissingServletRequestPartException("partName"); - testException(ex); + testException(new MissingServletRequestPartException("partName")); } @Test public void bindException() { - Exception ex = new BindException(new Object(), "name"); - testException(ex); + testException(new BindException(new Object(), "name")); } @Test public void noHandlerFoundException() { - ServletServerHttpRequest req = new ServletServerHttpRequest( - new MockHttpServletRequest("GET","/resource")); - Exception ex = new NoHandlerFoundException(req.getMethod().toString(), - req.getServletRequest().getRequestURI(),req.getHeaders()); - testException(ex); + testException(new NoHandlerFoundException("GET", "/resource", HttpHeaders.EMPTY)); } @Test @@ -244,8 +228,11 @@ public class ResponseEntityExceptionHandlerTests { resolver.setApplicationContext(ctx); resolver.afterPropertiesSet(); - IllegalStateException ex = new IllegalStateException(new ServletRequestBindingException("message")); - assertThat(resolver.resolveException(this.servletRequest, this.servletResponse, null, ex)).isNull(); + ModelAndView mav = resolver.resolveException( + this.servletRequest, this.servletResponse, null, + new IllegalStateException(new ServletRequestBindingException("message"))); + + assertThat(mav).isNull(); } @Test @@ -287,18 +274,18 @@ public class ResponseEntityExceptionHandlerTests { private ResponseEntity testException(Exception ex) { try { - ResponseEntity responseEntity = this.exceptionHandlerSupport.handleException(ex, this.request); + ResponseEntity entity = this.exceptionHandler.handleException(ex, this.request); // SPR-9653 - if (HttpStatus.INTERNAL_SERVER_ERROR.equals(responseEntity.getStatusCode())) { + if (HttpStatus.INTERNAL_SERVER_ERROR.equals(entity.getStatusCode())) { assertThat(this.servletRequest.getAttribute("jakarta.servlet.error.exception")).isSameAs(ex); } - this.defaultExceptionResolver.resolveException(this.servletRequest, this.servletResponse, null, ex); - - assertThat(responseEntity.getStatusCode().value()).isEqualTo(this.servletResponse.getStatus()); + // Verify DefaultHandlerExceptionResolver would set the same status + this.exceptionResolver.resolveException(this.servletRequest, this.servletResponse, null, ex); + assertThat(entity.getStatusCode().value()).isEqualTo(this.servletResponse.getStatus()); - return responseEntity; + return entity; } catch (Exception ex2) { throw new IllegalStateException("handleException threw exception", ex2); diff --git a/src/docs/asciidoc/web/webflux.adoc b/src/docs/asciidoc/web/webflux.adoc index 5218fe527b7..7f438689fa4 100644 --- a/src/docs/asciidoc/web/webflux.adoc +++ b/src/docs/asciidoc/web/webflux.adoc @@ -3421,10 +3421,14 @@ of error details in the response body is application-specific. However, a value to set the status and the body of the response. Such methods can also be declared in `@ControllerAdvice` classes to apply them globally. -NOTE: Note that Spring WebFlux does not have an equivalent for the Spring MVC -`ResponseEntityExceptionHandler`, because WebFlux raises only `ResponseStatusException` -(or subclasses thereof), and those do not need to be translated to -an HTTP status code. +Applications that implement global exception handling with error details in the response +body should consider extending +{api-spring-framework}/web/reactive/result/method/annotation/ResponseEntityExceptionHandler.html[`ResponseEntityExceptionHandler`], +which provides handling for exceptions that Spring MVC raises and provides hooks to +customize the response body. To make use of this, create a subclass of +`ResponseEntityExceptionHandler`, annotate it with `@ControllerAdvice`, override the +necessary methods, and declare it as a Spring bean. +