diff --git a/src/main/java/org/example/ConnectionHandler.java b/src/main/java/org/example/ConnectionHandler.java index 1fcc29fb..6a587c27 100644 --- a/src/main/java/org/example/ConnectionHandler.java +++ b/src/main/java/org/example/ConnectionHandler.java @@ -14,6 +14,7 @@ import java.io.IOException; import java.net.Socket; + public class ConnectionHandler implements AutoCloseable { Socket client; @@ -21,6 +22,8 @@ public class ConnectionHandler implements AutoCloseable { private final List filters; String webRoot; + + public ConnectionHandler(Socket client) { this.client = client; this.filters = buildFilters(); @@ -45,6 +48,7 @@ private List buildFilters() { } public void runConnectionHandler() throws IOException { + StaticFileHandler sfh; if (webRoot != null) { @@ -53,17 +57,23 @@ public void runConnectionHandler() throws IOException { sfh = new StaticFileHandler(); } + 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..64394079 --- /dev/null +++ b/src/main/java/org/example/FileResolver.java @@ -0,0 +1,54 @@ +package org.example; + +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; + +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 { + + + /** + * 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) { + 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(); + + 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; + + } + + + +} + + diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 8bcda375..90daa211 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,42 @@ private void handleGetRequest(String uri) throws IOException { } } + public void handle(HttpRequest request, HttpResponseBuilder response, FilterChain chain) throws IOException { + + String rootDir = ConfigLoader.get().server().rootDir(); + 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()) { + 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(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"); + } + } + + + } + 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 new file mode 100644 index 00000000..a0da0c5c --- /dev/null +++ b/src/main/java/org/example/http/CachingFilter.java @@ -0,0 +1,100 @@ +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 { + + + @Override + public void init() { + + } + + @Override + public void destroy() { + + } + + @Override + public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) { + + + HttpCachingHeaders cachingHeaders = new HttpCachingHeaders(); + + 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"); + + cachingHeaders.addETagHeader(eTag); + cachingHeaders.setLastModified(Instant.ofEpochMilli(file.lastModified())); + cachingHeaders.setDefaultCacheControlStatic(); + + + if (ifNoneMatch != null && ifNoneMatch.equals(quotedEtag)) { + 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) { + + // Ignore malformed If-Modified-Since header; proceed without cache validation + // Consider logging: log.debug("Invalid If-Modified-Since header: {}", modifiedSince, 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..eab6750c --- /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 bd4026af..16f894f6 100644 --- a/src/main/java/org/example/http/HttpResponseBuilder.java +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -103,6 +103,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[] @@ -147,6 +154,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 18f0e561..1ab51de9 100644 --- a/src/main/java/org/example/httpparser/HttpRequest.java +++ b/src/main/java/org/example/httpparser/HttpRequest.java @@ -1,7 +1,9 @@ package org.example.httpparser; import java.util.Collections; + import java.util.HashMap; + import java.util.Map; /* @@ -16,18 +18,23 @@ public class HttpRequest { private final String version; private final Map headers; private final String body; - private final Map attributes = new HashMap<>(); + private final String resolvedPath; + + 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() { @@ -40,10 +47,15 @@ public Map getHeaders() { return headers; } public String getBody() { return body; } + public String getResolvedPath() { + return resolvedPath; } + + 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/CompressionFilterTest.java b/src/test/java/org/example/filter/CompressionFilterTest.java index b9994635..97df9a60 100644 --- a/src/test/java/org/example/filter/CompressionFilterTest.java +++ b/src/test/java/org/example/filter/CompressionFilterTest.java @@ -1,31 +1,58 @@ 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( "GET", "/", "HTTP/1.1", headers, - null + null, + FileResolver.resolvePath("/whatever") ); String largeBody = "" + "Hello World! ".repeat(200) + ""; @@ -52,12 +79,14 @@ void testGzipCompressionWhenClientSupportsIt() throws Exception { @Test void testNoCompressionWhenClientDoesNotSupport() { + FileResolver.resolvePath("/whatever"); HttpRequest request = new HttpRequest( "GET", "/", "HTTP/1.1", Map.of(), - null + null, + FileResolver.resolvePath("/whatever") ); String body = "" + "Hello World! ".repeat(200) + ""; @@ -78,8 +107,9 @@ void testNoCompressionWhenClientDoesNotSupport() { void testNoCompressionForSmallResponses() { Map headers = new HashMap<>(); headers.put("Accept-Encoding", "gzip"); + FileResolver.resolvePath("/whatever"); - HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", headers, null); + HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", headers, null, FileResolver.resolvePath("/whatever")); String smallBody = "Hello"; HttpResponseBuilder response = new HttpResponseBuilder(); @@ -111,30 +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"); + FileResolver.resolvePath("/whatever"); - HttpRequest request = new HttpRequest("GET", "/image.jpg", "HTTP/1.1", headers, null); + 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(); @@ -155,8 +171,9 @@ void testSkipCompressionForImages() { void testCompressJsonResponse() { Map headers = new HashMap<>(); headers.put("Accept-Encoding", "gzip"); + FileResolver.resolvePath("/whatever"); - HttpRequest request = new HttpRequest("GET", "/api/data", "HTTP/1.1", headers, null); + 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(); @@ -177,8 +194,9 @@ void testCompressJsonResponse() { void testHandleContentTypeWithCharset() { Map headers = new HashMap<>(); headers.put("Accept-Encoding", "gzip"); + FileResolver.resolvePath("/whatever"); - HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", headers, null); + HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", headers, null, FileResolver.resolvePath("/whatever")); String body = "" + "content ".repeat(200) + ""; HttpResponseBuilder response = new HttpResponseBuilder(); @@ -194,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/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/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 + } +} 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..386c3519 --- /dev/null +++ b/src/test/java/org/example/http/CachingFilterTest.java @@ -0,0 +1,80 @@ +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.io.IOException; +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 shouldContinueChainWhenNoCacheHit() throws IOException { + + CachingFilter cachingFilter = new CachingFilter(); + + 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", + "/does-not-exist", + "HTTP/1.1", + Map.of(), + null, + resolvedPath + + ); + HttpResponseBuilder response = new HttpResponseBuilder(); + + TestFilterChain chain = new TestFilterChain(); + + cachingFilter.doFilter(request, response, chain); + + 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"); + + HttpRequest request = new HttpRequest( + "GET", + "/ok.txt", + "HTTP/1.1", + Map.of(), + null, + resolvedPath + ); + + 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/HttpCachingHeadersTest.java b/src/test/java/org/example/http/HttpCachingHeadersTest.java new file mode 100644 index 00000000..4492e4d4 --- /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\""); + + } +} 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..5eb7a254 --- /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