Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
5012693
Add CompressionConfig
DennSel Feb 27, 2026
e171a44
add fields, @Global annotation, constructors for default config and t…
DennSel Feb 27, 2026
e024652
add acceptsGzip helper with header parsing
DennSel Feb 27, 2026
d294b3b
add compress helper using GZIPOutputStream
DennSel Feb 27, 2026
4eab8ee
implement doFilter with early exit and GZIP compression logic
DennSel Feb 27, 2026
a23e7fe
add test for disabled filter
DennSel Feb 27, 2026
f52f884
add test for missing Accept-Encoding header
DennSel Feb 27, 2026
1e96a2b
add test for non-gzip Accept-Encoding header
DennSel Feb 27, 2026
0fd94d8
add test for compression of large response body
DennSel Feb 27, 2026
84bd376
add test for small body below compression threshold
DennSel Feb 27, 2026
d8da87a
add test for case-insensitive gzip header matching
DennSel Feb 27, 2026
5414e7c
add test to verify compression at exact threshold boundary
DennSel Feb 27, 2026
3685a15
add compression fields to ConfigLoader
DennSel Feb 27, 2026
6c1a919
parse compression settings from application-properties.yml
DennSel Feb 27, 2026
71a5dd7
add getters for compression settings in ConfigLoader
DennSel Feb 27, 2026
ba0eff8
add CompressionConfig now reading from ConfigLoader
DennSel Feb 27, 2026
12beaf4
prevent double-compression and preserve existing Vary header
DennSel Feb 27, 2026
f0a0597
handle case-insensitive header key and gzip quality values
DennSel Feb 27, 2026
89a758a
no negative min-compress-size values accepted
DennSel Feb 27, 2026
4b764fe
enforce minimum compress size of 100 bytes
DennSel Feb 27, 2026
79463fb
Merge branch 'main' into feature/144-gzip-compression-filter
DennSel Feb 27, 2026
749a170
Add a check before compression to prevent double encoding
DennSel Mar 1, 2026
5d6df15
Fix Accept-Encoding quality value parsing and improve compression tests
DennSel Mar 1, 2026
d2ae1ab
Spotless
DennSel Mar 1, 2026
e188d47
Fix edge cases in Content-Encoding and Vary header handling
DennSel Mar 1, 2026
1b862fa
Merge branch 'main' into feature/144-gzip-compression-filter
DennSel Mar 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/main/java/org/juv25d/config/CompressionConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package org.juv25d.config;

import org.juv25d.util.ConfigLoader;

public class CompressionConfig {
private final boolean enabled;
private final int minCompressSize;

public CompressionConfig() {
ConfigLoader config = ConfigLoader.getInstance();
this.enabled = config.isCompressionEnabled();
this.minCompressSize = config.getMinCompressSize();
}

public boolean isEnabled() {
return enabled;
}

public int getMinCompressSize() {
return minCompressSize;
}
}
116 changes: 116 additions & 0 deletions src/main/java/org/juv25d/filter/CompressionFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package org.juv25d.filter;

import org.juv25d.config.CompressionConfig;
import org.juv25d.filter.annotation.Global;
import org.juv25d.http.HttpRequest;
import org.juv25d.http.HttpResponse;
import org.juv25d.logging.ServerLogging;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Arrays;
import java.util.Map;
import java.util.logging.Logger;
import java.util.zip.GZIPOutputStream;


