diff --git a/rest-client-base/src/main/java/org/jboss/resteasy/microprofile/client/ProxyInvocationHandler.java b/rest-client-base/src/main/java/org/jboss/resteasy/microprofile/client/ProxyInvocationHandler.java index 40c9a8c..f80b3d9 100644 --- a/rest-client-base/src/main/java/org/jboss/resteasy/microprofile/client/ProxyInvocationHandler.java +++ b/rest-client-base/src/main/java/org/jboss/resteasy/microprofile/client/ProxyInvocationHandler.java @@ -19,16 +19,24 @@ package org.jboss.resteasy.microprofile.client; +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.ParameterizedType; +import java.lang.reflect.Proxy; import java.lang.reflect.Type; +import java.security.AccessController; +import java.security.PrivilegedAction; import java.util.Set; import java.util.concurrent.CompletionException; import java.util.concurrent.atomic.AtomicBoolean; +import jakarta.enterprise.inject.spi.BeanManager; +import jakarta.enterprise.inject.spi.CDI; +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; @@ -37,6 +45,7 @@ import org.jboss.logging.Logger; import org.jboss.resteasy.client.jaxrs.ResteasyClient; import org.jboss.resteasy.microprofile.client.header.ClientHeaderFillingException; +import org.jboss.resteasy.microprofile.client.header.ClientHeaderProviders; public class ProxyInvocationHandler implements InvocationHandler { @@ -64,14 +73,17 @@ public ProxyInvocationHandler(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"); } @@ -137,7 +149,30 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl } 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) { @@ -167,7 +202,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(getClassLoader(resourceInterface), 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; @@ -195,4 +279,21 @@ private Type[] getGenericTypes(Class aClass) { } return genericTypes; } + + private static ClassLoader getClassLoader(final Class type) { + if (System.getSecurityManager() == null) { + return type.getClassLoader(); + } + return AccessController.doPrivileged((PrivilegedAction) type::getClassLoader); + } + + private static BeanManager getBeanManager() { + try { + CDI 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/rest-client-base/src/main/java/org/jboss/resteasy/microprofile/client/RestClientBuilderImpl.java b/rest-client-base/src/main/java/org/jboss/resteasy/microprofile/client/RestClientBuilderImpl.java index e4f17dc..85e087c 100644 --- a/rest-client-base/src/main/java/org/jboss/resteasy/microprofile/client/RestClientBuilderImpl.java +++ b/rest-client-base/src/main/java/org/jboss/resteasy/microprofile/client/RestClientBuilderImpl.java @@ -30,7 +30,6 @@ 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; @@ -88,7 +87,6 @@ import org.jboss.resteasy.concurrent.ContextualExecutors; 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; @@ -378,15 +376,9 @@ 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; - - T proxy = (T) Proxy.newProxyInstance(classLoader, interfaces, - new ProxyInvocationHandler(aClass, actualClient, getLocalProviderInstances(), client)); - ClientHeaderProviders.registerForClass(aClass, proxy, beanManager); - return proxy; + return aClass.cast( + ProxyInvocationHandler.createProxy(aClass, actualClient, getLocalProviderInstances(), client, + beanManager)); } @SuppressWarnings("unchecked") diff --git a/testsuite/integration-tests/src/test/java/org/jboss/resteasy/microprofile/test/client/exception/SubResourceTest.java b/testsuite/integration-tests/src/test/java/org/jboss/resteasy/microprofile/test/client/exception/SubResourceTest.java new file mode 100644 index 0000000..233e124 --- /dev/null +++ b/testsuite/integration-tests/src/test/java/org/jboss/resteasy/microprofile/test/client/exception/SubResourceTest.java @@ -0,0 +1,163 @@ +/* + * 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 org.jboss.resteasy.microprofile.test.client.exception; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.net.URISyntaxException; +import java.net.URL; + +import jakarta.ws.rs.core.Response; + +import org.eclipse.microprofile.rest.client.RestClientBuilder; +import org.jboss.arquillian.container.test.api.Deployment; +import org.jboss.arquillian.container.test.api.RunAsClient; +import org.jboss.arquillian.junit.Arquillian; +import org.jboss.arquillian.test.api.ArquillianResource; +import org.jboss.resteasy.microprofile.test.client.exception.resource.ClientRootResource; +import org.jboss.resteasy.microprofile.test.client.exception.resource.ClientSubResource; +import org.jboss.resteasy.microprofile.test.client.exception.resource.ServerResource; +import org.jboss.resteasy.microprofile.test.client.exception.resource.TestException; +import org.jboss.resteasy.microprofile.test.client.exception.resource.TestExceptionMapper; +import org.jboss.resteasy.microprofile.test.util.TestEnvironment; +import org.jboss.shrinkwrap.api.spec.WebArchive; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Tests client sub-resources + * + * @author James R. Perkins + */ +@RunWith(Arquillian.class) +@RunAsClient +public class SubResourceTest { + + @ArquillianResource + private URL url; + + @Deployment + public static WebArchive deployment() { + return TestEnvironment.createWar(SubResourceTest.class) + .addClasses(ServerResource.class); + } + + /** + * 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() throws Exception { + try (ClientRootResource root = createClient(TestExceptionMapper.class)) { + try (Response ignore = root.fromRoot()) { + Assert.fail("fromRoot() should have thrown an TestException"); + } catch (TestException expected) { + Assert.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() throws Exception { + try (ClientRootResource root = createClient(TestExceptionMapper.class)) { + final ClientSubResource subResource = root.subResource(); + Assert.assertNotNull("The SubResource should not be null", subResource); + try (Response ignore = subResource.fromSub()) { + Assert.fail("fromSub() should have thrown an TestException"); + } catch (TestException expected) { + Assert.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 (ClientRootResource root = createClient()) { + final ClientSubResource subResource = root.subResource(); + Assert.assertNotNull("The SubResource should not be null", subResource); + try (Response response = subResource.withHeader()) { + Assert.assertEquals(Response.Status.OK, response.getStatusInfo()); + final String value = response.readEntity(String.class); + Assert.assertEquals("SubResourceHeader", value); + } + } + } + + /** + * 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 (ClientRootResource root = createClient()) { + final ClientSubResource subResource = root.subResource(); + Assert.assertNotNull("The SubResource should not be null", subResource); + try (Response response = subResource.withGlobalHeader()) { + Assert.assertEquals(Response.Status.OK, response.getStatusInfo()); + final String value = response.readEntity(String.class); + Assert.assertEquals("GlobalSubResourceHeader", value); + } + } + } + + private ClientRootResource createClient() throws URISyntaxException { + return createClient(null); + } + + private ClientRootResource createClient(final Class componentType) throws URISyntaxException { + final RestClientBuilder builder = RestClientBuilder.newBuilder() + .baseUri(TestEnvironment.generateUri(url, "test-app")); + if (componentType != null) { + builder.register(componentType); + } + return builder.build(ClientRootResource.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)); + Assert.fail(writer.toString()); + } +} diff --git a/testsuite/integration-tests/src/test/java/org/jboss/resteasy/microprofile/test/client/exception/resource/ClientRootResource.java b/testsuite/integration-tests/src/test/java/org/jboss/resteasy/microprofile/test/client/exception/resource/ClientRootResource.java new file mode 100644 index 0000000..321d454 --- /dev/null +++ b/testsuite/integration-tests/src/test/java/org/jboss/resteasy/microprofile/test/client/exception/resource/ClientRootResource.java @@ -0,0 +1,37 @@ +/* + * 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 org.jboss.resteasy.microprofile.test.client.exception.resource; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Response; + +/** + * @author James R. Perkins + */ +@Path("/root") +public interface ClientRootResource extends AutoCloseable { + + @Path("/sub") + ClientSubResource subResource(); + + @GET + Response fromRoot() throws TestException; +} diff --git a/testsuite/integration-tests/src/test/java/org/jboss/resteasy/microprofile/test/client/exception/resource/ClientSubResource.java b/testsuite/integration-tests/src/test/java/org/jboss/resteasy/microprofile/test/client/exception/resource/ClientSubResource.java new file mode 100644 index 0000000..880c7f1 --- /dev/null +++ b/testsuite/integration-tests/src/test/java/org/jboss/resteasy/microprofile/test/client/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 org.jboss.resteasy.microprofile.test.client.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/testsuite/integration-tests/src/test/java/org/jboss/resteasy/microprofile/test/client/exception/resource/ServerResource.java b/testsuite/integration-tests/src/test/java/org/jboss/resteasy/microprofile/test/client/exception/resource/ServerResource.java new file mode 100644 index 0000000..22299be --- /dev/null +++ b/testsuite/integration-tests/src/test/java/org/jboss/resteasy/microprofile/test/client/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 org.jboss.resteasy.microprofile.test.client.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(); + } +} diff --git a/testsuite/integration-tests/src/test/java/org/jboss/resteasy/microprofile/test/client/exception/resource/TestException.java b/testsuite/integration-tests/src/test/java/org/jboss/resteasy/microprofile/test/client/exception/resource/TestException.java new file mode 100644 index 0000000..f6cef40 --- /dev/null +++ b/testsuite/integration-tests/src/test/java/org/jboss/resteasy/microprofile/test/client/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 org.jboss.resteasy.microprofile.test.client.exception.resource; + +/** + * @author James R. Perkins + */ +public class TestException extends RuntimeException { + + public TestException(final String msg) { + super(msg); + } +} diff --git a/testsuite/integration-tests/src/test/java/org/jboss/resteasy/microprofile/test/client/exception/resource/TestExceptionMapper.java b/testsuite/integration-tests/src/test/java/org/jboss/resteasy/microprofile/test/client/exception/resource/TestExceptionMapper.java new file mode 100644 index 0000000..793a5e2 --- /dev/null +++ b/testsuite/integration-tests/src/test/java/org/jboss/resteasy/microprofile/test/client/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 org.jboss.resteasy.microprofile.test.client.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)); + } +}