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 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 }} + 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 diff --git a/pom.xml b/pom.xml index 6b7ade11..8a82b235 100644 --- a/pom.xml +++ b/pom.xml @@ -9,13 +9,21 @@ 1.0-SNAPSHOT - 23 + 25 UTF-8 6.0.2 3.27.7 5.21.0 + 8.14.0 + + + + com.bucket4j + bucket4j_jdk17-core + ${bucket4j.version} + org.junit.jupiter junit-jupiter @@ -28,12 +36,36 @@ ${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 + + + + tools.jackson.core + jackson-databind + 3.0.3 + + + tools.jackson.dataformat + jackson-dataformat-yaml + 3.0.3 + + @@ -118,6 +150,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 + + + + + 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/ConnectionHandler.java b/src/main/java/org/example/ConnectionHandler.java new file mode 100644 index 00000000..189388e7 --- /dev/null +++ b/src/main/java/org/example/ConnectionHandler.java @@ -0,0 +1,58 @@ +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(); + + // --- DIN ÄNDRING FÖR ISSUE #75 BÖRJAR HÄR --- + String requestedUri = parser.getUri(); + if (requestedUri.equals("/health")) { + String responseBody = "{\"status\": \"ok\"}"; + String header = "HTTP/1.1 200 OK\r\n" + + "Content-Type: application/json\r\n" + + "Content-Length: " + responseBody.length() + "\r\n" + + "\r\n"; + + client.getOutputStream().write(header.getBytes()); + client.getOutputStream().write(responseBody.getBytes()); + client.getOutputStream().flush(); + return; // Avslutar här så vi inte letar efter filer i onödan + } + // --- DIN ÄNDRING SLUTAR HÄR --- + + resolveTargetFile(requestedUri); + 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(); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java new file mode 100644 index 00000000..95a0b424 --- /dev/null +++ b/src/main/java/org/example/StaticFileHandler.java @@ -0,0 +1,35 @@ +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/TcpServer.java b/src/main/java/org/example/TcpServer.java new file mode 100644 index 00000000..3f96b4d8 --- /dev/null +++ b/src/main/java/org/example/TcpServer.java @@ -0,0 +1,36 @@ +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()); + Thread.ofVirtual().start(() -> handleClient(clientSocket)); + } + } catch (IOException e) { + throw new RuntimeException("Failed to start TCP server", e); + } + } + + private void handleClient(Socket client) { + try (ConnectionHandler connectionHandler = new ConnectionHandler(client)) { + connectionHandler.runConnectionHandler(); + } catch (Exception e) { + throw new RuntimeException("Error handling client connection " + e); + } + } +} 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..afaafcf9 --- /dev/null +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -0,0 +1,62 @@ +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 byte[] bytebody; + 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 setBody(byte[] body) { + this.bytebody = 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(); + 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(contentLength); + sb.append(CRLF); + sb.append("Connection: close").append(CRLF); + sb.append(CRLF); + sb.append(body); + return sb.toString(); + + } + +} 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 new file mode 100644 index 00000000..d08a000b --- /dev/null +++ b/src/main/java/org/example/httpparser/HttpParser.java @@ -0,0 +1,60 @@ +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 extends HttpParseRequestLine { + private boolean debug = false; + private Map headersMap = new HashMap<>(); + private BufferedReader reader; + + public 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) { + 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 void parseRequest() throws IOException { + parseHttpRequest(reader); + } + + public Map getHeadersMap() { + return headersMap; + } + + public BufferedReader getHeaderReader() { + return reader; + } +} 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"); + + } +} 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 new file mode 100644 index 00000000..a09b7e22 --- /dev/null +++ b/src/test/java/org/example/httpparser/HttpParserTest.java @@ -0,0 +1,53 @@ +package org.example.httpparser; + +import org.junit.jupiter.api.Test; + +import java.io.*; +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.setReader(in); + httpParser.parseHttp(); + + 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.setReader(in); + httpParser.parseHttp(); + + 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.setReader(in); + httpParser.parseHttp(); + + assertEquals(2, httpParser.getHeadersMap().size()); + assertEquals("localhost", httpParser.getHeadersMap().get("Host")); + assertEquals("*/*", httpParser.getHeadersMap().get("Accept")); + } + + +} 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.

+ + +