@Global(order = 10)
public class CompressionFilter implements Filter{
private static final Logger LOGGER = ServerLogging.getLogger();

private final boolean enabled;
private final int minCompressSize;

@Override
public void doFilter(HttpRequest req, HttpResponse res, FilterChain chain) throws IOException {
if (!enabled) {
chain.doFilter(req, res);
return;
}

if (!acceptsGzip(req)) {
chain.doFilter(req, res);
return;
}

chain.doFilter(req, res);

byte[] body = res.body();
if (body.length < minCompressSize) {
return;
}

String existingEncoding = res.getHeader("Content-Encoding");
if (existingEncoding != null && !existingEncoding.isBlank()) {
return;
}

byte[] compressed = compress(body);
res.setBody(compressed);
res.setHeader("Content-Encoding", "gzip");

String existingVary = res.getHeader("Vary");
if (existingVary == null || existingVary.isBlank()) {
res.setHeader("Vary", "Accept-Encoding");
} else if (Arrays.stream(existingVary.split(","))
.map(String::trim)
.noneMatch(v -> v.equalsIgnoreCase("Accept-Encoding"))) {
res.setHeader("Vary", existingVary + ", Accept-Encoding");
}

LOGGER.info("Compressed " + body.length + " bytes to " + compressed.length + " bytes");
}

public CompressionFilter() {
CompressionConfig config = new CompressionConfig();
this.enabled = config.isEnabled();
this.minCompressSize = config.getMinCompressSize();
}

public CompressionFilter(boolean enabled, int minCompressSize) {
this.enabled = enabled;
this.minCompressSize = minCompressSize;
}

private boolean acceptsGzip(HttpRequest req) {
String acceptEncoding = req.headers().entrySet().stream()
.filter(e -> e.getKey().equalsIgnoreCase("Accept-Encoding"))
.map(Map.Entry::getValue)
.findFirst()
.orElse(null);

if (acceptEncoding == null || acceptEncoding.isEmpty()) {
return false;
}

return Arrays.stream(acceptEncoding.split(","))
.map(String::trim)
.filter(this::isGzipWithQualityAboveZero)
.anyMatch(e -> e.split(";")[0].trim().equalsIgnoreCase("gzip"));
}
Comment on lines +75 to +90
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential NPE and redundant filtering logic.

Two issues in acceptsGzip:

  1. Line 74: e.getKey().equalsIgnoreCase(...) may throw NPE if a map entry has a null key. Add a null guard.

