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/pom.xml b/pom.xml
index 6b7ade11..104baa8e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -9,12 +9,13 @@
1.0-SNAPSHOT
- 23
+ 25
UTF-8
- 6.0.2
+ 5.11.4
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
+
@@ -63,24 +76,24 @@
3.4.0
- org.apache.maven.plugins
- maven-dependency-plugin
- 3.9.0
-
-
-
- properties
-
-
-
+ org.apache.maven.plugins
+ maven-dependency-plugin
+ 3.9.0
+
+
+
+ properties
+
+
+
org.apache.maven.plugins
maven-surefire-plugin
3.5.4
-
- @{argLine} -javaagent:${org.mockito:mockito-core:jar} -Xshare:off
-
+
+ @{argLine} -javaagent:${org.mockito:mockito-core:jar} -Xshare:off
+
org.apache.maven.plugins
@@ -118,6 +131,27 @@
+
+ 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/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
diff --git a/src/main/java/org/example/server/FilterChain.java b/src/main/java/org/example/server/FilterChain.java
new file mode 100644
index 00000000..378c2a51
--- /dev/null
+++ b/src/main/java/org/example/server/FilterChain.java
@@ -0,0 +1,24 @@
+package org.example.server;
+
+import java.util.Objects;
+
+public final class FilterChain {
+
+ private final HttpFilter[] filters;
+ private final TerminalHandler terminal;
+ private int index = 0;
+
+ public FilterChain(HttpFilter[] filters, TerminalHandler terminal) {
+ this.filters = Objects.requireNonNull(filters, "filters");
+ this.terminal = Objects.requireNonNull(terminal, "terminal");
+ }
+
+ public void doFilter(HttpRequest request, HttpResponse response) {
+ if (index < filters.length) {
+ HttpFilter current = filters[index++];
+ current.doFilter(request, response, this);
+ return;
+ }
+ terminal.handle(request, response);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/example/server/HttpFilter.java b/src/main/java/org/example/server/HttpFilter.java
new file mode 100644
index 00000000..5ac84ed5
--- /dev/null
+++ b/src/main/java/org/example/server/HttpFilter.java
@@ -0,0 +1,6 @@
+package org.example.server;
+
+@FunctionalInterface
+public interface HttpFilter {
+ void doFilter(HttpRequest request, HttpResponse response, FilterChain chain);
+}
diff --git a/src/main/java/org/example/server/HttpRequest.java b/src/main/java/org/example/server/HttpRequest.java
new file mode 100644
index 00000000..3146c645
--- /dev/null
+++ b/src/main/java/org/example/server/HttpRequest.java
@@ -0,0 +1,21 @@
+package org.example.server;
+
+import java.util.Objects;
+
+public final class HttpRequest {
+ private final String method;
+ private final String path;
+
+ public HttpRequest(String method, String path) {
+ this.method = Objects.requireNonNull(method, "method");
+ this.path = Objects.requireNonNull(path, "path");
+ }
+
+ public String method() {
+ return method;
+ }
+
+ public String path() {
+ return path;
+ }
+}
diff --git a/src/main/java/org/example/server/HttpResponse.java b/src/main/java/org/example/server/HttpResponse.java
new file mode 100644
index 00000000..07ad145a
--- /dev/null
+++ b/src/main/java/org/example/server/HttpResponse.java
@@ -0,0 +1,26 @@
+package org.example.server;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+public final class HttpResponse {
+ private int status = 200;
+ private final Map headers = new LinkedHashMap<>();
+
+ public int status() {
+ return status;
+ }
+
+ public void setStatus(int status) {
+ this.status = status;
+ }
+
+ public Map headers() {
+ return Collections.unmodifiableMap(headers);
+ }
+
+ public void setHeader(String name, String value) {
+ headers.put(name, value);
+ }
+}
diff --git a/src/main/java/org/example/server/RedirectFilter.java b/src/main/java/org/example/server/RedirectFilter.java
new file mode 100644
index 00000000..07097fdd
--- /dev/null
+++ b/src/main/java/org/example/server/RedirectFilter.java
@@ -0,0 +1,30 @@
+package org.example.server;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.logging.Logger;
+
+public final class RedirectFilter implements HttpFilter {
+ private static final Logger LOG = Logger.getLogger(RedirectFilter.class.getName());
+ private final List rules;
+
+ public RedirectFilter(List rules) {
+ this.rules = List.copyOf(Objects.requireNonNull(rules, "rules"));
+ }
+
+ @Override
+ public void doFilter(HttpRequest request, HttpResponse response, FilterChain chain) {
+ String path = request.path();
+
+ for (RedirectRule rule : rules) {
+ if (rule.matches(path)) {
+ LOG.info(() -> "Redirecting " + path + " -> " + rule.getTargetUrl() + " (" + rule.getStatusCode() + ")");
+ response.setStatus(rule.getStatusCode());
+ response.setHeader("Location", rule.getTargetUrl());
+ return; // STOP pipeline
+ }
+ }
+
+ chain.doFilter(request, response);
+ }
+}
diff --git a/src/main/java/org/example/server/RedirectRule.java b/src/main/java/org/example/server/RedirectRule.java
new file mode 100644
index 00000000..27640afd
--- /dev/null
+++ b/src/main/java/org/example/server/RedirectRule.java
@@ -0,0 +1,37 @@
+package org.example.server;
+
+import java.util.Objects;
+import java.util.regex.Pattern;
+
+public final class RedirectRule {
+ private final Pattern sourcePattern;
+ private final String targetUrl;
+ private final int statusCode;
+
+ public RedirectRule(Pattern sourcePattern, String targetUrl, int statusCode) {
+ this.sourcePattern = Objects.requireNonNull(sourcePattern, "sourcePattern");
+ this.targetUrl = Objects.requireNonNull(targetUrl, "targetUrl");
+ if (statusCode != 301 && statusCode != 302) {
+ throw new IllegalArgumentException("statusCode must be 301 or 302");
+ }
+ this.statusCode = statusCode;
+ }
+
+ public Pattern getSourcePattern() { return sourcePattern; }
+ public String getTargetUrl() { return targetUrl; }
+ public int getStatusCode() { return statusCode; }
+
+ public boolean matches(String requestPath) {
+ return sourcePattern.matcher(requestPath).matches();
+ }
+
+ @Override
+ public String toString() {
+ return "RedirectRule{" +
+ "sourcePattern=" + sourcePattern +
+ ", targetUrl='" + targetUrl + '\'' +
+ ", statusCode=" + statusCode +
+ '}';
+ }
+}
+
diff --git a/src/main/java/org/example/server/RedirectRulesLoader.java b/src/main/java/org/example/server/RedirectRulesLoader.java
new file mode 100644
index 00000000..26f1254c
--- /dev/null
+++ b/src/main/java/org/example/server/RedirectRulesLoader.java
@@ -0,0 +1,47 @@
+package org.example.server;
+
+import java.util.regex.Pattern;
+
+public final class RedirectRulesLoader {
+ private static final String REGEX_PREFIX = "regex:";
+
+ private RedirectRulesLoader() {}
+
+ public static Pattern compileSourcePattern(String sourcePath) {
+ if (sourcePath == null || sourcePath.isBlank()) {
+ throw new IllegalArgumentException("sourcePath must not be blank");
+ }
+
+ String trimmed = sourcePath.trim();
+
+ if (trimmed.startsWith(REGEX_PREFIX)) {
+ String rawRegex = trimmed.substring(REGEX_PREFIX.length());
+ if (rawRegex.isBlank()) {
+ throw new IllegalArgumentException("regex sourcePath must not be blank");
+ }
+ return Pattern.compile(rawRegex);
+ }
+
+ String regex;
+ if (trimmed.contains("*")) {
+ regex = wildcardToRegex(trimmed);
+ } else {
+ regex = Pattern.quote(trimmed);
+ }
+ return Pattern.compile("^" + regex + "$");
+ }
+
+ private static String wildcardToRegex(String wildcard) {
+ StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < wildcard.length(); i++) {
+ char c = wildcard.charAt(i);
+ if (c == '*') {
+ sb.append("[^/]*");
+ } else {
+ sb.append(Pattern.quote(String.valueOf(c)));
+ }
+ }
+ return sb.toString();
+ }
+}
+
diff --git a/src/main/java/org/example/server/TerminalHandler.java b/src/main/java/org/example/server/TerminalHandler.java
new file mode 100644
index 00000000..dc3bcfde
--- /dev/null
+++ b/src/main/java/org/example/server/TerminalHandler.java
@@ -0,0 +1,6 @@
+package org.example.server;
+
+@FunctionalInterface
+public interface TerminalHandler {
+ void handle(HttpRequest request, HttpResponse response);
+}
\ No newline at end of file
diff --git a/src/main/resources/.gitkeep b/src/main/resources/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/test/java/org/example/server/RedirectFilterTest.java b/src/test/java/org/example/server/RedirectFilterTest.java
new file mode 100644
index 00000000..e7a1a448
--- /dev/null
+++ b/src/test/java/org/example/server/RedirectFilterTest.java
@@ -0,0 +1,91 @@
+package org.example.server;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.regex.Pattern;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class RedirectFilterTest {
+
+ @Test
+ void returns_301_redirect_and_stops_pipeline() {
+ RedirectFilter filter = new RedirectFilter(List.of(
+ new RedirectRule(Pattern.compile("^/old-page$"), "/new-page", 301)
+ ));
+
+ AtomicBoolean terminalCalled = new AtomicBoolean(false);
+ FilterChain chain = new FilterChain(new HttpFilter[] {filter}, (req, res) -> terminalCalled.set(true));
+
+ HttpRequest req = new HttpRequest("GET", "/old-page");
+ HttpResponse res = new HttpResponse();
+
+ chain.doFilter(req, res);
+
+ assertThat(res.status()).isEqualTo(301);
+ assertThat(res.headers()).containsEntry("Location", "/new-page");
+ assertThat(terminalCalled.get()).isFalse();
+ }
+
+ @Test
+ void returns_302_redirect() {
+ RedirectFilter filter = new RedirectFilter(List.of(
+ new RedirectRule(Pattern.compile("^/temp$"), "https://example.com/temporary", 302)
+ ));
+
+ FilterChain chain = new FilterChain(new HttpFilter[] {filter}, (req, res) -> res.setStatus(200));
+
+ HttpRequest req = new HttpRequest("GET", "/temp");
+ HttpResponse res = new HttpResponse();
+
+ chain.doFilter(req, res);
+
+ assertThat(res.status()).isEqualTo(302);
+ assertThat(res.headers()).containsEntry("Location", "https://example.com/temporary");
+ }
+
+ @Test
+ void no_matching_rule_calls_next_in_chain() {
+ RedirectFilter filter = new RedirectFilter(List.of(
+ new RedirectRule(Pattern.compile("^/old-page$"), "/new-page", 301)
+ ));
+
+ AtomicBoolean terminalCalled = new AtomicBoolean(false);
+ FilterChain chain = new FilterChain(new HttpFilter[] {filter}, (req, res) -> terminalCalled.set(true));
+
+ HttpRequest req = new HttpRequest("GET", "/nope");
+ HttpResponse res = new HttpResponse();
+
+ chain.doFilter(req, res);
+
+ assertThat(terminalCalled.get()).isTrue();
+ assertThat(res.status()).isEqualTo(200);
+ assertThat(res.headers()).doesNotContainKey("Location");
+ }
+
+ @Test
+ void wildcard_matching_docs_star() {
+ var p = RedirectRulesLoader.compileSourcePattern("/docs/*");
+ assertThat(p.matcher("/docs/test").matches()).isTrue();
+ assertThat(p.matcher("/docs/any/path").matches()).isFalse();
+ assertThat(p.matcher("/doc/test").matches()).isFalse();
+ }
+
+ @Test
+ void regex_matching_via_loader_prefix() {
+ var p = RedirectRulesLoader.compileSourcePattern("regex:^/docs/(v1|v2)$");
+ assertThat(p.matcher("/docs/v1").matches()).isTrue();
+ assertThat(p.matcher("/docs/v2").matches()).isTrue();
+ assertThat(p.matcher("/docs/v3").matches()).isFalse();
+ }
+
+ @Test
+ void redirect_rule_rejects_invalid_status_code() {
+ assertThatThrownBy(() -> new RedirectRule(Pattern.compile("^/x$"), "/y", 307))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+}
+
diff --git a/src/test/resources/.gitkeep b/src/test/resources/.gitkeep
deleted file mode 100644
index e69de29b..00000000