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

@@ -283,12 +283,14 @@ public class WebFluxConfigurationSupport implements ApplicationContextAware {
@Qualifier("webFluxAdapterRegistry") ReactiveAdapterRegistry reactiveAdapterRegistry,
ServerCodecConfigurer serverCodecConfigurer,
@Qualifier("webFluxConversionService") FormattingConversionService conversionService,
@Qualifier("webFluxContentTypeResolver") RequestedContentTypeResolver contentTypeResolver,
@Qualifier("webFluxValidator") Validator validator) {
RequestMappingHandlerAdapter adapter = createRequestMappingHandlerAdapter();
adapter.setMessageReaders(serverCodecConfigurer.getReaders());
adapter.setWebBindingInitializer(getConfigurableWebBindingInitializer(conversionService, validator));
adapter.setReactiveAdapterRegistry(reactiveAdapterRegistry);
adapter.setContentTypeResolver(contentTypeResolver);
BlockingExecutionConfigurer executorConfigurer = getBlockingExecutionConfigurer();
if (executorConfigurer.getExecutor() != null) {

View File

@@ -38,6 +38,7 @@ import org.springframework.core.MethodIntrospector;
import org.springframework.core.MethodParameter;
import org.springframework.core.ReactiveAdapterRegistry;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.http.MediaType;
import org.springframework.http.codec.HttpMessageReader;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
@@ -50,12 +51,16 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.support.WebBindingInitializer;
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.HandlerMethodValidator;
import org.springframework.web.reactive.HandlerMapping;
import org.springframework.web.reactive.accept.RequestedContentTypeResolver;
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.ServerWebExchange;
/**
* Package-private class to assist {@link RequestMappingHandlerAdapter} with
@@ -104,6 +109,8 @@ class ControllerMethodResolver {
private final ReactiveAdapterRegistry reactiveAdapterRegistry;
private final RequestedContentTypeResolver contentTypeResolver;
@Nullable
private final Scheduler invocationScheduler;
@@ -129,16 +136,17 @@ class ControllerMethodResolver {
private final Map<Class<?>, SessionAttributesHandler> sessionAttributesHandlerCache = new ConcurrentHashMap<>(64);
ControllerMethodResolver(
ArgumentResolverConfigurer customResolvers, ReactiveAdapterRegistry adapterRegistry,
ConfigurableApplicationContext context, List<HttpMessageReader<?>> readers,
@Nullable WebBindingInitializer webBindingInitializer,
@Nullable Scheduler invocationScheduler,
@Nullable Predicate<? super HandlerMethod> blockingMethodPredicate) {
ConfigurableApplicationContext context, RequestedContentTypeResolver contentTypeResolver,
List<HttpMessageReader<?>> readers, @Nullable WebBindingInitializer webBindingInitializer,
@Nullable Scheduler invocationScheduler, @Nullable Predicate<? super HandlerMethod> blockingMethodPredicate) {
Assert.notNull(customResolvers, "ArgumentResolverConfigurer is required");
Assert.notNull(adapterRegistry, "ReactiveAdapterRegistry is required");
Assert.notNull(context, "ApplicationContext is required");
Assert.notNull(contentTypeResolver, "RequestedContentTypeResolver is required");
Assert.notNull(readers, "HttpMessageReader List is required");
this.initBinderResolvers = initBinderResolvers(customResolvers, adapterRegistry, context);
@@ -146,6 +154,7 @@ class ControllerMethodResolver {
this.requestMappingResolvers = requestMappingResolvers(customResolvers, adapterRegistry, context, readers);
this.exceptionHandlerResolvers = exceptionHandlerResolvers(customResolvers, adapterRegistry, context);
this.reactiveAdapterRegistry = adapterRegistry;
this.contentTypeResolver = contentTypeResolver;
this.invocationScheduler = invocationScheduler;
this.blockingMethodPredicate = blockingMethodPredicate;
@@ -398,44 +407,53 @@ class ControllerMethodResolver {
* controller method, and also within {@code @ControllerAdvice} classes that
* are applicable to the class of the given controller method.
* @param ex the exception to find a handler for
* @param handlerMethod the controller method that raised the exception, or
* if {@code null}, check only {@code @ControllerAdvice} classes.
* @param exchange the current HTTP exchange
* @param handlerMethod the controller method that raised the exception,
* or if {@code null}, check only {@code @ControllerAdvice} classes.
*/
@Nullable
@SuppressWarnings("NullAway")
public InvocableHandlerMethod getExceptionHandlerMethod(Throwable ex, @Nullable HandlerMethod handlerMethod) {
public InvocableHandlerMethod getExceptionHandlerMethod(Throwable ex, ServerWebExchange exchange, @Nullable HandlerMethod handlerMethod) {
Class<?> handlerType = (handlerMethod != null ? handlerMethod.getBeanType() : null);
Object exceptionHandlerObject = null;
Method exceptionHandlerMethod = null;
List<MediaType> requestedMediaTypes = this.contentTypeResolver.resolveMediaTypes(exchange);
// Controller-local first
if (handlerType != null) {
// Controller-local first...
exceptionHandlerObject = handlerMethod.getBean();
exceptionHandlerMethod = this.exceptionHandlerCache
.computeIfAbsent(handlerType, ExceptionHandlerMethodResolver::new)
.resolveMethodByThrowable(ex);
for (MediaType mediaType : requestedMediaTypes) {
ExceptionHandlerMappingInfo mappingInfo = this.exceptionHandlerCache
.computeIfAbsent(handlerType, ExceptionHandlerMethodResolver::new)
.resolveExceptionMapping(ex, mediaType);
if (mappingInfo != null) {
if (!mappingInfo.getProducibleTypes().isEmpty()) {
exchange.getAttributes().put(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE, mappingInfo.getProducibleTypes());
}
return createInvocableHandlerMethod(handlerMethod.getBean(), mappingInfo.getHandlerMethod());
}
}
}
if (exceptionHandlerMethod == null) {
// Global exception handlers...
// Global exception handlers
for (MediaType mediaType : requestedMediaTypes) {
for (Map.Entry<ControllerAdviceBean, ExceptionHandlerMethodResolver> entry : this.exceptionHandlerAdviceCache.entrySet()) {
ControllerAdviceBean advice = entry.getKey();
if (advice.isApplicableToBeanType(handlerType)) {
exceptionHandlerMethod = entry.getValue().resolveMethodByThrowable(ex);
if (exceptionHandlerMethod != null) {
exceptionHandlerObject = advice.resolveBean();
break;
ExceptionHandlerMappingInfo mappingInfo = entry.getValue().resolveExceptionMapping(ex, mediaType);
if (mappingInfo != null) {
if (!mappingInfo.getProducibleTypes().isEmpty()) {
exchange.getAttributes().put(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE, mappingInfo.getProducibleTypes());
}
return createInvocableHandlerMethod(advice.resolveBean(), mappingInfo.getHandlerMethod());
}
}
}
}
if (exceptionHandlerObject == null || exceptionHandlerMethod == null) {
return null;
}
return null;
}
InvocableHandlerMethod invocable = new InvocableHandlerMethod(exceptionHandlerObject, exceptionHandlerMethod);
private InvocableHandlerMethod createInvocableHandlerMethod(Object bean, Method method) {
InvocableHandlerMethod invocable = new InvocableHandlerMethod(bean, method);
invocable.setArgumentResolvers(this.exceptionHandlerResolvers);
return invocable;
}

View File

@@ -46,6 +46,8 @@ import org.springframework.web.reactive.DispatchExceptionHandler;
import org.springframework.web.reactive.HandlerAdapter;
import org.springframework.web.reactive.HandlerMapping;
import org.springframework.web.reactive.HandlerResult;
import org.springframework.web.reactive.accept.RequestedContentTypeResolver;
import org.springframework.web.reactive.accept.RequestedContentTypeResolverBuilder;
import org.springframework.web.reactive.result.method.InvocableHandlerMethod;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.util.DisconnectedClientHelper;
@@ -56,6 +58,7 @@ import org.springframework.web.util.DisconnectedClientHelper;
* handler methods.
*
* @author Rossen Stoyanchev
* @author Brian Clozel
* @since 5.0
*/
public class RequestMappingHandlerAdapter
@@ -82,6 +85,8 @@ public class RequestMappingHandlerAdapter
@Nullable
private ArgumentResolverConfigurer argumentResolverConfigurer;
private RequestedContentTypeResolver contentTypeResolver = new RequestedContentTypeResolverBuilder().build();
@Nullable
private Scheduler scheduler;
@@ -148,6 +153,24 @@ public class RequestMappingHandlerAdapter
return this.argumentResolverConfigurer;
}
/**
* Set the {@link RequestedContentTypeResolver} to use to determine requested
* media types. If not set, the default constructor is used.
* @since 6.2.0
*/
public void setContentTypeResolver(RequestedContentTypeResolver contentTypeResolver) {
Assert.notNull(contentTypeResolver, "'contentTypeResolver' must not be null");
this.contentTypeResolver = contentTypeResolver;
}
/**
* Return the configured {@link RequestedContentTypeResolver}.
* @since 6.2.0
*/
public RequestedContentTypeResolver getContentTypeResolver() {
return this.contentTypeResolver;
}
/**
* Configure an executor to invoke blocking controller methods with.
* <p>By default, this is not set in which case controller methods are
@@ -225,7 +248,7 @@ public class RequestMappingHandlerAdapter
this.methodResolver = new ControllerMethodResolver(
this.argumentResolverConfigurer, this.reactiveAdapterRegistry, this.applicationContext,
this.messageReaders, this.webBindingInitializer,
this.contentTypeResolver, this.messageReaders, this.webBindingInitializer,
this.scheduler, this.blockingMethodPredicate);
this.modelInitializer = new ModelInitializer(this.methodResolver, this.reactiveAdapterRegistry);
@@ -280,7 +303,7 @@ public class RequestMappingHandlerAdapter
exchange.getResponse().getHeaders().clearContentHeaders();
InvocableHandlerMethod invocable =
this.methodResolver.getExceptionHandlerMethod(exception, handlerMethod);
this.methodResolver.getExceptionHandlerMethod(exception, exchange, handlerMethod);
if (invocable != null) {
ArrayList<Throwable> exceptions = new ArrayList<>();

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