diff --git a/pom.xml b/pom.xml
index bb03ce76..104baa8e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -11,7 +11,7 @@
25
UTF-8
- 6.0.2
+ 5.11.4
3.27.7
5.21.0
@@ -76,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
@@ -131,18 +131,6 @@
-
- org.pitest
- pitest-maven
- 1.22.0
-
-
- org.pitest
- pitest-junit5-plugin
- 1.2.2
-
-
-
com.diffplug.spotless
spotless-maven-plugin
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