Content-Encoding: deflate on write-out.
+ *
+ * @since 5.6
+ */
+public final class DeflateCompressingEntity extends HttpEntityWrapper {
+
+ private static final String DEFLATE_CODEC = "deflate";
+
+ public DeflateCompressingEntity(final HttpEntity entity) {
+ super(entity);
+ }
+
+ @Override
+ public String getContentEncoding() {
+ return DEFLATE_CODEC;
+ }
+
+ @Override
+ public long getContentLength() {
+ return -1; // length unknown after compression
+ }
+
+ @Override
+ public boolean isChunked() {
+ return true; // force chunked transfer-encoding
+ }
+
+ @Override
+ public InputStream getContent() throws IOException {
+ throw new UnsupportedOperationException("getContent() not supported");
+ }
+
+ @Override
+ public void writeTo(final OutputStream out) throws IOException {
+ Args.notNull(out, "Output stream");
+ // ‘false’ second arg = include zlib wrapper (= RFC 1950 = HTTP “deflate”)
+ try (DeflaterOutputStream deflater =
+ new DeflaterOutputStream(out, new Deflater(Deflater.DEFAULT_COMPRESSION, /*nowrap*/ false))) {
+ super.writeTo(deflater);
+ }
+ }
+}
\ No newline at end of file
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/entity/EntityBuilder.java b/httpclient5/src/main/java/org/apache/hc/client5/http/entity/EntityBuilder.java
index 43960077a7..342c2fa60a 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/entity/EntityBuilder.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/entity/EntityBuilder.java
@@ -33,6 +33,8 @@
import java.util.Arrays;
import java.util.List;
+import org.apache.hc.client5.http.entity.compress.ContentCoding;
+import org.apache.hc.client5.http.entity.compress.ContentEncoderRegistry;
import org.apache.hc.core5.http.ContentType;
import org.apache.hc.core5.http.HttpEntity;
import org.apache.hc.core5.http.NameValuePair;
@@ -72,8 +74,29 @@ public class EntityBuilder {
private ContentType contentType;
private String contentEncoding;
private boolean chunked;
+ /**
+ * @deprecated use {@link #compressWith} instead
+ */
+ @Deprecated
private boolean gzipCompressed;
+ /**
+ * The compression algorithm to apply when {@link #build() building}
+ * the final {@link HttpEntity}.
+ * + * If {@code null} (default) the entity is sent as-is; otherwise the + * entity content is wrapped in the corresponding compressing + * wrapper (for example {@code GzipCompressingEntity}, + * {@code DeflateCompressingEntity}, or a Commons-Compress based + * implementation) and the correct {@code Content-Encoding} header is + * added. + *
+ * + * @since 5.6 + */ + private ContentCoding compressWith; + + EntityBuilder() { super(); } @@ -335,21 +358,56 @@ public EntityBuilder chunked() { * Tests if entities are to be GZIP compressed ({@code true}), or not ({@code false}). * * @return {@code true} if entity is to be GZIP compressed, {@code false} otherwise. + * @deprecated since 5.6 – use {@link #getCompressWith()} and + * check for {@code ContentCoding.GZIP} instead. */ + @Deprecated public boolean isGzipCompressed() { - return gzipCompressed; + return compressWith == ContentCoding.GZIP; } /** * Sets entities to be GZIP compressed. * * @return this instance. + * @deprecated since 5.6 – replace with + * {@code compressed(ContentCoding.GZIP)}. */ + @Deprecated public EntityBuilder gzipCompressed() { - this.gzipCompressed = true; + this.compressWith = ContentCoding.GZIP; + return this; + } + + /** + * Requests that the entity produced by this builder be compressed + * with the supplied content-coding. + * + * @param coding the content-coding to use (never {@code null}) + * @return this builder for method chaining + * @since 5.6 + */ + public EntityBuilder compressed(final ContentCoding coding) { + this.compressWith = coding; return this; } + /** + * Returns the content-coding that {@link #build()} will apply to the + * outgoing {@link org.apache.hc.core5.http.HttpEntity}, or {@code null} + * when no compression has been requested. + * + * @return the chosen {@link ContentCoding} — typically + * {@link ContentCoding#GZIP}, {@link ContentCoding#DEFLATE}, etc. — + * or {@code null} if the request body will be sent uncompressed. + * @since 5.6 + */ + public ContentCoding getCompressWith() { + return compressWith; + } + + + private ContentType getContentOrDefault(final ContentType def) { return this.contentType != null ? this.contentType : def; } @@ -380,8 +438,13 @@ public HttpEntity build() { } else { throw new IllegalStateException("No entity set"); } - if (this.gzipCompressed) { - return new GzipCompressingEntity(e); + if (compressWith != null) { + final ContentEncoderRegistry.EncoderFactory f = ContentEncoderRegistry.lookup(compressWith); + if (f == null) { + throw new UnsupportedOperationException( + "No encoder available for content-coding '" + compressWith.token() + '\''); + } + return f.wrap(e); } return e; } diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/entity/compress/CommonsCompressSupport.java b/httpclient5/src/main/java/org/apache/hc/client5/http/entity/compress/CommonsCompressSupport.java new file mode 100644 index 0000000000..986f5c5683 --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/entity/compress/CommonsCompressSupport.java @@ -0,0 +1,66 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + *The codec is chosen by its IANA token (for example {@code "br"} or + * {@code "zstd"}). The helper JAR must be present at run-time; otherwise + * {@link #writeTo(OutputStream)} will throw {@link IOException}.
+ * + * @since 5.6 + */ +@Internal +@Contract(threading = ThreadingBehavior.STATELESS) +public final class CommonsCompressingEntity extends HttpEntityWrapper { + + private final String coding; // lower-case + private final CompressorStreamFactory factory = new CompressorStreamFactory(); + + CommonsCompressingEntity(final HttpEntity src, final String coding) { + super(src); + this.coding = coding.toLowerCase(Locale.ROOT); + } + + @Override + public String getContentEncoding() { + return coding; + } + + @Override + public long getContentLength() { + return -1; + } // streaming + + @Override + public boolean isChunked() { + return true; + } + + @Override + public InputStream getContent() { // Pull-mode is not supported + throw new UnsupportedOperationException("Compressed entity is write-only"); + } + + @Override + public void writeTo(final OutputStream out) throws IOException { + Args.notNull(out, "Output stream"); + try (OutputStream cos = factory.createCompressorOutputStream(coding, out)) { + super.writeTo(cos); + } catch (final CompressorException | LinkageError ex) { + throw new IOException("Unable to compress using coding '" + coding + '\'', ex); + } + } +} \ No newline at end of file diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/entity/compress/ContentDecoderRegistry.java b/httpclient5/src/main/java/org/apache/hc/client5/http/entity/compress/ContentDecoderRegistry.java index a019f705e6..c3c5ced5f7 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/entity/compress/ContentDecoderRegistry.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/entity/compress/ContentDecoderRegistry.java @@ -68,9 +68,6 @@ @Contract(threading = ThreadingBehavior.STATELESS) public final class ContentDecoderRegistry { - private static final String CCSF = - "org.apache.commons.compress.compressors.CompressorStreamFactory"; - private static final Map+ * The code spins up an in-process {@link HttpServer} on a random port. The + * client builds a simple {@code POST /echo} request whose entity is + * compressed by calling + * {@code .compressed(ContentCoding.ZSTD)}. The server handler decodes the + * incoming body with Apache Commons Compress, echoes the text, re-encodes it + * with Zstandard, sets {@code Content-Encoding: zstd} and returns it. On the + * way back, HttpClient detects the header, selects the matching decoder that + * was registered automatically through {@code ContentDecoderRegistry} and + * hands application code a plain {@code String}. + *
+ * To run the example you need HttpClient 5.6-SNAPSHOT, Commons Compress
+ * 1.21 or newer and its transitive dependency {@code zstd-jni}. On JDK 17+
+ * the first invocation of the Zstandard codec prints a warning about loading
+ * native code; add {@code --enable-native-access=ALL-UNNAMED} if you prefer a
+ * clean console.
+ *
+ * @since 5.6
+ */
+public final class ClientServerCompressionExample {
+
+ private ClientServerCompressionExample() {
+ }
+
+ public static void main(final String[] args) throws Exception {
+ int port = 8080;
+ if (args.length >= 1) {
+ port = Integer.parseInt(args[0]);
+ }
+ final HttpServer server = ServerBootstrap.bootstrap()
+ .setListenerPort(port)
+ .setCanonicalHostName("localhost")
+ .register("/echo", new EchoHandler())
+ .create();
+ server.start();
+ final int actualPort = server.getLocalPort();
+
+ try (CloseableHttpClient client = HttpClients.createDefault()) {
+ final String requestBody = "Hello Zstandard world!";
+ System.out.println("Request : " + requestBody);
+
+ final ClassicHttpRequest post = ClassicRequestBuilder
+ .post("http://localhost:" + actualPort + "/echo")
+ .setEntity(EntityBuilder.create()
+ .setText(requestBody)
+ .compressed(ContentCoding.ZSTD)
+ .build())
+ .build();
+
+ final String reply = client.execute(
+ post,
+ rsp -> EntityUtils.toString(rsp.getEntity(), StandardCharsets.UTF_8));
+
+ System.out.println("Response: " + reply);
+ } finally {
+ server.close(CloseMode.GRACEFUL);
+ }
+ }
+
+ /**
+ * Simple echo handler that decodes and re-encodes Zstandard bodies.
+ */
+ private static final class EchoHandler implements HttpRequestHandler {
+
+ @Override
+ public void handle(
+ final ClassicHttpRequest request,
+ final ClassicHttpResponse response,
+ final HttpContext context) throws IOException {
+
+ try (InputStream in = new CompressorStreamFactory()
+ .createCompressorInputStream(ContentCoding.ZSTD.token(), request.getEntity().getContent())) {
+
+ final byte[] data = readAll(in);
+ final String text = new String(data, StandardCharsets.UTF_8);
+
+ response.setCode(HttpStatus.SC_OK);
+ response.addHeader("Content-Encoding", ContentCoding.ZSTD.token());
+
+ final ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ try (final OutputStream zstdOut = new CompressorStreamFactory()
+ .createCompressorOutputStream("zstd", baos)) {
+ zstdOut.write(text.getBytes(StandardCharsets.UTF_8));
+ }
+ response.setEntity(new ByteArrayEntity(baos.toByteArray(), ContentType.TEXT_PLAIN));
+ } catch (final CompressorException ex) {
+ response.setCode(HttpStatus.SC_BAD_REQUEST);
+ response.setEntity(new StringEntity("Unable to process compressed payload", StandardCharsets.UTF_8));
+ }
+ }
+
+ private static byte[] readAll(final InputStream in) throws IOException {
+ final ByteArrayOutputStream buf = new ByteArrayOutputStream();
+ final byte[] tmp = new byte[8 * 1024];
+ int n;
+ while ((n = in.read(tmp)) != -1) {
+ buf.write(tmp, 0, n);
+ }
+ return buf.toByteArray();
+ }
+ }
+}
diff --git a/pom.xml b/pom.xml
index 7556ea0107..f885e4db46 100644
--- a/pom.xml
+++ b/pom.xml
@@ -79,6 +79,7 @@