Support Content Negotiation with @ExceptionHandler
Prior to this commit, `@ExceptionHandler` annotated controller methods
could be mapped using the exception type declaration as an annotation
attribute, or as a method parameter.
While such methods support a wide variety of method arguments and return
types, it was not possible to declare the same exception type on
different methods (in the same controller/controller advice).
This commit adds a new `produces` attribute on `@ExceptionHandler`; with
that, applications can vary the HTTP response depending on the exception
type and the requested content-type by the client:
```
@ExceptionHandler(produces = "application/json")
public ResponseEntity<ErrorMessage> handleJson(IllegalArgumentException exc) {
return ResponseEntity.badRequest().body(new ErrorMessage(exc.getMessage(), 42));
}
@ExceptionHandler(produces = "text/html")
public String handle(IllegalArgumentException exc, Model model) {
model.addAttribute("error", new ErrorMessage(exc.getMessage(), 42));
return "errorView";
}
```
This commit implements support in both Spring MVC and Spring WebFlux.
Closes gh-31936
This commit is contained in:
@@ -16,7 +16,6 @@
|
||||
|
||||
package org.springframework.web.servlet.mvc.method.annotation;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
@@ -32,7 +31,9 @@ import org.springframework.aop.support.AopUtils;
|
||||
import org.springframework.beans.factory.InitializingBean;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.ApplicationContextAware;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.converter.ByteArrayHttpMessageConverter;
|
||||
import org.springframework.http.converter.HttpMessageConverter;
|
||||
import org.springframework.http.converter.StringHttpMessageConverter;
|
||||
@@ -40,11 +41,14 @@ import org.springframework.http.converter.support.AllEncompassingFormHttpMessage
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.ui.ModelMap;
|
||||
import org.springframework.web.ErrorResponse;
|
||||
import org.springframework.web.HttpMediaTypeNotAcceptableException;
|
||||
import org.springframework.web.accept.ContentNegotiationManager;
|
||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||
import org.springframework.web.context.request.RequestAttributes;
|
||||
import org.springframework.web.context.request.ServletWebRequest;
|
||||
import org.springframework.web.method.ControllerAdviceBean;
|
||||
import org.springframework.web.method.HandlerMethod;
|
||||
import org.springframework.web.method.annotation.ExceptionHandlerMappingInfo;
|
||||
import org.springframework.web.method.annotation.ExceptionHandlerMethodResolver;
|
||||
import org.springframework.web.method.annotation.MapMethodProcessor;
|
||||
import org.springframework.web.method.annotation.ModelMethodProcessor;
|
||||
@@ -53,6 +57,7 @@ import org.springframework.web.method.support.HandlerMethodArgumentResolverCompo
|
||||
import org.springframework.web.method.support.HandlerMethodReturnValueHandler;
|
||||
import org.springframework.web.method.support.HandlerMethodReturnValueHandlerComposite;
|
||||
import org.springframework.web.method.support.ModelAndViewContainer;
|
||||
import org.springframework.web.servlet.HandlerMapping;
|
||||
import org.springframework.web.servlet.ModelAndView;
|
||||
import org.springframework.web.servlet.View;
|
||||
import org.springframework.web.servlet.handler.AbstractHandlerMethodExceptionResolver;
|
||||
@@ -73,6 +78,7 @@ import org.springframework.web.util.DisconnectedClientHelper;
|
||||
* @author Rossen Stoyanchev
|
||||
* @author Juergen Hoeller
|
||||
* @author Sebastien Deleuze
|
||||
* @author Brian Clozel
|
||||
* @since 3.1
|
||||
*/
|
||||
public class ExceptionHandlerExceptionResolver extends AbstractHandlerMethodExceptionResolver
|
||||
@@ -425,7 +431,9 @@ public class ExceptionHandlerExceptionResolver extends AbstractHandlerMethodExce
|
||||
protected ModelAndView doResolveHandlerMethodException(HttpServletRequest request,
|
||||
HttpServletResponse response, @Nullable HandlerMethod handlerMethod, Exception exception) {
|
||||
|
||||
ServletInvocableHandlerMethod exceptionHandlerMethod = getExceptionHandlerMethod(handlerMethod, exception);
|
||||
ServletWebRequest webRequest = new ServletWebRequest(request, response);
|
||||
ServletInvocableHandlerMethod exceptionHandlerMethod = getExceptionHandlerMethod(handlerMethod, exception, webRequest);
|
||||
|
||||
if (exceptionHandlerMethod == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -437,7 +445,6 @@ public class ExceptionHandlerExceptionResolver extends AbstractHandlerMethodExce
|
||||
exceptionHandlerMethod.setHandlerMethodReturnValueHandlers(this.returnValueHandlers);
|
||||
}
|
||||
|
||||
ServletWebRequest webRequest = new ServletWebRequest(request, response);
|
||||
ModelAndViewContainer mavContainer = new ModelAndViewContainer();
|
||||
|
||||
ArrayList<Throwable> exceptions = new ArrayList<>();
|
||||
@@ -497,11 +504,22 @@ public class ExceptionHandlerExceptionResolver extends AbstractHandlerMethodExce
|
||||
* Spring-managed beans were detected.
|
||||
* @param handlerMethod the method where the exception was raised (may be {@code null})
|
||||
* @param exception the raised exception
|
||||
* @param webRequest the original web request that resulted in a handler error
|
||||
* @return a method to handle the exception, or {@code null} if none
|
||||
*/
|
||||
@Nullable
|
||||
protected ServletInvocableHandlerMethod getExceptionHandlerMethod(
|
||||
@Nullable HandlerMethod handlerMethod, Exception exception) {
|
||||
@Nullable HandlerMethod handlerMethod, Exception exception, ServletWebRequest webRequest) {
|
||||
|
||||
List<MediaType> acceptedMediaTypes = List.of(MediaType.ALL);
|
||||
try {
|
||||
acceptedMediaTypes = this.contentNegotiationManager.resolveMediaTypes(webRequest);
|
||||
}
|
||||
catch (HttpMediaTypeNotAcceptableException mediaTypeExc) {
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("Could not resolve accepted media types for @ExceptionHandler [" + webRequest.getHeader(HttpHeaders.ACCEPT) + "]", mediaTypeExc);
|
||||
}
|
||||
}
|
||||
|
||||
Class<?> handlerType = null;
|
||||
|
||||
@@ -511,9 +529,15 @@ public class ExceptionHandlerExceptionResolver extends AbstractHandlerMethodExce
|
||||
handlerType = handlerMethod.getBeanType();
|
||||
ExceptionHandlerMethodResolver resolver = this.exceptionHandlerCache.computeIfAbsent(
|
||||
handlerType, ExceptionHandlerMethodResolver::new);
|
||||
Method method = resolver.resolveMethod(exception);
|
||||
if (method != null) {
|
||||
return new ServletInvocableHandlerMethod(handlerMethod.getBean(), method, this.applicationContext);
|
||||
|
||||
for (MediaType mediaType : acceptedMediaTypes) {
|
||||
ExceptionHandlerMappingInfo mappingInfo = resolver.resolveExceptionMapping(exception, mediaType);
|
||||
if (mappingInfo != null) {
|
||||
if (!mappingInfo.getProducibleTypes().isEmpty()) {
|
||||
webRequest.setAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE, mappingInfo.getProducibleTypes(), RequestAttributes.SCOPE_REQUEST);
|
||||
}
|
||||
return new ServletInvocableHandlerMethod(handlerMethod.getBean(), mappingInfo.getHandlerMethod(), this.applicationContext);
|
||||
}
|
||||
}
|
||||
// For advice applicability check below (involving base packages, assignable types
|
||||
// and annotation presence), use target class instead of interface-based proxy.
|
||||
@@ -526,9 +550,14 @@ public class ExceptionHandlerExceptionResolver extends AbstractHandlerMethodExce
|
||||
ControllerAdviceBean advice = entry.getKey();
|
||||
if (advice.isApplicableToBeanType(handlerType)) {
|
||||
ExceptionHandlerMethodResolver resolver = entry.getValue();
|
||||
Method method = resolver.resolveMethod(exception);
|
||||
if (method != null) {
|
||||
return new ServletInvocableHandlerMethod(advice.resolveBean(), method, this.applicationContext);
|
||||
for (MediaType mediaType : acceptedMediaTypes) {
|
||||
ExceptionHandlerMappingInfo mappingInfo = resolver.resolveExceptionMapping(exception, mediaType);
|
||||
if (mappingInfo != null) {
|
||||
if (!mappingInfo.getProducibleTypes().isEmpty()) {
|
||||
webRequest.setAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE, mappingInfo.getProducibleTypes(), RequestAttributes.SCOPE_REQUEST);
|
||||
}
|
||||
return new ServletInvocableHandlerMethod(advice.resolveBean(), mappingInfo.getHandlerMethod(), this.applicationContext);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/**
|
||||
* Test fixture with {@link ExceptionHandlerExceptionResolver}.
|
||||
* Tests for {@link ExceptionHandlerExceptionResolver}.
|
||||
*
|
||||
* @author Rossen Stoyanchev
|
||||
* @author Arjen Poutsma
|
||||
@@ -419,6 +419,41 @@ class ExceptionHandlerExceptionResolverTests {
|
||||
assertThat(mav.isEmpty()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveExceptionJsonMediaType() throws UnsupportedEncodingException, NoSuchMethodException {
|
||||
IllegalArgumentException ex = new IllegalArgumentException();
|
||||
HandlerMethod handlerMethod = new HandlerMethod(new MediaTypeController(), "handle");
|
||||
this.resolver.afterPropertiesSet();
|
||||
this.request.addHeader("Accept", "application/json");
|
||||
ModelAndView mav = this.resolver.resolveException(this.request, this.response, handlerMethod, ex);
|
||||
|
||||
assertExceptionHandledAsBody(mav, "jsonBody");
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveExceptionHtmlMediaType() throws NoSuchMethodException {
|
||||
IllegalArgumentException ex = new IllegalArgumentException();
|
||||
HandlerMethod handlerMethod = new HandlerMethod(new MediaTypeController(), "handle");
|
||||
this.resolver.afterPropertiesSet();
|
||||
this.request.addHeader("Accept", "text/html");
|
||||
ModelAndView mav = this.resolver.resolveException(this.request, this.response, handlerMethod, ex);
|
||||
|
||||
assertThat(mav).isNotNull();
|
||||
assertThat(mav.getViewName()).isEqualTo("htmlView");
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveExceptionDefaultMediaType() throws NoSuchMethodException {
|
||||
IllegalArgumentException ex = new IllegalArgumentException();
|
||||
HandlerMethod handlerMethod = new HandlerMethod(new MediaTypeController(), "handle");
|
||||
this.resolver.afterPropertiesSet();
|
||||
this.request.addHeader("Accept", "*/*");
|
||||
ModelAndView mav = this.resolver.resolveException(this.request, this.response, handlerMethod, ex);
|
||||
|
||||
assertThat(mav).isNotNull();
|
||||
assertThat(mav.getViewName()).isEqualTo("htmlView");
|
||||
}
|
||||
|
||||
|
||||
private void assertMethodProcessorCount(int resolverCount, int handlerCount) {
|
||||
assertThat(this.resolver.getArgumentResolvers().getResolvers()).hasSize(resolverCount);
|
||||
@@ -650,4 +685,21 @@ class ExceptionHandlerExceptionResolverTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Controller
|
||||
static class MediaTypeController {
|
||||
|
||||
public void handle() {}
|
||||
|
||||
@ExceptionHandler(exception = IllegalArgumentException.class, produces = "application/json")
|
||||
@ResponseBody
|
||||
public String handleExceptionJson() {
|
||||
return "jsonBody";
|
||||
}
|
||||
|
||||
@ExceptionHandler(exception = IllegalArgumentException.class, produces = {"text/html", "*/*"})
|
||||
public String handleExceptionHtml() {
|
||||
return "htmlView";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user