Skip to content

Commit 239948e

Browse files
HTTPCLIENT-2375 Add first-class request-side compression support and pluggable encoders (#657)
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).
1 parent c568701 commit 239948e

10 files changed

Lines changed: 584 additions & 23 deletions

File tree

httpclient5/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,11 @@
113113
<artifactId>commons-compress</artifactId>
114114
<optional>true</optional>
115115
</dependency>
116+
<dependency>
117+
<groupId>com.github.luben</groupId>
118+
<artifactId>zstd-jni</artifactId>
119+
<scope>test</scope>
120+
</dependency>
116121
</dependencies>
117122

118123
<build>
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/*
2+
* ====================================================================
3+
* Licensed to the Apache Software Foundation (ASF) under one
4+
* or more contributor license agreements. See the NOTICE file
5+
* distributed with this work for additional information
6+
* regarding copyright ownership. The ASF licenses this file
7+
* to you under the Apache License, Version 2.0 (the
8+
* "License"); you may not use this file except in compliance
9+
* with the License. You may obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing,
14+
* software distributed under the License is distributed on an
15+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16+
* KIND, either express or implied. See the License for the
17+
* specific language governing permissions and limitations
18+
* under the License.
19+
* ====================================================================
20+
*
21+
* This software consists of voluntary contributions made by many
22+
* individuals on behalf of the Apache Software Foundation. For more
23+
* information on the Apache Software Foundation, please see
24+
* <http://www.apache.org/>.
25+
*
26+
*/
27+
28+
package org.apache.hc.client5.http.entity;
29+
30+
import java.io.IOException;
31+
import java.io.InputStream;
32+
import java.io.OutputStream;
33+
import java.util.zip.Deflater;
34+
import java.util.zip.DeflaterOutputStream;
35+
36+
import org.apache.hc.core5.http.HttpEntity;
37+
import org.apache.hc.core5.http.io.entity.HttpEntityWrapper;
38+
import org.apache.hc.core5.util.Args;
39+
40+
/**
41+
* Entity wrapper that compresses the wrapped entity with
42+
* <code>Content-Encoding: deflate</code> on write-out.
43+
*
44+
* @since 5.6
45+
*/
46+
public final class DeflateCompressingEntity extends HttpEntityWrapper {
47+
48+
private static final String DEFLATE_CODEC = "deflate";
49+
50+
public DeflateCompressingEntity(final HttpEntity entity) {
51+
super(entity);
52+
}
53+
54+
@Override
55+
public String getContentEncoding() {
56+
return DEFLATE_CODEC;
57+
}
58+
59+
@Override
60+
public long getContentLength() {
61+
return -1; // length unknown after compression
62+
}
63+
64+
@Override
65+
public boolean isChunked() {
66+
return true; // force chunked transfer-encoding
67+
}
68+
69+
@Override
70+
public InputStream getContent() throws IOException {
71+
throw new UnsupportedOperationException("getContent() not supported");
72+
}
73+
74+
@Override
75+
public void writeTo(final OutputStream out) throws IOException {
76+
Args.notNull(out, "Output stream");
77+
// ‘false’ second arg = include zlib wrapper (= RFC 1950 = HTTP “deflate”)
78+
try (DeflaterOutputStream deflater =
79+
new DeflaterOutputStream(out, new Deflater(Deflater.DEFAULT_COMPRESSION, /*nowrap*/ false))) {
80+
super.writeTo(deflater);
81+
}
82+
}
83+
}

httpclient5/src/main/java/org/apache/hc/client5/http/entity/EntityBuilder.java

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
import java.util.Arrays;
3434
import java.util.List;
3535

