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

@@ -37,6 +37,7 @@ import org.springframework.validation.Validator;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
import org.springframework.web.ErrorResponse;
import org.springframework.web.bind.support.ConfigurableWebBindingInitializer;
import org.springframework.web.reactive.accept.RequestedContentTypeResolver;
import org.springframework.web.reactive.accept.RequestedContentTypeResolverBuilder;
import org.springframework.web.reactive.result.method.annotation.ResponseBodyResultHandler;
import org.springframework.web.reactive.socket.server.WebSocketService;
@@ -98,11 +99,12 @@ public class DelegatingWebFluxConfigurationTests {
ReactiveAdapterRegistry reactiveAdapterRegistry = delegatingConfig.webFluxAdapterRegistry();
ServerCodecConfigurer serverCodecConfigurer = delegatingConfig.serverCodecConfigurer();
FormattingConversionService formattingConversionService = delegatingConfig.webFluxConversionService();
RequestedContentTypeResolver requestedContentTypeResolver = delegatingConfig.webFluxContentTypeResolver();
Validator validator = delegatingConfig.webFluxValidator();
ConfigurableWebBindingInitializer initializer = (ConfigurableWebBindingInitializer)
this.delegatingConfig.requestMappingHandlerAdapter(reactiveAdapterRegistry, serverCodecConfigurer,
formattingConversionService, validator).getWebBindingInitializer();
formattingConversionService, requestedContentTypeResolver, validator).getWebBindingInitializer();
verify(webFluxConfigurer).configureHttpMessageCodecs(codecsConfigurer.capture());
verify(webFluxConfigurer).getValidator();

View File

