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:
Brian Clozel
2024-05-20 17:22:30 +02:00
parent 991be14847
commit 4d4b343815
25 changed files with 984 additions and 212 deletions

View File

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

View File

@@ -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";
}
}
}