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 9267e5bc9fe31..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 @@ -24,6 +24,7 @@ public class RestClientConfig { EMPTY = new RestClientConfig(); EMPTY.url = Optional.empty(); EMPTY.uri = Optional.empty(); + EMPTY.overrideUri = Optional.empty(); EMPTY.providers = Optional.empty(); EMPTY.connectTimeout = Optional.empty(); EMPTY.readTimeout = Optional.empty(); @@ -75,6 +76,15 @@ public class RestClientConfig { @ConfigItem public Optional uri; + /** + * 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, only the Quarkus Rest client (formerly RESTEasy Reactive client). + */ + @ConfigItem + public Optional overrideUri; + /** * Map where keys are fully-qualified provider classnames to include in the client, and values are their integer * priorities. The equivalent of the `@RegisterProvider` annotation. @@ -314,6 +324,7 @@ public static RestClientConfig load(String configKey) { instance.url = getConfigValue(configKey, "url", String.class); instance.uri = getConfigValue(configKey, "uri", String.class); + instance.overrideUri = getConfigValue(configKey, "override-uri", String.class); instance.providers = getConfigValue(configKey, "providers", String.class); instance.connectTimeout = getConfigValue(configKey, "connect-timeout", Long.class); instance.readTimeout = getConfigValue(configKey, "read-timeout", Long.class); @@ -357,6 +368,7 @@ public static RestClientConfig load(Class interfaceClass) { instance.url = getConfigValue(interfaceClass, "url", String.class); instance.uri = getConfigValue(interfaceClass, "uri", String.class); + instance.overrideUri = getConfigValue(interfaceClass, "override-uri", String.class); instance.providers = getConfigValue(interfaceClass, "providers", String.class); instance.connectTimeout = getConfigValue(interfaceClass, "connect-timeout", Long.class); instance.readTimeout = getConfigValue(interfaceClass, "read-timeout", Long.class); @@ -394,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 @@ -403,7 +415,7 @@ private static Optional getConfigValue(String configKey, String fieldName return optional; } - private static Optional getConfigValue(Class clientInterface, String fieldName, Class type) { + public static Optional getConfigValue(Class clientInterface, String fieldName, Class type) { final Config config = ConfigProvider.getConfig(); // first try interface full name Optional optional = config.getOptionalValue(composePropertyKey('"' + clientInterface.getName() + '"', fieldName), 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/runtime/src/main/java/io/quarkus/rest/client/reactive/runtime/RestClientBuilderImpl.java b/extensions/resteasy-reactive/rest-client/runtime/src/main/java/io/quarkus/rest/client/reactive/runtime/RestClientBuilderImpl.java index 8d1dd587eb6f4..5020e1bdcbf7c 100644 --- a/extensions/resteasy-reactive/rest-client/runtime/src/main/java/io/quarkus/rest/client/reactive/runtime/RestClientBuilderImpl.java +++ b/extensions/resteasy-reactive/rest-client/runtime/src/main/java/io/quarkus/rest/client/reactive/runtime/RestClientBuilderImpl.java @@ -45,6 +45,7 @@ import io.quarkus.arc.ArcContainer; import io.quarkus.arc.InstanceHandle; import io.quarkus.rest.client.reactive.runtime.ProxyAddressUtil.HostAndPort; +import io.quarkus.restclient.config.RestClientConfig; import io.quarkus.restclient.config.RestClientLoggingConfig; import io.quarkus.restclient.config.RestClientsConfig; import io.quarkus.tls.TlsConfiguration; @@ -389,17 +390,23 @@ public RestClientBuilderImpl queryParamStyle(final QueryParamStyle style) { @Override public T build(Class aClass) throws IllegalStateException, RestClientDefinitionException { - if (uri == null) { - // mandated by the spec - throw new IllegalStateException("No URL specified. Cannot build a rest client without URL"); - } - ArcContainer arcContainer = Arc.container(); if (arcContainer == null) { throw new IllegalStateException( "The Reactive REST Client needs to be built within the context of a Quarkus application with a valid ArC (CDI) context running."); } + // support overriding the URI from the override-uri property + Optional maybeOverrideUri = RestClientConfig.getConfigValue(aClass, "override-uri", String.class); + if (maybeOverrideUri.isPresent()) { + uri = URI.create(maybeOverrideUri.get()); + } + + if (uri == null) { + // mandated by the spec + throw new IllegalStateException("No URL specified. Cannot build a rest client without URL"); + } + RestClientListeners.get().forEach(listener -> listener.onNewClient(aClass, this)); AnnotationRegisteredProviders annotationRegisteredProviders = arcContainer 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; + } +}