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