From b2706068920af3caf0bc236abbe445edf4d4b4a9 Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Sat, 21 Jun 2025 20:24:45 +0200 Subject: [PATCH] HTTPCLIENT-2375 Add first-class request-side compression support and pluggable encoders This change completes the work that began with the decoder registry (HTTPCLIENT-1843) by introducing a symmetric, service-loaded ContentEncoderRegistry and a concise, type-safe API on EntityBuilder. HttpClient will automatically wrap the chosen entity in the appropriate compressing wrapper, provided that the codec is available on the class-path (via Commons Compress or the built-in GZIP/deflate support). --- httpclient5/pom.xml | 5 + .../http/entity/DeflateCompressingEntity.java | 83 +++++++++ .../hc/client5/http/entity/EntityBuilder.java | 71 +++++++- .../compress/CommonsCompressSupport.java | 66 +++++++ .../compress/CommonsCompressingEntity.java | 94 ++++++++++ .../compress/ContentDecoderRegistry.java | 17 +- .../compress/ContentEncoderRegistry.java | 95 ++++++++++ .../http/entity/TestEntityBuilder.java | 7 +- .../ClientServerCompressionExample.java | 162 ++++++++++++++++++ pom.xml | 7 +- 10 files changed, 584 insertions(+), 23 deletions(-) create mode 100644 httpclient5/src/main/java/org/apache/hc/client5/http/entity/DeflateCompressingEntity.java create mode 100644 httpclient5/src/main/java/org/apache/hc/client5/http/entity/compress/CommonsCompressSupport.java create mode 100644 httpclient5/src/main/java/org/apache/hc/client5/http/entity/compress/CommonsCompressingEntity.java create mode 100644 httpclient5/src/main/java/org/apache/hc/client5/http/entity/compress/ContentEncoderRegistry.java create mode 100644 httpclient5/src/test/java/org/apache/hc/client5/http/examples/ClientServerCompressionExample.java diff --git a/httpclient5/pom.xml b/httpclient5/pom.xml index b66f028f0b..ff3992fd50 100644 --- a/httpclient5/pom.xml +++ b/httpclient5/pom.xml @@ -113,6 +113,11 @@ commons-compress true + + com.github.luben + zstd-jni + test + diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/entity/DeflateCompressingEntity.java b/httpclient5/src/main/java/org/apache/hc/client5/http/entity/DeflateCompressingEntity.java new file mode 100644 index 0000000000..9044844362 --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/entity/DeflateCompressingEntity.java @@ -0,0 +1,83 @@ +/* + * ==================================================================== + * 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 + * . + * + */ + +package org.apache.hc.client5.http.entity; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.zip.Deflater; +import java.util.zip.DeflaterOutputStream; + +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.io.entity.HttpEntityWrapper; +import org.apache.hc.core5.util.Args; + +/** + * Entity wrapper that compresses the wrapped entity with + * 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 + * . + * + */ + +package org.apache.hc.client5.http.entity.compress; + + +import org.apache.hc.core5.annotation.Contract; +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.annotation.ThreadingBehavior; + +/** + * Utility that answers the question “Is Apache Commons Compress + * on the class-path and in a usable state?” Both the encoder and + * decoder registries rely on this information. + * + * @since 5.6 + */ +@Internal +@Contract(threading = ThreadingBehavior.STATELESS) +final class CommonsCompressSupport { + + private static final String CCSF = + "org.apache.commons.compress.compressors.CompressorStreamFactory"; + + /** Non-instantiable. */ + private CommonsCompressSupport() { } + + /** + * Returns {@code true} if the core Commons Compress class can be loaded + * with the current class-loader, {@code false} otherwise. + */ + static boolean isPresent() { + try { + Class.forName(CCSF, false, + CommonsCompressSupport.class.getClassLoader()); + return true; + } catch (ClassNotFoundException | LinkageError ex) { + return false; + } + } +} + diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/entity/compress/CommonsCompressingEntity.java b/httpclient5/src/main/java/org/apache/hc/client5/http/entity/compress/CommonsCompressingEntity.java new file mode 100644 index 0000000000..7580b1a34b --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/entity/compress/CommonsCompressingEntity.java @@ -0,0 +1,94 @@ +/* + * ==================================================================== + * 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 + * . + * + */ + +package org.apache.hc.client5.http.entity.compress; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Locale; + +import org.apache.commons.compress.compressors.CompressorException; +import org.apache.commons.compress.compressors.CompressorStreamFactory; +import org.apache.hc.core5.annotation.Contract; +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.annotation.ThreadingBehavior; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.io.entity.HttpEntityWrapper; +import org.apache.hc.core5.util.Args; + +/** + * Compresses the wrapped entity on-the-fly using Apache Commons Compress. + * + *

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 REGISTRY = buildRegistry(); @@ -91,9 +88,9 @@ private static Map buildRegistry() { register(m, ContentCoding.DEFLATE, new DeflateInputStreamFactory()); // 2. Commons-Compress (optional) - if (commonsCompressPresent()) { + if (CommonsCompressSupport.isPresent()) { for (final ContentCoding coding : Arrays.asList( - ContentCoding.BROTLI, + ContentCoding.BROTLI, // note: will be skipped until CC ships an encoder ContentCoding.ZSTD, ContentCoding.XZ, ContentCoding.LZMA, @@ -128,16 +125,6 @@ private static void addCommons(final Map map, } } - private static boolean commonsCompressPresent() { - try { - Class.forName( - CCSF, false, ContentDecoderRegistry.class.getClassLoader()); - return true; - } catch (final ClassNotFoundException | LinkageError ex) { - return false; - } - } - private ContentDecoderRegistry() { } } diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/entity/compress/ContentEncoderRegistry.java b/httpclient5/src/main/java/org/apache/hc/client5/http/entity/compress/ContentEncoderRegistry.java new file mode 100644 index 0000000000..c6b8a9a2f8 --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/entity/compress/ContentEncoderRegistry.java @@ -0,0 +1,95 @@ +/* + * ==================================================================== + * 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 + * . + * + */ + +package org.apache.hc.client5.http.entity.compress; + +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumMap; +import java.util.Map; + +import org.apache.hc.client5.http.entity.DeflateCompressingEntity; +import org.apache.hc.client5.http.entity.GzipCompressingEntity; +import org.apache.hc.core5.annotation.Contract; +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.annotation.ThreadingBehavior; +import org.apache.hc.core5.http.HttpEntity; + +@Internal +@Contract(threading = ThreadingBehavior.STATELESS) +public final class ContentEncoderRegistry { + + /** + * Map token → factory (immutable, thread-safe). + */ + private static final Map REGISTRY = build(); + + public static EncoderFactory lookup(final ContentCoding coding) { + return REGISTRY.get(coding); + } + + + @FunctionalInterface + public interface EncoderFactory { + /** + * Wraps the source entity in its compressing counterpart. + */ + HttpEntity wrap(HttpEntity src); + } + + private static Map build() { + final Map m = + new EnumMap<>(ContentCoding.class); + + /* 1. Built-ins – gzip + deflate use the existing wrappers */ + m.put(ContentCoding.GZIP, GzipCompressingEntity::new); + m.put(ContentCoding.DEFLATE, DeflateCompressingEntity::new); + + /* 2. Commons-Compress – only if the helper class is present */ + if (CommonsCompressSupport.isPresent()) { + for (final ContentCoding c : Arrays.asList( + ContentCoding.BROTLI, + ContentCoding.ZSTD, + ContentCoding.XZ, + ContentCoding.LZMA, + ContentCoding.LZ4_FRAMED, + ContentCoding.LZ4_BLOCK, + ContentCoding.BZIP2, + ContentCoding.PACK200, + ContentCoding.DEFLATE64)) { + + if (CommonsCompressDecoderFactory.runtimeAvailable(c.token())) { + m.put(c, e -> new CommonsCompressingEntity(e, c.token())); + } + } + } + return Collections.unmodifiableMap(m); + } + + private ContentEncoderRegistry() { + } // no-instantiation +} diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/entity/TestEntityBuilder.java b/httpclient5/src/test/java/org/apache/hc/client5/http/entity/TestEntityBuilder.java index fec3a0aef2..8292251ac9 100644 --- a/httpclient5/src/test/java/org/apache/hc/client5/http/entity/TestEntityBuilder.java +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/entity/TestEntityBuilder.java @@ -30,6 +30,7 @@ import java.io.File; import java.io.InputStream; +import org.apache.hc.client5.http.entity.compress.ContentCoding; import org.apache.hc.core5.http.ContentType; import org.apache.hc.core5.http.HttpEntity; import org.apache.hc.core5.http.io.entity.EntityUtils; @@ -115,13 +116,13 @@ void testBuildChunked() { } @Test - void testBuildGZipped() { - final HttpEntity entity = EntityBuilder.create().setText("stuff").gzipCompressed().build(); + void testBuildCompressed() { + final HttpEntity entity = EntityBuilder.create().setText("stuff").compressed(ContentCoding.GZIP).build(); Assertions.assertNotNull(entity); Assertions.assertNotNull(entity.getContentType()); Assertions.assertEquals("text/plain; charset=UTF-8", entity.getContentType()); - Assertions.assertNotNull(entity.getContentEncoding()); Assertions.assertEquals("gzip", entity.getContentEncoding()); + Assertions.assertTrue(entity.isChunked()); } } diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/examples/ClientServerCompressionExample.java b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/ClientServerCompressionExample.java new file mode 100644 index 0000000000..4656cf88da --- /dev/null +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/ClientServerCompressionExample.java @@ -0,0 +1,162 @@ +/* + * ==================================================================== + * 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 + * . + * + */ + +package org.apache.hc.client5.http.examples; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; + +import org.apache.commons.compress.compressors.CompressorException; +import org.apache.commons.compress.compressors.CompressorStreamFactory; +import org.apache.hc.client5.http.entity.EntityBuilder; +import org.apache.hc.client5.http.entity.compress.ContentCoding; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.impl.bootstrap.HttpServer; +import org.apache.hc.core5.http.impl.bootstrap.ServerBootstrap; +import org.apache.hc.core5.http.io.HttpRequestHandler; +import org.apache.hc.core5.http.io.entity.ByteArrayEntity; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.http.io.support.ClassicRequestBuilder; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.io.CloseMode; + +/** + * A minimal end-to-end example that shows how the new + * {@code EntityBuilder.compressed(ContentCoding)} method (added in 5.6) + * can be used to send a request body compressed with Zstandard and to receive + * a transparently decompressed response. + *

+ * 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 @@ 5.3 javax.net.ssl.SSLEngine,javax.net.ssl.SSLParameters,java.nio.ByteBuffer,java.nio.CharBuffer 1.27.1 + 1.5.7-3 @@ -210,7 +211,11 @@ commons-compress ${commons.compress.version} - + + com.github.luben + zstd-jni + ${zstd.jni.version} +