diff --git a/extensions/resteasy-classic/resteasy-client/deployment/src/test/java/io/quarkus/restclient/exception/SubResourceTest.java b/extensions/resteasy-classic/resteasy-client/deployment/src/test/java/io/quarkus/restclient/exception/SubResourceTest.java
new file mode 100644
index 00000000000000..0e8bd0e2ad2653
--- /dev/null
+++ b/extensions/resteasy-classic/resteasy-client/deployment/src/test/java/io/quarkus/restclient/exception/SubResourceTest.java
@@ -0,0 +1,130 @@
+package io.quarkus.restclient.exception;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.net.URL;
+
+import jakarta.ws.rs.core.Response;
+
+import org.eclipse.microprofile.rest.client.RestClientBuilder;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import io.quarkus.restclient.exception.resource.ClientRootResource;
+import io.quarkus.restclient.exception.resource.ClientSubResource;
+import io.quarkus.restclient.exception.resource.ServerResource;
+import io.quarkus.restclient.exception.resource.TestException;
+import io.quarkus.restclient.exception.resource.TestExceptionMapper;
+import io.quarkus.test.QuarkusUnitTest;
+import io.quarkus.test.common.http.TestHTTPResource;
+
+/**
+ * Tests client sub-resources
+ *
+ * @author James R. Perkins
+ */
+public class SubResourceTest {
+
+ @RegisterExtension
+ static final QuarkusUnitTest config = new QuarkusUnitTest()
+ .withApplicationRoot((jar) -> jar.addClasses(SubResourceTest.class,
+ ClientRootResource.class, ClientSubResource.class, ServerResource.class,
+ TestExceptionMapper.class, TestException.class))
+ .overrideRuntimeConfigKey("quarkus.http.port", "0")
+ .overrideRuntimeConfigKey("quarkus.http.test-port", "0");
+
+ @TestHTTPResource
+ URL url;
+
+ /**
+ * Creates a REST client with an attached exception mapper. The exception mapper will throw a {@link TestException}.
+ * This test invokes a call to the root resource.
+ *
+ * @throws Exception if a test error occurs
+ */
+ @Test
+ public void rootResourceExceptionMapper() {
+ try (final ClientRootResource rootResource = RestClientBuilder.newBuilder().baseUrl(url)
+ .build(ClientRootResource.class)) {
+ rootResource.fromRoot();
+ fail("fromRoot() should have thrown a TestException");
+ } catch (TestException expected) {
+ assertEquals("RootResource failed on purpose", expected.getMessage());
+ } catch (Exception e) {
+ failWithException(e, "fromRoot");
+ }
+ }
+
+ /**
+ * Creates a REST client with an attached exception mapper. The exception mapper will throw a {@link TestException}.
+ * This test invokes a call to the sub-resource. The sub-resource then invokes an additional call which should also
+ * result in a {@link TestException} thrown.
+ *
+ * @throws Exception if a test error occurs
+ */
+ @Test
+ public void subResourceExceptionMapper() {
+ try (final ClientRootResource rootResource = RestClientBuilder.newBuilder().baseUrl(url)
+ .build(ClientRootResource.class)) {
+ final ClientSubResource subResource = rootResource.subResource();
+ assertNotNull(subResource, "The SubResource should not be null");
+ subResource.fromSub();
+ fail("fromSub() should have thrown a TestException");
+ } catch (TestException expected) {
+ assertEquals("SubResource failed on purpose", expected.getMessage());
+ } catch (Exception e) {
+ failWithException(e, "fromSub");
+ }
+ }
+
+ /**
+ * This test invokes a call to the sub-resource. The sub-resource then invokes an additional call which should
+ * return the header value for {@code test-header}.
+ *
+ * @throws Exception if a test error occurs
+ */
+ @Test
+ public void subResourceWithHeader() throws Exception {
+ try (final ClientRootResource rootResource = RestClientBuilder.newBuilder().baseUrl(url)
+ .build(ClientRootResource.class)) {
+ final ClientSubResource subResource = rootResource.subResource();
+ assertNotNull(subResource, "The SubResource should not be null");
+ try (final Response response = subResource.withHeader()) {
+ assertEquals(Response.Status.OK, response.getStatusInfo());
+ assertEquals("SubResourceHeader", response.readEntity(String.class));
+ }
+ }
+ }
+
+ /**
+ * This test invokes a call to the sub-resource. The sub-resource then invokes an additional call which should
+ * return the header value for {@code test-global-header}.
+ *
+ * @throws Exception if a test error occurs
+ */
+ @Test
+ public void subResourceWithGlobalHeader() throws Exception {
+ try (final ClientRootResource rootResource = RestClientBuilder.newBuilder().baseUrl(url)
+ .build(ClientRootResource.class)) {
+ final ClientSubResource subResource = rootResource.subResource();
+ assertNotNull(subResource, "The SubResource should not be null");
+ try (final Response response = subResource.withGlobalHeader()) {
+ assertEquals(Response.Status.OK, response.getStatusInfo());
+ assertEquals("GlobalSubResourceHeader", response.readEntity(String.class));
+ }
+ }
+ }
+
+ private static void failWithException(final Exception e, final String methodName) {
+ final StringWriter writer = new StringWriter();
+ writer.write(methodName);
+ writer.write("() should have thrown an TestException. Instead got: ");
+ writer.write(System.lineSeparator());
+ e.printStackTrace(new PrintWriter(writer));
+ fail(writer.toString());
+ }
+}
diff --git a/extensions/resteasy-classic/resteasy-client/deployment/src/test/java/io/quarkus/restclient/exception/resource/ClientRootResource.java b/extensions/resteasy-classic/resteasy-client/deployment/src/test/java/io/quarkus/restclient/exception/resource/ClientRootResource.java
new file mode 100644
index 00000000000000..e0c6d07d922919
--- /dev/null
+++ b/extensions/resteasy-classic/resteasy-client/deployment/src/test/java/io/quarkus/restclient/exception/resource/ClientRootResource.java
@@ -0,0 +1,40 @@
+/*
+ * JBoss, Home of Professional Open Source.
+ *
+ * Copyright 2024 Red Hat, Inc., and individual contributors
+ * as indicated by the @author tags.
+ *
+ * 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
+ *
+ * http://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 io.quarkus.restclient.exception.resource;
+
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.core.Response;
+
+import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
+
+/**
+ * @author James R. Perkins
+ */
+@RegisterRestClient
+@Path("/root")
+public interface ClientRootResource extends AutoCloseable {
+
+ @Path("/sub")
+ ClientSubResource subResource();
+
+ @GET
+ Response fromRoot() throws TestException;
+}
diff --git a/extensions/resteasy-classic/resteasy-client/deployment/src/test/java/io/quarkus/restclient/exception/resource/ClientSubResource.java b/extensions/resteasy-classic/resteasy-client/deployment/src/test/java/io/quarkus/restclient/exception/resource/ClientSubResource.java
new file mode 100644
index 00000000000000..f0c27395a75934
--- /dev/null
+++ b/extensions/resteasy-classic/resteasy-client/deployment/src/test/java/io/quarkus/restclient/exception/resource/ClientSubResource.java
@@ -0,0 +1,49 @@
+/*
+ * JBoss, Home of Professional Open Source.
+ *
+ * Copyright 2024 Red Hat, Inc., and individual contributors
+ * as indicated by the @author tags.
+ *
+ * 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
+ *
+ * http://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 io.quarkus.restclient.exception.resource;
+
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+
+import org.eclipse.microprofile.rest.client.annotation.ClientHeaderParam;
+
+/**
+ * @author James R. Perkins
+ */
+@ClientHeaderParam(name = "test-global-header", value = "GlobalSubResourceHeader")
+public interface ClientSubResource {
+
+ @GET
+ Response fromSub() throws TestException;
+
+ @GET
+ @ClientHeaderParam(name = "test-header", value = "SubResourceHeader")
+ @Path("/header")
+ @Produces(MediaType.TEXT_PLAIN)
+ Response withHeader();
+
+ @GET
+ @Path("/global/header")
+ @Produces(MediaType.TEXT_PLAIN)
+ Response withGlobalHeader();
+}
diff --git a/extensions/resteasy-classic/resteasy-client/deployment/src/test/java/io/quarkus/restclient/exception/resource/ServerResource.java b/extensions/resteasy-classic/resteasy-client/deployment/src/test/java/io/quarkus/restclient/exception/resource/ServerResource.java
new file mode 100644
index 00000000000000..1b318e8aaec07d
--- /dev/null
+++ b/extensions/resteasy-classic/resteasy-client/deployment/src/test/java/io/quarkus/restclient/exception/resource/ServerResource.java
@@ -0,0 +1,59 @@
+/*
+ * JBoss, Home of Professional Open Source.
+ *
+ * Copyright 2024 Red Hat, Inc., and individual contributors
+ * as indicated by the @author tags.
+ *
+ * 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
+ *
+ * http://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 io.quarkus.restclient.exception.resource;
+
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.HeaderParam;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+
+/**
+ * @author James R. Perkins
+ */
+@Path("/root")
+public class ServerResource {
+
+ @GET
+ public Response fromRoot() {
+ return Response.serverError().entity("RootResource failed on purpose").build();
+ }
+
+ @GET
+ @Path("/sub")
+ public Response fromSub() {
+ return Response.serverError().entity("SubResource failed on purpose").build();
+ }
+
+ @GET
+ @Path("/sub/header")
+ @Produces(MediaType.TEXT_PLAIN)
+ public Response subHeader(@HeaderParam("test-header") final String value) {
+ return Response.ok(value).build();
+ }
+
+ @GET
+ @Path("/sub/global/header")
+ @Produces(MediaType.TEXT_PLAIN)
+ public Response subGlobalHeader(@HeaderParam("test-global-header") final String value) {
+ return Response.ok(value).build();
+ }
+}
\ No newline at end of file
diff --git a/extensions/resteasy-classic/resteasy-client/deployment/src/test/java/io/quarkus/restclient/exception/resource/TestException.java b/extensions/resteasy-classic/resteasy-client/deployment/src/test/java/io/quarkus/restclient/exception/resource/TestException.java
new file mode 100644
index 00000000000000..20dc4ab987a10a
--- /dev/null
+++ b/extensions/resteasy-classic/resteasy-client/deployment/src/test/java/io/quarkus/restclient/exception/resource/TestException.java
@@ -0,0 +1,30 @@
+/*
+ * JBoss, Home of Professional Open Source.
+ *
+ * Copyright 2024 Red Hat, Inc., and individual contributors
+ * as indicated by the @author tags.
+ *
+ * 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
+ *
+ * http://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 io.quarkus.restclient.exception.resource;
+
+/**
+ * @author James R. Perkins
+ */
+public class TestException extends RuntimeException {
+
+ public TestException(final String msg) {
+ super(msg);
+ }
+}
\ No newline at end of file
diff --git a/extensions/resteasy-classic/resteasy-client/deployment/src/test/java/io/quarkus/restclient/exception/resource/TestExceptionMapper.java b/extensions/resteasy-classic/resteasy-client/deployment/src/test/java/io/quarkus/restclient/exception/resource/TestExceptionMapper.java
new file mode 100644
index 00000000000000..3f3bf1517d14e1
--- /dev/null
+++ b/extensions/resteasy-classic/resteasy-client/deployment/src/test/java/io/quarkus/restclient/exception/resource/TestExceptionMapper.java
@@ -0,0 +1,36 @@
+/*
+ * JBoss, Home of Professional Open Source.
+ *
+ * Copyright 2024 Red Hat, Inc., and individual contributors
+ * as indicated by the @author tags.
+ *
+ * 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
+ *
+ * http://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 io.quarkus.restclient.exception.resource;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.ws.rs.core.Response;
+
+import org.eclipse.microprofile.rest.client.ext.ResponseExceptionMapper;
+
+/**
+ * @author James R. Perkins
+ */
+@ApplicationScoped
+public class TestExceptionMapper implements ResponseExceptionMapper {
+ @Override
+ public TestException toThrowable(final Response response) {
+ return new TestException(response.readEntity(String.class));
+ }
+}
\ No newline at end of file
diff --git a/extensions/resteasy-classic/resteasy-client/runtime/src/main/java/io/quarkus/restclient/runtime/QuarkusProxyInvocationHandler.java b/extensions/resteasy-classic/resteasy-client/runtime/src/main/java/io/quarkus/restclient/runtime/QuarkusProxyInvocationHandler.java
index 022f4b53305b3a..35323cb7e1cbd4 100644
--- a/extensions/resteasy-classic/resteasy-client/runtime/src/main/java/io/quarkus/restclient/runtime/QuarkusProxyInvocationHandler.java
+++ b/extensions/resteasy-classic/resteasy-client/runtime/src/main/java/io/quarkus/restclient/runtime/QuarkusProxyInvocationHandler.java
@@ -1,11 +1,13 @@
package io.quarkus.restclient.runtime;
+import java.io.Closeable;
import java.lang.annotation.Annotation;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Proxy;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Collections;
@@ -22,6 +24,8 @@
import jakarta.enterprise.inject.spi.CDI;
import jakarta.enterprise.inject.spi.InterceptionType;
import jakarta.enterprise.inject.spi.Interceptor;
+import jakarta.ws.rs.HttpMethod;
+import jakarta.ws.rs.Path;
import jakarta.ws.rs.ProcessingException;
import jakarta.ws.rs.client.ResponseProcessingException;
import jakarta.ws.rs.ext.ParamConverter;
@@ -30,8 +34,10 @@
import org.jboss.logging.Logger;
import org.jboss.resteasy.client.jaxrs.ResteasyClient;
import org.jboss.resteasy.microprofile.client.ExceptionMapping;
+import org.jboss.resteasy.microprofile.client.ProxyInvocationHandler;
import org.jboss.resteasy.microprofile.client.RestClientProxy;
import org.jboss.resteasy.microprofile.client.header.ClientHeaderFillingException;
+import org.jboss.resteasy.microprofile.client.header.ClientHeaderProviders;
/**
* Quarkus version of {@link org.jboss.resteasy.microprofile.client.ProxyInvocationHandler} retaining the ability to
@@ -84,14 +90,17 @@ public QuarkusProxyInvocationHandler(final Class> restClientInterface,
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if (RestClientProxy.class.equals(method.getDeclaringClass())) {
- return invokeRestClientProxyMethod(proxy, method, args);
+ return invokeRestClientProxyMethod(method);
}
// Autocloseable/Closeable
if (method.getName().equals("close") && (args == null || args.length == 0)) {
close();
return null;
}
- if (closed.get()) {
+ // Check if this proxy is closed or the client itself is closed. The client may be closed if this proxy was a
+ // sub-resource and the resource client itself was closed.
+ if (closed.get() || client.isClosed()) {
+ closed.set(true);
throw new IllegalStateException("RestClientProxy is closed");
}
@@ -162,7 +171,30 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl
return new QuarkusInvocationContextImpl(target, method, args, chain, interceptorBindingsMap.get(method)).proceed();
} else {
try {
- return method.invoke(target, args);
+ final Object result = method.invoke(target, args);
+ final Class> returnType = method.getReturnType();
+ // Check if this is a sub-resource. A sub-resource must be an interface.
+ if (returnType.isInterface()) {
+ final Annotation[] annotations = method.getDeclaredAnnotations();
+ boolean hasPath = false;
+ boolean hasHttpMethod = false;
+ // Check the annotations. If the method has one of the @HttpMethod annotations, we will just use the
+ // current method. If it only has a @Path, then we need to create a proxy for the return type.
+ for (Annotation annotation : annotations) {
+ final Class> type = annotation.annotationType();
+ if (type.equals(Path.class)) {
+ hasPath = true;
+ } else if (type.getDeclaredAnnotation(HttpMethod.class) != null) {
+ hasHttpMethod = true;
+ }
+ }
+ if (!hasHttpMethod && hasPath) {
+ // Create a proxy of the return type re-using the providers and client, but do not add the required
+ // interfaces for the sub-resource.
+ return createProxy(returnType, result, false, providerInstances, client, getBeanManager());
+ }
+ }
+ return result;
} catch (InvocationTargetException e) {
Throwable cause = e.getCause();
if (cause instanceof CompletionException) {
@@ -193,7 +225,56 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl
}
}
- private Object invokeRestClientProxyMethod(Object proxy, Method method, Object[] args) {
+ /**
+ * Creates a proxy for the interface. The proxy will implement the interfaces {@link RestClientProxy} and
+ * {@link Closeable}.
+ *
+ * @param resourceInterface the resource interface to create the proxy for
+ * @param target the target object for the proxy
+ * @param providers the providers for the client
+ * @param client the client to use
+ * @param beanManager the bean manager used to register {@linkplain ClientHeaderProviders client header providers}
+ * @return the new proxy
+ */
+ static Object createProxy(final Class> resourceInterface, final Object target, final Set