Skip to content

Commit 245e188

Browse files
authored
28 file format compression filter (#82)
* Add CompressionFilter skeleton with basic structure * Add Accept-Encoding header detection for gzip * Add Accept-Encoding header detection for gzip * Implement gzip compression for response bodies * Add Content-Encoding * Added test file * Add content-type filtering to skip non-compressible formats * Add Javadoc * coderabbit fixes * added getter for HttpResponseBuilder and rewrote code that used reflection * fixed IOException
1 parent c7c63e8 commit 245e188

3 files changed

Lines changed: 351 additions & 0 deletions

File tree

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package org.example.filter;
2+
3+
import org.example.http.HttpResponseBuilder;
4+
import org.example.httpparser.HttpRequest;
5+
6+
import java.io.ByteArrayOutputStream;
7+
import java.io.IOException;
8+
import java.util.Map;
9+
import java.util.Set;
10+
import java.util.zip.GZIPOutputStream;
11+
12+
/**
13+
* Compression filter that compresses HTTP responses with gzip when supported by client.
14+
*
15+
* <p>This filter applies gzip compression to HTTP responses when the following conditions are met:
16+
* <ul>
17+
* <li>Client sends Accept-Encoding header containing "gzip"</li>
18+
* <li>Response body is larger than 1KB (MIN_COMPRESS_SIZE)</li>
19+
* <li>Content-Type is compressible (text-based formats like HTML, CSS, JS, JSON)</li>
20+
* <li>Content-Type is not already compressed (images, videos, zip files)</li>
21+
* </ul>
22+
*
23+
* <p>When compression is applied, the filter:
24+
* <ul>
25+
* <li>Compresses the response body using gzip</li>
26+
* <li>Sets Content-Encoding: gzip header</li>
27+
* <li>Sets Vary: Accept-Encoding header for proper caching</li>
28+
* </ul>
29+
*
30+
*/
31+
32+
public class CompressionFilter implements Filter {
33+
private static final int MIN_COMPRESS_SIZE = 1024;
34+
35+
private static final Set<String> COMPRESSIBLE_TYPES = Set.of(
36+
"text/html",
37+
"text/css",
38+
"text/javascript",
39+
"application/javascript",
40+
"application/json",
41+
"application/xml",
42+
"text/plain"
43+
);
44+
45+
private static final Set<String> SKIP_TYPES = Set.of(
46+
"image/jpeg", "image/jpg", "image/png", "image/gif",
47+
"image/webp", "video/mp4", "application/zip",
48+
"application/gzip", "application/x-gzip"
49+
);
50+
51+
52+
@Override
53+
public void init() {
54+
}
55+
56+
@Override
57+
public void doFilter(HttpRequest request, HttpResponseBuilder response,
58+
FilterChain chain) {
59+
chain.doFilter(request, response);
60+
61+
compressIfNeeded(request, response);
62+
}
63+
64+
private void compressIfNeeded(HttpRequest request, HttpResponseBuilder response) {
65+
if (response.getHeader("Content-Encoding") != null) {
66+
return;
67+
}
68+
69+
String acceptEncoding = getHeader(request, "Accept-Encoding");
70+
if (acceptEncoding == null || !acceptEncoding.toLowerCase().contains("gzip")) {
71+
return;
72+
}
73+
74+
byte[] originalBody = response.getBodyBytes();
75+
if (originalBody == null || originalBody.length < MIN_COMPRESS_SIZE) {
76+
return;
77+
}
78+
79+
String contentType = response.getHeader("Content-Type");
80+
if (!shouldCompress(contentType)) {
81+
return;
82+
}
83+
84+
try {
85+
byte[] compressed = gzipCompress(originalBody);
86+
87+
response.setBody(compressed);
88+
response.setHeader("Content-Encoding", "gzip");
89+
90+
String existingVary = response.getHeader("Vary");
91+
if (existingVary != null && !existingVary.isEmpty()) {
92+
if (!existingVary.toLowerCase().contains("accept-encoding")) {
93+
response.setHeader("Vary", existingVary + ", Accept-Encoding");
94+
}
95+
} else {
96+
response.setHeader("Vary", "Accept-Encoding");
97+
}
98+
99+
} catch (IOException e) {
100+
System.err.println("CompressionFilter: gzip compression failed: " + e.getMessage());
101+
}
102+
}
103+
104+
private boolean shouldCompress(String contentType) {
105+
if (contentType == null) {
106+
return false;
107+
}
108+
109+
String baseType = contentType.split(";")[0].trim().toLowerCase();
110+
111+
if (SKIP_TYPES.contains(baseType)) {
112+
return false;
113+
}
114+
115+
return COMPRESSIBLE_TYPES.contains(baseType) ||
116+
baseType.startsWith("text/");
117+
}
118+
119+
private String getHeader(HttpRequest request, String headerName) {
120+
Map<String, String> headers = request.getHeaders();
121+
122+
String value = headers.get(headerName);
123+
if (value != null) return value;
124+
125+
for (Map.Entry<String, String> entry : headers.entrySet()) {
126+
if (entry.getKey().equalsIgnoreCase(headerName)) {
127+
return entry.getValue();
128+
}
129+
}
130+
return null;
131+
}
132+
133+
private byte[] gzipCompress(byte[] data) throws IOException {
134+
ByteArrayOutputStream byteStream = new ByteArrayOutputStream(data.length);
135+
136+
try (GZIPOutputStream gzipStream = new GZIPOutputStream(byteStream)) {
137+
gzipStream.write(data);
138+
}
139+
140+
return byteStream.toByteArray();
141+
}
142+
143+
@Override
144+
public void destroy() {
145+
}
146+
}

src/main/java/org/example/http/HttpResponseBuilder.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,14 @@ public void setHeaders(Map<String, String> headers) {
8787
public void setHeader(String name, String value) {
8888
this.headers.put(name, value);
8989
}
90+
public String getHeader(String name) {
91+
return headers.get(name);
92+
}
93+
94+
public byte[] getBodyBytes() {
95+
if (bytebody != null) return bytebody;
96+
return body.getBytes(StandardCharsets.UTF_8);
97+
}
9098

9199
public void setContentTypeFromFilename(String filename) {
92100
String mimeType = MimeTypeDetector.detectMimeType(filename);
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
package org.example.filter;
2+
3+
import org.example.http.HttpResponseBuilder;
4+
import org.example.httpparser.HttpRequest;
5+
import org.junit.jupiter.api.Test;
6+
7+
import java.io.ByteArrayInputStream;
8+
import java.io.ByteArrayOutputStream;
9+
import java.nio.charset.StandardCharsets;
10+
import java.util.HashMap;
11+
import java.util.Map;
12+
import java.util.zip.GZIPInputStream;
13+
14+
import static org.junit.jupiter.api.Assertions.*;
15+
16+
class CompressionFilterTest {
17+
18+
@Test
19+
void testGzipCompressionWhenClientSupportsIt() throws Exception {
20+
Map<String, String> headers = new HashMap<>();
21+
headers.put("Accept-Encoding", "gzip, deflate");
22+
23+
HttpRequest request = new HttpRequest(
24+
"GET",
25+
"/",
26+
"HTTP/1.1",
27+
headers,
28+
null
29+
);
30+
31+
String largeBody = "<html><body>" + "Hello World! ".repeat(200) + "</body></html>";
32+
HttpResponseBuilder response = new HttpResponseBuilder();
33+
response.setBody(largeBody);
34+
response.setHeaders(Map.of("Content-Type", "text/html"));
35+
36+
FilterChain mockChain = (req, res) -> {
37+
};
38+
39+
CompressionFilter filter = new CompressionFilter();
40+
filter.init();
41+
filter.doFilter(request, response, mockChain);
42+
43+
byte[] compressedBody = getBodyFromResponse(response);
44+
assertNotNull(compressedBody, "Body should not be null");
45+
assertTrue(compressedBody.length < largeBody.getBytes(StandardCharsets.UTF_8).length,
46+
"Compressed body should be smaller than original");
47+
48+
49+
String decompressed = decompressGzip(compressedBody);
50+
assertEquals(largeBody, decompressed, "Decompressed data should match original");
51+
}
52+
53+
@Test
54+
void testNoCompressionWhenClientDoesNotSupport() {
55+
HttpRequest request = new HttpRequest(
56+
"GET",
57+
"/",
58+
"HTTP/1.1",
59+
Map.of(),
60+
null
61+
);
62+
63+
String body = "<html><body>" + "Hello World! ".repeat(200) + "</body></html>";
64+
HttpResponseBuilder response = new HttpResponseBuilder();
65+
response.setBody(body);
66+
67+
FilterChain mockChain = (req, res) -> {};
68+
69+
CompressionFilter filter = new CompressionFilter();
70+
filter.doFilter(request, response, mockChain);
71+
72+
byte[] resultBody = getBodyFromResponse(response);
73+
assertArrayEquals(body.getBytes(StandardCharsets.UTF_8), resultBody,
74+
"Body should not be compressed when client doesn't support gzip");
75+
}
76+
77+
@Test
78+
void testNoCompressionForSmallResponses() {
79+
Map<String, String> headers = new HashMap<>();
80+
headers.put("Accept-Encoding", "gzip");
81+
82+
HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", headers, null);
83+
84+
String smallBody = "Hello";
85+
HttpResponseBuilder response = new HttpResponseBuilder();
86+
response.setBody(smallBody);
87+
88+
FilterChain mockChain = (req, res) -> {};
89+
90+
CompressionFilter filter = new CompressionFilter();
91+
filter.doFilter(request, response, mockChain);
92+
93+
byte[] resultBody = getBodyFromResponse(response);
94+
assertArrayEquals(smallBody.getBytes(StandardCharsets.UTF_8), resultBody,
95+
"Small bodies should not be compressed");
96+
}
97+
98+
private String decompressGzip(byte[] compressed) throws Exception {
99+
ByteArrayInputStream bais = new ByteArrayInputStream(compressed);
100+
ByteArrayOutputStream baos = new ByteArrayOutputStream();
101+
102+
try (GZIPInputStream gzis = new GZIPInputStream(bais)) {
103+
byte[] buffer = new byte[1024];
104+
int len;
105+
while ((len = gzis.read(buffer)) > 0) {
106+
baos.write(buffer, 0, len);
107+
}
108+
}
109+
110+
return baos.toString(StandardCharsets.UTF_8);
111+
}
112+
113+
private byte[] getBodyFromResponse(HttpResponseBuilder response) {
114+
try {
115+
var field = response.getClass().getDeclaredField("bytebody");
116+
field.setAccessible(true);
117+
byte[] bytebody = (byte[]) field.get(response);
118+
119+
if (bytebody != null) {
120+
return bytebody;
121+
}
122+
123+
var bodyField = response.getClass().getDeclaredField("body");
124+
bodyField.setAccessible(true);
125+
String body = (String) bodyField.get(response);
126+
return body.getBytes(StandardCharsets.UTF_8);
127+
128+
} catch (Exception e) {
129+
throw new RuntimeException("Failed to get body", e);
130+
}
131+
}
132+
@Test
133+
void testSkipCompressionForImages() {
134+
Map<String, String> headers = new HashMap<>();
135+
headers.put("Accept-Encoding", "gzip");
136+
137+
HttpRequest request = new HttpRequest("GET", "/image.jpg", "HTTP/1.1", headers, null);
138+
139+
String largeImageData = "fake image data ".repeat(200);
140+
HttpResponseBuilder response = new HttpResponseBuilder();
141+
response.setBody(largeImageData);
142+
response.setHeaders(Map.of("Content-Type", "image/jpeg"));
143+
144+
FilterChain mockChain = (req, res) -> {};
145+
146+
CompressionFilter filter = new CompressionFilter();
147+
filter.doFilter(request, response, mockChain);
148+
149+
byte[] resultBody = getBodyFromResponse(response);
150+
assertArrayEquals(largeImageData.getBytes(StandardCharsets.UTF_8), resultBody,
151+
"Images should not be compressed");
152+
}
153+
154+
@Test
155+
void testCompressJsonResponse() {
156+
Map<String, String> headers = new HashMap<>();
157+
headers.put("Accept-Encoding", "gzip");
158+
159+
HttpRequest request = new HttpRequest("GET", "/api/data", "HTTP/1.1", headers, null);
160+
161+
String jsonData = "{\"data\": " + "\"value\",".repeat(200) + "}";
162+
HttpResponseBuilder response = new HttpResponseBuilder();
163+
response.setBody(jsonData);
164+
response.setHeaders(Map.of("Content-Type", "application/json"));
165+
166+
FilterChain mockChain = (req, res) -> {};
167+
168+
CompressionFilter filter = new CompressionFilter();
169+
filter.doFilter(request, response, mockChain);
170+
171+
byte[] resultBody = getBodyFromResponse(response);
172+
assertTrue(resultBody.length < jsonData.getBytes(StandardCharsets.UTF_8).length,
173+
"JSON should be compressed");
174+
}
175+
176+
@Test
177+
void testHandleContentTypeWithCharset() {
178+
Map<String, String> headers = new HashMap<>();
179+
headers.put("Accept-Encoding", "gzip");
180+
181+
HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", headers, null);
182+
183+
String body = "<html>" + "content ".repeat(200) + "</html>";
184+
HttpResponseBuilder response = new HttpResponseBuilder();
185+
response.setBody(body);
186+
response.setHeaders(Map.of("Content-Type", "text/html; charset=UTF-8"));
187+
188+
FilterChain mockChain = (req, res) -> {};
189+
190+
CompressionFilter filter = new CompressionFilter();
191+
filter.doFilter(request, response, mockChain);
192+
193+
byte[] resultBody = getBodyFromResponse(response);
194+
assertTrue(resultBody.length < body.getBytes(StandardCharsets.UTF_8).length,
195+
"Should compress even when Content-Type has charset");
196+
}
197+
}

0 commit comments

Comments
 (0)