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

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2022 the original author or authors.
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -23,6 +23,7 @@ import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.aot.hint.annotation.Reflective;
import org.springframework.core.annotation.AliasFor;
/**
* Annotation for handling exceptions in specific handler classes and/or
@@ -101,6 +102,7 @@ import org.springframework.aot.hint.annotation.Reflective;
*
* @author Arjen Poutsma
* @author Juergen Hoeller
* @author Brian Clozel
* @since 3.0
* @see ControllerAdvice
* @see org.springframework.web.context.request.WebRequest
@@ -112,9 +114,24 @@ import org.springframework.aot.hint.annotation.Reflective;
public @interface ExceptionHandler {
/**
* Exceptions handled by the annotated method. If empty, will default to any
* exceptions listed in the method argument list.
* Exceptions handled by the annotated method.
* <p>This is an alias for {@link #exception}.
*/
@AliasFor("exception")
Class<? extends Throwable>[] value() default {};
/**
* Exceptions handled by the annotated method. If empty, will default to any
* exceptions listed in the method argument list.
* @since 6.2.0
*/
@AliasFor("value")
Class<? extends Throwable>[] exception() default {};
/**
* Media Types that can be produced by the annotated method.
* @since 6.2.0
*/
String[] produces() default {};
}

View File

@@ -0,0 +1,73 @@
/*
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.web.method.annotation;
import java.lang.reflect.Method;
import java.util.Set;
import org.springframework.http.MediaType;
import org.springframework.util.Assert;
/**
* {@code @ExceptionHandler} mapping information. It contains:
* <ul>
* <li>the supported exception types
* <li>the producible media types, if any
* <li>the method in charge of handling the exception
* </ul>
* @author Brian Clozel
* @since 6.2.0
*/
public class ExceptionHandlerMappingInfo {
private final Set<Class<? extends Throwable>> exceptionTypes;
private final Set<MediaType> producibleTypes;
private final Method handlerMethod;
ExceptionHandlerMappingInfo(Set<Class<? extends Throwable>> exceptionTypes, Set<MediaType> producibleMediaTypes, Method handlerMethod) {
Assert.notNull(exceptionTypes, "exceptionTypes should not be null");
Assert.notNull(producibleMediaTypes, "producibleMediaTypes should not be null");
Assert.notNull(handlerMethod, "handlerMethod should not be null");
this.exceptionTypes = exceptionTypes;
this.producibleTypes = producibleMediaTypes;
this.handlerMethod = handlerMethod;
}
/**
* Return the method responsible for handling the exception.
*/
public Method getHandlerMethod() {
return this.handlerMethod;
}
/**
* Return the exception types supported by this handler.
*/
public Set<Class<? extends Throwable>> getExceptionTypes() {
return this.exceptionTypes;
}
/**
* Return the producible media types by this handler. Can be empty.
*/
public Set<MediaType> getProducibleTypes() {
return this.producibleTypes;
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2021 the original author or authors.
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -19,27 +19,42 @@ package org.springframework.web.method.annotation;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.core.ExceptionDepthComparator;
import org.springframework.core.MethodIntrospector;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.http.InvalidMediaTypeException;
import org.springframework.http.MediaType;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ConcurrentReferenceHashMap;
import org.springframework.util.ConcurrentLruCache;
import org.springframework.util.MimeType;
import org.springframework.util.ReflectionUtils.MethodFilter;
import org.springframework.web.bind.annotation.ExceptionHandler;
/**
* Discovers {@linkplain ExceptionHandler @ExceptionHandler} methods in a given class,
* including all of its superclasses, and helps to resolve a given {@link Exception}
* to the exception types supported by a given {@link Method}.
* and {@link MediaType} requested by clients to combinations supported by a given {@link Method}.
* <p>This resolver will use the exception information declared as {@code @ExceptionHandler}
* annotation attributes, or as a method argument as a fallback. This will throw
* {@code IllegalStateException} instances if:
* <ul>
* <li>No Exception information could be found for a method
* <li>An invalid {@link MediaType} has been declared as {@code @ExceptionHandler} attribute
* <li>Multiple handlers declare the same exception + media type mapping
* </ul>
*
* @author Rossen Stoyanchev
* @author Juergen Hoeller
* @author Sam Brannen
* @author Brian Clozel
* @since 3.1
*/
public class ExceptionHandlerMethodResolver {
@@ -47,35 +62,42 @@ public class ExceptionHandlerMethodResolver {
/**
* A filter for selecting {@code @ExceptionHandler} methods.
*/
public static final MethodFilter EXCEPTION_HANDLER_METHODS = method ->
private static final MethodFilter EXCEPTION_HANDLER_METHODS = method ->
AnnotatedElementUtils.hasAnnotation(method, ExceptionHandler.class);
private static final Method NO_MATCHING_EXCEPTION_HANDLER_METHOD;
private static final ExceptionHandlerMappingInfo NO_MATCHING_EXCEPTION_HANDLER;
static {
try {
NO_MATCHING_EXCEPTION_HANDLER_METHOD =
ExceptionHandlerMethodResolver.class.getDeclaredMethod("noMatchingExceptionHandler");
NO_MATCHING_EXCEPTION_HANDLER = new ExceptionHandlerMappingInfo(Set.of(), Set.of(),
ExceptionHandlerMethodResolver.class.getDeclaredMethod("noMatchingExceptionHandler"));
}
catch (NoSuchMethodException ex) {
throw new IllegalStateException("Expected method not found: " + ex);
}
}
private final Map<ExceptionMapping, ExceptionHandlerMappingInfo> mappedMethods = new HashMap<>(16);
private final Map<Class<? extends Throwable>, Method> mappedMethods = new HashMap<>(16);
private final Map<Class<? extends Throwable>, Method> exceptionLookupCache = new ConcurrentReferenceHashMap<>(16);
private final ConcurrentLruCache<ExceptionMapping, ExceptionHandlerMappingInfo> lookupCache = new ConcurrentLruCache<>(24,
cacheKey -> getMappedMethod(cacheKey.exceptionType(), cacheKey.mediaType()));
/**
* A constructor that finds {@link ExceptionHandler} methods in the given type.
* @param handlerType the type to introspect
* @throws IllegalStateException in case of invalid or ambiguous exception mapping declarations
*/
public ExceptionHandlerMethodResolver(Class<?> handlerType) {
for (Method method : MethodIntrospector.selectMethods(handlerType, EXCEPTION_HANDLER_METHODS)) {
for (Class<? extends Throwable> exceptionType : detectExceptionMappings(method)) {
addExceptionMapping(exceptionType, method);
ExceptionHandlerMappingInfo mappingInfo = detectExceptionMappings(method);
for (Class<? extends Throwable> exceptionType : mappingInfo.getExceptionTypes()) {
for (MediaType producibleType : mappingInfo.getProducibleTypes()) {
addExceptionMapping(new ExceptionMapping(exceptionType, producibleType), mappingInfo);
}
if (mappingInfo.getProducibleTypes().isEmpty()) {
addExceptionMapping(new ExceptionMapping(exceptionType, MediaType.ALL), mappingInfo);
}
}
}
}
@@ -86,33 +108,42 @@ public class ExceptionHandlerMethodResolver {
* and then as a fallback from the method signature itself.
*/
@SuppressWarnings("unchecked")
private List<Class<? extends Throwable>> detectExceptionMappings(Method method) {
List<Class<? extends Throwable>> result = new ArrayList<>();
detectAnnotationExceptionMappings(method, result);
if (result.isEmpty()) {
private ExceptionHandlerMappingInfo detectExceptionMappings(Method method) {
ExceptionHandler exceptionHandler = readExceptionHandlerAnnotation(method);
List<Class<? extends Throwable>> exceptions = new ArrayList<>(Arrays.asList(exceptionHandler.exception()));
if (exceptions.isEmpty()) {
for (Class<?> paramType : method.getParameterTypes()) {
if (Throwable.class.isAssignableFrom(paramType)) {
result.add((Class<? extends Throwable>) paramType);
exceptions.add((Class<? extends Throwable>) paramType);
}
}
}
if (result.isEmpty()) {
if (exceptions.isEmpty()) {
throw new IllegalStateException("No exception types mapped to " + method);
}
return result;
Set<MediaType> mediaTypes = new HashSet<>();
for (String mediaType : exceptionHandler.produces()) {
try {
mediaTypes.add(MediaType.parseMediaType(mediaType));
}
catch (InvalidMediaTypeException exc) {
throw new IllegalStateException("Invalid media type [" + mediaType + "] declared on @ExceptionHandler for " + method, exc);
}
}
return new ExceptionHandlerMappingInfo(Set.copyOf(exceptions), mediaTypes, method);
}
private void detectAnnotationExceptionMappings(Method method, List<Class<? extends Throwable>> result) {
private ExceptionHandler readExceptionHandlerAnnotation(Method method) {
ExceptionHandler ann = AnnotatedElementUtils.findMergedAnnotation(method, ExceptionHandler.class);
Assert.state(ann != null, "No ExceptionHandler annotation");
result.addAll(Arrays.asList(ann.value()));
return ann;
}
private void addExceptionMapping(Class<? extends Throwable> exceptionType, Method method) {
Method oldMethod = this.mappedMethods.put(exceptionType, method);
if (oldMethod != null && !oldMethod.equals(method)) {
private void addExceptionMapping(ExceptionMapping mapping, ExceptionHandlerMappingInfo mappingInfo) {
ExceptionHandlerMappingInfo oldMapping = this.mappedMethods.put(mapping, mappingInfo);
if (oldMapping != null && !oldMapping.getHandlerMethod().equals(mappingInfo.getHandlerMethod())) {
throw new IllegalStateException("Ambiguous @ExceptionHandler method mapped for [" +
exceptionType + "]: {" + oldMethod + ", " + method + "}");
mapping + "]: {" + oldMapping.getHandlerMethod() + ", " + mappingInfo.getHandlerMethod() + "}");
}
}
@@ -131,7 +162,8 @@ public class ExceptionHandlerMethodResolver {
*/
@Nullable
public Method resolveMethod(Exception exception) {
return resolveMethodByThrowable(exception);
ExceptionHandlerMappingInfo mappingInfo = resolveExceptionMapping(exception, MediaType.ALL);
return (mappingInfo != null) ? mappingInfo.getHandlerMethod() : null;
}
/**
@@ -143,14 +175,29 @@ public class ExceptionHandlerMethodResolver {
*/
@Nullable
public Method resolveMethodByThrowable(Throwable exception) {
Method method = resolveMethodByExceptionType(exception.getClass());
if (method == null) {
ExceptionHandlerMappingInfo mappingInfo = resolveExceptionMapping(exception, MediaType.ALL);
return (mappingInfo != null) ? mappingInfo.getHandlerMethod() : null;
}
/**
* Find a {@link Method} to handle the given Throwable for the requested {@link MediaType}.
* <p>Uses {@link ExceptionDepthComparator} and {@link MediaType#isMoreSpecific(MimeType)}
* if more than one match is found.
* @param exception the exception
* @param mediaType the media type requested by the HTTP client
* @return a Method to handle the exception, or {@code null} if none found
* @since 6.2.0
*/
@Nullable
public ExceptionHandlerMappingInfo resolveExceptionMapping(Throwable exception, MediaType mediaType) {
ExceptionHandlerMappingInfo mappingInfo = resolveExceptionMappingByExceptionType(exception.getClass(), mediaType);
if (mappingInfo == null) {
Throwable cause = exception.getCause();
if (cause != null) {
method = resolveMethodByThrowable(cause);
mappingInfo = resolveExceptionMapping(cause, mediaType);
}
}
return method;
return mappingInfo;
}
/**
@@ -162,42 +209,92 @@ public class ExceptionHandlerMethodResolver {
*/
@Nullable
public Method resolveMethodByExceptionType(Class<? extends Throwable> exceptionType) {
Method method = this.exceptionLookupCache.get(exceptionType);
if (method == null) {
method = getMappedMethod(exceptionType);
this.exceptionLookupCache.put(exceptionType, method);
}
return (method != NO_MATCHING_EXCEPTION_HANDLER_METHOD ? method : null);
ExceptionHandlerMappingInfo mappingInfo = resolveExceptionMappingByExceptionType(exceptionType, MediaType.ALL);
return (mappingInfo != null) ? mappingInfo.getHandlerMethod() : null;
}
/**
* Find a {@link Method} to handle the given exception type and media type.
* This can be useful if an {@link Exception} instance is not available (e.g. for tools).
* @param exceptionType the exception type
* @param mediaType the media type requested by the HTTP client
* @return a Method to handle the exception, or {@code null} if none found
*/
@Nullable
public ExceptionHandlerMappingInfo resolveExceptionMappingByExceptionType(Class<? extends Throwable> exceptionType, MediaType mediaType) {
ExceptionHandlerMappingInfo mappingInfo = this.lookupCache.get(new ExceptionMapping(exceptionType, mediaType));
return (mappingInfo != NO_MATCHING_EXCEPTION_HANDLER ? mappingInfo : null);
}
/**
* Return the {@link Method} mapped to the given exception type, or
* {@link #NO_MATCHING_EXCEPTION_HANDLER_METHOD} if none.
* {@link #NO_MATCHING_EXCEPTION_HANDLER} if none.
*/
@Nullable
private Method getMappedMethod(Class<? extends Throwable> exceptionType) {
List<Class<? extends Throwable>> matches = new ArrayList<>();
for (Class<? extends Throwable> mappedException : this.mappedMethods.keySet()) {
if (mappedException.isAssignableFrom(exceptionType)) {
matches.add(mappedException);
private ExceptionHandlerMappingInfo getMappedMethod(Class<? extends Throwable> exceptionType, MediaType mediaType) {
List<ExceptionMapping> matches = new ArrayList<>();
for (ExceptionMapping mappingInfo : this.mappedMethods.keySet()) {
if (mappingInfo.exceptionType().isAssignableFrom(exceptionType) && mappingInfo.mediaType().isCompatibleWith(mediaType)) {
matches.add(mappingInfo);
}
}
if (!matches.isEmpty()) {
if (matches.size() > 1) {
matches.sort(new ExceptionDepthComparator(exceptionType));
matches.sort(new ExceptionMapingComparator(exceptionType, mediaType));
}
return this.mappedMethods.get(matches.get(0));
}
else {
return NO_MATCHING_EXCEPTION_HANDLER_METHOD;
return NO_MATCHING_EXCEPTION_HANDLER;
}
}
/**
* For the {@link #NO_MATCHING_EXCEPTION_HANDLER_METHOD} constant.
* For the {@link #NO_MATCHING_EXCEPTION_HANDLER} constant.
*/
@SuppressWarnings("unused")
private void noMatchingExceptionHandler() {
}
private record ExceptionMapping(Class<? extends Throwable> exceptionType, MediaType mediaType) {
@Override
public String toString() {
return "ExceptionHandler{" +
"exceptionType=" + this.exceptionType.getCanonicalName() +
", mediaType=" + this.mediaType +
'}';
}
}
private static class ExceptionMapingComparator implements Comparator<ExceptionMapping> {
private final ExceptionDepthComparator exceptionDepthComparator;
private final MediaType mediaType;
public ExceptionMapingComparator(Class<? extends Throwable> exceptionType, MediaType mediaType) {
this.exceptionDepthComparator = new ExceptionDepthComparator(exceptionType);
this.mediaType = mediaType;
}
@Override
public int compare(ExceptionMapping o1, ExceptionMapping o2) {
int result = this.exceptionDepthComparator.compare(o1.exceptionType(), o2.exceptionType());
if (result != 0) {
return result;
}
if(o1.mediaType.equals(this.mediaType)) {
return -1;
}
if(o2.mediaType.equals(this.mediaType)) {
return 1;
}
if (o1.mediaType.equals(o2.mediaType)) {
return 0;
}
return (o1.mediaType.isMoreSpecific(o2.mediaType)) ? -1 : 1;
}
}
}