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:
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user