Skip to content

Commit 4101714

Browse files
spicydevbclozel
authored andcommitted
Add compression support in JdkClientHttpRequestFactory
This commit ensures that the "Accept-Encoding" header is present for HTTP requests sent by the `JdkClientHttpRequestFactory`. Only "gzip" and "deflate" encodings are supported. This also adds a custom `BodyHandler` that decompresses HTTP response bodies if the "Content-Encoding" header lists a supported variant. This feature is enabled by default and can be disabled on the request factory. See gh-35225 Signed-off-by: spicydev <[email protected]> [[email protected]: squash commits] Signed-off-by: Brian Clozel <[email protected]>
1 parent fdfd15b commit 4101714

File tree

4 files changed

+134
-4
lines changed

4 files changed

+134
-4
lines changed

spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequest.java

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,15 @@
2424
import java.net.http.HttpClient;
2525
import java.net.http.HttpRequest;
2626
import java.net.http.HttpResponse;
27+
import java.net.http.HttpResponse.BodyHandler;
28+
import java.net.http.HttpResponse.BodySubscriber;
29+
import java.net.http.HttpResponse.BodySubscribers;
30+
import java.net.http.HttpResponse.ResponseInfo;
2731
import java.net.http.HttpTimeoutException;
2832
import java.nio.ByteBuffer;
2933
import java.time.Duration;
3034
import java.util.Collections;
35+
import java.util.List;
3136
import java.util.Locale;
3237
import java.util.Set;
3338
import java.util.TreeSet;
@@ -38,6 +43,8 @@
3843
import java.util.concurrent.Flow;
3944
import java.util.concurrent.TimeUnit;
4045
import java.util.concurrent.atomic.AtomicBoolean;
46+
import java.util.zip.GZIPInputStream;
47+
import java.util.zip.InflaterInputStream;
4148

4249
import org.jspecify.annotations.Nullable;
4350

