Skip to content

Commit d8440f8

Browse files
committed
refactor: request and response are now streaming
1 parent 4ea2d41 commit d8440f8

9 files changed

Lines changed: 725 additions & 65 deletions

File tree

app.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: java-tproxy
2+
description: A lightweight HTTP/HTTPS proxy library with pluggable interceptors and full MITM HTTPS support.
3+
documentation: |
4+
# java-tproxy
5+
6+
A lightweight HTTP/HTTPS proxy library with pluggable interceptors and full MITM HTTPS support.
7+
8+
## Features
9+
10+
- **HTTP Proxy** — Full HTTP/1.1 proxy
11+
- **HTTPS Interception** — Man-in-the-Middle mode with on-the-fly certificate generation
12+
- **HTTPS Pass-through** — Secure tunneling via CONNECT (no decryption)
13+
- **Pluggable Interceptors** — Chain-of-responsibility for inspecting/modifying requests and responses
14+
- **Certificate Authority** — Auto-generated CA with per-hostname server certificates (BouncyCastle)
15+
authors:
16+
- Tako Schotanus (tako@codejive.org)
17+
contributors:
18+
- copilot
19+
links:
20+
homepage: https://github.com/codejive/java-tproxy
21+
repository: https://github.com/codejive/java-tproxy
22+
documentation: https://github.com/codejive/java-tproxy/blob/main/README.md
23+
java: 21
24+
dependencies:
25+
actions:
26+
clean: ./mvnw clean
27+
build: ./mvnw spotless:apply package -DskipTests
28+
test: ./mvnw test
29+
format: ./mvnw spotless:apply

src/main/java/org/codejive/tproxy/HttpProxy.java

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package org.codejive.tproxy;
22

33
import java.io.IOException;
4+
import java.io.InputStream;
45
import java.net.InetSocketAddress;
56
import java.net.ProxySelector;
67
import java.net.http.HttpClient;
@@ -169,9 +170,7 @@ private ProxyResponse executeActualRequest(ProxyRequest request) {
169170
.uri(request.uri())
170171
.method(
171172
request.method(),
172-
request.body().length > 0
173-
? BodyPublishers.ofByteArray(request.body())
174-
: BodyPublishers.noBody());
173+
BodyPublishers.ofInputStream(() -> request.bodyStream()));
175174

176175
// Add headers (HttpClient sets Host and Content-Length automatically)
177176
filteredHeaders.forEach(
@@ -185,25 +184,25 @@ private ProxyResponse executeActualRequest(ProxyRequest request) {
185184

186185
HttpRequest httpRequest = builder.build();
187186

188-
// Execute request
189-
HttpResponse<byte[]> httpResponse =
190-
httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofByteArray());
187+
// Execute request and stream response
188+
HttpResponse<InputStream> httpResponse =
189+
httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofInputStream());
191190

