From 98b40a1f09cc59c686898602bbf7a5a46441b111 Mon Sep 17 00:00:00 2001 From: Kathrin Trinh Date: Tue, 10 Feb 2026 14:44:16 +0100 Subject: [PATCH 01/28] 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/28] 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/28] =?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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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 6a424d54aa47843c43822900a6dbb7fd9ae46f78 Mon Sep 17 00:00:00 2001 From: Younes Date: Thu, 12 Feb 2026 09:26:54 +0100 Subject: [PATCH 10/28] Added MIME detector class and test class --- src/main/java/org/example/http/MimeTypeDetector.java | 5 +++++ src/test/java/org/example/http/MimeTypeDetectorTest.java | 4 ++++ 2 files changed, 9 insertions(+) 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/http/MimeTypeDetector.java b/src/main/java/org/example/http/MimeTypeDetector.java new file mode 100644 index 00000000..32b97800 --- /dev/null +++ b/src/main/java/org/example/http/MimeTypeDetector.java @@ -0,0 +1,5 @@ +package org.example.http; + +public class MimeTypeDetector { + +} 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..48eb10df --- /dev/null +++ b/src/test/java/org/example/http/MimeTypeDetectorTest.java @@ -0,0 +1,4 @@ +package org.example.http; + +public class MimeTypeDetectorTest { +} From bf44e600ccda0946336d8f33615578c4bd706197 Mon Sep 17 00:00:00 2001 From: Younes Date: Thu, 12 Feb 2026 09:38:07 +0100 Subject: [PATCH 11/28] Added logic for Mime detector class --- .../org/example/http/MimeTypeDetector.java | 71 ++++++++++++++++++- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/example/http/MimeTypeDetector.java b/src/main/java/org/example/http/MimeTypeDetector.java index 32b97800..a009250c 100644 --- a/src/main/java/org/example/http/MimeTypeDetector.java +++ b/src/main/java/org/example/http/MimeTypeDetector.java @@ -1,5 +1,72 @@ package org.example.http; -public class MimeTypeDetector { +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) { + if (filename == null || filename.isEmpty()) { + return "application/octet-stream"; + } + + // 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 "application/octet-stream"; + } + + String extension = filename.substring(lastDot).toLowerCase(); + return MIME_TYPES.getOrDefault(extension, "application/octet-stream"); + } +} \ No newline at end of file From 47cecbd336de6fcab338f2e3325e529f85dc6458 Mon Sep 17 00:00:00 2001 From: Younes Date: Thu, 12 Feb 2026 09:48:34 +0100 Subject: [PATCH 12/28] Added Unit tests --- .../org/example/http/MimeTypeDetector.java | 11 +- .../example/http/MimeTypeDetectorTest.java | 165 +++++++++++++++++- 2 files changed, 171 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/example/http/MimeTypeDetector.java b/src/main/java/org/example/http/MimeTypeDetector.java index a009250c..9005078a 100644 --- a/src/main/java/org/example/http/MimeTypeDetector.java +++ b/src/main/java/org/example/http/MimeTypeDetector.java @@ -54,19 +54,24 @@ private MimeTypeDetector() { * @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 "application/octet-stream"; + 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 "application/octet-stream"; + return octet; } String extension = filename.substring(lastDot).toLowerCase(); - return MIME_TYPES.getOrDefault(extension, "application/octet-stream"); + return MIME_TYPES.getOrDefault(extension, octet); } } \ 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 index 48eb10df..913aeb48 100644 --- a/src/test/java/org/example/http/MimeTypeDetectorTest.java +++ b/src/test/java/org/example/http/MimeTypeDetectorTest.java @@ -1,4 +1,165 @@ package org.example.http; -public class MimeTypeDetectorTest { -} +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 cc93f34d22bae3a53d6c23a2e5f54f1b9acd223d Mon Sep 17 00:00:00 2001 From: Younes Date: Thu, 12 Feb 2026 10:23:18 +0100 Subject: [PATCH 13/28] Added logic in HttpResponseBuilder and tests to try it out --- .../org/example/http/HttpResponseBuilder.java | 17 ++- .../example/http/HttpResponseBuilderTest.java | 144 +++++++++++++++--- 2 files changed, 131 insertions(+), 30 deletions(-) diff --git a/src/main/java/org/example/http/HttpResponseBuilder.java b/src/main/java/org/example/http/HttpResponseBuilder.java index eb1f77ea..9ca4d0ff 100644 --- a/src/main/java/org/example/http/HttpResponseBuilder.java +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -1,5 +1,5 @@ package org.example.http; -// + import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; import java.util.Map; @@ -13,7 +13,6 @@ public class HttpResponseBuilder { private static final String CRLF = "\r\n"; - public void setStatusCode(int statusCode) { this.statusCode = statusCode; } @@ -21,10 +20,20 @@ public void setStatusCode(int statusCode) { public void setBody(String body) { this.body = body != null ? body : ""; } + public void setHeaders(Map headers) { this.headers = new LinkedHashMap<>(headers); } + 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); + } + private static final Map REASON_PHRASES = Map.of( 200, "OK", 201, "Created", @@ -44,7 +53,5 @@ public String build(){ sb.append(CRLF); sb.append(body); return sb.toString(); - } - -} +} \ No newline at end of file diff --git a/src/test/java/org/example/http/HttpResponseBuilderTest.java b/src/test/java/org/example/http/HttpResponseBuilderTest.java index 8b80a7f8..9b487792 100644 --- a/src/test/java/org/example/http/HttpResponseBuilderTest.java +++ b/src/test/java/org/example/http/HttpResponseBuilderTest.java @@ -1,45 +1,139 @@ 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.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 - */ + /** + * 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() { + @Test + void build_returnsValidHttpResponse() { - HttpResponseBuilder builder = new HttpResponseBuilder(); + HttpResponseBuilder builder = new HttpResponseBuilder(); - builder.setBody("Hello"); + builder.setBody("Hello"); - String result = builder.build(); + 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"); + 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"); + + } + + @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"); + + String result = builder.build(); + + assertThat(result).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("{}"); + + String result = builder.build(); + + assertThat(result).contains("Content-Type: application/json"); + assertThat(result).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"); + + String result = builder.build(); + + assertThat(result).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", // Okänd ändelse + "/var/www/index.html, text/html; charset=UTF-8", // Med sökväg + "'', application/octet-stream", // Tom sträng + "null, application/octet-stream" // Null-värde + }, 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"); - // Verifies that Content-Length is calculated using UTF-8 byte length! - // - @Test - void build_handlesUtf8ContentLength() { - HttpResponseBuilder builder = new HttpResponseBuilder(); + String result = builder.build(); - builder.setBody("å"); + assertThat(result).contains("Content-Type: " + expectedContentType); + } - String result = builder.build(); + @Test + @DisplayName("Should override previous Content-Type when set again") + void setContentTypeFromFilename_overridesPrevious() { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setContentTypeFromFilename("file.txt"); + builder.setContentTypeFromFilename("file.html"); + builder.setBody("Test"); - assertThat(result).contains("Content-Length: 2"); + String result = builder.build(); + assertThat(result).contains("Content-Type: text/html; charset=UTF-8"); + assertThat(result).doesNotContain("text/plain"); + } } -} From 511b5eef9de4c3b49af24ecb79272142d3dd8a25 Mon Sep 17 00:00:00 2001 From: Caroline Nordbrandt Date: Thu, 12 Feb 2026 10:57:11 +0100 Subject: [PATCH 14/28] 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 15/28] 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 16/28] * 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 73447eebb82bd9cdb11de8a6c9f2d93b4aba7d08 Mon Sep 17 00:00:00 2001 From: Younes Date: Thu, 12 Feb 2026 16:37:45 +0100 Subject: [PATCH 17/28] Solves duplicate header issue --- .../example/http/BinaryResponseBuilder.java | 4 ++ .../org/example/http/HttpResponseBuilder.java | 29 ++++++++++++--- .../example/http/HttpResponseBuilderTest.java | 37 +++++++++++++++---- 3 files changed, 56 insertions(+), 14 deletions(-) create mode 100644 src/main/java/org/example/http/BinaryResponseBuilder.java diff --git a/src/main/java/org/example/http/BinaryResponseBuilder.java b/src/main/java/org/example/http/BinaryResponseBuilder.java new file mode 100644 index 00000000..6b94f41d --- /dev/null +++ b/src/main/java/org/example/http/BinaryResponseBuilder.java @@ -0,0 +1,4 @@ +package org.example.http; + +public class BinaryResponseBuilder { +} diff --git a/src/main/java/org/example/http/HttpResponseBuilder.java b/src/main/java/org/example/http/HttpResponseBuilder.java index 9ca4d0ff..f448fc7f 100644 --- a/src/main/java/org/example/http/HttpResponseBuilder.java +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -41,17 +41,34 @@ public void setContentTypeFromFilename(String filename) { 404, "Not Found", 500, "Internal Server Error"); - public String build(){ + public String build() { StringBuilder sb = new StringBuilder(); String reason = REASON_PHRASES.getOrDefault(statusCode, "OK"); + + // Status line 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); + + // User-defined headers + headers.forEach((k, v) -> sb.append(k).append(": ").append(v).append(CRLF)); + + // Only append Content-Length if not already set + if (!headers.containsKey("Content-Length")) { + sb.append("Content-Length: ") + .append(body.getBytes(StandardCharsets.UTF_8).length) + .append(CRLF); + } + + // Only append Connection if not already set + if (!headers.containsKey("Connection")) { + sb.append("Connection: close").append(CRLF); + } + + // Blank line before body sb.append(CRLF); + + // Body sb.append(body); + return sb.toString(); } } \ No newline at end of file diff --git a/src/test/java/org/example/http/HttpResponseBuilderTest.java b/src/test/java/org/example/http/HttpResponseBuilderTest.java index 9b487792..77c81763 100644 --- a/src/test/java/org/example/http/HttpResponseBuilderTest.java +++ b/src/test/java/org/example/http/HttpResponseBuilderTest.java @@ -3,7 +3,11 @@ 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 java.util.stream.Stream; import static org.assertj.core.api.AssertionsForClassTypes.assertThat; @@ -123,17 +127,34 @@ void setContentTypeFromFilename_allCases(String filename, String expectedContent assertThat(result).contains("Content-Type: " + expectedContentType); } - @Test - @DisplayName("Should override previous Content-Type when set again") - void setContentTypeFromFilename_overridesPrevious() { + @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.setContentTypeFromFilename("file.txt"); - builder.setContentTypeFromFilename("file.html"); - builder.setBody("Test"); + builder.setHeader(headerName, manualValue); + builder.setBody(bodyContent); String result = builder.build(); - assertThat(result).contains("Content-Type: text/html; charset=UTF-8"); - assertThat(result).doesNotContain("text/plain"); + // Count occurrences of the header + long count = result.lines() + .filter(line -> line.startsWith(headerName + ":")) + .count(); + + assertThat(count).isEqualTo(1); + assertThat(result).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") + ); } + } From 016b324d4b6176b136be01d5bdb380d13ebe3027 Mon Sep 17 00:00:00 2001 From: Younes Date: Thu, 12 Feb 2026 16:41:39 +0100 Subject: [PATCH 18/28] Removed a file for another issue --- src/main/java/org/example/http/BinaryResponseBuilder.java | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 src/main/java/org/example/http/BinaryResponseBuilder.java diff --git a/src/main/java/org/example/http/BinaryResponseBuilder.java b/src/main/java/org/example/http/BinaryResponseBuilder.java deleted file mode 100644 index 6b94f41d..00000000 --- a/src/main/java/org/example/http/BinaryResponseBuilder.java +++ /dev/null @@ -1,4 +0,0 @@ -package org.example.http; - -public class BinaryResponseBuilder { -} From 37fe80a7ee57fc6fc39f5b8fa6dc3b3734072ffe Mon Sep 17 00:00:00 2001 From: Younes Date: Thu, 12 Feb 2026 16:58:40 +0100 Subject: [PATCH 19/28] Changed hashmap to Treemap per code rabbits suggestion --- .../org/example/http/HttpResponseBuilder.java | 32 +++++-------------- 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/src/main/java/org/example/http/HttpResponseBuilder.java b/src/main/java/org/example/http/HttpResponseBuilder.java index f448fc7f..1580ce81 100644 --- a/src/main/java/org/example/http/HttpResponseBuilder.java +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -3,13 +3,14 @@ import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; import java.util.Map; +import java.util.TreeMap; public class HttpResponseBuilder { private static final String PROTOCOL = "HTTP/1.1"; private int statusCode = 200; private String body = ""; - private Map headers = new LinkedHashMap<>(); + private Map headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); private static final String CRLF = "\r\n"; @@ -41,34 +42,17 @@ public void setContentTypeFromFilename(String filename) { 404, "Not Found", 500, "Internal Server Error"); - public String build() { + public String build(){ StringBuilder sb = new StringBuilder(); String reason = REASON_PHRASES.getOrDefault(statusCode, "OK"); - - // Status line sb.append(PROTOCOL).append(" ").append(statusCode).append(" ").append(reason).append(CRLF); - - // User-defined headers - headers.forEach((k, v) -> sb.append(k).append(": ").append(v).append(CRLF)); - - // Only append Content-Length if not already set - if (!headers.containsKey("Content-Length")) { - sb.append("Content-Length: ") - .append(body.getBytes(StandardCharsets.UTF_8).length) - .append(CRLF); - } - - // Only append Connection if not already set - if (!headers.containsKey("Connection")) { - sb.append("Connection: close").append(CRLF); - } - - // Blank line before body + 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); - - // Body sb.append(body); - return sb.toString(); } } \ No newline at end of file From 11cdfea4977637e1382180f30c327326d26e2c59 Mon Sep 17 00:00:00 2001 From: Younes Date: Sat, 14 Feb 2026 09:50:02 +0100 Subject: [PATCH 20/28] Corrected logic error that was failing tests as per P2P review --- .../org/example/http/HttpResponseBuilder.java | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/example/http/HttpResponseBuilder.java b/src/main/java/org/example/http/HttpResponseBuilder.java index 1580ce81..e652de76 100644 --- a/src/main/java/org/example/http/HttpResponseBuilder.java +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -45,14 +45,31 @@ public void setContentTypeFromFilename(String filename) { public String build(){ StringBuilder sb = new StringBuilder(); String reason = REASON_PHRASES.getOrDefault(statusCode, "OK"); + + // Status line sb.append(PROTOCOL).append(" ").append(statusCode).append(" ").append(reason).append(CRLF); + + // User-defined headers 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); + + // Only adds Content-Length if not already set + if (!headers.containsKey("Content-Length")) { + sb.append("Content-Length: ") + .append(body.getBytes(StandardCharsets.UTF_8).length) + .append(CRLF); + } + + // Only adds Connection if not already set + if (!headers.containsKey("Connection")) { + sb.append("Connection: close").append(CRLF); + } + + // Blank line before body sb.append(CRLF); + + // Body sb.append(body); + return sb.toString(); } } \ No newline at end of file From 758f8c413083ef429edc35f934b57366954493b1 Mon Sep 17 00:00:00 2001 From: Younes Date: Sat, 14 Feb 2026 10:12:14 +0100 Subject: [PATCH 21/28] Added more reason phrases and unit testing, also applied code rabbits suggestions! --- .../org/example/http/HttpResponseBuilder.java | 41 +- .../example/http/HttpResponseBuilderTest.java | 477 +++++++++++++----- 2 files changed, 386 insertions(+), 132 deletions(-) diff --git a/src/main/java/org/example/http/HttpResponseBuilder.java b/src/main/java/org/example/http/HttpResponseBuilder.java index e652de76..c246bd36 100644 --- a/src/main/java/org/example/http/HttpResponseBuilder.java +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -1,7 +1,6 @@ package org.example.http; import java.nio.charset.StandardCharsets; -import java.util.LinkedHashMap; import java.util.Map; import java.util.TreeMap; @@ -23,7 +22,8 @@ public void setBody(String body) { } public void setHeaders(Map headers) { - this.headers = new LinkedHashMap<>(headers); + this.headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + this.headers.putAll(headers); } public void setHeader(String name, String value) { @@ -35,31 +35,46 @@ public void setContentTypeFromFilename(String filename) { setHeader("Content-Type", mimeType); } - private static final Map REASON_PHRASES = Map.of( - 200, "OK", - 201, "Created", - 400, "Bad Request", - 404, "Not Found", - 500, "Internal Server Error"); + 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 String build(){ StringBuilder sb = new StringBuilder(); - String reason = REASON_PHRASES.getOrDefault(statusCode, "OK"); + String reason = REASON_PHRASES.getOrDefault(statusCode, ""); - // Status line - sb.append(PROTOCOL).append(" ").append(statusCode).append(" ").append(reason).append(CRLF); + sb.append(PROTOCOL).append(" ").append(statusCode); + if (!reason.isEmpty()) { + sb.append(" ").append(reason); + } + sb.append(CRLF); // User-defined headers headers.forEach((k,v) -> sb.append(k).append(": ").append(v).append(CRLF)); - // Only adds Content-Length if not already set + // Auto-append Content-Length if not set if (!headers.containsKey("Content-Length")) { sb.append("Content-Length: ") .append(body.getBytes(StandardCharsets.UTF_8).length) .append(CRLF); } - // Only adds Connection if not already set + // Auto-append Connection if not set if (!headers.containsKey("Connection")) { sb.append("Connection: close").append(CRLF); } diff --git a/src/test/java/org/example/http/HttpResponseBuilderTest.java b/src/test/java/org/example/http/HttpResponseBuilderTest.java index 77c81763..fb5af436 100644 --- a/src/test/java/org/example/http/HttpResponseBuilderTest.java +++ b/src/test/java/org/example/http/HttpResponseBuilderTest.java @@ -6,155 +6,394 @@ 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 java.util.HashMap; +import java.util.Map; import java.util.stream.Stream; -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.Assertions.assertThat; - class HttpResponseBuilderTest { +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"); - @Test - void build_returnsValidHttpResponse() { + String result = builder.build(); - HttpResponseBuilder builder = new HttpResponseBuilder(); + assertThat(result) + .contains("HTTP/1.1 200 OK") + .contains("Content-Length: 5") + .contains("\r\n\r\n") + .contains("Hello"); + } - builder.setBody("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); + + String result = builder.build(); + + assertThat(result).contains("Content-Length: " + expectedLength); + } - String result = builder.build(); + @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"); - 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"); + String result = builder.build(); - } + assertThat(result).contains("Content-Type: text/html; charset=UTF-8"); + } - // Verifies that Content-Length is calculated using UTF-8 byte length! - // - @Test - void build_handlesUtf8ContentLength() { - HttpResponseBuilder builder = new HttpResponseBuilder(); + @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("{}"); - builder.setBody("å"); + String result = builder.build(); - String result = builder.build(); + assertThat(result) + .contains("Content-Type: application/json") + .contains("Cache-Control: no-cache"); + } - assertThat(result).contains("Content-Length: 2"); + @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"); + + String result = builder.build(); + + assertThat(result).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"); + + String result = builder.build(); + + assertThat(result).contains("Content-Type: " + expectedContentType); + } - @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"); + @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); - String result = builder.build(); + String result = builder.build(); - assertThat(result).contains("Content-Type: text/html; charset=UTF-8"); - } + long count = result.lines() + .filter(line -> line.startsWith(headerName + ":")) + .count(); - @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("{}"); + assertThat(count).isEqualTo(1); + assertThat(result).contains(headerName + ": " + manualValue); + } - String result = builder.build(); + 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") + ); + } - assertThat(result).contains("Content-Type: application/json"); - assertThat(result).contains("Cache-Control: no-cache"); - } + @Test + @DisplayName("setHeaders should preserve case-insensitive behavior") + void setHeaders_preservesCaseInsensitivity() { + HttpResponseBuilder builder = new HttpResponseBuilder(); - @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"); - - String result = builder.build(); - - assertThat(result).contains("Content-Type: " + expectedContentType); - } + Map headers = new HashMap<>(); + headers.put("content-type", "text/html"); + headers.put("cache-control", "no-cache"); + builder.setHeaders(headers); - @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", // Okänd ändelse - "/var/www/index.html, text/html; charset=UTF-8", // Med sökväg - "'', application/octet-stream", // Tom sträng - "null, application/octet-stream" // Null-värde - }, 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"); - - String result = builder.build(); - - assertThat(result).contains("Content-Type: " + expectedContentType); - } + builder.setHeader("Content-Length", "100"); + builder.setBody("Hello"); + + String result = builder.build(); + + long count = result.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(""); + + String result = builder.build(); + + assertThat(result).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(""); + + String result = builder.build(); + + assertThat(result) + .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"); + + String result = builder.build(); + + assertThat(result) + .contains("HTTP/1.1 " + statusCode + " " + expectedReason) + .doesNotContain("OK"); + } - @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); + // 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(""); + + String result = builder.build(); + + assertThat(result) + .startsWith("HTTP/1.1 " + statusCode) + .doesNotContain("OK"); + } - String result = builder.build(); + @Test + @DisplayName("Should auto-append headers when not manually set") + void build_autoAppendsHeadersWhenNotSet() { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setBody("Hello"); - // Count occurrences of the header - long count = result.lines() - .filter(line -> line.startsWith(headerName + ":")) - .count(); + String result = builder.build(); - assertThat(count).isEqualTo(1); - assertThat(result).contains(headerName + ": " + manualValue); + assertThat(result) + .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"); + + String result = builder.build(); + + assertThat(result) + .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(); + + long count = result.lines() + .filter(line -> line.toLowerCase().contains("content-length")) + .count(); + + assertThat(count).isEqualTo(1); + assertThat(result.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); + + String result = builder.build(); + + assertThat(result) + .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"); + + String result = builder.build(); + + assertThat(result) + .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"); } - 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") - ); + builder.setBody(body); + String result = builder.build(); + + if (expectedContentLength != null) { + assertThat(result).contains("Content-Length: " + expectedContentLength); } + if (expectedConnection != null) { + assertThat(result).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 + ); } +} \ No newline at end of file From 0de1e14188e3bf9b947190117ec13e212132e3f2 Mon Sep 17 00:00:00 2001 From: Younes Date: Mon, 16 Feb 2026 16:34:26 +0100 Subject: [PATCH 22/28] Added changes to Responsebuilder to make merging easier --- .../org/example/http/HttpResponseBuilder.java | 61 ++++++++++++------- 1 file changed, 38 insertions(+), 23 deletions(-) diff --git a/src/main/java/org/example/http/HttpResponseBuilder.java b/src/main/java/org/example/http/HttpResponseBuilder.java index c246bd36..7127b3a7 100644 --- a/src/main/java/org/example/http/HttpResponseBuilder.java +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -9,10 +9,30 @@ 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 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; } @@ -21,6 +41,10 @@ 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 TreeMap<>(String.CASE_INSENSITIVE_ORDER); this.headers.putAll(headers); @@ -35,29 +59,20 @@ public void setContentTypeFromFilename(String filename) { setHeader("Content-Type", mimeType); } - 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 String build(){ + public String build() { StringBuilder sb = new StringBuilder(); - String reason = REASON_PHRASES.getOrDefault(statusCode, ""); + // Calculate content length (both String and byte[] body) + 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; + } + + // Status line + String reason = REASON_PHRASES.getOrDefault(statusCode, ""); sb.append(PROTOCOL).append(" ").append(statusCode); if (!reason.isEmpty()) { sb.append(" ").append(reason); @@ -65,12 +80,12 @@ public String build(){ sb.append(CRLF); // User-defined headers - headers.forEach((k,v) -> sb.append(k).append(": ").append(v).append(CRLF)); + headers.forEach((k, v) -> sb.append(k).append(": ").append(v).append(CRLF)); // Auto-append Content-Length if not set if (!headers.containsKey("Content-Length")) { sb.append("Content-Length: ") - .append(body.getBytes(StandardCharsets.UTF_8).length) + .append(contentLength) .append(CRLF); } From 5a685f354af27b444ecbd1999a7835c24ab2a123 Mon Sep 17 00:00:00 2001 From: Younes Date: Mon, 16 Feb 2026 18:06:33 +0100 Subject: [PATCH 23/28] Changed back to earlier commit to hande byte corruption new PR --- .../org/example/http/HttpResponseBuilder.java | 55 ++++--- .../example/http/HttpResponseBuilderTest.java | 137 +++++++++++++----- 2 files changed, 132 insertions(+), 60 deletions(-) diff --git a/src/main/java/org/example/http/HttpResponseBuilder.java b/src/main/java/org/example/http/HttpResponseBuilder.java index 7127b3a7..7836f123 100644 --- a/src/main/java/org/example/http/HttpResponseBuilder.java +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -39,10 +39,12 @@ public void setStatusCode(int 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) { @@ -59,47 +61,58 @@ public void setContentTypeFromFilename(String filename) { setHeader("Content-Type", mimeType); } - public String build() { - StringBuilder sb = new StringBuilder(); - - // Calculate content length (both String and byte[] body) + /* + * 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; + contentBody = body.getBytes(StandardCharsets.UTF_8); + contentLength = contentBody.length; } + // Build headers as String + StringBuilder headerBuilder = new StringBuilder(); + // Status line String reason = REASON_PHRASES.getOrDefault(statusCode, ""); - sb.append(PROTOCOL).append(" ").append(statusCode); + headerBuilder.append(PROTOCOL).append(" ").append(statusCode); if (!reason.isEmpty()) { - sb.append(" ").append(reason); + headerBuilder.append(" ").append(reason); } - sb.append(CRLF); + headerBuilder.append(CRLF); // User-defined headers - headers.forEach((k, v) -> sb.append(k).append(": ").append(v).append(CRLF)); + headers.forEach((k, v) -> headerBuilder.append(k).append(": ").append(v).append(CRLF)); - // Auto-append Content-Length if not set + // Auto-append Content-Length if not set. if (!headers.containsKey("Content-Length")) { - sb.append("Content-Length: ") - .append(contentLength) - .append(CRLF); + headerBuilder.append("Content-Length: ").append(contentLength).append(CRLF); } - // Auto-append Connection if not set + // Auto-append Connection if not set. if (!headers.containsKey("Connection")) { - sb.append("Connection: close").append(CRLF); + headerBuilder.append("Connection: close").append(CRLF); } // Blank line before body - sb.append(CRLF); + headerBuilder.append(CRLF); + + // Convert headers to bytes + byte[] headerBytes = headerBuilder.toString().getBytes(StandardCharsets.UTF_8); - // Body - sb.append(body); + // 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 sb.toString(); + return response; } } \ No newline at end of file diff --git a/src/test/java/org/example/http/HttpResponseBuilderTest.java b/src/test/java/org/example/http/HttpResponseBuilderTest.java index fb5af436..b278ae19 100644 --- a/src/test/java/org/example/http/HttpResponseBuilderTest.java +++ b/src/test/java/org/example/http/HttpResponseBuilderTest.java @@ -8,6 +8,7 @@ import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; +import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; import java.util.stream.Stream; @@ -16,14 +17,20 @@ 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"); - String result = builder.build(); + byte[] result = builder.build(); + String resultStr = asString(result); - assertThat(result) + assertThat(resultStr) .contains("HTTP/1.1 200 OK") .contains("Content-Length: 5") .contains("\r\n\r\n") @@ -44,9 +51,10 @@ void build_handlesUtf8ContentLength(String body, int expectedLength) { HttpResponseBuilder builder = new HttpResponseBuilder(); builder.setBody(body); - String result = builder.build(); + byte[] result = builder.build(); + String resultStr = asString(result); - assertThat(result).contains("Content-Length: " + expectedLength); + assertThat(resultStr).contains("Content-Length: " + expectedLength); } @Test @@ -56,9 +64,10 @@ void setHeader_addsHeaderToResponse() { builder.setHeader("Content-Type", "text/html; charset=UTF-8"); builder.setBody("Hello"); - String result = builder.build(); + byte[] result = builder.build(); + String resultStr = asString(result); - assertThat(result).contains("Content-Type: text/html; charset=UTF-8"); + assertThat(resultStr).contains("Content-Type: text/html; charset=UTF-8"); } @Test @@ -69,9 +78,10 @@ void setHeader_allowsMultipleHeaders() { builder.setHeader("Cache-Control", "no-cache"); builder.setBody("{}"); - String result = builder.build(); + byte[] result = builder.build(); + String resultStr = asString(result); - assertThat(result) + assertThat(resultStr) .contains("Content-Type: application/json") .contains("Cache-Control: no-cache"); } @@ -99,9 +109,10 @@ void setContentTypeFromFilename_detectsVariousTypes(String filename, String expe builder.setContentTypeFromFilename(filename); builder.setBody("test content"); - String result = builder.build(); + byte[] result = builder.build(); + String resultStr = asString(result); - assertThat(result).contains("Content-Type: " + expectedContentType); + assertThat(resultStr).contains("Content-Type: " + expectedContentType); } @ParameterizedTest(name = "{index} - Filename: {0} => Expected: {1}") @@ -121,9 +132,10 @@ void setContentTypeFromFilename_allCases(String filename, String expectedContent builder.setContentTypeFromFilename(filename); builder.setBody("test"); - String result = builder.build(); + byte[] result = builder.build(); + String resultStr = asString(result); - assertThat(result).contains("Content-Type: " + expectedContentType); + assertThat(resultStr).contains("Content-Type: " + expectedContentType); } @ParameterizedTest @@ -134,14 +146,15 @@ void build_doesNotDuplicateHeaders(String headerName, String manualValue, String builder.setHeader(headerName, manualValue); builder.setBody(bodyContent); - String result = builder.build(); + byte[] result = builder.build(); + String resultStr = asString(result); - long count = result.lines() + long count = resultStr.lines() .filter(line -> line.startsWith(headerName + ":")) .count(); assertThat(count).isEqualTo(1); - assertThat(result).contains(headerName + ": " + manualValue); + assertThat(resultStr).contains(headerName + ": " + manualValue); } private static Stream provideHeaderDuplicationScenarios() { @@ -168,9 +181,10 @@ void setHeaders_preservesCaseInsensitivity() { builder.setHeader("Content-Length", "100"); builder.setBody("Hello"); - String result = builder.build(); + byte[] result = builder.build(); + String resultStr = asString(result); - long count = result.lines() + long count = resultStr.lines() .filter(line -> line.toLowerCase().startsWith("content-length:")) .count(); @@ -196,9 +210,10 @@ void build_correctReasonPhrases(int statusCode, String expectedReason) { builder.setStatusCode(statusCode); builder.setBody(""); - String result = builder.build(); + byte[] result = builder.build(); + String resultStr = asString(result); - assertThat(result).contains("HTTP/1.1 " + statusCode + " " + expectedReason); + assertThat(resultStr).contains("HTTP/1.1 " + statusCode + " " + expectedReason); } // Redirect status codes @@ -217,9 +232,10 @@ void build_handlesRedirectStatusCodes(int statusCode, String expectedReason, Str builder.setHeader("Location", location); builder.setBody(""); - String result = builder.build(); + byte[] result = builder.build(); + String resultStr = asString(result); - assertThat(result) + assertThat(resultStr) .contains("HTTP/1.1 " + statusCode + " " + expectedReason) .contains("Location: " + location) .doesNotContain("OK"); @@ -242,9 +258,10 @@ void build_handlesErrorStatusCodes(int statusCode, String expectedReason) { builder.setStatusCode(statusCode); builder.setBody("Error message"); - String result = builder.build(); + byte[] result = builder.build(); + String resultStr = asString(result); - assertThat(result) + assertThat(resultStr) .contains("HTTP/1.1 " + statusCode + " " + expectedReason) .doesNotContain("OK"); } @@ -258,9 +275,10 @@ void build_handlesUnknownStatusCodes(int statusCode) { builder.setStatusCode(statusCode); builder.setBody(""); - String result = builder.build(); + byte[] result = builder.build(); + String resultStr = asString(result); - assertThat(result) + assertThat(resultStr) .startsWith("HTTP/1.1 " + statusCode) .doesNotContain("OK"); } @@ -271,9 +289,10 @@ void build_autoAppendsHeadersWhenNotSet() { HttpResponseBuilder builder = new HttpResponseBuilder(); builder.setBody("Hello"); - String result = builder.build(); + byte[] result = builder.build(); + String resultStr = asString(result); - assertThat(result) + assertThat(resultStr) .contains("Content-Length: 5") .contains("Connection: close"); } @@ -286,9 +305,10 @@ void build_combinesCustomAndAutoHeaders() { builder.setHeader("Cache-Control", "no-cache"); builder.setBody("Hello"); - String result = builder.build(); + byte[] result = builder.build(); + String resultStr = asString(result); - assertThat(result) + assertThat(resultStr) .contains("Content-Type: text/html") .contains("Cache-Control: no-cache") .contains("Content-Length: 5") @@ -310,14 +330,15 @@ void setHeader_caseInsensitive(String headerName, String headerValue) { builder.setHeader(headerName, headerValue); builder.setBody("Hello"); - String result = builder.build(); + byte[] result = builder.build(); + String resultStr = asString(result); - long count = result.lines() + long count = resultStr.lines() .filter(line -> line.toLowerCase().contains("content-length")) .count(); assertThat(count).isEqualTo(1); - assertThat(result.toLowerCase()).contains("content-length: " + headerValue.toLowerCase()); + assertThat(resultStr.toLowerCase()).contains("content-length: " + headerValue.toLowerCase()); } // Empty/null body @@ -331,9 +352,10 @@ void build_emptyAndNullBody(String body, int expectedLength) { HttpResponseBuilder builder = new HttpResponseBuilder(); builder.setBody(body); - String result = builder.build(); + byte[] result = builder.build(); + String resultStr = asString(result); - assertThat(result) + assertThat(resultStr) .contains("HTTP/1.1 200 OK") .contains("Content-Length: " + expectedLength); } @@ -353,9 +375,10 @@ void setHeader_overridesPreviousValue(String headerName, String firstValue, Stri builder.setHeader(headerName, secondValue); // Override builder.setBody("Test"); - String result = builder.build(); + byte[] result = builder.build(); + String resultStr = asString(result); - assertThat(result) + assertThat(resultStr) .contains(headerName + ": " + secondValue) .doesNotContain(headerName + ": " + firstValue); } @@ -376,13 +399,14 @@ void build_autoAppendsSpecificHeaders(String body, boolean setContentLength, boo } builder.setBody(body); - String result = builder.build(); + byte[] result = builder.build(); + String resultStr = asString(result); if (expectedContentLength != null) { - assertThat(result).contains("Content-Length: " + expectedContentLength); + assertThat(resultStr).contains("Content-Length: " + expectedContentLength); } if (expectedConnection != null) { - assertThat(result).contains("Connection: " + expectedConnection); + assertThat(resultStr).contains("Connection: " + expectedConnection); } } @@ -396,4 +420,39 @@ private static Stream provideAutoAppendScenarios() { Arguments.of("", false, false, "0", "close") // Empty body ); } + + @Test + @DisplayName("Should preserve binary content without corruption") + void build_preservesBinaryContent() { + HttpResponseBuilder builder = new HttpResponseBuilder(); + + // 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; + } + } + + assertThat(bodyStart).isGreaterThan(0); + + // 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 From 8cc69d8c31686e82d74c71925a34aba8f787c0b6 Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Tue, 17 Feb 2026 14:12:27 +0100 Subject: [PATCH 24/28] 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 25/28] 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 5162d75359682c743e813bb9d05441f533a62484 Mon Sep 17 00:00:00 2001 From: Younes Date: Tue, 17 Feb 2026 17:26:49 +0100 Subject: [PATCH 26/28] Added StaticFileHandler from main --- .../java/org/example/StaticFileHandler.java | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/main/java/org/example/StaticFileHandler.java diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java new file mode 100644 index 00000000..bf9e79bc --- /dev/null +++ b/src/main/java/org/example/StaticFileHandler.java @@ -0,0 +1,56 @@ +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 final String WEB_ROOT; + private byte[] fileBytes; + private int statusCode; + + //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); + 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); + writer.println(response.build()); + + } + +} From 0e83b32a14b97f7fc721aaf23906d582d6c28ba1 Mon Sep 17 00:00:00 2001 From: Younes Date: Tue, 17 Feb 2026 17:35:11 +0100 Subject: [PATCH 27/28] Added staticFileHandler with binary-safe writing --- .../java/org/example/StaticFileHandler.java | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index bf9e79bc..8e5cc3fe 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -5,7 +5,6 @@ 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; @@ -14,25 +13,24 @@ public class StaticFileHandler { 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 { - 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 +39,17 @@ 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")); response.setBody(fileBytes); - PrintWriter writer = new PrintWriter(outputStream, true); - writer.println(response.build()); + // Använder outputStream.write() direkt istället för PrintWriter, + //PrintWriter lägger till extra radbrytningar och fungerar dåligt med binär data! + outputStream.write(response.build()); + outputStream.flush(); } - -} +} \ No newline at end of file From 5b518370adf092888fc4fa13ee16583915115774 Mon Sep 17 00:00:00 2001 From: Younes Date: Tue, 17 Feb 2026 17:49:47 +0100 Subject: [PATCH 28/28] 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 | 17 ++++++++++++----- .../java/org/example/StaticFileHandlerTest.java | 2 +- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 8e5cc3fe..58f13379 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -6,7 +6,6 @@ import java.io.IOException; import java.io.OutputStream; import java.nio.file.Files; -import java.util.Map; public class StaticFileHandler { private final String WEB_ROOT; @@ -24,7 +23,16 @@ public StaticFileHandler(String webRoot) { } private void handleGetRequest(String uri) throws IOException { - File file = new File(WEB_ROOT, uri); + // 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; + } + if (file.exists()) { fileBytes = Files.readAllBytes(file.toPath()); statusCode = 200; @@ -44,11 +52,10 @@ public void sendGetRequest(OutputStream outputStream, String uri) throws IOExcep 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); - // Använder outputStream.write() direkt istället för PrintWriter, - //PrintWriter lägger till extra radbrytningar och fungerar dåligt med binär data! outputStream.write(response.build()); outputStream.flush(); } 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 }