diff --git a/src/main/java/org/example/ResolveFileHandler.java b/src/main/java/org/example/ResolveFileHandler.java new file mode 100644 index 00000000..633cd7f9 --- /dev/null +++ b/src/main/java/org/example/ResolveFileHandler.java @@ -0,0 +1,9 @@ +package org.example; + +public class ResolveFileHandler { + + + public void handleCaching(){ + + } +} diff --git a/src/main/java/org/example/filter/CachingFilter.java b/src/main/java/org/example/filter/CachingFilter.java new file mode 100644 index 00000000..1afb54f9 --- /dev/null +++ b/src/main/java/org/example/filter/CachingFilter.java @@ -0,0 +1,96 @@ +package org.example.filter; + +import org.example.config.ConfigLoader; +import org.example.http.HttpCachingHeaders; +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; + +import java.io.File; +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(); + HttpCachingHeaders cachingHeaders = new HttpCachingHeaders(); + + if (path.equals("/")) { + path = "index.html"; + } else { + path = path.substring(1); + } + + // 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(HttpResponseBuilder.SC_NOT_FOUND); + + return; + } + + Map headers = request.getHeaders(); + + String modifiedSince = headers.get("If-Modified-Since"); + String eTag = generateEtag(file); + Instant lastModified = Instant.ofEpochMilli(file.lastModified()); + + 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); + cachingHeaders.getHeaders().forEach(response::addHeader); + return; + } + + if (modifiedSince != null) { + try { + Instant ifModifiedSinceInstant = + Instant.from(DateTimeFormatter.RFC_1123_DATE_TIME.parse(modifiedSince)); + + if (!lastModified.isAfter(ifModifiedSinceInstant)) { + response.setStatusCode(HttpResponseBuilder.SC_NOT_MODIFIED); + cachingHeaders.getHeaders().forEach(response::addHeader); + return; + } + + } catch (Exception e) { + + } + } + + chain.doFilter(request, response); + cachingHeaders.getHeaders().forEach(response::addHeader); + + + } + + private String generateEtag(File file) { + return "\"" + file.lastModified() + "-" + file.length() + "\""; + + } +} + 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..518ed880 --- /dev/null +++ b/src/main/java/org/example/http/HttpCachingHeaders.java @@ -0,0 +1,108 @@ +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; +// +/** + * 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"; + + private static final DateTimeFormatter HTTP_DATE_FORMATTER = + DateTimeFormatter.RFC_1123_DATE_TIME.withZone(ZoneOffset.UTC); + + + 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 Raw, unquoted ETag token (e.g. {`@code` abc123}); double quotes + * are added automatically to comply with RFC 7232. + */ + public void addETagHeader(String 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 an instant + * @param instant Timestamp of the last modification + */ + public void setLastModified(Instant instant){ + setHeader(LAST_MODIFIED, HTTP_DATE_FORMATTER.format(instant)); + } + + + /** + * 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"); + } + + + /** + * 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/main/java/org/example/http/HttpResponseBuilder.java b/src/main/java/org/example/http/HttpResponseBuilder.java index 29888541..88777c98 100644 --- a/src/main/java/org/example/http/HttpResponseBuilder.java +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -106,6 +106,13 @@ public void setContentTypeFromFilename(String filename) { setHeader("Content-Type", mimeType); } + + public void addHeader(String key, String value){ + this.headers.put(key, value); + } + + + /* * Builds the complete HTTP response as a byte array and preserves binary content without corruption. * @return Complete HTTP response (headers + body) as byte[] @@ -150,6 +157,7 @@ public byte[] build() { System.arraycopy(contentBody, 0, response, headerBytes.length, contentBody.length); return response; + } public Map getHeaders() { diff --git a/src/main/java/org/example/httpparser/HttpRequest.java b/src/main/java/org/example/httpparser/HttpRequest.java index 2f467708..3a642dc5 100644 --- a/src/main/java/org/example/httpparser/HttpRequest.java +++ b/src/main/java/org/example/httpparser/HttpRequest.java @@ -3,7 +3,9 @@ import org.jspecify.annotations.Nullable; import java.util.Collections; + import java.util.HashMap; + import java.util.Map; /* @@ -17,9 +19,13 @@ public class HttpRequest { private final String path; private final String version; private final Map headers; + + private final @Nullable String body; + private final Map attributes = new HashMap<>(); + public HttpRequest(String method, String path, String version, @@ -42,10 +48,13 @@ public Map getHeaders() { return headers; } public @Nullable String getBody() { return body; } + + public void setAttribute(String key, Object value) { attributes.put(key, value); } public Object getAttribute(String key) { return attributes.get(key); } + } diff --git a/src/test/java/org/example/filter/CachingFilterTest.java b/src/test/java/org/example/filter/CachingFilterTest.java new file mode 100644 index 00000000..abf1c2ed --- /dev/null +++ b/src/test/java/org/example/filter/CachingFilterTest.java @@ -0,0 +1,76 @@ +package org.example.filter; + +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.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Map; +/// // +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +public class CachingFilterTest { + + + @BeforeEach + void setup() { + Path configPath = Paths.get("src/test/resources/test-config.yml"); + ConfigLoader.loadOnce(configPath); + + } + + @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/filter/TestFilterChain.java b/src/test/java/org/example/filter/TestFilterChain.java new file mode 100644 index 00000000..ca1f5896 --- /dev/null +++ b/src/test/java/org/example/filter/TestFilterChain.java @@ -0,0 +1,13 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +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 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..b9c84f26 --- /dev/null +++ b/src/test/java/org/example/http/HttpCachingHeadersTest.java @@ -0,0 +1,93 @@ +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"); + + } + + + /** + * Verifies that Cache-Control header is correctly set + */ + @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"); + + } +}