36+
import org.apache.hc.client5.http.entity.compress.ContentCoding;
37+
import org.apache.hc.client5.http.entity.compress.ContentEncoderRegistry;
3638
import org.apache.hc.core5.http.ContentType;
3739
import org.apache.hc.core5.http.HttpEntity;
3840
import org.apache.hc.core5.http.NameValuePair;
@@ -72,8 +74,29 @@ public class EntityBuilder {
7274
private ContentType contentType;
7375
private String contentEncoding;
7476
private boolean chunked;
77+
/**
78+
* @deprecated use {@link #compressWith} instead
79+
*/
80+
@Deprecated
7581
private boolean gzipCompressed;
7682

83+
/**
84+
* The compression algorithm to apply when {@link #build() building}
85+
* the final {@link HttpEntity}.
86+
* <p>
87+
* If {@code null} (default) the entity is sent as-is; otherwise the
88+
* entity content is wrapped in the corresponding <i>compressing</i>
89+
* wrapper (for example {@code GzipCompressingEntity},
90+
* {@code DeflateCompressingEntity}, or a Commons-Compress based
91+
* implementation) and the correct {@code Content-Encoding} header is
92+
* added.
93+
* </p>
94+
*
95+
* @since 5.6
96+
*/
97+
private ContentCoding compressWith;
98+
99+
77100
EntityBuilder() {
78101
super();
79102
}
@@ -335,21 +358,56 @@ public EntityBuilder chunked() {
335358
* Tests if entities are to be GZIP compressed ({@code true}), or not ({@code false}).
336359
*
337360
* @return {@code true} if entity is to be GZIP compressed, {@code false} otherwise.
361+
* @deprecated since 5.6 – use {@link #getCompressWith()} and
362+
* check for {@code ContentCoding.GZIP} instead.
338363
*/
364+
@Deprecated
339365
public boolean isGzipCompressed() {
340-
return gzipCompressed;
366+
return compressWith == ContentCoding.GZIP;
341367
}
342368

343369
/**
344370
* Sets entities to be GZIP compressed.
345371
*
346372
* @return this instance.
373+
* @deprecated since 5.6 – replace with
374+
* {@code compressed(ContentCoding.GZIP)}.
347375
*/
376+
@Deprecated
348377
public EntityBuilder gzipCompressed() {
349-
this.gzipCompressed = true;
378+
this.compressWith = ContentCoding.GZIP;
379+
return this;
380+
}
381+
382+
/**
383+
* Requests that the entity produced by this builder be <em>compressed</em>
384+
* with the supplied content-coding.
385+
*
386+
* @param coding the content-coding to use (never {@code null})
387+
* @return this builder for method chaining
388+
* @since 5.6
389+
*/
390+
public EntityBuilder compressed(final ContentCoding coding) {
391+
this.compressWith = coding;
350392
return this;
351393
}
352394

395+
/**
396+
* Returns the content-coding that {@link #build()} will apply to the
397+
* outgoing {@link org.apache.hc.core5.http.HttpEntity}, or {@code null}
398+
* when no compression has been requested.
399+
*
400+
* @return the chosen {@link ContentCoding} — typically
401+
* {@link ContentCoding#GZIP}, {@link ContentCoding#DEFLATE}, etc. —
402+
* or {@code null} if the request body will be sent uncompressed.
403+
* @since 5.6
404+
*/
405+
public ContentCoding getCompressWith() {
406+
return compressWith;
407+
}
408+
409+
410+
353411
private ContentType getContentOrDefault(final ContentType def) {
354412
return this.contentType != null ? this.contentType : def;
355413
}
@@ -380,8 +438,13 @@ public HttpEntity build() {
380438
} else {
381439
throw new IllegalStateException("No entity set");
382440
}
383-
if (this.gzipCompressed) {
384-
return new GzipCompressingEntity(e);
441+
if (compressWith != null) {
442+
final ContentEncoderRegistry.EncoderFactory f = ContentEncoderRegistry.lookup(compressWith);
443+
if (f == null) {
444+
throw new UnsupportedOperationException(
445+
"No encoder available for content-coding '" + compressWith.token() + '\'');
446+
}
447+
return f.wrap(e);
385448
}
386449
return e;
387450
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
* ====================================================================
3+
* Licensed to the Apache Software Foundation (ASF) under one
4+
* or more contributor license agreements. See the NOTICE file
5+
* distributed with this work for additional information
6+
* regarding copyright ownership. The ASF licenses this file
7+
* to you under the Apache License, Version 2.0 (the
8+
* "License"); you may not use this file except in compliance
9+
* with the License. You may obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing,
14+
* software distributed under the License is distributed on an
15+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16+
* KIND, either express or implied. See the License for the
17+
* specific language governing permissions and limitations
18+
* under the License.
19+
* ====================================================================
20+
*
21+
* This software consists of voluntary contributions made by many
22+
* individuals on behalf of the Apache Software Foundation. For more
23+
* information on the Apache Software Foundation, please see
24+
* <http://www.apache.org/>.
25+
*
26+
*/
27+
28+
package org.apache.hc.client5.http.entity.compress;
29+
30+
31+
import org.apache.hc.core5.annotation.Contract;
32+
import org.apache.hc.core5.annotation.Internal;
33+
import org.apache.hc.core5.annotation.ThreadingBehavior;
34+
35+
/**
36+
* Utility that answers the question “Is Apache Commons Compress
37+
* on the class-path and in a usable state?” Both the encoder and
38+
* decoder registries rely on this information.
39+
*
40+
* @since 5.6
41+
*/
42+
@Internal
43+
@Contract(threading = ThreadingBehavior.STATELESS)
44+
final class CommonsCompressSupport {
45+
46+
private static final String CCSF =
47+
"org.apache.commons.compress.compressors.CompressorStreamFactory";
48+
49+
/** Non-instantiable. */
50+
private CommonsCompressSupport() { }
51+
52+
/**
53+
* Returns {@code true} if the core Commons Compress class can be loaded
54+
* with the current class-loader, {@code false} otherwise.
55+
*/
56+
static boolean isPresent() {
57+
try {
58+
Class.forName(CCSF, false,
59+
CommonsCompressSupport.class.getClassLoader());
60+
return true;
61+
} catch (ClassNotFoundException | LinkageError ex) {
62+
return false;
63+
}
64+
}
65+
}
66+
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*
2+
* ====================================================================
3+
* Licensed to the Apache Software Foundation (ASF) under one
4+
* or more contributor license agreements. See the NOTICE file
5+
* distributed with this work for additional information
6+
* regarding copyright ownership. The ASF licenses this file
7+
* to you under the Apache License, Version 2.0 (the
8+
* "License"); you may not use this file except in compliance
9+
* with the License. You may obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing,
14+
* software distributed under the License is distributed on an
15+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16+
* KIND, either express or implied. See the License for the
17+
* specific language governing permissions and limitations
18+
* under the License.
19+
* ====================================================================
20+
*
21+
* This software consists of voluntary contributions made by many
22+
* individuals on behalf of the Apache Software Foundation. For more
23+
* information on the Apache Software Foundation, please see
24+
* <http://www.apache.org/>.
25+
*
26+
*/
27+
28+
package org.apache.hc.client5.http.entity.compress;
29+
30+
import java.io.IOException;
31+
import java.io.InputStream;
32+
import java.io.OutputStream;
33+
import java.util.Locale;
34+
35+
import org.apache.commons.compress.compressors.CompressorException;
36+
import org.apache.commons.compress.compressors.CompressorStreamFactory;
37+
import org.apache.hc.core5.annotation.Contract;
38+
import org.apache.hc.core5.annotation.Internal;
39+
import org.apache.hc.core5.annotation.ThreadingBehavior;
40+
import org.apache.hc.core5.http.HttpEntity;
41+
import org.apache.hc.core5.http.io.entity.HttpEntityWrapper;
42+
import org.apache.hc.core5.util.Args;
43+
44+
/**
45+
* Compresses the wrapped entity on-the-fly using Apache&nbsp;Commons Compress.
46+
*
47+
* <p>The codec is chosen by its IANA token (for example {@code "br"} or
48+
* {@code "zstd"}). The helper JAR must be present at run-time; otherwise
49+
* {@link #writeTo(OutputStream)} will throw {@link IOException}.</p>
50+
*
51+
* @since 5.6
52+
*/
53+
@Internal
54+
@Contract(threading = ThreadingBehavior.STATELESS)
55+
public final class CommonsCompressingEntity extends HttpEntityWrapper {
56+
57+
private final String coding; // lower-case
58+
private final CompressorStreamFactory factory = new CompressorStreamFactory();
59+
60+
CommonsCompressingEntity(final HttpEntity src, final String coding) {
61+
super(src);
62+
this.coding = coding.toLowerCase(Locale.ROOT);
63+
}
64+
65+
@Override
66+
public String getContentEncoding() {
67+
return coding;
68+
}
69+
70+
@Override
71+
public long getContentLength() {
72+
return -1;
73+
} // streaming
74+
75+
@Override
76+
public boolean isChunked() {
77+
return true;
78+
}
79+
80+
@Override
81+
public InputStream getContent() { // Pull-mode is not supported
82+
throw new UnsupportedOperationException("Compressed entity is write-only");
83+
}
84+
85+
@Override
86+
public void writeTo(final OutputStream out) throws IOException {
87+
Args.notNull(out, "Output stream");
88+
try (OutputStream cos = factory.createCompressorOutputStream(coding, out)) {
89+
super.writeTo(cos);
90+
} catch (final CompressorException | LinkageError ex) {
91+
throw new IOException("Unable to compress using coding '" + coding + '\'', ex);
92+
}
93+
}
94+
}

0 commit comments

Comments
 (0)