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

@@ -25,6 +25,7 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
import org.springframework.util.ClassUtils;
import org.springframework.web.bind.annotation.ExceptionHandler;
@@ -33,28 +34,29 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
/**
* Test fixture for {@link ExceptionHandlerMethodResolver} tests.
* Tests for {@link ExceptionHandlerMethodResolver}.
*
* @author Rossen Stoyanchev
* @author Brian Clozel
*/
class ExceptionHandlerMethodResolverTests {
@Test
void resolveMethodFromAnnotation() {
void shouldResolveMethodFromAnnotationAttribute() {
ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(ExceptionController.class);
IOException exception = new IOException();
assertThat(resolver.resolveMethod(exception).getName()).isEqualTo("handleIOException");
}
@Test
void resolveMethodFromArgument() {
void shouldResolveMethodFromMethodArgument() {
ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(ExceptionController.class);
IllegalArgumentException exception = new IllegalArgumentException();
assertThat(resolver.resolveMethod(exception).getName()).isEqualTo("handleIllegalArgumentException");
}
@Test
void resolveMethodExceptionSubType() {
void shouldResolveMethodWithExceptionSubType() {
ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(ExceptionController.class);
IOException ioException = new FileNotFoundException();
assertThat(resolver.resolveMethod(ioException).getName()).isEqualTo("handleIOException");
@@ -63,14 +65,14 @@ class ExceptionHandlerMethodResolverTests {
}
@Test
void resolveMethodBestMatch() {
void shouldResolveMethodWithExceptionBestMatch() {
ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(ExceptionController.class);
SocketException exception = new SocketException();
assertThat(resolver.resolveMethod(exception).getName()).isEqualTo("handleSocketException");
}
@Test
void resolveMethodNoMatch() {
void shouldNotResolveMethodWhenExceptionNoMatch() {
ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(ExceptionController.class);
Exception exception = new Exception();
assertThat(resolver.resolveMethod(exception)).as("1st lookup").isNull();
@@ -78,7 +80,7 @@ class ExceptionHandlerMethodResolverTests {
}
@Test
void resolveMethodExceptionCause() {
void ShouldResolveMethodWithExceptionCause() {
ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(ExceptionController.class);
SocketException bindException = new BindException();
@@ -90,24 +92,65 @@ class ExceptionHandlerMethodResolverTests {
}
@Test
void resolveMethodInherited() {
void shouldResolveMethodFromSuperClass() {
ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(InheritedController.class);
IOException exception = new IOException();
assertThat(resolver.resolveMethod(exception).getName()).isEqualTo("handleIOException");
}
@Test
void ambiguousExceptionMapping() {
void shouldThrowExceptionWhenAmbiguousExceptionMapping() {
assertThatIllegalStateException().isThrownBy(() ->
new ExceptionHandlerMethodResolver(AmbiguousController.class));
}
@Test
void noExceptionMapping() {
void shouldThrowExceptionWhenNoExceptionMapping() {
assertThatIllegalStateException().isThrownBy(() ->
new ExceptionHandlerMethodResolver(NoExceptionController.class));
}
@Test
void shouldResolveMethodWithMediaType() {
ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(MediaTypeController.class);
assertThat(resolver.resolveExceptionMapping(new IllegalArgumentException(), MediaType.APPLICATION_JSON).getHandlerMethod().getName()).isEqualTo("handleJson");
assertThat(resolver.resolveExceptionMapping(new IllegalArgumentException(), MediaType.TEXT_HTML).getHandlerMethod().getName()).isEqualTo("handleHtml");
}
@Test
void shouldResolveMethodWithCompatibleMediaType() {
ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(MediaTypeController.class);
assertThat(resolver.resolveExceptionMapping(new IllegalArgumentException(), MediaType.parseMediaType("application/*")).getHandlerMethod().getName()).isEqualTo("handleJson");
}
@Test
void shouldFavorMethodWithExplicitAcceptAll() {
ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(MediaTypeController.class);
assertThat(resolver.resolveExceptionMapping(new IllegalArgumentException(), MediaType.ALL).getHandlerMethod().getName()).isEqualTo("handleHtml");
}
@Test
void shouldThrowExceptionWhenInvalidMediaTypeMapping() {
assertThatIllegalStateException().isThrownBy(() ->
new ExceptionHandlerMethodResolver(InvalidMediaTypeController.class))
.withMessageContaining("Invalid media type [invalid-mediatype] declared on @ExceptionHandler");
}
@Test
void shouldThrowExceptionWhenAmbiguousMediaTypeMapping() {
assertThatIllegalStateException().isThrownBy(() ->
new ExceptionHandlerMethodResolver(AmbiguousMediaTypeController.class))
.withMessageContaining("Ambiguous @ExceptionHandler method mapped for [ExceptionHandler{exceptionType=java.lang.IllegalArgumentException, mediaType=application/json}]")
.withMessageContaining("AmbiguousMediaTypeController.handleJson()")
.withMessageContaining("AmbiguousMediaTypeController.handleJsonToo()");
}
@Test
void shouldResolveMethodWithMediaTypeFallback() {
ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(MixedController.class);
assertThat(resolver.resolveExceptionMapping(new IllegalArgumentException(), MediaType.TEXT_HTML).getHandlerMethod().getName()).isEqualTo("handleOther");
}
@Controller
static class ExceptionController {
@@ -162,4 +205,58 @@ class ExceptionHandlerMethodResolverTests {
}
}
@Controller
static class MediaTypeController {
@ExceptionHandler(exception = {IllegalArgumentException.class}, produces = "application/json")
public void handleJson() {
}
@ExceptionHandler(exception = {IllegalArgumentException.class}, produces = {"text/html", "*/*"})
public void handleHtml() {
}
}
@Controller
static class AmbiguousMediaTypeController {
@ExceptionHandler(exception = {IllegalArgumentException.class}, produces = "application/json")
public void handleJson() {
}
@ExceptionHandler(exception = {IllegalArgumentException.class}, produces = "application/json")
public void handleJsonToo() {
}
}
@Controller
static class MixedController {
@ExceptionHandler(exception = {IllegalArgumentException.class}, produces = "application/json")
public void handleJson() {
}
@ExceptionHandler(IllegalArgumentException.class)
public void handleOther() {
}
}
@Controller
static class InvalidMediaTypeController {
@ExceptionHandler(exception = {IllegalArgumentException.class}, produces = "invalid-mediatype")
public void handle() {
}
}
}