From 98b40a1f09cc59c686898602bbf7a5a46441b111 Mon Sep 17 00:00:00 2001 From: Kathrin Trinh Date: Tue, 10 Feb 2026 14:44:16 +0100 Subject: [PATCH 01/54] Initial commit med all kod (#16) Add ci workflow for github actions. --- .github/workflows/ci.yml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..9610edff --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: Java CI with Maven + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Get Java Version + run: | + JAVA_VERSION=$(mvn help:evaluate "-Dexpression=maven.compiler.release" -q -DforceStdout) + echo "JAVA_VERSION=$JAVA_VERSION" >> $GITHUB_ENV + + - name: Set up JDK ${{ env.JAVA_VERSION }} + uses: actions/setup-java@v4 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: 'temurin' + cache: maven + + - name: Compile with Maven + run: mvn -B compile --file pom.xml + + - name: Test with Maven + run: mvn -B test --file pom.xml \ No newline at end of file From c1b5f7009a6f2b19dcf63ea965bf44efd5453d7a Mon Sep 17 00:00:00 2001 From: Ebba Andersson Date: Tue, 10 Feb 2026 14:45:46 +0100 Subject: [PATCH 02/54] build: configure pom.xml with needed plugin/tools. (#19) * build: configure pom.xml with needed plugin/tools. Setup Java 25 environment with JUnit 5, Mockito, JaCoCo, Pitest, and Spotless * fix: add missing test scope for awaitility --- pom.xml | 48 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 6b7ade11..bb03ce76 100644 --- a/pom.xml +++ b/pom.xml @@ -9,12 +9,13 @@ 1.0-SNAPSHOT - 23 + 25 UTF-8 6.0.2 3.27.7 5.21.0 + org.junit.jupiter @@ -28,12 +29,24 @@ ${assertj.core.version} test + + org.mockito + mockito-core + ${mockito.version} + test + org.mockito mockito-junit-jupiter ${mockito.version} test + + org.awaitility + awaitility + 4.3.0 + test + @@ -118,6 +131,39 @@ + + org.pitest + pitest-maven + 1.22.0 + + + org.pitest + pitest-junit5-plugin + 1.2.2 + + + + + com.diffplug.spotless + spotless-maven-plugin + 3.2.1 + + + + + + + + + verify + + + check + + + + + From bf4d977cb9a4b722cfc7f56f168a6afb0b78d1b4 Mon Sep 17 00:00:00 2001 From: Kathrin Trinh Date: Tue, 10 Feb 2026 15:14:19 +0100 Subject: [PATCH 03/54] =?UTF-8?q?Initial=20commit=20f=C3=B6r=20tcp-server?= =?UTF-8?q?=20(#17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/example/App.java | 4 ++-- src/main/java/org/example/TcpServer.java | 28 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/example/TcpServer.java diff --git a/src/main/java/org/example/App.java b/src/main/java/org/example/App.java index 165e5cd5..66c9af10 100644 --- a/src/main/java/org/example/App.java +++ b/src/main/java/org/example/App.java @@ -2,6 +2,6 @@ public class App { public static void main(String[] args) { - System.out.println("Hello There!"); + new TcpServer(8080).start(); } -} +} \ No newline at end of file diff --git a/src/main/java/org/example/TcpServer.java b/src/main/java/org/example/TcpServer.java new file mode 100644 index 00000000..73ba0f27 --- /dev/null +++ b/src/main/java/org/example/TcpServer.java @@ -0,0 +1,28 @@ +package org.example; + +import java.io.IOException; +import java.net.ServerSocket; +import java.net.Socket; + +public class TcpServer { + + private final int port; + + public TcpServer(int port) { + this.port = port; + } + + public void start() { + System.out.println("Starting TCP server on port " + port); + + try (ServerSocket serverSocket = new ServerSocket(port)) { + while (true) { + Socket clientSocket = serverSocket.accept(); // block + System.out.println("Client connected: " + clientSocket.getRemoteSocketAddress()); + clientSocket.close(); + } + } catch (IOException e) { + throw new RuntimeException("Failed to start TCP server", e); + } + } +} \ No newline at end of file From 148411e2d8dda4936f543fbd7a9e74efff668026 Mon Sep 17 00:00:00 2001 From: Elias Lennheimer <47382348+Xeutos@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:33:02 +0100 Subject: [PATCH 04/54] Issue #12 (#21) * release.yml that builds and publishes Docker image to GitHub packages on release. * Fixed unverified commit stopping pull request from being merged --- .github/workflows/release.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..b86973f7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,35 @@ +name: Publish Docker Image to Github Packages on Release +on: + release: + types: + - published +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v6.0.2 + - uses: docker/setup-qemu-action@v3.7.0 + - uses: docker/setup-buildx-action@v3.12.0 + - name: Log in to GHCR + uses: docker/login-action@v3.7.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5.10.0 + with: + images: ghcr.io/ithsjava25/webserver + - name: Build and push + uses: docker/build-push-action@v6.18.0 + with: + context: . + push: true + platforms: linux/amd64, linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + From 9ac7b57f283f45290547bf776c9f2a28a1f0c92b Mon Sep 17 00:00:00 2001 From: Elias Lennheimer <47382348+Xeutos@users.noreply.github.com> Date: Wed, 11 Feb 2026 08:55:53 +0100 Subject: [PATCH 05/54] Feature/docker image builder issue#11 (#25) * Dockerfile that builds an image in a docker container then runs in another docker container Current implementation uses Temporary App.class reference before relevant file is created to start server. * Fixed unverified commit * Set up non-root user and updated Dockerfile to use user. Fixed file path to use /app/ instead of /app/org/example to prevent unnessary nesting of packages. --- Dockerfile | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..d8b69012 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM maven:3-eclipse-temurin-25-alpine AS build +WORKDIR /build +COPY src/ src/ +COPY pom.xml pom.xml +RUN mvn compile + +FROM eclipse-temurin:25-jre-alpine +RUN addgroup -S appgroup && adduser -S appuser -G appgroup +COPY --from=build /build/target/classes/ /app/ +ENTRYPOINT ["java", "-classpath", "/app", "org.example.App"] +USER appuser From 875d1efeb26a36aa6b6d723f5dad99a7f25f7732 Mon Sep 17 00:00:00 2001 From: Felix Eriksson Date: Wed, 11 Feb 2026 13:01:37 +0100 Subject: [PATCH 06/54] Feature/http parse headers (#18) * added parser class for headers * refactoring * refactoring * refactoring, added getHeadersMap * refactoring * refactoring code, adding debug boolean * added unit tests for HttpParser.java class * refactoring and all green tests for class * refactoring the code * refactoring the bufferedReader * refactoring with coderabbit feedback for header put to merge * refactoring with coderabbit feedback for header put to merge, changed headersmap * refactoring from reviewer input * refactoring for code rabbit comments --- .../org/example/httpparser/HttpParser.java | 49 +++++++++++++++++ .../example/httpparser/HttpParserTest.java | 52 +++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 src/main/java/org/example/httpparser/HttpParser.java create mode 100644 src/test/java/org/example/httpparser/HttpParserTest.java diff --git a/src/main/java/org/example/httpparser/HttpParser.java b/src/main/java/org/example/httpparser/HttpParser.java new file mode 100644 index 00000000..0c27d34a --- /dev/null +++ b/src/main/java/org/example/httpparser/HttpParser.java @@ -0,0 +1,49 @@ +package org.example.httpparser; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +public class HttpParser { + private boolean debug = false; + private Map headersMap = new HashMap<>(); + private BufferedReader reader; + public void parseHttp(InputStream in) throws IOException { + if (this.reader == null) { + this.reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8)); + } + + String headerLine; + + while ((headerLine = reader.readLine()) != null) { + if (headerLine.isEmpty()) { + break; + } + + int valueSeparator = headerLine.indexOf(':'); + if (valueSeparator <= 0) { + continue; + } + + String key = headerLine.substring(0, valueSeparator).trim(); + String value = headerLine.substring(valueSeparator + 1).trim(); + + headersMap.merge(key, value, (existing, incoming) -> existing +", " + incoming); + } + if (debug) { + System.out.println("Host: " + headersMap.get("Host")); + for (String key : headersMap.keySet()) { + System.out.println(key + ": " + headersMap.get(key)); + } + } + } + + public Map getHeadersMap() { + return headersMap; + } + +} diff --git a/src/test/java/org/example/httpparser/HttpParserTest.java b/src/test/java/org/example/httpparser/HttpParserTest.java new file mode 100644 index 00000000..91a5af38 --- /dev/null +++ b/src/test/java/org/example/httpparser/HttpParserTest.java @@ -0,0 +1,52 @@ +package org.example.httpparser; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +class HttpParserTest { + private HttpParser httpParser = new HttpParser(); + + @Test + void TestHttpParserForHeaders() throws IOException { + String testInput = "GET /index.html HTTP/1.1\r\nHost: localhost\r\nContent-Type: text/plain\r\nUser-Agent: JUnit5\r\n\r\n"; + InputStream in = new ByteArrayInputStream(testInput.getBytes(StandardCharsets.UTF_8)); + + httpParser.parseHttp(in); + + assertNotNull(httpParser.getHeadersMap()); + assertThat(httpParser.getHeadersMap().size()).isEqualTo(3); + assertThat(httpParser.getHeadersMap().get("Host")).contains("localhost"); + assertThat(httpParser.getHeadersMap().get("Content-Type")).contains("text/plain"); + assertThat(httpParser.getHeadersMap().get("User-Agent")).contains("JUnit5"); + } + + @Test + void testParseHttp_EmptyInput() throws IOException { + InputStream in = new ByteArrayInputStream("".getBytes()); + httpParser.parseHttp(in); + + assertTrue(httpParser.getHeadersMap().isEmpty()); + } + + @Test + void testParseHttp_InvalidHeaderLine() throws IOException { + String rawInput = "Host: localhost\r\n InvalidLineWithoutColon\r\n Accept: */*\r\n\r\n"; + + InputStream in = new ByteArrayInputStream(rawInput.getBytes(StandardCharsets.UTF_8)); + httpParser.parseHttp(in); + + assertEquals(2, httpParser.getHeadersMap().size()); + assertEquals("localhost", httpParser.getHeadersMap().get("Host")); + assertEquals("*/*", httpParser.getHeadersMap().get("Accept")); + } + + +} From 9289c7d5e113976278b870c8e0dfc088b09c7109 Mon Sep 17 00:00:00 2001 From: JohanHiths Date: Wed, 11 Feb 2026 13:28:51 +0100 Subject: [PATCH 07/54] Feature/http response builder (#24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add HttpResponseBuilder and its tests Signed-off-by: JohanHiths * Implement minimal HTTP response builder Signed-off-by: JohanHiths * update coderabbit * more coderabbit fixes Signed-off-by: JohanHiths * Bytte från httptest till http efter rekommendationer från Xeutos * code rabbit fix * fixed more code rabbit problems --------- Signed-off-by: JohanHiths --- .../org/example/http/HttpResponseBuilder.java | 50 +++++++++++++++++++ .../example/http/HttpResponseBuilderTest.java | 45 +++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 src/main/java/org/example/http/HttpResponseBuilder.java create mode 100644 src/test/java/org/example/http/HttpResponseBuilderTest.java diff --git a/src/main/java/org/example/http/HttpResponseBuilder.java b/src/main/java/org/example/http/HttpResponseBuilder.java new file mode 100644 index 00000000..eb1f77ea --- /dev/null +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -0,0 +1,50 @@ +package org.example.http; +// +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +public class HttpResponseBuilder { + + private static final String PROTOCOL = "HTTP/1.1"; + private int statusCode = 200; + private String body = ""; + private Map headers = new LinkedHashMap<>(); + + private static final String CRLF = "\r\n"; + + + public void setStatusCode(int statusCode) { + this.statusCode = statusCode; + } + + public void setBody(String body) { + this.body = body != null ? body : ""; + } + public void setHeaders(Map headers) { + this.headers = new LinkedHashMap<>(headers); + } + + private static final Map REASON_PHRASES = Map.of( + 200, "OK", + 201, "Created", + 400, "Bad Request", + 404, "Not Found", + 500, "Internal Server Error"); + + public String build(){ + StringBuilder sb = new StringBuilder(); + String reason = REASON_PHRASES.getOrDefault(statusCode, "OK"); + sb.append(PROTOCOL).append(" ").append(statusCode).append(" ").append(reason).append(CRLF); + headers.forEach((k,v) -> sb.append(k).append(": ").append(v).append(CRLF)); + sb.append("Content-Length: ") + .append(body.getBytes(StandardCharsets.UTF_8).length); + sb.append(CRLF); + sb.append("Connection: close").append(CRLF); + sb.append(CRLF); + sb.append(body); + return sb.toString(); + + } + +} diff --git a/src/test/java/org/example/http/HttpResponseBuilderTest.java b/src/test/java/org/example/http/HttpResponseBuilderTest.java new file mode 100644 index 00000000..8b80a7f8 --- /dev/null +++ b/src/test/java/org/example/http/HttpResponseBuilderTest.java @@ -0,0 +1,45 @@ +package org.example.http; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + + class HttpResponseBuilderTest { + + /** + * Verifies that build produces a valid HTTP response string! + * Status line is present + * Content-Length header is generated + * The response body is included + */ + + @Test + void build_returnsValidHttpResponse() { + + HttpResponseBuilder builder = new HttpResponseBuilder(); + + builder.setBody("Hello"); + + String result = builder.build(); + + assertThat(result).contains("HTTP/1.1 200 OK"); + assertThat(result).contains("Content-Length: 5"); + assertThat(result).contains("\r\n\r\n"); + assertThat(result).contains("Hello"); + + } + + // Verifies that Content-Length is calculated using UTF-8 byte length! + // + @Test + void build_handlesUtf8ContentLength() { + HttpResponseBuilder builder = new HttpResponseBuilder(); + + builder.setBody("å"); + + String result = builder.build(); + + assertThat(result).contains("Content-Length: 2"); + + } +} From 6bdb1ef3376b5102baf66b07350f55945c7b8b0b Mon Sep 17 00:00:00 2001 From: Felix Eriksson Date: Wed, 11 Feb 2026 14:31:37 +0100 Subject: [PATCH 08/54] Feature/http parse request line (#20) * added parser class for headers * refactoring * refactoring * refactoring, added getHeadersMap * refactoring * adding class for parsing requestLine (method, path, version). Has getters/setters for the three. * refactoring code, private setters * added tests for throwing error * refactoring the bufferedReader * refactoring, adding check for requestLineArray.length() is less or equals to two with added unit test * refactoring, adding check for requestLineArray.length() is less or equals to two with added unit test * refactoring, adding test for null value of input stream * refactoring from coderabbit input * refactoring test class. * refactoring, adding debug boolean flag for logger lines * refactoring and testing buffered reader stream * refactoring, changed class HttpParser.java to extend HttpParseRequestLine.java and making them share the same buffered reader. Added method to set the reader and then methods to call the HttpParser * refactoring, changed class HttpParser.java to extend HttpParseRequestLine.java and making them share the same buffered reader. Added method to set the reader and then methods to call the HttpParser --- .../httpparser/HttpParseRequestLine.java | 66 +++++++++++++++++ .../org/example/httpparser/HttpParser.java | 15 +++- .../httpparser/HttpParseRequestLineTest.java | 73 +++++++++++++++++++ .../example/httpparser/HttpParserTest.java | 13 ++-- 4 files changed, 159 insertions(+), 8 deletions(-) create mode 100644 src/main/java/org/example/httpparser/HttpParseRequestLine.java create mode 100644 src/test/java/org/example/httpparser/HttpParseRequestLineTest.java diff --git a/src/main/java/org/example/httpparser/HttpParseRequestLine.java b/src/main/java/org/example/httpparser/HttpParseRequestLine.java new file mode 100644 index 00000000..cb97f838 --- /dev/null +++ b/src/main/java/org/example/httpparser/HttpParseRequestLine.java @@ -0,0 +1,66 @@ +package org.example.httpparser; + +import java.io.BufferedReader; +import java.io.IOException; +import java.util.logging.Logger; + +abstract class HttpParseRequestLine { + private String method; + private String uri; + private String version; + private boolean debug = false; + private static final Logger logger = Logger.getLogger(HttpParseRequestLine.class.getName()); + + public void parseHttpRequest(BufferedReader br) throws IOException { + BufferedReader reader = br; + String requestLine = reader.readLine(); + if (requestLine == null || requestLine.isEmpty()) { + throw new IOException("HTTP Request Line is Null or Empty"); + } + + String[] requestLineArray = requestLine.trim().split(" ", 3); + + if (requestLineArray.length <= 2) { + throw new IOException("HTTP Request Line is not long enough"); + } else { + setMethod(requestLineArray[0]); + if (!getMethod().matches("^[A-Z]+$")){ + throw new IOException("Invalid HTTP method"); + } + setUri(requestLineArray[1]); + setVersion(requestLineArray[2]); + } + + if(debug) { + logger.info(getMethod()); + logger.info(getUri()); + logger.info(getVersion()); + } + } + + + + public String getMethod() { + return method; + } + + private void setMethod(String method) { + this.method = method; + } + + public String getUri() { + return uri; + } + + private void setUri(String uri) { + this.uri = uri; + } + + public String getVersion() { + return version; + } + + private void setVersion(String version) { + this.version = version; + } +} diff --git a/src/main/java/org/example/httpparser/HttpParser.java b/src/main/java/org/example/httpparser/HttpParser.java index 0c27d34a..440d2cff 100644 --- a/src/main/java/org/example/httpparser/HttpParser.java +++ b/src/main/java/org/example/httpparser/HttpParser.java @@ -8,15 +8,18 @@ import java.util.HashMap; import java.util.Map; -public class HttpParser { +public class HttpParser extends HttpParseRequestLine { private boolean debug = false; private Map headersMap = new HashMap<>(); private BufferedReader reader; - public void parseHttp(InputStream in) throws IOException { + + protected void setReader(InputStream in) { if (this.reader == null) { this.reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8)); } + } + public void parseHttp() throws IOException { String headerLine; while ((headerLine = reader.readLine()) != null) { @@ -42,8 +45,16 @@ public void parseHttp(InputStream in) throws IOException { } } + + public void parseRequest() throws IOException { + parseHttpRequest(reader); + } + public Map getHeadersMap() { return headersMap; } + public BufferedReader getHeaderReader() { + return reader; + } } diff --git a/src/test/java/org/example/httpparser/HttpParseRequestLineTest.java b/src/test/java/org/example/httpparser/HttpParseRequestLineTest.java new file mode 100644 index 00000000..8ff289f3 --- /dev/null +++ b/src/test/java/org/example/httpparser/HttpParseRequestLineTest.java @@ -0,0 +1,73 @@ +package org.example.httpparser; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.*; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; + +class HttpParseRequestLineTest { + private HttpParser httpParseRequestLine; + + @BeforeEach + void setUp() { + httpParseRequestLine = new HttpParser(); + } + + @Test + void testParserWithTestRequestLine() throws IOException { + String testString = "GET / HTTP/1.1"; + + InputStream in = new ByteArrayInputStream(testString.getBytes()); + httpParseRequestLine.setReader(in); + httpParseRequestLine.parseRequest(); + + assertThat(httpParseRequestLine.getMethod()).isEqualTo("GET"); + assertThat(httpParseRequestLine.getUri()).isEqualTo("/"); + assertThat(httpParseRequestLine.getVersion()).isEqualTo("HTTP/1.1"); + } + + @Test + void testParserThrowErrorWhenNull(){ + assertThatThrownBy(() -> httpParseRequestLine.setReader(null)).isInstanceOf(NullPointerException.class); + } + + + @Test + void testParserThrowErrorWhenEmpty(){ + InputStream in = new ByteArrayInputStream("".getBytes()); + httpParseRequestLine.setReader(in); + Exception exception = assertThrows( + IOException.class, () -> httpParseRequestLine.parseRequest() + ); + + assertThat(exception.getMessage()).isEqualTo("HTTP Request Line is Null or Empty"); + } + + @Test + void testParserThrowErrorWhenMethodIsInvalid(){ + String testString = "get / HTTP/1.1"; + InputStream in = new ByteArrayInputStream(testString.getBytes()); + httpParseRequestLine.setReader(in); + Exception exception = assertThrows( + IOException.class, () -> httpParseRequestLine.parseRequest() + ); + assertThat(exception.getMessage()).isEqualTo("Invalid HTTP method"); + } + + @Test + void testParserThrowErrorWhenArrayLengthLessOrEqualsTwo(){ + String testString = "GET / "; + InputStream in = new ByteArrayInputStream(testString.getBytes()); + httpParseRequestLine.setReader(in); + Exception exception = assertThrows( + IOException.class, () -> httpParseRequestLine.parseRequest() + ); + + assertThat(exception.getMessage()).isEqualTo("HTTP Request Line is not long enough"); + } + +} diff --git a/src/test/java/org/example/httpparser/HttpParserTest.java b/src/test/java/org/example/httpparser/HttpParserTest.java index 91a5af38..a09b7e22 100644 --- a/src/test/java/org/example/httpparser/HttpParserTest.java +++ b/src/test/java/org/example/httpparser/HttpParserTest.java @@ -2,9 +2,7 @@ import org.junit.jupiter.api.Test; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.InputStream; +import java.io.*; import java.nio.charset.StandardCharsets; @@ -19,7 +17,8 @@ void TestHttpParserForHeaders() throws IOException { String testInput = "GET /index.html HTTP/1.1\r\nHost: localhost\r\nContent-Type: text/plain\r\nUser-Agent: JUnit5\r\n\r\n"; InputStream in = new ByteArrayInputStream(testInput.getBytes(StandardCharsets.UTF_8)); - httpParser.parseHttp(in); + httpParser.setReader(in); + httpParser.parseHttp(); assertNotNull(httpParser.getHeadersMap()); assertThat(httpParser.getHeadersMap().size()).isEqualTo(3); @@ -31,7 +30,8 @@ void TestHttpParserForHeaders() throws IOException { @Test void testParseHttp_EmptyInput() throws IOException { InputStream in = new ByteArrayInputStream("".getBytes()); - httpParser.parseHttp(in); + httpParser.setReader(in); + httpParser.parseHttp(); assertTrue(httpParser.getHeadersMap().isEmpty()); } @@ -41,7 +41,8 @@ void testParseHttp_InvalidHeaderLine() throws IOException { String rawInput = "Host: localhost\r\n InvalidLineWithoutColon\r\n Accept: */*\r\n\r\n"; InputStream in = new ByteArrayInputStream(rawInput.getBytes(StandardCharsets.UTF_8)); - httpParser.parseHttp(in); + httpParser.setReader(in); + httpParser.parseHttp(); assertEquals(2, httpParser.getHeadersMap().size()); assertEquals("localhost", httpParser.getHeadersMap().get("Host")); From 781e34a5de2f4737a8a0234f1de4b622f0700579 Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Wed, 11 Feb 2026 15:24:26 +0100 Subject: [PATCH 09/54] Add Bucket4j dependency in pom file (#40) * Add Bucket4j dependency in pom file * Extract Bucket4j version into a property and update version in dependency for consistency --- pom.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pom.xml b/pom.xml index bb03ce76..06adea9e 100644 --- a/pom.xml +++ b/pom.xml @@ -14,9 +14,16 @@ 6.0.2 3.27.7 5.21.0 + 8.14.0 + + + com.bucket4j + bucket4j_jdk17-core + ${bucket4j.version} + org.junit.jupiter junit-jupiter From 511b5eef9de4c3b49af24ecb79272142d3dd8a25 Mon Sep 17 00:00:00 2001 From: Caroline Nordbrandt Date: Thu, 12 Feb 2026 10:57:11 +0100 Subject: [PATCH 10/54] Add support for serving static files (#42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add support for serving static files - Introduced `StaticFileHandler` to serve static files from the `www` directory. - Enhanced `HttpResponseBuilder` to support byte array bodies. - Made `setReader` method public in `HttpParser` to improve flexibility. - Code tested via Insomnia by coding a temporary method for multithreads in TcpServer.java, this is now removed before future implementations of ConnectionHandler Co-authored-by: Daniel Fahlén xsz300@gmail.com * Fix typo in `HttpResponseBuilder` (contentLength) and refine body handling logic Co-authored-by: Daniel Fahlén xsz300@gmail.com * Add basic HTML structure to `index.html` in `www` directory * Make `handleGetRequest` method private in `StaticFileHandler` to encapsulate functionality --- .../java/org/example/StaticFileHandler.java | 34 +++++++++++++++++++ .../org/example/http/HttpResponseBuilder.java | 18 ++++++++-- .../org/example/httpparser/HttpParser.java | 2 +- www/index.html | 12 +++++++ 4 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 src/main/java/org/example/StaticFileHandler.java create mode 100644 www/index.html diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java new file mode 100644 index 00000000..56efc1ed --- /dev/null +++ b/src/main/java/org/example/StaticFileHandler.java @@ -0,0 +1,34 @@ +package org.example; + +import org.example.http.HttpResponseBuilder; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.io.PrintWriter; +import java.nio.file.Files; +import java.util.Map; + +public class StaticFileHandler { + private static final String WEB_ROOT = "www"; + private byte[] fileBytes; + + public StaticFileHandler(){} + + private void handleGetRequest(String uri) throws IOException { + + File file = new File(WEB_ROOT, uri); + fileBytes = Files.readAllBytes(file.toPath()); + } + + public void sendGetRequest(OutputStream outputStream, String uri) throws IOException{ + handleGetRequest(uri); + HttpResponseBuilder response = new HttpResponseBuilder(); + response.setHeaders(Map.of("Content-Type", "text/html; charset=utf-8")); + response.setBody(fileBytes); + PrintWriter writer = new PrintWriter(outputStream, true); + writer.println(response.build()); + + } + +} diff --git a/src/main/java/org/example/http/HttpResponseBuilder.java b/src/main/java/org/example/http/HttpResponseBuilder.java index eb1f77ea..afaafcf9 100644 --- a/src/main/java/org/example/http/HttpResponseBuilder.java +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -9,6 +9,7 @@ public class HttpResponseBuilder { private static final String PROTOCOL = "HTTP/1.1"; private int statusCode = 200; private String body = ""; + private byte[] bytebody; private Map headers = new LinkedHashMap<>(); private static final String CRLF = "\r\n"; @@ -17,10 +18,13 @@ public class HttpResponseBuilder { public void setStatusCode(int statusCode) { this.statusCode = statusCode; } - public void setBody(String body) { this.body = body != null ? body : ""; } + + public void setBody(byte[] body) { + this.bytebody = body; + } public void setHeaders(Map headers) { this.headers = new LinkedHashMap<>(headers); } @@ -31,14 +35,22 @@ public void setHeaders(Map headers) { 400, "Bad Request", 404, "Not Found", 500, "Internal Server Error"); - public String build(){ StringBuilder sb = new StringBuilder(); + int contentLength; + if(body.isEmpty() && bytebody != null){ + contentLength = bytebody.length; + setBody(new String(bytebody, StandardCharsets.UTF_8)); + }else{ + contentLength = body.getBytes(StandardCharsets.UTF_8).length; + } + + String reason = REASON_PHRASES.getOrDefault(statusCode, "OK"); sb.append(PROTOCOL).append(" ").append(statusCode).append(" ").append(reason).append(CRLF); headers.forEach((k,v) -> sb.append(k).append(": ").append(v).append(CRLF)); sb.append("Content-Length: ") - .append(body.getBytes(StandardCharsets.UTF_8).length); + .append(contentLength); sb.append(CRLF); sb.append("Connection: close").append(CRLF); sb.append(CRLF); diff --git a/src/main/java/org/example/httpparser/HttpParser.java b/src/main/java/org/example/httpparser/HttpParser.java index 440d2cff..d08a000b 100644 --- a/src/main/java/org/example/httpparser/HttpParser.java +++ b/src/main/java/org/example/httpparser/HttpParser.java @@ -13,7 +13,7 @@ public class HttpParser extends HttpParseRequestLine { private Map headersMap = new HashMap<>(); private BufferedReader reader; - protected void setReader(InputStream in) { + public void setReader(InputStream in) { if (this.reader == null) { this.reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8)); } diff --git a/www/index.html b/www/index.html new file mode 100644 index 00000000..8293c074 --- /dev/null +++ b/www/index.html @@ -0,0 +1,12 @@ + + + + + Welcome + + +

Website works!

+

Greetings from StaticFileHandler.

+ + + From aaeba6dcad1f725ce89fe5d8b39908b8e1949e63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Mohl=C3=A9n?= Date: Thu, 12 Feb 2026 13:13:01 +0100 Subject: [PATCH 11/54] Updates pom.xml, with jackson-dependencies, for config file (#48) * Updates pom.xml, with jackson-dependencies, for config file * Updates pom.xml, removes jackson-annotations:2.20, because it was apparently unnecessary. --- pom.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pom.xml b/pom.xml index 06adea9e..8a82b235 100644 --- a/pom.xml +++ b/pom.xml @@ -54,6 +54,18 @@ 4.3.0 test
+ + + tools.jackson.core + jackson-databind + 3.0.3 + + + tools.jackson.dataformat + jackson-dataformat-yaml + 3.0.3 + +
From 524f33c821d74407124fe4ab09df3ac199666f33 Mon Sep 17 00:00:00 2001 From: Daniel E Fahlen <229072997+donne41@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:24:09 +0100 Subject: [PATCH 12/54] * Move HTTP handling to a dedicated ConnectionHandler (#50) * * Move HTTP handling logic from TcpServer to a dedicated ConnectionTask class * Implement virtual thread execution for better scalability * Add regex-based URI routing to support clean URLs and default to index.html * Ensure sockets are properly closed Co-authored-by: Caroline Nordbrandt * change thrown exception to runtime instead with appropiet message in tcpServer. --------- Co-authored-by: Caroline Nordbrandt --- .../java/org/example/ConnectionHandler.java | 42 +++++++++++++++++++ .../java/org/example/StaticFileHandler.java | 1 + src/main/java/org/example/TcpServer.java | 12 +++++- 3 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/example/ConnectionHandler.java diff --git a/src/main/java/org/example/ConnectionHandler.java b/src/main/java/org/example/ConnectionHandler.java new file mode 100644 index 00000000..1a0861ff --- /dev/null +++ b/src/main/java/org/example/ConnectionHandler.java @@ -0,0 +1,42 @@ +package org.example; + +import org.example.httpparser.HttpParser; + +import java.io.IOException; +import java.net.Socket; + +public class ConnectionHandler implements AutoCloseable { + + Socket client; + String uri; + + public ConnectionHandler(Socket client) { + this.client = client; + } + + public void runConnectionHandler() throws IOException { + StaticFileHandler sfh = new StaticFileHandler(); + HttpParser parser = new HttpParser(); + parser.setReader(client.getInputStream()); + parser.parseRequest(); + parser.parseHttp(); + resolveTargetFile(parser.getUri()); + sfh.sendGetRequest(client.getOutputStream(), uri); + } + + private void resolveTargetFile(String uri) { + if (uri.matches("/$")) { //matches(/) + this.uri = "index.html"; + } else if (uri.matches("^(?!.*\\.html$).*$")) { + this.uri = uri.concat(".html"); + } else { + this.uri = uri; + } + + } + + @Override + public void close() throws Exception { + client.close(); + } +} diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 56efc1ed..95a0b424 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -19,6 +19,7 @@ private void handleGetRequest(String uri) throws IOException { File file = new File(WEB_ROOT, uri); fileBytes = Files.readAllBytes(file.toPath()); + } public void sendGetRequest(OutputStream outputStream, String uri) throws IOException{ diff --git a/src/main/java/org/example/TcpServer.java b/src/main/java/org/example/TcpServer.java index 73ba0f27..3f96b4d8 100644 --- a/src/main/java/org/example/TcpServer.java +++ b/src/main/java/org/example/TcpServer.java @@ -19,10 +19,18 @@ public void start() { while (true) { Socket clientSocket = serverSocket.accept(); // block System.out.println("Client connected: " + clientSocket.getRemoteSocketAddress()); - clientSocket.close(); + Thread.ofVirtual().start(() -> handleClient(clientSocket)); } } catch (IOException e) { throw new RuntimeException("Failed to start TCP server", e); } } -} \ No newline at end of file + + private void handleClient(Socket client) { + try (ConnectionHandler connectionHandler = new ConnectionHandler(client)) { + connectionHandler.runConnectionHandler(); + } catch (Exception e) { + throw new RuntimeException("Error handling client connection " + e); + } + } +} From 8cc69d8c31686e82d74c71925a34aba8f787c0b6 Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Tue, 17 Feb 2026 14:12:27 +0100 Subject: [PATCH 13/54] Feature/13 implement config file (#22) * Added basic YAML config-file. * Added class ConfigLoader with static classes for encapsulation * Added static metod loadOnce and step one of static method load * Added static method createMapperFor that checks for YAML or JSON-files before creating an ObjectMapper object. * implement ConfigLoader refs #13 * Added AppConfig.java record for config after coderabbit feedback * Updated ConfigLoader to use AppConfig record and jackson 3 * Added tests for ConfigLoader and reset cached method in ConfigLoader to ensure test isolation with static cache * Removed unused dependency. Minor readability tweaks in AppConfig. * Added check for illegal port numbers to withDefaultsApplied-method. * Added test for illegal port numbers. --- .../java/org/example/config/AppConfig.java | 53 +++++++ .../java/org/example/config/ConfigLoader.java | 71 +++++++++ src/main/resources/application.yml | 6 + .../org/example/config/ConfigLoaderTest.java | 141 ++++++++++++++++++ 4 files changed, 271 insertions(+) create mode 100644 src/main/java/org/example/config/AppConfig.java create mode 100644 src/main/java/org/example/config/ConfigLoader.java create mode 100644 src/main/resources/application.yml create mode 100644 src/test/java/org/example/config/ConfigLoaderTest.java diff --git a/src/main/java/org/example/config/AppConfig.java b/src/main/java/org/example/config/AppConfig.java new file mode 100644 index 00000000..00134b20 --- /dev/null +++ b/src/main/java/org/example/config/AppConfig.java @@ -0,0 +1,53 @@ +package org.example.config; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record AppConfig( + @JsonProperty("server") ServerConfig server, + @JsonProperty("logging") LoggingConfig logging +) { + public static AppConfig defaults() { + return new AppConfig(ServerConfig.defaults(), LoggingConfig.defaults()); + } + + public AppConfig withDefaultsApplied() { + ServerConfig serverConfig = (server == null ? ServerConfig.defaults() : server.withDefaultsApplied()); + LoggingConfig loggingConfig = (logging == null ? LoggingConfig.defaults() : logging.withDefaultsApplied()); + return new AppConfig(serverConfig, loggingConfig); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record ServerConfig( + @JsonProperty("port") Integer port, + @JsonProperty("rootDir") String rootDir + ) { + public static ServerConfig defaults() { + return new ServerConfig(8080, "./www"); + } + + public ServerConfig withDefaultsApplied() { + int p = (port == null ? 8080 : port); + if (p < 1 || p > 65535) { + throw new IllegalArgumentException("Invalid port number: " + p + ". Port must be between 1 and 65535"); + } + String rd = (rootDir == null || rootDir.isBlank()) ? "./www" : rootDir; + return new ServerConfig(p, rd); + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record LoggingConfig( + @JsonProperty("level") String level + ) { + public static LoggingConfig defaults() { + return new LoggingConfig("INFO"); + } + + public LoggingConfig withDefaultsApplied() { + String lvl = (level == null || level.isBlank()) ? "INFO" : level; + return new LoggingConfig(lvl); + } + } +} diff --git a/src/main/java/org/example/config/ConfigLoader.java b/src/main/java/org/example/config/ConfigLoader.java new file mode 100644 index 00000000..e69f784d --- /dev/null +++ b/src/main/java/org/example/config/ConfigLoader.java @@ -0,0 +1,71 @@ +package org.example.config; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.dataformat.yaml.YAMLFactory; +import tools.jackson.dataformat.yaml.YAMLMapper; + +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; + +public final class ConfigLoader { + + private static volatile AppConfig cached; + + private ConfigLoader() {} + + public static AppConfig loadOnce(Path configPath) { + if (cached != null) return cached; + + synchronized (ConfigLoader.class) { + if (cached == null){ + cached = load(configPath).withDefaultsApplied(); + } + return cached; + } + } + + public static AppConfig get(){ + if (cached == null){ + throw new IllegalStateException("Config not loaded. call ConfigLoader.loadOnce(...) at startup."); + } + return cached; + + } + + public static AppConfig load(Path configPath) { + Objects.requireNonNull(configPath, "configPath"); + + if (!Files.exists(configPath)) { + return AppConfig.defaults(); + } + + ObjectMapper objectMapper = createMapperFor(configPath); + + try (InputStream stream = Files.newInputStream(configPath)){ + AppConfig config = objectMapper.readValue(stream, AppConfig.class); + return config == null ? AppConfig.defaults() : config; + } catch (Exception e){ + throw new IllegalStateException("failed to read config file " + configPath.toAbsolutePath(), e); + } + } + + private static ObjectMapper createMapperFor(Path configPath) { + String name = configPath.getFileName().toString().toLowerCase(); + + if (name.endsWith(".yml") || name.endsWith(".yaml")) { + return YAMLMapper.builder(new YAMLFactory()).build(); + + } else if (name.endsWith(".json")) { + return JsonMapper.builder().build(); + } else { + return YAMLMapper.builder(new YAMLFactory()).build(); + } + } + + static void resetForTests() { + cached = null; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 00000000..8f2ef3a7 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,6 @@ +server: + port: 8080 + rootDir: ./www + +logging: + level: INFO \ No newline at end of file diff --git a/src/test/java/org/example/config/ConfigLoaderTest.java b/src/test/java/org/example/config/ConfigLoaderTest.java new file mode 100644 index 00000000..b694a7af --- /dev/null +++ b/src/test/java/org/example/config/ConfigLoaderTest.java @@ -0,0 +1,141 @@ +package org.example.config; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.*; + +class ConfigLoaderTest { + + @TempDir + Path tempDir; + + @BeforeEach + void reset() { + ConfigLoader.resetForTests(); + } + + @Test + @DisplayName("Should return default configuration when config file is missing") + void load_returns_defaults_when_file_missing() { + Path missing = tempDir.resolve("missing.yml"); + + AppConfig appConfig = ConfigLoader.load(missing).withDefaultsApplied(); + + assertThat(appConfig.server().port()).isEqualTo(8080); + assertThat(appConfig.server().rootDir()).isEqualTo("./www"); + assertThat(appConfig.logging().level()).isEqualTo("INFO"); + } + + @Test + @DisplayName("Should load values from YAML file when file exists") + void loadOnce_reads_yaml_values() throws Exception { + Path configFile = tempDir.resolve("application.yml"); + Files.writeString(configFile, """ + server: + port: 9090 + rootDir: ./public + logging: + level: DEBUG + """); + + AppConfig appConfig = ConfigLoader.loadOnce(configFile); + + assertThat(appConfig.server().port()).isEqualTo(9090); + assertThat(appConfig.server().rootDir()).isEqualTo("./public"); + assertThat(appConfig.logging().level()).isEqualTo("DEBUG"); + } + + @Test + @DisplayName("Should apply default values when sections or fields are missing") + void defaults_applied_when_sections_or_fields_missing() throws Exception { + Path configFile = tempDir.resolve("application.yml"); + Files.writeString(configFile, """ + server: + port: 1234 + """); + + AppConfig cfg = ConfigLoader.loadOnce(configFile); + + assertThat(cfg.server().port()).isEqualTo(1234); + assertThat(cfg.server().rootDir()).isEqualTo("./www"); // default + assertThat(cfg.logging().level()).isEqualTo("INFO"); // default + } + + @Test + @DisplayName("Should ignore unknown fields in configuration file") + void unknown_fields_are_ignored() throws Exception { + Path configFile = tempDir.resolve("application.yml"); + Files.writeString(configFile, """ + server: + port: 8081 + rootDir: ./www + threads: 8 + logging: + level: INFO + json: true + """); + + AppConfig cfg = ConfigLoader.loadOnce(configFile); + + assertThat(cfg.server().port()).isEqualTo(8081); + assertThat(cfg.server().rootDir()).isEqualTo("./www"); + assertThat(cfg.logging().level()).isEqualTo("INFO"); + } + + @Test + @DisplayName("Should return same instance on repeated loadOnce calls") + void loadOnce_caches_same_instance() throws Exception { + Path configFile = tempDir.resolve("application.yml"); + Files.writeString(configFile, """ + server: + port: 8080 + rootDir: ./www + logging: + level: INFO + """); + + AppConfig a = ConfigLoader.loadOnce(configFile); + AppConfig b = ConfigLoader.loadOnce(configFile); + + assertThat(a).isSameAs(b); + } + + @Test + @DisplayName("Should throw exception when get is called before configuration is loaded") + void get_throws_if_not_loaded() { + assertThatThrownBy(ConfigLoader::get) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("not loaded"); + } + + @Test + @DisplayName("Should fail when configuration file is invalid") + void invalid_yaml_fails() throws Exception { + Path configFile = tempDir.resolve("broken.yml"); + Files.writeString(configFile, "server:\n port 8080\n"); // saknar ':' efter port + + assertThatThrownBy(() -> ConfigLoader.load(configFile)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("failed to read config file"); + } + + @Test + @DisplayName("Should fail when port is out of range") + void invalid_port_should_Throw_Exception () throws Exception { + Path configFile = tempDir.resolve("application.yml"); + + Files.writeString(configFile, """ + server: + port: 70000 + """); + + assertThatThrownBy(() -> ConfigLoader.loadOnce(configFile)) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Invalid port number"); + } +} From c0e3de64d6ddf41604dd12bca529b891f63a6eef Mon Sep 17 00:00:00 2001 From: Caroline Nordbrandt Date: Tue, 17 Feb 2026 14:23:09 +0100 Subject: [PATCH 14/54] Enhancement/404 page not found (#53) * Add 404 error handling to `StaticFileHandler` with fallback page * Add test coverage for `StaticFileHandler` and improve constructor flexibility - Introduced a new constructor in `StaticFileHandler` to support custom web root paths, improving testability. - Added `StaticFileHandlerTest` to validate static file serving and error handling logic. * Add test for 404 handling in `StaticFileHandler` - Added a test to ensure nonexistent files return a 404 response. - Utilized a temporary directory and fallback file for improved test isolation. * Verify `Content-Type` header in `StaticFileHandlerTest` after running Pittest, mutations survived where setHeaders could be removed without test failure. * Remove unused `.btn` styles from `pageNotFound.html` * Improve 404 handling in `StaticFileHandler`: add fallback to plain text response if `pageNotFound.html` is missing, and fix typo in `pageNotFound.html`, after comments from CodeRabbit. --- .../java/org/example/StaticFileHandler.java | 29 ++++++- .../org/example/StaticFileHandlerTest.java | 79 +++++++++++++++++++ www/pageNotFound.html | 55 +++++++++++++ 3 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 src/test/java/org/example/StaticFileHandlerTest.java create mode 100644 www/pageNotFound.html diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 95a0b424..bf9e79bc 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -10,21 +10,42 @@ import java.util.Map; public class StaticFileHandler { - private static final String WEB_ROOT = "www"; + private final String WEB_ROOT; private byte[] fileBytes; + private int statusCode; - public StaticFileHandler(){} + //Constructor for production + public StaticFileHandler() { + WEB_ROOT = "www"; + } + + //Constructor for tests, otherwise the www folder won't be seen + public StaticFileHandler(String webRoot){ + WEB_ROOT = webRoot; + } private void handleGetRequest(String uri) throws IOException { File file = new File(WEB_ROOT, uri); - fileBytes = Files.readAllBytes(file.toPath()); - + if(file.exists()) { + fileBytes = Files.readAllBytes(file.toPath()); + statusCode = 200; + } else { + File errorFile = new File(WEB_ROOT, "pageNotFound.html"); + if(errorFile.exists()) { + fileBytes = Files.readAllBytes(errorFile.toPath()); + } else { + fileBytes = "404 Not Found".getBytes(); + } + statusCode = 404; + } } public void sendGetRequest(OutputStream outputStream, String uri) throws IOException{ handleGetRequest(uri); + HttpResponseBuilder response = new HttpResponseBuilder(); + response.setStatusCode(statusCode); response.setHeaders(Map.of("Content-Type", "text/html; charset=utf-8")); response.setBody(fileBytes); PrintWriter writer = new PrintWriter(outputStream, true); diff --git a/src/test/java/org/example/StaticFileHandlerTest.java b/src/test/java/org/example/StaticFileHandlerTest.java new file mode 100644 index 00000000..871fdb46 --- /dev/null +++ b/src/test/java/org/example/StaticFileHandlerTest.java @@ -0,0 +1,79 @@ +package org.example; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit test class for verifying the behavior of the StaticFileHandler class. + * + * This test class ensures that StaticFileHandler correctly handles GET requests + * for static files, including both cases where the requested file exists and + * where it does not. Temporary directories and files are utilized in tests to + * ensure no actual file system dependencies during test execution. + * + * Key functional aspects being tested include: + * - Correct response status code and content for an existing file. + * - Correct response status code and fallback behavior for a missing file. + */ +class StaticFileHandlerTest { + + //Junit creates a temporary folder which can be filled with temporary files that gets removed after tests + @TempDir + Path tempDir; + + + @Test + void test_file_that_exists_should_return_200() throws IOException { + //Arrange + Path testFile = tempDir.resolve("test.html"); // Defines the path in the temp directory + Files.writeString(testFile, "Hello Test"); // Creates a text in that file + + //Using the new constructor in StaticFileHandler to reroute so the tests uses the temporary folder instead of the hardcoded www + StaticFileHandler staticFileHandler = new StaticFileHandler(tempDir.toString()); + + //Using ByteArrayOutputStream instead of Outputstream during tests to capture the servers response in memory, fake stream + ByteArrayOutputStream fakeOutput = new ByteArrayOutputStream(); + + //Act + staticFileHandler.sendGetRequest(fakeOutput, "test.html"); //Get test.html and write the answer to fakeOutput + + //Assert + String response = fakeOutput.toString();//Converts the captured byte stream into a String for verification + + assertTrue(response.contains("HTTP/1.1 200 OK")); // Assert the status + assertTrue(response.contains("Hello Test")); //Assert the content in the file + + assertTrue(response.contains("Content-Type: text/html; charset=utf-8")); // Verify the correct Content-type header + + } + + @Test + void test_file_that_does_not_exists_should_return_404() throws IOException { + //Arrange + // Pre-create the mandatory error page in the temp directory to prevent NoSuchFileException + Path testFile = tempDir.resolve("pageNotFound.html"); + Files.writeString(testFile, "Fallback page"); + + //Using the new constructor in StaticFileHandler to reroute so the tests uses the temporary folder instead of the hardcoded www + StaticFileHandler staticFileHandler = new StaticFileHandler(tempDir.toString()); + + //Using ByteArrayOutputStream instead of Outputstream during tests to capture the servers response in memory, fake stream + ByteArrayOutputStream fakeOutput = new ByteArrayOutputStream(); + + //Act + staticFileHandler.sendGetRequest(fakeOutput, "notExistingFile.html"); // Request a file that clearly doesn't exist to trigger the 404 logic + + //Assert + String response = fakeOutput.toString();//Converts the captured byte stream into a String for verification + + assertTrue(response.contains("HTTP/1.1 404 Not Found")); // Assert the status + + } + +} diff --git a/www/pageNotFound.html b/www/pageNotFound.html new file mode 100644 index 00000000..b02f57aa --- /dev/null +++ b/www/pageNotFound.html @@ -0,0 +1,55 @@ + + + + + + 404 - Page Not Found + + + +
+
🚀
+

404

+

Woopsie daisy! Page went to the moon.

+

We cannot find the page you were looking for. Might have been moved, removed, or maybe it was kind of a useless link to begin with.

+
+ + \ No newline at end of file From bcb828c72571870ee642647ea8a8b76c5e59443c Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Wed, 18 Feb 2026 10:41:30 +0100 Subject: [PATCH 15/54] Feature/issue59 run configloader (#61) * Implements configuration loading and ensures that ConfigLoader is invoked during application startup (App.main). * Minor formating. --- src/main/java/org/example/App.java | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/example/App.java b/src/main/java/org/example/App.java index 66c9af10..408a1942 100644 --- a/src/main/java/org/example/App.java +++ b/src/main/java/org/example/App.java @@ -1,7 +1,16 @@ package org.example; +import org.example.config.AppConfig; +import org.example.config.ConfigLoader; + +import java.nio.file.Path; + public class App { public static void main(String[] args) { - new TcpServer(8080).start(); + Path configPath = Path.of("src/main/resources/application.yml"); + + AppConfig appConfig = ConfigLoader.loadOnce(configPath); + int port = appConfig.server().port(); + new TcpServer(port).start(); } -} \ No newline at end of file +} From 945d32b8c4fd2456f33ed2510d61ee34932aaf53 Mon Sep 17 00:00:00 2001 From: Eric Phu Date: Wed, 18 Feb 2026 17:25:37 +0100 Subject: [PATCH 16/54] 23 define and create filter interface (#46) * initial commit, added interfaces Filter and FilterChain * Added HttpRequest class, groups together all information about a request that the server needs and easier to handle by future filters * added interfaces Filter and FilterChain with Java Servlet Filter architecture. * added FilterChainImpl * Corrected imports from JDKS HttpRequest, to projects HttpRequest class * Changed, params for FilterChain * Updated HttpRequest with attributes, * Revert "Updated HttpRequest with attributes," This reverts commit 0fd490e83e335ceaef61b2acb22aa61d9e810f91. --- src/main/java/org/example/filter/Filter.java | 14 +++++++ .../java/org/example/filter/FilterChain.java | 10 +++++ .../org/example/filter/FilterChainImpl.java | 33 +++++++++++++++ .../org/example/httpparser/HttpRequest.java | 41 +++++++++++++++++++ 4 files changed, 98 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/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 3128ac77b717c901545521e6a83e1412f7a34a74 Mon Sep 17 00:00:00 2001 From: Younes Ahmad Date: Wed, 18 Feb 2026 23:37:02 +0100 Subject: [PATCH 17/54] Feature/mime type detection #8 (#47) * Added MIME detector class and test class * Added logic for Mime detector class * Added Unit tests * Added logic in HttpResponseBuilder and tests to try it out * Solves duplicate header issue * Removed a file for another issue * Changed hashmap to Treemap per code rabbits suggestion * Corrected logic error that was failing tests as per P2P review * Added more reason phrases and unit testing, also applied code rabbits suggestions! * Added changes to Responsebuilder to make merging easier * Changed back to earlier commit to hande byte corruption new PR * Added StaticFileHandler from main * Added staticFileHandler with binary-safe writing * Fix: Normalize Content-Type charset to uppercase UTF-8 Changed 'charset=utf-8' to 'charset=UTF-8' in StaticFileHandlerTest to match MimeTypeDetector output and align with HTTP RFC standard. Uppercase UTF-8 is the correct format per RFC 2616/7231. --- .../java/org/example/StaticFileHandler.java | 34 +- .../org/example/http/HttpResponseBuilder.java | 111 +++-- .../org/example/http/MimeTypeDetector.java | 77 +++ .../org/example/StaticFileHandlerTest.java | 2 +- .../example/http/HttpResponseBuilderTest.java | 453 +++++++++++++++++- .../example/http/MimeTypeDetectorTest.java | 165 +++++++ 6 files changed, 779 insertions(+), 63 deletions(-) create mode 100644 src/main/java/org/example/http/MimeTypeDetector.java create mode 100644 src/test/java/org/example/http/MimeTypeDetectorTest.java diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index bf9e79bc..58f13379 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -5,34 +5,40 @@ import java.io.File; import java.io.IOException; import java.io.OutputStream; -import java.io.PrintWriter; import java.nio.file.Files; -import java.util.Map; public class StaticFileHandler { private final String WEB_ROOT; private byte[] fileBytes; private int statusCode; - //Constructor for production + // Constructor for production public StaticFileHandler() { WEB_ROOT = "www"; } - //Constructor for tests, otherwise the www folder won't be seen - public StaticFileHandler(String webRoot){ + // Constructor for tests, otherwise the www folder won't be seen + public StaticFileHandler(String webRoot) { WEB_ROOT = webRoot; } private void handleGetRequest(String uri) throws IOException { + // Security: Prevent path traversal attacks (e.g. GET /../../etc/passwd) + File root = new File(WEB_ROOT).getCanonicalFile(); + File file = new File(root, uri).getCanonicalFile(); + + if (!file.toPath().startsWith(root.toPath())) { + fileBytes = "403 Forbidden".getBytes(); + statusCode = 403; + return; + } - File file = new File(WEB_ROOT, uri); - if(file.exists()) { + if (file.exists()) { fileBytes = Files.readAllBytes(file.toPath()); statusCode = 200; } else { File errorFile = new File(WEB_ROOT, "pageNotFound.html"); - if(errorFile.exists()) { + if (errorFile.exists()) { fileBytes = Files.readAllBytes(errorFile.toPath()); } else { fileBytes = "404 Not Found".getBytes(); @@ -41,16 +47,16 @@ private void handleGetRequest(String uri) throws IOException { } } - public void sendGetRequest(OutputStream outputStream, String uri) throws IOException{ + public void sendGetRequest(OutputStream outputStream, String uri) throws IOException { handleGetRequest(uri); HttpResponseBuilder response = new HttpResponseBuilder(); response.setStatusCode(statusCode); - response.setHeaders(Map.of("Content-Type", "text/html; charset=utf-8")); + // Use MimeTypeDetector instead of hardcoded text/html + response.setContentTypeFromFilename(uri); response.setBody(fileBytes); - PrintWriter writer = new PrintWriter(outputStream, true); - writer.println(response.build()); + outputStream.write(response.build()); + outputStream.flush(); } - -} +} \ No newline at end of file diff --git a/src/main/java/org/example/http/HttpResponseBuilder.java b/src/main/java/org/example/http/HttpResponseBuilder.java index afaafcf9..db6a6958 100644 --- a/src/main/java/org/example/http/HttpResponseBuilder.java +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -1,8 +1,8 @@ package org.example.http; -// + import java.nio.charset.StandardCharsets; -import java.util.LinkedHashMap; import java.util.Map; +import java.util.TreeMap; public class HttpResponseBuilder { @@ -10,53 +10,108 @@ public class HttpResponseBuilder { private int statusCode = 200; private String body = ""; private byte[] bytebody; - private Map headers = new LinkedHashMap<>(); + private Map headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); private static final String CRLF = "\r\n"; + private static final Map REASON_PHRASES = Map.ofEntries( + Map.entry(200, "OK"), + Map.entry(201, "Created"), + Map.entry(204, "No Content"), + Map.entry(301, "Moved Permanently"), + Map.entry(302, "Found"), + Map.entry(303, "See Other"), + Map.entry(304, "Not Modified"), + Map.entry(307, "Temporary Redirect"), + Map.entry(308, "Permanent Redirect"), + Map.entry(400, "Bad Request"), + Map.entry(401, "Unauthorized"), + Map.entry(403, "Forbidden"), + Map.entry(404, "Not Found"), + Map.entry(500, "Internal Server Error"), + Map.entry(502, "Bad Gateway"), + Map.entry(503, "Service Unavailable") + ); public void setStatusCode(int statusCode) { this.statusCode = statusCode; } public void setBody(String body) { this.body = body != null ? body : ""; + this.bytebody = null; // Clear byte body when setting string body } public void setBody(byte[] body) { this.bytebody = body; + this.body = ""; // Clear string body when setting byte body } + public void setHeaders(Map headers) { - this.headers = new LinkedHashMap<>(headers); + this.headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + this.headers.putAll(headers); } - private static final Map REASON_PHRASES = Map.of( - 200, "OK", - 201, "Created", - 400, "Bad Request", - 404, "Not Found", - 500, "Internal Server Error"); - public String build(){ - StringBuilder sb = new StringBuilder(); + public void setHeader(String name, String value) { + this.headers.put(name, value); + } + + public void setContentTypeFromFilename(String filename) { + String mimeType = MimeTypeDetector.detectMimeType(filename); + setHeader("Content-Type", mimeType); + } + + /* + * Builds the complete HTTP response as a byte array and preserves binary content without corruption. + * @return Complete HTTP response (headers + body) as byte[] + */ + public byte[] build() { + // Determine content body and length + byte[] contentBody; int contentLength; - if(body.isEmpty() && bytebody != null){ + + if (bytebody != null) { + contentBody = bytebody; contentLength = bytebody.length; - setBody(new String(bytebody, StandardCharsets.UTF_8)); - }else{ - contentLength = body.getBytes(StandardCharsets.UTF_8).length; + } else { + contentBody = body.getBytes(StandardCharsets.UTF_8); + contentLength = contentBody.length; } + // Build headers as String + StringBuilder headerBuilder = new StringBuilder(); - String reason = REASON_PHRASES.getOrDefault(statusCode, "OK"); - sb.append(PROTOCOL).append(" ").append(statusCode).append(" ").append(reason).append(CRLF); - headers.forEach((k,v) -> sb.append(k).append(": ").append(v).append(CRLF)); - sb.append("Content-Length: ") - .append(contentLength); - sb.append(CRLF); - sb.append("Connection: close").append(CRLF); - sb.append(CRLF); - sb.append(body); - return sb.toString(); + // Status line + String reason = REASON_PHRASES.getOrDefault(statusCode, ""); + headerBuilder.append(PROTOCOL).append(" ").append(statusCode); + if (!reason.isEmpty()) { + headerBuilder.append(" ").append(reason); + } + headerBuilder.append(CRLF); - } + // User-defined headers + headers.forEach((k, v) -> headerBuilder.append(k).append(": ").append(v).append(CRLF)); -} + // Auto-append Content-Length if not set. + if (!headers.containsKey("Content-Length")) { + headerBuilder.append("Content-Length: ").append(contentLength).append(CRLF); + } + + // Auto-append Connection if not set. + if (!headers.containsKey("Connection")) { + headerBuilder.append("Connection: close").append(CRLF); + } + + // Blank line before body + headerBuilder.append(CRLF); + + // Convert headers to bytes + byte[] headerBytes = headerBuilder.toString().getBytes(StandardCharsets.UTF_8); + + // Combine headers + body into single byte array + byte[] response = new byte[headerBytes.length + contentBody.length]; + System.arraycopy(headerBytes, 0, response, 0, headerBytes.length); + System.arraycopy(contentBody, 0, response, headerBytes.length, contentBody.length); + + return response; + } +} \ No newline at end of file diff --git a/src/main/java/org/example/http/MimeTypeDetector.java b/src/main/java/org/example/http/MimeTypeDetector.java new file mode 100644 index 00000000..9005078a --- /dev/null +++ b/src/main/java/org/example/http/MimeTypeDetector.java @@ -0,0 +1,77 @@ +package org.example.http; + +import java.util.Map; + +/** + * Detects MIME types based on file extensions. + * Used to set the Content-Type header when serving static files. + */ +public final class MimeTypeDetector { + + // Private constructor - utility class + private MimeTypeDetector() { + throw new AssertionError("Utility class - do not instantiate"); + } + + private static final Map MIME_TYPES = Map.ofEntries( + // HTML & Text + Map.entry(".html", "text/html; charset=UTF-8"), + Map.entry(".htm", "text/html; charset=UTF-8"), + Map.entry(".css", "text/css; charset=UTF-8"), + Map.entry(".js", "application/javascript; charset=UTF-8"), + Map.entry(".json", "application/json; charset=UTF-8"), + Map.entry(".xml", "application/xml; charset=UTF-8"), + Map.entry(".txt", "text/plain; charset=UTF-8"), + + // Images + Map.entry(".png", "image/png"), + Map.entry(".jpg", "image/jpeg"), + Map.entry(".jpeg", "image/jpeg"), + Map.entry(".gif", "image/gif"), + Map.entry(".svg", "image/svg+xml"), + Map.entry(".ico", "image/x-icon"), + Map.entry(".webp", "image/webp"), + + // Documents + Map.entry(".pdf", "application/pdf"), + + // Video & Audio + Map.entry(".mp4", "video/mp4"), + Map.entry(".webm", "video/webm"), + Map.entry(".mp3", "audio/mpeg"), + Map.entry(".wav", "audio/wav"), + + // Fonts + Map.entry(".woff", "font/woff"), + Map.entry(".woff2", "font/woff2"), + Map.entry(".ttf", "font/ttf"), + Map.entry(".otf", "font/otf") + ); + + /** + * Detects the MIME type of file based on its extension. + * + * @param filename the name of the file (t.ex., "index.html", "style.css") + * @return the MIME type string (t.ex, "text/html; charset=UTF-8") + */ + + + public static String detectMimeType(String filename) { + + String octet = "application/octet-stream"; + + if (filename == null || filename.isEmpty()) { + return octet; + } + + // Find the last dot to get extension + int lastDot = filename.lastIndexOf('.'); + if (lastDot == -1 || lastDot == filename.length() - 1) { + // No extension or dot at end + return octet; + } + + String extension = filename.substring(lastDot).toLowerCase(); + return MIME_TYPES.getOrDefault(extension, octet); + } +} \ No newline at end of file diff --git a/src/test/java/org/example/StaticFileHandlerTest.java b/src/test/java/org/example/StaticFileHandlerTest.java index 871fdb46..31db5108 100644 --- a/src/test/java/org/example/StaticFileHandlerTest.java +++ b/src/test/java/org/example/StaticFileHandlerTest.java @@ -49,7 +49,7 @@ void test_file_that_exists_should_return_200() throws IOException { assertTrue(response.contains("HTTP/1.1 200 OK")); // Assert the status assertTrue(response.contains("Hello Test")); //Assert the content in the file - assertTrue(response.contains("Content-Type: text/html; charset=utf-8")); // Verify the correct Content-type header + assertTrue(response.contains("Content-Type: text/html; charset=UTF-8")); // Verify the correct Content-type header } diff --git a/src/test/java/org/example/http/HttpResponseBuilderTest.java b/src/test/java/org/example/http/HttpResponseBuilderTest.java index 8b80a7f8..b278ae19 100644 --- a/src/test/java/org/example/http/HttpResponseBuilderTest.java +++ b/src/test/java/org/example/http/HttpResponseBuilderTest.java @@ -1,45 +1,458 @@ package org.example.http; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Stream; - class HttpResponseBuilderTest { +import static org.assertj.core.api.Assertions.assertThat; - /** - * Verifies that build produces a valid HTTP response string! - * Status line is present - * Content-Length header is generated - * The response body is included - */ +class HttpResponseBuilderTest { + + // Helper method to convert byte[] response to String for assertions + private String asString(byte[] response) { + return new String(response, StandardCharsets.UTF_8); + } @Test void build_returnsValidHttpResponse() { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setBody("Hello"); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr) + .contains("HTTP/1.1 200 OK") + .contains("Content-Length: 5") + .contains("\r\n\r\n") + .contains("Hello"); + } + + // UTF-8 content length för olika strängar + @ParameterizedTest + @CsvSource({ + "å, 2", // 1 char, 2 bytes + "åäö, 6", // 3 chars, 6 bytes + "Hello, 5", // 5 chars, 5 bytes + "'', 0", // Empty string + "€, 3" // Euro sign, 3 bytes + }) + @DisplayName("Should calculate correct Content-Length for various strings") + void build_handlesUtf8ContentLength(String body, int expectedLength) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setBody(body); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr).contains("Content-Length: " + expectedLength); + } + + @Test + @DisplayName("Should set individual header") + void setHeader_addsHeaderToResponse() { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setHeader("Content-Type", "text/html; charset=UTF-8"); + builder.setBody("Hello"); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr).contains("Content-Type: text/html; charset=UTF-8"); + } + + @Test + @DisplayName("Should set multiple headers") + void setHeader_allowsMultipleHeaders() { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setHeader("Content-Type", "application/json"); + builder.setHeader("Cache-Control", "no-cache"); + builder.setBody("{}"); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr) + .contains("Content-Type: application/json") + .contains("Cache-Control: no-cache"); + } + + @ParameterizedTest + @CsvSource({ + "index.html, text/html; charset=UTF-8", + "page.htm, text/html; charset=UTF-8", + "style.css, text/css; charset=UTF-8", + "app.js, application/javascript; charset=UTF-8", + "data.json, application/json; charset=UTF-8", + "logo.png, image/png", + "photo.jpg, image/jpeg", + "image.jpeg, image/jpeg", + "icon.gif, image/gif", + "graphic.svg, image/svg+xml", + "favicon.ico, image/x-icon", + "doc.pdf, application/pdf", + "file.txt, text/plain; charset=UTF-8", + "config.xml, application/xml; charset=UTF-8" + }) + @DisplayName("Should auto-detect Content-Type from filename") + void setContentTypeFromFilename_detectsVariousTypes(String filename, String expectedContentType) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setContentTypeFromFilename(filename); + builder.setBody("test content"); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr).contains("Content-Type: " + expectedContentType); + } + + @ParameterizedTest(name = "{index} - Filename: {0} => Expected: {1}") + @CsvSource(value = { + "index.html, text/html; charset=UTF-8", + "style.css, text/css; charset=UTF-8", + "logo.png, image/png", + "doc.pdf, application/pdf", + "file.xyz, application/octet-stream", + "/var/www/index.html, text/html; charset=UTF-8", + "'', application/octet-stream", + "null, application/octet-stream" + }, nullValues = "null") + @DisplayName("Should detect Content-Type from various filenames and edge cases") + void setContentTypeFromFilename_allCases(String filename, String expectedContentType) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setContentTypeFromFilename(filename); + builder.setBody("test"); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr).contains("Content-Type: " + expectedContentType); + } + + @ParameterizedTest + @MethodSource("provideHeaderDuplicationScenarios") + @DisplayName("Should not duplicate headers when manually set") + void build_doesNotDuplicateHeaders(String headerName, String manualValue, String bodyContent) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setHeader(headerName, manualValue); + builder.setBody(bodyContent); + + byte[] result = builder.build(); + String resultStr = asString(result); + + long count = resultStr.lines() + .filter(line -> line.startsWith(headerName + ":")) + .count(); + + assertThat(count).isEqualTo(1); + assertThat(resultStr).contains(headerName + ": " + manualValue); + } + + private static Stream provideHeaderDuplicationScenarios() { + return Stream.of( + Arguments.of("Content-Length", "999", "Hello"), + Arguments.of("Content-Length", "0", ""), + Arguments.of("Content-Length", "12345", "Test content"), + Arguments.of("Connection", "keep-alive", "Hello"), + Arguments.of("Connection", "upgrade", "WebSocket data"), + Arguments.of("Connection", "close", "Goodbye") + ); + } + + @Test + @DisplayName("setHeaders should preserve case-insensitive behavior") + void setHeaders_preservesCaseInsensitivity() { + HttpResponseBuilder builder = new HttpResponseBuilder(); + + Map headers = new HashMap<>(); + headers.put("content-type", "text/html"); + headers.put("cache-control", "no-cache"); + builder.setHeaders(headers); + + builder.setHeader("Content-Length", "100"); + builder.setBody("Hello"); + + byte[] result = builder.build(); + String resultStr = asString(result); + + long count = resultStr.lines() + .filter(line -> line.toLowerCase().startsWith("content-length:")) + .count(); + + assertThat(count).isEqualTo(1); + } + + @ParameterizedTest + @CsvSource({ + "301, Moved Permanently", + "302, Found", + "304, Not Modified", + "400, Bad Request", + "401, Unauthorized", + "403, Forbidden", + "404, Not Found", + "500, Internal Server Error", + "502, Bad Gateway", + "503, Service Unavailable" + }) + @DisplayName("Should have correct reason phrases for common status codes") + void build_correctReasonPhrases(int statusCode, String expectedReason) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setStatusCode(statusCode); + builder.setBody(""); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr).contains("HTTP/1.1 " + statusCode + " " + expectedReason); + } + + // Redirect status codes + @ParameterizedTest + @CsvSource({ + "301, Moved Permanently, /new-page", + "302, Found, /temporary-page", + "303, See Other, /other-page", + "307, Temporary Redirect, /temp-redirect", + "308, Permanent Redirect, /perm-redirect" + }) + @DisplayName("Should handle redirect status codes correctly") + void build_handlesRedirectStatusCodes(int statusCode, String expectedReason, String location) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setStatusCode(statusCode); + builder.setHeader("Location", location); + builder.setBody(""); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr) + .contains("HTTP/1.1 " + statusCode + " " + expectedReason) + .contains("Location: " + location) + .doesNotContain("OK"); + } + + // Error status codes + @ParameterizedTest + @CsvSource({ + "400, Bad Request", + "401, Unauthorized", + "403, Forbidden", + "404, Not Found", + "500, Internal Server Error", + "502, Bad Gateway", + "503, Service Unavailable" + }) + @DisplayName("Should handle error status codes correctly") + void build_handlesErrorStatusCodes(int statusCode, String expectedReason) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setStatusCode(statusCode); + builder.setBody("Error message"); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr) + .contains("HTTP/1.1 " + statusCode + " " + expectedReason) + .doesNotContain("OK"); + } + + // Unknown status codes + @ParameterizedTest + @ValueSource(ints = {999, 123, 777, 100, 600}) + @DisplayName("Should handle unknown status codes gracefully") + void build_handlesUnknownStatusCodes(int statusCode) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setStatusCode(statusCode); + builder.setBody(""); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr) + .startsWith("HTTP/1.1 " + statusCode) + .doesNotContain("OK"); + } + + @Test + @DisplayName("Should auto-append headers when not manually set") + void build_autoAppendsHeadersWhenNotSet() { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setBody("Hello"); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr) + .contains("Content-Length: 5") + .contains("Connection: close"); + } + + @Test + @DisplayName("Should allow custom headers alongside auto-generated ones") + void build_combinesCustomAndAutoHeaders() { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setHeader("Content-Type", "text/html"); + builder.setHeader("Cache-Control", "no-cache"); + builder.setBody("Hello"); + + byte[] result = builder.build(); + String resultStr = asString(result); + assertThat(resultStr) + .contains("Content-Type: text/html") + .contains("Cache-Control: no-cache") + .contains("Content-Length: 5") + .contains("Connection: close"); + } + + // Case-insensitive header names + @ParameterizedTest + @CsvSource({ + "content-length, 100", + "Content-Length, 100", + "CONTENT-LENGTH, 100", + "CoNtEnT-LeNgTh, 100" + }) + @DisplayName("Should handle case-insensitive header names") + void setHeader_caseInsensitive(String headerName, String headerValue) { HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setHeader(headerName, headerValue); builder.setBody("Hello"); - String result = builder.build(); + byte[] result = builder.build(); + String resultStr = asString(result); + + long count = resultStr.lines() + .filter(line -> line.toLowerCase().contains("content-length")) + .count(); - assertThat(result).contains("HTTP/1.1 200 OK"); - assertThat(result).contains("Content-Length: 5"); - assertThat(result).contains("\r\n\r\n"); - assertThat(result).contains("Hello"); + assertThat(count).isEqualTo(1); + assertThat(resultStr.toLowerCase()).contains("content-length: " + headerValue.toLowerCase()); + } + + // Empty/null body + @ParameterizedTest + @CsvSource(value = { + "'', 0", // Empty string + "null, 0" // Null + }, nullValues = "null") + @DisplayName("Should handle empty and null body") + void build_emptyAndNullBody(String body, int expectedLength) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setBody(body); + + byte[] result = builder.build(); + String resultStr = asString(result); + assertThat(resultStr) + .contains("HTTP/1.1 200 OK") + .contains("Content-Length: " + expectedLength); + } + + // Header override + @ParameterizedTest + @CsvSource({ + "Content-Type, text/plain, text/html", + "Content-Type, application/json, text/xml", + "Connection, keep-alive, close", + "Cache-Control, no-cache, max-age=3600" + }) + @DisplayName("Should override previous header value when set again") + void setHeader_overridesPreviousValue(String headerName, String firstValue, String secondValue) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setHeader(headerName, firstValue); + builder.setHeader(headerName, secondValue); // Override + builder.setBody("Test"); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr) + .contains(headerName + ": " + secondValue) + .doesNotContain(headerName + ": " + firstValue); + } + + // för auto-append behavior + @ParameterizedTest + @MethodSource("provideAutoAppendScenarios") + @DisplayName("Should auto-append specific headers when not manually set") + void build_autoAppendsSpecificHeaders(String body, boolean setContentLength, boolean setConnection, + String expectedContentLength, String expectedConnection) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + + if (setContentLength) { + builder.setHeader("Content-Length", "999"); + } + if (setConnection) { + builder.setHeader("Connection", "keep-alive"); + } + + builder.setBody(body); + byte[] result = builder.build(); + String resultStr = asString(result); + + if (expectedContentLength != null) { + assertThat(resultStr).contains("Content-Length: " + expectedContentLength); + } + if (expectedConnection != null) { + assertThat(resultStr).contains("Connection: " + expectedConnection); + } + } + + private static Stream provideAutoAppendScenarios() { + return Stream.of( + // body, setContentLength, setConnection, expectedContentLength, expectedConnection + Arguments.of("Hello", false, false, "5", "close"), // Auto-append both + Arguments.of("Hello", true, false, "999", "close"), // Manual CL, auto Connection + Arguments.of("Hello", false, true, "5", "keep-alive"), // Auto CL, manual Connection + Arguments.of("Hello", true, true, "999", "keep-alive"), // Both manual + Arguments.of("", false, false, "0", "close") // Empty body + ); } - // Verifies that Content-Length is calculated using UTF-8 byte length! - // @Test - void build_handlesUtf8ContentLength() { + @DisplayName("Should preserve binary content without corruption") + void build_preservesBinaryContent() { HttpResponseBuilder builder = new HttpResponseBuilder(); - builder.setBody("å"); + // Create binary data with non-UTF-8 bytes + byte[] binaryData = new byte[]{ + (byte) 0x89, 0x50, 0x4E, 0x47, // PNG header + (byte) 0xFF, (byte) 0xD8, (byte) 0xFF, (byte) 0xE0 // Invalid UTF-8 sequences + }; + + builder.setBody(binaryData); + builder.setContentTypeFromFilename("test.png"); + + byte[] result = builder.build(); + + // Extract body from response (everything after \r\n\r\n) + int bodyStart = -1; + for (int i = 0; i < result.length - 3; i++) { + if (result[i] == '\r' && result[i+1] == '\n' && + result[i+2] == '\r' && result[i+3] == '\n') { + bodyStart = i + 4; + break; + } + } - String result = builder.build(); + assertThat(bodyStart).isGreaterThan(0); - assertThat(result).contains("Content-Length: 2"); + // Verify binary data is intact + byte[] actualBody = new byte[binaryData.length]; + System.arraycopy(result, bodyStart, actualBody, 0, binaryData.length); + assertThat(actualBody).isEqualTo(binaryData); } -} +} \ No newline at end of file diff --git a/src/test/java/org/example/http/MimeTypeDetectorTest.java b/src/test/java/org/example/http/MimeTypeDetectorTest.java new file mode 100644 index 00000000..913aeb48 --- /dev/null +++ b/src/test/java/org/example/http/MimeTypeDetectorTest.java @@ -0,0 +1,165 @@ +package org.example.http; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.assertj.core.api.Assertions.*; + +class MimeTypeDetectorTest { + + @Test + @DisplayName("Should detect HTML files") + void detectMimeType_html() { + assertThat(MimeTypeDetector.detectMimeType("index.html")) + .isEqualTo("text/html; charset=UTF-8"); + + assertThat(MimeTypeDetector.detectMimeType("page.htm")) + .isEqualTo("text/html; charset=UTF-8"); + } + + @Test + @DisplayName("Should detect CSS files") + void detectMimeType_css() { + assertThat(MimeTypeDetector.detectMimeType("style.css")) + .isEqualTo("text/css; charset=UTF-8"); + } + + @Test + @DisplayName("Should detect JavaScript files") + void detectMimeType_javascript() { + assertThat(MimeTypeDetector.detectMimeType("app.js")) + .isEqualTo("application/javascript; charset=UTF-8"); + } + + @Test + @DisplayName("Should detect JSON files") + void detectMimeType_json() { + assertThat(MimeTypeDetector.detectMimeType("data.json")) + .isEqualTo("application/json; charset=UTF-8"); + } + + @Test + @DisplayName("Should detect PNG images") + void detectMimeType_png() { + assertThat(MimeTypeDetector.detectMimeType("logo.png")) + .isEqualTo("image/png"); + } + + @Test + @DisplayName("Should detect JPEG images with .jpg extension") + void detectMimeType_jpg() { + assertThat(MimeTypeDetector.detectMimeType("photo.jpg")) + .isEqualTo("image/jpeg"); + } + + @Test + @DisplayName("Should detect JPEG images with .jpeg extension") + void detectMimeType_jpeg() { + assertThat(MimeTypeDetector.detectMimeType("photo.jpeg")) + .isEqualTo("image/jpeg"); + } + + @Test + @DisplayName("Should detect PDF files") + void detectMimeType_pdf() { + assertThat(MimeTypeDetector.detectMimeType("document.pdf")) + .isEqualTo("application/pdf"); + } + + @Test + @DisplayName("Should be case-insensitive") + void detectMimeType_caseInsensitive() { + assertThat(MimeTypeDetector.detectMimeType("INDEX.HTML")) + .isEqualTo("text/html; charset=UTF-8"); + + assertThat(MimeTypeDetector.detectMimeType("Style.CSS")) + .isEqualTo("text/css; charset=UTF-8"); + + assertThat(MimeTypeDetector.detectMimeType("PHOTO.PNG")) + .isEqualTo("image/png"); + } + + @Test + @DisplayName("Should return default MIME type for unknown extensions") + void detectMimeType_unknownExtension() { + assertThat(MimeTypeDetector.detectMimeType("file.xyz")) + .isEqualTo("application/octet-stream"); + + assertThat(MimeTypeDetector.detectMimeType("document.unknown")) + .isEqualTo("application/octet-stream"); + } + + @Test + @DisplayName("Should handle files without extension") + void detectMimeType_noExtension() { + assertThat(MimeTypeDetector.detectMimeType("README")) + .isEqualTo("application/octet-stream"); + + assertThat(MimeTypeDetector.detectMimeType("Makefile")) + .isEqualTo("application/octet-stream"); + } + + @Test + @DisplayName("Should handle null filename") + void detectMimeType_null() { + assertThat(MimeTypeDetector.detectMimeType(null)) + .isEqualTo("application/octet-stream"); + } + + @Test + @DisplayName("Should handle empty filename") + void detectMimeType_empty() { + assertThat(MimeTypeDetector.detectMimeType("")) + .isEqualTo("application/octet-stream"); + } + + @Test + @DisplayName("Should handle filename ending with dot") + void detectMimeType_endsWithDot() { + assertThat(MimeTypeDetector.detectMimeType("file.")) + .isEqualTo("application/octet-stream"); + } + + @Test + @DisplayName("Should handle path with directories") + void detectMimeType_withPath() { + assertThat(MimeTypeDetector.detectMimeType("/var/www/index.html")) + .isEqualTo("text/html; charset=UTF-8"); + + assertThat(MimeTypeDetector.detectMimeType("css/styles/main.css")) + .isEqualTo("text/css; charset=UTF-8"); + } + + @Test + @DisplayName("Should handle multiple dots in filename") + void detectMimeType_multipleDots() { + assertThat(MimeTypeDetector.detectMimeType("jquery.min.js")) + .isEqualTo("application/javascript; charset=UTF-8"); + + assertThat(MimeTypeDetector.detectMimeType("bootstrap.bundle.min.css")) + .isEqualTo("text/css; charset=UTF-8"); + } + + // Parametriserad test för många filtyper på en gång + @ParameterizedTest + @CsvSource({ + "test.html, text/html; charset=UTF-8", + "style.css, text/css; charset=UTF-8", + "app.js, application/javascript; charset=UTF-8", + "data.json, application/json; charset=UTF-8", + "image.png, image/png", + "photo.jpg, image/jpeg", + "doc.pdf, application/pdf", + "icon.svg, image/svg+xml", + "favicon.ico, image/x-icon", + "video.mp4, video/mp4", + "audio.mp3, audio/mpeg" + }) + @DisplayName("Should detect common file types") + void detectMimeType_commonTypes(String filename, String expectedMimeType) { + assertThat(MimeTypeDetector.detectMimeType(filename)) + .isEqualTo(expectedMimeType); + } +} \ No newline at end of file From d4e74816655d48b0f925c0daab463184ed55e059 Mon Sep 17 00:00:00 2001 From: Elias Lennheimer <47382348+Xeutos@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:20:18 +0100 Subject: [PATCH 18/54] Dockerfile update (#52) (#63) * Update Dockerfile Dockerfiles now copies www folder aswell * Added building and copying of dependency jar files * Fix dependency path in Dockerfile and update classpath in ENTRYPOINT configuration. * Fixed typo in Entrypoint configuration * Expose port 8080 in Dockerfile and changed appuser to come before ENTRYPOINT configuration. * Adjust Dockerfile paths for classes and dependencies, update `COPY` targets accordingly. --- Dockerfile | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index d8b69012..ee7e511e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,9 +3,14 @@ WORKDIR /build COPY src/ src/ COPY pom.xml pom.xml RUN mvn compile +RUN mvn dependency:copy-dependencies -DincludeScope=compile FROM eclipse-temurin:25-jre-alpine +EXPOSE 8080 RUN addgroup -S appgroup && adduser -S appuser -G appgroup -COPY --from=build /build/target/classes/ /app/ -ENTRYPOINT ["java", "-classpath", "/app", "org.example.App"] +WORKDIR /app/ +COPY --from=build /build/target/classes/ / +COPY --from=build /build/target/dependency/ /dependencies/ +COPY /www/ /www/ USER appuser +ENTRYPOINT ["java", "-classpath", "/app:/dependencies/*", "org.example.App"] From 5c80eaac8c189f5a4c4e16d2d9f6e137cd76867b Mon Sep 17 00:00:00 2001 From: Younes Ahmad Date: Thu, 19 Feb 2026 10:25:32 +0100 Subject: [PATCH 19/54] Added comprehensive README.MD (#67) * Added comprehensive README.MD * Added formatting recommendations, clearer info --- README.md | 375 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 349 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index d2be0162..490ccc6b 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,372 @@ -# 🚀 Create Your First Java Program +# ⚡ HTTP Web Server ⚡ +### **Class JUV25G** | Lightweight • Configurable • Secure -Java has evolved to become more beginner-friendly. This guide walks you through creating a simple program that prints “Hello World,” using both the classic syntax and the new streamlined approach introduced in Java 21. +
+ +![Java](https://img.shields.io/badge/Java-21+-orange?style=for-the-badge&logo=openjdk) +![HTTP](https://img.shields.io/badge/HTTP-1.1-blue?style=for-the-badge) +![Status](https://img.shields.io/badge/Status-Active-success?style=for-the-badge) + +*A modern, high-performance HTTP web server built from scratch in Java* + +[Features](#features) • [Quick Start](#quick-start) • [Configuration](#configuration) --- -## ✨ Classic Java Approach +
-Traditionally, Java requires a class with a `main` method as the entry point: +## ✨ Features -```java -public class Main { - public static void main(String[] args) { - System.out.println("Hello World"); - } -} +- 🚀 **High Performance** - Virtual threads for handling thousands of concurrent connections +- 📁 **Static File Serving** - HTML, CSS, JavaScript, images, PDFs, fonts, and more +- 🎨 **Smart MIME Detection** - Automatic Content-Type headers for 20+ file types +- ⚙️ **Flexible Configuration** - YAML or JSON config files with sensible defaults +- 🔒 **Security First** - Path traversal protection and input validation +- 🐳 **Docker Ready** - Multi-stage Dockerfile for easy deployment +- ⚡ **HTTP/1.1 Compliant** - Proper status codes, headers, and responses +- 🎯 **Custom Error Pages** - Branded 404 pages and error handling + +## 📋 Requirements + +| Tool | Version | Purpose | +|------|---------|---------| +| ☕ **Java** | 21+ | Runtime environment | +| 📦 **Maven** | 3.6+ | Build tool | +| 🐳 **Docker** | Latest | Optional - for containerization | + +## Quick Start + +``` +┌─────────────────────────────────────────────┐ +│ Ready to launch your web server? │ +│ Follow these simple steps! │ +└─────────────────────────────────────────────┘ ``` -This works across all Java versions and forms the foundation of most Java programs. +### 1. Clone the repository +```bash +git clone git clone https://github.com/ithsjava25/project-webserver-juv25g.git +cd project-webserver +``` ---- +### 2. Build the project +```bash +mvn clean compile +``` + +### 3. Run the server + +**Option A: Run directly with Maven (recommended for development)** +```bash +mvn exec:java@run +``` + +**Option B: Run compiled classes directly** +```bash +mvn clean compile +java -cp target/classes org.example.App +``` + +**Option C: Using Docker** +```bash +docker build -t webserver . +docker run -p 8080:8080 -v $(pwd)/www:/www webserver +``` + +The server will start on the default port **8080** and serve files from the `www/` directory. + +### 4. Access in browser +Open your browser and navigate to: +``` +http://localhost:8080 +``` -## 🆕 Java 25: Unnamed Class with Instance Main Method +## Configuration -In newer versions like **Java 25**, you can use **Unnamed Classes** and an **Instance Main Method**, which allows for a much cleaner syntax: +The server can be configured using a YAML or JSON configuration file located at: +``` +src/main/resources/application.yml +``` + +### Configuration File Format (YAML) + +```yaml +server: + port: 8080 + rootDir: "./www" + +logging: + level: "INFO" +``` + +### Configuration File Format (JSON) -```java -void main() { - System.out.println("Hello World"); +```json +{ + "server": { + "port": 8080, + "rootDir": "./www" + }, + "logging": { + "level": "INFO" + } } ``` -### 💡 Why is this cool? +### Configuration Options + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `server.port` | Integer | `8080` | Port number the server listens on (1-65535) | +| `server.rootDir` | String | `"./www"` | Root directory for serving static files | +| `logging.level` | String | `"INFO"` | Logging level (INFO, DEBUG, WARN, ERROR) | + +### Default Values + +If no configuration file is provided or values are missing, the following defaults are used: + +- **Port:** 8080 +- **Root Directory:** ./www +- **Logging Level:** INFO + +## Directory Structure + +``` +project-webserver/ +├── src/ +│ ├── main/ +│ │ ├── java/ +│ │ │ └── org/example/ +│ │ │ ├── App.java # Main entry point +│ │ │ ├── TcpServer.java # TCP server implementation +│ │ │ ├── ConnectionHandler.java # HTTP request handler +│ │ │ ├── StaticFileHandler.java # Static file server +│ │ │ ├── config/ # Configuration classes +│ │ │ ├── http/ # HTTP response builder & MIME detection +│ │ │ ├── httpparser/ # HTTP request parser +│ │ │ └── filter/ # Filter chain (future feature) +│ │ └── resources/ +│ │ └── application.yml # Configuration file +│ └── test/ # Unit tests +├── www/ # Web root (static files) +│ ├── index.html +│ ├── pageNotFound.html # Custom 404 page +│ └── ... # Other static files +├── pom.xml +└── README.md +``` + +## Serving Static Files + +Place your static files in the `www/` directory (or the directory specified in `server.rootDir`). + +### Supported File Types + +The server automatically detects and serves the correct `Content-Type` for: + +**Text & Markup:** +- HTML (`.html`, `.htm`) +- CSS (`.css`) +- JavaScript (`.js`) +- JSON (`.json`) +- XML (`.xml`) +- Plain text (`.txt`) + +**Images:** +- PNG (`.png`) +- JPEG (`.jpg`, `.jpeg`) +- GIF (`.gif`) +- SVG (`.svg`) +- WebP (`.webp`) +- ICO (`.ico`) + +**Documents:** +- PDF (`.pdf`) + +**Fonts:** +- WOFF (`.woff`) +- WOFF2 (`.woff2`) +- TrueType (`.ttf`) +- OpenType (`.otf`) + +**Media:** +- MP4 video (`.mp4`) +- WebM video (`.webm`) +- MP3 audio (`.mp3`) +- WAV audio (`.wav`) + +Unknown file types are served as `application/octet-stream`. + +## URL Handling + +The server applies the following URL transformations: + +| Request URL | Resolved File | +|-------------|---------------| +| `/` | `index.html` | +| `/about` | `about.html` | +| `/contact` | `contact.html` | +| `/styles.css` | `styles.css` | +| `/page.html` | `page.html` | + +**Note:** URLs ending with `/` are resolved to `index.html`, and URLs without an extension get `.html` appended automatically. + +## Error Pages + +### 404 Not Found + +If a requested file doesn't exist, the server returns: +1. `pageNotFound.html` (if it exists in the web root) +2. Otherwise: Plain text "404 Not Found" + +To customize your 404 page, create `www/pageNotFound.html`. + +### 403 Forbidden + +Returned when a path traversal attack is detected (e.g., `GET /../../etc/passwd`). + +## Security Features + +### Path Traversal Protection + +The server validates all file paths to prevent directory traversal attacks: + +``` +✅ Allowed: /index.html +✅ Allowed: /docs/guide.pdf +❌ Blocked: /../../../etc/passwd +❌ Blocked: /www/../../../secret.txt +``` + +All blocked requests return `403 Forbidden`. -- ✅ No need for a `public class` declaration -- ✅ No `static` keyword required -- ✅ Great for quick scripts and learning +## Running Tests -To compile and run this, use: +```bash +mvn test +``` + +Test coverage includes: +- HTTP request parsing +- Response building +- MIME type detection +- Configuration loading +- Static file serving +- Path traversal security + +## Building for Production + +### Using Docker (recommended) ```bash -java --source 25 HelloWorld.java +docker build -t webserver . +docker run -d -p 8080:8080 -v $(pwd)/www:/www --name my-webserver webserver ``` ---- +### Running on a server + +```bash +# Compile the project +mvn clean compile + +# Run with nohup for background execution +nohup java -cp target/classes org.example.App > server.log 2>&1 & + +# Or use systemd (create /etc/systemd/system/webserver.service) +``` + +## Examples + +### Example 1: Serving a Simple Website + +**Directory structure:** +``` +www/ +├── index.html +├── styles.css +├── app.js +└── images/ + └── logo.png +``` + +**Access:** +- Homepage: `http://localhost:8080/` +- Stylesheet: `http://localhost:8080/styles.css` +- Logo: `http://localhost:8080/images/logo.png` + +### Example 2: Custom Port + +**application.yml:** +```yaml +server: + port: 3000 + rootDir: "./public" +``` + +Access at: `http://localhost:3000/` + +### Example 3: Different Web Root + +**application.yml:** +```yaml +server: + rootDir: "./dist" +``` + +Server will serve files from `dist/` instead of `www/`. + +## Architecture + +### Request Flow + +1. **TcpServer** accepts incoming TCP connections +2. **ConnectionHandler** creates a virtual thread for each request +3. **HttpParser** parses the HTTP request line and headers +4. **StaticFileHandler** resolves the file path and reads the file +5. **HttpResponseBuilder** constructs the HTTP response with correct headers +6. Response is written to the client socket + +### Filter Chain (Future Feature) + +The project includes a filter chain interface for future extensibility: +- Request/response filtering +- Authentication +- Logging +- Compression + +## Troubleshooting + +### Port already in use +``` +Error: Address already in use +``` +**Solution:** Change the port in `application.yml` or kill the process using port 8080: +```bash +# Linux/Mac +lsof -ti:8080 | xargs kill -9 + +# Windows +netstat -ano | findstr :8080 +taskkill /PID /F +``` + +### File not found but file exists +**Solution:** Check that the file is in the correct directory (`www/` by default) and that the filename matches exactly (case-sensitive on Linux/Mac). + +### Binary files (images/PDFs) are corrupted +**Solution:** This should not happen with the current implementation. The server uses `byte[]` internally to preserve binary data. If you see this issue, please report it as a bug. + +## Contributing + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/new-feature`) +3. Commit your changes (`git commit -m 'Add new feature'`) +4. Push to the branch (`git push origin feature/new-feature`) +5. Open a Pull Request + +
-## 📚 Learn More +### 👨‍💻 Built by Class JUV25G -This feature is part of Java’s ongoing effort to streamline syntax. You can explore deeper in [Baeldung’s guide to Unnamed Classes and Instance Main Methods](https://www.baeldung.com/java-21-unnamed-class-instance-main). +**Made with ❤️ and ☕ in Sweden** +
From 6950c14057b2747e0b98dc4881b319e8ffed48e2 Mon Sep 17 00:00:00 2001 From: Andreas Kaiberger <91787385+apaegs@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:23:33 +0100 Subject: [PATCH 20/54] Fix: Path traversal vulnerability in StaticFileHandler (#65) * Prevent path traversal and sanitize URI in StaticFileHandler. * Add test for path traversal protection and support 403 responses. * Add tests for URI sanitization and handling of encoded/special URLs. * Add test for null byte injection prevention in StaticFileHandler * Improve StaticFileHandler path traversal handling and test coverage * Improve URI sanitization and add test for 404 response handling in StaticFileHandler --- .../java/org/example/StaticFileHandler.java | 22 ++++--- .../org/example/http/HttpResponseBuilder.java | 14 +---- .../org/example/StaticFileHandlerTest.java | 58 +++++++++++++++++++ 3 files changed, 75 insertions(+), 19 deletions(-) diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 58f13379..756c87fd 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -23,25 +23,33 @@ public StaticFileHandler(String webRoot) { } private void handleGetRequest(String uri) throws IOException { - // Security: Prevent path traversal attacks (e.g. GET /../../etc/passwd) + // Sanitize URI + int q = uri.indexOf('?'); + if (q >= 0) uri = uri.substring(0, q); + int h = uri.indexOf('#'); + if (h >= 0) uri = uri.substring(0, h); + uri = uri.replace("\0", ""); + if (uri.startsWith("/")) uri = uri.substring(1); + + // 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(); + fileBytes = "403 Forbidden".getBytes(java.nio.charset.StandardCharsets.UTF_8); statusCode = 403; return; } - if (file.exists()) { + // Read file + if (file.isFile()) { fileBytes = Files.readAllBytes(file.toPath()); statusCode = 200; } else { File errorFile = new File(WEB_ROOT, "pageNotFound.html"); - if (errorFile.exists()) { + if (errorFile.isFile()) { fileBytes = Files.readAllBytes(errorFile.toPath()); } else { - fileBytes = "404 Not Found".getBytes(); + fileBytes = "404 Not Found".getBytes(java.nio.charset.StandardCharsets.UTF_8); } statusCode = 404; } @@ -49,13 +57,11 @@ private void handleGetRequest(String uri) throws IOException { public void sendGetRequest(OutputStream outputStream, String uri) throws IOException { handleGetRequest(uri); - HttpResponseBuilder response = new HttpResponseBuilder(); response.setStatusCode(statusCode); // Use MimeTypeDetector instead of hardcoded text/html response.setContentTypeFromFilename(uri); response.setBody(fileBytes); - outputStream.write(response.build()); outputStream.flush(); } diff --git a/src/main/java/org/example/http/HttpResponseBuilder.java b/src/main/java/org/example/http/HttpResponseBuilder.java index db6a6958..8f55ebf2 100644 --- a/src/main/java/org/example/http/HttpResponseBuilder.java +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -36,14 +36,15 @@ public class HttpResponseBuilder { public void setStatusCode(int statusCode) { this.statusCode = statusCode; } + public void setBody(String body) { this.body = body != null ? body : ""; - this.bytebody = null; // Clear byte body when setting string body + this.bytebody = null; } public void setBody(byte[] body) { this.bytebody = body; - this.body = ""; // Clear string body when setting byte body + this.body = ""; } public void setHeaders(Map headers) { @@ -65,7 +66,6 @@ public void setContentTypeFromFilename(String filename) { * @return Complete HTTP response (headers + body) as byte[] */ public byte[] build() { - // Determine content body and length byte[] contentBody; int contentLength; @@ -77,10 +77,8 @@ public byte[] build() { contentLength = contentBody.length; } - // Build headers as String StringBuilder headerBuilder = new StringBuilder(); - // Status line String reason = REASON_PHRASES.getOrDefault(statusCode, ""); headerBuilder.append(PROTOCOL).append(" ").append(statusCode); if (!reason.isEmpty()) { @@ -88,26 +86,20 @@ public byte[] build() { } headerBuilder.append(CRLF); - // User-defined headers headers.forEach((k, v) -> headerBuilder.append(k).append(": ").append(v).append(CRLF)); - // Auto-append Content-Length if not set. if (!headers.containsKey("Content-Length")) { headerBuilder.append("Content-Length: ").append(contentLength).append(CRLF); } - // Auto-append Connection if not set. if (!headers.containsKey("Connection")) { headerBuilder.append("Connection: close").append(CRLF); } - // Blank line before body headerBuilder.append(CRLF); - // Convert headers to bytes byte[] headerBytes = headerBuilder.toString().getBytes(StandardCharsets.UTF_8); - // Combine headers + body into single byte array byte[] response = new byte[headerBytes.length + contentBody.length]; System.arraycopy(headerBytes, 0, response, 0, headerBytes.length); System.arraycopy(contentBody, 0, response, headerBytes.length, contentBody.length); diff --git a/src/test/java/org/example/StaticFileHandlerTest.java b/src/test/java/org/example/StaticFileHandlerTest.java index 31db5108..1fe4893b 100644 --- a/src/test/java/org/example/StaticFileHandlerTest.java +++ b/src/test/java/org/example/StaticFileHandlerTest.java @@ -2,6 +2,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -76,4 +78,60 @@ void test_file_that_does_not_exists_should_return_404() throws IOException { } + @Test + void test_path_traversal_should_return_403() throws IOException { + // Arrange + Path secret = tempDir.resolve("secret.txt"); + Files.writeString(secret,"TOP SECRET"); + Path webRoot = tempDir.resolve("www"); + Files.createDirectories(webRoot); + StaticFileHandler handler = new StaticFileHandler(webRoot.toString()); + ByteArrayOutputStream fakeOutput = new ByteArrayOutputStream(); + + // Act + handler.sendGetRequest(fakeOutput, "../secret.txt"); + + // Assert + String response = fakeOutput.toString(); + assertFalse(response.contains("TOP SECRET")); + assertTrue(response.contains("HTTP/1.1 403 Forbidden")); + } + + @ParameterizedTest + @CsvSource({ + "index.html?foo=bar", + "index.html#section", + "/index.html" + }) + void sanitized_uris_should_return_200(String uri) throws IOException { + // Arrange + Path webRoot = tempDir.resolve("www"); + Files.createDirectories(webRoot); + Files.writeString(webRoot.resolve("index.html"), "Hello"); + StaticFileHandler handler = new StaticFileHandler(webRoot.toString()); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + + // Act + handler.sendGetRequest(out, uri); + + // Assert + assertTrue(out.toString().contains("HTTP/1.1 200 OK")); + } + + @Test + void null_byte_injection_should_not_return_200() throws IOException { + // Arrange + Path webRoot = tempDir.resolve("www"); + Files.createDirectories(webRoot); + StaticFileHandler handler = new StaticFileHandler(webRoot.toString()); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + + // Act + handler.sendGetRequest(out, "index.html\0../../etc/passwd"); + + // Assert + String response = out.toString(); + assertFalse(response.contains("HTTP/1.1 200 OK")); + assertTrue(response.contains("HTTP/1.1 404 Not Found")); + } } From 78f7e21a1fffcf731bd5087425fcd24ed3cbfea4 Mon Sep 17 00:00:00 2001 From: viktorlindell12 Date: Mon, 23 Feb 2026 09:32:43 +0100 Subject: [PATCH 21/54] Resolve port: CLI > config > default (#29) * Resolve port: CLI > config > default * Wire port resolution to AppConfig/ConfigLoader and update docs/tests * Update PortConfigurationGuide.md * Update PortConfigurationGuide.md * Remove ServerPortResolver; use ConfigLoader for port * Update PortConfigurationGuide.md * Update PortConfigurationGuide.md * may be done --- PortConfigurationGuide.md | 49 +++++++++++++++++++ src/main/java/org/example/App.java | 41 +++++++++++++++- .../org/example/AppPortResolutionTest.java | 21 ++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 PortConfigurationGuide.md create mode 100644 src/test/java/org/example/AppPortResolutionTest.java diff --git a/PortConfigurationGuide.md b/PortConfigurationGuide.md new file mode 100644 index 00000000..622b5777 --- /dev/null +++ b/PortConfigurationGuide.md @@ -0,0 +1,49 @@ +# Konfiguration: port (CLI → config-fil → default) + +Det här projektet väljer vilken port servern ska starta på enligt följande prioritet: + +1. **CLI-argument** (`--port `) – högst prioritet +2. **Config-fil** (`application.yml`: `server.port`) +3. **Default** (`8080`) – används om port saknas i config eller om config-filen saknas + +--- + +## 1) Default-värde + +Om varken CLI eller config anger port används: + +- **8080** (default för `server.port` i `AppConfig`) + +--- + +## 2) Config-fil: `application.yml` + +### Var ska filen ligga? +Standard: +- `src/main/resources/application.yml` + +### Exempel +```yaml +server: +port: 9090 +``` + +--- + +## 3) CLI-argument + +CLI kan användas för att override:a config: + +```bash +java -cp target/classes org.example.App --port 8000 +``` + +--- + +## 4) Sammanfattning + +Prioritet: + +1. CLI (`--port`) +2. `application.yml` (`server.port`) +3. Default (`8080`) \ No newline at end of file diff --git a/src/main/java/org/example/App.java b/src/main/java/org/example/App.java index 408a1942..75c2914b 100644 --- a/src/main/java/org/example/App.java +++ b/src/main/java/org/example/App.java @@ -6,11 +6,50 @@ import java.nio.file.Path; public class App { + + private static final String PORT_FLAG = "--port"; + public static void main(String[] args) { Path configPath = Path.of("src/main/resources/application.yml"); AppConfig appConfig = ConfigLoader.loadOnce(configPath); - int port = appConfig.server().port(); + + int port = resolvePort(args, appConfig.server().port()); + new TcpServer(port).start(); } + + static int resolvePort(String[] args, int configPort) { + Integer cliPort = parsePortFromCli(args); + if (cliPort != null) { + return validatePort(cliPort, "CLI argument " + PORT_FLAG); + } + return validatePort(configPort, "configuration server.port"); + } + + static Integer parsePortFromCli(String[] args) { + if (args == null) return null; + + for (int i = 0; i < args.length; i++) { + if (PORT_FLAG.equals(args[i])) { + int valueIndex = i + 1; + if (valueIndex >= args.length) { + throw new IllegalArgumentException("Missing value after " + PORT_FLAG); + } + try { + return Integer.parseInt(args[valueIndex]); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid port value after " + PORT_FLAG + ": " + args[valueIndex], e); + } + } + } + return null; + } + + static int validatePort(int port, String source) { + if (port < 1 || port > 65535) { + throw new IllegalArgumentException("Port out of range (1-65535) from " + source + ": " + port); + } + return port; + } } diff --git a/src/test/java/org/example/AppPortResolutionTest.java b/src/test/java/org/example/AppPortResolutionTest.java new file mode 100644 index 00000000..a406b6f1 --- /dev/null +++ b/src/test/java/org/example/AppPortResolutionTest.java @@ -0,0 +1,21 @@ +package org.example; + + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class AppPortResolutionTest { + + @Test + void cli_port_wins_over_config() { + int port = App.resolvePort(new String[]{"--port", "8000"}, 9090); + assertThat(port).isEqualTo(8000); + } + + @Test + void config_port_used_when_no_cli_arg() { + int port = App.resolvePort(new String[]{}, 9090); + assertThat(port).isEqualTo(9090); + } +} \ No newline at end of file From 86f2ba725a9362d9929e6a824149a42ea8aade7d Mon Sep 17 00:00:00 2001 From: Ebba Andersson Date: Mon, 23 Feb 2026 14:47:45 +0100 Subject: [PATCH 22/54] Refactor status codes to constants #71 (#77) * refactor: add predefined HTTP status code constants to HttpResponseBuilder * refactor: use status code constants in StaticFileHandler * test: refactor StaticFileHandlerTest to use status code constants * test: refactor HttpResponseBuilderTest to use status code constants and explicit assertations --- .../java/org/example/StaticFileHandler.java | 9 +-- .../org/example/http/HttpResponseBuilder.java | 64 +++++++++++++------ .../org/example/StaticFileHandlerTest.java | 13 ++-- .../example/http/HttpResponseBuilderTest.java | 22 ++++++- 4 files changed, 77 insertions(+), 31 deletions(-) diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 756c87fd..8bcda375 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -1,6 +1,7 @@ package org.example; import org.example.http.HttpResponseBuilder; +import static org.example.http.HttpResponseBuilder.*; import java.io.File; import java.io.IOException; @@ -36,14 +37,14 @@ private void handleGetRequest(String uri) throws IOException { File file = new File(root, uri).getCanonicalFile(); if (!file.toPath().startsWith(root.toPath())) { fileBytes = "403 Forbidden".getBytes(java.nio.charset.StandardCharsets.UTF_8); - statusCode = 403; + statusCode = SC_FORBIDDEN; return; } // Read file if (file.isFile()) { fileBytes = Files.readAllBytes(file.toPath()); - statusCode = 200; + statusCode = SC_OK; } else { File errorFile = new File(WEB_ROOT, "pageNotFound.html"); if (errorFile.isFile()) { @@ -51,7 +52,7 @@ private void handleGetRequest(String uri) throws IOException { } else { fileBytes = "404 Not Found".getBytes(java.nio.charset.StandardCharsets.UTF_8); } - statusCode = 404; + statusCode = SC_NOT_FOUND; } } @@ -65,4 +66,4 @@ public void sendGetRequest(OutputStream outputStream, String uri) throws IOExcep outputStream.write(response.build()); outputStream.flush(); } -} \ No newline at end of file +} diff --git a/src/main/java/org/example/http/HttpResponseBuilder.java b/src/main/java/org/example/http/HttpResponseBuilder.java index 8f55ebf2..e84579e9 100644 --- a/src/main/java/org/example/http/HttpResponseBuilder.java +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -6,8 +6,35 @@ public class HttpResponseBuilder { + // SUCCESS + public static final int SC_OK = 200; + public static final int SC_CREATED = 201; + public static final int SC_NO_CONTENT = 204; + + // REDIRECTION + public static final int SC_MOVED_PERMANENTLY = 301; + public static final int SC_FOUND = 302; + public static final int SC_SEE_OTHER = 303; + public static final int SC_NOT_MODIFIED = 304; + public static final int SC_TEMPORARY_REDIRECT = 307; + public static final int SC_PERMANENT_REDIRECT = 308; + + // CLIENT ERROR + public static final int SC_BAD_REQUEST = 400; + public static final int SC_UNAUTHORIZED = 401; + public static final int SC_FORBIDDEN = 403; + public static final int SC_NOT_FOUND = 404; + + // SERVER ERROR + public static final int SC_INTERNAL_SERVER_ERROR = 500; + public static final int SC_BAD_GATEWAY = 502; + public static final int SC_SERVICE_UNAVAILABLE = 503; + public static final int SC_GATEWAY_TIMEOUT = 504; + + + private static final String PROTOCOL = "HTTP/1.1"; - private int statusCode = 200; + private int statusCode = SC_OK; private String body = ""; private byte[] bytebody; private Map headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); @@ -15,22 +42,23 @@ public class HttpResponseBuilder { private static final String CRLF = "\r\n"; private static final Map REASON_PHRASES = Map.ofEntries( - Map.entry(200, "OK"), - Map.entry(201, "Created"), - Map.entry(204, "No Content"), - Map.entry(301, "Moved Permanently"), - Map.entry(302, "Found"), - Map.entry(303, "See Other"), - Map.entry(304, "Not Modified"), - Map.entry(307, "Temporary Redirect"), - Map.entry(308, "Permanent Redirect"), - Map.entry(400, "Bad Request"), - Map.entry(401, "Unauthorized"), - Map.entry(403, "Forbidden"), - Map.entry(404, "Not Found"), - Map.entry(500, "Internal Server Error"), - Map.entry(502, "Bad Gateway"), - Map.entry(503, "Service Unavailable") + Map.entry(SC_OK, "OK"), + Map.entry(SC_CREATED, "Created"), + Map.entry(SC_NO_CONTENT, "No Content"), + Map.entry(SC_MOVED_PERMANENTLY, "Moved Permanently"), + Map.entry(SC_FOUND, "Found"), + Map.entry(SC_SEE_OTHER, "See Other"), + Map.entry(SC_NOT_MODIFIED, "Not Modified"), + Map.entry(SC_TEMPORARY_REDIRECT, "Temporary Redirect"), + Map.entry(SC_PERMANENT_REDIRECT, "Permanent Redirect"), + Map.entry(SC_BAD_REQUEST, "Bad Request"), + Map.entry(SC_UNAUTHORIZED, "Unauthorized"), + Map.entry(SC_FORBIDDEN, "Forbidden"), + Map.entry(SC_NOT_FOUND, "Not Found"), + Map.entry(SC_INTERNAL_SERVER_ERROR, "Internal Server Error"), + Map.entry(SC_BAD_GATEWAY, "Bad Gateway"), + Map.entry(SC_SERVICE_UNAVAILABLE, "Service Unavailable"), + Map.entry(SC_GATEWAY_TIMEOUT, "Gateway Timeout") ); public void setStatusCode(int statusCode) { @@ -106,4 +134,4 @@ public byte[] build() { return response; } -} \ No newline at end of file +} diff --git a/src/test/java/org/example/StaticFileHandlerTest.java b/src/test/java/org/example/StaticFileHandlerTest.java index 1fe4893b..ce6feb7a 100644 --- a/src/test/java/org/example/StaticFileHandlerTest.java +++ b/src/test/java/org/example/StaticFileHandlerTest.java @@ -10,6 +10,7 @@ import java.nio.file.Files; import java.nio.file.Path; import static org.junit.jupiter.api.Assertions.*; +import static org.example.http.HttpResponseBuilder.*; /** * Unit test class for verifying the behavior of the StaticFileHandler class. @@ -48,7 +49,7 @@ void test_file_that_exists_should_return_200() throws IOException { //Assert String response = fakeOutput.toString();//Converts the captured byte stream into a String for verification - assertTrue(response.contains("HTTP/1.1 200 OK")); // Assert the status + assertTrue(response.contains("HTTP/1.1 " + SC_OK + " OK")); // Assert the status assertTrue(response.contains("Hello Test")); //Assert the content in the file assertTrue(response.contains("Content-Type: text/html; charset=UTF-8")); // Verify the correct Content-type header @@ -74,7 +75,7 @@ void test_file_that_does_not_exists_should_return_404() throws IOException { //Assert String response = fakeOutput.toString();//Converts the captured byte stream into a String for verification - assertTrue(response.contains("HTTP/1.1 404 Not Found")); // Assert the status + assertTrue(response.contains("HTTP/1.1 " + SC_NOT_FOUND + " Not Found")); // Assert the status } @@ -94,7 +95,7 @@ void test_path_traversal_should_return_403() throws IOException { // Assert String response = fakeOutput.toString(); assertFalse(response.contains("TOP SECRET")); - assertTrue(response.contains("HTTP/1.1 403 Forbidden")); + assertTrue(response.contains("HTTP/1.1 " + SC_FORBIDDEN + " Forbidden")); } @ParameterizedTest @@ -115,7 +116,7 @@ void sanitized_uris_should_return_200(String uri) throws IOException { handler.sendGetRequest(out, uri); // Assert - assertTrue(out.toString().contains("HTTP/1.1 200 OK")); + assertTrue(out.toString().contains("HTTP/1.1 " + SC_OK + " OK")); } @Test @@ -131,7 +132,7 @@ void null_byte_injection_should_not_return_200() throws IOException { // Assert String response = out.toString(); - assertFalse(response.contains("HTTP/1.1 200 OK")); - assertTrue(response.contains("HTTP/1.1 404 Not Found")); + assertFalse(response.contains("HTTP/1.1 " + SC_OK + " OK")); + assertTrue(response.contains("HTTP/1.1 " + SC_NOT_FOUND + " Not Found")); } } diff --git a/src/test/java/org/example/http/HttpResponseBuilderTest.java b/src/test/java/org/example/http/HttpResponseBuilderTest.java index b278ae19..5363d5cf 100644 --- a/src/test/java/org/example/http/HttpResponseBuilderTest.java +++ b/src/test/java/org/example/http/HttpResponseBuilderTest.java @@ -14,6 +14,7 @@ import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; +import static org.example.http.HttpResponseBuilder.*; class HttpResponseBuilderTest { @@ -25,13 +26,14 @@ private String asString(byte[] response) { @Test void build_returnsValidHttpResponse() { HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setStatusCode(SC_OK); builder.setBody("Hello"); byte[] result = builder.build(); String resultStr = asString(result); assertThat(resultStr) - .contains("HTTP/1.1 200 OK") + .contains("HTTP/1.1 " + SC_OK + " OK") .contains("Content-Length: 5") .contains("\r\n\r\n") .contains("Hello"); @@ -49,6 +51,7 @@ void build_returnsValidHttpResponse() { @DisplayName("Should calculate correct Content-Length for various strings") void build_handlesUtf8ContentLength(String body, int expectedLength) { HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setStatusCode(SC_OK); builder.setBody(body); byte[] result = builder.build(); @@ -62,6 +65,7 @@ void build_handlesUtf8ContentLength(String body, int expectedLength) { void setHeader_addsHeaderToResponse() { HttpResponseBuilder builder = new HttpResponseBuilder(); builder.setHeader("Content-Type", "text/html; charset=UTF-8"); + builder.setStatusCode(SC_OK); builder.setBody("Hello"); byte[] result = builder.build(); @@ -76,6 +80,7 @@ void setHeader_allowsMultipleHeaders() { HttpResponseBuilder builder = new HttpResponseBuilder(); builder.setHeader("Content-Type", "application/json"); builder.setHeader("Cache-Control", "no-cache"); + builder.setStatusCode(SC_OK); builder.setBody("{}"); byte[] result = builder.build(); @@ -107,6 +112,7 @@ void setHeader_allowsMultipleHeaders() { void setContentTypeFromFilename_detectsVariousTypes(String filename, String expectedContentType) { HttpResponseBuilder builder = new HttpResponseBuilder(); builder.setContentTypeFromFilename(filename); + builder.setStatusCode(SC_OK); builder.setBody("test content"); byte[] result = builder.build(); @@ -130,6 +136,7 @@ void setContentTypeFromFilename_detectsVariousTypes(String filename, String expe void setContentTypeFromFilename_allCases(String filename, String expectedContentType) { HttpResponseBuilder builder = new HttpResponseBuilder(); builder.setContentTypeFromFilename(filename); + builder.setStatusCode(SC_OK); builder.setBody("test"); byte[] result = builder.build(); @@ -143,6 +150,7 @@ void setContentTypeFromFilename_allCases(String filename, String expectedContent @DisplayName("Should not duplicate headers when manually set") void build_doesNotDuplicateHeaders(String headerName, String manualValue, String bodyContent) { HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setStatusCode(SC_OK); builder.setHeader(headerName, manualValue); builder.setBody(bodyContent); @@ -179,6 +187,7 @@ void setHeaders_preservesCaseInsensitivity() { builder.setHeaders(headers); builder.setHeader("Content-Length", "100"); + builder.setStatusCode(SC_OK); builder.setBody("Hello"); byte[] result = builder.build(); @@ -287,6 +296,7 @@ void build_handlesUnknownStatusCodes(int statusCode) { @DisplayName("Should auto-append headers when not manually set") void build_autoAppendsHeadersWhenNotSet() { HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setStatusCode(SC_OK); builder.setBody("Hello"); byte[] result = builder.build(); @@ -303,6 +313,7 @@ void build_combinesCustomAndAutoHeaders() { HttpResponseBuilder builder = new HttpResponseBuilder(); builder.setHeader("Content-Type", "text/html"); builder.setHeader("Cache-Control", "no-cache"); + builder.setStatusCode(SC_OK); builder.setBody("Hello"); byte[] result = builder.build(); @@ -328,6 +339,7 @@ void setHeader_caseInsensitive(String headerName, String headerValue) { HttpResponseBuilder builder = new HttpResponseBuilder(); builder.setHeader(headerName, headerValue); + builder.setStatusCode(SC_OK); builder.setBody("Hello"); byte[] result = builder.build(); @@ -350,13 +362,14 @@ void setHeader_caseInsensitive(String headerName, String headerValue) { @DisplayName("Should handle empty and null body") void build_emptyAndNullBody(String body, int expectedLength) { HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setStatusCode(SC_OK); builder.setBody(body); byte[] result = builder.build(); String resultStr = asString(result); assertThat(resultStr) - .contains("HTTP/1.1 200 OK") + .contains("HTTP/1.1 " + SC_OK + " OK") .contains("Content-Length: " + expectedLength); } @@ -373,6 +386,7 @@ void setHeader_overridesPreviousValue(String headerName, String firstValue, Stri HttpResponseBuilder builder = new HttpResponseBuilder(); builder.setHeader(headerName, firstValue); builder.setHeader(headerName, secondValue); // Override + builder.setStatusCode(SC_OK); builder.setBody("Test"); byte[] result = builder.build(); @@ -390,6 +404,7 @@ void setHeader_overridesPreviousValue(String headerName, String firstValue, Stri void build_autoAppendsSpecificHeaders(String body, boolean setContentLength, boolean setConnection, String expectedContentLength, String expectedConnection) { HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setStatusCode(SC_OK); if (setContentLength) { builder.setHeader("Content-Length", "999"); @@ -425,6 +440,7 @@ private static Stream provideAutoAppendScenarios() { @DisplayName("Should preserve binary content without corruption") void build_preservesBinaryContent() { HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setStatusCode(SC_OK); // Create binary data with non-UTF-8 bytes byte[] binaryData = new byte[]{ @@ -455,4 +471,4 @@ void build_preservesBinaryContent() { assertThat(actualBody).isEqualTo(binaryData); } -} \ No newline at end of file +} From 261a32afd5a8d70289c1cfc1acb4958038b22bb6 Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Tue, 24 Feb 2026 12:35:41 +0100 Subject: [PATCH 23/54] Add rate limiting filter implementation --- .../example/filter/RateLimitingFilter.java | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 src/main/java/org/example/filter/RateLimitingFilter.java diff --git a/src/main/java/org/example/filter/RateLimitingFilter.java b/src/main/java/org/example/filter/RateLimitingFilter.java new file mode 100644 index 00000000..dc4c7fdb --- /dev/null +++ b/src/main/java/org/example/filter/RateLimitingFilter.java @@ -0,0 +1,71 @@ +package org.example.filter; + +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Logger; + +/** + * Filter responsible for limiting the number of requests per client IP. + * Implements the Token Bucket algorithm using the Bucket4j library. + */ + +public class RateLimitingFilter implements Filter { + private static final Logger logger = Logger.getLogger(RateLimitingFilter.class.getName()); + private static final Map buckets = new ConcurrentHashMap<>(); + private static final long capacity = 10; + private static final long refillTokens = 1; + private final Duration refillPeriod = Duration.ofSeconds(10); + + @Override + public void init() { + logger.info("RateLimitingFilter initialized with capacity: " + capacity); + } + + /** + * Intercepts the request and checks if the client has enough tokens. + */ + + @Override + public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) { + + String clientIp = (String) request.getAttribute("clientIp"); + + Bucket bucket = buckets.computeIfAbsent(clientIp, k -> createNewBucket()); + + if (bucket.tryConsume(1)) { + chain.doFilter(request, response); + } else { + logger.warning("Limit exceeded per IP: " + clientIp); + //todo: cambiar 429 a la nueva configuracion del builder + //agregar el rate limit exceeded cuando el ip es privado como vimos en la clase??? ip 172.19 + response.setStatusCode(429); + response.setBody("429 Too Many Requests: limit of requests exceeded.\n"); + } + } + + @Override + public void destroy() { + buckets.clear(); + } + + /** + * Configures a new Bucket with the specified bandwidth. + */ + private Bucket createNewBucket() { + return Bucket.builder() + .addLimit(Bandwidth.builder() + .capacity(capacity) + .refillGreedy(refillTokens, refillPeriod) + .build()) + .build(); + } + + public void clearBuckets(){ + buckets.clear(); + } +} From 2b3386584b3fd71027592ab2cd8e99e06dda93dc Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Tue, 24 Feb 2026 12:36:11 +0100 Subject: [PATCH 24/54] Add rate limiting filter test --- .../filter/RateLimitingFilterTest.java | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 src/test/java/org/example/filter/RateLimitingFilterTest.java diff --git a/src/test/java/org/example/filter/RateLimitingFilterTest.java b/src/test/java/org/example/filter/RateLimitingFilterTest.java new file mode 100644 index 00000000..cdc63590 --- /dev/null +++ b/src/test/java/org/example/filter/RateLimitingFilterTest.java @@ -0,0 +1,77 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.net.http.HttpResponse; +import java.util.HashMap; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class RateLimitingFilterTest { + + @Mock + FilterChain filterChain; + + private RateLimitingFilter filter; + private HttpRequest request; + private HttpResponseBuilder response; + + @BeforeEach + void setUp(){ + filter = new RateLimitingFilter(); + request = new HttpRequest("GET", "/", "HTTP/1.1", new HashMap<>(), ""); + request.setAttribute("clientIp", "127.0.0.1"); + response = new HttpResponseBuilder(); + filter.clearBuckets(); + } + + @Test + void shouldAllowRequestWhenTokensAreAvailable(){ + + filter.doFilter(request, response, filterChain); + + verify(filterChain, times(1)).doFilter(request, response); + assertEquals(200, response.getStatusCode()); + } + + @Test + void shouldNotAllowRequestWhenTokensAreNotAvailable(){ + + //capacity of the bucket is 10 + for(int i = 0; i < 11; i++ ) + filter.doFilter(request, response, filterChain); + + assertEquals(429, response.getStatusCode()); + verify(filterChain, times(10)).doFilter(any(), any()); + } + + @Test + void shouldHaveSeparateBucketsPerIp(){ + + //Request 1 + for(int i = 0; i < 11; i++) + filter.doFilter(request, response, filterChain); + + //Request 2 with a different Ip + HttpRequest request2 = new HttpRequest("GET", "/", "HTTP/1.1", new HashMap<>(), ""); + request2.setAttribute("clientIp", "127.2.2.2"); + HttpResponseBuilder response2 = new HttpResponseBuilder(); + + filter.doFilter(request2, response2, filterChain); + + //First request should be 429 because it exceeded the capacity of the bucket (10) + assertEquals(429, response.getStatusCode()); + //Second request should be 200 + assertEquals(200, response2.getStatusCode()); + } + + +} From 650028efa60f3b9609364aa93048d9c95ba27794 Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Tue, 24 Feb 2026 12:44:11 +0100 Subject: [PATCH 25/54] Add error 429 too many requests in builder --- src/main/java/org/example/http/HttpResponseBuilder.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/org/example/http/HttpResponseBuilder.java b/src/main/java/org/example/http/HttpResponseBuilder.java index e84579e9..051ae48b 100644 --- a/src/main/java/org/example/http/HttpResponseBuilder.java +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -24,6 +24,7 @@ public class HttpResponseBuilder { public static final int SC_UNAUTHORIZED = 401; public static final int SC_FORBIDDEN = 403; public static final int SC_NOT_FOUND = 404; + public static final int SC_TOO_MANY_REQUESTS = 429; // SERVER ERROR public static final int SC_INTERNAL_SERVER_ERROR = 500; @@ -55,6 +56,7 @@ public class HttpResponseBuilder { Map.entry(SC_UNAUTHORIZED, "Unauthorized"), Map.entry(SC_FORBIDDEN, "Forbidden"), Map.entry(SC_NOT_FOUND, "Not Found"), + Map.entry(SC_TOO_MANY_REQUESTS, "Too Many Requests"), Map.entry(SC_INTERNAL_SERVER_ERROR, "Internal Server Error"), Map.entry(SC_BAD_GATEWAY, "Bad Gateway"), Map.entry(SC_SERVICE_UNAVAILABLE, "Service Unavailable"), From 23c0667e926b41887c6e9de766376a6f84853029 Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Tue, 24 Feb 2026 12:54:36 +0100 Subject: [PATCH 26/54] Change to new reason phrases from builder in test class --- .../org/example/filter/RateLimitingFilterTest.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/test/java/org/example/filter/RateLimitingFilterTest.java b/src/test/java/org/example/filter/RateLimitingFilterTest.java index cdc63590..24cf28b7 100644 --- a/src/test/java/org/example/filter/RateLimitingFilterTest.java +++ b/src/test/java/org/example/filter/RateLimitingFilterTest.java @@ -11,6 +11,8 @@ import java.net.http.HttpResponse; import java.util.HashMap; +import static org.example.http.HttpResponseBuilder.SC_OK; +import static org.example.http.HttpResponseBuilder.SC_TOO_MANY_REQUESTS; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.*; @@ -39,7 +41,7 @@ void shouldAllowRequestWhenTokensAreAvailable(){ filter.doFilter(request, response, filterChain); verify(filterChain, times(1)).doFilter(request, response); - assertEquals(200, response.getStatusCode()); + assertEquals(SC_OK, response.getStatusCode()); } @Test @@ -49,7 +51,7 @@ void shouldNotAllowRequestWhenTokensAreNotAvailable(){ for(int i = 0; i < 11; i++ ) filter.doFilter(request, response, filterChain); - assertEquals(429, response.getStatusCode()); + assertEquals(SC_TOO_MANY_REQUESTS, response.getStatusCode()); verify(filterChain, times(10)).doFilter(any(), any()); } @@ -68,9 +70,9 @@ void shouldHaveSeparateBucketsPerIp(){ filter.doFilter(request2, response2, filterChain); //First request should be 429 because it exceeded the capacity of the bucket (10) - assertEquals(429, response.getStatusCode()); + assertEquals(SC_TOO_MANY_REQUESTS, response.getStatusCode()); //Second request should be 200 - assertEquals(200, response2.getStatusCode()); + assertEquals(SC_OK, response2.getStatusCode()); } From 103178ab24538e2112d35f4a82056f97cfe1cef2 Mon Sep 17 00:00:00 2001 From: Johan Karlsson <93186588+gurkvatten@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:19:33 +0100 Subject: [PATCH 27/54] fixed file path (#86) --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index ee7e511e..2976670e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,8 +9,8 @@ FROM eclipse-temurin:25-jre-alpine EXPOSE 8080 RUN addgroup -S appgroup && adduser -S appuser -G appgroup WORKDIR /app/ -COPY --from=build /build/target/classes/ / -COPY --from=build /build/target/dependency/ /dependencies/ +COPY --from=build /build/target/classes/ /app/ +COPY --from=build /build/target/dependency/ /app/dependencies/ COPY /www/ /www/ USER appuser -ENTRYPOINT ["java", "-classpath", "/app:/dependencies/*", "org.example.App"] +ENTRYPOINT ["java", "-classpath", "/app:/app/dependencies/*", "org.example.App"] From a7368123496e485c302c92c1796576406008cc98 Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Tue, 24 Feb 2026 13:31:35 +0100 Subject: [PATCH 28/54] Add a little description of the bucket and change "429" to sc_too_many_requests --- src/main/java/org/example/filter/RateLimitingFilter.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/example/filter/RateLimitingFilter.java b/src/main/java/org/example/filter/RateLimitingFilter.java index dc4c7fdb..473aab66 100644 --- a/src/main/java/org/example/filter/RateLimitingFilter.java +++ b/src/main/java/org/example/filter/RateLimitingFilter.java @@ -12,6 +12,7 @@ /** * Filter responsible for limiting the number of requests per client IP. * Implements the Token Bucket algorithm using the Bucket4j library. + * The capacity of the bucket is 10, and it refills one token per 10 seconds */ public class RateLimitingFilter implements Filter { @@ -41,9 +42,7 @@ public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterCh chain.doFilter(request, response); } else { logger.warning("Limit exceeded per IP: " + clientIp); - //todo: cambiar 429 a la nueva configuracion del builder - //agregar el rate limit exceeded cuando el ip es privado como vimos en la clase??? ip 172.19 - response.setStatusCode(429); + response.setStatusCode(HttpResponseBuilder.SC_TOO_MANY_REQUESTS); response.setBody("429 Too Many Requests: limit of requests exceeded.\n"); } } From 20773773454524d8d83fc6779545fc62764da25c Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Tue, 24 Feb 2026 13:46:45 +0100 Subject: [PATCH 29/54] Add a description of how the token bucket algorithm works --- src/main/java/org/example/filter/RateLimitingFilter.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/example/filter/RateLimitingFilter.java b/src/main/java/org/example/filter/RateLimitingFilter.java index 473aab66..a00decc3 100644 --- a/src/main/java/org/example/filter/RateLimitingFilter.java +++ b/src/main/java/org/example/filter/RateLimitingFilter.java @@ -10,8 +10,15 @@ import java.util.logging.Logger; /** - * Filter responsible for limiting the number of requests per client IP. + * Rate Limiting Filter responsible for limiting the number of requests per client IP. * Implements the Token Bucket algorithm using the Bucket4j library. + * How it works: + * A "bucket" hold a fixed number of tokens (capacity) + * Each incoming request attempts to consume exactly one token + * If a token is available, the request is processed and the token is removed + * If the bucket is empty, the request is rejected with an HTTP 429 (Too Many Requests) status + * Tokens are replenished at a fixed rate over time (Refill Rate), up to the maximum capaci + * This allows for occasional bursts of traffic while maintaining a steady long-term rate limit * The capacity of the bucket is 10, and it refills one token per 10 seconds */ From e72f0730df605c758a9d3ac8392e54fa8c056200 Mon Sep 17 00:00:00 2001 From: Caroline Nordbrandt Date: Tue, 24 Feb 2026 14:40:55 +0100 Subject: [PATCH 30/54] Fix path in Dockerfile for `www` directory copy operation (#87) * Fix path in Dockerfile for `www` directory copy operation * Correct relative path for `www` directory in Dockerfile copy operation --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 2976670e..635cbbff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,6 @@ RUN addgroup -S appgroup && adduser -S appuser -G appgroup WORKDIR /app/ COPY --from=build /build/target/classes/ /app/ COPY --from=build /build/target/dependency/ /app/dependencies/ -COPY /www/ /www/ +COPY www/ ./www/ USER appuser ENTRYPOINT ["java", "-classpath", "/app:/app/dependencies/*", "org.example.App"] From ff4cd1260195a7446a638409bf2e852e0b2f488a Mon Sep 17 00:00:00 2001 From: Andreas Kaiberger <91787385+apaegs@users.noreply.github.com> Date: Tue, 24 Feb 2026 15:00:45 +0100 Subject: [PATCH 31/54] Feature/27 ipfilter (#70) * Add IpFilter and corresponding test skeleton Co-authored-by: Rickard Ankar * Extend IpFilter with blocklist mode and add unit tests Co-authored-by: Rickard Ankar * Enhance IpFilter to return 403 for blocked IPs and add corresponding test case Co-authored-by: Rickard Ankar * Extend IpFilter with allowlist mode and add corresponding unit tests Co-authored-by: Rickard Ankar * Refactor IpFilter to support both allowlist and blocklist modes, update logic, and add unit tests for allowlist mode Co-authored-by: Rickard Ankar * Handle missing client IP in IpFilter, return 400 response, and add corresponding test case Co-authored-by: Rickard Ankar * Refactor tests in `IpFilterTest` to use `assertAll` for improved assertion grouping and readability Co-authored-by: Rickard Ankar * Refactor `IpFilter` to use `HttpResponse` instead of `HttpResponseBuilder` and update tests accordingly Co-authored-by: Rickard Ankar * Add unit tests for edge cases in `IpFilter` allowlist and blocklist modes Co-authored-by: Rickard Ankar * Refactor `IpFilter` and tests to use `HttpResponseBuilder` instead of `HttpResponse` Co-authored-by: Rickard Ankar * Handle empty client IP in `IpFilter`, return 400 response, and add corresponding test case Co-authored-by: Rickard Ankar * Add comments to `IpFilter` empty methods, clarifying no action is required Co-authored-by: Rickard Ankar * Fix typos in comments within `IpFilterTest` Co-authored-by: Rickard Ankar * Add Javadoc comments to `IpFilter` and `IpFilterTest` for improved clarity and documentation * Refactor `IpFilter` to use thread-safe collections and normalize IP addresses * Make `mode` field in `IpFilter` volatile to ensure thread safety * Ensure UTF-8 encoding for response string in `IpFilterTest` and add attribute management to `HttpRequest` * Ensure UTF-8 encoding for response string in `IpFilterTest` and add attribute management to `HttpRequest` Co-authored-by: Rickard Ankar * Integrate IP filtering into `ConnectionHandler` and update configuration to support filter settings Co-authored-by: Rickard Ankar * Refactor IP filter check in `ConnectionHandler` to use `Boolean.TRUE.equals` for improved null safety Co-authored-by: Rickard Ankar * Validate null inputs for allowed/blocked IPs in `IpFilter`, enhance test coverage, and fix typographical error in comments Co-authored-by: Rickard Ankar * refactor: extract applyFilters() method using FilterChainImpl Co-authored-by: Andreas Kaiberger * refactor: cache filter list at construction time Co-authored-by: Andreas Kaiberger * refactor: cache filter list at construction time Co-authored-by: Andreas Kaiberger * test: verify GPG signing * Replace hardcoded status codes in `IpFilter` and `ConnectionHandler` with constants from `HttpResponseBuilder` for better maintainability Co-authored-by: Rickard Ankar --------- Co-authored-by: Rickard Ankar --- .../java/org/example/ConnectionHandler.java | 80 ++++++- .../java/org/example/config/AppConfig.java | 32 ++- .../java/org/example/filter/IpFilter.java | 108 ++++++++++ .../org/example/http/HttpResponseBuilder.java | 4 + .../org/example/httpparser/HttpRequest.java | 8 + src/main/resources/application.yml | 8 +- .../java/org/example/filter/IpFilterTest.java | 203 ++++++++++++++++++ 7 files changed, 438 insertions(+), 5 deletions(-) create mode 100644 src/main/java/org/example/filter/IpFilter.java create mode 100644 src/test/java/org/example/filter/IpFilterTest.java diff --git a/src/main/java/org/example/ConnectionHandler.java b/src/main/java/org/example/ConnectionHandler.java index 1a0861ff..9fc219d4 100644 --- a/src/main/java/org/example/ConnectionHandler.java +++ b/src/main/java/org/example/ConnectionHandler.java @@ -1,6 +1,15 @@ package org.example; +import org.example.config.AppConfig; +import org.example.filter.IpFilter; import org.example.httpparser.HttpParser; +import org.example.httpparser.HttpRequest; +import java.util.ArrayList; +import java.util.List; +import org.example.filter.Filter; +import org.example.filter.FilterChainImpl; +import org.example.http.HttpResponseBuilder; +import org.example.config.ConfigLoader; import java.io.IOException; import java.net.Socket; @@ -9,21 +18,66 @@ public class ConnectionHandler implements AutoCloseable { Socket client; String uri; + private final List filters; public ConnectionHandler(Socket client) { this.client = client; + this.filters = buildFilters(); } +private List buildFilters() { + List list = new ArrayList<>(); + AppConfig config = ConfigLoader.get(); + AppConfig.IpFilterConfig ipFilterConfig = config.ipFilter(); + if (Boolean.TRUE.equals(ipFilterConfig.enabled())) { + list.add(createIpFilterFromConfig(ipFilterConfig)); + } + // Add more filters here... + return list; + } + public void runConnectionHandler() throws IOException { - StaticFileHandler sfh = new StaticFileHandler(); HttpParser parser = new HttpParser(); parser.setReader(client.getInputStream()); parser.parseRequest(); parser.parseHttp(); + + HttpRequest request = new HttpRequest( + parser.getMethod(), + parser.getUri(), + parser.getVersion(), + parser.getHeadersMap(), + "" + ); + + String clientIp = client.getInetAddress().getHostAddress(); + request.setAttribute("clientIp", clientIp); + + HttpResponseBuilder response = applyFilters(request); + + int statusCode = response.getStatusCode(); + if (statusCode == HttpResponseBuilder.SC_FORBIDDEN || + statusCode == HttpResponseBuilder.SC_BAD_REQUEST) { + byte[] responseBytes = response.build(); + client.getOutputStream().write(responseBytes); + client.getOutputStream().flush(); + return; + } + resolveTargetFile(parser.getUri()); + StaticFileHandler sfh = new StaticFileHandler(); sfh.sendGetRequest(client.getOutputStream(), uri); } + private HttpResponseBuilder applyFilters(HttpRequest request) { + HttpResponseBuilder response = new HttpResponseBuilder(); + + FilterChainImpl chain = new FilterChainImpl(filters); + chain.doFilter(request, response); + + return response; + } + private void resolveTargetFile(String uri) { if (uri.matches("/$")) { //matches(/) this.uri = "index.html"; @@ -39,4 +93,28 @@ private void resolveTargetFile(String uri) { public void close() throws Exception { client.close(); } + + private IpFilter createIpFilterFromConfig(AppConfig.IpFilterConfig config) { + IpFilter filter = new IpFilter(); + + // Set mode + if ("ALLOWLIST".equalsIgnoreCase(config.mode())) { + filter.setMode(IpFilter.FilterMode.ALLOWLIST); + } else { + filter.setMode(IpFilter.FilterMode.BLOCKLIST); + } + + // Add blocked IPs + for (String ip : config.blockedIps()) { + filter.addBlockedIp(ip); + } + + // Add allowed IPs + for (String ip : config.allowedIps()) { + filter.addAllowedIp(ip); + } + + filter.init(); + return filter; + } } diff --git a/src/main/java/org/example/config/AppConfig.java b/src/main/java/org/example/config/AppConfig.java index 00134b20..db689ed5 100644 --- a/src/main/java/org/example/config/AppConfig.java +++ b/src/main/java/org/example/config/AppConfig.java @@ -6,16 +6,22 @@ @JsonIgnoreProperties(ignoreUnknown = true) public record AppConfig( @JsonProperty("server") ServerConfig server, - @JsonProperty("logging") LoggingConfig logging + @JsonProperty("logging") LoggingConfig logging, + @JsonProperty("ipFilter") IpFilterConfig ipFilter ) { public static AppConfig defaults() { - return new AppConfig(ServerConfig.defaults(), LoggingConfig.defaults()); + return new AppConfig( + ServerConfig.defaults(), + LoggingConfig.defaults(), + IpFilterConfig.defaults() + ); } public AppConfig withDefaultsApplied() { ServerConfig serverConfig = (server == null ? ServerConfig.defaults() : server.withDefaultsApplied()); LoggingConfig loggingConfig = (logging == null ? LoggingConfig.defaults() : logging.withDefaultsApplied()); - return new AppConfig(serverConfig, loggingConfig); + IpFilterConfig ipFilterConfig = (ipFilter == null ? IpFilterConfig.defaults() : ipFilter.withDefaultsApplied()); // ← LÄGG TILL + return new AppConfig(serverConfig, loggingConfig, ipFilterConfig); // ← UPPDATERA DENNA RAD } @JsonIgnoreProperties(ignoreUnknown = true) @@ -50,4 +56,24 @@ public LoggingConfig withDefaultsApplied() { return new LoggingConfig(lvl); } } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record IpFilterConfig( + @JsonProperty("enabled") Boolean enabled, + @JsonProperty("mode") String mode, + @JsonProperty("blockedIps") java.util.List blockedIps, + @JsonProperty("allowedIps") java.util.List allowedIps + ) { + public static IpFilterConfig defaults() { + return new IpFilterConfig(false, "BLOCKLIST", java.util.List.of(), java.util.List.of()); + } + + public IpFilterConfig withDefaultsApplied() { + Boolean e = (enabled == null) ? false : enabled; + String m = (mode == null || mode.isBlank()) ? "BLOCKLIST" : mode; + java.util.List blocked = (blockedIps == null) ? java.util.List.of() : blockedIps; + java.util.List allowed = (allowedIps == null) ? java.util.List.of() : allowedIps; + return new IpFilterConfig(e, m, blocked, allowed); + } + } } diff --git a/src/main/java/org/example/filter/IpFilter.java b/src/main/java/org/example/filter/IpFilter.java new file mode 100644 index 00000000..e9c877f2 --- /dev/null +++ b/src/main/java/org/example/filter/IpFilter.java @@ -0,0 +1,108 @@ +package org.example.filter; + + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * A filter that allows or blocks HTTP requests based on the client's IP address. + * The filter supports two modes: + * ALLOWLIST – only IP addresses in the allowlist are permitted + * BLOCKLIST – all IP addresses are permitted except those in the blocklist + */ +public class IpFilter implements Filter { + + private final Set blockedIps = ConcurrentHashMap.newKeySet(); + private final Set allowedIps = ConcurrentHashMap.newKeySet(); + private volatile FilterMode mode = FilterMode.BLOCKLIST; + + /** + * Defines the filtering mode. + */ + public enum FilterMode { + ALLOWLIST, + BLOCKLIST + } + + @Override + public void init() { + // Intentionally empty - no initialization needed + } + + /** + * Filters incoming HTTP requests based on the client's IP address. + * + * @param request the incoming HTTP request + * @param response the HTTP response builder used when blocking requests + * @param chain the filter chain to continue if the request is allowed + */ + @Override + public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) { + String clientIp = normalizeIp((String) request.getAttribute("clientIp")); + + if (clientIp == null || clientIp.trim().isEmpty()) { + response.setStatusCode(HttpResponseBuilder.SC_BAD_REQUEST); + response.setBody("Bad Request: Missing client IP address"); + return; + } + + boolean allowed = isIpAllowed(clientIp); + + if (allowed) { + chain.doFilter(request, response); + } else { + response.setStatusCode(HttpResponseBuilder.SC_FORBIDDEN); + response.setBody("Forbidden: IP address " + clientIp + " is not allowed"); + } + } + + @Override + public void destroy() { + // Intentionally empty - no cleanup needed + } + + /** + * Determines whether an IP address is allowed based on the current filter mode. + * + * @param ip the IP address to check + * @return true if the IP address is allowed, otherwise false + */ + private boolean isIpAllowed(String ip) { + if (mode == FilterMode.ALLOWLIST) { + return allowedIps.contains(ip); + } else { + return !blockedIps.contains(ip); + } + } + + /** + * Trims leading and trailing whitespace from an IP address. + * + * @param ip the IP address + * @return the trimmed IP address, or {@code null} if the input is {@code null} + */ + private String normalizeIp(String ip) { + return ip == null ? null : ip.trim(); + } + + public void setMode(FilterMode mode) { + this.mode = mode; + } + + public void addBlockedIp(String ip) { + if (ip == null) { + throw new IllegalArgumentException("IP address cannot be null"); + } + blockedIps.add(normalizeIp(ip)); + } + + public void addAllowedIp(String ip) { + if (ip == null) { + throw new IllegalArgumentException("IP address cannot be null"); + } + allowedIps.add(normalizeIp(ip)); + } +} diff --git a/src/main/java/org/example/http/HttpResponseBuilder.java b/src/main/java/org/example/http/HttpResponseBuilder.java index e84579e9..9b6ed2a7 100644 --- a/src/main/java/org/example/http/HttpResponseBuilder.java +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -65,6 +65,10 @@ public void setStatusCode(int statusCode) { this.statusCode = statusCode; } + public int getStatusCode() { + return this.statusCode; + } + public void setBody(String body) { this.body = body != null ? body : ""; this.bytebody = null; diff --git a/src/main/java/org/example/httpparser/HttpRequest.java b/src/main/java/org/example/httpparser/HttpRequest.java index ad65d496..18f0e561 100644 --- a/src/main/java/org/example/httpparser/HttpRequest.java +++ b/src/main/java/org/example/httpparser/HttpRequest.java @@ -1,6 +1,7 @@ package org.example.httpparser; import java.util.Collections; +import java.util.HashMap; import java.util.Map; /* @@ -15,6 +16,7 @@ public class HttpRequest { private final String version; private final Map headers; private final String body; + private final Map attributes = new HashMap<>(); public HttpRequest(String method, String path, @@ -38,4 +40,10 @@ public Map getHeaders() { return headers; } public 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/main/resources/application.yml b/src/main/resources/application.yml index 8f2ef3a7..a57443be 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,4 +3,10 @@ server: rootDir: ./www logging: - level: INFO \ No newline at end of file + level: INFO + +ipFilter: + enabled: false + mode: "BLOCKLIST" + blockedIps: [ ] + allowedIps: [ ] diff --git a/src/test/java/org/example/filter/IpFilterTest.java b/src/test/java/org/example/filter/IpFilterTest.java new file mode 100644 index 00000000..3b556b43 --- /dev/null +++ b/src/test/java/org/example/filter/IpFilterTest.java @@ -0,0 +1,203 @@ +package org.example.filter; + +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.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +/** + * Integration tests for IpFilter. + * Verifies behavior in both ALLOWLIST and BLOCKLIST modes. + */ +class IpFilterTest { + + private IpFilter ipFilter; + private HttpResponseBuilder response; + private FilterChain mockChain; + private boolean chainCalled; + + @BeforeEach + void setUp() { + ipFilter = new IpFilter(); + response = new HttpResponseBuilder(); + chainCalled = false; + mockChain = (req, resp) -> chainCalled = true; + } + + @Test + void testBlocklistMode_AllowsUnblockedIp() { + // ARRANGE + ipFilter.setMode(IpFilter.FilterMode.BLOCKLIST); + ipFilter.addBlockedIp("192.168.1.100"); + ipFilter.init(); + + HttpRequest request = createRequestWithIp("192.168.1.50"); + + // ACT + ipFilter.doFilter(request, response, mockChain); + + // ASSERT + assertThat(chainCalled).isTrue(); + } + + @Test + void testBlocklistMode_BlocksBlockedIp() { + // ARRANGE + ipFilter.setMode(IpFilter.FilterMode.BLOCKLIST); + ipFilter.addBlockedIp("192.168.1.100"); + ipFilter.init(); + + HttpRequest request = createRequestWithIp("192.168.1.100"); + + // ACT + ipFilter.doFilter(request, response, mockChain); + + // ASSERT + String result = new String(response.build(), StandardCharsets.UTF_8); + assertAll( + () -> assertThat(chainCalled).isFalse(), + () -> assertThat(result).contains("403"), + () -> assertThat(result).contains("Forbidden") + ); + } + + @Test + void testAllowListMode_AllowsWhitelistedIp() { + // ARRANGE + ipFilter.setMode(IpFilter.FilterMode.ALLOWLIST); + ipFilter.addAllowedIp("10.0.0.1"); + ipFilter.init(); + + HttpRequest request = createRequestWithIp("10.0.0.1"); + + // ACT + ipFilter.doFilter(request, response, mockChain); + + // ASSERT + assertThat(chainCalled).isTrue(); + } + + @Test + void testAllowListMode_BlockNonWhitelistedIp() { + // ARRANGE + ipFilter.setMode(IpFilter.FilterMode.ALLOWLIST); + ipFilter.addAllowedIp("10.0.0.1"); + ipFilter.init(); + + HttpRequest request = createRequestWithIp("10.0.0.2"); + + // ACT + ipFilter.doFilter(request, response, mockChain); + + // ASSERT + assertThat(chainCalled).isFalse(); + + String result = new String(response.build(), StandardCharsets.UTF_8); + assertThat(result).contains("403"); + } + + @Test + void testMissingClientIp_Returns400() { + // ARRANGE + HttpRequest request = new HttpRequest( + "GET", + "/", + "HTTP/1.1", + Collections.emptyMap(), + "" + ); + + // ACT + ipFilter.doFilter(request, response, mockChain); + + // ASSERT + String result = new String(response.build(), StandardCharsets.UTF_8); + assertAll( + () -> assertThat(chainCalled).isFalse(), + () -> assertThat(result).contains("400"), + () -> assertThat(result).contains("Bad Request") + ); + } + + private HttpRequest createRequestWithIp(String ip) { + HttpRequest request = new HttpRequest( + "GET", + "/", + "HTTP/1.1", + Collections.emptyMap(), + "" + ); + request.setAttribute("clientIp", ip); + return request; + } + + // EDGE CASES + + @Test + void testEmptyAllowlist_BlocksAllIps() { + // ARRANGE + ipFilter.setMode(IpFilter.FilterMode.ALLOWLIST); + // Do not add Ip to the list + + // ACT + HttpRequest request = createRequestWithIp("1.2.3.4"); + ipFilter.doFilter(request, response, mockChain); + + // ASSERT + assertThat(chainCalled).isFalse(); + } + + @Test + void testEmptyBlocklist_AllowAllIps() { + // ARRANGE + ipFilter.setMode(IpFilter.FilterMode.BLOCKLIST); + // Do not add Ip to the list + + // ACT + HttpRequest request = createRequestWithIp("1.2.3.4"); + ipFilter.doFilter(request, response, mockChain); + + // ASSERT + assertThat(chainCalled).isTrue(); + } + + @Test + void testEmptyStringIp() { + // ARRANGE + ipFilter.setMode(IpFilter.FilterMode.BLOCKLIST); + HttpRequest request = createRequestWithIp(""); + + // ACT + ipFilter.doFilter(request, response, mockChain); + + // ASSERT + String result = new String(response.build(), StandardCharsets.UTF_8); + assertAll( + () -> assertThat(chainCalled).isFalse(), + () -> assertThat(result).contains("400"), + () -> assertThat(result).contains("Bad Request") + ); + } + + @Test + void testAddBlockedIp_ThrowsOnNull() { + assertThatThrownBy(() -> ipFilter.addBlockedIp(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot be null"); + } + + @Test + void testAddAllowedIp_ThrowsOnNull() { + assertThatThrownBy(() -> ipFilter.addAllowedIp(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot be null"); + } + +} From c46f6e6877f32ffe50773e57b068a7fc6c9b53e1 Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Tue, 24 Feb 2026 15:03:56 +0100 Subject: [PATCH 32/54] Update 429 error response with HTML body --- src/main/java/org/example/filter/RateLimitingFilter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/example/filter/RateLimitingFilter.java b/src/main/java/org/example/filter/RateLimitingFilter.java index a00decc3..839ff9fb 100644 --- a/src/main/java/org/example/filter/RateLimitingFilter.java +++ b/src/main/java/org/example/filter/RateLimitingFilter.java @@ -50,7 +50,7 @@ public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterCh } else { logger.warning("Limit exceeded per IP: " + clientIp); response.setStatusCode(HttpResponseBuilder.SC_TOO_MANY_REQUESTS); - response.setBody("429 Too Many Requests: limit of requests exceeded.\n"); + response.setBody("

429 Too Many Requests

Limit of requests exceeded.

\n"); } } From 4140c9589c23aa222c9fb05633c49e222a1f5f04 Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Tue, 24 Feb 2026 15:10:43 +0100 Subject: [PATCH 33/54] Add Rate Limiting Filter in connection handler --- src/main/java/org/example/ConnectionHandler.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/example/ConnectionHandler.java b/src/main/java/org/example/ConnectionHandler.java index 9fc219d4..a9685cdc 100644 --- a/src/main/java/org/example/ConnectionHandler.java +++ b/src/main/java/org/example/ConnectionHandler.java @@ -26,6 +26,9 @@ public ConnectionHandler(Socket client) { } private List buildFilters() { List list = new ArrayList<>(); + + list.add(new org.example.filter.RateLimitingFilter()); + AppConfig config = ConfigLoader.get(); AppConfig.IpFilterConfig ipFilterConfig = config.ipFilter(); if (Boolean.TRUE.equals(ipFilterConfig.enabled())) { @@ -57,7 +60,8 @@ public void runConnectionHandler() throws IOException { int statusCode = response.getStatusCode(); if (statusCode == HttpResponseBuilder.SC_FORBIDDEN || - statusCode == HttpResponseBuilder.SC_BAD_REQUEST) { + statusCode == HttpResponseBuilder.SC_BAD_REQUEST || + statusCode == HttpResponseBuilder.SC_TOO_MANY_REQUESTS) { byte[] responseBytes = response.build(); client.getOutputStream().write(responseBytes); client.getOutputStream().flush(); From 7652687af167b0ad9be4b1a83f26fd3d3c619b48 Mon Sep 17 00:00:00 2001 From: AntonAhlqvist Date: Wed, 25 Feb 2026 08:48:17 +0100 Subject: [PATCH 34/54] Feature/LocaleFilter (#81) * Re-commit LocaleFilter + tests to clean branch for PR * Update LocaleFilter to handle quality weights and improve javadoc * Fix: rename test method to reflect actual headers scenario * Fix: ensure resolveLocale never returns empty string; strip quality weights --- .../java/org/example/filter/LocaleFilter.java | 98 +++++++++++++++++++ .../org/example/filter/LocaleFilterTest.java | 96 ++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 src/main/java/org/example/filter/LocaleFilter.java create mode 100644 src/test/java/org/example/filter/LocaleFilterTest.java diff --git a/src/main/java/org/example/filter/LocaleFilter.java b/src/main/java/org/example/filter/LocaleFilter.java new file mode 100644 index 00000000..c02f634e --- /dev/null +++ b/src/main/java/org/example/filter/LocaleFilter.java @@ -0,0 +1,98 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; + +import java.util.Map; + +/** + * Filter that extracts the preferred locale from the Accept-Language header of an HTTP request. + *

+ * If the Accept-Language header is missing, blank, or malformed, the filter defaults to "en-US". + * The selected locale is stored in a ThreadLocal variable so it can be accessed during the request. + *

+ * This filter does not modify the response or stop the filter chain; it simply sets the + * current locale and forwards the request to the next filter in the chain. + *

+ * ThreadLocal cleanup is performed after the filter chain completes to prevent memory leaks. + */ +public class LocaleFilter implements Filter { + + private static final String DEFAULT_LOCALE = "en-US"; + private static final ThreadLocal currentLocale = new ThreadLocal<>(); + + @Override + public void init() { + } + + @Override + public void doFilter(HttpRequest request, + HttpResponseBuilder response, + FilterChain chain) { + try { + String locale = resolveLocale(request); + currentLocale.set(locale); + + chain.doFilter(request, response); + } finally { + currentLocale.remove(); + } + } + + @Override + public void destroy() { + } + + public static String getCurrentLocale() { + String locale = currentLocale.get(); + if (locale != null) { + return locale; + } else { + return DEFAULT_LOCALE; + } + } + + /** + * Determines the preferred locale from the Accept-Language header of the request. + * If the header is missing, blank, or malformed, this method returns the default locale "en-US". + * The first language tag is used, and any optional quality value (e.g., ";q=0.9") is stripped. + * If the request itself is null, the default locale is also returned. + */ + private String resolveLocale(HttpRequest request) { + + if (request == null) { + return DEFAULT_LOCALE; + } + + Map headers = request.getHeaders(); + if (headers == null || headers.isEmpty()) { + return DEFAULT_LOCALE; + } + + String acceptLanguage = null; + + for (Map.Entry entry : headers.entrySet()) { + if (entry.getKey() != null && + entry.getKey().equalsIgnoreCase("Accept-Language")) { + acceptLanguage = entry.getValue(); + break; + } + } + + if (acceptLanguage == null || acceptLanguage.isBlank()) { + return DEFAULT_LOCALE; + } + + String[] parts = acceptLanguage.split(","); + if (parts[0].isBlank()) { + return DEFAULT_LOCALE; + } + + String locale = parts[0].split(";")[0].trim(); + if (locale.isEmpty()) { + return DEFAULT_LOCALE; + } else { + return locale; + } + } +} diff --git a/src/test/java/org/example/filter/LocaleFilterTest.java b/src/test/java/org/example/filter/LocaleFilterTest.java new file mode 100644 index 00000000..98e626b3 --- /dev/null +++ b/src/test/java/org/example/filter/LocaleFilterTest.java @@ -0,0 +1,96 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; +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()); + } + + @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()); + }); + } + + @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()); + }); + } + + @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()); + }); + } + + @Test + void shouldUseDefaultWhenRequestIsNull() { + LocaleFilter filter = new LocaleFilter(); + HttpResponseBuilder response = new HttpResponseBuilder(); + + filter.doFilter(null, response, (req, res) -> { + assertEquals("en-US", LocaleFilter.getCurrentLocale()); + }); + } + + @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()); + }); + } +} From ff5e13ecdd8a75b0d7482374bae810ba14c376c6 Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Wed, 25 Feb 2026 10:38:29 +0100 Subject: [PATCH 35/54] Add guard against mission/invalid client ip --- src/main/java/org/example/filter/RateLimitingFilter.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/example/filter/RateLimitingFilter.java b/src/main/java/org/example/filter/RateLimitingFilter.java index 839ff9fb..75a87945 100644 --- a/src/main/java/org/example/filter/RateLimitingFilter.java +++ b/src/main/java/org/example/filter/RateLimitingFilter.java @@ -41,8 +41,14 @@ public void init() { @Override public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) { - String clientIp = (String) request.getAttribute("clientIp"); + Object clientIpAttr = request.getAttribute("clientIp"); + if (!(clientIpAttr instanceof String) || ((String) clientIpAttr).isBlank()) { + response.setStatusCode(HttpResponseBuilder.SC_BAD_REQUEST); + response.setBody("

400 Bad Request

Missing client IP.

\n"); + return; + } + String clientIp = (String) clientIpAttr; Bucket bucket = buckets.computeIfAbsent(clientIp, k -> createNewBucket()); if (bucket.tryConsume(1)) { From d6f1d2602c82da38d3415f11005a456338291b68 Mon Sep 17 00:00:00 2001 From: Anna Ziafar <229710576+AnnaZiafar@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:48:39 +0100 Subject: [PATCH 36/54] Create LoggFilter (#83) * Add LoggFilter and update HttpResponseBuilder with method getStatusCode() in order to use it in logging * Refactor code * Change magic number to constans in httpresponsebuilder * Add LoggFilter and update HttpResponseBuilder with method getStatusCode() in order to use it in logging * Refactor code * Added tests -> LoggingFilterTest * Trigger rebuild * Git is hallucinating. Trying to fix problem * Final version (hopefully) --- .../org/example/filter/LoggingFilter.java | 42 +++++++++ .../org/example/filter/LoggingFilterTest.java | 91 +++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 src/main/java/org/example/filter/LoggingFilter.java create mode 100644 src/test/java/org/example/filter/LoggingFilterTest.java diff --git a/src/main/java/org/example/filter/LoggingFilter.java b/src/main/java/org/example/filter/LoggingFilter.java new file mode 100644 index 00000000..a978e108 --- /dev/null +++ b/src/main/java/org/example/filter/LoggingFilter.java @@ -0,0 +1,42 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; + +import java.util.logging.Logger; + +public class LoggingFilter implements Filter { + + private static final Logger logg = Logger.getLogger(LoggingFilter.class.getName()); + + @Override + public void init() { + //No initialization needed + } + + @Override + public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) { + long startTime = System.nanoTime(); + + try { + chain.doFilter(request, response); + } catch (Exception e) { + if(response.getStatusCode() == HttpResponseBuilder.SC_OK) + response.setStatusCode(HttpResponseBuilder.SC_INTERNAL_SERVER_ERROR); + } finally { + long endTime = System.nanoTime(); + long processingTimeInMs = (endTime - startTime) / 1000000; + + String message = String.format("REQUEST: %s %s | STATUS: %s | TIME: %dms", + request.getMethod(), request.getPath(), response.getStatusCode(), processingTimeInMs); + + logg.info(message); + } + + } + + @Override + public void destroy() { + //No initialization needed + } +} diff --git a/src/test/java/org/example/filter/LoggingFilterTest.java b/src/test/java/org/example/filter/LoggingFilterTest.java new file mode 100644 index 00000000..3e9e248d --- /dev/null +++ b/src/test/java/org/example/filter/LoggingFilterTest.java @@ -0,0 +1,91 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.logging.Handler; +import java.util.logging.LogRecord; +import java.util.logging.Logger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class LoggingFilterTest { + + @Mock Handler handler; + @Mock HttpRequest request; + @Mock HttpResponseBuilder response; + @Mock FilterChain chain; + + LoggingFilter filter = new LoggingFilter(); + Logger logger; + + @BeforeEach + void setup(){ + logger = Logger.getLogger(LoggingFilter.class.getName()); + logger.addHandler(handler); + + when(request.getMethod()).thenReturn("GET"); + when(request.getPath()).thenReturn("/index.html"); + } + + @AfterEach + void tearDown(){ + logger.removeHandler(handler); + } + + @Test + void loggingWorksWhenChainWorks(){ + when(response.getStatusCode()).thenReturn(HttpResponseBuilder.SC_OK); + + filter.doFilter(request, response, chain); + + verifyLogContent("REQUEST: GET /index.html | STATUS: 200 | TIME: "); + } + + @Test + void loggingWorksWhenErrorOccurs(){ + when(response.getStatusCode()).thenReturn(HttpResponseBuilder.SC_NOT_FOUND); + + doThrow(new RuntimeException()).when(chain).doFilter(request, response); + + filter.doFilter(request, response, chain); + + verifyLogContent("REQUEST: GET /index.html | STATUS: 404 | TIME: "); + } + + @Test + void statusChangesFrom200To500WhenErrorOccurs(){ + //Return status 200 first time. + //When error is thrown status should switch to 500 (if it was originally 200) + when(response.getStatusCode()) + .thenReturn(HttpResponseBuilder.SC_OK) + .thenReturn(HttpResponseBuilder.SC_INTERNAL_SERVER_ERROR); + + doThrow(new RuntimeException()).when(chain).doFilter(request, response); + + filter.doFilter(request, response, chain); + verify(response).setStatusCode(HttpResponseBuilder.SC_INTERNAL_SERVER_ERROR); + + verifyLogContent("REQUEST: GET /index.html | STATUS: 500 | TIME: "); + } + + private void verifyLogContent(String expectedMessage){ + //Use ArgumentCaptor to capture the actual message in the log + ArgumentCaptor logCaptor = ArgumentCaptor.forClass(LogRecord.class); + verify(handler).publish(logCaptor.capture()); + + String message = logCaptor.getValue().getMessage(); + + assertThat(message).contains(expectedMessage); + } + +} From 65838332a4b54896801bab0a1f1339e8ea7540c9 Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Wed, 25 Feb 2026 14:40:33 +0100 Subject: [PATCH 37/54] =?UTF-8?q?Add=20method=20for=20prevent=20unbounded?= =?UTF-8?q?=20growth=20of=20buckets=20per=E2=80=91IP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/filter/RateLimitingFilter.java | 65 ++++++++++++++++--- 1 file changed, 55 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/example/filter/RateLimitingFilter.java b/src/main/java/org/example/filter/RateLimitingFilter.java index 75a87945..27a6cb48 100644 --- a/src/main/java/org/example/filter/RateLimitingFilter.java +++ b/src/main/java/org/example/filter/RateLimitingFilter.java @@ -24,20 +24,21 @@ public class RateLimitingFilter implements Filter { private static final Logger logger = Logger.getLogger(RateLimitingFilter.class.getName()); - private static final Map buckets = new ConcurrentHashMap<>(); - private static final long capacity = 10; - private static final long refillTokens = 1; - private final Duration refillPeriod = Duration.ofSeconds(10); + private static final Map buckets = new ConcurrentHashMap<>(); + private static final long CAPACITY = 10; + private static final long REFILL_TOKENS = 1; + private final Duration REFILL_PERIOD = Duration.ofSeconds(10); + private static final int MAX_BUCKETS_THRESHOLD = 1000; @Override public void init() { - logger.info("RateLimitingFilter initialized with capacity: " + capacity); + logger.info("RateLimitingFilter initialized with capacity: " + CAPACITY); + startCleanupThread(); } /** * Intercepts the request and checks if the client has enough tokens. */ - @Override public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) { @@ -49,9 +50,12 @@ public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterCh } String clientIp = (String) clientIpAttr; - Bucket bucket = buckets.computeIfAbsent(clientIp, k -> createNewBucket()); - if (bucket.tryConsume(1)) { + BucketWrapper wrapper = buckets.computeIfAbsent(clientIp, k -> new BucketWrapper(createNewBucket())); + + wrapper.updateAccess(); + + if (wrapper.bucket.tryConsume(1)) { chain.doFilter(request, response); } else { logger.warning("Limit exceeded per IP: " + clientIp); @@ -71,8 +75,8 @@ public void destroy() { private Bucket createNewBucket() { return Bucket.builder() .addLimit(Bandwidth.builder() - .capacity(capacity) - .refillGreedy(refillTokens, refillPeriod) + .capacity(CAPACITY) + .refillGreedy(REFILL_TOKENS, REFILL_PERIOD) .build()) .build(); } @@ -80,4 +84,45 @@ private Bucket createNewBucket() { public void clearBuckets(){ buckets.clear(); } + + /** + * Track the last access time of every bucket + */ + private static class BucketWrapper { + private final Bucket bucket; + private volatile long lastAccessTime; + + BucketWrapper(Bucket bucket) { + this.bucket = bucket; + this.lastAccessTime = System.currentTimeMillis(); + } + + void updateAccess() { + this.lastAccessTime = System.currentTimeMillis(); + } + } + + private void startCleanupThread() { + Thread cleanupThread = new Thread(() -> { + while (!Thread.currentThread().isInterrupted()) { + try { + //it checks every 10 minutes + Thread.sleep(Duration.ofMinutes(10).toMillis()); + + //it will only clean when the size of the buckets is more than 1000 + if (buckets.size() > MAX_BUCKETS_THRESHOLD) { + long idleThreshold = System.currentTimeMillis() - Duration.ofMinutes(30).toMillis(); + buckets.entrySet().removeIf(entry -> entry.getValue().lastAccessTime < idleThreshold); + } + + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + }); + cleanupThread.setDaemon(true); + cleanupThread.start(); + } + } From 27e627c7f9726a0a3440ebe24a7078a98fe23ff2 Mon Sep 17 00:00:00 2001 From: Anna Ziafar <229710576+AnnaZiafar@users.noreply.github.com> Date: Wed, 25 Feb 2026 15:13:15 +0100 Subject: [PATCH 38/54] Return status code 500 (#79) * Add error handling for when client request fail -> return internal server error 500 * Create ConnectionFactory in order to be able to test the TcpServer. Implement factory into TcpServer and App. * Create test to check whether handleClient throws internal server error when there is an exception * Modify test * Update logic -> seperate handleClient and processRequest in order to have open socket when handling internal server error * Exchange Printwriter to Outputstream in handleInternalServerError * Add error handling for when client request fail -> return internal server error 500 * Create ConnectionFactory in order to be able to test the TcpServer. Implement factory into TcpServer and App. * Create test to check whether handleClient throws internal server error when there is an exception * Modify test * Update logic -> seperate handleClient and processRequest in order to have open socket when handling internal server error * Exchange Printwriter to Outputstream in handleInternalServerError * Rebase main onto bransch. Update App -> add Connectionhandler::new Also update handleInternalServerError --- src/main/java/org/example/App.java | 3 +- .../java/org/example/ConnectionFactory.java | 7 +++ src/main/java/org/example/TcpServer.java | 49 +++++++++++++++++-- src/test/java/org/example/TcpServerTest.java | 40 +++++++++++++++ 4 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 src/main/java/org/example/ConnectionFactory.java create mode 100644 src/test/java/org/example/TcpServerTest.java diff --git a/src/main/java/org/example/App.java b/src/main/java/org/example/App.java index 75c2914b..966e9563 100644 --- a/src/main/java/org/example/App.java +++ b/src/main/java/org/example/App.java @@ -3,6 +3,7 @@ import org.example.config.AppConfig; import org.example.config.ConfigLoader; +import java.net.Socket; import java.nio.file.Path; public class App { @@ -16,7 +17,7 @@ public static void main(String[] args) { int port = resolvePort(args, appConfig.server().port()); - new TcpServer(port).start(); + new TcpServer(port, ConnectionHandler::new).start(); } static int resolvePort(String[] args, int configPort) { diff --git a/src/main/java/org/example/ConnectionFactory.java b/src/main/java/org/example/ConnectionFactory.java new file mode 100644 index 00000000..5f9ac7dc --- /dev/null +++ b/src/main/java/org/example/ConnectionFactory.java @@ -0,0 +1,7 @@ +package org.example; + +import java.net.Socket; + +public interface ConnectionFactory { + ConnectionHandler create(Socket socket); +} diff --git a/src/main/java/org/example/TcpServer.java b/src/main/java/org/example/TcpServer.java index 3f96b4d8..e0a3655d 100644 --- a/src/main/java/org/example/TcpServer.java +++ b/src/main/java/org/example/TcpServer.java @@ -1,15 +1,23 @@ package org.example; +import org.example.http.HttpResponseBuilder; + import java.io.IOException; +import java.io.OutputStream; +import java.io.PrintWriter; import java.net.ServerSocket; import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.Map; public class TcpServer { private final int port; + private final ConnectionFactory connectionFactory; - public TcpServer(int port) { + public TcpServer(int port, ConnectionFactory connectionFactory) { this.port = port; + this.connectionFactory = connectionFactory; } public void start() { @@ -26,11 +34,42 @@ public void start() { } } - private void handleClient(Socket client) { - try (ConnectionHandler connectionHandler = new ConnectionHandler(client)) { - connectionHandler.runConnectionHandler(); + protected void handleClient(Socket client) { + try(client){ + processRequest(client); + } catch (Exception e) { + throw new RuntimeException("Failed to close socket", e); + } + } + + private void processRequest(Socket client) throws Exception { + ConnectionHandler handler = null; + try{ + handler = connectionFactory.create(client); + handler.runConnectionHandler(); } catch (Exception e) { - throw new RuntimeException("Error handling client connection " + e); + handleInternalServerError(client); + } finally { + if(handler != null) + handler.close(); + } + } + + + private void handleInternalServerError(Socket client){ + HttpResponseBuilder response = new HttpResponseBuilder(); + response.setStatusCode(HttpResponseBuilder.SC_INTERNAL_SERVER_ERROR); + response.setHeaders(Map.of("Content-Type", "text/plain; charset=utf-8")); + response.setBody("⚠️ Internal Server Error 500 ⚠️"); + + if (!client.isClosed()) { + try { + OutputStream out = client.getOutputStream(); + out.write(response.build()); + out.flush(); + } catch (IOException e) { + System.err.println("Failed to send 500 response: " + e.getMessage()); + } } } } diff --git a/src/test/java/org/example/TcpServerTest.java b/src/test/java/org/example/TcpServerTest.java new file mode 100644 index 00000000..a62b3e95 --- /dev/null +++ b/src/test/java/org/example/TcpServerTest.java @@ -0,0 +1,40 @@ +package org.example; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import java.net.Socket; + +class TcpServerTest { + + + @Test + void failedClientRequestShouldReturnError500() throws Exception{ + ConnectionFactory mockFactory = Mockito.mock(ConnectionFactory.class); + ConnectionHandler mockHandler = Mockito.mock(ConnectionHandler.class); + TcpServer server = new TcpServer(0, mockFactory); + + Socket mockSocket = Mockito.mock(Socket.class); + java.io.ByteArrayOutputStream outputStream = new java.io.ByteArrayOutputStream(); + + when(mockSocket.getOutputStream()).thenReturn(outputStream); + when(mockFactory.create(any(Socket.class))).thenReturn(mockHandler); + + Mockito.doThrow(new RuntimeException("Simulated Crash")) + .when(mockHandler).runConnectionHandler(); + + server.handleClient(mockSocket); + + String response = outputStream.toString(); + assertAll( + () -> assertTrue(response.contains("500")), + () -> assertTrue(response.contains("Internal Server Error 500")), + () -> assertTrue(response.contains("Content-Type: text/plain")) + ); + } +} From db0c574b39d9d91613913a902f3932fcb7b34280 Mon Sep 17 00:00:00 2001 From: AntonAhlqvist Date: Wed, 25 Feb 2026 17:56:57 +0100 Subject: [PATCH 39/54] Feature/LocaleFilterCookie (#92) * Re-commit LocaleFilterWithCookie + tests to clean branch for PR * Fix: make header lookups case-insensitive to avoid incorrect default locale fallback. --- .../filter/LocaleFilterWithCookie.java | 138 ++++++++++++++++++ .../filter/LocaleFilterWithCookieTest.java | 96 ++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 src/main/java/org/example/filter/LocaleFilterWithCookie.java create mode 100644 src/test/java/org/example/filter/LocaleFilterWithCookieTest.java diff --git a/src/main/java/org/example/filter/LocaleFilterWithCookie.java b/src/main/java/org/example/filter/LocaleFilterWithCookie.java new file mode 100644 index 00000000..28f452ca --- /dev/null +++ b/src/main/java/org/example/filter/LocaleFilterWithCookie.java @@ -0,0 +1,138 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; + +import java.util.Map; + +/** + * Filter that determines the preferred locale for an HTTP request using cookies and headers. + *

+ * First, it checks for a locale set in a cookie named "user-lang". If the cookie is missing, + * blank, or malformed, it falls back to the Accept-Language header. If neither is present + * or valid, the filter defaults to "en-US". + *

+ * The selected locale is stored in a ThreadLocal variable so it can be accessed throughout + * the processing of the request. + *

+ * This filter does not modify the response or stop the filter chain; it only sets the + * current locale and forwards the request to the next filter. + *

+ * ThreadLocal cleanup is performed after the filter chain completes to prevent memory leaks. + */ +public class LocaleFilterWithCookie implements Filter { + + private static final String DEFAULT_LOCALE = "en-US"; + private static final String LOCALE_COOKIE_NAME = "user-lang"; + private static final ThreadLocal currentLocale = new ThreadLocal<>(); + + @Override + public void init() { + } + + @Override + public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) { + try { + String locale = resolveLocale(request); + currentLocale.set(locale); + + chain.doFilter(request, response); + } finally { + currentLocale.remove(); + } + } + + @Override + public void destroy() { + } + + public static String getCurrentLocale() { + String locale = currentLocale.get(); + if (locale != null) { + return locale; + } else { + return DEFAULT_LOCALE; + } + } + + private String resolveLocale(HttpRequest request) { + String cookieLocale = extractLocaleFromCookie(request); + if (cookieLocale != null && !cookieLocale.isBlank()) { + return cookieLocale; + } + + String headerLocale = extractLocaleFromHeader(request); + if (headerLocale != null && !headerLocale.isBlank()) { + return headerLocale; + } + + return DEFAULT_LOCALE; + } + + /** + * Extracts the locale from the "user-lang" cookie if present. + *

+ * If the cookie header is missing, blank, or malformed, returns null. + */ + private String extractLocaleFromCookie(HttpRequest request) { + Map headers = request.getHeaders(); + if (headers == null) { + return null; + } + + String cookieHeader = headers.entrySet().stream() + .filter(e -> e.getKey() != null && e.getKey().equalsIgnoreCase("Cookie")) + .map(Map.Entry::getValue) + .findFirst() + .orElse(null); + + if (cookieHeader == null || cookieHeader.isBlank()) { + return null; + } + + String[] cookies = cookieHeader.split(";"); + for (String cookie : cookies) { + String[] parts = cookie.trim().split("=", 2); + if (parts.length == 2) { + String name = parts[0].trim(); + String value = parts[1].trim(); + if (LOCALE_COOKIE_NAME.equals(name) && !value.isBlank()) { + return value; + } + } + } + + return null; + } + + /** + * Extracts the preferred locale from the Accept-Language header of the request. + *

+ * If the header is missing, blank, or malformed, returns null. + * The first language tag is used and any optional quality value (e.g., ";q=0.9") is stripped. + */ + private String extractLocaleFromHeader(HttpRequest request) { + Map headers = request.getHeaders(); + if (headers == null) { + return null; + } + + String acceptLanguage = headers.entrySet().stream() + .filter(e -> e.getKey() != null && e.getKey().equalsIgnoreCase("Accept-Language")) + .map(Map.Entry::getValue) + .findFirst() + .orElse(null); + + if (acceptLanguage == null || acceptLanguage.isBlank()) { + return null; + } + + String[] parts = acceptLanguage.split(","); + if (parts.length == 0 || parts[0].isBlank()) { + return null; + } + + String locale = parts[0].split(";")[0].trim(); + return locale.isEmpty() ? null : locale; + } +} diff --git a/src/test/java/org/example/filter/LocaleFilterWithCookieTest.java b/src/test/java/org/example/filter/LocaleFilterWithCookieTest.java new file mode 100644 index 00000000..e83603b9 --- /dev/null +++ b/src/test/java/org/example/filter/LocaleFilterWithCookieTest.java @@ -0,0 +1,96 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class LocaleFilterWithCookieTest { + + @Test + void testDefaultLocaleWhenNoHeaderOrCookie() { + HttpRequest request = new HttpRequest( + "GET", + "/", + "HTTP/1.1", + Map.of(), + null + ); + + LocaleFilterWithCookie filter = new LocaleFilterWithCookie(); + + filter.doFilter(request, new HttpResponseBuilder(), (req, res) -> { + assertEquals("en-US", LocaleFilterWithCookie.getCurrentLocale()); + }); + } + + @Test + void testLocaleFromHeader() { + HttpRequest request = new HttpRequest( + "GET", + "/", + "HTTP/1.1", + Map.of("Accept-Language", "fr-FR,fr;q=0.9"), + null + ); + + LocaleFilterWithCookie filter = new LocaleFilterWithCookie(); + + filter.doFilter(request, new HttpResponseBuilder(), (req, res) -> { + assertEquals("fr-FR", LocaleFilterWithCookie.getCurrentLocale()); + }); + } + + @Test + void testLocaleFromCookie() { + HttpRequest request = new HttpRequest( + "GET", + "/", + "HTTP/1.1", + Map.of("Cookie", "user-lang=es-ES; other=val"), + null + ); + + LocaleFilterWithCookie filter = new LocaleFilterWithCookie(); + + filter.doFilter(request, new HttpResponseBuilder(), (req, res) -> { + assertEquals("es-ES", LocaleFilterWithCookie.getCurrentLocale()); + }); + } + + @Test + void testBlankCookieFallsBackToHeader() { + HttpRequest request = new HttpRequest( + "GET", "/", "HTTP/1.1", + Map.of( + "Cookie", "user-lang=; other=value", + "Accept-Language", "fr-FR,fr;q=0.9" + ), + null + ); + + LocaleFilterWithCookie filter = new LocaleFilterWithCookie(); + filter.doFilter(request, new HttpResponseBuilder(), (req, res) -> { + assertEquals("fr-FR", LocaleFilterWithCookie.getCurrentLocale()); + }); + } + + @Test + void testCookieWithWhitespaceOnly() { + HttpRequest request = new HttpRequest( + "GET", "/", "HTTP/1.1", + Map.of( + "Cookie", "user-lang= " + ), + null + ); + + LocaleFilterWithCookie filter = new LocaleFilterWithCookie(); + filter.doFilter(request, new HttpResponseBuilder(), (req, res) -> { + assertEquals("en-US", LocaleFilterWithCookie.getCurrentLocale()); + }); + } +} From b7154fa8d01ccd46fb0add4f14f1fd7087208417 Mon Sep 17 00:00:00 2001 From: Johan Karlsson <93186588+gurkvatten@users.noreply.github.com> Date: Wed, 25 Feb 2026 18:54:34 +0100 Subject: [PATCH 40/54] added brotli4j (#94) --- pom.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pom.xml b/pom.xml index 8a82b235..e747e6aa 100644 --- a/pom.xml +++ b/pom.xml @@ -65,6 +65,11 @@ jackson-dataformat-yaml 3.0.3 + + com.aayushatharva.brotli4j + brotli4j + 1.20.0 + From 2d93fc51f0e4f62aba3d5102b8bb4313c43ba53a Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Wed, 25 Feb 2026 21:25:33 +0100 Subject: [PATCH 41/54] Add three test to check the removing of the buckets --- .../filter/RateLimitingFilterTest.java | 69 +++++++++++++++++-- 1 file changed, 64 insertions(+), 5 deletions(-) diff --git a/src/test/java/org/example/filter/RateLimitingFilterTest.java b/src/test/java/org/example/filter/RateLimitingFilterTest.java index 24cf28b7..d115d9c4 100644 --- a/src/test/java/org/example/filter/RateLimitingFilterTest.java +++ b/src/test/java/org/example/filter/RateLimitingFilterTest.java @@ -7,8 +7,6 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; - -import java.net.http.HttpResponse; import java.util.HashMap; import static org.example.http.HttpResponseBuilder.SC_OK; @@ -32,11 +30,11 @@ void setUp(){ request = new HttpRequest("GET", "/", "HTTP/1.1", new HashMap<>(), ""); request.setAttribute("clientIp", "127.0.0.1"); response = new HttpResponseBuilder(); - filter.clearBuckets(); + filter.destroy(); } @Test - void shouldAllowRequestWhenTokensAreAvailable(){ + void shouldAllowRequest_WhenTokensAreAvailable(){ filter.doFilter(request, response, filterChain); @@ -45,7 +43,7 @@ void shouldAllowRequestWhenTokensAreAvailable(){ } @Test - void shouldNotAllowRequestWhenTokensAreNotAvailable(){ + void shouldNotAllowRequest_WhenTokensAreNotAvailable(){ //capacity of the bucket is 10 for(int i = 0; i < 11; i++ ) @@ -75,5 +73,66 @@ void shouldHaveSeparateBucketsPerIp(){ assertEquals(SC_OK, response2.getStatusCode()); } + @Test + void shouldDeleteOldBuckets_WhenSizeIsMoreThanThreshold(){ + + filter.init(); + + for(int i = 0; i < 1001; i++ ){ + String fakeIp = "192.168.1." + i; + request.setAttribute("clientIp", fakeIp); + filter.doFilter(request, response, filterChain); + } + + assertEquals(1001, filter.getBucketsCount()); + + filter.ageBucketsForTesting(3600000); + filter.cleanupIdleBuckets(); + + assertEquals(0, filter.getBucketsCount()); + } + + @Test + void shouldNotDeleteOldBuckets_WhenSizeIsLessThanThreshold(){ + filter.init(); + + for(int i = 0; i < 1000; i++ ){ + String fakeIp = "192.168.1." + i; + request.setAttribute("clientIp", fakeIp); + filter.doFilter(request, response, filterChain); + } + + assertEquals(1000, filter.getBucketsCount()); + + filter.ageBucketsForTesting(3600000); + filter.cleanupIdleBuckets(); + + assertEquals(1000, filter.getBucketsCount()); + + } + + @Test + void shouldDeleteOnlyExpiredBuckets_WhenAreOld(){ + filter.init(); + + for(int i = 0; i < 1001; i++ ){ + String fakeIp = "192.168.1." + i; + request.setAttribute("clientIp", fakeIp); + filter.doFilter(request, response, filterChain); + } + + assertEquals(1001, filter.getBucketsCount()); + filter.ageBucketsForTesting(3600000); + + for(int i = 0; i < 500; i++ ){ + String fakeIp = "192.168.1." + i; + request.setAttribute("clientIp", fakeIp); + filter.doFilter(request, response, filterChain); + } + + filter.cleanupIdleBuckets(); + + assertEquals(500, filter.getBucketsCount()); + } } From e18b048b9d85d5523b079c4983f34e66313791b2 Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Wed, 25 Feb 2026 21:47:56 +0100 Subject: [PATCH 42/54] Refactorization of the method startCleanupThread, add method getBucketsCount and ageBucketsForTesting --- .../example/filter/RateLimitingFilter.java | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/src/main/java/org/example/filter/RateLimitingFilter.java b/src/main/java/org/example/filter/RateLimitingFilter.java index 27a6cb48..b9189747 100644 --- a/src/main/java/org/example/filter/RateLimitingFilter.java +++ b/src/main/java/org/example/filter/RateLimitingFilter.java @@ -27,7 +27,7 @@ public class RateLimitingFilter implements Filter { private static final Map buckets = new ConcurrentHashMap<>(); private static final long CAPACITY = 10; private static final long REFILL_TOKENS = 1; - private final Duration REFILL_PERIOD = Duration.ofSeconds(10); + private final Duration refillPeriod = Duration.ofSeconds(10); private static final int MAX_BUCKETS_THRESHOLD = 1000; @Override @@ -76,15 +76,11 @@ private Bucket createNewBucket() { return Bucket.builder() .addLimit(Bandwidth.builder() .capacity(CAPACITY) - .refillGreedy(REFILL_TOKENS, REFILL_PERIOD) + .refillGreedy(REFILL_TOKENS, refillPeriod) .build()) .build(); } - public void clearBuckets(){ - buckets.clear(); - } - /** * Track the last access time of every bucket */ @@ -102,27 +98,40 @@ void updateAccess() { } } - private void startCleanupThread() { - Thread cleanupThread = new Thread(() -> { + public void startCleanupThread() { + Thread.ofVirtual().start(() -> { while (!Thread.currentThread().isInterrupted()) { try { //it checks every 10 minutes Thread.sleep(Duration.ofMinutes(10).toMillis()); - //it will only clean when the size of the buckets is more than 1000 - if (buckets.size() > MAX_BUCKETS_THRESHOLD) { - long idleThreshold = System.currentTimeMillis() - Duration.ofMinutes(30).toMillis(); - buckets.entrySet().removeIf(entry -> entry.getValue().lastAccessTime < idleThreshold); - } - + cleanupIdleBuckets(); - } catch (InterruptedException e) { + } catch (InterruptedException _) { Thread.currentThread().interrupt(); + break; } } }); - cleanupThread.setDaemon(true); - cleanupThread.start(); + } + + public void cleanupIdleBuckets() { + //it will only clean when the size of the buckets is more than 1000 + if (buckets.size() > MAX_BUCKETS_THRESHOLD) { + long idleThreshold = System.currentTimeMillis() - Duration.ofMinutes(30).toMillis(); + buckets.entrySet().removeIf(entry -> entry.getValue().lastAccessTime < idleThreshold); + } + } + + public int getBucketsCount() { + return buckets.size(); + } + + public void ageBucketsForTesting(long millisToSubtract) { + for (BucketWrapper wrapper : buckets.values()) { + long oldTime = wrapper.lastAccessTime; + wrapper.lastAccessTime = oldTime - millisToSubtract; + } } } From c38e84b217a22e25a37b109ace164d36829445d6 Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Wed, 25 Feb 2026 21:59:56 +0100 Subject: [PATCH 43/54] Add lifecycle control to cleanup --- .../org/example/filter/RateLimitingFilter.java | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/example/filter/RateLimitingFilter.java b/src/main/java/org/example/filter/RateLimitingFilter.java index b9189747..5ce846a3 100644 --- a/src/main/java/org/example/filter/RateLimitingFilter.java +++ b/src/main/java/org/example/filter/RateLimitingFilter.java @@ -8,6 +8,7 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.logging.Logger; +import java.util.concurrent.atomic.AtomicBoolean; /** * Rate Limiting Filter responsible for limiting the number of requests per client IP. @@ -29,11 +30,15 @@ public class RateLimitingFilter implements Filter { private static final long REFILL_TOKENS = 1; private final Duration refillPeriod = Duration.ofSeconds(10); private static final int MAX_BUCKETS_THRESHOLD = 1000; + private final AtomicBoolean cleanupStarted = new AtomicBoolean(false); + private volatile Thread cleanupThread; @Override public void init() { logger.info("RateLimitingFilter initialized with capacity: " + CAPACITY); - startCleanupThread(); + if (cleanupStarted.compareAndSet(false, true)) { + cleanupThread = startCleanupThread(); + } } /** @@ -66,6 +71,12 @@ public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterCh @Override public void destroy() { + Thread t = cleanupThread; + if (t != null) { + t.interrupt(); + cleanupThread = null; + } + cleanupStarted.set(false); buckets.clear(); } @@ -98,8 +109,8 @@ void updateAccess() { } } - public void startCleanupThread() { - Thread.ofVirtual().start(() -> { + public Thread startCleanupThread() { + return Thread.ofVirtual().name("rate-limit-cleanup").start(() -> { while (!Thread.currentThread().isInterrupted()) { try { //it checks every 10 minutes From fa1599ab6823fb91c2f67e9f45b0f0b3d87ae21b Mon Sep 17 00:00:00 2001 From: Rickard Ankar Date: Thu, 26 Feb 2026 09:09:24 +0100 Subject: [PATCH 44/54] Issue/69 remove html concat (#73) * Remove .html concat to support all files * Add test for jpg handling in ConnectionHandler (TDD:RED), related to #69 * Make ConnectionHandler testable with webroot parameter * Fix: Strip leading slash from URI to prevent absolute path (coderabbit) --- .../java/org/example/ConnectionHandler.java | 32 ++++-- .../java/org/example/config/ConfigLoader.java | 2 +- src/main/resources/test.jpg | Bin 0 -> 5819 bytes .../org/example/ConnectionHandlerTest.java | 107 ++++++++++++++++++ www/test.jpg | Bin 0 -> 5819 bytes 5 files changed, 130 insertions(+), 11 deletions(-) create mode 100644 src/main/resources/test.jpg create mode 100644 src/test/java/org/example/ConnectionHandlerTest.java create mode 100644 www/test.jpg diff --git a/src/main/java/org/example/ConnectionHandler.java b/src/main/java/org/example/ConnectionHandler.java index 9fc219d4..1fcc29fb 100644 --- a/src/main/java/org/example/ConnectionHandler.java +++ b/src/main/java/org/example/ConnectionHandler.java @@ -19,12 +19,21 @@ public class ConnectionHandler implements AutoCloseable { Socket client; String uri; private final List filters; + String webRoot; public ConnectionHandler(Socket client) { this.client = client; this.filters = buildFilters(); + this.webRoot = null; } -private List buildFilters() { + + public ConnectionHandler(Socket client, String webRoot) { + this.client = client; + this.webRoot = webRoot; + this.filters = buildFilters(); + } + + private List buildFilters() { List list = new ArrayList<>(); AppConfig config = ConfigLoader.get(); AppConfig.IpFilterConfig ipFilterConfig = config.ipFilter(); @@ -33,10 +42,17 @@ private List buildFilters() { } // Add more filters here... return list; - } - + } public void runConnectionHandler() throws IOException { + StaticFileHandler sfh; + + if (webRoot != null) { + sfh = new StaticFileHandler(webRoot); + } else { + sfh = new StaticFileHandler(); + } + HttpParser parser = new HttpParser(); parser.setReader(client.getInputStream()); parser.parseRequest(); @@ -65,7 +81,6 @@ public void runConnectionHandler() throws IOException { } resolveTargetFile(parser.getUri()); - StaticFileHandler sfh = new StaticFileHandler(); sfh.sendGetRequest(client.getOutputStream(), uri); } @@ -79,14 +94,11 @@ private HttpResponseBuilder applyFilters(HttpRequest request) { } private void resolveTargetFile(String uri) { - if (uri.matches("/$")) { //matches(/) + if (uri == null || "/".equals(uri)) { this.uri = "index.html"; - } else if (uri.matches("^(?!.*\\.html$).*$")) { - this.uri = uri.concat(".html"); } else { - this.uri = uri; + this.uri = uri.startsWith("/") ? uri.substring(1) : uri; } - } @Override @@ -117,4 +129,4 @@ private IpFilter createIpFilterFromConfig(AppConfig.IpFilterConfig config) { filter.init(); return filter; } -} +} \ No newline at end of file diff --git a/src/main/java/org/example/config/ConfigLoader.java b/src/main/java/org/example/config/ConfigLoader.java index e69f784d..96274158 100644 --- a/src/main/java/org/example/config/ConfigLoader.java +++ b/src/main/java/org/example/config/ConfigLoader.java @@ -65,7 +65,7 @@ private static ObjectMapper createMapperFor(Path configPath) { } } - static void resetForTests() { + public static void resetForTests() { cached = null; } } diff --git a/src/main/resources/test.jpg b/src/main/resources/test.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8bf506459723345f8af76c7a828047d568b67ba9 GIT binary patch literal 5819 zcmcgwcQjmWw;x^f(M5|vbctxuiJlO_h!(th3xeoGLV^eqCQ)N1dYeHQWAy0Vs3U6h zZuH)+y!ZRQ_q%t!f84v)U3Z_ge$P33KW9IC?dLgr{m#Dnc{Kx|d8nbS0l>op0PwCa zz|{mm6+lElNJvOPbPYsAM8qT%q$JluO-_D;0!U3u3#0}D=@{9V=;&`U0D;V0%s1KD zIXOA$m_R%r4jwiRPLAJB@UCAaAts?BC8gq^2hwx=ujT3o07wd$1)!Q7cO&(9B$|c}Ks5+iGp>=@Wzw z+C2$@WviHn=W0$3E9+MPkNhiuS&?hw((ij{$Myo0 z?xbo2P`0*v{&+*^KxO?D^+97ci{s%!>_`_#G(9TnbyO7TU!X;B&x=j}xool1rMyuz zPYaV)P);s}+t*|^%`tz`RgY!>5;bSu|8NN#-L}}fMGqghELWDLjtF!KHi|8fYW|TB zJ;pzk#Ghb21mkzSZAC7Q4yqGcEuxz7>vXDAo2Xb4D|5Da)dM~!{K&GP*#m&e$t+bP z>?IJQ4B5<9)ZpiqYgyyajJyyYO^7J1T-NJfgY>D(GJW2YbIbP=mg!P>b`$*bpF$uf zrXM*3dgV{#UUBR(u7+zd$C=92T?UdldKsEyf=a1ab+<7Ut^N-S>89Un#(893oJ?$&N2Wea(Cnq8QA7^DR z(l-`?j1jFL3q!@hmPv-=45GG0v8~DLG!;o?D5QnUYkWTdAms$F4jh=bC#k;bqmW^c zsIapK3>+^(1!6w-_4q4P26HwgDQyb=8m+kk6dmN$Tdq1j->*#jOp9C^Z3iEsgH$X6 zg;vX-AS7)EecG7@jb_hPrGqV;xM^Drl&SI~<>UHUQK?$L2H9j)_*k)i6o%Hl0$^=* z=IF$Aa@W+hnAA?TWhrI(V)xNy(NV)bU%@&(X;-pM$$_1w#?4SToGo3)PK?wBbiy@O6 z$B#?$I)Y%SSmd2=(k7z)Hf+`kB1ZP&Z$BfS&>m;`Pjej6NPU$Bun;te*WBR~v5~Hn zo)g1L*CENjYEn8;1-k6A@-|Q=+q0abhBg*Y%|A=Y;O9t|4T^|m+$S4&GUgC8!8@?w zyD~n6{OQff|A|dg8SoO9#&lmuj1^WL%zIl%3Wkn~?tR1wzI4d16U{>k8no9d+T1ZW zI>Z>c7o7L%2U_Skfpb0v2e(+FiZ-F;^LynS)NWIPAQ8&gB*Q+Rwg0wpBWnSDb;bot zo7NV&)r$8`6s-81GA^I$9VqQL#se+jYHEaP%KrkDgE3LMaSt$)%ab8}RU-x+m3gV8 zw(Z&E*<>(Swtvv?_R(|pCp;Z)#KvKhr(V2Yqg~Wn3(WoWZr`-qeRt>FiC-~t_BN zae>wC1Nm@1MX0KX#xugVlrVlL1y}YS&l|~ev|*i-@N~_t#OnnNQx^Bzg>ItD0I&A-5)1)^Ynti9v%0*0#=O zGo+#zapYWW#Gf%%EyixP^9EE^jC6^LvpraRx$>{P|G5JyN3#=>?KD;b!l-C(eG6kg z@94W;EV~JM$^cqDWum7y75YV~no)<~IexB&XW>%03E832AETZ-dJ=pnSA48Kdmvgm z)NLNx0ud`o#bale;SQ224}Kfq^~X_hn%Rc87kZv$+f7pE(>S6x2CUX@CwSXibuW|= zDf5j=XHV|Po?K^qva~&6v=cH9Ut@)2_Yd>5EYC1^`!Pba?j4HC&8sSn{y{yM{p3>R z9vglzXZflUvY6_)A9L`NV84K_UPAoB7jPk{PQ7rS=+tni+fbiDQbNOYDaiJHEbH_U zTPn%tPjoXEFzt&ZpTvPI*CBhK)Umh4x^LfbW#{F6z6;ekROyzFK3yx#CCzzrz+mD* z)kse4k?@e&3>(LjxWJ?viNGoVosv_-u#N6{;@GYqQiYB(4jREZeVquBEW4KiblS|g zD?l7>3sF0aHjH%CQ)}m#CkOlXZ!{zYu_LjF@B!bntzm_X1Gkf1vqYV1J+57W zLaq@5U^}uu9}2t_dTEha`$)qsoO+z2xl^Iyq9gobC5mJsHL8q_X~?ytxZ)|!3w!=R3DdB7kCVgwog$Re!xPKm9G!mNqeSm-?!h{eJS&S@b6 zcuqlPQEFTn)Ws#4!&LZQ>GvJ9R~Xgy)00dQYoXP`45V!(S)nFcEJ^C_4bUEf_3N)L z0C13fV5uUl&BUBx&L7k~{6bHJMi{!qatUdYTCCde=u=Q4@wGe9uGJGspF^2c^3I2% zG_ETu2y_bs!v76qkp>O)JJfK~F``&kN{l7nepC%9w!6S8+CLI2yb;z`5wToCcdBOb z)`kAi6v9-GA&!dPv^g5DHD~b|66J`{h}0y+6#53+#9}uLTWQoypG^8rpJ@Zdq}uN`L6uRg~p{>aHubuHD~&ZTF;(Tw2ESHQbf z&76&jjFQoWYDP|`X8C&}?)$0Gu`ca44(aI&Tf_0Hr{jO4RAov}J`Q!8G6M;r|Xv^Sr* zuMGe#zW=liW!Y4OkvNT5x$5=2TgDiKEJ@y){Tn3()W>#C0jVKm)=!+cvL8ZZQMl?1 z&5cH7KR!(jAfD8;lb&HiMQog?tO@a|n?Dt~nOHPhrZttuCZC}-uJ>a!nCLybb2Xl@0siSjl=N+|CX9b(7R|U%H%Q8NHsCH_ z^9P$ocY0l4V&Vf+>1Y-5^@8B|73f6e@@?!^d>3Y00X5($&bcAC`xkAYwz4#ZO z3ril(L=;!p1X8pO`_Sla1X=#N=1@DPugdB5vWARywg}1M2{)#?D1<$*gEi>}cX}(F z6%MPQtPIof{gvWZ04XI(STzra0aMX|7;K-+O|eZ~(jnrFusC!IKQ`B1io z-FD9ADA>54ntDd5SzAHv&d@25=I{svVGEx2U%kN3T63A_i^+kFIwD4G=bs5I30@8h z27*OvIB(ACi5VYe7!EE|zm=H{c>)3zt_vA)*dwMPh7&T{_nX_!e!*RWj0H!HXsO<9 z&alrFW^<0pX#02Opu0{-DWa;tVX|jMpTz>kAd!btBOp+Xd1=$7_;`pS)j(lA7M4Pe zfo7lexTyS{l0|;4y(P#gMjvQV3dZI7M?XwspC`v~y~y}d0sa9C>lCMUTBCA*W&A}< zq|(s%p~M$&S8tpKhAWh!rGN3D>XEQ}LL{BVTv9sLy^}pjhl!h93WRQD4^n0=f8`ch zw-Im%A8-#`s(?kJ{HP<`0^~ZUtdcIV%+>93*|Lpt?X)1!rv5+vfJ2@J#ad);YWsYH zRE_BOby}Y98EQ^5mN% z0vrc#kf}6`;SyPq*rHyFeaTWb7(Jrn0N8KlHL3B(J_AlZ-v6SyIrX0A=JvmxfKKdR zr6CB?X1V-7D+js5eS7SWDQiu>ZY}1!s?9bWE;@A|C0_wT&Dwk-OSL2SD+6N69mr1P zF29~Hb*K1M&8ciyMTC2n%qV^ma5tg?O&G{wYpJ5XYUrJh0=8~(=NMFn4y;RrzqNrQ znX_x3AbTGiqMr|o{a{kB=!BFJbrWcNMDXGX?da$=#ptyH4Rvr_!d4$l70US-u{BvERI1Hml z=sS6wv)E8O^r)uj^{Nb!jaAWx8E8Ox%R$0$qTno+O;koU*E_F2u^%>PnRG+GJjL5&mi*NT!oHXD)`R`MNgqR#{)bQ-5qMX7_A~ zEqDFuP;8Q+WHt8~z$M)s_D1VZJ1bv4_K$1X?^9sG1-s7;C5GI}w-3PzwPIUexHa{9 z7FLcR?WddsN+YXGH|nUo@Ef(p4MR&f1E=(8usBCN+9;%U3H~a4=z2nJ2QI8`p$X zA7W!_ay6jF0gqHXs@?=evpbgV{ggYSu$T?iiqOjD*fl>8}HXW)@bq8fl?vlT!=o_Cf*k=0qMwGBe9xRbeww;0a;((#UaMl~X%5 z)EaJDCxmBKbu!a+T-o^ zEoBb|sh^BnS^-u$nP!rnn-s^Xa4=6k%%^OVoCJE!$WhCOY!#=a7xu|K*|t0iNX zQ9vlS$3bkB4YsKgn1J7F6`xzREbWMkei=ihNn+v6#!P)V${CH|4xy^Pgohq@sG7pr z6VIr)Ful!}YoRP~){F3{P-Bv_qUm5*q?KssVQI3&qwEyecMj?*$CDrmj-&fTxNS?s zPiVcfYH~gEq~hM>XGMN|8Zl8J4Q_>tZ2= zvD>rVM7&7$roaN7t=)*%*zm=1^4>)l3hM32xC+c2CA?3qiWXU5Mts(lsp^ySVeqm) zHBc)0Lb3$pw#uMqf vZHS4Q>^_X$3n$i8+1GeJ-L?KFff%2=+dJAzNSxX=&HIZm{XgE3T#fw)ThPDt literal 0 HcmV?d00001 diff --git a/src/test/java/org/example/ConnectionHandlerTest.java b/src/test/java/org/example/ConnectionHandlerTest.java new file mode 100644 index 00000000..ee366fa2 --- /dev/null +++ b/src/test/java/org/example/ConnectionHandlerTest.java @@ -0,0 +1,107 @@ +package org.example; + +import org.example.config.ConfigLoader; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.net.InetAddress; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ConnectionHandlerTest { + + @Mock + private Socket socket; + + @TempDir + Path tempDir; + + @BeforeAll + static void setupConfig() { + ConfigLoader.resetForTests(); + ConfigLoader.loadOnce(Path.of("nonexistent-test-config.yml")); + } + + @Test + void test_jpg_file_should_return_200_not_404() throws Exception { + // Arrange + byte[] imageContent = "fake-image-data".getBytes(StandardCharsets.UTF_8); + Files.write(tempDir.resolve("test.jpg"), imageContent); + + String request = "GET /test.jpg HTTP/1.1\r\nHost: localhost\r\n\r\n"; + ByteArrayInputStream inputStream = new ByteArrayInputStream(request.getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + when(socket.getInputStream()).thenReturn(inputStream); + when(socket.getOutputStream()).thenReturn(outputStream); + when(socket.getInetAddress()).thenReturn(InetAddress.getByName("127.0.0.1")); + + // Act + try (ConnectionHandler handler = new ConnectionHandler(socket, tempDir.toString())) { + handler.runConnectionHandler(); + } + + // Assert + String response = outputStream.toString(); + assertThat(response).contains("HTTP/1.1 200 OK"); + assertThat(response).doesNotContain("404"); + } + + @Test + void test_root_path_should_serve_index_html() throws Exception { + // Arrange + byte[] indexContent = "Hello".getBytes(StandardCharsets.UTF_8); + Files.write(tempDir.resolve("index.html"), indexContent); + + String request = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"; + ByteArrayInputStream inputStream = new ByteArrayInputStream(request.getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + when(socket.getInputStream()).thenReturn(inputStream); + when(socket.getOutputStream()).thenReturn(outputStream); + when(socket.getInetAddress()).thenReturn(InetAddress.getByName("127.0.0.1")); + + // Act + try (ConnectionHandler handler = new ConnectionHandler(socket, tempDir.toString())) { + handler.runConnectionHandler(); + } + + // Assert + String response = outputStream.toString(); + assertThat(response).contains("HTTP/1.1 200 OK"); + assertThat(response).doesNotContain("404"); + } + + @Test + void test_missing_file_should_return_404() throws Exception { + // Arrange — no file written to tempDir + String request = "GET /doesnotexist.html HTTP/1.1\r\nHost: localhost\r\n\r\n"; + ByteArrayInputStream inputStream = new ByteArrayInputStream(request.getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + when(socket.getInputStream()).thenReturn(inputStream); + when(socket.getOutputStream()).thenReturn(outputStream); + when(socket.getInetAddress()).thenReturn(InetAddress.getByName("127.0.0.1")); + + // Act + try (ConnectionHandler handler = new ConnectionHandler(socket, tempDir.toString())) { + handler.runConnectionHandler(); + } + + // Assert + String response = outputStream.toString(); + assertThat(response).contains("404"); + } +} \ No newline at end of file diff --git a/www/test.jpg b/www/test.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8bf506459723345f8af76c7a828047d568b67ba9 GIT binary patch literal 5819 zcmcgwcQjmWw;x^f(M5|vbctxuiJlO_h!(th3xeoGLV^eqCQ)N1dYeHQWAy0Vs3U6h zZuH)+y!ZRQ_q%t!f84v)U3Z_ge$P33KW9IC?dLgr{m#Dnc{Kx|d8nbS0l>op0PwCa zz|{mm6+lElNJvOPbPYsAM8qT%q$JluO-_D;0!U3u3#0}D=@{9V=;&`U0D;V0%s1KD zIXOA$m_R%r4jwiRPLAJB@UCAaAts?BC8gq^2hwx=ujT3o07wd$1)!Q7cO&(9B$|c}Ks5+iGp>=@Wzw z+C2$@WviHn=W0$3E9+MPkNhiuS&?hw((ij{$Myo0 z?xbo2P`0*v{&+*^KxO?D^+97ci{s%!>_`_#G(9TnbyO7TU!X;B&x=j}xool1rMyuz zPYaV)P);s}+t*|^%`tz`RgY!>5;bSu|8NN#-L}}fMGqghELWDLjtF!KHi|8fYW|TB zJ;pzk#Ghb21mkzSZAC7Q4yqGcEuxz7>vXDAo2Xb4D|5Da)dM~!{K&GP*#m&e$t+bP z>?IJQ4B5<9)ZpiqYgyyajJyyYO^7J1T-NJfgY>D(GJW2YbIbP=mg!P>b`$*bpF$uf zrXM*3dgV{#UUBR(u7+zd$C=92T?UdldKsEyf=a1ab+<7Ut^N-S>89Un#(893oJ?$&N2Wea(Cnq8QA7^DR z(l-`?j1jFL3q!@hmPv-=45GG0v8~DLG!;o?D5QnUYkWTdAms$F4jh=bC#k;bqmW^c zsIapK3>+^(1!6w-_4q4P26HwgDQyb=8m+kk6dmN$Tdq1j->*#jOp9C^Z3iEsgH$X6 zg;vX-AS7)EecG7@jb_hPrGqV;xM^Drl&SI~<>UHUQK?$L2H9j)_*k)i6o%Hl0$^=* z=IF$Aa@W+hnAA?TWhrI(V)xNy(NV)bU%@&(X;-pM$$_1w#?4SToGo3)PK?wBbiy@O6 z$B#?$I)Y%SSmd2=(k7z)Hf+`kB1ZP&Z$BfS&>m;`Pjej6NPU$Bun;te*WBR~v5~Hn zo)g1L*CENjYEn8;1-k6A@-|Q=+q0abhBg*Y%|A=Y;O9t|4T^|m+$S4&GUgC8!8@?w zyD~n6{OQff|A|dg8SoO9#&lmuj1^WL%zIl%3Wkn~?tR1wzI4d16U{>k8no9d+T1ZW zI>Z>c7o7L%2U_Skfpb0v2e(+FiZ-F;^LynS)NWIPAQ8&gB*Q+Rwg0wpBWnSDb;bot zo7NV&)r$8`6s-81GA^I$9VqQL#se+jYHEaP%KrkDgE3LMaSt$)%ab8}RU-x+m3gV8 zw(Z&E*<>(Swtvv?_R(|pCp;Z)#KvKhr(V2Yqg~Wn3(WoWZr`-qeRt>FiC-~t_BN zae>wC1Nm@1MX0KX#xugVlrVlL1y}YS&l|~ev|*i-@N~_t#OnnNQx^Bzg>ItD0I&A-5)1)^Ynti9v%0*0#=O zGo+#zapYWW#Gf%%EyixP^9EE^jC6^LvpraRx$>{P|G5JyN3#=>?KD;b!l-C(eG6kg z@94W;EV~JM$^cqDWum7y75YV~no)<~IexB&XW>%03E832AETZ-dJ=pnSA48Kdmvgm z)NLNx0ud`o#bale;SQ224}Kfq^~X_hn%Rc87kZv$+f7pE(>S6x2CUX@CwSXibuW|= zDf5j=XHV|Po?K^qva~&6v=cH9Ut@)2_Yd>5EYC1^`!Pba?j4HC&8sSn{y{yM{p3>R z9vglzXZflUvY6_)A9L`NV84K_UPAoB7jPk{PQ7rS=+tni+fbiDQbNOYDaiJHEbH_U zTPn%tPjoXEFzt&ZpTvPI*CBhK)Umh4x^LfbW#{F6z6;ekROyzFK3yx#CCzzrz+mD* z)kse4k?@e&3>(LjxWJ?viNGoVosv_-u#N6{;@GYqQiYB(4jREZeVquBEW4KiblS|g zD?l7>3sF0aHjH%CQ)}m#CkOlXZ!{zYu_LjF@B!bntzm_X1Gkf1vqYV1J+57W zLaq@5U^}uu9}2t_dTEha`$)qsoO+z2xl^Iyq9gobC5mJsHL8q_X~?ytxZ)|!3w!=R3DdB7kCVgwog$Re!xPKm9G!mNqeSm-?!h{eJS&S@b6 zcuqlPQEFTn)Ws#4!&LZQ>GvJ9R~Xgy)00dQYoXP`45V!(S)nFcEJ^C_4bUEf_3N)L z0C13fV5uUl&BUBx&L7k~{6bHJMi{!qatUdYTCCde=u=Q4@wGe9uGJGspF^2c^3I2% zG_ETu2y_bs!v76qkp>O)JJfK~F``&kN{l7nepC%9w!6S8+CLI2yb;z`5wToCcdBOb z)`kAi6v9-GA&!dPv^g5DHD~b|66J`{h}0y+6#53+#9}uLTWQoypG^8rpJ@Zdq}uN`L6uRg~p{>aHubuHD~&ZTF;(Tw2ESHQbf z&76&jjFQoWYDP|`X8C&}?)$0Gu`ca44(aI&Tf_0Hr{jO4RAov}J`Q!8G6M;r|Xv^Sr* zuMGe#zW=liW!Y4OkvNT5x$5=2TgDiKEJ@y){Tn3()W>#C0jVKm)=!+cvL8ZZQMl?1 z&5cH7KR!(jAfD8;lb&HiMQog?tO@a|n?Dt~nOHPhrZttuCZC}-uJ>a!nCLybb2Xl@0siSjl=N+|CX9b(7R|U%H%Q8NHsCH_ z^9P$ocY0l4V&Vf+>1Y-5^@8B|73f6e@@?!^d>3Y00X5($&bcAC`xkAYwz4#ZO z3ril(L=;!p1X8pO`_Sla1X=#N=1@DPugdB5vWARywg}1M2{)#?D1<$*gEi>}cX}(F z6%MPQtPIof{gvWZ04XI(STzra0aMX|7;K-+O|eZ~(jnrFusC!IKQ`B1io z-FD9ADA>54ntDd5SzAHv&d@25=I{svVGEx2U%kN3T63A_i^+kFIwD4G=bs5I30@8h z27*OvIB(ACi5VYe7!EE|zm=H{c>)3zt_vA)*dwMPh7&T{_nX_!e!*RWj0H!HXsO<9 z&alrFW^<0pX#02Opu0{-DWa;tVX|jMpTz>kAd!btBOp+Xd1=$7_;`pS)j(lA7M4Pe zfo7lexTyS{l0|;4y(P#gMjvQV3dZI7M?XwspC`v~y~y}d0sa9C>lCMUTBCA*W&A}< zq|(s%p~M$&S8tpKhAWh!rGN3D>XEQ}LL{BVTv9sLy^}pjhl!h93WRQD4^n0=f8`ch zw-Im%A8-#`s(?kJ{HP<`0^~ZUtdcIV%+>93*|Lpt?X)1!rv5+vfJ2@J#ad);YWsYH zRE_BOby}Y98EQ^5mN% z0vrc#kf}6`;SyPq*rHyFeaTWb7(Jrn0N8KlHL3B(J_AlZ-v6SyIrX0A=JvmxfKKdR zr6CB?X1V-7D+js5eS7SWDQiu>ZY}1!s?9bWE;@A|C0_wT&Dwk-OSL2SD+6N69mr1P zF29~Hb*K1M&8ciyMTC2n%qV^ma5tg?O&G{wYpJ5XYUrJh0=8~(=NMFn4y;RrzqNrQ znX_x3AbTGiqMr|o{a{kB=!BFJbrWcNMDXGX?da$=#ptyH4Rvr_!d4$l70US-u{BvERI1Hml z=sS6wv)E8O^r)uj^{Nb!jaAWx8E8Ox%R$0$qTno+O;koU*E_F2u^%>PnRG+GJjL5&mi*NT!oHXD)`R`MNgqR#{)bQ-5qMX7_A~ zEqDFuP;8Q+WHt8~z$M)s_D1VZJ1bv4_K$1X?^9sG1-s7;C5GI}w-3PzwPIUexHa{9 z7FLcR?WddsN+YXGH|nUo@Ef(p4MR&f1E=(8usBCN+9;%U3H~a4=z2nJ2QI8`p$X zA7W!_ay6jF0gqHXs@?=evpbgV{ggYSu$T?iiqOjD*fl>8}HXW)@bq8fl?vlT!=o_Cf*k=0qMwGBe9xRbeww;0a;((#UaMl~X%5 z)EaJDCxmBKbu!a+T-o^ zEoBb|sh^BnS^-u$nP!rnn-s^Xa4=6k%%^OVoCJE!$WhCOY!#=a7xu|K*|t0iNX zQ9vlS$3bkB4YsKgn1J7F6`xzREbWMkei=ihNn+v6#!P)V${CH|4xy^Pgohq@sG7pr z6VIr)Ful!}YoRP~){F3{P-Bv_qUm5*q?KssVQI3&qwEyecMj?*$CDrmj-&fTxNS?s zPiVcfYH~gEq~hM>XGMN|8Zl8J4Q_>tZ2= zvD>rVM7&7$roaN7t=)*%*zm=1^4>)l3hM32xC+c2CA?3qiWXU5Mts(lsp^ySVeqm) zHBc)0Lb3$pw#uMqf vZHS4Q>^_X$3n$i8+1GeJ-L?KFff%2=+dJAzNSxX=&HIZm{XgE3T#fw)ThPDt literal 0 HcmV?d00001 From c7c63e88908f35df41f0403e3c3c85227f050df8 Mon Sep 17 00:00:00 2001 From: Ericthilen Date: Thu, 26 Feb 2026 13:40:09 +0100 Subject: [PATCH 45/54] =?UTF-8?q?Implement=20configurable=20filter=20pipel?= =?UTF-8?q?ine=20(global=20+=20per=E2=80=91route=20filters)=20#34=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ensure global detection for empty route patterns * larify global vs route-specific registrations * add support for exact and /prefix/* matching * add coverage for global, route-specific, ordering and short-circuit * configurable global and route-specific filters with ordering * changed package * removed from branch * Add missing tests for configurable filter pipeline * Refactor filter pipeline: sort FilterRegistration directly by order * Fix RoutePattern wildcard to match base path (/api/* matches /api) * Restore App entry point and basic tests from main * Bump org.apache.maven.plugins:maven-dependency-plugin (#6) (#55) * Bump org.apache.maven.plugins:maven-dependency-plugin (#6) Bumps the maven-deps group with 1 update: [org.apache.maven.plugins:maven-dependency-plugin](https://github.com/apache/maven-dependency-plugin). Updates `org.apache.maven.plugins:maven-dependency-plugin` from 3.9.0 to 3.10.0 - [Release notes](https://github.com/apache/maven-dependency-plugin/releases) - [Commits](https://github.com/apache/maven-dependency-plugin/compare/maven-dependency-plugin-3.9.0...maven-dependency-plugin-3.10.0) --- updated-dependencies: - dependency-name: org.apache.maven.plugins:maven-dependency-plugin dependency-version: 3.10.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: maven-deps ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump org.junit.jupiter:junit-jupiter in the maven-deps group (#13) Bumps the maven-deps group with 1 update: [org.junit.jupiter:junit-jupiter](https://github.com/junit-team/junit-framework). Updates `org.junit.jupiter:junit-jupiter` from 6.0.2 to 6.0.3 - [Release notes](https://github.com/junit-team/junit-framework/releases) - [Commits](https://github.com/junit-team/junit-framework/compare/r6.0.2...r6.0.3) --- updated-dependencies: - dependency-name: org.junit.jupiter:junit-jupiter dependency-version: 6.0.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: maven-deps ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Add short-circuit tests for configurable filter pipeline * Add tests for response-phase and global-vs-route ordering * pom fix - no changes * Align server filter pipeline with main FilterChainImpl and HttpResponseBuilder * Align filter pipeline with main filter chain and harden immutability --------- Signed-off-by: dependabot[bot] Co-authored-by: Viktor Lindell Co-authored-by: viktorlindell12 Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../org/example/filter/FilterChainImpl.java | 21 +- .../server/ConfigurableFilterPipeline.java | 73 ++++ .../example/server/FilterRegistration.java | 21 ++ .../java/org/example/server/RoutePattern.java | 17 + .../ConfigurableFilterPipelineTest.java | 331 ++++++++++++++++++ 5 files changed, 454 insertions(+), 9 deletions(-) create mode 100644 src/main/java/org/example/server/ConfigurableFilterPipeline.java create mode 100644 src/main/java/org/example/server/FilterRegistration.java create mode 100644 src/main/java/org/example/server/RoutePattern.java create mode 100644 src/test/java/org/example/server/ConfigurableFilterPipelineTest.java diff --git a/src/main/java/org/example/filter/FilterChainImpl.java b/src/main/java/org/example/filter/FilterChainImpl.java index b6c4509f..39f01422 100644 --- a/src/main/java/org/example/filter/FilterChainImpl.java +++ b/src/main/java/org/example/filter/FilterChainImpl.java @@ -1,24 +1,27 @@ package org.example.filter; -import org.example.http.HttpResponseBuilder; import org.example.httpparser.HttpRequest; - +import org.example.http.HttpResponseBuilder; import java.util.List; - -/* -* The default class of FilterChain, -* Contains a list of filters. For each of the filter, will execute the doFilter method. -* - */ +import java.util.function.BiConsumer; public class FilterChainImpl implements FilterChain { private final List filters; + private final BiConsumer terminalHandler; private int index = 0; public FilterChainImpl(List filters) { + this(filters, (req, resp) -> { + // default no-op (preserves previous behavior) + }); + } + + public FilterChainImpl(List filters, + BiConsumer terminalHandler) { this.filters = filters; + this.terminalHandler = terminalHandler; } @Override @@ -27,7 +30,7 @@ public void doFilter(HttpRequest request, HttpResponseBuilder response) { Filter next = filters.get(index++); next.doFilter(request, response, this); } else { - // TODO: when no more filters, should execute the request + terminalHandler.accept(request, response); } } } diff --git a/src/main/java/org/example/server/ConfigurableFilterPipeline.java b/src/main/java/org/example/server/ConfigurableFilterPipeline.java new file mode 100644 index 00000000..1e4b939f --- /dev/null +++ b/src/main/java/org/example/server/ConfigurableFilterPipeline.java @@ -0,0 +1,73 @@ +package org.example.server; + +import org.example.filter.Filter; +import org.example.filter.FilterChainImpl; +import org.example.httpparser.HttpRequest; +import org.example.http.HttpResponseBuilder; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.function.BiConsumer; + +public class ConfigurableFilterPipeline { + + private final List registrations; + + public ConfigurableFilterPipeline(List registrations) { + this.registrations = List.copyOf( + Objects.requireNonNull(registrations, "registrations must not be null") + ); + } + + public HttpResponseBuilder execute(HttpRequest request, + BiConsumer terminalHandler) { + + Objects.requireNonNull(request, "request must not be null"); + Objects.requireNonNull(terminalHandler, "terminalHandler must not be null"); + + List globalRegs = new ArrayList<>(); + List routeRegs = new ArrayList<>(); + + for (FilterRegistration reg : registrations) { + if (reg.isGlobal()) { + globalRegs.add(reg); + } else { + if (matchesAny(reg.routePatterns(), request.getPath())) { + routeRegs.add(reg); + } + } + } + + Comparator byOrder = + Comparator.comparingInt(FilterRegistration::order); + + globalRegs.sort(byOrder); + routeRegs.sort(byOrder); + + List allFilters = new ArrayList<>(globalRegs.size() + routeRegs.size()); + for (FilterRegistration reg : globalRegs) { + allFilters.add(reg.filter()); + } + for (FilterRegistration reg : routeRegs) { + allFilters.add(reg.filter()); + } + + HttpResponseBuilder response = new HttpResponseBuilder(); + new FilterChainImpl(allFilters, terminalHandler).doFilter(request, response); + return response; + } + + private boolean matchesAny(List patterns, String path) { + if (patterns == null || path == null) return false; + + for (String pattern : patterns) { + if (RoutePattern.matches(pattern, path)) { + return true; + } + } + + return false; + } +} \ No newline at end of file diff --git a/src/main/java/org/example/server/FilterRegistration.java b/src/main/java/org/example/server/FilterRegistration.java new file mode 100644 index 00000000..a9578161 --- /dev/null +++ b/src/main/java/org/example/server/FilterRegistration.java @@ -0,0 +1,21 @@ +package org.example.server; + +import org.example.filter.Filter; + +import java.util.List; +import java.util.Objects; + +public record FilterRegistration( + Filter filter, + int order, + List routePatterns +) { + public FilterRegistration { + filter = Objects.requireNonNull(filter, "filter must not be null"); + routePatterns = routePatterns == null ? null : List.copyOf(routePatterns); + } + + public boolean isGlobal() { + return routePatterns == null || routePatterns.isEmpty(); + } +} diff --git a/src/main/java/org/example/server/RoutePattern.java b/src/main/java/org/example/server/RoutePattern.java new file mode 100644 index 00000000..ffae5059 --- /dev/null +++ b/src/main/java/org/example/server/RoutePattern.java @@ -0,0 +1,17 @@ +package org.example.server; + +public final class RoutePattern { + + private RoutePattern() {} + + public static boolean matches(String pattern, String path) { + if (pattern == null || path == null) return false; + + if (pattern.endsWith("/*")) { + String base = pattern.substring(0, pattern.length() - 2); // drop "/*" + return path.equals(base) || path.startsWith(base + "/"); + } + + return pattern.equals(path); + } +} \ No newline at end of file diff --git a/src/test/java/org/example/server/ConfigurableFilterPipelineTest.java b/src/test/java/org/example/server/ConfigurableFilterPipelineTest.java new file mode 100644 index 00000000..c9d179d3 --- /dev/null +++ b/src/test/java/org/example/server/ConfigurableFilterPipelineTest.java @@ -0,0 +1,331 @@ +package org.example.server; + +import org.example.filter.Filter; +import org.example.filter.FilterChain; +import org.example.httpparser.HttpRequest; +import org.example.http.HttpResponseBuilder; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ConfigurableFilterPipelineTest { + + @Test + void global_filter_runs() { + + List events = new ArrayList<>(); + + Filter filter = new TestFilter("g1", events, false); + + List regs = List.of( + new FilterRegistration(filter, 1, null) + ); + + ConfigurableFilterPipeline pipeline = + new ConfigurableFilterPipeline(regs); + + pipeline.execute( + newRequest("/home"), + (req, resp) -> events.add("handler") + ); + + assertEquals( + List.of("g1", "handler"), + events + ); + } + + @Test + void filter_can_stop_chain() { + + List events = new ArrayList<>(); + + Filter stopFilter = new TestFilter("stop", events, true); + + List regs = List.of( + new FilterRegistration(stopFilter, 1, null) + ); + + ConfigurableFilterPipeline pipeline = + new ConfigurableFilterPipeline(regs); + + HttpResponseBuilder response = pipeline.execute( + newRequest("/home"), + (req, resp) -> events.add("handler") + ); + + assertEquals(HttpResponseBuilder.SC_FORBIDDEN, response.getStatusCode()); + assertEquals(List.of("stop"), events); + } + + @Test + void route_specific_filter_runs_when_path_matches() { + List events = new ArrayList<>(); + + Filter routeFilter = new TestFilter("r1", events, false); + + List regs = List.of( + new FilterRegistration(routeFilter, 1, List.of("/api/*")) + ); + + ConfigurableFilterPipeline pipeline = + new ConfigurableFilterPipeline(regs); + + pipeline.execute( + newRequest("/api/users"), + (req, resp) -> events.add("handler") + ); + + assertEquals(List.of("r1", "handler"), events); + } + + @Test + void route_specific_filter_is_skipped_when_path_does_not_match() { + List events = new ArrayList<>(); + + Filter routeFilter = new TestFilter("r1", events, false); + + List regs = List.of( + new FilterRegistration(routeFilter, 1, List.of("/api/*")) + ); + + ConfigurableFilterPipeline pipeline = + new ConfigurableFilterPipeline(regs); + + pipeline.execute( + newRequest("/public"), + (req, resp) -> events.add("handler") + ); + + assertEquals(List.of("handler"), events); + } + + @Test + void mixed_pipeline_runs_global_then_route_then_handler() { + List events = new ArrayList<>(); + + Filter global = new TestFilter("g1", events, false); + Filter route = new TestFilter("r1", events, false); + + List regs = List.of( + new FilterRegistration(global, 1, null), + new FilterRegistration(route, 1, List.of("/api/*")) + ); + + ConfigurableFilterPipeline pipeline = + new ConfigurableFilterPipeline(regs); + + pipeline.execute( + newRequest("/api/users"), + (req, resp) -> events.add("handler") + ); + + assertEquals(List.of("g1", "r1", "handler"), events); + } + + @Test + void ordering_is_by_order_field() { + List events = new ArrayList<>(); + + Filter f20 = new TestFilter("f20", events, false); + Filter f10 = new TestFilter("f10", events, false); + Filter f30 = new TestFilter("f30", events, false); + + List regs = List.of( + new FilterRegistration(f20, 20, null), + new FilterRegistration(f10, 10, null), + new FilterRegistration(f30, 30, null) + ); + + ConfigurableFilterPipeline pipeline = + new ConfigurableFilterPipeline(regs); + + pipeline.execute( + newRequest("/home"), + (req, resp) -> events.add("handler") + ); + + assertEquals(List.of("f10", "f20", "f30", "handler"), events); + } + + @Test + void global_stop_filter_prevents_route_and_handler() { + List events = new ArrayList<>(); + + Filter globalStop = new TestFilter("gStop", events, true); + Filter routeFilter = new TestFilter("r1", events, false); + + List regs = List.of( + new FilterRegistration(globalStop, 1, null), + new FilterRegistration(routeFilter, 1, List.of("/api/*")) + ); + + ConfigurableFilterPipeline pipeline = + new ConfigurableFilterPipeline(regs); + + HttpResponseBuilder response = pipeline.execute( + newRequest("/api/users"), + (req, resp) -> events.add("handler") + ); + + assertEquals(HttpResponseBuilder.SC_FORBIDDEN, response.getStatusCode()); + assertEquals(List.of("gStop"), events); + } + + @Test + void route_stop_filter_prevents_handler_but_global_runs() { + List events = new ArrayList<>(); + + Filter global = new TestFilter("g1", events, false); + Filter routeStop = new TestFilter("rStop", events, true); + + List regs = List.of( + new FilterRegistration(global, 1, null), + new FilterRegistration(routeStop, 1, List.of("/api/*")) + ); + + ConfigurableFilterPipeline pipeline = + new ConfigurableFilterPipeline(regs); + + HttpResponseBuilder response = pipeline.execute( + newRequest("/api/users"), + (req, resp) -> events.add("handler") + ); + + assertEquals(HttpResponseBuilder.SC_FORBIDDEN, response.getStatusCode()); + assertEquals(List.of("g1", "rStop"), events); + } + + @Test + void response_phase_can_be_done_with_try_finally_in_filters_reverse_order() { + List events = new ArrayList<>(); + + Filter f1 = new Filter() { + @Override + public void init() { + // no-op + } + + @Override + public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) { + events.add("f1:enter"); + try { + chain.doFilter(request, response); + } finally { + events.add("f1:exit"); + } + } + + @Override + public void destroy() { + // no-op + } + }; + + Filter f2 = new Filter() { + @Override + public void init() { + // no-op + } + + @Override + public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) { + events.add("f2:enter"); + try { + chain.doFilter(request, response); + } finally { + events.add("f2:exit"); + } + } + + @Override + public void destroy() { + // no-op + } + }; + + List regs = List.of( + new FilterRegistration(f1, 10, null), + new FilterRegistration(f2, 20, null) + ); + + ConfigurableFilterPipeline pipeline = + new ConfigurableFilterPipeline(regs); + + pipeline.execute( + newRequest("/home"), + (req, resp) -> events.add("handler") + ); + + assertEquals( + List.of("f1:enter", "f2:enter", "handler", "f2:exit", "f1:exit"), + events + ); + } + + @Test + void global_filters_run_before_route_filters_even_if_route_has_lower_order() { + List events = new ArrayList<>(); + + Filter global100 = new TestFilter("g100", events, false); + Filter route0 = new TestFilter("r0", events, false); + + List regs = List.of( + new FilterRegistration(global100, 100, null), + new FilterRegistration(route0, 0, List.of("/api/*")) + ); + + ConfigurableFilterPipeline pipeline = + new ConfigurableFilterPipeline(regs); + + pipeline.execute( + newRequest("/api/users"), + (req, resp) -> events.add("handler") + ); + + assertEquals(List.of("g100", "r0", "handler"), events); + } + + private static HttpRequest newRequest(String path) { + return new HttpRequest("GET", path, "HTTP/1.1", Map.of(), ""); + } + + static class TestFilter implements Filter { + + private final String name; + private final List events; + private final boolean stop; + + TestFilter(String name, List events, boolean stop) { + this.name = name; + this.events = events; + this.stop = stop; + } + + @Override + public void init() { + // no-op + } + @Override + public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) { + + events.add(name); + + if (stop) { + response.setStatusCode(HttpResponseBuilder.SC_FORBIDDEN); + return; + } + + chain.doFilter(request, response); + } + + @Override + public void destroy() { + // no-op + } + } +} From 245e18839f228b3e1c8da341f4ab40fa6f85f3a8 Mon Sep 17 00:00:00 2001 From: Johan Karlsson <93186588+gurkvatten@users.noreply.github.com> Date: Thu, 26 Feb 2026 13:53:59 +0100 Subject: [PATCH 46/54] 28 file format compression filter (#82) * Add CompressionFilter skeleton with basic structure * Add Accept-Encoding header detection for gzip * Add Accept-Encoding header detection for gzip * Implement gzip compression for response bodies * Add Content-Encoding * Added test file * Add content-type filtering to skip non-compressible formats * Add Javadoc * coderabbit fixes * added getter for HttpResponseBuilder and rewrote code that used reflection * fixed IOException --- .../org/example/filter/CompressionFilter.java | 146 +++++++++++++ .../org/example/http/HttpResponseBuilder.java | 8 + .../example/filter/CompressionFilterTest.java | 197 ++++++++++++++++++ 3 files changed, 351 insertions(+) create mode 100644 src/main/java/org/example/filter/CompressionFilter.java create mode 100644 src/test/java/org/example/filter/CompressionFilterTest.java diff --git a/src/main/java/org/example/filter/CompressionFilter.java b/src/main/java/org/example/filter/CompressionFilter.java new file mode 100644 index 00000000..120c0769 --- /dev/null +++ b/src/main/java/org/example/filter/CompressionFilter.java @@ -0,0 +1,146 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Map; +import java.util.Set; +import java.util.zip.GZIPOutputStream; + +/** + * Compression filter that compresses HTTP responses with gzip when supported by client. + * + *

This filter applies gzip compression to HTTP responses when the following conditions are met: + *

    + *
  • Client sends Accept-Encoding header containing "gzip"
  • + *
  • Response body is larger than 1KB (MIN_COMPRESS_SIZE)
  • + *
  • Content-Type is compressible (text-based formats like HTML, CSS, JS, JSON)
  • + *
  • Content-Type is not already compressed (images, videos, zip files)
  • + *
+ * + *

When compression is applied, the filter: + *

    + *
  • Compresses the response body using gzip
  • + *
  • Sets Content-Encoding: gzip header
  • + *
  • Sets Vary: Accept-Encoding header for proper caching
  • + *
+ * + */ + +public class CompressionFilter implements Filter { + private static final int MIN_COMPRESS_SIZE = 1024; + + private static final Set COMPRESSIBLE_TYPES = Set.of( + "text/html", + "text/css", + "text/javascript", + "application/javascript", + "application/json", + "application/xml", + "text/plain" + ); + + private static final Set SKIP_TYPES = Set.of( + "image/jpeg", "image/jpg", "image/png", "image/gif", + "image/webp", "video/mp4", "application/zip", + "application/gzip", "application/x-gzip" + ); + + + @Override + public void init() { + } + + @Override + public void doFilter(HttpRequest request, HttpResponseBuilder response, + FilterChain chain) { + chain.doFilter(request, response); + + compressIfNeeded(request, response); + } + + private void compressIfNeeded(HttpRequest request, HttpResponseBuilder response) { + if (response.getHeader("Content-Encoding") != null) { + return; + } + + String acceptEncoding = getHeader(request, "Accept-Encoding"); + if (acceptEncoding == null || !acceptEncoding.toLowerCase().contains("gzip")) { + return; + } + + byte[] originalBody = response.getBodyBytes(); + if (originalBody == null || originalBody.length < MIN_COMPRESS_SIZE) { + return; + } + + String contentType = response.getHeader("Content-Type"); + if (!shouldCompress(contentType)) { + return; + } + + try { + byte[] compressed = gzipCompress(originalBody); + + response.setBody(compressed); + response.setHeader("Content-Encoding", "gzip"); + + String existingVary = response.getHeader("Vary"); + if (existingVary != null && !existingVary.isEmpty()) { + if (!existingVary.toLowerCase().contains("accept-encoding")) { + response.setHeader("Vary", existingVary + ", Accept-Encoding"); + } + } else { + response.setHeader("Vary", "Accept-Encoding"); + } + + } catch (IOException e) { + System.err.println("CompressionFilter: gzip compression failed: " + e.getMessage()); + } + } + + private boolean shouldCompress(String contentType) { + if (contentType == null) { + return false; + } + + String baseType = contentType.split(";")[0].trim().toLowerCase(); + + if (SKIP_TYPES.contains(baseType)) { + return false; + } + + return COMPRESSIBLE_TYPES.contains(baseType) || + baseType.startsWith("text/"); + } + + private String getHeader(HttpRequest request, String headerName) { + Map headers = request.getHeaders(); + + String value = headers.get(headerName); + if (value != null) return value; + + for (Map.Entry entry : headers.entrySet()) { + if (entry.getKey().equalsIgnoreCase(headerName)) { + return entry.getValue(); + } + } + return null; + } + + private byte[] gzipCompress(byte[] data) throws IOException { + ByteArrayOutputStream byteStream = new ByteArrayOutputStream(data.length); + + try (GZIPOutputStream gzipStream = new GZIPOutputStream(byteStream)) { + gzipStream.write(data); + } + + return byteStream.toByteArray(); + } + + @Override + public void destroy() { + } +} \ No newline at end of file diff --git a/src/main/java/org/example/http/HttpResponseBuilder.java b/src/main/java/org/example/http/HttpResponseBuilder.java index 9b6ed2a7..db6e1880 100644 --- a/src/main/java/org/example/http/HttpResponseBuilder.java +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -87,6 +87,14 @@ public void setHeaders(Map headers) { public void setHeader(String name, String value) { this.headers.put(name, value); } + public String getHeader(String name) { + return headers.get(name); + } + + public byte[] getBodyBytes() { + if (bytebody != null) return bytebody; + return body.getBytes(StandardCharsets.UTF_8); + } public void setContentTypeFromFilename(String filename) { String mimeType = MimeTypeDetector.detectMimeType(filename); diff --git a/src/test/java/org/example/filter/CompressionFilterTest.java b/src/test/java/org/example/filter/CompressionFilterTest.java new file mode 100644 index 00000000..b9994635 --- /dev/null +++ b/src/test/java/org/example/filter/CompressionFilterTest.java @@ -0,0 +1,197 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.zip.GZIPInputStream; + +import static org.junit.jupiter.api.Assertions.*; + +class CompressionFilterTest { + + @Test + void testGzipCompressionWhenClientSupportsIt() throws Exception { + Map headers = new HashMap<>(); + headers.put("Accept-Encoding", "gzip, deflate"); + + HttpRequest request = new HttpRequest( + "GET", + "/", + "HTTP/1.1", + headers, + null + ); + + 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.init(); + filter.doFilter(request, response, mockChain); + + byte[] compressedBody = getBodyFromResponse(response); + assertNotNull(compressedBody, "Body should not be null"); + assertTrue(compressedBody.length < largeBody.getBytes(StandardCharsets.UTF_8).length, + "Compressed body should be smaller than original"); + + + String decompressed = decompressGzip(compressedBody); + assertEquals(largeBody, decompressed, "Decompressed data should match original"); + } + + @Test + void testNoCompressionWhenClientDoesNotSupport() { + HttpRequest request = new HttpRequest( + "GET", + "/", + "HTTP/1.1", + Map.of(), + null + ); + + String body = "" + "Hello World! ".repeat(200) + ""; + HttpResponseBuilder response = new HttpResponseBuilder(); + response.setBody(body); + + FilterChain mockChain = (req, res) -> {}; + + CompressionFilter filter = new CompressionFilter(); + filter.doFilter(request, response, mockChain); + + byte[] resultBody = getBodyFromResponse(response); + assertArrayEquals(body.getBytes(StandardCharsets.UTF_8), resultBody, + "Body should not be compressed when client doesn't support gzip"); + } + + @Test + void testNoCompressionForSmallResponses() { + Map headers = new HashMap<>(); + headers.put("Accept-Encoding", "gzip"); + + HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", headers, null); + + String smallBody = "Hello"; + HttpResponseBuilder response = new HttpResponseBuilder(); + response.setBody(smallBody); + + FilterChain mockChain = (req, res) -> {}; + + CompressionFilter filter = new CompressionFilter(); + filter.doFilter(request, response, mockChain); + + byte[] resultBody = getBodyFromResponse(response); + assertArrayEquals(smallBody.getBytes(StandardCharsets.UTF_8), resultBody, + "Small bodies should not be compressed"); + } + + private String decompressGzip(byte[] compressed) throws Exception { + ByteArrayInputStream bais = new ByteArrayInputStream(compressed); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + try (GZIPInputStream gzis = new GZIPInputStream(bais)) { + byte[] buffer = new byte[1024]; + int len; + while ((len = gzis.read(buffer)) > 0) { + baos.write(buffer, 0, len); + } + } + + return baos.toString(StandardCharsets.UTF_8); + } + + 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); + } + } + @Test + void testSkipCompressionForImages() { + Map headers = new HashMap<>(); + headers.put("Accept-Encoding", "gzip"); + + HttpRequest request = new HttpRequest("GET", "/image.jpg", "HTTP/1.1", headers, null); + + String largeImageData = "fake image data ".repeat(200); + HttpResponseBuilder response = new HttpResponseBuilder(); + response.setBody(largeImageData); + response.setHeaders(Map.of("Content-Type", "image/jpeg")); + + FilterChain mockChain = (req, res) -> {}; + + CompressionFilter filter = new CompressionFilter(); + filter.doFilter(request, response, mockChain); + + byte[] resultBody = getBodyFromResponse(response); + assertArrayEquals(largeImageData.getBytes(StandardCharsets.UTF_8), resultBody, + "Images should not be compressed"); + } + + @Test + void testCompressJsonResponse() { + Map headers = new HashMap<>(); + headers.put("Accept-Encoding", "gzip"); + + HttpRequest request = new HttpRequest("GET", "/api/data", "HTTP/1.1", headers, null); + + String jsonData = "{\"data\": " + "\"value\",".repeat(200) + "}"; + HttpResponseBuilder response = new HttpResponseBuilder(); + response.setBody(jsonData); + response.setHeaders(Map.of("Content-Type", "application/json")); + + FilterChain mockChain = (req, res) -> {}; + + CompressionFilter filter = new CompressionFilter(); + filter.doFilter(request, response, mockChain); + + byte[] resultBody = getBodyFromResponse(response); + assertTrue(resultBody.length < jsonData.getBytes(StandardCharsets.UTF_8).length, + "JSON should be compressed"); + } + + @Test + void testHandleContentTypeWithCharset() { + Map headers = new HashMap<>(); + headers.put("Accept-Encoding", "gzip"); + + HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", headers, null); + + String body = "" + "content ".repeat(200) + ""; + HttpResponseBuilder response = new HttpResponseBuilder(); + response.setBody(body); + response.setHeaders(Map.of("Content-Type", "text/html; charset=UTF-8")); + + FilterChain mockChain = (req, res) -> {}; + + CompressionFilter filter = new CompressionFilter(); + filter.doFilter(request, response, mockChain); + + byte[] resultBody = getBodyFromResponse(response); + assertTrue(resultBody.length < body.getBytes(StandardCharsets.UTF_8).length, + "Should compress even when Content-Type has charset"); + } +} \ No newline at end of file From bc9331366939df097bd297a251a8264b979c933e Mon Sep 17 00:00:00 2001 From: Ebba Andersson Date: Thu, 26 Feb 2026 17:05:53 +0100 Subject: [PATCH 47/54] Feature/32 filter request timeout class (#90) * Create RequestTimeOutFilter and RequestTimeOutFilterTest Co-authored-by: Ebba Andersson * Revert "Create RequestTimeOutFilter and RequestTimeOutFilterTest" This reverts commit efc883d8baa8f4289d3a75e7cd3566fb52b45ca0. * Create RequestTimeOutFilter and RequestTimeOutFilterTest Co-authored-by: Ebba Andersson * Add test for exception in RequestTimeOutFilterTest Co-authored-by: Ebba Andersson * Add a test for exception in RequestTimeOutFilterTest Changes by ebbaandersson * Add javadocs for RequestTimeOutFilter Co-authored-by: Ebba Andersson * Changes made after CodeRabbit Review Co-authored-by: Ebba Andersson * Change ExecutorService, add rejectedExecutionException, add method handleInternalError Co-authored-by: Ebba Andersson * Changes tests to assert the new code in RequestTimeOutFilter * Create RequestTimeOutFilter and RequestTimeOutFilterTest Co-authored-by: Ebba Andersson * Add test for exception in RequestTimeOutFilterTest Co-authored-by: Ebba Andersson * Add a test for exception in RequestTimeOutFilterTest Changes by ebbaandersson * Add javadocs for RequestTimeOutFilter Co-authored-by: Ebba Andersson * Changes made after CodeRabbit Review Co-authored-by: Ebba Andersson * Change ExecutorService, add rejectedExecutionException, add method handleInternalError Co-authored-by: Ebba Andersson * Changes tests to assert the new code in RequestTimeOutFilter * add a future.cancel to stop the worker task. add defensive copying and unmodifiable views of setter and getter in HttpResponseBuilder Co-authored-by: Ebba Andersson * Removes "static" from ExecutorService. Adds a test which checks that the timeout-time is bigger than zero Co-authored-by: Ebba Andersson * Adds chain-execution verification to happy-path-test. * Implementing a transfer from existing respons to shadowResponse in the beginning to make sure we don't lose information Co-authored-by: Ebba Andersson --------- Co-authored-by: Fredrik Mohlen --- .../example/filter/RequestTimeOutFilter.java | 122 ++++++++++++++++++ .../org/example/http/HttpResponseBuilder.java | 19 ++- .../filter/RequestTimeOutFilterTest.java | 91 +++++++++++++ 3 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/example/filter/RequestTimeOutFilter.java create mode 100644 src/test/java/org/example/filter/RequestTimeOutFilterTest.java diff --git a/src/main/java/org/example/filter/RequestTimeOutFilter.java b/src/main/java/org/example/filter/RequestTimeOutFilter.java new file mode 100644 index 00000000..1bfc7274 --- /dev/null +++ b/src/main/java/org/example/filter/RequestTimeOutFilter.java @@ -0,0 +1,122 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; +import static org.example.http.HttpResponseBuilder.*; + +import java.util.Map; +import java.util.concurrent.*; +import java.util.logging.Logger; + +/** + * A proactive filter that monitors the execution time of the request processing chain. + * If the execution exceeds the specified timeout, the filter interrupts the + * processing thread and returns an HTTP 504 Gateway Timeout response. + */ +public class RequestTimeOutFilter implements Filter { + + private final int timeoutMS; + private static final Logger logger = Logger.getLogger(RequestTimeOutFilter.class.getName()); + + /** Thread pool used to execute the filter chain asynchronously for timeout monitoring. */ + private final ExecutorService executor = new ThreadPoolExecutor( + Math.max(4, Runtime.getRuntime().availableProcessors() * 2), + Math.max(4, Runtime.getRuntime().availableProcessors() * 2), + 60L, TimeUnit.SECONDS, + new ArrayBlockingQueue<>(50), + new ThreadPoolExecutor.AbortPolicy() + ); + + public RequestTimeOutFilter(int timeoutMS) { + + if (timeoutMS <= 0) { + throw new IllegalArgumentException("timeoutMS must be greater than 0"); + } + this.timeoutMS = timeoutMS; + } + + @Override + public void init() {} + + @Override + public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) { + + HttpResponseBuilder shadowResponse = new HttpResponseBuilder(); + // Preserve state already present on the real response before downstream execution + transferResponseData(response, shadowResponse); + + Future future; + try { + future = executor.submit(() -> { + try { + chain.doFilter(request, shadowResponse); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + + } catch (RejectedExecutionException e) { + logger.severe("SERVER OVERLOADED: Queue is full for path " + request.getPath()); + response.setStatusCode(SC_SERVICE_UNAVAILABLE); + response.setHeaders(Map.of("Content-Type", "text/html; charset=utf-8")); + response.setBody("

503 Service Unavailable

Server is too busy to handle the request.

"); + return; + } + + try { + future.get(timeoutMS, TimeUnit.MILLISECONDS); + transferResponseData(shadowResponse, response); + + } catch (TimeoutException e) { + future.cancel(true); + logger.warning("TIMEOUT ERROR: " + request.getPath() + " was interrupted after " + timeoutMS + "ms"); + + response.setStatusCode(SC_GATEWAY_TIMEOUT); + response.setHeaders(Map.of("Content-Type", "text/html; charset=utf-8")); + response.setBody("

504 Gateway Timeout

The server took too long to respond.

"); + + return; + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + future.cancel(true); + handleInternalError(response, e); + return; + + } catch (ExecutionException e) { + handleInternalError(response, e); + return; + } + } + private void transferResponseData(HttpResponseBuilder source, HttpResponseBuilder target) { + target.setStatusCode(source.getStatusCode()); + target.setHeaders(source.getHeaders()); + + byte[] sourceBytes = source.getByteBody(); + if (sourceBytes != null) { + target.setBody(sourceBytes); + } else { + target.setBody(source.getBody()); + } + } + + private void handleInternalError(HttpResponseBuilder response, Exception e) { + logger.severe("Error during execution: " + e.getMessage()); + response.setStatusCode(SC_INTERNAL_SERVER_ERROR); + response.setHeaders(Map.of("Content-Type", "text/html; charset=utf-8")); + response.setBody("

500 Internal Server Error

"); + } + + @Override + public void destroy() { + executor.shutdown(); + try { + if(!executor.awaitTermination(5, TimeUnit.SECONDS)) { + executor.shutdownNow(); + } + } catch (InterruptedException e) { + executor.shutdownNow(); + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/main/java/org/example/http/HttpResponseBuilder.java b/src/main/java/org/example/http/HttpResponseBuilder.java index db6e1880..bd4026af 100644 --- a/src/main/java/org/example/http/HttpResponseBuilder.java +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -1,6 +1,8 @@ package org.example.http; import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; import java.util.Map; import java.util.TreeMap; @@ -75,7 +77,7 @@ public void setBody(String body) { } public void setBody(byte[] body) { - this.bytebody = body; + this.bytebody = body == null ? null : Arrays.copyOf(body, body.length); this.body = ""; } @@ -146,4 +148,19 @@ public byte[] build() { return response; } + + public Map getHeaders() { + TreeMap copy = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + copy.putAll(headers); + return Collections.unmodifiableMap(copy); + } + + public String getBody(){ + return body; + } + + public byte[] getByteBody() { + + return bytebody == null ? null : Arrays.copyOf(bytebody, bytebody.length); + } } diff --git a/src/test/java/org/example/filter/RequestTimeOutFilterTest.java b/src/test/java/org/example/filter/RequestTimeOutFilterTest.java new file mode 100644 index 00000000..f46b6281 --- /dev/null +++ b/src/test/java/org/example/filter/RequestTimeOutFilterTest.java @@ -0,0 +1,91 @@ +package org.example.filter; + +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.concurrent.atomic.AtomicBoolean; + +import static org.example.http.HttpResponseBuilder.*; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +class RequestTimeOutFilterTest { + + private RequestTimeOutFilter filter; + private HttpResponseBuilder response; + private HttpRequest request; + + @BeforeEach + void setUp() { + filter = new RequestTimeOutFilter(100); + response = new HttpResponseBuilder(); + request = new HttpRequest("GET", "/", "HTTP/1.1",null,""); + } + + + // Happy Path --> Allt går bra + @Test + void requestTimeOutFilter_shouldSucceedWhenFast() { + + // Arrange --> FilterChain som körs utan fördröjning + AtomicBoolean chainInvoked = new AtomicBoolean(false); + FilterChain fastChain = (request, response) -> chainInvoked.set(true); + + // Act + filter.doFilter(request, response, fastChain); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(SC_OK); + assertThat(chainInvoked.get()).isTrue(); + } + + // Timeout Path --> Anropet tar för lång tid och kastar RunTimeException + @Test + void requestTimeOutFilter_shouldReturn504ResponseWhenSlow() { + // Arrange --> En simulation av en fördröjning + FilterChain slowChain = (request, response) -> { + try { + Thread.sleep(600); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }; + + // Act + filter.doFilter(request, response, slowChain); + + // Assert + assertThat(response.getStatusCode()) + .as("Status code should be 504 at timeout") + .isEqualTo(SC_GATEWAY_TIMEOUT); + + assertThat(new String(response.build())) + .contains("Gateway Timeout"); + } + + // Exception Path --> Oväntat undantag kastar en exception + @Test + void requestTimeOutFilter_shouldHandleGenericException() { + // Arrange + FilterChain errorChain = (request, response) -> { + throw new RuntimeException("Unexpected error"); + }; + + // Act + filter.doFilter(request, response, errorChain); + + // Assert + assertThat(response.getStatusCode()).isEqualTo((SC_INTERNAL_SERVER_ERROR)); + + } + @Test + void constructor_shouldRejectNonPositiveTimeout() { + assertThatThrownBy(() -> new RequestTimeOutFilter(0)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new RequestTimeOutFilter(-1)) + .isInstanceOf(IllegalArgumentException.class); + } +} From 80ffccf569c4db31e5ab96a1385e49c85b2f9886 Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Fri, 27 Feb 2026 10:35:55 +0100 Subject: [PATCH 48/54] Feature/config loading from classpath (#72) * Added new methods loadFromClassPath, loadOnceWithClasspathFallback and createMapperForName. Modified createMapperFor-method to work with createMapperForName * Updated main to use new method loadOnceWithClasspathFallback * Added new test: fallback_to_classpath_when_external_file_missing() * reworked loadOnceWithCLasspathFallback to adress external file deleted between Files.exists and load() silently bypasses the classpath fallback. Removed unused code from main. * Replaced new YAMLFactory() with YAMLFactory.builder().build() after feedback from coderabbit. * Uppdated main to run both loadOnceWithClasspathFallback and cli. * Added null preconditions after feedback from coderabbit and removed unused import. --- src/main/java/org/example/App.java | 6 +- .../java/org/example/config/ConfigLoader.java | 59 +++++++++++++++++-- .../org/example/config/ConfigLoaderTest.java | 12 ++++ src/test/resources/application.yml | 6 ++ 4 files changed, 76 insertions(+), 7 deletions(-) create mode 100644 src/test/resources/application.yml diff --git a/src/main/java/org/example/App.java b/src/main/java/org/example/App.java index 966e9563..0189e1f3 100644 --- a/src/main/java/org/example/App.java +++ b/src/main/java/org/example/App.java @@ -11,9 +11,11 @@ public class App { private static final String PORT_FLAG = "--port"; public static void main(String[] args) { - Path configPath = Path.of("src/main/resources/application.yml"); - AppConfig appConfig = ConfigLoader.loadOnce(configPath); + AppConfig appConfig = ConfigLoader.loadOnceWithClasspathFallback( + Path.of("application.yml"), + "application.yml" + ); int port = resolvePort(args, appConfig.server().port()); diff --git a/src/main/java/org/example/config/ConfigLoader.java b/src/main/java/org/example/config/ConfigLoader.java index 96274158..86bd5ef5 100644 --- a/src/main/java/org/example/config/ConfigLoader.java +++ b/src/main/java/org/example/config/ConfigLoader.java @@ -2,7 +2,6 @@ import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.json.JsonMapper; -import tools.jackson.dataformat.yaml.YAMLFactory; import tools.jackson.dataformat.yaml.YAMLMapper; import java.io.InputStream; @@ -27,6 +26,32 @@ public static AppConfig loadOnce(Path configPath) { } } + public static AppConfig loadOnceWithClasspathFallback(Path externalPath, String classpathResourceName) { + Objects.requireNonNull(externalPath, "externalPath"); + Objects.requireNonNull(classpathResourceName, "classpathResourceName"); + if (cached != null) return cached; + + synchronized (ConfigLoader.class) { + if (cached == null){ + AppConfig base; + try (InputStream ext = Files.newInputStream(externalPath)) { + ObjectMapper objectMapper = createMapperFor(externalPath); + AppConfig config = objectMapper.readValue(ext, AppConfig.class); + base = config == null ? AppConfig.defaults() : config; + + } catch (java.nio.file.NoSuchFileException ignored) { + base = loadFromClasspath(classpathResourceName); + + } catch (Exception e) { + throw new IllegalStateException("failed to read config file " + externalPath.toAbsolutePath(), e); + } + + cached = base.withDefaultsApplied(); + } + return cached; + } + } + public static AppConfig get(){ if (cached == null){ throw new IllegalStateException("Config not loaded. call ConfigLoader.loadOnce(...) at startup."); @@ -52,17 +77,41 @@ public static AppConfig load(Path configPath) { } } - private static ObjectMapper createMapperFor(Path configPath) { - String name = configPath.getFileName().toString().toLowerCase(); + public static AppConfig loadFromClasspath(String classpathResourceName) { + Objects.requireNonNull(classpathResourceName, "classpathResourceName"); + + try (InputStream stream = ConfigLoader.class.getClassLoader().getResourceAsStream(classpathResourceName)) { + if (stream == null) { + return AppConfig.defaults(); + } + + ObjectMapper objectMapper = createMapperForName(classpathResourceName); + + AppConfig config = objectMapper.readValue(stream, AppConfig.class); + return config == null ? AppConfig.defaults() : config; + } catch (Exception e){ + throw new IllegalStateException("failed to read config file from classpath: " + classpathResourceName, e); + } + } + + private static ObjectMapper createMapperForName(String fileName) { + String name = fileName.toLowerCase(); if (name.endsWith(".yml") || name.endsWith(".yaml")) { - return YAMLMapper.builder(new YAMLFactory()).build(); + return YAMLMapper.builder().build(); } else if (name.endsWith(".json")) { return JsonMapper.builder().build(); } else { - return YAMLMapper.builder(new YAMLFactory()).build(); + return YAMLMapper.builder().build(); } + + } + + private static ObjectMapper createMapperFor(Path configPath) { + String name = configPath.getFileName().toString(); + + return createMapperForName(name); } public static void resetForTests() { diff --git a/src/test/java/org/example/config/ConfigLoaderTest.java b/src/test/java/org/example/config/ConfigLoaderTest.java index b694a7af..ff92dd93 100644 --- a/src/test/java/org/example/config/ConfigLoaderTest.java +++ b/src/test/java/org/example/config/ConfigLoaderTest.java @@ -137,5 +137,17 @@ void invalid_port_should_Throw_Exception () throws Exception { assertThatThrownBy(() -> ConfigLoader.loadOnce(configFile)) .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Invalid port number"); + } + + @Test + @DisplayName("missing external file should fallback to classpath") + void fallback_to_classpath_when_external_file_missing(){ + AppConfig appConfig = ConfigLoader.loadOnceWithClasspathFallback(tempDir.resolve("missing.yml"),"application.yml"); + + assertThat(appConfig.server().port()).isEqualTo(3030); + + + + } } diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 00000000..1e368859 --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,6 @@ +server: + port: 3030 + rootDir: ./from-classpath + +logging: + level: DEBUG From bf3805073fcadff6b957cfc9aa1812c600e93f18 Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Fri, 27 Feb 2026 12:59:39 +0100 Subject: [PATCH 49/54] Implement X-Forwarded-For in rate limiting filter --- src/main/java/org/example/filter/RateLimitingFilter.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/example/filter/RateLimitingFilter.java b/src/main/java/org/example/filter/RateLimitingFilter.java index 5ce846a3..2e0b91c2 100644 --- a/src/main/java/org/example/filter/RateLimitingFilter.java +++ b/src/main/java/org/example/filter/RateLimitingFilter.java @@ -48,13 +48,18 @@ public void init() { public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) { Object clientIpAttr = request.getAttribute("clientIp"); - if (!(clientIpAttr instanceof String) || ((String) clientIpAttr).isBlank()) { + + if (!(clientIpAttr instanceof String clientIp) || (clientIp.isBlank())) { response.setStatusCode(HttpResponseBuilder.SC_BAD_REQUEST); response.setBody("

400 Bad Request

Missing client IP.

\n"); return; } - String clientIp = (String) clientIpAttr; + String xForwardedFor = request.getHeaders().get("X-Forwarded-For"); + + if( xForwardedFor != null || xForwardedFor.isBlank() ) { + clientIp = xForwardedFor.split(",")[0].trim(); + } BucketWrapper wrapper = buckets.computeIfAbsent(clientIp, k -> new BucketWrapper(createNewBucket())); From 95752884b02d36f4a77d5cf12dd3428c5b8c3b13 Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Fri, 27 Feb 2026 13:37:41 +0100 Subject: [PATCH 50/54] Correction in x forwarded for logic --- src/main/java/org/example/filter/RateLimitingFilter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/example/filter/RateLimitingFilter.java b/src/main/java/org/example/filter/RateLimitingFilter.java index 2e0b91c2..3994eb8c 100644 --- a/src/main/java/org/example/filter/RateLimitingFilter.java +++ b/src/main/java/org/example/filter/RateLimitingFilter.java @@ -57,7 +57,7 @@ public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterCh String xForwardedFor = request.getHeaders().get("X-Forwarded-For"); - if( xForwardedFor != null || xForwardedFor.isBlank() ) { + if( xForwardedFor != null && xForwardedFor.isBlank() ) { clientIp = xForwardedFor.split(",")[0].trim(); } From a610bac092aeaa49224673128b730b95429e1ac2 Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Fri, 27 Feb 2026 15:38:16 +0100 Subject: [PATCH 51/54] Refactoring logic of x forwarded for to a method --- .../example/filter/RateLimitingFilter.java | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/example/filter/RateLimitingFilter.java b/src/main/java/org/example/filter/RateLimitingFilter.java index 3994eb8c..e0ffdfcd 100644 --- a/src/main/java/org/example/filter/RateLimitingFilter.java +++ b/src/main/java/org/example/filter/RateLimitingFilter.java @@ -47,19 +47,9 @@ public void init() { @Override public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) { - Object clientIpAttr = request.getAttribute("clientIp"); + String clientIp = resolveClientIp(request, response); - if (!(clientIpAttr instanceof String clientIp) || (clientIp.isBlank())) { - response.setStatusCode(HttpResponseBuilder.SC_BAD_REQUEST); - response.setBody("

400 Bad Request

Missing client IP.

\n"); - return; - } - - String xForwardedFor = request.getHeaders().get("X-Forwarded-For"); - - if( xForwardedFor != null && xForwardedFor.isBlank() ) { - clientIp = xForwardedFor.split(",")[0].trim(); - } + if (clientIp == null) return; BucketWrapper wrapper = buckets.computeIfAbsent(clientIp, k -> new BucketWrapper(createNewBucket())); @@ -114,6 +104,24 @@ void updateAccess() { } } + public static String resolveClientIp(HttpRequest request, HttpResponseBuilder response) { + Object clientIpAttr = request.getAttribute("clientIp"); + + if (!(clientIpAttr instanceof String clientIp) || (clientIp.isBlank())) { + response.setStatusCode(HttpResponseBuilder.SC_BAD_REQUEST); + response.setBody("

400 Bad Request

Missing client IP.

\n"); + return null; + } + + String xForwardedFor = request.getHeaders().get("X-Forwarded-For"); + + if( xForwardedFor != null && xForwardedFor.isBlank() ) { + clientIp = xForwardedFor.split(",")[0].trim(); + } + + return clientIp; + } + public Thread startCleanupThread() { return Thread.ofVirtual().name("rate-limit-cleanup").start(() -> { while (!Thread.currentThread().isInterrupted()) { From d95116b96c9ac34241a24868ab89dd1889ad636a Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Fri, 27 Feb 2026 15:46:40 +0100 Subject: [PATCH 52/54] Fix logic in method resolve client ip --- src/main/java/org/example/filter/RateLimitingFilter.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/example/filter/RateLimitingFilter.java b/src/main/java/org/example/filter/RateLimitingFilter.java index e0ffdfcd..e5a9667b 100644 --- a/src/main/java/org/example/filter/RateLimitingFilter.java +++ b/src/main/java/org/example/filter/RateLimitingFilter.java @@ -105,6 +105,7 @@ void updateAccess() { } public static String resolveClientIp(HttpRequest request, HttpResponseBuilder response) { + Object clientIpAttr = request.getAttribute("clientIp"); if (!(clientIpAttr instanceof String clientIp) || (clientIp.isBlank())) { @@ -115,7 +116,7 @@ public static String resolveClientIp(HttpRequest request, HttpResponseBuilder re String xForwardedFor = request.getHeaders().get("X-Forwarded-For"); - if( xForwardedFor != null && xForwardedFor.isBlank() ) { + if( xForwardedFor != null && !xForwardedFor.isBlank() ) { clientIp = xForwardedFor.split(",")[0].trim(); } From 0583b1455ca8ad43eb765ae53fb946b3a70878b4 Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Fri, 27 Feb 2026 15:52:13 +0100 Subject: [PATCH 53/54] Add rate limiting filter IP test --- .../filter/RateLimitingFilterIpTest.java | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/test/java/org/example/filter/RateLimitingFilterIpTest.java diff --git a/src/test/java/org/example/filter/RateLimitingFilterIpTest.java b/src/test/java/org/example/filter/RateLimitingFilterIpTest.java new file mode 100644 index 00000000..c63dc3df --- /dev/null +++ b/src/test/java/org/example/filter/RateLimitingFilterIpTest.java @@ -0,0 +1,47 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.HashMap; +import java.util.Map; + +import static org.example.filter.RateLimitingFilter.resolveClientIp; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RateLimitingFilterIpTest { + + @Mock HttpRequest request; + @Mock HttpResponseBuilder response; + + @Test + void shouldUseXForwarded_WhenPresent(){ + + Map headers = new HashMap<>(); + headers.put("X-Forwarded-For", "203.0.113.195"); + + when(request.getHeaders()).thenReturn(headers); + when(request.getAttribute("clientIp")).thenReturn("127.0.0.1"); + + String finalIp = resolveClientIp(request, response); + + assertEquals("203.0.113.195", finalIp); + } + + @Test + void shouldFallbackToAttribute_WhenXForwardedForIsNotPresent(){ + + when(request.getAttribute("clientIp")).thenReturn(Map.of()); + when(request.getAttribute("clientIp")).thenReturn("10.0.0.5"); + + String finalIp = resolveClientIp(request, response); + + assertEquals("10.0.0.5", finalIp); + } +} From 6fed94073bc76a12e361e5717abe037866ce269f Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Fri, 27 Feb 2026 16:03:01 +0100 Subject: [PATCH 54/54] Fix with code rabbit review --- src/test/java/org/example/filter/RateLimitingFilterIpTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/java/org/example/filter/RateLimitingFilterIpTest.java b/src/test/java/org/example/filter/RateLimitingFilterIpTest.java index c63dc3df..f27cd8ec 100644 --- a/src/test/java/org/example/filter/RateLimitingFilterIpTest.java +++ b/src/test/java/org/example/filter/RateLimitingFilterIpTest.java @@ -37,7 +37,8 @@ void shouldUseXForwarded_WhenPresent(){ @Test void shouldFallbackToAttribute_WhenXForwardedForIsNotPresent(){ - when(request.getAttribute("clientIp")).thenReturn(Map.of()); + Map headers = new HashMap<>(); + when(request.getHeaders()).thenReturn(headers); when(request.getAttribute("clientIp")).thenReturn("10.0.0.5"); String finalIp = resolveClientIp(request, response);