From 15f6a360b3887b0e53a00d6f713bf8f65845b9da Mon Sep 17 00:00:00 2001 From: Maximilian Rehberger Date: Fri, 30 Aug 2024 14:11:16 +0200 Subject: [PATCH] issue-42851: port resteasy fix for sub-resources from resteasy/resteasy-microprofile PR #241 --- .../restclient/exception/SubResourceTest.java | 165 ++++++++++++++++++ .../QuarkusProxyInvocationHandler.java | 73 +++++++- .../runtime/QuarkusRestClientBuilder.java | 16 +- 3 files changed, 236 insertions(+), 18 deletions(-) create mode 100644 extensions/resteasy-classic/resteasy-client/deployment/src/test/java/io/quarkus/restclient/exception/SubResourceTest.java 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..e897820b5f8921 --- /dev/null +++ b/extensions/resteasy-classic/resteasy-client/deployment/src/test/java/io/quarkus/restclient/exception/SubResourceTest.java @@ -0,0 +1,165 @@ +package io.quarkus.restclient.exception; + +import static jakarta.ws.rs.core.MediaType.TEXT_PLAIN; +import static jakarta.ws.rs.core.Response.Status.OK; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +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.Response; + +import org.eclipse.microprofile.rest.client.annotation.ClientHeaderParam; +import org.eclipse.microprofile.rest.client.annotation.RegisterProvider; +import org.eclipse.microprofile.rest.client.ext.ResponseExceptionMapper; +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; +import org.eclipse.microprofile.rest.client.inject.RestClient; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +/** + * 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) + .addAsResource(new StringAsset(ClientRootResource.class.getName() + "/mp-rest/url=${test.url}\n"), + "application.properties")); + + @RestClient + ClientRootResource clientRootResource; + + /** + * 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. + * + */ + @Test + public void rootResourceExceptionMapper() { + try (final Response ignored = clientRootResource.fromRoot()) { + fail("fromRoot() should have thrown a TestException"); + } catch (TestException expected) { + assertEquals("RootResource failed on purpose", expected.getMessage()); + } + } + + /** + * 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() throws Exception { + try (final Response ignored = clientRootResource.subResource().fromSub()) { + fail("fromSub() should have thrown a TestException"); + } catch (TestException expected) { + assertEquals("SubResource failed on purpose", expected.getMessage()); + } + } + + /** + * 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}. + * + */ + @Test + public void subResourceWithHeader() { + try (final Response response = clientRootResource.subResource().withHeader()) { + assertEquals(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 Response response = clientRootResource.subResource().withGlobalHeader()) { + assertEquals(OK, response.getStatusInfo()); + assertEquals("GlobalSubResourceHeader", response.readEntity(String.class)); + } + } + + @RegisterRestClient + @RegisterProvider(TestExceptionMapper.class) + @Path("/root") + public interface ClientRootResource { + @Path("/sub") + ClientSubResource subResource(); + + @GET + Response fromRoot() throws TestException; + } + + @ClientHeaderParam(name = "test-global-header", value = "GlobalSubResourceHeader") + @Produces(TEXT_PLAIN) + public interface ClientSubResource { + @GET + Response fromSub() throws TestException; + + @GET + @ClientHeaderParam(name = "test-header", value = "SubResourceHeader") + @Path("/header") + Response withHeader(); + + @GET + @Path("/global/header") + Response withGlobalHeader(); + } + + @Path("/root") + public static 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") + public Response subHeader(@HeaderParam("test-header") final String value) { + return Response.ok(value).build(); + } + + @GET + @Path("/sub/global/header") + public Response subGlobalHeader(@HeaderParam("test-global-header") final String value) { + return Response.ok(value).build(); + } + } + + public static class TestExceptionMapper implements ResponseExceptionMapper { + @Override + public TestException toThrowable(final Response response) { + return new TestException(response.readEntity(String.class)); + } + } + + public static class TestException extends RuntimeException { + public TestException(final String msg) { + super(msg); + } + } +} 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..25626fd865ec8b 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; @@ -32,6 +36,7 @@ import org.jboss.resteasy.microprofile.client.ExceptionMapping; 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 +89,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 +170,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); + } + } + return result; } catch (InvocationTargetException e) { Throwable cause = e.getCause(); if (cause instanceof CompletionException) { @@ -193,7 +224,41 @@ 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. + *

+ * 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 + * @return the new proxy + */ + static Object createProxy(final Class resourceInterface, final Object target, final boolean addExtendedInterfaces, + final Set providers, final ResteasyClient client) { + 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 BeanManager beanManager = getBeanManager(resourceInterface); + final Object proxy = Proxy.newProxyInstance(resourceInterface.getClassLoader(), interfaces, + new QuarkusProxyInvocationHandler(resourceInterface, target, Set.copyOf(providers), client, + beanManager)); + ClientHeaderProviders.registerForClass(resourceInterface, proxy, beanManager); + return proxy; + } + + private Object invokeRestClientProxyMethod(final Method method) { switch (method.getName()) { case "getClient": return client; 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..fa46569bcdde4e 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,8 @@ 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, true, getLocalProviderInstances(), client)); } private void configureTrustAll(ResteasyClientBuilder clientBuilder) {