From 501269392902a0f387a1ac7336984ab72845142d Mon Sep 17 00:00:00 2001 From: Dennis Selden <111012436+DennSel@users.noreply.github.com> Date: Fri, 27 Feb 2026 09:31:27 +0100 Subject: [PATCH 01/24] Add CompressionConfig --- .../org/juv25d/config/CompressionConfig.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/main/java/org/juv25d/config/CompressionConfig.java diff --git a/src/main/java/org/juv25d/config/CompressionConfig.java b/src/main/java/org/juv25d/config/CompressionConfig.java new file mode 100644 index 00000000..b4fd95e0 --- /dev/null +++ b/src/main/java/org/juv25d/config/CompressionConfig.java @@ -0,0 +1,19 @@ +package org.juv25d.config; + +public class CompressionConfig { + private final boolean enabled; + private final int minCompressSize; + + public CompressionConfig() { + this.enabled = false; + this.minCompressSize = 1024; + } + + public boolean isEnabled() { + return enabled; + } + + public int getMinCompressSize() { + return minCompressSize; + } +} From e171a44c516a122e779e5389cc50cc875a4153c6 Mon Sep 17 00:00:00 2001 From: Dennis Selden <111012436+DennSel@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:06:36 +0100 Subject: [PATCH 02/24] add fields, @Global annotation, constructors for default config and test injection --- .../org/juv25d/filter/CompressionFilter.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/main/java/org/juv25d/filter/CompressionFilter.java diff --git a/src/main/java/org/juv25d/filter/CompressionFilter.java b/src/main/java/org/juv25d/filter/CompressionFilter.java new file mode 100644 index 00000000..1b0664d6 --- /dev/null +++ b/src/main/java/org/juv25d/filter/CompressionFilter.java @@ -0,0 +1,28 @@ +package org.juv25d.filter; + +import org.juv25d.config.CompressionConfig; +import org.juv25d.filter.annotation.Global; +import org.juv25d.logging.ServerLogging; + +import java.util.logging.Logger; + + +@Global(order = 10) +public class CompressionFilter { + private static final Logger LOGGER = ServerLogging.getLogger(); + + private final boolean enabled; + private final int minCompressSize; + + 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; + } + +} From e024652ef8554fc1959204621ef0598220827ee3 Mon Sep 17 00:00:00 2001 From: Dennis Selden <111012436+DennSel@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:15:44 +0100 Subject: [PATCH 03/24] add acceptsGzip helper with header parsing --- .../java/org/juv25d/filter/CompressionFilter.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/main/java/org/juv25d/filter/CompressionFilter.java b/src/main/java/org/juv25d/filter/CompressionFilter.java index 1b0664d6..2826ebaa 100644 --- a/src/main/java/org/juv25d/filter/CompressionFilter.java +++ b/src/main/java/org/juv25d/filter/CompressionFilter.java @@ -2,8 +2,11 @@ import org.juv25d.config.CompressionConfig; import org.juv25d.filter.annotation.Global; +import org.juv25d.http.HttpRequest; import org.juv25d.logging.ServerLogging; +import java.util.Arrays; +import java.util.List; import java.util.logging.Logger; @@ -25,4 +28,14 @@ public CompressionFilter(boolean enabled, int minCompressSize) { this.minCompressSize = minCompressSize; } + private boolean acceptsGzip(HttpRequest req) { + String acceptEncoding = req.headers().get("Accept-Encoding"); + if (acceptEncoding == null || acceptEncoding.isEmpty()) { + return false; + } + return Arrays.stream(acceptEncoding.split(",")) + .map(String::trim) + .anyMatch(e -> e.equalsIgnoreCase("gzip")); + } + } From d294b3bcf54effcbceefb836aa847ed1e9c9f1af Mon Sep 17 00:00:00 2001 From: Dennis Selden <111012436+DennSel@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:17:45 +0100 Subject: [PATCH 04/24] add compress helper using GZIPOutputStream --- .../java/org/juv25d/filter/CompressionFilter.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/java/org/juv25d/filter/CompressionFilter.java b/src/main/java/org/juv25d/filter/CompressionFilter.java index 2826ebaa..4639dd0a 100644 --- a/src/main/java/org/juv25d/filter/CompressionFilter.java +++ b/src/main/java/org/juv25d/filter/CompressionFilter.java @@ -5,9 +5,13 @@ import org.juv25d.http.HttpRequest; import org.juv25d.logging.ServerLogging; +import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.util.Arrays; import java.util.List; import java.util.logging.Logger; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; @Global(order = 10) @@ -38,4 +42,12 @@ private boolean acceptsGzip(HttpRequest req) { .anyMatch(e -> e.equalsIgnoreCase("gzip")); } + private byte [] compress(byte [] data) throws IOException { + ByteArrayOutputStream byteStream = new ByteArrayOutputStream(); + GZIPOutputStream gzipstream = new GZIPOutputStream(byteStream); + + gzipstream.write(data); + gzipstream.close(); + return byteStream.toByteArray(); + } } From 4eab8ee20003483c4c79ab3522dc7a187b59e97b Mon Sep 17 00:00:00 2001 From: Dennis Selden <111012436+DennSel@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:23:17 +0100 Subject: [PATCH 05/24] implement doFilter with early exit and GZIP compression logic --- .../org/juv25d/filter/CompressionFilter.java | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/juv25d/filter/CompressionFilter.java b/src/main/java/org/juv25d/filter/CompressionFilter.java index 4639dd0a..e6dc4e57 100644 --- a/src/main/java/org/juv25d/filter/CompressionFilter.java +++ b/src/main/java/org/juv25d/filter/CompressionFilter.java @@ -3,24 +3,50 @@ 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.List; import java.util.logging.Logger; -import java.util.zip.GZIPInputStream; import java.util.zip.GZIPOutputStream; @Global(order = 10) -public class CompressionFilter { +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; + } + + byte[] compressed = compress(body); + res.setBody(compressed); + res.setHeader("Content-Encoding", "gzip"); + res.setHeader("Vary", "Accept-Encoding"); + + LOGGER.info("Compressed " + body.length + " bytes to " + compressed.length + " bytes"); + } + public CompressionFilter() { CompressionConfig config = new CompressionConfig(); this.enabled = config.isEnabled(); From a23e7fe8515b2faaa31bd278d9e929ad2f253548 Mon Sep 17 00:00:00 2001 From: Dennis Selden <111012436+DennSel@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:35:46 +0100 Subject: [PATCH 06/24] add test for disabled filter --- .../juv25d/filter/CompressionFilterTest.java | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/test/java/org/juv25d/filter/CompressionFilterTest.java diff --git a/src/test/java/org/juv25d/filter/CompressionFilterTest.java b/src/test/java/org/juv25d/filter/CompressionFilterTest.java new file mode 100644 index 00000000..ce679c7d --- /dev/null +++ b/src/test/java/org/juv25d/filter/CompressionFilterTest.java @@ -0,0 +1,34 @@ +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +@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); + } +} From f52f8848a6b14ac3579a547f6c3fddfeff4c7326 Mon Sep 17 00:00:00 2001 From: Dennis Selden <111012436+DennSel@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:39:43 +0100 Subject: [PATCH 07/24] add test for missing Accept-Encoding header --- .../org/juv25d/filter/CompressionFilterTest.java | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/test/java/org/juv25d/filter/CompressionFilterTest.java b/src/test/java/org/juv25d/filter/CompressionFilterTest.java index ce679c7d..2a33e1a3 100644 --- a/src/test/java/org/juv25d/filter/CompressionFilterTest.java +++ b/src/test/java/org/juv25d/filter/CompressionFilterTest.java @@ -8,9 +8,9 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.io.IOException; +import java.util.Map; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) public class CompressionFilterTest { @@ -31,4 +31,15 @@ void shouldNotCompress_whenDisabled() throws IOException { 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()); + } } From 1e96a2b4e8a8268807c0988e54f6f181562a7708 Mon Sep 17 00:00:00 2001 From: Dennis Selden <111012436+DennSel@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:40:58 +0100 Subject: [PATCH 08/24] add test for non-gzip Accept-Encoding header --- .../org/juv25d/filter/CompressionFilterTest.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/test/java/org/juv25d/filter/CompressionFilterTest.java b/src/test/java/org/juv25d/filter/CompressionFilterTest.java index 2a33e1a3..5a266431 100644 --- a/src/test/java/org/juv25d/filter/CompressionFilterTest.java +++ b/src/test/java/org/juv25d/filter/CompressionFilterTest.java @@ -42,4 +42,17 @@ void shouldNotCompress_whenNoAcceptEncoding() throws IOException { 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()); + } } From 0fd94d8a2c1e9d9871f51f5142709b962314b681 Mon Sep 17 00:00:00 2001 From: Dennis Selden <111012436+DennSel@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:45:21 +0100 Subject: [PATCH 09/24] add test for compression of large response body --- .../juv25d/filter/CompressionFilterTest.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/test/java/org/juv25d/filter/CompressionFilterTest.java b/src/test/java/org/juv25d/filter/CompressionFilterTest.java index 5a266431..754a2f1e 100644 --- a/src/test/java/org/juv25d/filter/CompressionFilterTest.java +++ b/src/test/java/org/juv25d/filter/CompressionFilterTest.java @@ -55,4 +55,22 @@ void shouldNotCompress_whenAcceptEncodingIsNotGzip() throws IOException { 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); + + verify(chain).doFilter(req, res); + verify(res).setBody(any(byte[].class)); + verify(res).setHeader("Content-Encoding", "gzip"); + verify(res).setHeader("Vary", "Accept-Encoding"); + } } From 84bd37618f383c346e3c1bc29f9a26c526182a48 Mon Sep 17 00:00:00 2001 From: Dennis Selden <111012436+DennSel@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:46:48 +0100 Subject: [PATCH 10/24] add test for small body below compression threshold --- .../org/juv25d/filter/CompressionFilterTest.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/test/java/org/juv25d/filter/CompressionFilterTest.java b/src/test/java/org/juv25d/filter/CompressionFilterTest.java index 754a2f1e..80ab575e 100644 --- a/src/test/java/org/juv25d/filter/CompressionFilterTest.java +++ b/src/test/java/org/juv25d/filter/CompressionFilterTest.java @@ -73,4 +73,19 @@ void shouldCompress_whenAcceptEncodingIsGzip() throws IOException { 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()); + } } From d8da87affc8cd444c4881d6f363578a30edb9b9d Mon Sep 17 00:00:00 2001 From: Dennis Selden <111012436+DennSel@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:50:02 +0100 Subject: [PATCH 11/24] add test for case-insensitive gzip header matching --- .../org/juv25d/filter/CompressionFilterTest.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/test/java/org/juv25d/filter/CompressionFilterTest.java b/src/test/java/org/juv25d/filter/CompressionFilterTest.java index 80ab575e..f45c2c9e 100644 --- a/src/test/java/org/juv25d/filter/CompressionFilterTest.java +++ b/src/test/java/org/juv25d/filter/CompressionFilterTest.java @@ -88,4 +88,20 @@ void shouldNotCompress_whenBodyIsSmallerThanThreshold() throws IOException { 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); + + verify(res).setBody(any(byte[].class)); + verify(res).setHeader("Content-Encoding", "gzip"); + } } From 5414e7ceff051df82a9c0497f88343f00ffb0dd6 Mon Sep 17 00:00:00 2001 From: Dennis Selden <111012436+DennSel@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:52:15 +0100 Subject: [PATCH 12/24] add test to verify compression at exact threshold boundary --- .../org/juv25d/filter/CompressionFilterTest.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/test/java/org/juv25d/filter/CompressionFilterTest.java b/src/test/java/org/juv25d/filter/CompressionFilterTest.java index f45c2c9e..f64c91ec 100644 --- a/src/test/java/org/juv25d/filter/CompressionFilterTest.java +++ b/src/test/java/org/juv25d/filter/CompressionFilterTest.java @@ -104,4 +104,19 @@ void shouldCompress_whenAcceptEncodingIsUpperCase() throws IOException { verify(res).setBody(any(byte[].class)); 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); + + verify(res).setBody(any(byte[].class)); + verify(res).setHeader("Content-Encoding", "gzip"); + } } From 3685a159a0ac7276043888fcd76812739dff0d58 Mon Sep 17 00:00:00 2001 From: Dennis Selden <111012436+DennSel@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:56:42 +0100 Subject: [PATCH 13/24] add compression fields to ConfigLoader --- src/main/java/org/juv25d/util/ConfigLoader.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/org/juv25d/util/ConfigLoader.java b/src/main/java/org/juv25d/util/ConfigLoader.java index b89550fc..636bcccb 100644 --- a/src/main/java/org/juv25d/util/ConfigLoader.java +++ b/src/main/java/org/juv25d/util/ConfigLoader.java @@ -10,11 +10,13 @@ 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 boolean rateLimitingEnabled; + private boolean compressionEnabled; private List trustedProxies; private List proxyRoutes = new ArrayList<>(); From 6c1a919f27255ee2b168a6f868116fdb2f883905 Mon Sep 17 00:00:00 2001 From: Dennis Selden <111012436+DennSel@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:03:58 +0100 Subject: [PATCH 14/24] parse compression settings from application-properties.yml --- src/main/java/org/juv25d/util/ConfigLoader.java | 12 ++++++++++++ src/main/resources/application-properties.yml | 4 ++++ 2 files changed, 16 insertions(+) diff --git a/src/main/java/org/juv25d/util/ConfigLoader.java b/src/main/java/org/juv25d/util/ConfigLoader.java index 636bcccb..b62711b7 100644 --- a/src/main/java/org/juv25d/util/ConfigLoader.java +++ b/src/main/java/org/juv25d/util/ConfigLoader.java @@ -54,6 +54,8 @@ private void loadConfiguration(InputStream input) { this.rootDirectory = "static"; this.logLevel = "INFO"; this.trustedProxies = List.of(); + this.compressionEnabled = false; + this.minCompressSize = 1024; // server Object serverObj = config.get("server"); @@ -115,6 +117,16 @@ private void loadConfiguration(InputStream input) { Long.parseLong(String.valueOf(rateLimitingConfig.getOrDefault("burst-capacity", 100L))); } + Object compressionObj = config.get("compression"); + if (compressionObj != null) { + Map compressionConfig = asStringObjectMap(compressionObj); + this.compressionEnabled = + Boolean.parseBoolean(String.valueOf(compressionConfig.getOrDefault("enabled", false))); + + this.minCompressSize = + Integer.parseInt(String.valueOf(compressionConfig.getOrDefault("min-compress-size", 1024))); + } + } catch (Exception e) { throw new RuntimeException("Failed to load application config", e); } diff --git a/src/main/resources/application-properties.yml b/src/main/resources/application-properties.yml index b1363129..457ff499 100644 --- a/src/main/resources/application-properties.yml +++ b/src/main/resources/application-properties.yml @@ -15,3 +15,7 @@ rate-limiting: enabled: true requests-per-minute: 60 burst-capacity: 100 + +compression: + enabled: true + min-compress-size: 1024 From 71a5dd75945ee59f29d4b0bd1cfeada7c3701b84 Mon Sep 17 00:00:00 2001 From: Dennis Selden <111012436+DennSel@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:05:11 +0100 Subject: [PATCH 15/24] add getters for compression settings in ConfigLoader --- src/main/java/org/juv25d/util/ConfigLoader.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/java/org/juv25d/util/ConfigLoader.java b/src/main/java/org/juv25d/util/ConfigLoader.java index b62711b7..a86331fb 100644 --- a/src/main/java/org/juv25d/util/ConfigLoader.java +++ b/src/main/java/org/juv25d/util/ConfigLoader.java @@ -171,4 +171,12 @@ public boolean isRateLimitingEnabled() { public List getProxyRoutes() { return Collections.unmodifiableList(proxyRoutes); } + + public boolean isCompressionEnabled() { + return compressionEnabled; + } + + public int getMinCompressSize() { + return minCompressSize; + } } From ba0eff88158fd42cd327dc720ee3a0e81c7cb5d8 Mon Sep 17 00:00:00 2001 From: Dennis Selden <111012436+DennSel@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:07:35 +0100 Subject: [PATCH 16/24] add CompressionConfig now reading from ConfigLoader --- src/main/java/org/juv25d/config/CompressionConfig.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/juv25d/config/CompressionConfig.java b/src/main/java/org/juv25d/config/CompressionConfig.java index b4fd95e0..c6618206 100644 --- a/src/main/java/org/juv25d/config/CompressionConfig.java +++ b/src/main/java/org/juv25d/config/CompressionConfig.java @@ -1,12 +1,15 @@ package org.juv25d.config; +import org.juv25d.util.ConfigLoader; + public class CompressionConfig { private final boolean enabled; private final int minCompressSize; public CompressionConfig() { - this.enabled = false; - this.minCompressSize = 1024; + ConfigLoader config = ConfigLoader.getInstance(); + this.enabled = config.isCompressionEnabled(); + this.minCompressSize = config.getMinCompressSize(); } public boolean isEnabled() { From 12beaf470c54cf96215fe4a6ace95a2efee298c4 Mon Sep 17 00:00:00 2001 From: Dennis Selden <111012436+DennSel@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:29:42 +0100 Subject: [PATCH 17/24] prevent double-compression and preserve existing Vary header --- src/main/java/org/juv25d/filter/CompressionFilter.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/juv25d/filter/CompressionFilter.java b/src/main/java/org/juv25d/filter/CompressionFilter.java index e6dc4e57..fba91d6f 100644 --- a/src/main/java/org/juv25d/filter/CompressionFilter.java +++ b/src/main/java/org/juv25d/filter/CompressionFilter.java @@ -42,7 +42,13 @@ public void doFilter(HttpRequest req, HttpResponse res, FilterChain chain) throw byte[] compressed = compress(body); res.setBody(compressed); res.setHeader("Content-Encoding", "gzip"); - res.setHeader("Vary", "Accept-Encoding"); + + String existingVary = res.getHeader("Vary"); + if (existingVary != null && !existingVary.isEmpty()) { + res.setHeader("Vary", existingVary + ", Accept-Encoding"); + } else { + res.setHeader("Vary", "Accept-Encoding"); + } LOGGER.info("Compressed " + body.length + " bytes to " + compressed.length + " bytes"); } From f0a05970cdc3ba3c69a75f306ea97b757dd926f1 Mon Sep 17 00:00:00 2001 From: Dennis Selden <111012436+DennSel@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:33:17 +0100 Subject: [PATCH 18/24] handle case-insensitive header key and gzip quality values --- src/main/java/org/juv25d/filter/CompressionFilter.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/juv25d/filter/CompressionFilter.java b/src/main/java/org/juv25d/filter/CompressionFilter.java index fba91d6f..116f0f2c 100644 --- a/src/main/java/org/juv25d/filter/CompressionFilter.java +++ b/src/main/java/org/juv25d/filter/CompressionFilter.java @@ -9,6 +9,7 @@ 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; @@ -65,12 +66,18 @@ public CompressionFilter(boolean enabled, int minCompressSize) { } private boolean acceptsGzip(HttpRequest req) { - String acceptEncoding = req.headers().get("Accept-Encoding"); + 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) + .map(e -> e.split(";")[0].trim()) .anyMatch(e -> e.equalsIgnoreCase("gzip")); } From 89a758a5fc9cdc1efaa16e0cd1dff054ea7ea674 Mon Sep 17 00:00:00 2001 From: Dennis Selden <111012436+DennSel@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:38:48 +0100 Subject: [PATCH 19/24] no negative min-compress-size values accepted --- src/main/java/org/juv25d/util/ConfigLoader.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/juv25d/util/ConfigLoader.java b/src/main/java/org/juv25d/util/ConfigLoader.java index a86331fb..5d7f1354 100644 --- a/src/main/java/org/juv25d/util/ConfigLoader.java +++ b/src/main/java/org/juv25d/util/ConfigLoader.java @@ -123,8 +123,9 @@ private void loadConfiguration(InputStream input) { this.compressionEnabled = Boolean.parseBoolean(String.valueOf(compressionConfig.getOrDefault("enabled", false))); - this.minCompressSize = + int parsedMinCompressSize = Integer.parseInt(String.valueOf(compressionConfig.getOrDefault("min-compress-size", 1024))); + this.minCompressSize = Math.max(0, parsedMinCompressSize); } } catch (Exception e) { From 4b764febf2f5740181aa8cb44813314e22085b51 Mon Sep 17 00:00:00 2001 From: Dennis Selden <111012436+DennSel@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:40:14 +0100 Subject: [PATCH 20/24] enforce minimum compress size of 100 bytes --- src/main/java/org/juv25d/util/ConfigLoader.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/juv25d/util/ConfigLoader.java b/src/main/java/org/juv25d/util/ConfigLoader.java index 5d7f1354..cf0fe5c8 100644 --- a/src/main/java/org/juv25d/util/ConfigLoader.java +++ b/src/main/java/org/juv25d/util/ConfigLoader.java @@ -125,7 +125,7 @@ private void loadConfiguration(InputStream input) { int parsedMinCompressSize = Integer.parseInt(String.valueOf(compressionConfig.getOrDefault("min-compress-size", 1024))); - this.minCompressSize = Math.max(0, parsedMinCompressSize); + this.minCompressSize = Math.max(100, parsedMinCompressSize); } } catch (Exception e) { From 749a170531e31647b245ef69cf92be802a1d5569 Mon Sep 17 00:00:00 2001 From: Dennis Selden <111012436+DennSel@users.noreply.github.com> Date: Sun, 1 Mar 2026 18:47:20 +0100 Subject: [PATCH 21/24] Add a check before compression to prevent double encoding --- src/main/java/org/juv25d/filter/CompressionFilter.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/org/juv25d/filter/CompressionFilter.java b/src/main/java/org/juv25d/filter/CompressionFilter.java index 116f0f2c..72b01a00 100644 --- a/src/main/java/org/juv25d/filter/CompressionFilter.java +++ b/src/main/java/org/juv25d/filter/CompressionFilter.java @@ -40,6 +40,10 @@ public void doFilter(HttpRequest req, HttpResponse res, FilterChain chain) throw return; } + if (res.getHeader("Content-Encoding") != null) { + return; + } + byte[] compressed = compress(body); res.setBody(compressed); res.setHeader("Content-Encoding", "gzip"); From 5d6df15edbdc219f6e7879c17fcf508a5c63d08e Mon Sep 17 00:00:00 2001 From: Dennis Selden <111012436+DennSel@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:44:06 +0100 Subject: [PATCH 22/24] Fix Accept-Encoding quality value parsing and improve compression tests Parse quality values in Accept-Encoding header to respect client preferences. Skip compression when gzip;q=0 is specified by client. Add payload integrity verification using round-trip decompression in tests. Fixes: - Respect Accept-Encoding quality values (gzip;q=0 should not compress) - Verify compressed data can be decompressed correctly - Prevent double compression when Content-Encoding already exists --- .../org/juv25d/filter/CompressionFilter.java | 22 ++++++- .../juv25d/filter/CompressionFilterTest.java | 60 +++++++++++++++++-- 2 files changed, 76 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/juv25d/filter/CompressionFilter.java b/src/main/java/org/juv25d/filter/CompressionFilter.java index 72b01a00..1f5b1315 100644 --- a/src/main/java/org/juv25d/filter/CompressionFilter.java +++ b/src/main/java/org/juv25d/filter/CompressionFilter.java @@ -79,10 +79,28 @@ private boolean acceptsGzip(HttpRequest req) { if (acceptEncoding == null || acceptEncoding.isEmpty()) { return false; } + return Arrays.stream(acceptEncoding.split(",")) .map(String::trim) - .map(e -> e.split(";")[0].trim()) - .anyMatch(e -> e.equalsIgnoreCase("gzip")); + .filter(this::isGzipWithQualityAboveZero) + .anyMatch(e -> e.split(";")[0].trim().equalsIgnoreCase("gzip")); + } + + 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; } private byte [] compress(byte [] data) throws IOException { diff --git a/src/test/java/org/juv25d/filter/CompressionFilterTest.java b/src/test/java/org/juv25d/filter/CompressionFilterTest.java index f64c91ec..009db1d2 100644 --- a/src/test/java/org/juv25d/filter/CompressionFilterTest.java +++ b/src/test/java/org/juv25d/filter/CompressionFilterTest.java @@ -4,12 +4,17 @@ 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) @@ -68,8 +73,10 @@ void shouldCompress_whenAcceptEncodingIsGzip() throws IOException { filter.doFilter(req, res, chain); - verify(chain).doFilter(req, res); - verify(res).setBody(any(byte[].class)); + ArgumentCaptor 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"); } @@ -101,7 +108,10 @@ void shouldCompress_whenAcceptEncodingIsUpperCase() throws IOException { filter.doFilter(req, res, chain); - verify(res).setBody(any(byte[].class)); + ArgumentCaptor captor = ArgumentCaptor.forClass(byte[].class); + verify(res).setBody(captor.capture()); + byte[] decompressed = gunzip(captor.getValue()); + assertArrayEquals(body, decompressed); verify(res).setHeader("Content-Encoding", "gzip"); } @@ -116,7 +126,49 @@ void shouldCompress_whenBodyIsExactlyThreshold() throws IOException { filter.doFilter(req, res, chain); - verify(res).setBody(any(byte[].class)); + ArgumentCaptor 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 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(); + } + } } From d2ae1ab590857566f89f5db054df0c0bc73f96ab Mon Sep 17 00:00:00 2001 From: Dennis Selden <111012436+DennSel@users.noreply.github.com> Date: Sun, 1 Mar 2026 22:13:26 +0100 Subject: [PATCH 23/24] Spotless --- src/main/java/org/juv25d/filter/CompressionFilter.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/juv25d/filter/CompressionFilter.java b/src/main/java/org/juv25d/filter/CompressionFilter.java index 1f5b1315..69964480 100644 --- a/src/main/java/org/juv25d/filter/CompressionFilter.java +++ b/src/main/java/org/juv25d/filter/CompressionFilter.java @@ -79,7 +79,7 @@ private boolean acceptsGzip(HttpRequest req) { if (acceptEncoding == null || acceptEncoding.isEmpty()) { return false; } - + return Arrays.stream(acceptEncoding.split(",")) .map(String::trim) .filter(this::isGzipWithQualityAboveZero) @@ -90,7 +90,7 @@ 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=")) { From e188d477b34510a5f827e242bb44b4e97798d033 Mon Sep 17 00:00:00 2001 From: Dennis Selden <111012436+DennSel@users.noreply.github.com> Date: Sun, 1 Mar 2026 22:26:21 +0100 Subject: [PATCH 24/24] Fix edge cases in Content-Encoding and Vary header handling Check for non-blank Content-Encoding to avoid skipping compression when header exists but is empty. Prevent duplicate Vary header values by checking if Accept-Encoding already exists. Use try-with-resources for GZIPOutputStream to ensure proper resource cleanup. Fixes: - Skip compression only if Content-Encoding has actual value - Avoid duplicate Accept-Encoding in Vary header - Ensure GZIPOutputStream is closed even on write failure --- .../org/juv25d/filter/CompressionFilter.java | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/juv25d/filter/CompressionFilter.java b/src/main/java/org/juv25d/filter/CompressionFilter.java index 69964480..273ae6f9 100644 --- a/src/main/java/org/juv25d/filter/CompressionFilter.java +++ b/src/main/java/org/juv25d/filter/CompressionFilter.java @@ -40,7 +40,8 @@ public void doFilter(HttpRequest req, HttpResponse res, FilterChain chain) throw return; } - if (res.getHeader("Content-Encoding") != null) { + String existingEncoding = res.getHeader("Content-Encoding"); + if (existingEncoding != null && !existingEncoding.isBlank()) { return; } @@ -49,10 +50,12 @@ public void doFilter(HttpRequest req, HttpResponse res, FilterChain chain) throw res.setHeader("Content-Encoding", "gzip"); String existingVary = res.getHeader("Vary"); - if (existingVary != null && !existingVary.isEmpty()) { - res.setHeader("Vary", existingVary + ", Accept-Encoding"); - } else { + 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"); @@ -105,10 +108,9 @@ private boolean isGzipWithQualityAboveZero(String encoding) { private byte [] compress(byte [] data) throws IOException { ByteArrayOutputStream byteStream = new ByteArrayOutputStream(); - GZIPOutputStream gzipstream = new GZIPOutputStream(byteStream); - - gzipstream.write(data); - gzipstream.close(); + try (GZIPOutputStream gzipstream = new GZIPOutputStream(byteStream)) { + gzipstream.write(data); + } return byteStream.toByteArray(); } }