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 providers, + final ResteasyClient client, final BeanManager beanManager) { + return createProxy(resourceInterface, target, true, providers, client, beanManager); + } + + /** + * Creates a proxy for the interface. + *

+ * If {@code addExtendedInterfaces} is set to {@code true}, 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 addExtendedInterfaces {@code true} if the proxy should also implement {@link RestClientProxy} and + * {@link Closeable} + * @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 boolean addExtendedInterfaces, + final Set providers, final ResteasyClient client, final BeanManager beanManager) { + final Class[] interfaces; + if (addExtendedInterfaces) { + interfaces = new Class[3]; + interfaces[1] = RestClientProxy.class; + interfaces[2] = Closeable.class; + } else { + interfaces = new Class[1]; + } + interfaces[0] = resourceInterface; + final Object proxy = Proxy.newProxyInstance(resourceInterface.getClassLoader(), interfaces, + new ProxyInvocationHandler(resourceInterface, target, Set.copyOf(providers), client)); + ClientHeaderProviders.registerForClass(resourceInterface, proxy, beanManager); + return proxy; + } + + private Object invokeRestClientProxyMethod(final Method method) { switch (method.getName()) { case "getClient": return client; @@ -299,4 +380,13 @@ private static Annotation[] merge(List methodLevelBindings, List current = CDI.current(); + return current != null ? current.getBeanManager() : null; + } catch (IllegalStateException e) { + LOGGER.debug("CDI container is not available", e); + return null; + } + } } diff --git a/extensions/resteasy-classic/resteasy-client/runtime/src/main/java/io/quarkus/restclient/runtime/QuarkusRestClientBuilder.java b/extensions/resteasy-classic/resteasy-client/runtime/src/main/java/io/quarkus/restclient/runtime/QuarkusRestClientBuilder.java index 7ecc462c52d120..ea7b36569c0cc1 100644 --- a/extensions/resteasy-classic/resteasy-client/runtime/src/main/java/io/quarkus/restclient/runtime/QuarkusRestClientBuilder.java +++ b/extensions/resteasy-classic/resteasy-client/runtime/src/main/java/io/quarkus/restclient/runtime/QuarkusRestClientBuilder.java @@ -4,14 +4,12 @@ import static org.jboss.resteasy.client.jaxrs.ResteasyClientBuilder.PROPERTY_PROXY_PORT; import static org.jboss.resteasy.client.jaxrs.ResteasyClientBuilder.PROPERTY_PROXY_SCHEME; -import java.io.Closeable; import java.lang.annotation.Annotation; import java.lang.reflect.AnnotatedElement; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Parameter; -import java.lang.reflect.Proxy; import java.net.InetSocketAddress; import java.net.ProxySelector; import java.net.URI; @@ -72,10 +70,8 @@ import org.jboss.resteasy.microprofile.client.ExceptionMapping; import org.jboss.resteasy.microprofile.client.MethodInjectionFilter; import org.jboss.resteasy.microprofile.client.RestClientListeners; -import org.jboss.resteasy.microprofile.client.RestClientProxy; import org.jboss.resteasy.microprofile.client.async.AsyncInterceptorRxInvokerProvider; import org.jboss.resteasy.microprofile.client.async.AsyncInvocationInterceptorThreadContext; -import org.jboss.resteasy.microprofile.client.header.ClientHeaderProviders; import org.jboss.resteasy.microprofile.client.header.ClientHeadersRequestFilter; import org.jboss.resteasy.microprofile.client.impl.MpClient; import org.jboss.resteasy.microprofile.client.impl.MpClientBuilderImpl; @@ -353,16 +349,10 @@ public T build(Class aClass, ClientHttpEngine httpEngine) .defaultConsumes(MediaType.APPLICATION_JSON) .defaultProduces(MediaType.APPLICATION_JSON).build(); - Class[] interfaces = new Class[3]; - interfaces[0] = aClass; - interfaces[1] = RestClientProxy.class; - interfaces[2] = Closeable.class; - final BeanManager beanManager = getBeanManager(); - T proxy = (T) Proxy.newProxyInstance(classLoader, interfaces, - new QuarkusProxyInvocationHandler(aClass, actualClient, getLocalProviderInstances(), client, beanManager)); - ClientHeaderProviders.registerForClass(aClass, proxy, beanManager); - return proxy; + return aClass.cast( + QuarkusProxyInvocationHandler.createProxy(aClass, actualClient, getLocalProviderInstances(), client, + beanManager)); } private void configureTrustAll(ResteasyClientBuilder clientBuilder) {