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