  2. Lines 83-86: The logic is redundant. isGzipWithQualityAboveZero already returns true only when the encoding is gzip AND quality > 0. The subsequent anyMatch check for equalsIgnoreCase("gzip") is always true for items that passed the filter.

🐛 Proposed fix
     private boolean acceptsGzip(HttpRequest req) {
         String acceptEncoding = req.headers().entrySet().stream()
-            .filter(e -> e.getKey().equalsIgnoreCase("Accept-Encoding"))
+            .filter(e -> e.getKey() != null && e.getKey().equalsIgnoreCase("Accept-Encoding"))
             .map(Map.Entry::getValue)
             .findFirst()
             .orElse(null);

         if (acceptEncoding == null || acceptEncoding.isEmpty()) {
             return false;
         }
         
         return Arrays.stream(acceptEncoding.split(","))
             .map(String::trim)
-            .filter(this::isGzipWithQualityAboveZero)
-            .anyMatch(e -> e.split(";")[0].trim().equalsIgnoreCase("gzip"));
+            .anyMatch(this::isGzipWithQualityAboveZero);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/org/juv25d/filter/CompressionFilter.java` around lines 72 - 87,
In acceptsGzip, guard against a null header name by changing the header key
check to ensure e.getKey() != null before calling equalsIgnoreCase, and simplify
the encoding stream by removing the redundant map/filter/anyMatch sequence:
after splitting and trimming the encodings, directly use
anyMatch(this::isGzipWithQualityAboveZero) instead of filtering then checking
equalsIgnoreCase("gzip") because isGzipWithQualityAboveZero already enforces
gzip and quality>0; refer to the acceptsGzip method and
isGzipWithQualityAboveZero when making these changes.

Comment thread
coderabbitai[bot] marked this conversation as resolved.

private boolean isGzipWithQualityAboveZero(String encoding) {
String[] parts = encoding.split(";");
String name = parts[0].trim();
if (!name.equalsIgnoreCase("gzip")) return false;

if (parts.length > 1) {
String q = parts[1].trim();
if (q.startsWith("q=")) {
try {
double quality = Double.parseDouble(q.substring(2));
return quality > 0;
} catch (NumberFormatException ignored) {}
}
}
return true;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private byte [] compress(byte [] data) throws IOException {
ByteArrayOutputStream byteStream = new ByteArrayOutputStream();
try (GZIPOutputStream gzipstream = new GZIPOutputStream(byteStream)) {
gzipstream.write(data);
}
return byteStream.toByteArray();
}
}
22 changes: 22 additions & 0 deletions src/main/java/org/juv25d/util/ConfigLoader.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@
public class ConfigLoader {
@Nullable private static ConfigLoader instance;
private int port;
private int minCompressSize;
private String logLevel = "INFO";
private String rootDirectory = "static";
private long requestsPerMinute;
private long burstCapacity;
private long maxBodySizeMb;
private boolean rateLimitingEnabled;
private boolean compressionEnabled;
private boolean requestBodySizeEnabled;
private List<String> trustedProxies;
private List<ProxyRoute> proxyRoutes = new ArrayList<>();
Expand Down Expand Up @@ -56,6 +58,8 @@ private void loadConfiguration(InputStream input) {
this.rootDirectory = "static";
this.logLevel = "INFO";
this.trustedProxies = List.of();
this.compressionEnabled = false;
this.minCompressSize = 1024;
this.allowedOrigins = List.of();
this.allowedMethods = List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS");

Expand Down Expand Up @@ -120,6 +124,17 @@ private void loadConfiguration(InputStream input) {
Long.parseLong(String.valueOf(rateLimitingConfig.getOrDefault("burst-capacity", 100L)));
}

Object compressionObj = config.get("compression");
if (compressionObj != null) {
Map<String, Object> compressionConfig = asStringObjectMap(compressionObj);
this.compressionEnabled =
Boolean.parseBoolean(String.valueOf(compressionConfig.getOrDefault("enabled", false)));

int parsedMinCompressSize =
Integer.parseInt(String.valueOf(compressionConfig.getOrDefault("min-compress-size", 1024)));
this.minCompressSize = Math.max(100, parsedMinCompressSize);
}

//Cors
Object corsObj = config.get("cors");
if (corsObj != null) {
Expand Down Expand Up @@ -223,6 +238,13 @@ public List<ProxyRoute> getProxyRoutes() {
return Collections.unmodifiableList(proxyRoutes);
}

public boolean isCompressionEnabled() {
return compressionEnabled;
}

public int getMinCompressSize() {
return minCompressSize;
}
public List<String> getAllowedOrigins() {
return allowedOrigins;
}
Expand Down
4 changes: 4 additions & 0 deletions src/main/resources/application-properties.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ rate-limiting:
requests-per-minute: 60
burst-capacity: 100

compression:
enabled: true
min-compress-size: 1024

cors:
allowed-origins:
- http://localhost:3000
Expand Down
174 changes: 174 additions & 0 deletions src/test/java/org/juv25d/filter/CompressionFilterTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package org.juv25d.filter;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.juv25d.http.HttpRequest;
import org.juv25d.http.HttpResponse;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Map;
import java.util.zip.GZIPInputStream;

import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
public class CompressionFilterTest {

@Mock
private HttpRequest req;
@Mock
private HttpResponse res;
@Mock
private FilterChain chain;

@Test
void shouldNotCompress_whenDisabled() throws IOException {
CompressionFilter filter = new CompressionFilter(false, 1024);

filter.doFilter(req, res, chain);

verify(chain).doFilter(req, res);
verifyNoMoreInteractions(res);
}

@Test
void shouldNotCompress_whenNoAcceptEncoding() throws IOException {
CompressionFilter filter = new CompressionFilter(true, 1024);
when(req.headers()).thenReturn(Map.of());

filter.doFilter(req, res, chain);

verify(chain).doFilter(req, res);
verify(res, never()).setBody(any());
}

@Test
void shouldNotCompress_whenAcceptEncodingIsNotGzip() throws IOException {
CompressionFilter filter = new CompressionFilter(true, 1024);
when(req.headers()).thenReturn(Map.of(
"Accept-Encoding", "deflate, br"
));

filter.doFilter(req, res, chain);

verify(chain).doFilter(req, res);
verify(res, never()).setBody(any());
}

@Test
void shouldCompress_whenAcceptEncodingIsGzip() throws IOException {
CompressionFilter filter = new CompressionFilter(true, 100);
when(req.headers()).thenReturn(Map.of(
"Accept-Encoding", "gzip, deflate"
));

byte[] body = "Hello, world!".repeat(100).getBytes();
when(res.body()).thenReturn(body);

filter.doFilter(req, res, chain);

ArgumentCaptor<byte[]> captor = ArgumentCaptor.forClass(byte[].class);
verify(res).setBody(captor.capture());
byte[] decompressed = gunzip(captor.getValue());
assertArrayEquals(body, decompressed);
verify(res).setHeader("Content-Encoding", "gzip");
verify(res).setHeader("Vary", "Accept-Encoding");
}

@Test
void shouldNotCompress_whenBodyIsSmallerThanThreshold() throws IOException {
CompressionFilter filter = new CompressionFilter(true, 1024);
when(req.headers()).thenReturn(Map.of(
"Accept-Encoding", "gzip"
));

when(res.body()).thenReturn("small".getBytes());

filter.doFilter(req, res, chain);

verify(chain).doFilter(req, res);
verify(res, never()).setBody(any());
}

@Test
void shouldCompress_whenAcceptEncodingIsUpperCase() throws IOException {
CompressionFilter filter = new CompressionFilter(true, 100);
when(req.headers()).thenReturn(Map.of(
"Accept-Encoding", "GZIP"
));

byte[] body = "Hello, world!".repeat(100).getBytes();
when(res.body()).thenReturn(body);

filter.doFilter(req, res, chain);

ArgumentCaptor<byte[]> captor = ArgumentCaptor.forClass(byte[].class);
verify(res).setBody(captor.capture());
byte[] decompressed = gunzip(captor.getValue());
assertArrayEquals(body, decompressed);
verify(res).setHeader("Content-Encoding", "gzip");
}

@Test
void shouldCompress_whenBodyIsExactlyThreshold() throws IOException {
CompressionFilter filter = new CompressionFilter(true, 5);
when(req.headers()).thenReturn(Map.of(
"Accept-Encoding", "gzip"
));

when(res.body()).thenReturn("Hello".getBytes());

filter.doFilter(req, res, chain);

ArgumentCaptor<byte[]> captor = ArgumentCaptor.forClass(byte[].class);
verify(res).setBody(captor.capture());
byte[] decompressed = gunzip(captor.getValue());
assertArrayEquals("Hello".getBytes(), decompressed);
verify(res).setHeader("Content-Encoding", "gzip");
}

@Test
void shouldNotCompress_whenGzipWithQualityZero() throws IOException {
CompressionFilter filter = new CompressionFilter(true, 100);
when(req.headers()).thenReturn(Map.of(
"Accept-Encoding", "gzip;q=0"
));

filter.doFilter(req, res, chain);

verify(chain).doFilter(req, res);
verify(res, never()).setBody(any());
}

@Test
void shouldCompress_whenGzipWithQualityAboveZero() throws IOException {
CompressionFilter filter = new CompressionFilter(true, 100);
when(req.headers()).thenReturn(Map.of(
"Accept-Encoding", "gzip;q=0.5"
));
byte[] body = "Hello, world!".repeat(100).getBytes();
when(res.body()).thenReturn(body);

filter.doFilter(req, res, chain);

ArgumentCaptor<byte[]> captor = ArgumentCaptor.forClass(byte[].class);
verify(res).setBody(captor.capture());
byte[] decompressed = gunzip(captor.getValue());
assertArrayEquals(body, decompressed);
verify(res).setHeader("Content-Encoding", "gzip");
}

private byte[] gunzip(byte[] gz) throws IOException {
try (GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(gz));
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
in.transferTo(out);
return out.toByteArray();
}
}
}