Skip to content

Commit 83a28e1

Browse files
committed
Add support for Spring WebClient (and thus Reactor-Netty)
1 parent fb44cb5 commit 83a28e1

File tree

9 files changed

+143
-10
lines changed

9 files changed

+143
-10
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ Traffic can be captured from at least:
1717
- [x] Retrofit
1818
- [x] Jetty-Client v9, 10 & 11
1919
- [x] Async-Http-Client
20+
- [x] Reactor-Netty
21+
- [x] Spring WebClient
2022

2123
This will also capture HTTP(S) from any downstream libraries based on each of these clients, and many other untested clients sharing similar implementations, and so should cover a very large percentage of HTTP client usage.
2224

build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ dependencies {
3030
compileOnly group: 'org.apache.httpcomponents.client5', name: 'httpclient5', version: '5.0.3'
3131
compileOnly group: 'org.eclipse.jetty', name: 'jetty-client', version: '11.0.1'
3232
compileOnly group: 'org.asynchttpclient', name: 'async-http-client', version: '2.12.2'
33+
compileOnly group: 'io.projectreactor.netty', name: 'reactor-netty', version: '1.0.4'
3334

3435
// Test deps:
3536
testImplementation group: 'io.kotest', name: 'kotest-runner-junit5-jvm', version: '4.4.0'
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package tech.httptoolkit.javaagent.reactornetty;
2+
3+
import io.netty.handler.ssl.SslContextBuilder;
4+
import net.bytebuddy.asm.Advice;
5+
import reactor.netty.http.client.HttpClientConfig;
6+
import reactor.netty.tcp.SslProvider;
7+
import reactor.netty.transport.ClientTransportConfig;
8+
import reactor.netty.transport.ProxyProvider;
9+
import tech.httptoolkit.javaagent.HttpProxyAgent;
10+
11+
import java.lang.reflect.Field;
12+
import java.net.InetSocketAddress;
13+
14+
public class ReactorNettyResetAllConfigAdvice {
15+
16+
public static final ProxyProvider agentProxyProvider = ProxyProvider.builder()
17+
.type(ProxyProvider.Proxy.HTTP)
18+
.address(new InetSocketAddress(
19+
HttpProxyAgent.getAgentProxyHost(),
20+
HttpProxyAgent.getAgentProxyPort()
21+
))
22+
.build();
23+
24+
public static final SslProvider agentSslProvider;
25+
26+
public static final Field configSslField;
27+
public static final Field proxyProviderField;
28+
29+
static {
30+
System.out.println("Running netty reset static initializer");
31+
try {
32+
// Initialize our intercepted SSL provider:
33+
agentSslProvider = SslProvider.builder()
34+
.sslContext(
35+
SslContextBuilder
36+
.forClient()
37+
.trustManager(HttpProxyAgent.getInterceptedTrustManagerFactory())
38+
.build()
39+
).build();
40+
41+
// Rewrite the fields we want to mess with in the client config:
42+
configSslField = HttpClientConfig.class.getDeclaredField("sslProvider");
43+
configSslField.setAccessible(true);
44+
45+
proxyProviderField = ClientTransportConfig.class.getDeclaredField("proxyProvider");
46+
proxyProviderField.setAccessible(true);
47+
} catch (Exception e) {
48+
throw new RuntimeException(e);
49+
}
50+
}
51+
52+
// Netty's HTTP Client works by creating a new client subclass, and passing the existing client's
53+
// config. We hook that here: we rewrite the config whenever it's used, affecting all clients
54+
// involved, and in practice anybody using config anywhere.
55+
56+
@Advice.OnMethodEnter
57+
public static void beforeConstructor(
58+
@Advice.Argument(value=0) HttpClientConfig baseHttpConfig
59+
) {
60+
try {
61+
configSslField.set(baseHttpConfig, agentSslProvider);
62+
proxyProviderField.set(baseHttpConfig, agentProxyProvider);
63+
} catch (IllegalAccessException e) {
64+
throw new RuntimeException(e);
65+
}
66+
}
67+
}

src/main/kotlin/tech/httptoolkit/javaagent/AgentMain.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@ fun interceptAllHttps(config: Config, instrumentation: Instrumentation) {
8181
JavaClientTransformer(),
8282
JettyClientTransformer(),
8383
AsyncHttpClientConfigTransformer(),
84-
AsyncHttpChannelManagerTransformer()
84+
AsyncHttpChannelManagerTransformer(),
85+
ReactorNettyClientConfigTransformer()
8586
).forEach { matchingAgentTransformer ->
8687
agentBuilder = matchingAgentTransformer.register(agentBuilder)
8788
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package tech.httptoolkit.javaagent
2+
3+
import net.bytebuddy.agent.builder.AgentBuilder
4+
import net.bytebuddy.asm.Advice
5+
import net.bytebuddy.description.method.MethodDescription
6+
import net.bytebuddy.description.type.TypeDescription
7+
import net.bytebuddy.dynamic.DynamicType
8+
import net.bytebuddy.matcher.ElementMatchers.*
9+
import net.bytebuddy.utility.JavaModule
10+
import reactor.netty.http.client.HttpClientConfig
11+
import tech.httptoolkit.javaagent.reactornetty.ReactorNettyResetAllConfigAdvice
12+
13+
// To patch Reactor-Netty's HTTP client, we hook the constructor of the client itself. It has a constructor
14+
// that receives the config as part of every single HTTP request - we hook that to reset the relevant
15+
// config props every time they're used.
16+
17+
class ReactorNettyClientConfigTransformer : MatchingAgentTransformer {
18+
override fun register(builder: AgentBuilder): AgentBuilder {
19+
return builder
20+
.type(
21+
hasSuperType(named("reactor.netty.http.client.HttpClient"))
22+
).and(
23+
not(isInterface())
24+
).transform(this)
25+
}
26+
27+
override fun transform(
28+
builder: DynamicType.Builder<*>,
29+
typeDescription: TypeDescription,
30+
classLoader: ClassLoader?,
31+
module: JavaModule?
32+
): DynamicType.Builder<*> {
33+
return builder
34+
.visit(Advice.to(ReactorNettyResetAllConfigAdvice::class.java)
35+
.on(isConstructor<MethodDescription>().and(
36+
takesArguments(HttpClientConfig::class.java)
37+
)))
38+
}
39+
}

test-app/build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ dependencies {
1818
implementation group: 'com.squareup.retrofit2', name: 'retrofit', version: '2.9.0'
1919
implementation group: 'org.eclipse.jetty', name: 'jetty-client', version: '10.0.0'
2020
implementation group: 'org.asynchttpclient', name: 'async-http-client', version: '2.12.2'
21+
implementation group: 'org.springframework', name: 'spring-webflux', version: '5.3.4'
22+
implementation group: 'io.projectreactor.netty', name: 'reactor-netty', version: '1.0.4'
2123
}
2224

2325
test {

test-app/src/main/java/tech/httptoolkit/testapp/Main.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,16 @@ public class Main {
1919
"okhttp-v4", new OkHttpV4Case(),
2020
"retrofit", new RetrofitCase(),
2121
"jetty-client", new JettyClientCase(),
22-
"async-http-client", new AsyncHttpClientCase()
22+
"async-http-client", new AsyncHttpClientCase(),
23+
"spring-web", new SpringWebClientCase()
2324
);
2425

2526
public static void main(String[] args) throws Exception {
2627
String runtimeName = ManagementFactory.getRuntimeMXBean().getName();
2728
String pid = runtimeName.split("@")[0];
2829
System.out.println("PID: " + pid); // Purely for convenient manual attachment to this process
2930

30-
String url = "https://example.test"; // Invalid URL: this should always fail to resolve
31+
String url = "https://httpbin.org/404/"; // Always returns a 404, quelle surprise
3132

3233
while (true) {
3334
AtomicBoolean allSuccessful = new AtomicBoolean(true);

test-app/src/main/java/tech/httptoolkit/testapp/cases/ClientCase.java

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,9 @@
11
package tech.httptoolkit.testapp.cases;
22

3-
import java.io.IOException;
4-
import java.net.MalformedURLException;
5-
import java.net.URL;
6-
import java.util.concurrent.ExecutionException;
7-
import java.util.concurrent.TimeoutException;
8-
93
public abstract class ClientCase<T> {
104

115
public abstract T newClient(String url) throws Exception;
12-
public abstract int test(String url, T client) throws IOException, InterruptedException, ExecutionException, TimeoutException;
6+
public abstract int test(String url, T client) throws Exception;
137

148
private String existingClientUrl;
159
private T client;
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package tech.httptoolkit.testapp.cases;
2+
3+
import org.springframework.http.MediaType;
4+
import org.springframework.web.reactive.function.client.WebClient;
5+
import reactor.core.publisher.Mono;
6+
7+
import java.net.URI;
8+
import java.net.URISyntaxException;
9+
10+
public class SpringWebClientCase extends ClientCase<WebClient> {
11+
@Override
12+
public WebClient newClient(String url) throws Exception {
13+
return WebClient.create();
14+
}
15+
16+
@Override
17+
public int test(String url, WebClient client) throws URISyntaxException {
18+
Mono<Integer> result = client.get()
19+
.uri(new URI(url))
20+
.accept(MediaType.ALL)
21+
.exchangeToMono(response -> Mono.just(response.rawStatusCode()));
22+
23+
//noinspection ConstantConditions
24+
return result.block();
25+
}
26+
}

0 commit comments

Comments
 (0)