Skip to content

Commit 0e3c4c0

Browse files
authored
feat: Support zstd encoding for downloads (#1287)
1 parent 29b6519 commit 0e3c4c0

File tree

7 files changed

+230
-4
lines changed

7 files changed

+230
-4
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,9 @@ public final class BoxDeveloperEditionAPIConnectionAsEnterpriseUser {
207207

208208
The Box Java SDK is compatible with Java 8 and up.
209209

210+
## Compression Support
211+
The SDK supports both gzip and zstd compression for API requests. Compression is handled automatically based on server capabilities.
212+
210213
## Building
211214

212215
The SDK uses Gradle for its build system. SDK comes with Gradle wrapper. Running `./gradlew build` from the root

build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ dependencies {
5454
implementation "org.bouncycastle:bcprov-jdk18on:1.78.1"
5555
implementation "org.bouncycastle:bcpkix-jdk18on:1.78.1"
5656
implementation "com.squareup.okhttp3:okhttp:4.12.0"
57+
implementation "com.github.luben:zstd-jni:1.5.5-5"
5758
testsCommonImplementation "junit:junit:4.13.2"
5859
testsCommonImplementation "org.hamcrest:hamcrest-library:2.2"
5960
testsCommonImplementation "org.mockito:mockito-core:4.8.0"

src/intTest/java/com/box/sdk/BoxFileIT.java

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package com.box.sdk;
22

3+
import static com.box.sdk.BinaryBodyUtils.writeStream;
34
import static com.box.sdk.BoxApiProvider.jwtApiForServiceAccount;
45
import static com.box.sdk.BoxFile.ALL_VERSION_FIELDS;
6+
import static com.box.sdk.BoxFile.CONTENT_URL_TEMPLATE;
57
import static com.box.sdk.BoxRetentionPolicyAssignment.createAssignmentToFolder;
68
import static com.box.sdk.BoxSharedLink.Access.OPEN;
79
import static com.box.sdk.CleanupTools.deleteFile;
@@ -225,6 +227,64 @@ public void uploadAndDownloadFileSucceeds() throws IOException {
225227

226228
}
227229

230+
@Test
231+
public void downloadFileUseZstdSucceeds() throws IOException {
232+
BoxAPIConnection api = jwtApiForServiceAccount();
233+
api.setUseZstdCompression(true);
234+
235+
String fileName = "smalltest.pdf";
236+
URL fileURL = this.getClass().getResource("/sample-files/" + fileName);
237+
String filePath = URLDecoder.decode(fileURL.getFile(), "utf-8");
238+
byte[] fileContent = readAllBytes(filePath);
239+
BoxFile file = null;
240+
try {
241+
file = uploadSampleFileToUniqueFolder(api, fileName);
242+
243+
ByteArrayOutputStream downloadStream = new ByteArrayOutputStream();
244+
ProgressListener mockDownloadListener = mock(ProgressListener.class);
245+
246+
URL url = CONTENT_URL_TEMPLATE.build(api.getBaseURL(), file.getID());
247+
BoxAPIRequest request = new BoxAPIRequest(api, url, "GET");
248+
BoxAPIResponse response = request.send();
249+
writeStream(response, downloadStream, mockDownloadListener);
250+
251+
byte[] downloadedFileContent = downloadStream.toByteArray();
252+
assertThat(response.getHeaders().get("X-Content-Encoding").get(0), is(equalTo("zstd")));
253+
assertThat(downloadedFileContent, is(equalTo(fileContent)));
254+
} finally {
255+
deleteFile(file);
256+
}
257+
}
258+
259+
@Test
260+
public void uploadAndDownloadFileDisabledZstdSucceeds() throws IOException {
261+
BoxAPIConnection api = jwtApiForServiceAccount();
262+
api.setUseZstdCompression(false);
263+
264+
String fileName = "smalltest.pdf";
265+
URL fileURL = this.getClass().getResource("/sample-files/" + fileName);
266+
String filePath = URLDecoder.decode(fileURL.getFile(), "utf-8");
267+
byte[] fileContent = readAllBytes(filePath);
268+
BoxFile file = null;
269+
try {
270+
file = uploadSampleFileToUniqueFolder(api, fileName);
271+
272+
ByteArrayOutputStream downloadStream = new ByteArrayOutputStream();
273+
ProgressListener mockDownloadListener = mock(ProgressListener.class);
274+
275+
URL url = CONTENT_URL_TEMPLATE.build(api.getBaseURL(), file.getID());
276+
BoxAPIRequest request = new BoxAPIRequest(api, url, "GET");
277+
BoxAPIResponse response = request.send();
278+
writeStream(response, downloadStream, mockDownloadListener);
279+
280+
byte[] downloadedFileContent = downloadStream.toByteArray();
281+
assertThat(response.getHeaders().get("X-Content-Encoding"), is(nullValue()));
282+
assertThat(downloadedFileContent, is(equalTo(fileContent)));
283+
} finally {
284+
deleteFile(file);
285+
}
286+
}
287+
228288
@Test
229289
public void downloadFileRangeSucceeds() throws IOException {
230290
BoxAPIConnection api = jwtApiForServiceAccount();

src/main/java/com/box/sdk/BoxAPIConnection.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ public class BoxAPIConnection {
122122
private int maxRetryAttempts;
123123
private int connectTimeout;
124124
private int readTimeout;
125+
private boolean useZstdCompression;
125126
private final List<BoxAPIConnectionListener> listeners;
126127
private RequestInterceptor interceptor;
127128
private final Map<String, String> customHeaders;
@@ -160,6 +161,7 @@ public BoxAPIConnection(String clientID, String clientSecret, String accessToken
160161
this.maxRetryAttempts = BoxGlobalSettings.getMaxRetryAttempts();
161162
this.connectTimeout = BoxGlobalSettings.getConnectTimeout();
162163
this.readTimeout = BoxGlobalSettings.getReadTimeout();
164+
this.useZstdCompression = BoxGlobalSettings.getUseZstdCompression();
163165
this.refreshLock = new ReentrantReadWriteLock();
164166
this.userAgent = "Box Java SDK v" + SDK_VERSION + " (Java " + JAVA_VERSION + ")";
165167
this.listeners = new ArrayList<>();
@@ -237,6 +239,9 @@ private void buildHttpClients() {
237239
}
238240
}
239241
builder = modifyHttpClientBuilder(builder);
242+
if (this.useZstdCompression) {
243+
builder.addNetworkInterceptor(new ZstdInterceptor());
244+
}
240245

241246
this.httpClient = builder.build();
242247
this.noRedirectsHttpClient = new OkHttpClient.Builder(httpClient)
@@ -657,6 +662,23 @@ public void setConnectTimeout(int connectTimeout) {
657662
buildHttpClients();
658663
}
659664

665+
/*
666+
* Gets if request use zstd encoding when possible
667+
* @return true if request use zstd encoding when possible
668+
*/
669+
public boolean getUseZstdCompression() {
670+
return this.useZstdCompression;
671+
}
672+
673+
/*
674+
* Sets if request use zstd encoding when possible
675+
* @param useZstdCompression true if request use zstd encoding when possible
676+
*/
677+
public void setUseZstdCompression(boolean useZstdCompression) {
678+
this.useZstdCompression = useZstdCompression;
679+
buildHttpClients();
680+
}
681+
660682
/**
661683
* Gets the read timeout for this connection in milliseconds.
662684
*

src/main/java/com/box/sdk/BoxGlobalSettings.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ public final class BoxGlobalSettings {
77
private static int connectTimeout = 0;
88
private static int readTimeout = 0;
99
private static int maxRetryAttempts = BoxAPIConnection.DEFAULT_MAX_RETRIES;
10+
private static boolean useZstdCompression = true;
1011

1112
private BoxGlobalSettings() {
1213
}
@@ -67,4 +68,20 @@ public static int getMaxRetryAttempts() {
6768
public static void setMaxRetryAttempts(int attempts) {
6869
BoxGlobalSettings.maxRetryAttempts = attempts;
6970
}
71+
72+
/*
73+
* Returns the global settings for using Zstd compression.
74+
* @return true if Zstd compression is enabled, false otherwise
75+
*/
76+
public static boolean getUseZstdCompression() {
77+
return useZstdCompression;
78+
}
79+
80+
/*
81+
* Sets the global settings for using Zstd compression.
82+
* @param useZstdCompression true to enable Zstd compression, false otherwise
83+
*/
84+
public static void setUseZstdCompression(boolean useZstdCompression) {
85+
BoxGlobalSettings.useZstdCompression = useZstdCompression;
86+
}
7087
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package com.box.sdk;
2+
3+
import com.github.luben.zstd.ZstdInputStream;
4+
import java.io.IOException;
5+
import java.io.InputStream;
6+
import okhttp3.Interceptor;
7+
import okhttp3.MediaType;
8+
import okhttp3.Request;
9+
import okhttp3.Response;
10+
import okhttp3.ResponseBody;
11+
import okio.BufferedSource;
12+
import okio.Okio;
13+
import okio.Source;
14+
import org.jetbrains.annotations.NotNull;
15+
16+
/**
17+
* Interceptor that adds zstd compression support to API requests.
18+
* This interceptor adds zstd to the Accept-Encoding header and handles decompression of zstd responses.
19+
*/
20+
public class ZstdInterceptor implements Interceptor {
21+
@NotNull
22+
@Override
23+
public Response intercept(Chain chain) throws IOException {
24+
Request request = chain.request();
25+
26+
// Add zstd to the Accept-Encoding header
27+
String acceptEncoding;
28+
String acceptEncodingHeader = request.header("Accept-Encoding");
29+
if (acceptEncodingHeader == null || acceptEncodingHeader.isEmpty()) {
30+
acceptEncoding = "zstd";
31+
} else {
32+
acceptEncoding = acceptEncodingHeader + ", zstd";
33+
}
34+
35+
Request compressedRequest = request.newBuilder()
36+
.removeHeader("Accept-Encoding")
37+
.addHeader("Accept-Encoding", acceptEncoding)
38+
.build();
39+
40+
Response response = chain.proceed(compressedRequest);
41+
String contentEncoding = response.header("Content-Encoding");
42+
43+
// Only handle zstd encoded responses, let OkHttp handle gzip and others
44+
if (contentEncoding == null || !contentEncoding.equalsIgnoreCase("zstd")) {
45+
return response;
46+
}
47+
48+
ResponseBody originalBody = response.body();
49+
if (originalBody == null) {
50+
return response;
51+
}
52+
53+
// Create a streaming response body
54+
ResponseBody decompressedBody = createStreamingResponseBody(originalBody);
55+
56+
return response.newBuilder()
57+
.body(decompressedBody)
58+
.addHeader("X-Content-Encoding", "zstd")
59+
.removeHeader("Content-Encoding")
60+
.removeHeader("Content-Length")
61+
.build();
62+
}
63+
64+
/**
65+
* Wraps the original response body in a streaming Zstd decompressor.
66+
*/
67+
private ResponseBody createStreamingResponseBody(ResponseBody originalBody) {
68+
return new ResponseBody() {
69+
@Override
70+
public MediaType contentType() {
71+
return originalBody.contentType();
72+
}
73+
74+
@Override
75+
public long contentLength() {
76+
return -1;
77+
}
78+
79+
@Override
80+
public BufferedSource source() {
81+
InputStream decompressedStream;
82+
try {
83+
decompressedStream = new ZstdInputStream(originalBody.byteStream());
84+
} catch (IOException e) {
85+
throw new RuntimeException("Failed to create ZstdInputStream", e);
86+
}
87+
88+
Source source = Okio.source(decompressedStream);
89+
return Okio.buffer(source);
90+
}
91+
};
92+
}
93+
}

src/test/java/com/box/sdk/BoxAPIRequestTest.java

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import java.net.MalformedURLException;
3232
import java.net.URL;
3333
import java.util.zip.GZIPOutputStream;
34+
import com.github.luben.zstd.ZstdOutputStream;
3435
import org.junit.Rule;
3536
import org.junit.Test;
3637
import org.mockito.Mockito;
@@ -225,7 +226,7 @@ public void handlesGZIPResponse() {
225226
aResponse()
226227
.withStatus(200)
227228
.withHeader("content-type", APPLICATION_JSON)
228-
.withHeader("content-encoding", "GZIP")
229+
.withHeader("content-encoding", "gzip")
229230
.withBody(gzipped(jsonString))
230231
));
231232

@@ -338,14 +339,43 @@ public void willNotAddAuthenticationHeaderWhenDisabled() {
338339
private byte[] gzipped(String str) {
339340
try {
340341
ByteArrayOutputStream obj = new ByteArrayOutputStream();
341-
GZIPOutputStream gzip = new GZIPOutputStream(obj);
342-
gzip.write(str.getBytes(UTF_8));
343-
gzip.close();
342+
try (GZIPOutputStream gzip = new GZIPOutputStream(obj)) {
343+
byte[] bytes = str.getBytes(UTF_8);
344+
gzip.write(bytes, 0, bytes.length);
345+
gzip.flush();
346+
gzip.finish();
347+
}
344348
return obj.toByteArray();
345349
} catch (IOException e) {
346350
throw new RuntimeException(e);
347351
}
352+
}
353+
354+
@Test
355+
public void handlesZstdResponse() throws IOException {
356+
BoxAPIConnection api = createConnectionWith(boxMockUrl().toString());
357+
BoxAPIRequest request = new BoxAPIRequest(api, boxMockUrl(), "GET");
358+
String jsonString = "{\"foo\":\"bar\"}";
348359

360+
stubFor(get(urlEqualTo("/")).willReturn(
361+
aResponse()
362+
.withStatus(200)
363+
.withHeader("content-type", APPLICATION_JSON)
364+
.withHeader("content-encoding", "zstd")
365+
.withBody(zstdCompressed(jsonString))
366+
));
367+
368+
try (BoxAPIResponse response = request.send()) {
369+
assertThat(response.bodyToString(), is(jsonString));
370+
}
371+
}
372+
373+
private byte[] zstdCompressed(String str) throws IOException {
374+
ByteArrayOutputStream obj = new ByteArrayOutputStream();
375+
try (ZstdOutputStream zstd = new ZstdOutputStream(obj)) {
376+
zstd.write(str.getBytes(UTF_8));
377+
}
378+
return obj.toByteArray();
349379
}
350380

351381
private URL boxMockUrl() {

0 commit comments

Comments
 (0)