192191
// Convert response
193192
Headers responseHeaders = convertResponseHeaders(httpResponse);
194-
return new ProxyResponse(
193+
return ProxyResponse.fromStream(
195194
httpResponse.statusCode(), responseHeaders, httpResponse.body());
196195

197196
} catch (InterruptedException e) {
198197
Thread.currentThread().interrupt();
199198
logger.error("Request interrupted: {} {}", request.method(), request.uri(), e);
200-
return new ProxyResponse(
199+
return ProxyResponse.fromBytes(
201200
503,
202201
Headers.of("Content-Type", "text/plain"),
203202
"Service Unavailable: Request interrupted".getBytes());
204203
} catch (IOException e) {
205204
logger.error("Error executing request: {} {}", request.method(), request.uri(), e);
206-
return new ProxyResponse(
205+
return ProxyResponse.fromBytes(
207206
502,
208207
Headers.of("Content-Type", "text/plain"),
209208
("Bad Gateway: " + e.getMessage()).getBytes());
@@ -216,7 +215,7 @@ private ProxyResponse executeActualRequest(ProxyRequest request) {
216215
* @param httpResponse the HTTP response
217216
* @return converted headers
218217
*/
219-
private Headers convertResponseHeaders(HttpResponse<byte[]> httpResponse) {
218+
private Headers convertResponseHeaders(HttpResponse<InputStream> httpResponse) {
220219
return Headers.of(httpResponse.headers().map());
221220
}
222221

src/main/java/org/codejive/tproxy/ProxyRequest.java

Lines changed: 230 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,127 @@
11
package org.codejive.tproxy;
22

3+
import java.io.ByteArrayInputStream;
4+
import java.io.IOException;
5+
import java.io.InputStream;
36
import java.net.URI;
47
import java.util.Objects;
8+
import java.util.function.Supplier;
59

610
/**
711
* Immutable representation of an HTTP request in the proxy layer. Use {@code with*()} methods to
812
* create modified copies.
13+
*
14+
* <p>The body can be accessed either as a stream via {@link #bodyStream()} or as a byte array via
15+
* {@link #body()}. Bodies created from {@code InputStream} or {@code Supplier<InputStream>} are
16+
* single-use - calling {@link #bodyStream()} a second time will throw an exception. Bodies created
17+
* from byte arrays support multiple {@link #bodyStream()} calls.
18+
*
19+
* <p>The {@link #body()} method materializes the stream into a byte array and caches it, so
20+
* subsequent calls return the same cached array.
921
*/
1022
public class ProxyRequest {
1123
private final String method;
1224
private final URI uri;
1325
private final Headers headers;
14-
private final byte[] body;
26+
private final Supplier<InputStream> bodySupplier;
27+
private final boolean isByteArrayBased;
28+
private final boolean isSupplierBased;
29+
private boolean streamConsumed = false;
30+
private byte[] cachedBody = null;
1531

16-
public ProxyRequest(String method, URI uri, Headers headers, byte[] body) {
32+
/**
33+
* Create a request with a byte array body. The body can be read multiple times.
34+
*
35+
* @param method the HTTP method
36+
* @param uri the request URI
37+
* @param headers the request headers
38+
* @param body the request body as a byte array
39+
* @return a new ProxyRequest
40+
*/
41+
public static ProxyRequest fromBytes(String method, URI uri, Headers headers, byte[] body) {
42+
byte[] bodyBytes = body != null ? body : new byte[0];
43+
Supplier<InputStream> supplier = () -> new ByteArrayInputStream(bodyBytes);
44+
return new ProxyRequest(method, uri, headers, supplier, true, false, bodyBytes);
45+
}
46+
47+
/**
48+
* Create a request with a streaming body from an InputStream. The stream can only be read once.
49+
*
50+
* @param method the HTTP method
51+
* @param uri the request URI
52+
* @param headers the request headers
53+
* @param bodyStream the request body as an InputStream (single-use)
54+
* @return a new ProxyRequest
55+
*/
56+
public static ProxyRequest fromStream(
57+
String method, URI uri, Headers headers, InputStream bodyStream) {
58+
Supplier<InputStream> supplier =
59+
bodyStream != null ? () -> bodyStream : () -> new ByteArrayInputStream(new byte[0]);
60+
return new ProxyRequest(method, uri, headers, supplier, false, false, null);
61+
}
62+
63+
/**
64+
* Create a request with a body supplier that provides re-readable streams.
65+
*
66+
* @param method the HTTP method
67+
* @param uri the request URI
68+
* @param headers the request headers
69+
* @param bodySupplier supplier that provides a fresh InputStream on each call
70+
* @return a new ProxyRequest
71+
*/
72+
public static ProxyRequest fromSupplier(
73+
String method, URI uri, Headers headers, Supplier<InputStream> bodySupplier) {
74+
Supplier<InputStream> supplier =
75+
bodySupplier != null ? bodySupplier : () -> new ByteArrayInputStream(new byte[0]);
76+
return new ProxyRequest(method, uri, headers, supplier, false, true, null);
77+
}
78+
79+
/**
80+
* Main constructor used by factory methods.
81+
*
82+
* @param method the HTTP method
83+
* @param uri the request URI
84+
* @param headers the request headers
85+
* @param bodySupplier supplier that provides the request body stream
86+
* @param isByteArrayBased whether the body is backed by a byte array
87+
* @param isSupplierBased whether the body supplier can be called multiple times
88+
* @param cachedBody optional cached body bytes (for byte array-based bodies)
89+
*/
90+
private ProxyRequest(
91+
String method,
92+
URI uri,
93+
Headers headers,
94+
Supplier<InputStream> bodySupplier,
95+
boolean isByteArrayBased,
96+
boolean isSupplierBased,
97+
byte[] cachedBody) {
1798
this.method = Objects.requireNonNull(method, "method cannot be null");
1899
this.uri = Objects.requireNonNull(uri, "uri cannot be null");
19100
this.headers = headers != null ? headers : Headers.empty();
20-
this.body = body != null ? body.clone() : new byte[0];
101+
this.bodySupplier = bodySupplier;
102+
this.isByteArrayBased = isByteArrayBased;
103+
this.isSupplierBased = isSupplierBased;
104+
this.cachedBody = cachedBody;
105+
}
106+
107+
/** Copy constructor for wither methods. */
108+
private ProxyRequest(
109+
String method,
110+
URI uri,
111+
Headers headers,
112+
Supplier<InputStream> bodySupplier,
113+
boolean isByteArrayBased,
114+
boolean isSupplierBased,
115+
boolean streamConsumed,
116+
byte[] cachedBody) {
117+
this.method = method;
118+
this.uri = uri;
119+
this.headers = headers;
120+
this.bodySupplier = bodySupplier;
121+
this.isByteArrayBased = isByteArrayBased;
122+
this.isSupplierBased = isSupplierBased;
123+
this.streamConsumed = streamConsumed;
124+
this.cachedBody = cachedBody;
21125
}
22126

23127
public String method() {
@@ -32,28 +136,142 @@ public Headers headers() {
32136
return headers;
33137
}
34138

139+
/**
140+
* Get the request body as an InputStream.
141+
*
142+
* <p>For byte array-based bodies, this method can be called multiple times and returns a new
143+
* {@code ByteArrayInputStream} on each call.
144+
*
145+
* <p>For supplier-based bodies, this method can be called multiple times and the supplier
146+
* provides a fresh InputStream on each call.
147+
*
148+
* <p>For stream-based bodies (created from {@code InputStream}), this method can only be called
149+
* once unless {@link #body()} has been called to materialize it. Subsequent calls will throw
150+
* {@code IllegalStateException}.
151+
*
152+
* @return an InputStream containing the request body
153+
* @throws IllegalStateException if called more than once on a stream-based body
154+
*/
155+
public InputStream bodyStream() {
156+
// If body has been materialized, always return from cached
157+
if (cachedBody != null) {
158+
return new ByteArrayInputStream(cachedBody);
159+
}
160+
161+
// Supplier-based bodies are repeatable
162+
if (isSupplierBased) {
163+
return bodySupplier.get();
164+
}
165+
166+
// Stream-based bodies are single-use
167+
if (!isByteArrayBased && streamConsumed) {
168+
throw new IllegalStateException(
169+
"Body stream already consumed. Create a new ProxyRequest with a re-readable"
170+
+ " body source.");
171+
}
172+
if (!isByteArrayBased) {
173+
streamConsumed = true;
174+
}
175+
return bodySupplier.get();
176+
}
177+
178+
/**
179+
* Get the request body as a byte array. This method materializes the stream if not already
180+
* cached, and returns the cached result on subsequent calls.
181+
*
182+
* <p>After calling this method, the request becomes byte-array-based, allowing multiple
183+
* bodyStream() calls.
184+
*
185+
* @return the request body as a byte array
186+
* @throws RuntimeException if an I/O error occurs reading the stream
187+
*/
35188
public byte[] body() {
36-
return body.clone();
189+
if (cachedBody == null) {
190+
try {
191+
cachedBody = bodyStream().readAllBytes();
192+
} catch (IOException e) {
193+
throw new RuntimeException("Failed to read request body", e);
194+
}
195+
}
196+
return cachedBody;
37197
}
38198

39199
public ProxyRequest withMethod(String method) {
40-
return new ProxyRequest(method, uri, headers, body);
200+
return new ProxyRequest(
201+
method,
202+
uri,
203+
headers,
204+
bodySupplier,
205+
isByteArrayBased,
206+
isSupplierBased,
207+
streamConsumed,
208+
cachedBody);
41209
}
42210

43211
public ProxyRequest withUri(URI uri) {
44-
return new ProxyRequest(method, uri, headers, body);
212+
return new ProxyRequest(
213+
method,
214+
uri,
215+
headers,
216+
bodySupplier,
217+
isByteArrayBased,
218+
isSupplierBased,
219+
streamConsumed,
220+
cachedBody);
45221
}
46222

47223
public ProxyRequest withHeaders(Headers headers) {
48-
return new ProxyRequest(method, uri, headers, body);
224+
return new ProxyRequest(
225+
method,
226+
uri,
227+
headers,
228+
bodySupplier,
229+
isByteArrayBased,
230+
isSupplierBased,
231+
streamConsumed,
232+
cachedBody);
49233
}
50234

51235
public ProxyRequest withHeader(String name, String value) {
52-
return new ProxyRequest(method, uri, headers.with(name, value), body);
236+
return new ProxyRequest(
237+
method,
238+
uri,
239+
headers.with(name, value),
240+
bodySupplier,
241+
isByteArrayBased,
242+
isSupplierBased,
243+
streamConsumed,
244+
cachedBody);
53245
}
54246

247+
/**
248+
* Create a new request with a byte array body.
249+
*
250+
* @param body the new body
251+
* @return a new ProxyRequest
252+
*/
55253
public ProxyRequest withBody(byte[] body) {
56-
return new ProxyRequest(method, uri, headers, body);
254+
return ProxyRequest.fromBytes(method, uri, headers, body);
255+
}
256+
257+
/**
258+
* Create a new request with a streaming body.
259+
*
260+
* @param bodyStream the new body stream
261+
* @return a new ProxyRequest
262+
*/
263+
public ProxyRequest withBody(InputStream bodyStream) {
264+
return ProxyRequest.fromStream(method, uri, headers, bodyStream);
265+
}
266+
267+
/**
268+
* Create a new request with a body supplier.
269+
*
270+
* @param bodySupplier supplier that provides a fresh InputStream on each call
271+
* @return a new ProxyRequest
272+
*/
273+
public ProxyRequest withBody(Supplier<InputStream> bodySupplier) {
274+
return ProxyRequest.fromSupplier(method, uri, headers, bodySupplier);
57275
}
58276

59277
@Override
@@ -64,13 +282,13 @@ public boolean equals(Object o) {
64282
return Objects.equals(method, that.method)
65283
&& Objects.equals(uri, that.uri)
66284
&& Objects.equals(headers, that.headers)
67-
&& java.util.Arrays.equals(body, that.body);
285+
&& java.util.Arrays.equals(body(), that.body());
68286
}
69287

70288
@Override
71289
public int hashCode() {
72290
int result = Objects.hash(method, uri, headers);
73-
result = 31 * result + java.util.Arrays.hashCode(body);
291+
result = 31 * result + java.util.Arrays.hashCode(body());
74292
return result;
75293
}
76294

@@ -85,7 +303,7 @@ public String toString() {
85303
+ ", headers="
86304
+ headers
87305
+ ", bodyLength="
88-
+ body.length
306+
+ (cachedBody != null ? cachedBody.length : "stream")
89307
+ '}';
90308
}
91309
}

0 commit comments

Comments
 (0)