Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 14 additions & 26 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<junit.jupiter.version>6.0.2</junit.jupiter.version>
<junit.jupiter.version>5.11.4</junit.jupiter.version>
<assertj.core.version>3.27.7</assertj.core.version>
<mockito.version>5.21.0</mockito.version>
</properties>
Expand Down Expand Up @@ -76,24 +76,24 @@
<version>3.4.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.9.0</version>
<executions>
<execution>
<goals>
<goal>properties</goal>
</goals>
</execution>
</executions>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.9.0</version>
<executions>
<execution>
<goals>
<goal>properties</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.4</version>
<configuration>
<argLine>@{argLine} -javaagent:${org.mockito:mockito-core:jar} -Xshare:off</argLine>
</configuration>
<configuration>
<argLine>@{argLine} -javaagent:${org.mockito:mockito-core:jar} -Xshare:off</argLine>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
Expand Down Expand Up @@ -131,18 +131,6 @@
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<version>1.22.0</version>
<dependencies>
<dependency>
<groupId>org.pitest</groupId>
<artifactId>pitest-junit5-plugin</artifactId>
<version>1.2.2</version>
</dependency>
</dependencies>
</plugin>
<plugin>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
Expand Down
24 changes: 24 additions & 0 deletions src/main/java/org/example/server/FilterChain.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
6 changes: 6 additions & 0 deletions src/main/java/org/example/server/HttpFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package org.example.server;

@FunctionalInterface
public interface HttpFilter {
void doFilter(HttpRequest request, HttpResponse response, FilterChain chain);
}
21 changes: 21 additions & 0 deletions src/main/java/org/example/server/HttpRequest.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
26 changes: 26 additions & 0 deletions src/main/java/org/example/server/HttpResponse.java
Original file line number Diff line number Diff line change
@@ -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<String, String> headers = new LinkedHashMap<>();

public int status() {
return status;
}

public void setStatus(int status) {
this.status = status;
}

public Map<String, String> headers() {
return Collections.unmodifiableMap(headers);
}

public void setHeader(String name, String value) {
headers.put(name, value);
}
}
30 changes: 30 additions & 0 deletions src/main/java/org/example/server/RedirectFilter.java
Original file line number Diff line number Diff line change
@@ -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<RedirectRule> rules;

public RedirectFilter(List<RedirectRule> 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);
}
}
37 changes: 37 additions & 0 deletions src/main/java/org/example/server/RedirectRule.java
Original file line number Diff line number Diff line change
@@ -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");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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 +
'}';
}
}

47 changes: 47 additions & 0 deletions src/main/java/org/example/server/RedirectRulesLoader.java
Original file line number Diff line number Diff line change
@@ -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();
}
}

6 changes: 6 additions & 0 deletions src/main/java/org/example/server/TerminalHandler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package org.example.server;

@FunctionalInterface
public interface TerminalHandler {
void handle(HttpRequest request, HttpResponse response);
}
Empty file removed src/main/resources/.gitkeep
Empty file.
91 changes: 91 additions & 0 deletions src/test/java/org/example/server/RedirectFilterTest.java
Original file line number Diff line number Diff line change
@@ -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);
}
}

Empty file removed src/test/resources/.gitkeep
Empty file.