From 913b8dee6117172815a0b219416ffcf7c20dac7f Mon Sep 17 00:00:00 2001 From: kmdy7991 Date: Thu, 2 Jul 2026 12:33:57 +0900 Subject: [PATCH] support deflate request encoding in HC5 --- .../java/feign/hc5/ApacheHttp5Client.java | 11 +- .../feign/hc5/AsyncApacheHttp5Client.java | 44 ++++--- .../feign/hc5/AsyncApacheHttp5ClientTest.java | 85 ++++++++++++++ .../feign/hc5/DeflateHttp5ClientTest.java | 111 ++++++++++++++++++ .../java/feign/hc5/GzipHttp5ClientTest.java | 20 ++++ 5 files changed, 250 insertions(+), 21 deletions(-) create mode 100644 hc5/src/test/java/feign/hc5/DeflateHttp5ClientTest.java diff --git a/hc5/src/main/java/feign/hc5/ApacheHttp5Client.java b/hc5/src/main/java/feign/hc5/ApacheHttp5Client.java index 65edd9e71c..3960a476d9 100644 --- a/hc5/src/main/java/feign/hc5/ApacheHttp5Client.java +++ b/hc5/src/main/java/feign/hc5/ApacheHttp5Client.java @@ -37,6 +37,7 @@ import org.apache.hc.client5.http.classic.HttpClient; import org.apache.hc.client5.http.config.Configurable; import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.entity.DeflateCompressingEntity; import org.apache.hc.client5.http.entity.GzipCompressingEntity; import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; import org.apache.hc.client5.http.protocol.HttpClientContext; @@ -126,6 +127,7 @@ ClassicHttpRequest toClassicHttpRequest(Request request, Request.Options options // request headers boolean hasAcceptHeader = false; boolean isGzip = false; + boolean isDeflate = false; for (final Map.Entry> headerEntry : request.headers().entrySet()) { final String headerName = headerEntry.getKey(); if (headerName.equalsIgnoreCase(ACCEPT_HEADER_NAME)) { @@ -139,13 +141,8 @@ ClassicHttpRequest toClassicHttpRequest(Request request, Request.Options options } if (headerName.equalsIgnoreCase(Util.CONTENT_ENCODING)) { isGzip = headerEntry.getValue().stream().anyMatch(Util.ENCODING_GZIP::equalsIgnoreCase); - boolean isDeflate = + isDeflate = headerEntry.getValue().stream().anyMatch(Util.ENCODING_DEFLATE::equalsIgnoreCase); - if (isDeflate) { - // DeflateCompressingEntity not available in hc5 yet - throw new IllegalArgumentException( - "Deflate Content-Encoding is not supported by feign-hc5"); - } } for (final String headerValue : headerEntry.getValue()) { requestBuilder.addHeader(headerName, headerValue); @@ -175,6 +172,8 @@ ClassicHttpRequest toClassicHttpRequest(Request request, Request.Options options } if (isGzip) { entity = new GzipCompressingEntity(entity); + } else if (isDeflate) { + entity = new DeflateCompressingEntity(entity); } requestBuilder.setEntity(entity); } else { diff --git a/hc5/src/main/java/feign/hc5/AsyncApacheHttp5Client.java b/hc5/src/main/java/feign/hc5/AsyncApacheHttp5Client.java index f51c1c2221..51b4f0c6ff 100644 --- a/hc5/src/main/java/feign/hc5/AsyncApacheHttp5Client.java +++ b/hc5/src/main/java/feign/hc5/AsyncApacheHttp5Client.java @@ -23,6 +23,7 @@ import java.io.IOException; import java.util.*; import java.util.concurrent.CompletableFuture; +import java.util.zip.DeflaterOutputStream; import java.util.zip.GZIPOutputStream; import org.apache.hc.client5.http.async.methods.SimpleHttpRequest; import org.apache.hc.client5.http.async.methods.SimpleHttpResponse; @@ -121,6 +122,7 @@ SimpleHttpRequest toClassicHttpRequest(Request request, Request.Options options) // request headers boolean hasAcceptHeader = false; boolean isGzip = false; + boolean isDeflate = false; for (final Map.Entry> headerEntry : request.headers().entrySet()) { final String headerName = headerEntry.getKey(); if (headerName.equalsIgnoreCase(ACCEPT_HEADER_NAME)) { @@ -134,13 +136,8 @@ SimpleHttpRequest toClassicHttpRequest(Request request, Request.Options options) } if (headerName.equalsIgnoreCase(Util.CONTENT_ENCODING)) { isGzip = headerEntry.getValue().stream().anyMatch(Util.ENCODING_GZIP::equalsIgnoreCase); - boolean isDeflate = + isDeflate = headerEntry.getValue().stream().anyMatch(Util.ENCODING_DEFLATE::equalsIgnoreCase); - if (isDeflate) { - // DeflateCompressingEntity not available in hc5 yet - throw new IllegalArgumentException( - "Deflate Content-Encoding is not supported by feign-hc5"); - } } for (final String headerValue : headerEntry.getValue()) { @@ -155,15 +152,10 @@ SimpleHttpRequest toClassicHttpRequest(Request request, Request.Options options) // request body // final Body requestBody = request.requestBody(); byte[] data = request.body(); - if (isGzip && data != null && data.length > 0) { - // compress if needed - try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); - GZIPOutputStream gzipOs = new GZIPOutputStream(baos, true)) { - gzipOs.write(data); - gzipOs.flush(); - data = baos.toByteArray(); - } catch (IOException suppressed) { // NOPMD - } + if (isGzip && data != null) { + data = gzip(data); + } else if (isDeflate && data != null) { + data = deflate(data); } if (data != null) { httpRequest.setBody(data, getContentType(request)); @@ -172,6 +164,28 @@ SimpleHttpRequest toClassicHttpRequest(Request request, Request.Options options) return httpRequest; } + private static byte[] gzip(byte[] data) { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); + GZIPOutputStream gzip = new GZIPOutputStream(baos)) { + gzip.write(data); + gzip.finish(); + return baos.toByteArray(); + } catch (IOException e) { + throw new IllegalStateException("Unable to gzip request body", e); + } + } + + private static byte[] deflate(byte[] data) { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); + DeflaterOutputStream deflater = new DeflaterOutputStream(baos)) { + deflater.write(data); + deflater.finish(); + return baos.toByteArray(); + } catch (IOException e) { + throw new IllegalStateException("Unable to deflate request body", e); + } + } + private ContentType getContentType(Request request) { ContentType contentType = null; for (final Map.Entry> entry : request.headers().entrySet()) { diff --git a/hc5/src/test/java/feign/hc5/AsyncApacheHttp5ClientTest.java b/hc5/src/test/java/feign/hc5/AsyncApacheHttp5ClientTest.java index 3b3a4f33cf..b4f0f46f9e 100644 --- a/hc5/src/test/java/feign/hc5/AsyncApacheHttp5ClientTest.java +++ b/hc5/src/test/java/feign/hc5/AsyncApacheHttp5ClientTest.java @@ -149,6 +149,83 @@ void postBodyParam() throws Exception { checkCFCompletedSoon(cf); } + @Test + void postGZIPEncodedBodyParam() throws Exception { + server.enqueue(new MockResponse().setBody("foo")); + + final TestInterfaceAsync api = + new TestInterfaceAsyncBuilder().target("http://localhost:" + server.getPort()); + + final CompletableFuture cf = + api.gzipBody(Arrays.asList("netflix", "denominator", "password")); + + assertThat(server.takeRequest()) + .hasGzippedBody("[netflix, denominator, password]".getBytes(Util.UTF_8)); + + checkCFCompletedSoon(cf); + } + + @Test + void postGZIPEncodedEmptyBodyParam() throws Exception { + server.enqueue(new MockResponse().setBody("foo")); + + final TestInterfaceAsync api = + new TestInterfaceAsyncBuilder().target("http://localhost:" + server.getPort()); + + final CompletableFuture cf = api.gzipBody(""); + + assertThat(server.takeRequest()).hasGzippedBody(new byte[0]); + + checkCFCompletedSoon(cf); + } + + @Test + void postGZIPAndDeflateEncodedBodyParam() throws Exception { + server.enqueue(new MockResponse().setBody("foo")); + + final TestInterfaceAsync api = + new TestInterfaceAsyncBuilder() + .requestInterceptor(req -> req.header("Content-Encoding", "gzip", "deflate")) + .target("http://localhost:" + server.getPort()); + + final CompletableFuture cf = api.body(Arrays.asList("netflix", "denominator", "password")); + + assertThat(server.takeRequest()) + .hasGzippedBody("[netflix, denominator, password]".getBytes(Util.UTF_8)); + + checkCFCompletedSoon(cf); + } + + @Test + void postDeflateEncodedBodyParam() throws Exception { + server.enqueue(new MockResponse().setBody("foo")); + + final TestInterfaceAsync api = + new TestInterfaceAsyncBuilder().target("http://localhost:" + server.getPort()); + + final CompletableFuture cf = + api.deflateBody(Arrays.asList("netflix", "denominator", "password")); + + assertThat(server.takeRequest()) + .hasDeflatedBody("[netflix, denominator, password]".getBytes(Util.UTF_8)); + + checkCFCompletedSoon(cf); + } + + @Test + void postDeflateEncodedEmptyBodyParam() throws Exception { + server.enqueue(new MockResponse().setBody("foo")); + + final TestInterfaceAsync api = + new TestInterfaceAsyncBuilder().target("http://localhost:" + server.getPort()); + + final CompletableFuture cf = api.deflateBody(""); + + assertThat(server.takeRequest()).hasDeflatedBody(new byte[0]); + + checkCFCompletedSoon(cf); + } + /** * The type of a parameter value may not be the desired type to encode as. Prefer the interface * type. @@ -949,10 +1026,18 @@ CompletableFuture login( @Headers("Content-Encoding: gzip") CompletableFuture gzipBody(List contents); + @RequestLine("POST /") + @Headers("Content-Encoding: gzip") + CompletableFuture gzipBody(String contents); + @RequestLine("POST /") @Headers("Content-Encoding: deflate") CompletableFuture deflateBody(List contents); + @RequestLine("POST /") + @Headers("Content-Encoding: deflate") + CompletableFuture deflateBody(String contents); + @RequestLine("POST /") CompletableFuture form( @Param("customer_name") String customer, diff --git a/hc5/src/test/java/feign/hc5/DeflateHttp5ClientTest.java b/hc5/src/test/java/feign/hc5/DeflateHttp5ClientTest.java new file mode 100644 index 0000000000..f53cfacc8f --- /dev/null +++ b/hc5/src/test/java/feign/hc5/DeflateHttp5ClientTest.java @@ -0,0 +1,111 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.hc5; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import feign.Feign; +import feign.Feign.Builder; +import feign.RequestLine; +import feign.client.AbstractClientTest; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.zip.InflaterInputStream; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.Test; + +/** Tests that 'Content-Encoding: deflate' is handled correctly */ +public class DeflateHttp5ClientTest extends AbstractClientTest { + + @Override + public Builder newBuilder() { + return Feign.builder().client(new ApacheHttp5Client()); + } + + @Test + public void testWithCompressedBody() throws InterruptedException, IOException { + final TestInterface testInterface = buildTestInterface(true); + + server.enqueue(new MockResponse().setBody("foo")); + + assertEquals("foo", testInterface.withBody("bar")); + final RecordedRequest request1 = server.takeRequest(); + assertEquals("/test", request1.getPath()); + assertEquals("deflate", request1.getHeader("Content-Encoding")); + + ByteArrayInputStream bodyContentIs = + new ByteArrayInputStream(request1.getBody().readByteArray()); + byte[] uncompressed = new InflaterInputStream(bodyContentIs).readAllBytes(); + + assertEquals("bar", new String(uncompressed, StandardCharsets.UTF_8)); + } + + @Test + public void testWithEmptyCompressedBody() throws InterruptedException, IOException { + final TestInterface testInterface = buildTestInterface(true); + + server.enqueue(new MockResponse().setBody("foo")); + + assertEquals("foo", testInterface.withBody("")); + final RecordedRequest request1 = server.takeRequest(); + assertEquals("/test", request1.getPath()); + assertEquals("deflate", request1.getHeader("Content-Encoding")); + + ByteArrayInputStream bodyContentIs = + new ByteArrayInputStream(request1.getBody().readByteArray()); + byte[] uncompressed = new InflaterInputStream(bodyContentIs).readAllBytes(); + + assertEquals(0, uncompressed.length); + } + + @Test + public void testWithUncompressedBody() throws InterruptedException, IOException { + final TestInterface testInterface = buildTestInterface(false); + + server.enqueue(new MockResponse().setBody("foo")); + + assertEquals("foo", testInterface.withBody("bar")); + final RecordedRequest request1 = server.takeRequest(); + assertEquals("/test", request1.getPath()); + + assertEquals("bar", request1.getBody().readString(StandardCharsets.UTF_8)); + } + + private TestInterface buildTestInterface(boolean compress) { + return newBuilder() + .requestInterceptor(req -> req.header("Content-Encoding", compress ? "deflate" : "")) + .target(TestInterface.class, "http://localhost:" + server.getPort()); + } + + @Override + public void veryLongResponseNullLength() { + assumeTrue(true, "HC5 client seems to hang with response size equalto Long.MAX"); + } + + @Override + public void contentTypeDefaultsToRequestCharset() throws Exception { + assumeTrue(true, "this test is flaky on windows, but works fine."); + } + + public interface TestInterface { + + @RequestLine("POST /test") + String withBody(String body); + } +} diff --git a/hc5/src/test/java/feign/hc5/GzipHttp5ClientTest.java b/hc5/src/test/java/feign/hc5/GzipHttp5ClientTest.java index 6f080b2029..10c99564b3 100644 --- a/hc5/src/test/java/feign/hc5/GzipHttp5ClientTest.java +++ b/hc5/src/test/java/feign/hc5/GzipHttp5ClientTest.java @@ -55,6 +55,26 @@ public void testWithCompressedBody() throws InterruptedException, IOException { assertEquals("bar", new String(uncompressed, StandardCharsets.UTF_8)); } + @Test + public void testWithGzipAndDeflateHeaders() throws InterruptedException, IOException { + final TestInterface testInterface = + newBuilder() + .requestInterceptor(req -> req.header("Content-Encoding", "gzip", "deflate")) + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + server.enqueue(new MockResponse().setBody("foo")); + + assertEquals("foo", testInterface.withBody("bar")); + final RecordedRequest request1 = server.takeRequest(); + assertEquals("/test", request1.getPath()); + + ByteArrayInputStream bodyContentIs = + new ByteArrayInputStream(request1.getBody().readByteArray()); + byte[] uncompressed = new GZIPInputStream(bodyContentIs).readAllBytes(); + + assertEquals("bar", new String(uncompressed, StandardCharsets.UTF_8)); + } + @Test public void testWithUncompressedBody() throws InterruptedException, IOException { final TestInterface testInterface = buildTestInterface(false);