Skip to content

Commit

Permalink
Add HTTP Proxy Dev Service for REST Client
Browse files Browse the repository at this point in the history
  • Loading branch information
geoand committed Jul 10, 2024
1 parent 5086543 commit 30c5e47
Show file tree
Hide file tree
Showing 8 changed files with 584 additions and 152 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,16 @@ public class RestClientBuildConfig {
*/
@ConfigItem
public Optional<String> 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.
* <p>
* This property is not applicable to the RESTEasy Client.
* <p>
* This property only applicable to dev and test mode.
*/
@ConfigItem(defaultValue = "false")
public boolean enableLocalProxy;
}
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ public static RestClientConfig load(Class<?> interfaceClass) {
return instance;
}

private static <T> Optional<T> getConfigValue(String configKey, String fieldName, Class<T> type) {
public static <T> Optional<T> getConfigValue(String configKey, String fieldName, Class<T> type) {
final Config config = ConfigProvider.getConfig();
Optional<T> optional = config.getOptionalValue(composePropertyKey(configKey, fieldName), type);
if (optional.isEmpty()) { // try to find property with quoted configKey
Expand Down
4 changes: 4 additions & 0 deletions extensions/resteasy-reactive/rest-client/deployment/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@
<groupId>io.quarkus</groupId>
<artifactId>quarkus-tls-registry-deployment</artifactId>
</dependency>
<dependency>
<groupId>io.vertx</groupId>
<artifactId>vertx-http-proxy</artifactId>
</dependency>
<!-- test dependencies: -->
<dependency>
<groupId>io.quarkus</groupId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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;

/**
* TODO
*/
public final class RegisteredRestClientBuildItem extends MultiBuildItem {

private final ClassInfo classInfo;
private final Optional<String> configKey;
private final Optional<String> defaultBaseUri;

public RegisteredRestClientBuildItem(ClassInfo classInfo, Optional<String> configKey, Optional<String> defaultBaseUri) {
this.classInfo = Objects.requireNonNull(classInfo);
this.configKey = Objects.requireNonNull(configKey);
this.defaultBaseUri = Objects.requireNonNull(defaultBaseUri);
}

public ClassInfo getClassInfo() {
return classInfo;
}

public Optional<String> getConfigKey() {
return configKey;
}

public Optional<String> getDefaultBaseUri() {
return defaultBaseUri;
}
}

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
package io.quarkus.rest.client.reactive.deployment.devservices;

import java.io.Closeable;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.ServerSocket;
import java.net.URI;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;

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.restclient.config.RestClientBuildConfig;
import io.quarkus.restclient.config.RestClientConfig;
import io.quarkus.restclient.config.RestClientsBuildTimeConfig;
import io.vertx.core.Future;
import io.vertx.core.MultiMap;
import io.vertx.core.Vertx;
import io.vertx.core.http.HttpClient;
import io.vertx.core.http.HttpClientOptions;
import io.vertx.core.http.HttpServer;
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;

@BuildSteps(onlyIfNot = IsNormal.class)
public class DevServicesRestClientHttpProxy {

private static final Logger log = Logger.getLogger(DevServicesRestClientHttpProxy.class);

private static final Set<RestClientHttpProxyBuildItem> runningBIs = new HashSet<>();

private static final AtomicReference<Vertx> vertx = new AtomicReference<>();

@BuildStep
public void determineRequiredProxies(RestClientsBuildTimeConfig restClientsBuildTimeConfig,
CombinedIndexBuildItem combinedIndexBuildItem,
List<RegisteredRestClientBuildItem> registeredRestClientBuildItems,
BuildProducer<RestClientHttpProxyBuildItem> producer) {
if (restClientsBuildTimeConfig.configs.isEmpty()) {
return;
}

IndexView index = combinedIndexBuildItem.getIndex();

Map<String, RestClientBuildConfig> 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<String> 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()));
} 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<String> 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()));
}
}
}

private String sanitizeKey(String key) {
if (key.startsWith("\"") && key.endsWith("\"")) {
return key.substring(1, key.length() - 1);
}
return key;
}

@BuildStep
public void start(List<RestClientHttpProxyBuildItem> restClientHttpProxyBuildItems,
BuildProducer<DevServicesResultBuildItem> devServicePropertiesProducer,
CuratedApplicationShutdownBuildItem closeBuildItem) {
if (restClientHttpProxyBuildItems.isEmpty()) {
return;
}

Set<RestClientHttpProxyBuildItem> currentBIs = new HashSet<>(restClientHttpProxyBuildItems);

Set<RestClientHttpProxyBuildItem> inRunningAndNotCurrent = new HashSet<>(runningBIs);
inRunningAndNotCurrent.removeAll(currentBIs);

// we need to remove the running ones that should no longer be running
for (var running : inRunningAndNotCurrent) {
try {
log.debug("Attempting to close HTTP proxy server for REST Client '" + running.getClassName() + "'");
running.getCloseable().close();
log.debug("Closed HTTP proxy server for REST Client '" + running.getClassName() + "'");
} catch (IOException e) {
throw new UncheckedException(e);
}
}
runningBIs.removeAll(inRunningAndNotCurrent);

Set<RestClientHttpProxyBuildItem> inCurrentAndNotRunning = new HashSet<>(currentBIs);
inCurrentAndNotRunning.removeAll(runningBIs);

if (vertx.get() == null) {
// TODO: what settings do we need here in order to minimize the footprint?
vertx.set(Vertx.vertx());
}

for (var current : inCurrentAndNotRunning) {
URI baseUri = URI.create(current.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);

log.info("Started HTTP proxy server on http://localhost:" + port + " for REST Client '" + current.getClassName()
+ "'");

current.attachClosable(new HttpServerClosable(proxyServer));
runningBIs.add(current);
closeBuildItem.addCloseTask(new VertxAndRunningBIsClosingRunnable(vertx, runningBIs), true);

String urlKeyName = String.format("quarkus.rest-client.\"%s\".override-uri", current.getClassName());
String urlKeyValue = String.format("http://localhost:%d", port);
if (baseUri.getPath() != null) {
if (!"/".equals(baseUri.getPath()) && !baseUri.getPath().isEmpty()) {
urlKeyValue = urlKeyValue + "/" + baseUri.getPath();
}
}

devServicePropertiesProducer.produce(
new DevServicesResultBuildItem("rest-client-" + current.getClassName() + "-proxy",
null,
Map.of(urlKeyName, urlKeyValue)));
}
}

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<ProxyResponse> 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 {
// TODO: do we need to wait for the future to complete?
server.close();
}
}

private static class VertxAndRunningBIsClosingRunnable implements Runnable {
private final AtomicReference<Vertx> vertx;
private final Set<RestClientHttpProxyBuildItem> runningBIs;

public VertxAndRunningBIsClosingRunnable(AtomicReference<Vertx> vertx, Set<RestClientHttpProxyBuildItem> runningBIs) {
this.vertx = vertx;
this.runningBIs = runningBIs;
}

@Override
public void run() {
for (RestClientHttpProxyBuildItem runningBI : runningBIs) {
try {
runningBI.getCloseable().close();
} catch (IOException e) {
throw new UncheckedException(e);
}
}
// TODO: do we need to wait for this?
vertx.get().close();
vertx.set(null);
}
}

@SafeVarargs
private static <T> Optional<T> oneOf(Optional<T>... optionals) {
for (Optional<T> o : optionals) {
if (o != null && o.isPresent()) {
return o;
}
}
return Optional.empty();
}
}
Loading

0 comments on commit 30c5e47

Please sign in to comment.