diff --git a/bom/runtime/pom.xml b/bom/runtime/pom.xml index bc49223a587c6..58a718106ff2d 100644 --- a/bom/runtime/pom.xml +++ b/bom/runtime/pom.xml @@ -1033,6 +1033,11 @@ quarkus-junit5-mockito ${project.version} + + io.quarkus + quarkus-test-rest-client + ${project.version} + io.quarkus quarkus-arquillian diff --git a/integration-tests/rest-client/pom.xml b/integration-tests/rest-client/pom.xml index b271a14d68b0e..e7f802dc68cef 100644 --- a/integration-tests/rest-client/pom.xml +++ b/integration-tests/rest-client/pom.xml @@ -34,8 +34,7 @@ io.quarkus - quarkus-junit5 - test + quarkus-test-rest-client io.rest-assured diff --git a/integration-tests/rest-client/src/main/java/io/quarkus/it/rest/client/MultipartService.java b/integration-tests/rest-client/src/main/java/io/quarkus/it/rest/client/MultipartService.java index 26021b4751c24..04945608d6bdc 100644 --- a/integration-tests/rest-client/src/main/java/io/quarkus/it/rest/client/MultipartService.java +++ b/integration-tests/rest-client/src/main/java/io/quarkus/it/rest/client/MultipartService.java @@ -1,5 +1,6 @@ package io.quarkus.it.rest.client; +import javax.enterprise.context.ApplicationScoped; import javax.ws.rs.Consumes; import javax.ws.rs.POST; import javax.ws.rs.Path; @@ -11,6 +12,7 @@ @Path("/echo") @RegisterRestClient +@ApplicationScoped public interface MultipartService { @POST @@ -18,4 +20,4 @@ public interface MultipartService { @Produces(MediaType.TEXT_PLAIN) String sendMultipartData(@MultipartForm MultipartBody data); -} \ No newline at end of file +} diff --git a/integration-tests/rest-client/src/main/java/io/quarkus/it/rest/client/server/AnotherEchoService.java b/integration-tests/rest-client/src/main/java/io/quarkus/it/rest/client/server/AnotherEchoService.java new file mode 100644 index 0000000000000..3869a572949ea --- /dev/null +++ b/integration-tests/rest-client/src/main/java/io/quarkus/it/rest/client/server/AnotherEchoService.java @@ -0,0 +1,19 @@ +package io.quarkus.it.rest.client.server; + +import javax.ws.rs.Consumes; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; + +@Path("/another/new") +public class AnotherEchoService { + + @Path("/echo") + @POST + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Produces(MediaType.TEXT_PLAIN) + public String echo(String requestBody) throws Exception { + return "another"; + } +} diff --git a/integration-tests/rest-client/src/main/java/io/quarkus/it/rest/client/server/OtherEchoService.java b/integration-tests/rest-client/src/main/java/io/quarkus/it/rest/client/server/OtherEchoService.java new file mode 100644 index 0000000000000..64b18ce5a5b31 --- /dev/null +++ b/integration-tests/rest-client/src/main/java/io/quarkus/it/rest/client/server/OtherEchoService.java @@ -0,0 +1,18 @@ +package io.quarkus.it.rest.client.server; + +import javax.ws.rs.Consumes; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; + +@Path("/other/echo") +public class OtherEchoService { + + @POST + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Produces(MediaType.TEXT_PLAIN) + public String echo(String requestBody) throws Exception { + return "other"; + } +} diff --git a/integration-tests/rest-client/src/test/java/io/quarkus/it/rest/client/MultipartResourceTest.java b/integration-tests/rest-client/src/test/java/io/quarkus/it/rest/client/MultipartResourceTest.java index 6a3f6cbd217df..baa13483f5ac5 100644 --- a/integration-tests/rest-client/src/test/java/io/quarkus/it/rest/client/MultipartResourceTest.java +++ b/integration-tests/rest-client/src/test/java/io/quarkus/it/rest/client/MultipartResourceTest.java @@ -3,13 +3,23 @@ import static io.restassured.RestAssured.given; import static org.hamcrest.CoreMatchers.containsString; +import java.net.URI; +import java.net.URISyntaxException; + +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import io.quarkus.test.junit.DisabledOnNativeImage; import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.restclient.RestClientTestSupport; +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) @QuarkusTest public class MultipartResourceTest { + @Order(3) // execute this last to make sure that the reset of the base URL is properly performed automatically @Test public void testMultipartDataIsSent() { given() @@ -23,4 +33,30 @@ public void testMultipartDataIsSent() { containsString("greeting.txt")); } + @DisabledOnNativeImage + @Order(1) + @Test + public void testCustomEcho() throws URISyntaxException { + RestClientTestSupport.setBaseURI(MultipartService.class, new URI(System.getProperty("test.url") + "/other")); + given() + .header("Content-Type", "text/plain") + .when().post("/client/multipart") + .then() + .statusCode(200) + .body(containsString("other")); + } + + @DisabledOnNativeImage + @Order(2) + @Test + public void testAnotherCustomEcho() throws URISyntaxException { + RestClientTestSupport.setBaseURI(MultipartService.class, new URI(System.getProperty("test.url") + "/another/new")); + given() + .header("Content-Type", "text/plain") + .when().post("/client/multipart") + .then() + .statusCode(200) + .body(containsString("another")); + } + } diff --git a/test-framework/pom.xml b/test-framework/pom.xml index 4f10d79f93fe2..116d7ebecb81d 100644 --- a/test-framework/pom.xml +++ b/test-framework/pom.xml @@ -28,6 +28,7 @@ maven vault ldap + rest-client diff --git a/test-framework/rest-client/pom.xml b/test-framework/rest-client/pom.xml new file mode 100644 index 0000000000000..06690aedc3059 --- /dev/null +++ b/test-framework/rest-client/pom.xml @@ -0,0 +1,57 @@ + + + 4.0.0 + + + io.quarkus + quarkus-test-framework + 999-SNAPSHOT + ../ + + + quarkus-test-rest-client + Quarkus - Test framework - REST Client + + + io.quarkus + quarkus-junit5 + + + io.quarkus + quarkus-arc-deployment + + + org.jboss.resteasy + resteasy-client-microprofile + + + org.jboss.spec.javax.interceptor + + jboss-interceptors-api_1.2_spec + + + + org.jboss.resteasy + resteasy-cdi + + + + + org.apache.httpcomponents + httpasyncclient + + + commons-logging + commons-logging + + + + + org.jboss.logging + commons-logging-jboss-logging + + + + diff --git a/test-framework/rest-client/src/main/java/io/quarkus/test/restclient/RestClientTestSupport.java b/test-framework/rest-client/src/main/java/io/quarkus/test/restclient/RestClientTestSupport.java new file mode 100644 index 0000000000000..8d495742dbb65 --- /dev/null +++ b/test-framework/rest-client/src/main/java/io/quarkus/test/restclient/RestClientTestSupport.java @@ -0,0 +1,235 @@ +package io.quarkus.test.restclient; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.net.URI; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; + +import javax.enterprise.context.ApplicationScoped; +import javax.enterprise.inject.spi.Bean; +import javax.inject.Singleton; +import javax.ws.rs.client.WebTarget; +import javax.ws.rs.core.UriBuilder; + +import org.eclipse.microprofile.rest.client.inject.RestClient; +import org.jboss.resteasy.client.jaxrs.internal.ClientWebTarget; +import org.jboss.resteasy.client.jaxrs.internal.proxy.ClientInvoker; +import org.jboss.resteasy.client.jaxrs.internal.proxy.ClientProxy; +import org.jboss.resteasy.client.jaxrs.internal.proxy.MethodInvoker; +import org.jboss.resteasy.microprofile.client.ProxyInvocationHandler; +import org.jboss.resteasy.microprofile.client.impl.MpClientWebTarget; +import org.jboss.resteasy.specimpl.ResteasyUriBuilderImpl; + +import io.quarkus.arc.Arc; +import io.quarkus.arc.runtime.ClientProxyUnwrapper; + +/** + * Provides a way for tests to change the baseURI of a rest-client + */ +public class RestClientTestSupport { + + private static final Map, Object> restClientClassToObject = new ConcurrentHashMap<>(); + private static final Map restClientObjectToContext = new ConcurrentHashMap<>(); + + // needed for unwrapping @ApplicationScoped beans + private static final ClientProxyUnwrapper unwrapper = new ClientProxyUnwrapper(); + + /** + * Given a class that is a rest-client interface (that has been made a Quarkus bean in the usual ways) + * sets the a new baseURI. + */ + public static void setBaseURI(Class restClientClass, URI newBaseURI) { + verifyBean(restClientClass); + Object restClient = restClientClassToObject.computeIfAbsent(restClientClass, + (k -> unwrapper.apply(Arc.container().instance(k, RestClient.LITERAL).get()))); + + Map originalUriBuilders = new HashMap<>(); + updateUriBuilder(restClient, restClientClass, c -> { + ResteasyUriBuilderImpl currentURIBuilder = c.getCurrentURIBuilderForMethod(); + UriBuilder newUriBuilder = UriBuilder.fromUri(newBaseURI); + + // currentURIBuilder.getPath() contains the entire path of the request + // meaning it has info from the baseURL, the path defined on the class + // and the path defined on the method. + // We need to make sure that any path of the original baseURL is removed + // before adding the method specific path part + String path = currentURIBuilder.getPath() == null ? "" : currentURIBuilder.getPath(); + if (!path.isEmpty()) { + newUriBuilder.path(path.replace(c.getBasePath(), "")); + } + + c.useUriBuilder(newUriBuilder); + originalUriBuilders.put(c.getUriBuilderField(), currentURIBuilder); + }); + + restClientObjectToContext.put(restClient, new RestClientContext(restClientClass, originalUriBuilders)); + } + + /** + * Restore the original baseURI that was associated with the {@code restClientClass} interface + * + * This method is called automatically by Quarkus after each test and for the time being isn't exposed + */ + private static void resetURL(Class restClientClass) { + Object restClient = restClientClassToObject.get(restClientClass); + if (restClient == null) { + throw new IllegalStateException("Unable to reset URL for rest-client class '" + restClientClass.getName() + + "'. Please make sure the URL had been previously set"); + } + updateUriBuilder(restClient, restClientClass, c -> { + Map originalUriBuilders = restClientObjectToContext.get(restClient).getOriginalUriBuilders(); + UriBuilder originalUriBuilder = originalUriBuilders.get(c.getUriBuilderField()); + c.useUriBuilder(originalUriBuilder); + }); + restClientObjectToContext.remove(restClient); + } + + /** + * Get a collection of rest-client classes that have currently been updated + * + * This method is called automatically by Quarkus after each test and for the time being isn't exposed + */ + private static Collection> activeUpdatedRestClients() { + if (restClientObjectToContext.isEmpty()) { + return Collections.emptyList(); + } + List> classes = new ArrayList<>(restClientObjectToContext.values().size()); + Collection contexts = restClientObjectToContext.values(); + for (RestClientContext context : contexts) { + classes.add(context.getRestClientClass()); + } + return classes; + } + + /** + * This makes use of more reflection that we would like, but currently + * there are no hooks into the internals of the rest-client that would allow + * us to change URIs + */ + private static void updateUriBuilder(Object restClient, Class restClientClass, + Consumer consumer) { + try { + ProxyInvocationHandler mpProxyInvocationHandler = (ProxyInvocationHandler) Proxy + .getInvocationHandler(restClient); + + Field targetField = ProxyInvocationHandler.class.getDeclaredField("target"); + targetField.setAccessible(true); + Object targetObject = targetField.get(mpProxyInvocationHandler); + + ClientProxy clientProxy = (ClientProxy) Proxy.getInvocationHandler(targetObject); + + Field clientProxyTargetField = ClientProxy.class.getDeclaredField("target"); + clientProxyTargetField.setAccessible(true); + WebTarget classWebTarget = (MpClientWebTarget) clientProxyTargetField.get(clientProxy); + ResteasyUriBuilderImpl classUriBuilder = (ResteasyUriBuilderImpl) classWebTarget.getUriBuilder(); + String basePath = getBasePath(restClientClass, classUriBuilder); + + Field methodMapField = ClientProxy.class.getDeclaredField("methodMap"); + methodMapField.setAccessible(true); + Map methodMap = (Map) methodMapField.get(clientProxy); + for (Map.Entry entry : methodMap.entrySet()) { + ClientInvoker clientInvoker = (ClientInvoker) entry.getValue(); + + Field webTargetField = ClientInvoker.class.getDeclaredField("webTarget"); + webTargetField.setAccessible(true); + + ClientWebTarget clientWebTarget = (MpClientWebTarget) webTargetField.get(clientInvoker); + Field uriBuilderField = ClientWebTarget.class.getDeclaredField("uriBuilder"); + uriBuilderField.setAccessible(true); + ResteasyUriBuilderImpl uriBuilder = (ResteasyUriBuilderImpl) uriBuilderField.get(clientWebTarget); + + consumer.accept(new RestClientMethodContext(uriBuilderField, uriBuilder, + clientWebTarget, basePath)); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + /** + * @return The base path of the original baseURL. Never returns {@code null} + */ + private static String getBasePath(Class restClientClass, ResteasyUriBuilderImpl classUriBuilder) { + ResteasyUriBuilderImpl uriBuilderFromClass = (ResteasyUriBuilderImpl) UriBuilder.fromResource(restClientClass); + if (classUriBuilder.getPath() == null) { + return ""; + } + return classUriBuilder.getPath().replace(uriBuilderFromClass.getPath() == null ? "" : uriBuilderFromClass.getPath(), + ""); + } + + private static void verifyBean(Class restClientClass) { + Set> beans = Arc.container().beanManager().getBeans(restClientClass, RestClient.LITERAL); + if (beans.isEmpty()) { + throw new IllegalArgumentException("No RestClient bean of type '" + restClientClass + "' exists"); + } + + Bean bean = beans.iterator().next(); + if (!bean.getScope().equals(ApplicationScoped.class) && !bean.getScope().equals(Singleton.class)) { + throw new IllegalStateException( + "RestClient beans with the default scope or a scope other than '@ApplicationScoped' and '@Singleton' cannot be updated. To be able to update the base URL, consider using one of these scoped"); + } + } + + private static class RestClientMethodContext { + private final Field uriBuilderField; + private final ResteasyUriBuilderImpl currentURIBuilderForMethod; + private final ClientWebTarget clientWebTarget; + private final String basePath; + + public RestClientMethodContext(Field uriBuilderField, ResteasyUriBuilderImpl currentUriBuilder, + ClientWebTarget clientWebTarget, String basePath) { + this.uriBuilderField = uriBuilderField; + this.currentURIBuilderForMethod = currentUriBuilder; + this.clientWebTarget = clientWebTarget; + this.basePath = basePath; + } + + public Field getUriBuilderField() { + return uriBuilderField; + } + + public ResteasyUriBuilderImpl getCurrentURIBuilderForMethod() { + return currentURIBuilderForMethod; + } + + public String getBasePath() { + return basePath; + } + + public void useUriBuilder(UriBuilder uriBuilder) { + try { + uriBuilderField.set(clientWebTarget, uriBuilder); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } + } + + private static class RestClientContext { + private final Class restClientClass; + private final Map originalUriBuilders; + + public RestClientContext(Class restClientClass, Map originalUriBuilders) { + this.restClientClass = restClientClass; + this.originalUriBuilders = originalUriBuilders; + } + + public Class getRestClientClass() { + return restClientClass; + } + + public Map getOriginalUriBuilders() { + return originalUriBuilders; + } + } +} diff --git a/test-framework/rest-client/src/main/java/io/quarkus/test/restclient/internal/ResetRestClients.java b/test-framework/rest-client/src/main/java/io/quarkus/test/restclient/internal/ResetRestClients.java new file mode 100644 index 0000000000000..5f4293ebb1b00 --- /dev/null +++ b/test-framework/rest-client/src/main/java/io/quarkus/test/restclient/internal/ResetRestClients.java @@ -0,0 +1,47 @@ +package io.quarkus.test.restclient.internal; + +import java.lang.reflect.Method; +import java.util.Collection; + +import io.quarkus.test.junit.callback.QuarkusTestAfterEachCallback; +import io.quarkus.test.junit.callback.QuarkusTestMethodContext; +import io.quarkus.test.restclient.RestClientTestSupport; + +/** + * This class resets every rest-client back to what is originally was before it was change from {@link RestClientTestSupport#setBaseURI} + */ +public class ResetRestClients implements QuarkusTestAfterEachCallback { + + // we call the methods with reflection because the API is currently not exposed + + private final Method activeUpdatedRestClientsMethod; + private final Method resetURLMethod; + + public ResetRestClients() { + try { + Class restClientTestSupportClass = Class.forName(RestClientTestSupport.class.getName(), true, + Thread.currentThread().getContextClassLoader()); + + activeUpdatedRestClientsMethod = restClientTestSupportClass.getDeclaredMethod("activeUpdatedRestClients"); + activeUpdatedRestClientsMethod.setAccessible(true); + + resetURLMethod = restClientTestSupportClass.getDeclaredMethod("resetURL", Class.class); + resetURLMethod.setAccessible(true); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Override + @SuppressWarnings("unchecked") + public void afterEach(QuarkusTestMethodContext context) { + try { + Collection> activeUpdatedRestClients = (Collection>) activeUpdatedRestClientsMethod.invoke(null); + for (Class activeUpdatedRestClient : activeUpdatedRestClients) { + resetURLMethod.invoke(null, activeUpdatedRestClient); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/test-framework/rest-client/src/main/resources/META-INF/services/io.quarkus.test.junit.callback.QuarkusTestAfterEachCallback b/test-framework/rest-client/src/main/resources/META-INF/services/io.quarkus.test.junit.callback.QuarkusTestAfterEachCallback new file mode 100644 index 0000000000000..982c339c9ba21 --- /dev/null +++ b/test-framework/rest-client/src/main/resources/META-INF/services/io.quarkus.test.junit.callback.QuarkusTestAfterEachCallback @@ -0,0 +1 @@ +io.quarkus.test.restclient.internal.ResetRestClients