From 420d44c30092c4297617ad18365fdadf1fa8a44a Mon Sep 17 00:00:00 2001 From: Georgios Andrianakis Date: Tue, 9 Jul 2024 17:17:40 +0300 Subject: [PATCH] Add HTTP Proxy Dev Service for REST Client The quarkus-rest-client starts a simple pass-through proxy by default which can be used as a target for Wireshark (or similar tools) in order to capture all the traffic originating from the REST Client (this really makes sense when the REST Client is used against HTTPS services). However, advances integrations that build on the REST Client can register their own proxies by using the DevServicesRestClientProxyProvider SPI --- .../config/RestClientBuildConfig.java | 25 ++ .../restclient/config/RestClientConfig.java | 4 +- .../rest-client/deployment/pom.xml | 4 + .../RegisteredRestClientBuildItem.java | 37 ++ .../RestClientReactiveProcessor.java | 316 +++++++++--------- ...vServicesRestClientHttpProxyProcessor.java | 291 ++++++++++++++++ ...oxyDevServicesRestClientProxyProvider.java | 179 ++++++++++ ...ProxyDevServicesDeclarativeClientTest.java | 83 +++++ ...evServicesMultipleCustomProvidersTest.java | 135 ++++++++ ...roxyDevServicesProgrammaticClientTest.java | 84 +++++ ...xyDevServicesSingleCustomProviderTest.java | 115 +++++++ .../DevServicesRestClientProxyProvider.java | 49 +++ .../spi/RestClientHttpProxyBuildItem.java | 70 ++++ 13 files changed, 1239 insertions(+), 153 deletions(-) create mode 100644 extensions/resteasy-reactive/rest-client/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/RegisteredRestClientBuildItem.java create mode 100644 extensions/resteasy-reactive/rest-client/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/devservices/DevServicesRestClientHttpProxyProcessor.java create mode 100644 extensions/resteasy-reactive/rest-client/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/devservices/VertxHttpProxyDevServicesRestClientProxyProvider.java create mode 100644 extensions/resteasy-reactive/rest-client/deployment/src/test/java/io/quarkus/rest/client/reactive/proxy/HttpProxyDevServicesDeclarativeClientTest.java create mode 100644 extensions/resteasy-reactive/rest-client/deployment/src/test/java/io/quarkus/rest/client/reactive/proxy/HttpProxyDevServicesMultipleCustomProvidersTest.java create mode 100644 extensions/resteasy-reactive/rest-client/deployment/src/test/java/io/quarkus/rest/client/reactive/proxy/HttpProxyDevServicesProgrammaticClientTest.java create mode 100644 extensions/resteasy-reactive/rest-client/deployment/src/test/java/io/quarkus/rest/client/reactive/proxy/HttpProxyDevServicesSingleCustomProviderTest.java create mode 100644 extensions/resteasy-reactive/rest-client/spi-deployment/src/main/java/io/quarkus/rest/client/reactive/spi/DevServicesRestClientProxyProvider.java create mode 100644 extensions/resteasy-reactive/rest-client/spi-deployment/src/main/java/io/quarkus/rest/client/reactive/spi/RestClientHttpProxyBuildItem.java diff --git a/extensions/resteasy-classic/rest-client-config/runtime/src/main/java/io/quarkus/restclient/config/RestClientBuildConfig.java b/extensions/resteasy-classic/rest-client-config/runtime/src/main/java/io/quarkus/restclient/config/RestClientBuildConfig.java index 8dac7273fe320..d24deacf25833 100644 --- a/extensions/resteasy-classic/rest-client-config/runtime/src/main/java/io/quarkus/restclient/config/RestClientBuildConfig.java +++ b/extensions/resteasy-classic/rest-client-config/runtime/src/main/java/io/quarkus/restclient/config/RestClientBuildConfig.java @@ -22,4 +22,29 @@ public class RestClientBuildConfig { */ @ConfigItem public Optional scope; + + /** + * If set to true, then Quarkus will ensure that all calls from the rest client go through a local proxy + * server (that is managed by Quarkus). + * This can be very useful for capturing network traffic to a service that use HTTPS. + *

+ * This property is not applicable to the RESTEasy Client, only the Quarkus Rest client (formerly RESTEasy Reactive client). + *

+ * This property only applicable to dev and test mode. + */ + @ConfigItem(defaultValue = "false") + public boolean enableLocalProxy; + + /** + * This setting is used to select which proxy provider to use if there are multiple ones. + * It only applies if {@code enable-local-proxy} is true. + *

+ * The algorithm for picking between multiple provider is the following: + *

+ */ + public Optional localProxyProvider; } diff --git a/extensions/resteasy-classic/rest-client-config/runtime/src/main/java/io/quarkus/restclient/config/RestClientConfig.java b/extensions/resteasy-classic/rest-client-config/runtime/src/main/java/io/quarkus/restclient/config/RestClientConfig.java index ef030c6c1db6c..3165fdb1c643c 100644 --- a/extensions/resteasy-classic/rest-client-config/runtime/src/main/java/io/quarkus/restclient/config/RestClientConfig.java +++ b/extensions/resteasy-classic/rest-client-config/runtime/src/main/java/io/quarkus/restclient/config/RestClientConfig.java @@ -80,7 +80,7 @@ public class RestClientConfig { * This property is only meant to be set by advanced configurations to override whatever value was set for the uri or url. * The override is done using the REST Client class name configuration syntax. *

- * This property is not applicable to the RESTEasy Client. + * This property is not applicable to the RESTEasy Client, only the Quarkus Rest client (formerly RESTEasy Reactive client). */ @ConfigItem public Optional overrideUri; @@ -406,7 +406,7 @@ public static RestClientConfig load(Class interfaceClass) { return instance; } - private static Optional getConfigValue(String configKey, String fieldName, Class type) { + public static Optional getConfigValue(String configKey, String fieldName, Class type) { final Config config = ConfigProvider.getConfig(); Optional optional = config.getOptionalValue(composePropertyKey(configKey, fieldName), type); if (optional.isEmpty()) { // try to find property with quoted configKey diff --git a/extensions/resteasy-reactive/rest-client/deployment/pom.xml b/extensions/resteasy-reactive/rest-client/deployment/pom.xml index c64bbb0167b0a..5809e48efd730 100644 --- a/extensions/resteasy-reactive/rest-client/deployment/pom.xml +++ b/extensions/resteasy-reactive/rest-client/deployment/pom.xml @@ -36,6 +36,10 @@ io.quarkus quarkus-tls-registry-deployment + + io.vertx + vertx-http-proxy + io.quarkus diff --git a/extensions/resteasy-reactive/rest-client/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/RegisteredRestClientBuildItem.java b/extensions/resteasy-reactive/rest-client/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/RegisteredRestClientBuildItem.java new file mode 100644 index 0000000000000..d1d82f9c64359 --- /dev/null +++ b/extensions/resteasy-reactive/rest-client/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/RegisteredRestClientBuildItem.java @@ -0,0 +1,37 @@ +package io.quarkus.rest.client.reactive.deployment; + +import java.util.Objects; +import java.util.Optional; + +import org.jboss.jandex.ClassInfo; + +import io.quarkus.builder.item.MultiBuildItem; + +/** + * Contains information about the REST Clients that have been discovered via + * {@link org.eclipse.microprofile.rest.client.inject.RegisterRestClient} + */ +public final class RegisteredRestClientBuildItem extends MultiBuildItem { + + private final ClassInfo classInfo; + private final Optional configKey; + private final Optional defaultBaseUri; + + public RegisteredRestClientBuildItem(ClassInfo classInfo, Optional configKey, Optional defaultBaseUri) { + this.classInfo = Objects.requireNonNull(classInfo); + this.configKey = Objects.requireNonNull(configKey); + this.defaultBaseUri = Objects.requireNonNull(defaultBaseUri); + } + + public ClassInfo getClassInfo() { + return classInfo; + } + + public Optional getConfigKey() { + return configKey; + } + + public Optional getDefaultBaseUri() { + return defaultBaseUri; + } +} diff --git a/extensions/resteasy-reactive/rest-client/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/RestClientReactiveProcessor.java b/extensions/resteasy-reactive/rest-client/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/RestClientReactiveProcessor.java index 3ccb72cce6088..3a144757d81ab 100644 --- a/extensions/resteasy-reactive/rest-client/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/RestClientReactiveProcessor.java +++ b/extensions/resteasy-reactive/rest-client/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/RestClientReactiveProcessor.java @@ -16,7 +16,6 @@ import static io.quarkus.rest.client.reactive.deployment.DotNames.REGISTER_PROVIDERS; import static io.quarkus.rest.client.reactive.deployment.DotNames.RESPONSE_EXCEPTION_MAPPER; import static java.util.Arrays.asList; -import static java.util.stream.Collectors.toList; import static org.jboss.resteasy.reactive.common.processor.EndpointIndexer.CDI_WRAPPER_SUFFIX; import static org.jboss.resteasy.reactive.common.processor.JandexUtil.isImplementorOf; import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.APPLICATION; @@ -35,7 +34,6 @@ import java.util.Optional; import java.util.Set; import java.util.function.Predicate; -import java.util.stream.Collectors; import jakarta.enterprise.context.SessionScoped; import jakarta.enterprise.inject.Typed; @@ -406,10 +404,58 @@ void handleSseEventFilter(BuildProducer reflectiveClas .build()); } + @BuildStep + void determineRegisteredRestClients(CombinedIndexBuildItem combinedIndexBuildItem, + RestClientsBuildTimeConfig clientsConfig, + BuildProducer producer) { + CompositeIndex index = CompositeIndex.create(combinedIndexBuildItem.getIndex()); + Set seen = new HashSet<>(); + + List actualInstances = index.getAnnotations(REGISTER_REST_CLIENT); + for (AnnotationInstance instance : actualInstances) { + AnnotationTarget annotationTarget = instance.target(); + ClassInfo classInfo = annotationTarget.asClass(); + if (!Modifier.isAbstract(classInfo.flags())) { + continue; + } + DotName className = classInfo.name(); + seen.add(className); + + AnnotationValue configKeyValue = instance.value("configKey"); + Optional configKey = configKeyValue == null ? Optional.empty() : Optional.of(configKeyValue.asString()); + + AnnotationValue baseUriValue = instance.value("baseUri"); + Optional baseUri = baseUriValue == null ? Optional.empty() : Optional.of(baseUriValue.asString()); + + producer.produce(new RegisteredRestClientBuildItem(classInfo, configKey, baseUri)); + } + + // now we go through the keys and if any of them correspond to classes that don't have a @RegisterRestClient annotation, we fake that annotation + Set configKeyNames = clientsConfig.configs.keySet(); + for (String configKeyName : configKeyNames) { + ClassInfo classInfo = index.getClassByName(configKeyName); + if (classInfo == null) { + continue; + } + if (seen.contains(classInfo.name())) { + continue; + } + if (!Modifier.isAbstract(classInfo.flags())) { + continue; + } + Optional cdiScope = clientsConfig.configs.get(configKeyName).scope; + if (cdiScope.isEmpty()) { + continue; + } + producer.produce(new RegisteredRestClientBuildItem(classInfo, Optional.of(configKeyName), Optional.empty())); + } + } + @BuildStep @Record(ExecutionTime.STATIC_INIT) void addRestClientBeans(Capabilities capabilities, CombinedIndexBuildItem combinedIndexBuildItem, + List registeredRestClients, CustomScopeAnnotationsBuildItem scopes, List restClientAnnotationsTransformerBuildItem, BuildProducer generatedBeans, @@ -419,142 +465,138 @@ void addRestClientBeans(Capabilities capabilities, CompositeIndex index = CompositeIndex.create(combinedIndexBuildItem.getIndex()); - Set registerRestClientAnnos = determineRegisterRestClientInstances(clientsBuildConfig, index); - Map configKeys = new HashMap<>(); var annotationsStore = new AnnotationStore(index, restClientAnnotationsTransformerBuildItem.stream() .map(RestClientAnnotationsTransformerBuildItem::getAnnotationTransformation).toList()); - for (AnnotationInstance registerRestClient : registerRestClientAnnos) { - ClassInfo jaxrsInterface = registerRestClient.target().asClass(); + for (RegisteredRestClientBuildItem registerRestClient : registeredRestClients) { + ClassInfo jaxrsInterface = registerRestClient.getClassInfo(); // for each interface annotated with @RegisterRestClient, generate a $$CDIWrapper CDI bean that can be injected - if (Modifier.isAbstract(jaxrsInterface.flags())) { - validateKotlinDefaultMethods(jaxrsInterface, index); - - List methodsToImplement = new ArrayList<>(); - - // search this interface and its super interfaces for jaxrs methods - searchForJaxRsMethods(methodsToImplement, jaxrsInterface, index); - // search this interface for default methods - // we could search for default methods in super interfaces too, - // but emitting the correct invokespecial instruction would become convoluted - // (as invokespecial may only reference a method from a _direct_ super interface) - for (MethodInfo method : jaxrsInterface.methods()) { - boolean isDefault = !Modifier.isAbstract(method.flags()) && !Modifier.isStatic(method.flags()); - if (isDefault) { - methodsToImplement.add(method); - } + validateKotlinDefaultMethods(jaxrsInterface, index); + + List methodsToImplement = new ArrayList<>(); + + // search this interface and its super interfaces for jaxrs methods + searchForJaxRsMethods(methodsToImplement, jaxrsInterface, index); + // search this interface for default methods + // we could search for default methods in super interfaces too, + // but emitting the correct invokespecial instruction would become convoluted + // (as invokespecial may only reference a method from a _direct_ super interface) + for (MethodInfo method : jaxrsInterface.methods()) { + boolean isDefault = !Modifier.isAbstract(method.flags()) && !Modifier.isStatic(method.flags()); + if (isDefault) { + methodsToImplement.add(method); } - if (methodsToImplement.isEmpty()) { - continue; - } - - String wrapperClassName = jaxrsInterface.name().toString() + CDI_WRAPPER_SUFFIX; - try (ClassCreator classCreator = ClassCreator.builder() - .className(wrapperClassName) - .classOutput(new GeneratedBeanGizmoAdaptor(generatedBeans)) - .interfaces(jaxrsInterface.name().toString()) - .superClass(RestClientReactiveCDIWrapperBase.class) - .build()) { - - // CLASS LEVEL - final Optional configKey = getConfigKey(registerRestClient); - - configKey.ifPresent( - key -> configKeys.put(jaxrsInterface.name().toString(), key)); - - final ScopeInfo scope = computeDefaultScope(capabilities, ConfigProvider.getConfig(), jaxrsInterface, - configKey); - // add a scope annotation, e.g. @Singleton - classCreator.addAnnotation(scope.getDotName().toString()); - classCreator.addAnnotation(RestClient.class); - // e.g. @Typed({InterfaceClass.class}) - // needed for CDI to inject the proper wrapper in case of - // subinterfaces - org.objectweb.asm.Type asmType = org.objectweb.asm.Type - .getObjectType(jaxrsInterface.name().toString().replace('.', '/')); - classCreator.addAnnotation(Typed.class.getName(), RetentionPolicy.RUNTIME) - .addValue("value", new org.objectweb.asm.Type[] { asmType }); - - for (AnnotationInstance annotation : annotationsStore.getAnnotations(jaxrsInterface)) { - if (SKIP_COPYING_ANNOTATIONS_TO_GENERATED_CLASS.contains(annotation.name())) { - continue; - } - - // scope annotation is added to the generated class already, see above - if (scopes.isScopeIn(Set.of(annotation))) { - continue; - } + } + if (methodsToImplement.isEmpty()) { + continue; + } - classCreator.addAnnotation(annotation); + String wrapperClassName = jaxrsInterface.name().toString() + CDI_WRAPPER_SUFFIX; + try (ClassCreator classCreator = ClassCreator.builder() + .className(wrapperClassName) + .classOutput(new GeneratedBeanGizmoAdaptor(generatedBeans)) + .interfaces(jaxrsInterface.name().toString()) + .superClass(RestClientReactiveCDIWrapperBase.class) + .build()) { + + // CLASS LEVEL + final Optional configKey = registerRestClient.getConfigKey(); + + configKey.ifPresent( + key -> configKeys.put(jaxrsInterface.name().toString(), key)); + + final ScopeInfo scope = computeDefaultScope(capabilities, ConfigProvider.getConfig(), jaxrsInterface, + configKey); + // add a scope annotation, e.g. @Singleton + classCreator.addAnnotation(scope.getDotName().toString()); + classCreator.addAnnotation(RestClient.class); + // e.g. @Typed({InterfaceClass.class}) + // needed for CDI to inject the proper wrapper in case of + // subinterfaces + org.objectweb.asm.Type asmType = org.objectweb.asm.Type + .getObjectType(jaxrsInterface.name().toString().replace('.', '/')); + classCreator.addAnnotation(Typed.class.getName(), RetentionPolicy.RUNTIME) + .addValue("value", new org.objectweb.asm.Type[] { asmType }); + + for (AnnotationInstance annotation : annotationsStore.getAnnotations(jaxrsInterface)) { + if (SKIP_COPYING_ANNOTATIONS_TO_GENERATED_CLASS.contains(annotation.name())) { + continue; } - // CONSTRUCTOR: - - MethodCreator constructor = classCreator - .getMethodCreator(MethodDescriptor.ofConstructor(classCreator.getClassName())); - - AnnotationValue baseUri = registerRestClient.value("baseUri"); - - ResultHandle baseUriHandle = constructor.load(baseUri != null ? baseUri.asString() : ""); - constructor.invokeSpecialMethod( - MethodDescriptor.ofConstructor(RestClientReactiveCDIWrapperBase.class, Class.class, String.class, - String.class, boolean.class), - constructor.getThis(), - constructor.loadClassFromTCCL(jaxrsInterface.toString()), - baseUriHandle, - configKey.isPresent() ? constructor.load(configKey.get()) : constructor.loadNull(), - constructor.load(scope.getDotName().equals(REQUEST_SCOPED))); - constructor.returnValue(null); - - // METHODS: - for (MethodInfo method : methodsToImplement) { - // for each method that corresponds to making a rest call, create a method like: - // public JsonArray get() { - // return ((InterfaceClass)this.getDelegate()).get(); - // } - // - // for each default method, create a method like: - // public JsonArray get() { - // return InterfaceClass.super.get(); - // } - MethodCreator methodCreator = classCreator.getMethodCreator(MethodDescriptor.of(method)); - methodCreator.setSignature(method.genericSignatureIfRequired()); - - // copy method annotations, there can be interceptors bound to them: - for (AnnotationInstance annotation : annotationsStore.getAnnotations(method)) { - if (annotation.target().kind() == AnnotationTarget.Kind.METHOD - && !BUILTIN_HTTP_ANNOTATIONS_TO_METHOD.containsKey(annotation.name()) - && !ResteasyReactiveDotNames.PATH.equals(annotation.name())) { - methodCreator.addAnnotation(annotation); - } - if (annotation.target().kind() == AnnotationTarget.Kind.METHOD_PARAMETER) { - // TODO should skip annotations like `@PathParam` / `@RestPath`, probably (?) - short position = annotation.target().asMethodParameter().position(); - methodCreator.getParameterAnnotations(position).addAnnotation(annotation); - } - } + // scope annotation is added to the generated class already, see above + if (scopes.isScopeIn(Set.of(annotation))) { + continue; + } - ResultHandle result; + classCreator.addAnnotation(annotation); + } - int parameterCount = method.parameterTypes().size(); - ResultHandle[] params = new ResultHandle[parameterCount]; - for (int i = 0; i < parameterCount; i++) { - params[i] = methodCreator.getMethodParam(i); + // CONSTRUCTOR: + + MethodCreator constructor = classCreator + .getMethodCreator(MethodDescriptor.ofConstructor(classCreator.getClassName())); + + Optional baseUri = registerRestClient.getDefaultBaseUri(); + + ResultHandle baseUriHandle = constructor.load(baseUri.isPresent() ? baseUri.get() : ""); + constructor.invokeSpecialMethod( + MethodDescriptor.ofConstructor(RestClientReactiveCDIWrapperBase.class, Class.class, String.class, + String.class, boolean.class), + constructor.getThis(), + constructor.loadClassFromTCCL(jaxrsInterface.toString()), + baseUriHandle, + configKey.isPresent() ? constructor.load(configKey.get()) : constructor.loadNull(), + constructor.load(scope.getDotName().equals(REQUEST_SCOPED))); + constructor.returnValue(null); + + // METHODS: + for (MethodInfo method : methodsToImplement) { + // for each method that corresponds to making a rest call, create a method like: + // public JsonArray get() { + // return ((InterfaceClass)this.getDelegate()).get(); + // } + // + // for each default method, create a method like: + // public JsonArray get() { + // return InterfaceClass.super.get(); + // } + MethodCreator methodCreator = classCreator.getMethodCreator(MethodDescriptor.of(method)); + methodCreator.setSignature(method.genericSignatureIfRequired()); + + // copy method annotations, there can be interceptors bound to them: + for (AnnotationInstance annotation : annotationsStore.getAnnotations(method)) { + if (annotation.target().kind() == AnnotationTarget.Kind.METHOD + && !BUILTIN_HTTP_ANNOTATIONS_TO_METHOD.containsKey(annotation.name()) + && !ResteasyReactiveDotNames.PATH.equals(annotation.name())) { + methodCreator.addAnnotation(annotation); } + if (annotation.target().kind() == AnnotationTarget.Kind.METHOD_PARAMETER) { + // TODO should skip annotations like `@PathParam` / `@RestPath`, probably (?) + short position = annotation.target().asMethodParameter().position(); + methodCreator.getParameterAnnotations(position).addAnnotation(annotation); + } + } - if (Modifier.isAbstract(method.flags())) { // RestClient method - ResultHandle delegate = methodCreator.invokeVirtualMethod( - MethodDescriptor.ofMethod(RestClientReactiveCDIWrapperBase.class, "getDelegate", - Object.class), - methodCreator.getThis()); + ResultHandle result; - result = methodCreator.invokeInterfaceMethod(method, delegate, params); - } else { // default method - result = methodCreator.invokeSpecialInterfaceMethod(method, methodCreator.getThis(), params); - } + int parameterCount = method.parameterTypes().size(); + ResultHandle[] params = new ResultHandle[parameterCount]; + for (int i = 0; i < parameterCount; i++) { + params[i] = methodCreator.getMethodParam(i); + } - methodCreator.returnValue(result); + if (Modifier.isAbstract(method.flags())) { // RestClient method + ResultHandle delegate = methodCreator.invokeVirtualMethod( + MethodDescriptor.ofMethod(RestClientReactiveCDIWrapperBase.class, "getDelegate", + Object.class), + methodCreator.getThis()); + + result = methodCreator.invokeInterfaceMethod(method, delegate, params); + } else { // default method + result = methodCreator.invokeSpecialInterfaceMethod(method, methodCreator.getThis(), params); } + + methodCreator.returnValue(result); } } } @@ -581,34 +623,6 @@ && isImplementorOf(index, target.asClass(), RESPONSE_EXCEPTION_MAPPER, Set.of(AP } } - private Set determineRegisterRestClientInstances(RestClientsBuildTimeConfig clientsConfig, - CompositeIndex index) { - // these are the actual instances - Set registerRestClientAnnos = new HashSet<>(index.getAnnotations(REGISTER_REST_CLIENT)); - // a set of the original target class - Set registerRestClientTargets = registerRestClientAnnos.stream().map(ai -> ai.target().asClass()).collect( - Collectors.toSet()); - - // now we go through the keys and if any of them correspond to classes that don't have a @RegisterRestClient annotation, we fake that annotation - Set configKeyNames = clientsConfig.configs.keySet(); - for (String configKeyName : configKeyNames) { - ClassInfo classInfo = index.getClassByName(configKeyName); - if (classInfo == null) { - continue; - } - if (registerRestClientTargets.contains(classInfo)) { - continue; - } - Optional cdiScope = clientsConfig.configs.get(configKeyName).scope; - if (cdiScope.isEmpty()) { - continue; - } - registerRestClientAnnos.add(AnnotationInstance.builder(REGISTER_REST_CLIENT).add("configKey", configKeyName) - .buildWithTarget(classInfo)); - } - return registerRestClientAnnos; - } - /** * Based on a list of interfaces implemented by @Provider class, determine if registration * should be skipped or not. Server-specific types should be omitted unless implementation diff --git a/extensions/resteasy-reactive/rest-client/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/devservices/DevServicesRestClientHttpProxyProcessor.java b/extensions/resteasy-reactive/rest-client/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/devservices/DevServicesRestClientHttpProxyProcessor.java new file mode 100644 index 0000000000000..60ac73dac04ff --- /dev/null +++ b/extensions/resteasy-reactive/rest-client/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/devservices/DevServicesRestClientHttpProxyProcessor.java @@ -0,0 +1,291 @@ +package io.quarkus.rest.client.reactive.deployment.devservices; + +import java.io.Closeable; +import java.io.IOException; +import java.net.URI; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.exception.UncheckedException; +import org.jboss.jandex.ClassInfo; +import org.jboss.jandex.IndexView; +import org.jboss.logging.Logger; + +import io.quarkus.deployment.IsNormal; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.BuildSteps; +import io.quarkus.deployment.builditem.CombinedIndexBuildItem; +import io.quarkus.deployment.builditem.CuratedApplicationShutdownBuildItem; +import io.quarkus.deployment.builditem.DevServicesResultBuildItem; +import io.quarkus.rest.client.reactive.deployment.RegisteredRestClientBuildItem; +import io.quarkus.rest.client.reactive.spi.DevServicesRestClientProxyProvider; +import io.quarkus.rest.client.reactive.spi.RestClientHttpProxyBuildItem; +import io.quarkus.restclient.config.RestClientBuildConfig; +import io.quarkus.restclient.config.RestClientConfig; +import io.quarkus.restclient.config.RestClientsBuildTimeConfig; + +@BuildSteps(onlyIfNot = IsNormal.class) +public class DevServicesRestClientHttpProxyProcessor { + + private static final Logger log = Logger.getLogger(DevServicesRestClientHttpProxyProcessor.class); + + // the following fields are needed for state management as proxied can come and go + private static final AtomicReference> runningProxies = new AtomicReference<>( + new HashSet<>()); + private static final AtomicReference> runningProviders = new AtomicReference<>( + Collections.newSetFromMap(new IdentityHashMap<>())); + private static final AtomicReference> providerCloseables = new AtomicReference<>( + Collections.newSetFromMap(new IdentityHashMap<>())); + + @BuildStep + public DevServicesRestClientProxyProvider.BuildItem registerDefaultProvider() { + return new DevServicesRestClientProxyProvider.BuildItem(VertxHttpProxyDevServicesRestClientProxyProvider.INSTANCE); + } + + @BuildStep + public void determineRequiredProxies(RestClientsBuildTimeConfig restClientsBuildTimeConfig, + CombinedIndexBuildItem combinedIndexBuildItem, + List registeredRestClientBuildItems, + BuildProducer producer) { + if (restClientsBuildTimeConfig.configs.isEmpty()) { + return; + } + + IndexView index = combinedIndexBuildItem.getIndex(); + + Map configs = restClientsBuildTimeConfig.configs; + for (var configEntry : configs.entrySet()) { + if (!configEntry.getValue().enableLocalProxy) { + log.trace("Ignoring config key: '" + configEntry.getKey() + "' because enableLocalProxy is false"); + break; + } + + String configKey = sanitizeKey(configEntry.getKey()); + + RegisteredRestClientBuildItem matchingBI = null; + // check if the configKey matches one of the @RegisterRestClient values + for (RegisteredRestClientBuildItem bi : registeredRestClientBuildItems) { + if (bi.getConfigKey().isPresent() && configKey.equals(bi.getConfigKey().get())) { + matchingBI = bi; + break; + } + } + if (matchingBI != null) { + Optional baseUri = oneOf( + RestClientConfig.getConfigValue(configKey, "uri", String.class), + RestClientConfig.getConfigValue(configKey, "url", String.class), + matchingBI.getDefaultBaseUri()); + + if (baseUri.isEmpty()) { + log.debug("Unable to determine uri or url for config key '" + configKey + "'"); + break; + } + producer.produce(new RestClientHttpProxyBuildItem(matchingBI.getClassInfo().name().toString(), baseUri.get(), + configEntry.getValue().localProxyProvider)); + } else { + // now we check if the configKey was actually a class name + ClassInfo classInfo = index.getClassByName(configKey); + if (classInfo == null) { + log.debug( + "Key '" + configKey + "' could not be matched to either a class name or a REST Client's configKey"); + break; + } + Optional baseUri = oneOf( + RestClientConfig.getConfigValue(configKey, "uri", String.class), + RestClientConfig.getConfigValue(configKey, "url", String.class)); + if (baseUri.isEmpty()) { + log.debug("Unable to determine uri or url for config key '" + configKey + "'"); + break; + } + producer.produce(new RestClientHttpProxyBuildItem(classInfo.name().toString(), baseUri.get(), + configEntry.getValue().localProxyProvider)); + } + } + } + + private String sanitizeKey(String key) { + if (key.startsWith("\"") && key.endsWith("\"")) { + return key.substring(1, key.length() - 1); + } + return key; + } + + @BuildStep + public void start(List restClientHttpProxyBuildItems, + List restClientProxyProviderBuildItems, + BuildProducer devServicePropertiesProducer, + CuratedApplicationShutdownBuildItem closeBuildItem) { + if (restClientHttpProxyBuildItems.isEmpty()) { + return; + } + + Set requestedProxies = new HashSet<>(restClientHttpProxyBuildItems); + + Set proxiesToClose = new HashSet<>(runningProxies.get()); + proxiesToClose.removeAll(requestedProxies); + + // we need to remove the running ones that should no longer be running + for (var running : proxiesToClose) { + closeRunningProxy(running); + } + runningProxies.get().removeAll(proxiesToClose); + + // we need to figure out which ones to start + Set proxiesToRun = new HashSet<>(requestedProxies); + proxiesToRun.removeAll(runningProxies.get()); + + // determine which providers to use for each of the new proxies to start + Map biToProviderMap = new HashMap<>(); + for (var toStart : proxiesToRun) { + DevServicesRestClientProxyProvider provider; + if (toStart.getProvider().isPresent()) { + String requestedProviderName = toStart.getProvider().get(); + + var maybeProviderBI = restClientProxyProviderBuildItems + .stream() + .filter(pbi -> requestedProviderName.equals(pbi.getProvider().name())) + .findFirst(); + if (maybeProviderBI.isEmpty()) { + throw new RuntimeException("Unable to find provider for REST Client '" + toStart.getClassName() + + "' with name '" + requestedProviderName + "'"); + } + + provider = maybeProviderBI.get().getProvider(); + } else { + // the algorithm is the following: + // if only the default is around, use it + // if there is only one besides the default, use it + // if there are multiple ones, fail + + List nonDefault = restClientProxyProviderBuildItems.stream() + .filter(pib -> !pib.getProvider().name().equals(VertxHttpProxyDevServicesRestClientProxyProvider.NAME)) + .toList(); + if (nonDefault.isEmpty()) { + provider = VertxHttpProxyDevServicesRestClientProxyProvider.INSTANCE; + } else if (nonDefault.size() == 1) { + // TODO: this part of the algorithm is questionable... + provider = nonDefault.iterator().next().getProvider(); + } else { + String availableProviders = restClientProxyProviderBuildItems.stream().map(bi -> bi.getProvider().name()) + .collect( + Collectors.joining(",")); + throw new RuntimeException("Multiple providers found for REST Client '" + toStart.getClassName() + + "'. Please specify one by setting 'quarkus.rest-client.\"" + toStart.getClassName() + + "\".local-proxy-provider' to one the following providers: " + availableProviders); + } + } + + biToProviderMap.put(toStart, provider); + } + + // here is where we set up providers + var providersToRun = new HashSet<>(biToProviderMap.values()); + providersToRun.removeAll(runningProviders.get()); + for (var provider : providersToRun) { + Closeable closeable = provider.setup(); + if (closeable != null) { + providerCloseables.get().add(closeable); + } + runningProviders.get().add(provider); + } + + // this is where we actually start proxies + for (var bi : proxiesToRun) { + URI baseUri = URI.create(bi.getBaseUri()); + + var provider = biToProviderMap.get(bi); + var createResult = provider.create(bi); + var proxyServerClosable = createResult.closeable(); + bi.attachClosable(proxyServerClosable); + runningProxies.get().add(bi); + + var urlKeyName = String.format("quarkus.rest-client.\"%s\".override-uri", bi.getClassName()); + var urlKeyValue = String.format("http://%s:%d", createResult.host(), createResult.port()); + if (baseUri.getPath() != null) { + if (!"/".equals(baseUri.getPath()) && !baseUri.getPath().isEmpty()) { + urlKeyValue = urlKeyValue + "/" + baseUri.getPath(); + } + } + + devServicePropertiesProducer.produce( + new DevServicesResultBuildItem("rest-client-" + bi.getClassName() + "-proxy", + null, + Map.of(urlKeyName, urlKeyValue))); + } + + closeBuildItem.addCloseTask(new CloseTask(runningProxies, providerCloseables, runningProviders), true); + } + + private static void closeRunningProxy(RestClientHttpProxyBuildItem running) { + try { + Closeable closeable = running.getCloseable(); + if (closeable != null) { + log.debug("Attempting to close HTTP proxy server for REST Client '" + running.getClassName() + "'"); + closeable.close(); + log.debug("Closed HTTP proxy server for REST Client '" + running.getClassName() + "'"); + } + } catch (IOException e) { + throw new UncheckedException(e); + } + } + + @SafeVarargs + private static Optional oneOf(Optional... optionals) { + for (Optional o : optionals) { + if (o != null && o.isPresent()) { + return o; + } + } + return Optional.empty(); + } + + private static class CloseTask implements Runnable { + + private final AtomicReference> runningProxiesRef; + private final AtomicReference> providerCloseablesRef; + private final AtomicReference> runningProvidersRef; + + public CloseTask(AtomicReference> runningProxiesRef, + AtomicReference> providerCloseablesRef, + AtomicReference> runningProvidersRef) { + + this.runningProxiesRef = runningProxiesRef; + this.providerCloseablesRef = providerCloseablesRef; + this.runningProvidersRef = runningProvidersRef; + } + + @Override + public void run() { + Set restClientHttpProxyBuildItems = runningProxiesRef.get(); + for (var bi : restClientHttpProxyBuildItems) { + closeRunningProxy(bi); + } + runningProxiesRef.set(new HashSet<>()); + + Set providerCloseables = providerCloseablesRef.get(); + for (Closeable closeable : providerCloseables) { + try { + if (closeable != null) { + log.debug("Attempting to close provider"); + closeable.close(); + log.debug("Closed provider"); + } + } catch (IOException e) { + throw new UncheckedException(e); + } + } + providerCloseablesRef.set(Collections.newSetFromMap(new IdentityHashMap<>())); + + runningProvidersRef.set(Collections.newSetFromMap(new IdentityHashMap<>())); + } + } +} diff --git a/extensions/resteasy-reactive/rest-client/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/devservices/VertxHttpProxyDevServicesRestClientProxyProvider.java b/extensions/resteasy-reactive/rest-client/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/devservices/VertxHttpProxyDevServicesRestClientProxyProvider.java new file mode 100644 index 0000000000000..4965a4d35c457 --- /dev/null +++ b/extensions/resteasy-reactive/rest-client/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/devservices/VertxHttpProxyDevServicesRestClientProxyProvider.java @@ -0,0 +1,179 @@ +package io.quarkus.rest.client.reactive.deployment.devservices; + +import static io.vertx.core.spi.resolver.ResolverProvider.DISABLE_DNS_RESOLVER_PROP_NAME; + +import java.io.Closeable; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.ServerSocket; +import java.net.URI; +import java.util.concurrent.atomic.AtomicReference; + +import org.jboss.logging.Logger; + +import io.quarkus.rest.client.reactive.spi.DevServicesRestClientProxyProvider; +import io.quarkus.rest.client.reactive.spi.RestClientHttpProxyBuildItem; +import io.quarkus.runtime.ResettableSystemProperties; +import io.vertx.core.Future; +import io.vertx.core.MultiMap; +import io.vertx.core.Vertx; +import io.vertx.core.VertxOptions; +import io.vertx.core.file.FileSystemOptions; +import io.vertx.core.http.HttpClient; +import io.vertx.core.http.HttpClientOptions; +import io.vertx.core.http.HttpServer; +import io.vertx.core.metrics.MetricsOptions; +import io.vertx.httpproxy.HttpProxy; +import io.vertx.httpproxy.ProxyContext; +import io.vertx.httpproxy.ProxyInterceptor; +import io.vertx.httpproxy.ProxyRequest; +import io.vertx.httpproxy.ProxyResponse; + +/** + * A simple implementation of {@link DevServicesRestClientProxyProvider} that creates a pass-through proxy + * based on {@code vertx-http-proxy} + */ +public class VertxHttpProxyDevServicesRestClientProxyProvider implements DevServicesRestClientProxyProvider { + + public static final VertxHttpProxyDevServicesRestClientProxyProvider INSTANCE = new VertxHttpProxyDevServicesRestClientProxyProvider(); + + static final String NAME = "default"; + + protected static final Logger log = Logger.getLogger(VertxHttpProxyDevServicesRestClientProxyProvider.class); + + private static final AtomicReference vertx = new AtomicReference<>(); + + // protected for testing + protected VertxHttpProxyDevServicesRestClientProxyProvider() { + } + + @Override + public String name() { + return NAME; + } + + @Override + public Closeable setup() { + if (vertx.get() == null) { + vertx.set(createVertx()); + } + + return new VertxClosingCloseable(vertx); + } + + @Override + public CreateResult create(RestClientHttpProxyBuildItem buildItem) { + URI baseUri = URI.create(buildItem.getBaseUri()); + + var clientOptions = new HttpClientOptions(); + if (baseUri.getScheme().equals("https")) { + clientOptions.setSsl(true); + } + HttpClient proxyClient = vertx.get().createHttpClient(clientOptions); + HttpProxy proxy = HttpProxy.reverseProxy(proxyClient); + proxy.origin(determineOriginPort(baseUri), baseUri.getHost()) + .addInterceptor(new HostSettingInterceptor(baseUri.getHost())); + + HttpServer proxyServer = vertx.get().createHttpServer(); + Integer port = findRandomPort(); + proxyServer.requestHandler(proxy).listen(port); + + logStartup(buildItem.getClassName(), port); + + return new CreateResult("localhost", port, new HttpServerClosable(proxyServer)); + } + + protected void logStartup(String className, Integer port) { + log.info("Started HTTP proxy server on http://localhost:" + port + " for REST Client '" + className + + "'"); + } + + private Vertx createVertx() { + try (var ignored = ResettableSystemProperties.of( + DISABLE_DNS_RESOLVER_PROP_NAME, "true")) { + return Vertx.vertx( + new VertxOptions() + .setFileSystemOptions( + new FileSystemOptions().setFileCachingEnabled(false).setClassPathResolvingEnabled(false)) + .setMetricsOptions(new MetricsOptions().setEnabled(false)) + .setEventLoopPoolSize(2) + .setWorkerPoolSize(2) + .setInternalBlockingPoolSize(2)); + } + } + + private int determineOriginPort(URI baseUri) { + if (baseUri.getPort() != -1) { + return baseUri.getPort(); + } + if (baseUri.getScheme().equals("https")) { + return 443; + } + return 80; + } + + private Integer findRandomPort() { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /** + * This class sets the Host HTTP Header in order to avoid having services being blocked + * for presenting a wrong value + */ + private static class HostSettingInterceptor implements ProxyInterceptor { + + private final String host; + + private HostSettingInterceptor(String host) { + this.host = host; + } + + @Override + public Future handleProxyRequest(ProxyContext context) { + ProxyRequest request = context.request(); + MultiMap headers = request.headers(); + headers.set("Host", host); + + return context.sendRequest(); + } + } + + private static class HttpServerClosable implements Closeable { + private final HttpServer server; + + public HttpServerClosable(HttpServer server) { + this.server = server; + } + + @Override + public void close() throws IOException { + try { + server.close().toCompletionStage().toCompletableFuture().join(); + } catch (Exception e) { + log.debug("Error closing HTTP Proxy server", e); + } + } + } + + private static class VertxClosingCloseable implements Closeable { + private final AtomicReference vertx; + + public VertxClosingCloseable(AtomicReference vertx) { + this.vertx = vertx; + } + + @Override + public void close() { + try { + vertx.get().close().toCompletionStage().toCompletableFuture().join(); + } catch (Exception e) { + log.debug("Error closing Vertx", e); + } + vertx.set(null); + } + } +} diff --git a/extensions/resteasy-reactive/rest-client/deployment/src/test/java/io/quarkus/rest/client/reactive/proxy/HttpProxyDevServicesDeclarativeClientTest.java b/extensions/resteasy-reactive/rest-client/deployment/src/test/java/io/quarkus/rest/client/reactive/proxy/HttpProxyDevServicesDeclarativeClientTest.java new file mode 100644 index 0000000000000..89f5a9f49e11a --- /dev/null +++ b/extensions/resteasy-reactive/rest-client/deployment/src/test/java/io/quarkus/rest/client/reactive/proxy/HttpProxyDevServicesDeclarativeClientTest.java @@ -0,0 +1,83 @@ +package io.quarkus.rest.client.reactive.proxy; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.function.Consumer; +import java.util.logging.Level; +import java.util.logging.LogRecord; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; +import org.eclipse.microprofile.rest.client.inject.RestClient; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +public class HttpProxyDevServicesDeclarativeClientTest { + + @RegisterExtension + static final QuarkusUnitTest TEST = new QuarkusUnitTest() + .withApplicationRoot( + jar -> jar.addClasses(Resource.class, Client.class)) + .overrideConfigKey("quarkus.rest-client.\"client\".enable-local-proxy", "true") + .overrideConfigKey("quarkus.rest-client.\"client\".url", "http://localhost:${quarkus.http.test-port:8081}") + .setLogRecordPredicate(record -> record.getLevel().equals(Level.INFO)) + .assertLogRecords(new Consumer<>() { + @Override + public void accept(List logRecords) { + assertThat(logRecords).extracting(LogRecord::getMessage) + .anyMatch(message -> message.startsWith("Started HTTP proxy server") && message.endsWith( + "REST Client 'io.quarkus.rest.client.reactive.proxy.HttpProxyDevServicesDeclarativeClientTest$Client'")); + } + }); + + @RestClient + Client client; + + @ConfigProperty(name = "quarkus.rest-client.\"io.quarkus.rest.client.reactive.proxy.HttpProxyDevServicesDeclarativeClientTest$Client\".override-uri") + String proxyUrl; + + @Test + public void test() { + + // test that the proxy works as expected + given() + .baseUri(proxyUrl) + .get("test/count") + .then() + .statusCode(200) + .body(equalTo("10")); + + // test that the client works as expected + long result = client.count(); + assertEquals(10, result); + + } + + @Path("test") + @RegisterRestClient(configKey = "client") + public interface Client { + + @Path("count") + @GET + long count(); + } + + @Path("test") + public static class Resource { + + @GET + @Path("count") + public long count() { + return 10; + } + } +} diff --git a/extensions/resteasy-reactive/rest-client/deployment/src/test/java/io/quarkus/rest/client/reactive/proxy/HttpProxyDevServicesMultipleCustomProvidersTest.java b/extensions/resteasy-reactive/rest-client/deployment/src/test/java/io/quarkus/rest/client/reactive/proxy/HttpProxyDevServicesMultipleCustomProvidersTest.java new file mode 100644 index 0000000000000..663c8085b3075 --- /dev/null +++ b/extensions/resteasy-reactive/rest-client/deployment/src/test/java/io/quarkus/rest/client/reactive/proxy/HttpProxyDevServicesMultipleCustomProvidersTest.java @@ -0,0 +1,135 @@ +package io.quarkus.rest.client.reactive.proxy; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.net.URI; +import java.util.List; +import java.util.function.Consumer; +import java.util.logging.Level; +import java.util.logging.LogRecord; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.builder.BuildChainBuilder; +import io.quarkus.builder.BuildContext; +import io.quarkus.builder.BuildStep; +import io.quarkus.rest.client.reactive.QuarkusRestClientBuilder; +import io.quarkus.rest.client.reactive.deployment.devservices.VertxHttpProxyDevServicesRestClientProxyProvider; +import io.quarkus.rest.client.reactive.spi.DevServicesRestClientProxyProvider; +import io.quarkus.test.QuarkusUnitTest; + +public class HttpProxyDevServicesMultipleCustomProvidersTest { + + @RegisterExtension + static final QuarkusUnitTest TEST = new QuarkusUnitTest() + .withApplicationRoot( + jar -> jar.addClasses(Resource.class, Client.class, Custom1DevServicesRestClientProxyProvider.class, + Custom2DevServicesRestClientProxyProvider.class)) + .overrideConfigKey( + "quarkus.rest-client.\"io.quarkus.rest.client.reactive.proxy.HttpProxyDevServicesMultipleCustomProvidersTest$Client\".local-proxy-provider", + "custom2") + .overrideConfigKey( + "quarkus.rest-client.\"io.quarkus.rest.client.reactive.proxy.HttpProxyDevServicesMultipleCustomProvidersTest$Client\".enable-local-proxy", + "true") + .overrideConfigKey( + "quarkus.rest-client.\"io.quarkus.rest.client.reactive.proxy.HttpProxyDevServicesMultipleCustomProvidersTest$Client\".url", + "http://localhost:${quarkus.http.test-port:8081}") + .setLogRecordPredicate(record -> record.getLevel().equals(Level.INFO)) + .assertLogRecords(new Consumer<>() { + @Override + public void accept(List logRecords) { + assertThat(logRecords).extracting(LogRecord::getMessage) + .anyMatch(message -> message.startsWith("Started custom2 HTTP proxy server") && message.endsWith( + "REST Client 'io.quarkus.rest.client.reactive.proxy.HttpProxyDevServicesMultipleCustomProvidersTest$Client'")); + } + }) + .addBuildChainCustomizer(new Consumer<>() { + @Override + public void accept(BuildChainBuilder buildChainBuilder) { + buildChainBuilder.addBuildStep(new BuildStep() { + @Override + public void execute(BuildContext context) { + context.produce( + new DevServicesRestClientProxyProvider.BuildItem( + new Custom1DevServicesRestClientProxyProvider())); + context.produce( + new DevServicesRestClientProxyProvider.BuildItem( + new Custom2DevServicesRestClientProxyProvider())); + } + }).produces(DevServicesRestClientProxyProvider.BuildItem.class).build(); + } + }); + + @ConfigProperty(name = "quarkus.rest-client.\"io.quarkus.rest.client.reactive.proxy.HttpProxyDevServicesMultipleCustomProvidersTest$Client\".override-uri") + String proxyUrl; + + @Test + public void test() { + Client client = QuarkusRestClientBuilder.newBuilder().baseUri(URI.create("http://unused.dev")).build(Client.class); + + // test that the proxy works as expected + given() + .baseUri(proxyUrl) + .get("test/count") + .then() + .statusCode(200) + .body(equalTo("10")); + + // test that the client works as expected + long result = client.count(); + assertEquals(10, result); + } + + @Path("test") + public interface Client { + + @Path("count") + @GET + long count(); + } + + @Path("test") + public static class Resource { + + @GET + @Path("count") + public long count() { + return 10; + } + } + + public static class Custom1DevServicesRestClientProxyProvider extends VertxHttpProxyDevServicesRestClientProxyProvider { + + @Override + public String name() { + return "custom1"; + } + + @Override + protected void logStartup(String className, Integer port) { + log.info("Started custom1 HTTP proxy server on http://localhost:" + port + " for REST Client '" + className + "'"); + } + } + + // this is tested by having this class provide a different startup log + public static class Custom2DevServicesRestClientProxyProvider extends VertxHttpProxyDevServicesRestClientProxyProvider { + + @Override + public String name() { + return "custom2"; + } + + @Override + protected void logStartup(String className, Integer port) { + log.info("Started custom2 HTTP proxy server on http://localhost:" + port + " for REST Client '" + className + "'"); + } + } +} diff --git a/extensions/resteasy-reactive/rest-client/deployment/src/test/java/io/quarkus/rest/client/reactive/proxy/HttpProxyDevServicesProgrammaticClientTest.java b/extensions/resteasy-reactive/rest-client/deployment/src/test/java/io/quarkus/rest/client/reactive/proxy/HttpProxyDevServicesProgrammaticClientTest.java new file mode 100644 index 0000000000000..030d743d8c70b --- /dev/null +++ b/extensions/resteasy-reactive/rest-client/deployment/src/test/java/io/quarkus/rest/client/reactive/proxy/HttpProxyDevServicesProgrammaticClientTest.java @@ -0,0 +1,84 @@ +package io.quarkus.rest.client.reactive.proxy; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.net.URI; +import java.util.List; +import java.util.function.Consumer; +import java.util.logging.Level; +import java.util.logging.LogRecord; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.rest.client.reactive.QuarkusRestClientBuilder; +import io.quarkus.test.QuarkusUnitTest; + +public class HttpProxyDevServicesProgrammaticClientTest { + + @RegisterExtension + static final QuarkusUnitTest TEST = new QuarkusUnitTest() + .withApplicationRoot( + jar -> jar.addClasses(Resource.class, Client.class)) + .overrideConfigKey( + "quarkus.rest-client.\"io.quarkus.rest.client.reactive.proxy.HttpProxyDevServicesProgrammaticClientTest$Client\".enable-local-proxy", + "true") + .overrideConfigKey( + "quarkus.rest-client.\"io.quarkus.rest.client.reactive.proxy.HttpProxyDevServicesProgrammaticClientTest$Client\".url", + "http://localhost:${quarkus.http.test-port:8081}") + .setLogRecordPredicate(record -> record.getLevel().equals(Level.INFO)) + .assertLogRecords(new Consumer<>() { + @Override + public void accept(List logRecords) { + assertThat(logRecords).extracting(LogRecord::getMessage) + .anyMatch(message -> message.startsWith("Started HTTP proxy server") && message.endsWith( + "REST Client 'io.quarkus.rest.client.reactive.proxy.HttpProxyDevServicesProgrammaticClientTest$Client'")); + } + }); + + @ConfigProperty(name = "quarkus.rest-client.\"io.quarkus.rest.client.reactive.proxy.HttpProxyDevServicesProgrammaticClientTest$Client\".override-uri") + String proxyUrl; + + @Test + public void test() { + Client client = QuarkusRestClientBuilder.newBuilder().baseUri(URI.create("http://unused.dev")).build(Client.class); + + // test that the proxy works as expected + given() + .baseUri(proxyUrl) + .get("test/count") + .then() + .statusCode(200) + .body(equalTo("10")); + + // test that the client works as expected + long result = client.count(); + assertEquals(10, result); + + } + + @Path("test") + public interface Client { + + @Path("count") + @GET + long count(); + } + + @Path("test") + public static class Resource { + + @GET + @Path("count") + public long count() { + return 10; + } + } +} diff --git a/extensions/resteasy-reactive/rest-client/deployment/src/test/java/io/quarkus/rest/client/reactive/proxy/HttpProxyDevServicesSingleCustomProviderTest.java b/extensions/resteasy-reactive/rest-client/deployment/src/test/java/io/quarkus/rest/client/reactive/proxy/HttpProxyDevServicesSingleCustomProviderTest.java new file mode 100644 index 0000000000000..787f1be5d0f80 --- /dev/null +++ b/extensions/resteasy-reactive/rest-client/deployment/src/test/java/io/quarkus/rest/client/reactive/proxy/HttpProxyDevServicesSingleCustomProviderTest.java @@ -0,0 +1,115 @@ +package io.quarkus.rest.client.reactive.proxy; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.net.URI; +import java.util.List; +import java.util.function.Consumer; +import java.util.logging.Level; +import java.util.logging.LogRecord; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.builder.BuildChainBuilder; +import io.quarkus.builder.BuildContext; +import io.quarkus.builder.BuildStep; +import io.quarkus.rest.client.reactive.QuarkusRestClientBuilder; +import io.quarkus.rest.client.reactive.deployment.devservices.VertxHttpProxyDevServicesRestClientProxyProvider; +import io.quarkus.rest.client.reactive.spi.DevServicesRestClientProxyProvider; +import io.quarkus.test.QuarkusUnitTest; + +public class HttpProxyDevServicesSingleCustomProviderTest { + + @RegisterExtension + static final QuarkusUnitTest TEST = new QuarkusUnitTest() + .withApplicationRoot( + jar -> jar.addClasses(Resource.class, Client.class, CustomDevServicesRestClientProxyProvider.class)) + .overrideConfigKey( + "quarkus.rest-client.\"io.quarkus.rest.client.reactive.proxy.HttpProxyDevServicesSingleCustomProviderTest$Client\".enable-local-proxy", + "true") + .overrideConfigKey( + "quarkus.rest-client.\"io.quarkus.rest.client.reactive.proxy.HttpProxyDevServicesSingleCustomProviderTest$Client\".url", + "http://localhost:${quarkus.http.test-port:8081}") + .setLogRecordPredicate(record -> record.getLevel().equals(Level.INFO)) + .assertLogRecords(new Consumer<>() { + @Override + public void accept(List logRecords) { + assertThat(logRecords).extracting(LogRecord::getMessage) + .anyMatch(message -> message.startsWith("Started custom HTTP proxy server") && message.endsWith( + "REST Client 'io.quarkus.rest.client.reactive.proxy.HttpProxyDevServicesSingleCustomProviderTest$Client'")); + } + }) + .addBuildChainCustomizer(new Consumer<>() { + @Override + public void accept(BuildChainBuilder buildChainBuilder) { + buildChainBuilder.addBuildStep(new BuildStep() { + @Override + public void execute(BuildContext context) { + context.produce( + new DevServicesRestClientProxyProvider.BuildItem( + new CustomDevServicesRestClientProxyProvider())); + } + }).produces(DevServicesRestClientProxyProvider.BuildItem.class).build(); + } + }); + + @ConfigProperty(name = "quarkus.rest-client.\"io.quarkus.rest.client.reactive.proxy.HttpProxyDevServicesSingleCustomProviderTest$Client\".override-uri") + String proxyUrl; + + @Test + public void test() { + Client client = QuarkusRestClientBuilder.newBuilder().baseUri(URI.create("http://unused.dev")).build(Client.class); + + // test that the proxy works as expected + given() + .baseUri(proxyUrl) + .get("test/count") + .then() + .statusCode(200) + .body(equalTo("10")); + + // test that the client works as expected + long result = client.count(); + assertEquals(10, result); + } + + @Path("test") + public interface Client { + + @Path("count") + @GET + long count(); + } + + @Path("test") + public static class Resource { + + @GET + @Path("count") + public long count() { + return 10; + } + } + + // this is tested by having this class provide a different startup log + public static class CustomDevServicesRestClientProxyProvider extends VertxHttpProxyDevServicesRestClientProxyProvider { + + @Override + public String name() { + return "custom"; + } + + @Override + protected void logStartup(String className, Integer port) { + log.info("Started custom HTTP proxy server on http://localhost:" + port + " for REST Client '" + className + "'"); + } + } +} diff --git a/extensions/resteasy-reactive/rest-client/spi-deployment/src/main/java/io/quarkus/rest/client/reactive/spi/DevServicesRestClientProxyProvider.java b/extensions/resteasy-reactive/rest-client/spi-deployment/src/main/java/io/quarkus/rest/client/reactive/spi/DevServicesRestClientProxyProvider.java new file mode 100644 index 0000000000000..752a375794bb3 --- /dev/null +++ b/extensions/resteasy-reactive/rest-client/spi-deployment/src/main/java/io/quarkus/rest/client/reactive/spi/DevServicesRestClientProxyProvider.java @@ -0,0 +1,49 @@ +package io.quarkus.rest.client.reactive.spi; + +import java.io.Closeable; + +import io.quarkus.builder.item.MultiBuildItem; + +/** + * Extensions that integrate with the REST Client can use this interface in order to provide their own proxy + * when users have {@code quarkus.rest-client."full-class-name".enable-local-proxy"} enabled, can implement + * this interface and register it by producing {@link BuildItem}. + */ +public interface DevServicesRestClientProxyProvider { + + /** + * Used by Quarkus to determine which provider to use when multiple providers exist. + * User control this if necessary by setting {@code quarkus.rest-client."full-class-name".local-proxy-provider} + */ + String name(); + + /** + * Called once by Quarkus to allow the provider to initialize + */ + Closeable setup(); + + /** + * Called by Quarkus for each of the REST Clients that need to be proxied + */ + CreateResult create(RestClientHttpProxyBuildItem buildItem); + + record CreateResult(String host, Integer port, Closeable closeable) { + + } + + /** + * Build item used to register the provider with Quarkus + */ + final class BuildItem extends MultiBuildItem { + + private final DevServicesRestClientProxyProvider provider; + + public BuildItem(DevServicesRestClientProxyProvider provider) { + this.provider = provider; + } + + public DevServicesRestClientProxyProvider getProvider() { + return provider; + } + } +} diff --git a/extensions/resteasy-reactive/rest-client/spi-deployment/src/main/java/io/quarkus/rest/client/reactive/spi/RestClientHttpProxyBuildItem.java b/extensions/resteasy-reactive/rest-client/spi-deployment/src/main/java/io/quarkus/rest/client/reactive/spi/RestClientHttpProxyBuildItem.java new file mode 100644 index 0000000000000..d28f90b548e6c --- /dev/null +++ b/extensions/resteasy-reactive/rest-client/spi-deployment/src/main/java/io/quarkus/rest/client/reactive/spi/RestClientHttpProxyBuildItem.java @@ -0,0 +1,70 @@ +package io.quarkus.rest.client.reactive.spi; + +import java.io.Closeable; +import java.util.Objects; +import java.util.Optional; + +import io.quarkus.builder.item.MultiBuildItem; + +/** + * Represents the data necessary for creating a Http proxy for a REST Client + */ +public final class RestClientHttpProxyBuildItem extends MultiBuildItem { + + private final String className; + private final String baseUri; + private final Optional provider; + + // this is only used to make bookkeeping easier + private volatile Closeable closeable; + + public RestClientHttpProxyBuildItem(String className, String baseUri, Optional provider) { + this.className = Objects.requireNonNull(className); + this.baseUri = Objects.requireNonNull(baseUri); + this.provider = provider; + } + + public String getClassName() { + return className; + } + + public String getBaseUri() { + return baseUri; + } + + public Optional getProvider() { + return provider; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + RestClientHttpProxyBuildItem that = (RestClientHttpProxyBuildItem) o; + return Objects.equals(className, that.className) && Objects.equals(baseUri, that.baseUri) + && Objects.equals(provider, that.provider); + } + + @Override + public int hashCode() { + return Objects.hash(className, baseUri, provider); + } + + /** + * Called by Quarkus in order to associate a {@link Closeable} with a started proxy + */ + public void attachClosable(Closeable closeable) { + this.closeable = closeable; + } + + /** + * Called by Quarkus when it's time to stop the proxy + */ + public Closeable getCloseable() { + return closeable; + } +}