From 843b34ce798f9b4b075c000fecf96fe6c0226c47 Mon Sep 17 00:00:00 2001 From: JohanHiths Date: Tue, 17 Feb 2026 00:00:56 +0100 Subject: [PATCH 01/31] initial commit --- .../org/example/http/HttpCachingHeaders.java | 54 ++++++++++++ .../example/http/HttpCachingHeadersTest.java | 85 +++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 src/main/java/org/example/http/HttpCachingHeaders.java create mode 100644 src/test/java/org/example/http/HttpCachingHeadersTest.java diff --git a/src/main/java/org/example/http/HttpCachingHeaders.java b/src/main/java/org/example/http/HttpCachingHeaders.java new file mode 100644 index 00000000..d70f0f6e --- /dev/null +++ b/src/main/java/org/example/http/HttpCachingHeaders.java @@ -0,0 +1,54 @@ +package org.example.http; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.LinkedHashMap; +import java.util.Map; + +public class HttpCachingHeaders { + + private static final String CACHE_CONTROL = "Cache-Control"; + private static final String LAST_MODIFIED = "Last-Modified"; + private static final String ETAG = "ETag"; + + private static final DateTimeFormatter HTTP_DATE_FORMATTER = + DateTimeFormatter.RFC_1123_DATE_TIME.withZone(ZoneOffset.UTC); + + + private final Map headers = new LinkedHashMap<>(); + + + public void setHeader(String name, String value) { + headers.put(name, value); + } + + public void addETagHeader(String etag) { + setHeader(ETAG, etag); + } + + public void setCacheControl(String cacheControl) { + setHeader(CACHE_CONTROL, cacheControl); + } + + public void setLastModified(Instant instant){ + setHeader(LAST_MODIFIED, HTTP_DATE_FORMATTER.format(instant)); + } + + + + public void applyTo(Map target){ + target.putAll(headers); + } + + public Map getHeaders() { + return new LinkedHashMap<>(headers); + } + + public void setDefaultCacheControlStatic(){ + setCacheControl("public, max-age=3600"); + } + + + +} diff --git a/src/test/java/org/example/http/HttpCachingHeadersTest.java b/src/test/java/org/example/http/HttpCachingHeadersTest.java new file mode 100644 index 00000000..c6a937ce --- /dev/null +++ b/src/test/java/org/example/http/HttpCachingHeadersTest.java @@ -0,0 +1,85 @@ +package org.example.http; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +public class HttpCachingHeadersTest { + + + @Test + void shouldStoreEtagValue() { + + HttpCachingHeaders cachingHeaders = new HttpCachingHeaders(); + + cachingHeaders.addETagHeader("123456789"); + + String etagValue = cachingHeaders.getHeaders().get("ETag"); + + assertThat(etagValue).isEqualTo("123456789"); + + } + + + + @Test + void CacheControlReturnsCorrectValue() { + HttpCachingHeaders cachingHeaders = new HttpCachingHeaders(); + cachingHeaders.setCacheControl("public, max-age=3600"); + + String etagValue = cachingHeaders.getHeaders().get("Cache-Control"); + + assertThat(etagValue).isEqualTo("public, max-age=3600"); + + } + + @Test + void setDefaultCacheControlStatic_shouldSetPublicMaxAge3600(){ + HttpCachingHeaders cachingHeaders = new HttpCachingHeaders(); + + cachingHeaders.setDefaultCacheControlStatic(); + + String etagValue = cachingHeaders.getHeaders().get("Cache-Control"); + + assertThat(etagValue).isEqualTo("public, max-age=3600"); + + } + + // Verifies that applyTo() copies all configured caching headers + // into the provided target map. + @Test + void applyToResponse(){ + Map target = new LinkedHashMap<>(); + HttpCachingHeaders cachingHeaders = new HttpCachingHeaders(); + cachingHeaders.addETagHeader("123456789"); + cachingHeaders.applyTo(target); + + assertThat(target).containsEntry("ETag", "123456789"); + } + + // Verifies that getHeaders() returns a defensive copy + // so external modifications do not affect internal state. + @Test + void getHeaders_shouldReturnDefensiveCopy(){ + + HttpCachingHeaders cachingHeaders = new HttpCachingHeaders(); + + cachingHeaders.addETagHeader("123"); + + Map returnedMap = cachingHeaders.getHeaders(); + + returnedMap.put("Etag", "hacked"); + + Map returnedMap2 = cachingHeaders.getHeaders(); + + assertThat(returnedMap.get("Etag")).isEqualTo("hacked"); + + assertThat(cachingHeaders.getHeaders().get("ETag")).isEqualTo("123"); + + } +} From 2844d6e4b6576eafaa7f6dc1b78ae23202869847 Mon Sep 17 00:00:00 2001 From: JohanHiths Date: Tue, 17 Feb 2026 14:30:45 +0100 Subject: [PATCH 02/31] Add `setNoCache` method to configure no-cache headers --- src/main/java/org/example/http/HttpCachingHeaders.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/org/example/http/HttpCachingHeaders.java b/src/main/java/org/example/http/HttpCachingHeaders.java index d70f0f6e..c8ceabf0 100644 --- a/src/main/java/org/example/http/HttpCachingHeaders.java +++ b/src/main/java/org/example/http/HttpCachingHeaders.java @@ -35,6 +35,10 @@ public void setLastModified(Instant instant){ setHeader(LAST_MODIFIED, HTTP_DATE_FORMATTER.format(instant)); } + public void setNoCache() { + setCacheControl("no-store, no-cache, must-revalidate"); + } + public void applyTo(Map target){ From 0048a056bd389e0e6b241c45af91f4f06545a349 Mon Sep 17 00:00:00 2001 From: JohanHiths Date: Wed, 18 Feb 2026 15:34:25 +0100 Subject: [PATCH 03/31] Improve Javadocs for `HttpCachingHeaders` and `HttpCachingHeadersTest` --- .../org/example/http/HttpCachingHeaders.java | 51 ++++++++++++++++++- .../example/http/HttpCachingHeadersTest.java | 18 +++++-- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/example/http/HttpCachingHeaders.java b/src/main/java/org/example/http/HttpCachingHeaders.java index c8ceabf0..cfd8e0f2 100644 --- a/src/main/java/org/example/http/HttpCachingHeaders.java +++ b/src/main/java/org/example/http/HttpCachingHeaders.java @@ -6,8 +6,21 @@ import java.util.LinkedHashMap; import java.util.Map; +/** + * Helper class for building HTTP response headers + * Lets the client reuse cached responses + * Reduces bandwidth + * Reduces latency + * Reduces load on your server + * Ensures webserver and proxies understand caching instructions + */ public class HttpCachingHeaders { + /** + * Cache Control helps manage servers and browsers by settings rules + * ETag helps cache be more efficient and not needing to send a full resend assuming the content has not changed + * Last-Modified + */ private static final String CACHE_CONTROL = "Cache-Control"; private static final String LAST_MODIFIED = "Last-Modified"; private static final String ETAG = "ETag"; @@ -19,36 +32,70 @@ public class HttpCachingHeaders { private final Map headers = new LinkedHashMap<>(); + /** + * Sets a header + * @param name Header name eg. Cache-Control + * @param value Header value eg. public, max-age=3600 + */ public void setHeader(String name, String value) { headers.put(name, value); } + /** + * Helper method for setting ETag header value + * ETag values must be enclosed in double quotes "123" not 123 + * @param etag ETag value as quouted per RFC 7232 + * + */ public void addETagHeader(String etag) { - setHeader(ETAG, etag); + setHeader(ETAG, "\"" + etag + "\""); } + /** + * Sets Cache-Control header value + * @param cacheControl sets rules eg. public, max-age=3600 + */ public void setCacheControl(String cacheControl) { setHeader(CACHE_CONTROL, cacheControl); } + /** + * Helper method for setting Last-Modified header value + * Formates and sets Last modified based on instant + * @param instant Timestamp of last modification + */ public void setLastModified(Instant instant){ setHeader(LAST_MODIFIED, HTTP_DATE_FORMATTER.format(instant)); } + + /** + * Incase of errors or unexpected behaviour, cache should be disabled and no data should be saved + */ public void setNoCache() { setCacheControl("no-store, no-cache, must-revalidate"); } - + /** + * Copies all configured caching headers into the provided target map eg. HttpReponseBuilder. + * @param target Map should return generated headers + */ public void applyTo(Map target){ target.putAll(headers); } + /** + * Maps all configured caching headers into a new map + * @return A map which includes all caching headers + */ public Map getHeaders() { return new LinkedHashMap<>(headers); } + /** + * Standard settings for caching, 1 hour + */ public void setDefaultCacheControlStatic(){ setCacheControl("public, max-age=3600"); } diff --git a/src/test/java/org/example/http/HttpCachingHeadersTest.java b/src/test/java/org/example/http/HttpCachingHeadersTest.java index c6a937ce..b9c84f26 100644 --- a/src/test/java/org/example/http/HttpCachingHeadersTest.java +++ b/src/test/java/org/example/http/HttpCachingHeadersTest.java @@ -9,6 +9,9 @@ import static org.assertj.core.api.Assertions.assertThat; +/** + * + */ public class HttpCachingHeadersTest { @@ -26,7 +29,9 @@ void shouldStoreEtagValue() { } - + /** + * Verifies that Cache-Control header is correctly set + */ @Test void CacheControlReturnsCorrectValue() { HttpCachingHeaders cachingHeaders = new HttpCachingHeaders(); @@ -62,8 +67,11 @@ void applyToResponse(){ assertThat(target).containsEntry("ETag", "123456789"); } - // Verifies that getHeaders() returns a defensive copy - // so external modifications do not affect internal state. +/** + Verifies that getHeaders() returns a defensive copy + so external modifications do not affect internal state. + */ + @Test void getHeaders_shouldReturnDefensiveCopy(){ @@ -73,11 +81,11 @@ void getHeaders_shouldReturnDefensiveCopy(){ Map returnedMap = cachingHeaders.getHeaders(); - returnedMap.put("Etag", "hacked"); + returnedMap.put("ETag", "hacked"); Map returnedMap2 = cachingHeaders.getHeaders(); - assertThat(returnedMap.get("Etag")).isEqualTo("hacked"); + assertThat(returnedMap.get("ETag")).isEqualTo("hacked"); assertThat(cachingHeaders.getHeaders().get("ETag")).isEqualTo("123"); From e422463ce5a6c37fbb4348bfac2a2f5d9afec194 Mon Sep 17 00:00:00 2001 From: JohanHiths Date: Wed, 18 Feb 2026 17:14:41 +0100 Subject: [PATCH 04/31] Fix `addETagHeader` with better formatting after coderabbits suggestion made test fail. to handle various ETag formats; improve Javadocs and correct typos in `HttpCachingHeaders` --- .../org/example/http/HttpCachingHeaders.java | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/example/http/HttpCachingHeaders.java b/src/main/java/org/example/http/HttpCachingHeaders.java index cfd8e0f2..ca5d178b 100644 --- a/src/main/java/org/example/http/HttpCachingHeaders.java +++ b/src/main/java/org/example/http/HttpCachingHeaders.java @@ -48,7 +48,17 @@ public void setHeader(String name, String value) { * */ public void addETagHeader(String etag) { - setHeader(ETAG, "\"" + etag + "\""); + if (etag == null) return; + + String formattedEtag; + if (etag.startsWith("\"") && etag.endsWith("W/\"") ) { + formattedEtag = etag; + } else { + formattedEtag = "\"" + etag + "\""; + } + setHeader(ETAG, etag); + + } /** @@ -61,8 +71,8 @@ public void setCacheControl(String cacheControl) { /** * Helper method for setting Last-Modified header value - * Formates and sets Last modified based on instant - * @param instant Timestamp of last modification + * Formates and sets Last modified based on an instant + * @param instant Timestamp of the last modification */ public void setLastModified(Instant instant){ setHeader(LAST_MODIFIED, HTTP_DATE_FORMATTER.format(instant)); @@ -70,7 +80,7 @@ public void setLastModified(Instant instant){ /** - * Incase of errors or unexpected behaviour, cache should be disabled and no data should be saved + * In case of errors or unexpected behaviour, cache should be disabled and no data should be saved */ public void setNoCache() { setCacheControl("no-store, no-cache, must-revalidate"); From 4b3267dd5885aab30fc0a6e74fd65df753f5aeef Mon Sep 17 00:00:00 2001 From: JohanHiths Date: Wed, 18 Feb 2026 19:03:04 +0100 Subject: [PATCH 05/31] Refactor `addETagHeader` for RFC compliance; update Javadocs and simplify `setNoCache` behavior. --- src/main/java/org/example/http/HttpCachingHeaders.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/example/http/HttpCachingHeaders.java b/src/main/java/org/example/http/HttpCachingHeaders.java index ca5d178b..1e8de96d 100644 --- a/src/main/java/org/example/http/HttpCachingHeaders.java +++ b/src/main/java/org/example/http/HttpCachingHeaders.java @@ -44,8 +44,8 @@ public void setHeader(String name, String value) { /** * Helper method for setting ETag header value * ETag values must be enclosed in double quotes "123" not 123 - * @param etag ETag value as quouted per RFC 7232 - * + * @param etag Raw, unquoted ETag token (e.g. {`@code` abc123}); double quotes + * are added automatically to comply with RFC 7232. */ public void addETagHeader(String etag) { if (etag == null) return; @@ -80,10 +80,10 @@ public void setLastModified(Instant instant){ /** - * In case of errors or unexpected behaviour, cache should be disabled and no data should be saved + * In case of errors or unexpected behaviour, the cache should be disabled and no data should be saved */ public void setNoCache() { - setCacheControl("no-store, no-cache, must-revalidate"); + setCacheControl("no-store, no-cache"); } From 907e58800a7410e74de9252ee6bafcd2ae84eadc Mon Sep 17 00:00:00 2001 From: JohanHiths Date: Sat, 21 Feb 2026 20:18:44 +0100 Subject: [PATCH 06/31] Add `CachingFilter` to enable HTTP caching with ETag and Last-Modified handling --- .../java/org/example/http/CachingFilter.java | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 src/main/java/org/example/http/CachingFilter.java diff --git a/src/main/java/org/example/http/CachingFilter.java b/src/main/java/org/example/http/CachingFilter.java new file mode 100644 index 00000000..24acba94 --- /dev/null +++ b/src/main/java/org/example/http/CachingFilter.java @@ -0,0 +1,75 @@ +package org.example.http; + +import org.example.filter.Filter; +import org.example.filter.FilterChain; +import org.example.httpparser.HttpRequest; + +import java.io.File; +import java.net.URI; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.util.Map; + + +public class CachingFilter implements Filter { + + + @Override + public void init() { + + } + + @Override + public void destroy() { + + } + + @Override + public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) { + + String path = request.getPath(); + + File file = new File("www", path); + + if(!file.exists()){ + response.setStatusCode(404); + return; + } + + Map headers = request.getHeaders(); + + String modifiedSince = headers.get("If-Modified-Since"); + String ETag = generateEtag(file); + Instant lastModified = + Instant.from(DateTimeFormatter.RFC_1123_DATE_TIME.parse(modifiedSince)); + + String ifNoneMatch = headers.get("If-None-Match"); + + + if (ifNoneMatch != null && ifNoneMatch.equals(ETag)) { + response.setStatusCode(304); + return; + } + if (!lastModified.isAfter(Instant.parse(modifiedSince))) { + response.setStatusCode(304); + return; + } + + chain.doFilter(request, response); + + HttpCachingHeaders cachingHeaders = new HttpCachingHeaders(); + cachingHeaders.addETagHeader(ETag); + cachingHeaders.setLastModified(Instant.ofEpochMilli(file.lastModified())); + cachingHeaders.setDefaultCacheControlStatic(); + + response.setHeaders(cachingHeaders.getHeaders()); + + } + + private String generateEtag(File file) { + return String.valueOf(file.lastModified()); + + } +} + + From ec9b987a4059f86bf2e7702969208881b7e37efd Mon Sep 17 00:00:00 2001 From: JohanHiths Date: Sun, 22 Feb 2026 22:24:17 +0100 Subject: [PATCH 07/31] Handle invalid `If-Modified-Since` parsing in `CachingFilter`; add ETag generation logic; refactor `HttpCachingHeaders` and `HttpResponseBuilder` for improved compliance and maintainability. # Conflicts: # src/main/java/org/example/http/CachingFilter.java # src/main/java/org/example/http/HttpResponseBuilder.java --- .../java/org/example/http/HttpCachingHeaders.java | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/main/java/org/example/http/HttpCachingHeaders.java b/src/main/java/org/example/http/HttpCachingHeaders.java index 1e8de96d..96f0fb1c 100644 --- a/src/main/java/org/example/http/HttpCachingHeaders.java +++ b/src/main/java/org/example/http/HttpCachingHeaders.java @@ -21,6 +21,7 @@ public class HttpCachingHeaders { * ETag helps cache be more efficient and not needing to send a full resend assuming the content has not changed * Last-Modified */ + private static final String CACHE_CONTROL = "Cache-Control"; private static final String LAST_MODIFIED = "Last-Modified"; private static final String ETAG = "ETag"; @@ -48,17 +49,7 @@ public void setHeader(String name, String value) { * are added automatically to comply with RFC 7232. */ public void addETagHeader(String etag) { - if (etag == null) return; - - String formattedEtag; - if (etag.startsWith("\"") && etag.endsWith("W/\"") ) { - formattedEtag = etag; - } else { - formattedEtag = "\"" + etag + "\""; - } - setHeader(ETAG, etag); - - + setHeader(ETAG, etag); } /** @@ -103,6 +94,8 @@ public Map getHeaders() { return new LinkedHashMap<>(headers); } + + /** * Standard settings for caching, 1 hour */ From b2ad6ee6e67b0bddce5287c4c008ab55d708804e Mon Sep 17 00:00:00 2001 From: JohanHiths Date: Mon, 23 Feb 2026 14:03:32 +0100 Subject: [PATCH 08/31] Fixing --- src/main/java/org/example/http/CachingFilter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/example/http/CachingFilter.java b/src/main/java/org/example/http/CachingFilter.java index 24acba94..4a0fa839 100644 --- a/src/main/java/org/example/http/CachingFilter.java +++ b/src/main/java/org/example/http/CachingFilter.java @@ -9,7 +9,7 @@ import java.time.Instant; import java.time.format.DateTimeFormatter; import java.util.Map; - +// public class CachingFilter implements Filter { From 5ea159ece2e51f9c4681a9eb0ece4962c620c975 Mon Sep 17 00:00:00 2001 From: JohanHiths Date: Mon, 23 Feb 2026 14:04:37 +0100 Subject: [PATCH 09/31] Fixing --- src/main/java/org/example/http/CachingFilter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/example/http/CachingFilter.java b/src/main/java/org/example/http/CachingFilter.java index 4a0fa839..6f0b4110 100644 --- a/src/main/java/org/example/http/CachingFilter.java +++ b/src/main/java/org/example/http/CachingFilter.java @@ -9,7 +9,7 @@ import java.time.Instant; import java.time.format.DateTimeFormatter; import java.util.Map; -// +/// / public class CachingFilter implements Filter { From af7fcdb3bbc4fba356d8074adeec49429dbe794d Mon Sep 17 00:00:00 2001 From: JohanHiths Date: Mon, 23 Feb 2026 14:07:03 +0100 Subject: [PATCH 10/31] Fix --- src/main/java/org/example/http/CachingFilter.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/org/example/http/CachingFilter.java b/src/main/java/org/example/http/CachingFilter.java index 6f0b4110..a1b22b68 100644 --- a/src/main/java/org/example/http/CachingFilter.java +++ b/src/main/java/org/example/http/CachingFilter.java @@ -2,10 +2,8 @@ import org.example.filter.Filter; import org.example.filter.FilterChain; -import org.example.httpparser.HttpRequest; import java.io.File; -import java.net.URI; import java.time.Instant; import java.time.format.DateTimeFormatter; import java.util.Map; From c27a17d4bb93adf5806be39a55345c1b859bbd00 Mon Sep 17 00:00:00 2001 From: JohanHiths Date: Mon, 23 Feb 2026 14:17:40 +0100 Subject: [PATCH 11/31] restore deleted files --- src/main/java/org/example/filter/Filter.java | 14 +++++++ .../java/org/example/filter/FilterChain.java | 10 +++++ .../org/example/filter/FilterChainImpl.java | 33 +++++++++++++++ .../java/org/example/http/CachingFilter.java | 1 + .../org/example/httpparser/HttpRequest.java | 41 +++++++++++++++++++ 5 files changed, 99 insertions(+) create mode 100644 src/main/java/org/example/filter/Filter.java create mode 100644 src/main/java/org/example/filter/FilterChain.java create mode 100644 src/main/java/org/example/filter/FilterChainImpl.java create mode 100644 src/main/java/org/example/httpparser/HttpRequest.java diff --git a/src/main/java/org/example/filter/Filter.java b/src/main/java/org/example/filter/Filter.java new file mode 100644 index 00000000..5bd4eb1c --- /dev/null +++ b/src/main/java/org/example/filter/Filter.java @@ -0,0 +1,14 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; + +import org.example.httpparser.HttpRequest; + +public interface Filter { + void init(); + + void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain); + + void destroy(); + +} diff --git a/src/main/java/org/example/filter/FilterChain.java b/src/main/java/org/example/filter/FilterChain.java new file mode 100644 index 00000000..942da453 --- /dev/null +++ b/src/main/java/org/example/filter/FilterChain.java @@ -0,0 +1,10 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; + + +public interface FilterChain { + + void doFilter(HttpRequest request, HttpResponseBuilder response); +} diff --git a/src/main/java/org/example/filter/FilterChainImpl.java b/src/main/java/org/example/filter/FilterChainImpl.java new file mode 100644 index 00000000..b6c4509f --- /dev/null +++ b/src/main/java/org/example/filter/FilterChainImpl.java @@ -0,0 +1,33 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; + + +import java.util.List; + +/* +* The default class of FilterChain, +* Contains a list of filters. For each of the filter, will execute the doFilter method. +* + */ + +public class FilterChainImpl implements FilterChain { + + private final List filters; + private int index = 0; + + public FilterChainImpl(List filters) { + this.filters = filters; + } + + @Override + public void doFilter(HttpRequest request, HttpResponseBuilder response) { + if (index < filters.size()) { + Filter next = filters.get(index++); + next.doFilter(request, response, this); + } else { + // TODO: when no more filters, should execute the request + } + } +} diff --git a/src/main/java/org/example/http/CachingFilter.java b/src/main/java/org/example/http/CachingFilter.java index a1b22b68..bbc97805 100644 --- a/src/main/java/org/example/http/CachingFilter.java +++ b/src/main/java/org/example/http/CachingFilter.java @@ -2,6 +2,7 @@ import org.example.filter.Filter; import org.example.filter.FilterChain; +import org.example.httpparser.HttpRequest; import java.io.File; import java.time.Instant; diff --git a/src/main/java/org/example/httpparser/HttpRequest.java b/src/main/java/org/example/httpparser/HttpRequest.java new file mode 100644 index 00000000..ad65d496 --- /dev/null +++ b/src/main/java/org/example/httpparser/HttpRequest.java @@ -0,0 +1,41 @@ +package org.example.httpparser; + +import java.util.Collections; +import java.util.Map; + +/* +* +*This class groups together all information about a request that the server needs + */ + +public class HttpRequest { + + private final String method; + private final String path; + private final String version; + private final Map headers; + private final String body; + + public HttpRequest(String method, + String path, + String version, + Map headers, + String body) { + this.method = method; + this.path = path; + this.version = version; + this.headers = headers != null ? Map.copyOf(headers) : Collections.emptyMap(); + this.body = body; + } + + public String getMethod() { + return method; } + public String getPath() { + return path; } + public String getVersion() { + return version; } + public Map getHeaders() { + return headers; } + public String getBody() { + return body; } + } From 7bbcdd4bb0e9419166d086cdb64f9e495b760155 Mon Sep 17 00:00:00 2001 From: JohanHiths Date: Mon, 23 Feb 2026 14:32:31 +0100 Subject: [PATCH 12/31] Coderabbit review --- src/main/java/org/example/http/HttpCachingHeaders.java | 2 +- src/main/java/org/example/http/HttpResponseBuilder.java | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/example/http/HttpCachingHeaders.java b/src/main/java/org/example/http/HttpCachingHeaders.java index 96f0fb1c..518ed880 100644 --- a/src/main/java/org/example/http/HttpCachingHeaders.java +++ b/src/main/java/org/example/http/HttpCachingHeaders.java @@ -5,7 +5,7 @@ import java.time.format.DateTimeFormatter; import java.util.LinkedHashMap; import java.util.Map; - +// /** * Helper class for building HTTP response headers * Lets the client reuse cached responses diff --git a/src/main/java/org/example/http/HttpResponseBuilder.java b/src/main/java/org/example/http/HttpResponseBuilder.java index eb1f77ea..d3ab505a 100644 --- a/src/main/java/org/example/http/HttpResponseBuilder.java +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -1,5 +1,5 @@ package org.example.http; -// +//// import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; import java.util.Map; @@ -47,4 +47,6 @@ public String build(){ } + + } From 5bd97d0eab5f9a825338a1dad2bf69fb36289a34 Mon Sep 17 00:00:00 2001 From: JohanHiths Date: Mon, 23 Feb 2026 14:48:48 +0100 Subject: [PATCH 13/31] Update `CachingFilter` to include ETag in response headers; add `addHeader` method to `HttpResponseBuilder`. --- src/main/java/org/example/http/CachingFilter.java | 3 ++- src/main/java/org/example/http/HttpResponseBuilder.java | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/example/http/CachingFilter.java b/src/main/java/org/example/http/CachingFilter.java index bbc97805..6360c26b 100644 --- a/src/main/java/org/example/http/CachingFilter.java +++ b/src/main/java/org/example/http/CachingFilter.java @@ -61,7 +61,8 @@ public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterCh cachingHeaders.setLastModified(Instant.ofEpochMilli(file.lastModified())); cachingHeaders.setDefaultCacheControlStatic(); - response.setHeaders(cachingHeaders.getHeaders()); + //response.setHeaders(cachingHeaders.getHeaders()); + response.addHeader("ETag", ETag); } diff --git a/src/main/java/org/example/http/HttpResponseBuilder.java b/src/main/java/org/example/http/HttpResponseBuilder.java index d3ab505a..b2239215 100644 --- a/src/main/java/org/example/http/HttpResponseBuilder.java +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -47,6 +47,10 @@ public String build(){ } + public void addHeader(String key, String value){ + this.headers.put(key, value); + } + } From 90b6fc46e775be7d07134c58de7f014689b92253 Mon Sep 17 00:00:00 2001 From: JohanHiths Date: Tue, 24 Feb 2026 21:48:08 +0100 Subject: [PATCH 14/31] Add unit tests for `CachingFilter` functionality; improve path normalization and handle invalid `If-Modified-Since` parsing gracefully. --- .../java/org/example/http/CachingFilter.java | 37 ++++++++--- .../org/example/http/HttpResponseBuilder.java | 6 +- .../org/example/http/CachingFilterTest.java | 64 +++++++++++++++++++ .../org/example/http/TestFilterChain.java | 13 ++++ 4 files changed, 108 insertions(+), 12 deletions(-) create mode 100644 src/test/java/org/example/http/CachingFilterTest.java create mode 100644 src/test/java/org/example/http/TestFilterChain.java diff --git a/src/main/java/org/example/http/CachingFilter.java b/src/main/java/org/example/http/CachingFilter.java index 6360c26b..87b26761 100644 --- a/src/main/java/org/example/http/CachingFilter.java +++ b/src/main/java/org/example/http/CachingFilter.java @@ -28,6 +28,12 @@ public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterCh String path = request.getPath(); + if (path.equals("/")) { + path = "index.html"; + } else { + path = path.substring(1); + } + File file = new File("www", path); if(!file.exists()){ @@ -38,31 +44,42 @@ public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterCh Map headers = request.getHeaders(); String modifiedSince = headers.get("If-Modified-Since"); - String ETag = generateEtag(file); - Instant lastModified = - Instant.from(DateTimeFormatter.RFC_1123_DATE_TIME.parse(modifiedSince)); + String eTag = generateEtag(file); + Instant lastModified = Instant.ofEpochMilli(file.lastModified()); String ifNoneMatch = headers.get("If-None-Match"); - if (ifNoneMatch != null && ifNoneMatch.equals(ETag)) { + if (ifNoneMatch != null && ifNoneMatch.equals(eTag)) { response.setStatusCode(304); return; } - if (!lastModified.isAfter(Instant.parse(modifiedSince))) { - response.setStatusCode(304); - return; + + + if (modifiedSince != null) { + try { + Instant ifModifiedSinceInstant = + Instant.from(DateTimeFormatter.RFC_1123_DATE_TIME.parse(modifiedSince)); + + if (!lastModified.isAfter(ifModifiedSinceInstant)) { + response.setStatusCode(304); + return; + } + + } catch (Exception e) { + + } } chain.doFilter(request, response); HttpCachingHeaders cachingHeaders = new HttpCachingHeaders(); - cachingHeaders.addETagHeader(ETag); + cachingHeaders.addETagHeader(eTag); cachingHeaders.setLastModified(Instant.ofEpochMilli(file.lastModified())); cachingHeaders.setDefaultCacheControlStatic(); - //response.setHeaders(cachingHeaders.getHeaders()); - response.addHeader("ETag", ETag); + + response.addHeader("ETag", eTag); } diff --git a/src/main/java/org/example/http/HttpResponseBuilder.java b/src/main/java/org/example/http/HttpResponseBuilder.java index b2239215..61aa0771 100644 --- a/src/main/java/org/example/http/HttpResponseBuilder.java +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -1,5 +1,5 @@ package org.example.http; -//// + import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; import java.util.Map; @@ -52,5 +52,7 @@ public void addHeader(String key, String value){ } - + public int getStatusCode() { + return statusCode; + } } diff --git a/src/test/java/org/example/http/CachingFilterTest.java b/src/test/java/org/example/http/CachingFilterTest.java new file mode 100644 index 00000000..d4ad6cb2 --- /dev/null +++ b/src/test/java/org/example/http/CachingFilterTest.java @@ -0,0 +1,64 @@ +package org.example.http; + +import org.example.httpparser.HttpRequest; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.nio.file.Files; +import java.util.Map; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +public class CachingFilterTest { + + + + @Test + void shouldReturn404WhenFileDoesNotExist() { + + CachingFilter cachingFilter = new CachingFilter(); + + HttpRequest request = new HttpRequest( + "GET", + "/does-not-exist", + "HTTP/1.1", + Map.of(), + null + ); + + HttpResponseBuilder response = new HttpResponseBuilder(); + + TestFilterChain chain = new TestFilterChain(); + + cachingFilter.doFilter(request, response, chain); + + assertThat(response.getStatusCode()).isEqualTo(404); + assertThat(chain.called).isFalse(); + } + + + @Test + void shouldContinueChainWhenNoCachingHeaders() throws Exception { + + CachingFilter cachingFilter = new CachingFilter(); + + File file = new File("www/ok.txt"); + file.getParentFile().mkdirs(); + Files.writeString(file.toPath(), "hello"); + + HttpRequest request = new HttpRequest( + "GET", + "/ok.txt", + "HTTP/1.1", + Map.of(), + null + ); + + HttpResponseBuilder response = new HttpResponseBuilder(); + TestFilterChain chain = new TestFilterChain(); + + cachingFilter.doFilter(request, response, chain); + + assertThat(chain.called).isTrue(); + } +} diff --git a/src/test/java/org/example/http/TestFilterChain.java b/src/test/java/org/example/http/TestFilterChain.java new file mode 100644 index 00000000..a1e3a8f4 --- /dev/null +++ b/src/test/java/org/example/http/TestFilterChain.java @@ -0,0 +1,13 @@ +package org.example.http; + +import org.example.filter.FilterChain; +import org.example.httpparser.HttpRequest; + +class TestFilterChain implements FilterChain { + boolean called = false; + + @Override + public void doFilter(HttpRequest request, HttpResponseBuilder response) { + called = true; + } +} \ No newline at end of file From 5962ecb16cf6e855989d5d60999bd28650504613 Mon Sep 17 00:00:00 2001 From: JohanHiths Date: Tue, 24 Feb 2026 23:16:17 +0100 Subject: [PATCH 15/31] Add unit tests for `CachingFilter` functionality; improve path normalization and handle invalid `If-Modified-Since` parsing gracefully. --- src/main/java/org/example/http/CachingFilter.java | 2 +- src/test/java/org/example/http/CachingFilterTest.java | 2 +- src/test/java/org/example/http/TestFilterChain.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/example/http/CachingFilter.java b/src/main/java/org/example/http/CachingFilter.java index 87b26761..d0e396f8 100644 --- a/src/main/java/org/example/http/CachingFilter.java +++ b/src/main/java/org/example/http/CachingFilter.java @@ -8,7 +8,7 @@ import java.time.Instant; import java.time.format.DateTimeFormatter; import java.util.Map; -/// / +/// /// public class CachingFilter implements Filter { diff --git a/src/test/java/org/example/http/CachingFilterTest.java b/src/test/java/org/example/http/CachingFilterTest.java index d4ad6cb2..db0166ac 100644 --- a/src/test/java/org/example/http/CachingFilterTest.java +++ b/src/test/java/org/example/http/CachingFilterTest.java @@ -6,7 +6,7 @@ import java.io.File; import java.nio.file.Files; import java.util.Map; - +// import static org.assertj.core.api.AssertionsForClassTypes.assertThat; public class CachingFilterTest { diff --git a/src/test/java/org/example/http/TestFilterChain.java b/src/test/java/org/example/http/TestFilterChain.java index a1e3a8f4..7b8e1d5c 100644 --- a/src/test/java/org/example/http/TestFilterChain.java +++ b/src/test/java/org/example/http/TestFilterChain.java @@ -2,7 +2,7 @@ import org.example.filter.FilterChain; import org.example.httpparser.HttpRequest; - +// class TestFilterChain implements FilterChain { boolean called = false; From 456cda3a25bb463ab69101ecfdeff0b7eb0eafb4 Mon Sep 17 00:00:00 2001 From: JohanHiths Date: Tue, 24 Feb 2026 23:17:35 +0100 Subject: [PATCH 16/31] Add unit tests for CachingFilter functionality; improve path normalization and handle invalid If-Modified-Since parsing gracefully. --- www/ok.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 www/ok.txt diff --git a/www/ok.txt b/www/ok.txt new file mode 100644 index 00000000..b6fc4c62 --- /dev/null +++ b/www/ok.txt @@ -0,0 +1 @@ +hello \ No newline at end of file From c1321dd5b1f385c7fd1b9b292277699efa886182 Mon Sep 17 00:00:00 2001 From: JohanHiths Date: Tue, 24 Feb 2026 23:19:07 +0100 Subject: [PATCH 17/31] Add unit tests for `CachingFilter` functionality; improve path normalization and handle invalid `If-Modified-Since` parsing gracefully. --- src/main/java/org/example/http/CachingFilter.java | 2 +- src/test/java/org/example/http/CachingFilterTest.java | 2 +- src/test/java/org/example/http/TestFilterChain.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/example/http/CachingFilter.java b/src/main/java/org/example/http/CachingFilter.java index d0e396f8..c29c5f08 100644 --- a/src/main/java/org/example/http/CachingFilter.java +++ b/src/main/java/org/example/http/CachingFilter.java @@ -8,7 +8,7 @@ import java.time.Instant; import java.time.format.DateTimeFormatter; import java.util.Map; -/// /// +/// ///// public class CachingFilter implements Filter { diff --git a/src/test/java/org/example/http/CachingFilterTest.java b/src/test/java/org/example/http/CachingFilterTest.java index db0166ac..a1ad2871 100644 --- a/src/test/java/org/example/http/CachingFilterTest.java +++ b/src/test/java/org/example/http/CachingFilterTest.java @@ -6,7 +6,7 @@ import java.io.File; import java.nio.file.Files; import java.util.Map; -// +/// // import static org.assertj.core.api.AssertionsForClassTypes.assertThat; public class CachingFilterTest { diff --git a/src/test/java/org/example/http/TestFilterChain.java b/src/test/java/org/example/http/TestFilterChain.java index 7b8e1d5c..5eb7a254 100644 --- a/src/test/java/org/example/http/TestFilterChain.java +++ b/src/test/java/org/example/http/TestFilterChain.java @@ -2,7 +2,7 @@ import org.example.filter.FilterChain; import org.example.httpparser.HttpRequest; -// +/// / class TestFilterChain implements FilterChain { boolean called = false; From 5552e856f296bca39a2dee402bb988db8ce8a936 Mon Sep 17 00:00:00 2001 From: JohanHiths Date: Tue, 24 Feb 2026 23:22:58 +0100 Subject: [PATCH 18/31] Add unit tests for CachingFilter functionality; improve path normalization and handle invalid If-Modified-Since parsing gracefully. --- www/ok.txt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 www/ok.txt diff --git a/www/ok.txt b/www/ok.txt deleted file mode 100644 index b6fc4c62..00000000 --- a/www/ok.txt +++ /dev/null @@ -1 +0,0 @@ -hello \ No newline at end of file From cbb0743f94b99c27dae536545f63fe846b703dd9 Mon Sep 17 00:00:00 2001 From: JohanHiths Date: Tue, 24 Feb 2026 23:52:47 +0100 Subject: [PATCH 19/31] Remove getStatusCode method from HttpResponseBuilder Removed the getStatusCode method from HttpResponseBuilder. --- src/main/java/org/example/http/HttpResponseBuilder.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/org/example/http/HttpResponseBuilder.java b/src/main/java/org/example/http/HttpResponseBuilder.java index 3bf343eb..faf25532 100644 --- a/src/main/java/org/example/http/HttpResponseBuilder.java +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -99,8 +99,7 @@ public void addHeader(String key, String value){ } - public int getStatusCode() { - return statusCode; + /* * Builds the complete HTTP response as a byte array and preserves binary content without corruption. * @return Complete HTTP response (headers + body) as byte[] From 29910586d4c660c66fdb05ee4649ce963fe7ff37 Mon Sep 17 00:00:00 2001 From: JohanHiths Date: Thu, 26 Feb 2026 15:24:49 +0100 Subject: [PATCH 20/31] Refactor `CachingFilter` to use configurable root directory, improve ETag generation, and add caching headers. --- .../java/org/example/http/CachingFilter.java | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/example/http/CachingFilter.java b/src/main/java/org/example/http/CachingFilter.java index c29c5f08..44153908 100644 --- a/src/main/java/org/example/http/CachingFilter.java +++ b/src/main/java/org/example/http/CachingFilter.java @@ -1,14 +1,17 @@ package org.example.http; +import org.example.config.AppConfig; +import org.example.config.ConfigLoader; import org.example.filter.Filter; import org.example.filter.FilterChain; import org.example.httpparser.HttpRequest; import java.io.File; +import java.io.ObjectInputFilter; import java.time.Instant; import java.time.format.DateTimeFormatter; import java.util.Map; -/// ///// + public class CachingFilter implements Filter { @@ -34,10 +37,13 @@ public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterCh path = path.substring(1); } - File file = new File("www", path); + // Ingen mer hårtkodat utan sökväg från ConfigLoader + String rootDir = ConfigLoader.get().server().rootDir(); + File file = new File(rootDir, path); + if(!file.exists()){ - response.setStatusCode(404); + response.setStatusCode(HttpResponseBuilder.SC_NOT_FOUND); return; } @@ -51,7 +57,7 @@ public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterCh if (ifNoneMatch != null && ifNoneMatch.equals(eTag)) { - response.setStatusCode(304); + response.setStatusCode(HttpResponseBuilder.SC_NOT_MODIFIED); return; } @@ -62,7 +68,7 @@ public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterCh Instant.from(DateTimeFormatter.RFC_1123_DATE_TIME.parse(modifiedSince)); if (!lastModified.isAfter(ifModifiedSinceInstant)) { - response.setStatusCode(304); + response.setStatusCode(HttpResponseBuilder.SC_NOT_MODIFIED); return; } @@ -80,11 +86,13 @@ public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterCh response.addHeader("ETag", eTag); + response.addHeader("Last-Modified", DateTimeFormatter.RFC_1123_DATE_TIME.format(lastModified.atZone(java.time.ZoneOffset.UTC))); + response.addHeader("Cache-Control", "public, max-age=3600"); } private String generateEtag(File file) { - return String.valueOf(file.lastModified()); + return "\"" + file.lastModified() + "-" + file.length() + "\""; } } From 522ee98f48480aba23b3972de0a4d57367283b2b Mon Sep 17 00:00:00 2001 From: JohanHiths Date: Thu, 26 Feb 2026 21:22:45 +0100 Subject: [PATCH 21/31] Update `CachingFilter` to enhance caching logic, centralize header handling, and ensure consistent ETag and caching headers in responses. At the request of Martin Stenhagen and gvaguirres --- src/main/java/org/example/http/CachingFilter.java | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/example/http/CachingFilter.java b/src/main/java/org/example/http/CachingFilter.java index 44153908..43292435 100644 --- a/src/main/java/org/example/http/CachingFilter.java +++ b/src/main/java/org/example/http/CachingFilter.java @@ -30,6 +30,7 @@ public void destroy() { public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) { String path = request.getPath(); + HttpCachingHeaders cachingHeaders = new HttpCachingHeaders(); if (path.equals("/")) { path = "index.html"; @@ -44,6 +45,7 @@ public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterCh if(!file.exists()){ response.setStatusCode(HttpResponseBuilder.SC_NOT_FOUND); + return; } @@ -58,10 +60,10 @@ public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterCh if (ifNoneMatch != null && ifNoneMatch.equals(eTag)) { response.setStatusCode(HttpResponseBuilder.SC_NOT_MODIFIED); + cachingHeaders.getHeaders().forEach(response::addHeader); return; } - if (modifiedSince != null) { try { Instant ifModifiedSinceInstant = @@ -69,6 +71,7 @@ public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterCh if (!lastModified.isAfter(ifModifiedSinceInstant)) { response.setStatusCode(HttpResponseBuilder.SC_NOT_MODIFIED); + cachingHeaders.getHeaders().forEach(response::addHeader); return; } @@ -77,17 +80,16 @@ public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterCh } } - chain.doFilter(request, response); - HttpCachingHeaders cachingHeaders = new HttpCachingHeaders(); + + cachingHeaders.addETagHeader(eTag); cachingHeaders.setLastModified(Instant.ofEpochMilli(file.lastModified())); cachingHeaders.setDefaultCacheControlStatic(); + chain.doFilter(request, response); + cachingHeaders.getHeaders().forEach(response::addHeader); - response.addHeader("ETag", eTag); - response.addHeader("Last-Modified", DateTimeFormatter.RFC_1123_DATE_TIME.format(lastModified.atZone(java.time.ZoneOffset.UTC))); - response.addHeader("Cache-Control", "public, max-age=3600"); } From d319eb1cdd1b0c54dd42cb50e4655495dc86c03d Mon Sep 17 00:00:00 2001 From: JohanHiths Date: Sat, 28 Feb 2026 17:00:02 +0100 Subject: [PATCH 22/31] Introduce `FileResolver` to centralize path resolution, update `HttpRequest` to include `resolvedPath`, and refactor handlers and tests for consistent path handling. --- .../java/org/example/ConnectionHandler.java | 12 ++++++- src/main/java/org/example/FileResolver.java | 24 ++++++++++++++ .../java/org/example/StaticFileHandler.java | 32 +++++++++++++++++++ .../java/org/example/http/CachingFilter.java | 18 +++-------- .../org/example/httpparser/HttpRequest.java | 10 ++++-- .../java/org/example/filter/IpFilterTest.java | 16 ++++++++-- .../org/example/http/CachingFilterTest.java | 27 +++++++++++----- 7 files changed, 111 insertions(+), 28 deletions(-) create mode 100644 src/main/java/org/example/FileResolver.java diff --git a/src/main/java/org/example/ConnectionHandler.java b/src/main/java/org/example/ConnectionHandler.java index 9fc219d4..2752b823 100644 --- a/src/main/java/org/example/ConnectionHandler.java +++ b/src/main/java/org/example/ConnectionHandler.java @@ -14,11 +14,14 @@ import java.io.IOException; import java.net.Socket; + public class ConnectionHandler implements AutoCloseable { Socket client; String uri; private final List filters; + private HttpRequest request; + public ConnectionHandler(Socket client) { this.client = client; @@ -37,17 +40,24 @@ private List buildFilters() { public void runConnectionHandler() throws IOException { + + HttpParser parser = new HttpParser(); + parser.setReader(client.getInputStream()); parser.parseRequest(); parser.parseHttp(); + String resolvedPath = FileResolver.resolvePath(parser.getUri()); + HttpRequest request = new HttpRequest( parser.getMethod(), parser.getUri(), parser.getVersion(), parser.getHeadersMap(), - "" + "", + resolvedPath + ); String clientIp = client.getInetAddress().getHostAddress(); diff --git a/src/main/java/org/example/FileResolver.java b/src/main/java/org/example/FileResolver.java new file mode 100644 index 00000000..545b818e --- /dev/null +++ b/src/main/java/org/example/FileResolver.java @@ -0,0 +1,24 @@ +package org.example; + +import org.example.config.ConfigLoader; +import org.example.filter.FilterChain; +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; + +import java.io.File; + +public class FileResolver { + + + public static String resolvePath(String requestPath) { + String path = requestPath.equals("/") ? "index.html" : requestPath.substring(1); + String rootDir = ConfigLoader.get().server().rootDir(); + + return path; + } + + + +} + + diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 8bcda375..6fdcf71f 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -1,6 +1,10 @@ package org.example; +import org.example.config.ConfigLoader; +import org.example.filter.FilterChain; import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; + import static org.example.http.HttpResponseBuilder.*; import java.io.File; @@ -35,6 +39,7 @@ private void handleGetRequest(String uri) throws IOException { // Path traversal check File root = new File(WEB_ROOT).getCanonicalFile(); File file = new File(root, uri).getCanonicalFile(); + if (!file.toPath().startsWith(root.toPath())) { fileBytes = "403 Forbidden".getBytes(java.nio.charset.StandardCharsets.UTF_8); statusCode = SC_FORBIDDEN; @@ -56,6 +61,33 @@ private void handleGetRequest(String uri) throws IOException { } } + public void handle(HttpRequest request, HttpResponseBuilder response, FilterChain chain) throws IOException { + + String rootDir = ConfigLoader.get().server().rootDir(); + request.getResolvedPath(); + File file = new File(rootDir, request.getResolvedPath()); + + + if (file.isFile()) { + fileBytes = Files.readAllBytes(file.toPath()); + response.setBody(fileBytes); + statusCode = SC_OK; + } + else { + response.setStatusCode(HttpResponseBuilder.SC_NOT_FOUND); + response.setBody(fileBytes); + + File errorFile = new File(WEB_ROOT, "pageNotFound.html"); + if (errorFile.exists()) { + response.setBody(Files.readAllBytes(errorFile.toPath())); + } else { + response.setBody("404 Not Found".getBytes()); + } + + } + + } + public void sendGetRequest(OutputStream outputStream, String uri) throws IOException { handleGetRequest(uri); HttpResponseBuilder response = new HttpResponseBuilder(); diff --git a/src/main/java/org/example/http/CachingFilter.java b/src/main/java/org/example/http/CachingFilter.java index 43292435..5917a6c8 100644 --- a/src/main/java/org/example/http/CachingFilter.java +++ b/src/main/java/org/example/http/CachingFilter.java @@ -1,5 +1,6 @@ package org.example.http; + import org.example.config.AppConfig; import org.example.config.ConfigLoader; import org.example.filter.Filter; @@ -29,25 +30,14 @@ public void destroy() { @Override public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) { - String path = request.getPath(); - HttpCachingHeaders cachingHeaders = new HttpCachingHeaders(); - if (path.equals("/")) { - path = "index.html"; - } else { - path = path.substring(1); - } + HttpCachingHeaders cachingHeaders = new HttpCachingHeaders(); - // Ingen mer hårtkodat utan sökväg från ConfigLoader - String rootDir = ConfigLoader.get().server().rootDir(); + String rootDir = request.getResolvedPath(); + String path = request.getResolvedPath(); File file = new File(rootDir, path); - if(!file.exists()){ - response.setStatusCode(HttpResponseBuilder.SC_NOT_FOUND); - - return; - } Map headers = request.getHeaders(); diff --git a/src/main/java/org/example/httpparser/HttpRequest.java b/src/main/java/org/example/httpparser/HttpRequest.java index 274f9409..1ab51de9 100644 --- a/src/main/java/org/example/httpparser/HttpRequest.java +++ b/src/main/java/org/example/httpparser/HttpRequest.java @@ -18,21 +18,23 @@ public class HttpRequest { private final String version; private final Map headers; private final String body; + private final String resolvedPath; - - private final Map attributes = new HashMap<>(); + private final Map attributes = new HashMap<>(); public HttpRequest(String method, String path, String version, Map headers, - String body) { + String body, + String resolvedPath) { this.method = method; this.path = path; this.version = version; this.headers = headers != null ? Map.copyOf(headers) : Collections.emptyMap(); this.body = body; + this.resolvedPath = resolvedPath; } public String getMethod() { @@ -45,6 +47,8 @@ public Map getHeaders() { return headers; } public String getBody() { return body; } + public String getResolvedPath() { + return resolvedPath; } public void setAttribute(String key, Object value) { diff --git a/src/test/java/org/example/filter/IpFilterTest.java b/src/test/java/org/example/filter/IpFilterTest.java index 3b556b43..88a1477d 100644 --- a/src/test/java/org/example/filter/IpFilterTest.java +++ b/src/test/java/org/example/filter/IpFilterTest.java @@ -1,11 +1,14 @@ package org.example.filter; +import org.example.FileResolver; +import org.example.config.ConfigLoader; import org.example.http.HttpResponseBuilder; import org.example.httpparser.HttpRequest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.nio.charset.StandardCharsets; +import java.nio.file.Paths; import java.util.Collections; import static org.assertj.core.api.Assertions.assertThat; @@ -18,6 +21,7 @@ */ class IpFilterTest { + private IpFilter ipFilter; private HttpResponseBuilder response; private FilterChain mockChain; @@ -29,6 +33,7 @@ void setUp() { response = new HttpResponseBuilder(); chainCalled = false; mockChain = (req, resp) -> chainCalled = true; + ConfigLoader.loadOnce(Paths.get("src/test/resources/test-config.yml")); } @Test @@ -106,12 +111,16 @@ void testAllowListMode_BlockNonWhitelistedIp() { @Test void testMissingClientIp_Returns400() { // ARRANGE + String resolvedPath = FileResolver.resolvePath("/does-not-exist"); + HttpRequest request = new HttpRequest( "GET", "/", "HTTP/1.1", Collections.emptyMap(), - "" + "", + resolvedPath + ); // ACT @@ -127,12 +136,15 @@ void testMissingClientIp_Returns400() { } private HttpRequest createRequestWithIp(String ip) { + + String resolvedPath = FileResolver.resolvePath("/does-not-exist"); HttpRequest request = new HttpRequest( "GET", "/", "HTTP/1.1", Collections.emptyMap(), - "" + "", + resolvedPath ); request.setAttribute("clientIp", ip); return request; diff --git a/src/test/java/org/example/http/CachingFilterTest.java b/src/test/java/org/example/http/CachingFilterTest.java index a1ad2871..54db3004 100644 --- a/src/test/java/org/example/http/CachingFilterTest.java +++ b/src/test/java/org/example/http/CachingFilterTest.java @@ -1,47 +1,57 @@ package org.example.http; +import org.example.FileResolver; +import org.example.config.ConfigLoader; import org.example.httpparser.HttpRequest; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.io.File; import java.nio.file.Files; +import java.nio.file.Paths; import java.util.Map; /// // import static org.assertj.core.api.AssertionsForClassTypes.assertThat; public class CachingFilterTest { - + @BeforeEach + void setup() { + ConfigLoader.loadOnce(Paths.get("src/test/resources/test-config.yml")); + } @Test - void shouldReturn404WhenFileDoesNotExist() { + void shouldContinueChainWhenNoCacheHit() { CachingFilter cachingFilter = new CachingFilter(); + String resolvedPath = FileResolver.resolvePath("/does-not-exist"); + HttpRequest request = new HttpRequest( "GET", "/does-not-exist", "HTTP/1.1", Map.of(), - null - ); + null, + resolvedPath + ); HttpResponseBuilder response = new HttpResponseBuilder(); TestFilterChain chain = new TestFilterChain(); cachingFilter.doFilter(request, response, chain); - assertThat(response.getStatusCode()).isEqualTo(404); - assertThat(chain.called).isFalse(); + assertThat(chain.called).isTrue(); } - @Test void shouldContinueChainWhenNoCachingHeaders() throws Exception { CachingFilter cachingFilter = new CachingFilter(); + String resolvedPath = FileResolver.resolvePath("/does-not-exist"); + File file = new File("www/ok.txt"); file.getParentFile().mkdirs(); Files.writeString(file.toPath(), "hello"); @@ -51,7 +61,8 @@ void shouldContinueChainWhenNoCachingHeaders() throws Exception { "/ok.txt", "HTTP/1.1", Map.of(), - null + null, + resolvedPath ); HttpResponseBuilder response = new HttpResponseBuilder(); From 2b3ed4e029ce0439adf29e9d94201d05677c4e82 Mon Sep 17 00:00:00 2001 From: JohanHiths Date: Sat, 28 Feb 2026 22:44:01 +0100 Subject: [PATCH 23/31] Enhance `FileResolver` to append root directory to resolved paths, improve path conversion logic, and ensure accurate file system path generation. --- src/main/java/org/example/FileResolver.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/example/FileResolver.java b/src/main/java/org/example/FileResolver.java index 545b818e..2e585d91 100644 --- a/src/main/java/org/example/FileResolver.java +++ b/src/main/java/org/example/FileResolver.java @@ -10,11 +10,20 @@ public class FileResolver { + /** + * Helper class to resolve HTTP request paths to actual file system paths. + * This method processes incoming HTTP request paths and converts them into + * absolute file paths that can be used to locate and serve static files. + * @param requestPath the HTTP request path (e.g., "/", "/index.html" + * @return the complete file system path combining the root directory with the processed request path + */ + public static String resolvePath(String requestPath) { String path = requestPath.equals("/") ? "index.html" : requestPath.substring(1); String rootDir = ConfigLoader.get().server().rootDir(); - return path; + return rootDir + "/" + path; + } From 6107ef20f5a02247661440b9fca2ba17ea6133fc Mon Sep 17 00:00:00 2001 From: JohanHiths Date: Sat, 28 Feb 2026 23:15:30 +0100 Subject: [PATCH 24/31] Handle null/empty paths in `FileResolver`, improve ETag formatting, and enhance caching headers in `CachingFilter`. Update tests accordingly. --- src/main/java/org/example/ConnectionHandler.java | 2 +- src/main/java/org/example/FileResolver.java | 3 +++ .../java/org/example/http/CachingFilter.java | 16 ++++++++++------ .../org/example/http/HttpCachingHeaders.java | 2 +- .../java/org/example/http/CachingFilterTest.java | 9 +++++++-- 5 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/example/ConnectionHandler.java b/src/main/java/org/example/ConnectionHandler.java index 2752b823..173078ed 100644 --- a/src/main/java/org/example/ConnectionHandler.java +++ b/src/main/java/org/example/ConnectionHandler.java @@ -20,7 +20,7 @@ public class ConnectionHandler implements AutoCloseable { Socket client; String uri; private final List filters; - private HttpRequest request; + public ConnectionHandler(Socket client) { diff --git a/src/main/java/org/example/FileResolver.java b/src/main/java/org/example/FileResolver.java index 2e585d91..530a00b1 100644 --- a/src/main/java/org/example/FileResolver.java +++ b/src/main/java/org/example/FileResolver.java @@ -19,6 +19,9 @@ public class FileResolver { */ public static String resolvePath(String requestPath) { + if (requestPath == null || requestPath.isEmpty()) { + requestPath = "/"; + } String path = requestPath.equals("/") ? "index.html" : requestPath.substring(1); String rootDir = ConfigLoader.get().server().rootDir(); diff --git a/src/main/java/org/example/http/CachingFilter.java b/src/main/java/org/example/http/CachingFilter.java index 5917a6c8..2fe81a0a 100644 --- a/src/main/java/org/example/http/CachingFilter.java +++ b/src/main/java/org/example/http/CachingFilter.java @@ -33,9 +33,8 @@ public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterCh HttpCachingHeaders cachingHeaders = new HttpCachingHeaders(); - String rootDir = request.getResolvedPath(); - String path = request.getResolvedPath(); - File file = new File(rootDir, path); + String resolvedPath = request.getResolvedPath(); + File file = new File(resolvedPath); @@ -47,6 +46,10 @@ public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterCh String ifNoneMatch = headers.get("If-None-Match"); + cachingHeaders.addETagHeader(eTag); + cachingHeaders.setLastModified(Instant.ofEpochMilli(file.lastModified())); + cachingHeaders.setDefaultCacheControlStatic(); + if (ifNoneMatch != null && ifNoneMatch.equals(eTag)) { response.setStatusCode(HttpResponseBuilder.SC_NOT_MODIFIED); @@ -67,15 +70,16 @@ public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterCh } catch (Exception e) { + // Ignore malformed If-Modified-Since header; proceed without cache validation + // Consider logging: log.debug("Invalid If-Modified-Since header: {}", modifiedSince, e); } + } - cachingHeaders.addETagHeader(eTag); - cachingHeaders.setLastModified(Instant.ofEpochMilli(file.lastModified())); - cachingHeaders.setDefaultCacheControlStatic(); + chain.doFilter(request, response); cachingHeaders.getHeaders().forEach(response::addHeader); diff --git a/src/main/java/org/example/http/HttpCachingHeaders.java b/src/main/java/org/example/http/HttpCachingHeaders.java index 518ed880..eab6750c 100644 --- a/src/main/java/org/example/http/HttpCachingHeaders.java +++ b/src/main/java/org/example/http/HttpCachingHeaders.java @@ -49,7 +49,7 @@ public void setHeader(String name, String value) { * are added automatically to comply with RFC 7232. */ public void addETagHeader(String etag) { - setHeader(ETAG, etag); + setHeader(ETAG, "\"" + etag + "\""); } /** diff --git a/src/test/java/org/example/http/CachingFilterTest.java b/src/test/java/org/example/http/CachingFilterTest.java index 54db3004..386c3519 100644 --- a/src/test/java/org/example/http/CachingFilterTest.java +++ b/src/test/java/org/example/http/CachingFilterTest.java @@ -7,6 +7,7 @@ import org.junit.jupiter.api.Test; import java.io.File; +import java.io.IOException; import java.nio.file.Files; import java.nio.file.Paths; import java.util.Map; @@ -21,11 +22,15 @@ void setup() { } @Test - void shouldContinueChainWhenNoCacheHit() { + void shouldContinueChainWhenNoCacheHit() throws IOException { CachingFilter cachingFilter = new CachingFilter(); - String resolvedPath = FileResolver.resolvePath("/does-not-exist"); + File file = new File("www/ok.txt"); + file.getParentFile().mkdirs(); + Files.writeString(file.toPath(), "hello"); + + String resolvedPath = FileResolver.resolvePath("/ok.txt"); HttpRequest request = new HttpRequest( "GET", From 8f0f324d058f0e7167f7a49c3894b137fe5d37e1 Mon Sep 17 00:00:00 2001 From: JohanHiths Date: Sat, 28 Feb 2026 23:15:50 +0100 Subject: [PATCH 25/31] Remove unnecessary `setBody` call for 404 response in `StaticFileHandler`. --- src/main/java/org/example/StaticFileHandler.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 6fdcf71f..2a34e7d3 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -75,7 +75,6 @@ public void handle(HttpRequest request, HttpResponseBuilder response, FilterChai } else { response.setStatusCode(HttpResponseBuilder.SC_NOT_FOUND); - response.setBody(fileBytes); File errorFile = new File(WEB_ROOT, "pageNotFound.html"); if (errorFile.exists()) { From 41eeeb21ec718ffc25f9eb11093df5ee2ed54a49 Mon Sep 17 00:00:00 2001 From: JohanHiths Date: Sun, 1 Mar 2026 13:47:00 +0100 Subject: [PATCH 26/31] Prevent directory traversal in `FileResolver` by blocking paths with `..` and returning a 403 response. Ensure fallback body for error handling. --- src/main/java/org/example/FileResolver.java | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/example/FileResolver.java b/src/main/java/org/example/FileResolver.java index 530a00b1..a16c2a15 100644 --- a/src/main/java/org/example/FileResolver.java +++ b/src/main/java/org/example/FileResolver.java @@ -6,6 +6,10 @@ import org.example.httpparser.HttpRequest; import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; public class FileResolver { @@ -18,13 +22,25 @@ public class FileResolver { * @return the complete file system path combining the root directory with the processed request path */ - public static String resolvePath(String requestPath) { + public static String resolvePath(String requestPath, HttpResponseBuilder response, FilterChain chain, File errorFile) { if (requestPath == null || requestPath.isEmpty()) { requestPath = "/"; } + + String path = requestPath.equals("/") ? "index.html" : requestPath.substring(1); String rootDir = ConfigLoader.get().server().rootDir(); + if (path.contains("..")) { + try { + response.setBody(Files.readAllBytes(errorFile.toPath())); + } catch (IOException e) { + response.setBody("Forbidden".getBytes(StandardCharsets.UTF_8)); + } + response.setStatusCode(HttpResponseBuilder.SC_FORBIDDEN); + return null; + } + return rootDir + "/" + path; } From 17b914afa54b87cd32a5bbe39938405ebd68eaa5 Mon Sep 17 00:00:00 2001 From: JohanHiths Date: Sun, 1 Mar 2026 20:05:36 +0100 Subject: [PATCH 27/31] Improve ETag formatting in `CachingFilter` and tests, ensure canonical file path validation in `StaticFileHandler`, and simplify `FileResolver` parameters. --- src/main/java/org/example/FileResolver.java | 8 +++++--- src/main/java/org/example/StaticFileHandler.java | 10 ++++++++-- src/main/java/org/example/http/CachingFilter.java | 10 +++++++--- .../java/org/example/http/HttpCachingHeadersTest.java | 6 +++--- 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/main/java/org/example/FileResolver.java b/src/main/java/org/example/FileResolver.java index a16c2a15..64394079 100644 --- a/src/main/java/org/example/FileResolver.java +++ b/src/main/java/org/example/FileResolver.java @@ -2,6 +2,7 @@ import org.example.config.ConfigLoader; import org.example.filter.FilterChain; +import org.example.http.HttpCachingHeaders; import org.example.http.HttpResponseBuilder; import org.example.httpparser.HttpRequest; @@ -22,12 +23,13 @@ public class FileResolver { * @return the complete file system path combining the root directory with the processed request path */ - public static String resolvePath(String requestPath, HttpResponseBuilder response, FilterChain chain, File errorFile) { + public static String resolvePath(String requestPath) { + HttpResponseBuilder response = new HttpResponseBuilder(); + File errorFile = new File("src/main/resources/error.html"); + if (requestPath == null || requestPath.isEmpty()) { requestPath = "/"; } - - String path = requestPath.equals("/") ? "index.html" : requestPath.substring(1); String rootDir = ConfigLoader.get().server().rootDir(); diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 2a34e7d3..48482ff2 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -64,8 +64,14 @@ private void handleGetRequest(String uri) throws IOException { public void handle(HttpRequest request, HttpResponseBuilder response, FilterChain chain) throws IOException { String rootDir = ConfigLoader.get().server().rootDir(); - request.getResolvedPath(); - File file = new File(rootDir, request.getResolvedPath()); + File root = new File(rootDir).getCanonicalFile(); + File file = new File(request.getResolvedPath()).getCanonicalFile(); + + if (!file.toPath().startsWith(root.toPath())) { + response.setStatusCode(HttpResponseBuilder.SC_FORBIDDEN); + response.setBody("403 Forbidden"); + return; + } if (file.isFile()) { diff --git a/src/main/java/org/example/http/CachingFilter.java b/src/main/java/org/example/http/CachingFilter.java index 2fe81a0a..a0da0c5c 100644 --- a/src/main/java/org/example/http/CachingFilter.java +++ b/src/main/java/org/example/http/CachingFilter.java @@ -35,13 +35,17 @@ public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterCh String resolvedPath = request.getResolvedPath(); File file = new File(resolvedPath); - + if (!file.isFile()) { + chain.doFilter(request, response); + return; + } Map headers = request.getHeaders(); String modifiedSince = headers.get("If-Modified-Since"); String eTag = generateEtag(file); + String quotedEtag = "\"" + eTag + "\""; Instant lastModified = Instant.ofEpochMilli(file.lastModified()); String ifNoneMatch = headers.get("If-None-Match"); @@ -51,7 +55,7 @@ public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterCh cachingHeaders.setDefaultCacheControlStatic(); - if (ifNoneMatch != null && ifNoneMatch.equals(eTag)) { + if (ifNoneMatch != null && ifNoneMatch.equals(quotedEtag)) { response.setStatusCode(HttpResponseBuilder.SC_NOT_MODIFIED); cachingHeaders.getHeaders().forEach(response::addHeader); return; @@ -88,7 +92,7 @@ public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterCh } private String generateEtag(File file) { - return "\"" + file.lastModified() + "-" + file.length() + "\""; + return file.lastModified() + "-" + file.length(); } } diff --git a/src/test/java/org/example/http/HttpCachingHeadersTest.java b/src/test/java/org/example/http/HttpCachingHeadersTest.java index b9c84f26..4492e4d4 100644 --- a/src/test/java/org/example/http/HttpCachingHeadersTest.java +++ b/src/test/java/org/example/http/HttpCachingHeadersTest.java @@ -24,7 +24,7 @@ void shouldStoreEtagValue() { String etagValue = cachingHeaders.getHeaders().get("ETag"); - assertThat(etagValue).isEqualTo("123456789"); + assertThat(etagValue).isEqualTo("\"123456789\""); } @@ -64,7 +64,7 @@ void applyToResponse(){ cachingHeaders.addETagHeader("123456789"); cachingHeaders.applyTo(target); - assertThat(target).containsEntry("ETag", "123456789"); + assertThat(target).containsEntry("ETag", "\"123456789\""); } /** @@ -87,7 +87,7 @@ void getHeaders_shouldReturnDefensiveCopy(){ assertThat(returnedMap.get("ETag")).isEqualTo("hacked"); - assertThat(cachingHeaders.getHeaders().get("ETag")).isEqualTo("123"); + assertThat(cachingHeaders.getHeaders().get("ETag")).isEqualTo("\"123\""); } } From 737ded2420f12822a3a701bbb5c1c24335fd9f39 Mon Sep 17 00:00:00 2001 From: JohanHiths Date: Mon, 2 Mar 2026 00:04:11 +0100 Subject: [PATCH 28/31] Update src/main/java/org/example/StaticFileHandler.java Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> # Conflicts: # src/main/java/org/example/StaticFileHandler.java --- src/main/java/org/example/StaticFileHandler.java | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 48482ff2..3b133ce1 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -75,19 +75,24 @@ public void handle(HttpRequest request, HttpResponseBuilder response, FilterChai if (file.isFile()) { - fileBytes = Files.readAllBytes(file.toPath()); + byte[] fileBytes = Files.readAllBytes(file.toPath()); response.setBody(fileBytes); + response.setStatusCode(SC_OK); + response.setContentTypeFromFilename(file.getName()); statusCode = SC_OK; } else { response.setStatusCode(HttpResponseBuilder.SC_NOT_FOUND); - File errorFile = new File(WEB_ROOT, "pageNotFound.html"); - if (errorFile.exists()) { + File errorFile = new File(root, "pageNotFound.html"); + if (errorFile.isFile()) { response.setBody(Files.readAllBytes(errorFile.toPath())); + response.setContentTypeFromFilename(errorFile.getName()); } else { response.setBody("404 Not Found".getBytes()); + response.setHeader("Content-Type", "text/plain; charset=UTF-8"); } + } } From c65b5cc0fd42f68fff43c42ec2a4aada7483e795 Mon Sep 17 00:00:00 2001 From: JohanHiths Date: Mon, 2 Mar 2026 00:27:45 +0100 Subject: [PATCH 29/31] Remove unnecessary blank lines in StaticFileHandler --- src/main/java/org/example/StaticFileHandler.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 3b133ce1..90daa211 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -94,8 +94,7 @@ public void handle(HttpRequest request, HttpResponseBuilder response, FilterChai } } - } - + } public void sendGetRequest(OutputStream outputStream, String uri) throws IOException { From 2123ba6063c2826f97da86eec785caaa2d35296d Mon Sep 17 00:00:00 2001 From: JohanHiths Date: Mon, 2 Mar 2026 00:48:20 +0100 Subject: [PATCH 30/31] Update `CompressionFilterTest` to incorporate `FileResolver` for consistent path resolution in test `HttpRequest` instances --- .../example/filter/CompressionFilterTest.java | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/test/java/org/example/filter/CompressionFilterTest.java b/src/test/java/org/example/filter/CompressionFilterTest.java index b9994635..455d50d8 100644 --- a/src/test/java/org/example/filter/CompressionFilterTest.java +++ b/src/test/java/org/example/filter/CompressionFilterTest.java @@ -1,5 +1,6 @@ package org.example.filter; +import org.example.FileResolver; import org.example.http.HttpResponseBuilder; import org.example.httpparser.HttpRequest; import org.junit.jupiter.api.Test; @@ -20,12 +21,16 @@ void testGzipCompressionWhenClientSupportsIt() throws Exception { Map headers = new HashMap<>(); headers.put("Accept-Encoding", "gzip, deflate"); + FileResolver.resolvePath("/whatever"); + HttpRequest request = new HttpRequest( "GET", "/", "HTTP/1.1", headers, - null + null, + FileResolver.resolvePath("/whatever") + ); String largeBody = "" + "Hello World! ".repeat(200) + ""; @@ -52,12 +57,15 @@ void testGzipCompressionWhenClientSupportsIt() throws Exception { @Test void testNoCompressionWhenClientDoesNotSupport() { + + String resolvedPath = FileResolver.resolvePath("/"); HttpRequest request = new HttpRequest( "GET", "/", "HTTP/1.1", Map.of(), - null + null, + resolvedPath ); String body = "" + "Hello World! ".repeat(200) + ""; @@ -78,8 +86,9 @@ void testNoCompressionWhenClientDoesNotSupport() { void testNoCompressionForSmallResponses() { Map headers = new HashMap<>(); headers.put("Accept-Encoding", "gzip"); + String resolvedPath = FileResolver.resolvePath("/"); - HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", headers, null); + HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", headers, null, resolvedPath); String smallBody = "Hello"; HttpResponseBuilder response = new HttpResponseBuilder(); @@ -133,8 +142,10 @@ private byte[] getBodyFromResponse(HttpResponseBuilder response) { void testSkipCompressionForImages() { Map headers = new HashMap<>(); headers.put("Accept-Encoding", "gzip"); + String resolvedPath = FileResolver.resolvePath("/"); + - HttpRequest request = new HttpRequest("GET", "/image.jpg", "HTTP/1.1", headers, null); + HttpRequest request = new HttpRequest("GET", "/image.jpg", "HTTP/1.1", headers, null, resolvedPath); String largeImageData = "fake image data ".repeat(200); HttpResponseBuilder response = new HttpResponseBuilder(); @@ -155,8 +166,9 @@ void testSkipCompressionForImages() { void testCompressJsonResponse() { Map headers = new HashMap<>(); headers.put("Accept-Encoding", "gzip"); + String resolvedPath = FileResolver.resolvePath("/"); - HttpRequest request = new HttpRequest("GET", "/api/data", "HTTP/1.1", headers, null); + HttpRequest request = new HttpRequest("GET", "/api/data", "HTTP/1.1", headers, null, resolvedPath); String jsonData = "{\"data\": " + "\"value\",".repeat(200) + "}"; HttpResponseBuilder response = new HttpResponseBuilder(); @@ -177,8 +189,9 @@ void testCompressJsonResponse() { void testHandleContentTypeWithCharset() { Map headers = new HashMap<>(); headers.put("Accept-Encoding", "gzip"); + String resolvedPath = FileResolver.resolvePath("/"); - HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", headers, null); + HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", headers, null, resolvedPath); String body = "" + "content ".repeat(200) + ""; HttpResponseBuilder response = new HttpResponseBuilder(); From 12b48b3a3c2bf4ef06f671efad4f71a21ecad1c5 Mon Sep 17 00:00:00 2001 From: JohanHiths Date: Mon, 2 Mar 2026 18:28:03 +0100 Subject: [PATCH 31/31] Integrate `FileResolver` into filter tests for consistent path resolution and update locale statistics validation logic, ensuring proper lowercase comparison. # Conflicts: # src/test/java/org/example/filter/CompressionFilterTest.java # src/test/java/org/example/filter/LocaleFilterTest.java # src/test/java/org/example/filter/LocaleStatsFilterTest.java --- .../example/filter/CompressionFilterTest.java | 180 +++++++++++++++--- .../org/example/filter/LocaleFilterTest.java | 157 ++++++++------- .../example/filter/LocaleStatsFilterTest.java | 120 ++++++++++++ 3 files changed, 359 insertions(+), 98 deletions(-) create mode 100644 src/test/java/org/example/filter/LocaleStatsFilterTest.java diff --git a/src/test/java/org/example/filter/CompressionFilterTest.java b/src/test/java/org/example/filter/CompressionFilterTest.java index 455d50d8..97df9a60 100644 --- a/src/test/java/org/example/filter/CompressionFilterTest.java +++ b/src/test/java/org/example/filter/CompressionFilterTest.java @@ -1,26 +1,49 @@ package org.example.filter; +import com.aayushatharva.brotli4j.Brotli4jLoader; import org.example.FileResolver; +import org.example.config.ConfigLoader; import org.example.http.HttpResponseBuilder; import org.example.httpparser.HttpRequest; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import com.aayushatharva.brotli4j.decoder.Decoder; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.nio.charset.StandardCharsets; +import java.nio.file.Paths; import java.util.HashMap; import java.util.Map; import java.util.zip.GZIPInputStream; import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; class CompressionFilterTest { + private static boolean BROTLI_AVAILABLE = false; + + + @BeforeAll + static void setupBrotli() { + ConfigLoader.loadOnce(Paths.get("src/test/resources/test-config.yml")); + + try { + Brotli4jLoader.ensureAvailability(); + BROTLI_AVAILABLE = true; + } catch (Exception e) { + System.err.println("Brotli not available for tests: " + e.getMessage()); + BROTLI_AVAILABLE = false; + } + + } + @Test void testGzipCompressionWhenClientSupportsIt() throws Exception { Map headers = new HashMap<>(); headers.put("Accept-Encoding", "gzip, deflate"); - FileResolver.resolvePath("/whatever"); HttpRequest request = new HttpRequest( @@ -30,7 +53,6 @@ void testGzipCompressionWhenClientSupportsIt() throws Exception { headers, null, FileResolver.resolvePath("/whatever") - ); String largeBody = "" + "Hello World! ".repeat(200) + ""; @@ -57,15 +79,14 @@ void testGzipCompressionWhenClientSupportsIt() throws Exception { @Test void testNoCompressionWhenClientDoesNotSupport() { - - String resolvedPath = FileResolver.resolvePath("/"); + FileResolver.resolvePath("/whatever"); HttpRequest request = new HttpRequest( "GET", "/", "HTTP/1.1", Map.of(), null, - resolvedPath + FileResolver.resolvePath("/whatever") ); String body = "" + "Hello World! ".repeat(200) + ""; @@ -86,9 +107,9 @@ void testNoCompressionWhenClientDoesNotSupport() { void testNoCompressionForSmallResponses() { Map headers = new HashMap<>(); headers.put("Accept-Encoding", "gzip"); - String resolvedPath = FileResolver.resolvePath("/"); + FileResolver.resolvePath("/whatever"); - HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", headers, null, resolvedPath); + HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", headers, null, FileResolver.resolvePath("/whatever")); String smallBody = "Hello"; HttpResponseBuilder response = new HttpResponseBuilder(); @@ -120,32 +141,16 @@ private String decompressGzip(byte[] compressed) throws Exception { } private byte[] getBodyFromResponse(HttpResponseBuilder response) { - try { - var field = response.getClass().getDeclaredField("bytebody"); - field.setAccessible(true); - byte[] bytebody = (byte[]) field.get(response); - - if (bytebody != null) { - return bytebody; - } - - var bodyField = response.getClass().getDeclaredField("body"); - bodyField.setAccessible(true); - String body = (String) bodyField.get(response); - return body.getBytes(StandardCharsets.UTF_8); - - } catch (Exception e) { - throw new RuntimeException("Failed to get body", e); - } + return response.getBodyBytes(); } + @Test void testSkipCompressionForImages() { Map headers = new HashMap<>(); headers.put("Accept-Encoding", "gzip"); - String resolvedPath = FileResolver.resolvePath("/"); - + FileResolver.resolvePath("/whatever"); - HttpRequest request = new HttpRequest("GET", "/image.jpg", "HTTP/1.1", headers, null, resolvedPath); + HttpRequest request = new HttpRequest("GET", "/image.jpg", "HTTP/1.1", headers, null, FileResolver.resolvePath("/whatever")); String largeImageData = "fake image data ".repeat(200); HttpResponseBuilder response = new HttpResponseBuilder(); @@ -166,9 +171,9 @@ void testSkipCompressionForImages() { void testCompressJsonResponse() { Map headers = new HashMap<>(); headers.put("Accept-Encoding", "gzip"); - String resolvedPath = FileResolver.resolvePath("/"); + FileResolver.resolvePath("/whatever"); - HttpRequest request = new HttpRequest("GET", "/api/data", "HTTP/1.1", headers, null, resolvedPath); + HttpRequest request = new HttpRequest("GET", "/api/data", "HTTP/1.1", headers, null, FileResolver.resolvePath("/whatever")); String jsonData = "{\"data\": " + "\"value\",".repeat(200) + "}"; HttpResponseBuilder response = new HttpResponseBuilder(); @@ -189,9 +194,9 @@ void testCompressJsonResponse() { void testHandleContentTypeWithCharset() { Map headers = new HashMap<>(); headers.put("Accept-Encoding", "gzip"); - String resolvedPath = FileResolver.resolvePath("/"); + FileResolver.resolvePath("/whatever"); - HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", headers, null, resolvedPath); + HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", headers, null, FileResolver.resolvePath("/whatever")); String body = "" + "content ".repeat(200) + ""; HttpResponseBuilder response = new HttpResponseBuilder(); @@ -207,4 +212,117 @@ void testHandleContentTypeWithCharset() { assertTrue(resultBody.length < body.getBytes(StandardCharsets.UTF_8).length, "Should compress even when Content-Type has charset"); } + + @Test + void testBrotliCompression() throws Exception { + assumeTrue(BROTLI_AVAILABLE, "Brotli native library unavailable"); + FileResolver.resolvePath("/whatever"); + + Map headers = new HashMap<>(); + headers.put("Accept-Encoding", "br"); + + HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", headers, null, FileResolver.resolvePath("/whatever")); + + String largeBody = "" + "Hello World! ".repeat(200) + ""; + HttpResponseBuilder response = new HttpResponseBuilder(); + response.setBody(largeBody); + response.setHeaders(Map.of("Content-Type", "text/html")); + + FilterChain mockChain = (req, res) -> {}; + + CompressionFilter filter = new CompressionFilter(); + filter.doFilter(request, response, mockChain); + + assertEquals("br", response.getHeader("Content-Encoding")); + + byte[] compressedBody = getBodyFromResponse(response); + assertTrue(compressedBody.length < largeBody.getBytes(StandardCharsets.UTF_8).length); + + String decompressed = decompressBrotli(compressedBody); + assertEquals(largeBody, decompressed); + } + + @Test + void testBrotliPreferredOverGzip() { + assumeTrue(BROTLI_AVAILABLE, "Brotli native library unavailable"); + FileResolver.resolvePath("/whatever"); + + Map headers = new HashMap<>(); + headers.put("Accept-Encoding", "gzip, br"); + + HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", headers, null, FileResolver.resolvePath("/whatever")); + + String body = "" + "content ".repeat(200) + ""; + HttpResponseBuilder response = new HttpResponseBuilder(); + response.setBody(body); + response.setHeaders(Map.of("Content-Type", "text/html")); + + FilterChain mockChain = (req, res) -> {}; + + CompressionFilter filter = new CompressionFilter(); + filter.doFilter(request, response, mockChain); + + assertEquals("br", response.getHeader("Content-Encoding")); + } + + @Test + void testGzipWhenOnlyGzipAccepted() { + Map headers = new HashMap<>(); + FileResolver.resolvePath("/whatever"); + headers.put("Accept-Encoding", "gzip"); + + HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", headers, null, FileResolver.resolvePath("/whatever")); + + String body = "" + "content ".repeat(200) + ""; + HttpResponseBuilder response = new HttpResponseBuilder(); + response.setBody(body); + response.setHeaders(Map.of("Content-Type", "text/html")); + + FilterChain mockChain = (req, res) -> {}; + + CompressionFilter filter = new CompressionFilter(); + filter.doFilter(request, response, mockChain); + + assertEquals("gzip", response.getHeader("Content-Encoding")); + } + + @Test + void testBrotliCompressionBetterThanGzip() { + assumeTrue(BROTLI_AVAILABLE, "Brotli native library unavailable"); + + FileResolver.resolvePath("/whatever"); + + Map headers = new HashMap<>(); + String largeBody = "" + "Hello World! ".repeat(200) + ""; + + headers.put("Accept-Encoding", "br"); + HttpRequest brotliRequest = new HttpRequest("GET", "/", "HTTP/1.1", headers, null, FileResolver.resolvePath("/whatever")); + HttpResponseBuilder brotliResponse = new HttpResponseBuilder(); + brotliResponse.setBody(largeBody); + brotliResponse.setHeaders(Map.of("Content-Type", "text/html")); + + CompressionFilter filter = new CompressionFilter(); + filter.doFilter(brotliRequest, brotliResponse, (req, res) -> {}); + + int brotliSize = getBodyFromResponse(brotliResponse).length; + + headers.put("Accept-Encoding", "gzip"); + HttpRequest gzipRequest = new HttpRequest("GET", "/", "HTTP/1.1", headers, null, FileResolver.resolvePath("/whatever")); + HttpResponseBuilder gzipResponse = new HttpResponseBuilder(); + gzipResponse.setBody(largeBody); + gzipResponse.setHeaders(Map.of("Content-Type", "text/html")); + + filter.doFilter(gzipRequest, gzipResponse, (req, res) -> {}); + + int gzipSize = getBodyFromResponse(gzipResponse).length; + + assertTrue(brotliSize <= gzipSize, + "Brotli (" + brotliSize + " bytes) should compress better than gzip (" + gzipSize + " bytes)"); + } + + private String decompressBrotli(byte[] compressed) throws Exception { + byte[] decompressed = Decoder.decompress(compressed).getDecompressedData(); + return new String(decompressed, StandardCharsets.UTF_8); + } + } \ No newline at end of file diff --git a/src/test/java/org/example/filter/LocaleFilterTest.java b/src/test/java/org/example/filter/LocaleFilterTest.java index 98e626b3..90019766 100644 --- a/src/test/java/org/example/filter/LocaleFilterTest.java +++ b/src/test/java/org/example/filter/LocaleFilterTest.java @@ -1,96 +1,119 @@ package org.example.filter; +import org.example.FileResolver; import org.example.http.HttpResponseBuilder; import org.example.httpparser.HttpRequest; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import java.util.HashMap; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; class LocaleFilterTest { - @Test - void shouldUseFirstLanguageFromHeader() { - Map headers = new HashMap<>(); - headers.put("Accept-Language", "sv-SE,sv;q=0.9,en;q=0.8"); - - HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", headers, null); - HttpResponseBuilder response = new HttpResponseBuilder(); - - LocaleFilter filter = new LocaleFilter(); - - filter.doFilter(request, response, (req, res) -> { - assertEquals("sv-SE", LocaleFilter.getCurrentLocale()); - }); - - assertEquals("en-US", LocaleFilter.getCurrentLocale()); + @BeforeEach + void resetStats() { + LocaleStatsFilter.resetStatsForTests(); } @Test - void shouldUseDefaultWhenHeaderMissing() { - Map headers = new HashMap<>(); - - HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", headers, null); - HttpResponseBuilder response = new HttpResponseBuilder(); - - LocaleFilter filter = new LocaleFilter(); - - filter.doFilter(request, response, (req, res) -> { - assertEquals("en-US", LocaleFilter.getCurrentLocale()); + void testSingleRequestUpdatesStats() { + FileResolver.resolvePath("/whatever"); + HttpRequest request = new HttpRequest( + "GET", + "/", + "HTTP/1.1", + Map.of("Accept-Language", "fr-FR,fr;q=0.9"), + null, + FileResolver.resolvePath("/whatever") + ); + + LocaleFilterWithCookie filter = new LocaleFilterWithCookie(); + LocaleStatsFilter statsFilter = new LocaleStatsFilter(); + + filter.doFilter(request, new HttpResponseBuilder(), (req, res) -> { + statsFilter.doFilter(req, res, (r, s) -> {}); }); - } - - @Test - void shouldUseDefaultWhenHeaderBlank() { - Map headers = new HashMap<>(); - headers.put("Accept-Language", " "); - - HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", headers, null); - HttpResponseBuilder response = new HttpResponseBuilder(); - LocaleFilter filter = new LocaleFilter(); - - filter.doFilter(request, response, (req, res) -> { - assertEquals("en-US", LocaleFilter.getCurrentLocale()); - }); + Map stats = LocaleStatsFilter.getLocaleStats(); + assertEquals(1, stats.size()); + assertEquals(1, stats.get("fr-fr")); // only change: lowercased } @Test - void shouldHandleCaseInsensitiveHeader() { - Map headers = new HashMap<>(); - headers.put("accept-language", "fr-FR"); - - HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", headers, null); - HttpResponseBuilder response = new HttpResponseBuilder(); - - LocaleFilter filter = new LocaleFilter(); - - filter.doFilter(request, response, (req, res) -> { - assertEquals("fr-FR", LocaleFilter.getCurrentLocale()); - }); + void testMultipleRequestsSameLocale() { + FileResolver.resolvePath("/whatever"); + HttpRequest request = new HttpRequest( + "GET", + "/", + "HTTP/1.1", + Map.of("Accept-Language", "sv-SE,sv;q=0.9"), + null, + FileResolver.resolvePath("/whatever") + ); + + LocaleFilterWithCookie localeFilter = new LocaleFilterWithCookie(); + LocaleStatsFilter statsFilter = new LocaleStatsFilter(); + + for (int i = 0; i < 3; i++) { + localeFilter.doFilter(request, new HttpResponseBuilder(), (req, res) -> { + statsFilter.doFilter(req, res, (r, s) -> {}); + }); + } + + Map stats = LocaleStatsFilter.getLocaleStats(); + assertEquals(1, stats.size()); + assertEquals(3, stats.get("sv-se")); // only change: lowercased } @Test - void shouldUseDefaultWhenRequestIsNull() { - LocaleFilter filter = new LocaleFilter(); - HttpResponseBuilder response = new HttpResponseBuilder(); - - filter.doFilter(null, response, (req, res) -> { - assertEquals("en-US", LocaleFilter.getCurrentLocale()); + void testMultipleRequestsDifferentLocales() { + FileResolver.resolvePath("/whatever"); + HttpRequest request1 = new HttpRequest( + "GET", "/", "HTTP/1.1", + Map.of("Accept-Language", "fr-FR"), null, + FileResolver.resolvePath("/whatever") + ); + HttpRequest request2 = new HttpRequest( + "GET", "/", "HTTP/1.1", + Map.of("Accept-Language", "es-ES"), null, + FileResolver.resolvePath("/whatever") + ); + + LocaleFilterWithCookie localeFilter = new LocaleFilterWithCookie(); + LocaleStatsFilter statsFilter = new LocaleStatsFilter(); + + localeFilter.doFilter(request1, new HttpResponseBuilder(), (req, res) -> { + statsFilter.doFilter(req, res, (r, s) -> {}); + }); + localeFilter.doFilter(request2, new HttpResponseBuilder(), (req, res) -> { + statsFilter.doFilter(req, res, (r, s) -> {}); }); + + Map stats = LocaleStatsFilter.getLocaleStats(); + assertEquals(2, stats.size()); + assertEquals(1, stats.get("fr-fr")); // only change: lowercased + assertEquals(1, stats.get("es-es")); // only change: lowercased } @Test - void shouldUseDefaultWhenHeadersAreEmpty() { - HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", null, null); - HttpResponseBuilder response = new HttpResponseBuilder(); - - LocaleFilter filter = new LocaleFilter(); - - filter.doFilter(request, response, (req, res) -> { - assertEquals("en-US", LocaleFilter.getCurrentLocale()); + void testNoLocaleFallsBackToDefault() { + FileResolver.resolvePath("/whatever"); + HttpRequest request = new HttpRequest( + "GET", "/", "HTTP/1.1", + Map.of(), null, FileResolver.resolvePath("/whatever") + ); + + LocaleFilterWithCookie localeFilter = new LocaleFilterWithCookie(); + LocaleStatsFilter statsFilter = new LocaleStatsFilter(); + + localeFilter.doFilter(request, new HttpResponseBuilder(), (req, res) -> { + statsFilter.doFilter(req, res, (r, s) -> {}); }); + + Map stats = LocaleStatsFilter.getLocaleStats(); + assertEquals(1, stats.size()); + assertEquals(1, stats.get("en-us")); // only change: lowercased } -} +} \ No newline at end of file diff --git a/src/test/java/org/example/filter/LocaleStatsFilterTest.java b/src/test/java/org/example/filter/LocaleStatsFilterTest.java new file mode 100644 index 00000000..e749fb97 --- /dev/null +++ b/src/test/java/org/example/filter/LocaleStatsFilterTest.java @@ -0,0 +1,120 @@ +package org.example.filter; + +import org.example.FileResolver; +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class LocaleStatsFilterTest { + + @BeforeEach + void resetStats() { + LocaleStatsFilter.resetStatsForTests(); + } + + @Test + void testSingleRequestUpdatesStats() { + FileResolver.resolvePath("/whatever"); + HttpRequest request = new HttpRequest( + "GET", + "/", + "HTTP/1.1", + Map.of("Accept-Language", "fr-FR,fr;q=0.9"), + null, + FileResolver.resolvePath("/whatever") + ); + + LocaleFilterWithCookie filter = new LocaleFilterWithCookie(); + LocaleStatsFilter statsFilter = new LocaleStatsFilter(); + + filter.doFilter(request, new HttpResponseBuilder(), (req, res) -> { + statsFilter.doFilter(req, res, (r, s) -> {}); + }); + + Map stats = LocaleStatsFilter.getLocaleStats(); + assertEquals(1, stats.size()); + assertEquals(1, stats.get("fr-fr")); // only change: lowercased + } + + @Test + void testMultipleRequestsSameLocale() { + FileResolver.resolvePath("/whatever"); + HttpRequest request = new HttpRequest( + "GET", + "/", + "HTTP/1.1", + Map.of("Accept-Language", "sv-SE,sv;q=0.9"), + null, + FileResolver.resolvePath("/whatever") + ); + + LocaleFilterWithCookie localeFilter = new LocaleFilterWithCookie(); + LocaleStatsFilter statsFilter = new LocaleStatsFilter(); + + for (int i = 0; i < 3; i++) { + localeFilter.doFilter(request, new HttpResponseBuilder(), (req, res) -> { + statsFilter.doFilter(req, res, (r, s) -> {}); + }); + } + + Map stats = LocaleStatsFilter.getLocaleStats(); + assertEquals(1, stats.size()); + assertEquals(3, stats.get("sv-se")); // only change: lowercased + } + + @Test + void testMultipleRequestsDifferentLocales() { + FileResolver.resolvePath("/whatever"); + HttpRequest request1 = new HttpRequest( + "GET", "/", "HTTP/1.1", + Map.of("Accept-Language", "fr-FR"), null, + FileResolver.resolvePath("/whatever") + ); + HttpRequest request2 = new HttpRequest( + "GET", "/", "HTTP/1.1", + Map.of("Accept-Language", "es-ES"), null, + FileResolver.resolvePath("/whatever") + ); + + LocaleFilterWithCookie localeFilter = new LocaleFilterWithCookie(); + LocaleStatsFilter statsFilter = new LocaleStatsFilter(); + + localeFilter.doFilter(request1, new HttpResponseBuilder(), (req, res) -> { + statsFilter.doFilter(req, res, (r, s) -> {}); + }); + localeFilter.doFilter(request2, new HttpResponseBuilder(), (req, res) -> { + statsFilter.doFilter(req, res, (r, s) -> {}); + }); + + Map stats = LocaleStatsFilter.getLocaleStats(); + assertEquals(2, stats.size()); + assertEquals(1, stats.get("fr-fr")); // only change: lowercased + assertEquals(1, stats.get("es-es")); // only change: lowercased + } + + @Test + void testNoLocaleFallsBackToDefault() { + FileResolver.resolvePath("/whatever"); + HttpRequest request = new HttpRequest( + "GET", "/", "HTTP/1.1", + Map.of(), null, + FileResolver.resolvePath("/whatever") + ); + + LocaleFilterWithCookie localeFilter = new LocaleFilterWithCookie(); + LocaleStatsFilter statsFilter = new LocaleStatsFilter(); + + localeFilter.doFilter(request, new HttpResponseBuilder(), (req, res) -> { + statsFilter.doFilter(req, res, (r, s) -> {}); + }); + + Map stats = LocaleStatsFilter.getLocaleStats(); + assertEquals(1, stats.size()); + assertEquals(1, stats.get("en-us")); // only change: lowercased + } +}