@@ -60,6 +67,8 @@ class JdkClientHttpRequest extends AbstractStreamingClientHttpRequest {
6067

6168
private static final Set<String> DISALLOWED_HEADERS = disallowedHeaders();
6269

70+
private static final List<String> ALLOWED_ENCODINGS = List.of("gzip", "deflate");
71+
6372

6473
private final HttpClient httpClient;
6574

@@ -71,15 +80,18 @@ class JdkClientHttpRequest extends AbstractStreamingClientHttpRequest {
7180

7281
private final @Nullable Duration timeout;
7382

83+
private final boolean compressionEnabled;
84+
7485

7586
public JdkClientHttpRequest(HttpClient httpClient, URI uri, HttpMethod method, Executor executor,
76-
@Nullable Duration readTimeout) {
87+
@Nullable Duration readTimeout, boolean compressionEnabled) {
7788

7889
this.httpClient = httpClient;
7990
this.uri = uri;
8091
this.method = method;
8192
this.executor = executor;
8293
this.timeout = readTimeout;
94+
this.compressionEnabled = compressionEnabled;
8395
}
8496

8597

@@ -100,7 +112,7 @@ protected ClientHttpResponse executeInternal(HttpHeaders headers, @Nullable Body
100112
TimeoutHandler timeoutHandler = null;
101113
try {
102114
HttpRequest request = buildRequest(headers, body);
103-
responseFuture = this.httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream());
115+
responseFuture = this.httpClient.sendAsync(request, new DecompressingBodyHandler());
104116

105117
if (this.timeout != null) {
106118
timeoutHandler = new TimeoutHandler(responseFuture, this.timeout);
@@ -152,6 +164,15 @@ else if (cause instanceof IOException ioEx) {
152164
private HttpRequest buildRequest(HttpHeaders headers, @Nullable Body body) {
153165
HttpRequest.Builder builder = HttpRequest.newBuilder().uri(this.uri);
154166

167+
// When compression is enabled and valid encoding is absent, we add gzip as standard encoding
168+
if (this.compressionEnabled) {
169+
if (headers.containsHeader(HttpHeaders.ACCEPT_ENCODING) &&
170+
!ALLOWED_ENCODINGS.contains(headers.getFirst(HttpHeaders.ACCEPT_ENCODING))) {
171+
headers.remove(HttpHeaders.ACCEPT_ENCODING);
172+
}
173+
headers.add(HttpHeaders.ACCEPT_ENCODING, "gzip");
174+
}
175+
155176
headers.forEach((headerName, headerValues) -> {
156177
if (!DISALLOWED_HEADERS.contains(headerName.toLowerCase(Locale.ROOT))) {
157178
for (String headerValue : headerValues) {
@@ -237,7 +258,7 @@ public ByteBuffer map(byte[] b, int off, int len) {
237258
/**
238259
* Temporary workaround to use instead of {@link HttpRequest.Builder#timeout(Duration)}
239260
* until <a href="https://bugs.openjdk.org/browse/JDK-8258397">JDK-8258397</a>
240-
* is fixed. Essentially, create a future wiht a timeout handler, and use it
261+
* is fixed. Essentially, create a future with a timeout handler, and use it
241262
* to close the response.
242263
* @see <a href="https://mail.openjdk.org/pipermail/net-dev/2021-October/016672.html">OpenJDK discussion thread</a>
243264
*/
@@ -288,4 +309,39 @@ public void handleCancellationException(CancellationException ex) throws HttpTim
288309
}
289310
}
290311

312+
/**
313+
* Custom BodyHandler that checks the Content-Encoding header and applies the appropriate decompression algorithm.
314+
* Supports Gzip and Deflate encoded responses.
315+
*/
316+
public static final class DecompressingBodyHandler implements BodyHandler<InputStream> {
317+
318+
@Override
319+
public BodySubscriber<InputStream> apply(ResponseInfo responseInfo) {
320+
String contentEncoding = responseInfo.headers().firstValue(HttpHeaders.CONTENT_ENCODING).orElse("");
321+
if (contentEncoding.equalsIgnoreCase("gzip")) {
322+
// If the content is gzipped, wrap the InputStream with a GZIPInputStream
323+
return BodySubscribers.mapping(
324+
BodySubscribers.ofInputStream(),
325+
(InputStream is) -> {
326+
try {
327+
return new GZIPInputStream(is);
328+
}
329+
catch (IOException ex) {
330+
throw new UncheckedIOException(ex); // Propagate IOExceptions
331+
}
332+
});
333+
}
334+
else if (contentEncoding.equalsIgnoreCase("deflate")) {
335+
// If the content is encoded using deflate, wrap the InputStream with a InflaterInputStream
336+
return BodySubscribers.mapping(
337+
BodySubscribers.ofInputStream(),
338+
InflaterInputStream::new);
339+
}
340+
else {
341+
// Otherwise, return a standard InputStream BodySubscriber
342+
return BodySubscribers.ofInputStream();
343+
}
344+
}
345+
}
346+
291347
}

spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequestFactory.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ public class JdkClientHttpRequestFactory implements ClientHttpRequestFactory {
4343

4444
private @Nullable Duration readTimeout;
4545

46+
private boolean compressionEnabled;
47+
4648

4749
/**
4850
* Create a new instance of the {@code JdkClientHttpRequestFactory}
@@ -96,10 +98,18 @@ public void setReadTimeout(Duration readTimeout) {
9698
this.readTimeout = readTimeout;
9799
}
98100

101+
/**
102+
* Sets custom {@link BodyHandler} that can handle gzip encoded {@link HttpClient}'s response.
103+
* @param compressionEnabled to enable compression by default for all {@link HttpClient}'s requests.
104+
*/
105+
public void setCompressionEnabled(boolean compressionEnabled) {
106+
this.compressionEnabled = compressionEnabled;
107+
}
108+
99109

100110
@Override
101111
public ClientHttpRequest createRequest(URI uri, HttpMethod httpMethod) throws IOException {
102-
return new JdkClientHttpRequest(this.httpClient, uri, httpMethod, this.executor, this.readTimeout);
112+
return new JdkClientHttpRequest(this.httpClient, uri, httpMethod, this.executor, this.readTimeout, this.compressionEnabled);
103113
}
104114

105115
}

spring-web/src/test/java/org/springframework/http/client/AbstractMockWebServerTests.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,14 @@
2323
import org.junit.jupiter.api.AfterEach;
2424
import org.junit.jupiter.api.BeforeEach;
2525

26+
import org.springframework.http.HttpHeaders;
2627
import org.springframework.util.StringUtils;
2728

29+
import java.io.ByteArrayOutputStream;
30+
import java.nio.charset.StandardCharsets;
31+
import java.util.zip.DeflaterOutputStream;
32+
import java.util.zip.GZIPOutputStream;
33+
2834
import static org.assertj.core.api.Assertions.assertThat;
2935

3036
/**
@@ -106,6 +112,26 @@ else if(request.getTarget().startsWith("/header/")) {
106112
String headerName = request.getTarget().replace("/header/","");
107113
return new MockResponse.Builder().body(headerName + ":" + request.getHeaders().get(headerName)).code(200).build();
108114
}
115+
else if(request.getTarget().startsWith("/compress/")) {
116+
String encoding = request.getTarget().replace("/compress/","");
117+
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
118+
if (encoding.equals("gzip")) {
119+
try(GZIPOutputStream gzipOutputStream = new GZIPOutputStream(byteArrayOutputStream)) {
120+
gzipOutputStream.write("Test Payload".getBytes());
121+
gzipOutputStream.flush();
122+
}
123+
}
124+
else if(encoding.equals("deflate")) {
125+
try(DeflaterOutputStream deflaterOutputStream = new DeflaterOutputStream(byteArrayOutputStream)) {
126+
deflaterOutputStream.write("Test Payload".getBytes());
127+
deflaterOutputStream.flush();
128+
}
129+
} else {
130+
byteArrayOutputStream.write("Test Payload".getBytes());
131+
}
132+
return new MockResponse.Builder().body(byteArrayOutputStream.toString(StandardCharsets.ISO_8859_1))
133+
.code(200).setHeader(HttpHeaders.CONTENT_ENCODING, encoding).build();
134+
}
109135
return new MockResponse.Builder().code(404).build();
110136
}
111137
catch (Throwable ex) {

spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestFactoryTests.java

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,44 @@ void deleteRequestWithBody() throws Exception {
108108
}
109109
}
110110

111+
@Test
112+
void compressionDisabled() throws IOException {
113+
URI uri = URI.create(baseUrl + "/compress/");
114+
ClientHttpRequest request = this.factory.createRequest(uri, HttpMethod.GET);
115+
try (ClientHttpResponse response = request.execute()) {
116+
assertThat(response.getStatusCode()).as("Invalid response status").isEqualTo(HttpStatus.OK);
117+
assertThat(StreamUtils.copyToString(response.getBody(), StandardCharsets.ISO_8859_1))
118+
.as("Invalid request body").isEqualTo("Test Payload");
119+
}
120+
}
121+
122+
@Test
123+
void compressionGzip() throws IOException {
124+
URI uri = URI.create(baseUrl + "/compress/gzip");
125+
JdkClientHttpRequestFactory requestFactory = (JdkClientHttpRequestFactory) this.factory;
126+
requestFactory.setCompressionEnabled(true);
127+
ClientHttpRequest request = requestFactory.createRequest(uri, HttpMethod.GET);
128+
129+
try (ClientHttpResponse response = request.execute()) {
130+
assertThat(response.getStatusCode()).as("Invalid response status").isEqualTo(HttpStatus.OK);
131+
assertThat(StreamUtils.copyToString(response.getBody(), StandardCharsets.ISO_8859_1))
132+
.as("Invalid request body").isEqualTo("Test Payload");
133+
}
134+
}
135+
136+
@Test
137+
void compressionDeflate() throws IOException {
138+
URI uri = URI.create(baseUrl + "/compress/deflate");
139+
JdkClientHttpRequestFactory requestFactory = (JdkClientHttpRequestFactory) this.factory;
140+
requestFactory.setCompressionEnabled(true);
141+
ClientHttpRequest request = requestFactory.createRequest(uri, HttpMethod.GET);
142+
try (ClientHttpResponse response = request.execute()) {
143+
assertThat(response.getStatusCode()).as("Invalid response status").isEqualTo(HttpStatus.OK);
144+
assertThat(StreamUtils.copyToString(response.getBody(), StandardCharsets.ISO_8859_1))
145+
.as("Invalid request body").isEqualTo("Test Payload");
146+
}
147+
}
148+
111149
@Test // gh-34971
112150
@EnabledForJreRange(min = JRE.JAVA_19) // behavior fixed in Java 19
113151
void requestContentLengthHeaderWhenNoBody() throws Exception {

0 commit comments

Comments
 (0)