@@ -18,6 +18,7 @@ package org.springframework.web.reactive.result.method.annotation;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.BeforeEach;
@@ -30,6 +31,7 @@ import org.springframework.core.ReactiveAdapterRegistry;
import org.springframework.core.codec.ByteArrayDecoder;
import org.springframework.core.codec.ByteBufferDecoder;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerCodecConfigurer;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.ControllerAdvice;
@@ -39,13 +41,17 @@ import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.reactive.BindingContext;
import org.springframework.web.reactive.HandlerMapping;
import org.springframework.web.reactive.accept.RequestedContentTypeResolverBuilder;
import org.springframework.web.reactive.result.method.HandlerMethodArgumentResolver;
import org.springframework.web.reactive.result.method.InvocableHandlerMethod;
import org.springframework.web.reactive.result.method.SyncHandlerMethodArgumentResolver;
import org.springframework.web.reactive.result.method.SyncInvocableHandlerMethod;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest;
import org.springframework.web.testfixture.method.ResolvableMethod;
import org.springframework.web.testfixture.server.MockServerWebExchange;
import static org.assertj.core.api.Assertions.assertThat;
@@ -77,7 +83,8 @@ class ControllerMethodResolverTests {
this.methodResolver = new ControllerMethodResolver(
resolvers, ReactiveAdapterRegistry.getSharedInstance(), applicationContext,
codecs.getReaders(), null, null, null);
new RequestedContentTypeResolverBuilder().build(), codecs.getReaders(),
null, null, null);
Method method = ResolvableMethod.on(TestController.class).mockCall(TestController::handle).method();
this.handlerMethod = new HandlerMethod(new TestController(), method);
@@ -191,8 +198,9 @@ class ControllerMethodResolverTests {
@Test
void exceptionHandlerArgumentResolvers() {
MockServerWebExchange serverWebExchange = MockServerWebExchange.builder(MockServerHttpRequest.get("/test").build()).build();
InvocableHandlerMethod invocable = this.methodResolver.getExceptionHandlerMethod(
new ResponseStatusException(HttpStatus.BAD_REQUEST, "reason"), this.handlerMethod);
new ResponseStatusException(HttpStatus.BAD_REQUEST, "reason"), serverWebExchange, this.handlerMethod);
assertThat(invocable).as("No match").isNotNull();
assertThat(invocable.getBeanType()).isEqualTo(TestController.class);
@@ -226,13 +234,30 @@ class ControllerMethodResolverTests {
@Test
void exceptionHandlerFromControllerAdvice() {
MockServerWebExchange serverWebExchange = MockServerWebExchange.builder(MockServerHttpRequest.get("/test").build()).build();
InvocableHandlerMethod invocable = this.methodResolver.getExceptionHandlerMethod(
new IllegalStateException("reason"), this.handlerMethod);
new IllegalStateException("reason"), serverWebExchange, this.handlerMethod);
assertThat(invocable).isNotNull();
assertThat(invocable.getBeanType()).isEqualTo(TestControllerAdvice.class);
}
@Test
void exceptionHandlerWithMediaType() {
Method method = ResolvableMethod.on(ExceptionHandlerController.class).mockCall(ExceptionHandlerController::handle).method();
this.handlerMethod = new HandlerMethod(new ExceptionHandlerController(), method);
MockServerHttpRequest httpRequest = MockServerHttpRequest.get("/test").accept(MediaType.APPLICATION_JSON).build();
MockServerWebExchange serverWebExchange = MockServerWebExchange.builder(httpRequest).build();
InvocableHandlerMethod invocable = this.methodResolver.getExceptionHandlerMethod(
new ResponseStatusException(HttpStatus.BAD_REQUEST, "reason"), serverWebExchange, this.handlerMethod);
assertThat(invocable).as("No match").isNotNull();
assertThat(invocable.getBeanType()).isEqualTo(ExceptionHandlerController.class);
assertThat(invocable.getMethod().getName()).isEqualTo("handleExceptionJson");
Set<MediaType> producibleMediaTypes = serverWebExchange.getAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE);
assertThat(producibleMediaTypes).isNotEmpty().contains(MediaType.APPLICATION_JSON);
}
private static HandlerMethodArgumentResolver next(
List<? extends HandlerMethodArgumentResolver> resolvers, AtomicInteger index) {
@@ -273,6 +298,20 @@ class ControllerMethodResolverTests {
}
@Controller
static class ExceptionHandlerController {
@GetMapping
void handle() {}
@ExceptionHandler(produces = "text/html")
void handleExceptionHtml(ResponseStatusException ex) {}
@ExceptionHandler(produces = "application/json")
void handleExceptionJson(ResponseStatusException ex) {}
}
static class CustomArgumentResolver implements HandlerMethodArgumentResolver {

View File

@@ -45,6 +45,7 @@ import org.springframework.web.bind.support.ConfigurableWebBindingInitializer;
import org.springframework.web.bind.support.WebBindingInitializer;
import org.springframework.web.bind.support.WebExchangeDataBinder;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.reactive.accept.RequestedContentTypeResolverBuilder;
import org.springframework.web.reactive.result.method.SyncInvocableHandlerMethod;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebSession;
@@ -80,7 +81,8 @@ class ModelInitializerTests {
ControllerMethodResolver methodResolver = new ControllerMethodResolver(
resolverConfigurer, adapterRegistry, new StaticApplicationContext(),
Collections.emptyList(), null, null, null);
new RequestedContentTypeResolverBuilder().build(), Collections.emptyList(),
null, null, null);
this.modelInitializer = new ModelInitializer(methodResolver, adapterRegistry);
}

View File

@@ -27,6 +27,7 @@ import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.ModelAttribute
import org.springframework.web.bind.support.ConfigurableWebBindingInitializer
import org.springframework.web.method.HandlerMethod
import org.springframework.web.reactive.accept.RequestedContentTypeResolverBuilder
import org.springframework.web.server.ServerWebExchange
import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest
import org.springframework.web.testfixture.method.ResolvableMethod
@@ -52,8 +53,10 @@ class ModelInitializerKotlinTests {
val adapterRegistry = ReactiveAdapterRegistry.getSharedInstance()
val resolverConfigurer = ArgumentResolverConfigurer()
resolverConfigurer.addCustomResolver(ModelMethodArgumentResolver(adapterRegistry))
val methodResolver = ControllerMethodResolver(resolverConfigurer, adapterRegistry, StaticApplicationContext(),
emptyList(), null, null, null)
val methodResolver = ControllerMethodResolver(
resolverConfigurer, adapterRegistry, StaticApplicationContext(),
RequestedContentTypeResolverBuilder().build(), emptyList(), null, null, null
)
modelInitializer = ModelInitializer(methodResolver, adapterRegistry)
}