diff --git a/docs/src/main/asciidoc/rest.adoc b/docs/src/main/asciidoc/rest.adoc index f40b865e34917d..2a7f3d25318b75 100644 --- a/docs/src/main/asciidoc/rest.adoc +++ b/docs/src/main/asciidoc/rest.adoc @@ -2271,6 +2271,13 @@ By default, methods annotated with `@ServerExceptionMapper` do **not** run CDI i Users however can opt into interceptors by adding the corresponding annotations to the method. ==== +[TIP] +==== +When mapping an exception to a `@ServerExceptionMapper` method, the cause of the exception normally does not come into play. +However, in cases where the root cause of an issue is wrapped in some generic exception, the `@UnwrapException` comes in handy +for essentially using the cause when resolving the exception mapper. +==== + [NOTE] ==== Εxception mappers defined in REST endpoint classes will only be called if the exception is thrown in the same class. If you want to define global exception mappers, diff --git a/extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveScanningProcessor.java b/extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveScanningProcessor.java index bd97894704a7fe..0f0f08e21ee551 100644 --- a/extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveScanningProcessor.java +++ b/extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveScanningProcessor.java @@ -25,6 +25,7 @@ import org.jboss.jandex.IndexView; import org.jboss.jandex.Indexer; import org.jboss.jandex.MethodInfo; +import org.jboss.jandex.Type; import org.jboss.resteasy.reactive.common.core.BlockingNotAllowedException; import org.jboss.resteasy.reactive.common.model.ResourceContextResolver; import org.jboss.resteasy.reactive.common.model.ResourceExceptionMapper; @@ -33,6 +34,7 @@ import org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames; import org.jboss.resteasy.reactive.common.processor.scanning.ApplicationScanningResult; import org.jboss.resteasy.reactive.common.processor.scanning.ResteasyReactiveInterceptorScanner; +import org.jboss.resteasy.reactive.server.UnwrapException; import org.jboss.resteasy.reactive.server.core.ExceptionMapping; import org.jboss.resteasy.reactive.server.model.ContextResolvers; import org.jboss.resteasy.reactive.server.model.ParamConverterProviders; @@ -82,6 +84,9 @@ */ public class ResteasyReactiveScanningProcessor { + private static final DotName EXCEPTION = DotName.createSimple(Exception.class); + private static final DotName RUNTIME_EXCEPTION = DotName.createSimple(RuntimeException.class); + public static final Set CONDITIONAL_BEAN_ANNOTATIONS; static { @@ -118,11 +123,55 @@ public void accept(ResourceInterceptors interceptors) { } @BuildStep - public List defaultUnwrappedException() { + public List defaultUnwrappedExceptions() { return List.of(new UnwrappedExceptionBuildItem(ArcUndeclaredThrowableException.class), new UnwrappedExceptionBuildItem(RollbackException.class)); } + @BuildStep + public void applicationSpecificUnwrappedExceptions(CombinedIndexBuildItem combinedIndexBuildItem, + BuildProducer producer) { + IndexView index = combinedIndexBuildItem.getIndex(); + for (AnnotationInstance instance : index.getAnnotations(UnwrapException.class)) { + AnnotationValue value = instance.value(); + if (value == null) { + // in this case we need to use the class where the annotation was placed as the exception to be unwrapped + + AnnotationTarget target = instance.target(); + if (target.kind() != AnnotationTarget.Kind.CLASS) { + throw new IllegalStateException( + "@UnwrapException is only supported on classes. Offending target is: " + target); + } + ClassInfo classInfo = target.asClass(); + ClassInfo toCheck = classInfo; + boolean isException = false; + while (true) { + DotName superDotName = toCheck.superName(); + if (EXCEPTION.equals(superDotName) || RUNTIME_EXCEPTION.equals(superDotName)) { + isException = true; + break; + } + toCheck = index.getClassByName(superDotName); + if (toCheck == null) { + break; + } + } + if (!isException) { + throw new IllegalArgumentException( + "Using @UnwrapException without a value is only supported on exception classes. Offending target is '" + + classInfo.name() + "'."); + } + + producer.produce(new UnwrappedExceptionBuildItem(classInfo.name().toString())); + } else { + Type[] exceptionTypes = value.asClassArray(); + for (Type exceptionType : exceptionTypes) { + producer.produce(new UnwrappedExceptionBuildItem(exceptionType.name().toString())); + } + } + } + } + @BuildStep public ExceptionMappersBuildItem scanForExceptionMappers(CombinedIndexBuildItem combinedIndexBuildItem, ApplicationResultBuildItem applicationResultBuildItem, @@ -137,7 +186,7 @@ public ExceptionMappersBuildItem scanForExceptionMappers(CombinedIndexBuildItem exceptions.addBlockingProblem(BlockingOperationNotAllowedException.class); exceptions.addBlockingProblem(BlockingNotAllowedException.class); for (UnwrappedExceptionBuildItem bi : unwrappedExceptions) { - exceptions.addUnwrappedException(bi.getThrowableClass().getName()); + exceptions.addUnwrappedException(bi.getThrowableClassName()); } if (capabilities.isPresent(Capability.HIBERNATE_REACTIVE)) { exceptions.addNonBlockingProblem( diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/customexceptions/UnwrapExceptionTest.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/customexceptions/UnwrapExceptionTest.java new file mode 100644 index 00000000000000..c129c8a490d131 --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/customexceptions/UnwrapExceptionTest.java @@ -0,0 +1,155 @@ +package io.quarkus.resteasy.reactive.server.test.customexceptions; + +import static io.quarkus.resteasy.reactive.server.test.ExceptionUtil.removeStackTrace; +import static io.restassured.RestAssured.when; + +import java.util.function.Supplier; + +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Response; + +import org.jboss.resteasy.reactive.server.ServerExceptionMapper; +import org.jboss.resteasy.reactive.server.UnwrapException; +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.resteasy.reactive.server.test.ExceptionUtil; +import io.quarkus.test.QuarkusUnitTest; + +public class UnwrapExceptionTest { + + @RegisterExtension + static QuarkusUnitTest test = new QuarkusUnitTest() + .setArchiveProducer(new Supplier<>() { + @Override + public JavaArchive get() { + return ShrinkWrap.create(JavaArchive.class) + .addClasses(FirstException.class, SecondException.class, ThirdException.class, + FourthException.class, FifthException.class, SixthException.class, + Mappers.class, Resource.class, ExceptionUtil.class); + } + }); + + @Test + public void testWrapperWithUnmappedException() { + when().get("/hello/iaeInSecond") + .then().statusCode(500); + } + + @Test + public void testMappedExceptionWithoutUnwrappedWrapper() { + when().get("/hello/iseInFirst") + .then().statusCode(500); + + when().get("/hello/iseInThird") + .then().statusCode(500); + + when().get("/hello/iseInSixth") + .then().statusCode(500); + } + + @Test + public void testWrapperWithMappedException() { + when().get("/hello/iseInSecond") + .then().statusCode(900); + + when().get("/hello/iseInFourth") + .then().statusCode(900); + + when().get("/hello/iseInFifth") + .then().statusCode(900); + } + + @Path("hello") + public static class Resource { + + @Path("iseInFirst") + public String throwsISEAsCauseOfFirstException() { + throw removeStackTrace(new FirstException(removeStackTrace(new IllegalStateException("dummy")))); + } + + @Path("iseInSecond") + public String throwsISEAsCauseOfSecondException() { + throw removeStackTrace(new SecondException(removeStackTrace(new IllegalStateException("dummy")))); + } + + @Path("iaeInSecond") + public String throwsIAEAsCauseOfSecondException() { + throw removeStackTrace(new SecondException(removeStackTrace(new IllegalArgumentException("dummy")))); + } + + @Path("iseInThird") + public String throwsISEAsCauseOfThirdException() throws ThirdException { + throw removeStackTrace(new ThirdException(removeStackTrace(new IllegalStateException("dummy")))); + } + + @Path("iseInFourth") + public String throwsISEAsCauseOfFourthException() throws FourthException { + throw removeStackTrace(new FourthException(removeStackTrace(new IllegalStateException("dummy")))); + } + + @Path("iseInFifth") + public String throwsISEAsCauseOfFifthException() { + throw removeStackTrace(new FifthException(removeStackTrace(new IllegalStateException("dummy")))); + } + + @Path("iseInSixth") + public String throwsISEAsCauseOfSixthException() { + throw removeStackTrace(new SixthException(removeStackTrace(new IllegalStateException("dummy")))); + } + } + + @UnwrapException({ FourthException.class, FifthException.class }) + public static class Mappers { + + @ServerExceptionMapper + public Response handleIllegalStateException(IllegalStateException e) { + return Response.status(900).build(); + } + } + + public static class FirstException extends RuntimeException { + + public FirstException(Throwable cause) { + super(cause); + } + } + + @UnwrapException + public static class SecondException extends FirstException { + + public SecondException(Throwable cause) { + super(cause); + } + } + + public static class ThirdException extends Exception { + + public ThirdException(Throwable cause) { + super(cause); + } + } + + public static class FourthException extends SecondException { + + public FourthException(Throwable cause) { + super(cause); + } + } + + public static class FifthException extends RuntimeException { + + public FifthException(Throwable cause) { + super(cause); + } + } + + public static class SixthException extends RuntimeException { + + public SixthException(Throwable cause) { + super(cause); + } + } +} diff --git a/extensions/resteasy-reactive/rest/spi-deployment/src/main/java/io/quarkus/resteasy/reactive/server/spi/UnwrappedExceptionBuildItem.java b/extensions/resteasy-reactive/rest/spi-deployment/src/main/java/io/quarkus/resteasy/reactive/server/spi/UnwrappedExceptionBuildItem.java index 6cb915916c3fab..9bf0b4dbfa59c1 100644 --- a/extensions/resteasy-reactive/rest/spi-deployment/src/main/java/io/quarkus/resteasy/reactive/server/spi/UnwrappedExceptionBuildItem.java +++ b/extensions/resteasy-reactive/rest/spi-deployment/src/main/java/io/quarkus/resteasy/reactive/server/spi/UnwrappedExceptionBuildItem.java @@ -3,18 +3,32 @@ import io.quarkus.builder.item.MultiBuildItem; /** - * When an Exception of this type is thrown and no {@code jakarta.ws.rs.ext.ExceptionMapper} exists, + * When an {@link Exception} of this type is thrown and no {@code jakarta.ws.rs.ext.ExceptionMapper} exists, * then RESTEasy Reactive will attempt to locate an {@code ExceptionMapper} for the cause of the Exception. */ public final class UnwrappedExceptionBuildItem extends MultiBuildItem { - private final Class throwableClass; + private final String throwableClassName; - public UnwrappedExceptionBuildItem(Class throwableClass) { - this.throwableClass = throwableClass; + public UnwrappedExceptionBuildItem(String throwableClassName) { + this.throwableClassName = throwableClassName; } + public UnwrappedExceptionBuildItem(Class throwableClassName) { + this.throwableClassName = throwableClassName.getName(); + } + + @Deprecated(forRemoval = true) public Class getThrowableClass() { - return throwableClass; + try { + return (Class) Class.forName(throwableClassName, false, + Thread.currentThread().getContextClassLoader()); + } catch (ClassNotFoundException e) { + throw new RuntimeException(e); + } + } + + public String getThrowableClassName() { + return throwableClassName; } } diff --git a/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/ServerExceptionMapper.java b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/ServerExceptionMapper.java index 7a4689f9ca757c..2d0df9e1e8f145 100644 --- a/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/ServerExceptionMapper.java +++ b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/ServerExceptionMapper.java @@ -41,6 +41,8 @@ *

* The return type of the method must be either be of type {@code Response}, {@code Uni}, {@code RestResponse} or * {@code Uni}. + *

+ * See also {@link UnwrapException} */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) diff --git a/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/UnwrapException.java b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/UnwrapException.java new file mode 100644 index 00000000000000..430757f67ebaac --- /dev/null +++ b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/UnwrapException.java @@ -0,0 +1,23 @@ +package org.jboss.resteasy.reactive.server; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Used to configure that an exception (or exceptions) should be unwrapped during exception handling. + *

+ * Unwrapping means that when an {@link Exception} of the configured type is thrown and no + * {@code jakarta.ws.rs.ext.ExceptionMapper} exists, + * then RESTEasy Reactive will attempt to locate an {@code ExceptionMapper} for the cause of the Exception. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface UnwrapException { + + /** + * If this is not set, the value is assumed to be the exception class where the annotation is placed + */ + Class[] value() default {}; +}