Skip to content

Commit 101d1f4

Browse files
Update redirect rules and tests
1 parent 6950c14 commit 101d1f4

File tree

4 files changed

+239
-0
lines changed

4 files changed

+239
-0
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package org.example.filter.redirect;
2+
3+
import org.example.filter.Filter;
4+
import org.example.filter.FilterChain;
5+
import org.example.http.HttpResponseBuilder;
6+
import org.example.httpparser.HttpRequest;
7+
8+
import java.util.List;
9+
import java.util.Objects;
10+
import java.util.logging.Logger;
11+
12+
public final class RedirectFilter implements Filter {
13+
private static final Logger LOG = Logger.getLogger(RedirectFilter.class.getName());
14+
private final List<RedirectRule> rules;
15+
16+
public RedirectFilter(List<RedirectRule> rules) {
17+
this.rules = List.copyOf(Objects.requireNonNull(rules, "rules"));
18+
}
19+
20+
@Override
21+
public void init() {
22+
// no-op
23+
}
24+
25+
@Override
26+
public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) {
27+
String path = request.getPath();
28+
29+
for (RedirectRule rule : rules) {
30+
if (rule.matches(path)) {
31+
LOG.info(() -> "Redirecting " + path + " -> " + rule.getTargetUrl() + " (" + rule.getStatusCode() + ")");
32+
response.setStatusCode(rule.getStatusCode());
33+
response.setHeader("Location", rule.getTargetUrl());
34+
return; // STOP pipeline
35+
}
36+
}
37+
38+
chain.doFilter(request, response);
39+
}
40+
41+
@Override
42+
public void destroy() {
43+
// no-op
44+
}
45+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package org.example.filter.redirect;
2+
import java.util.Objects;
3+
import java.util.regex.Pattern;
4+
5+
public final class RedirectRule {
6+
private final Pattern sourcePattern;
7+
private final String targetUrl;
8+
private final int statusCode;
9+
10+
public RedirectRule(Pattern sourcePattern, String targetUrl, int statusCode) {
11+
this.sourcePattern = Objects.requireNonNull(sourcePattern, "sourcePattern");
12+
this.targetUrl = Objects.requireNonNull(targetUrl, "targetUrl");
13+
if (statusCode != 301 && statusCode != 302) {
14+
throw new IllegalArgumentException("statusCode must be 301 or 302");
15+
}
16+
this.statusCode = statusCode;
17+
}
18+
19+
public Pattern getSourcePattern() { return sourcePattern; }
20+
public String getTargetUrl() { return targetUrl; }
21+
public int getStatusCode() { return statusCode; }
22+
23+
public boolean matches(String requestPath) {
24+
return sourcePattern.matcher(requestPath).matches();
25+
}
26+
27+
@Override
28+
public String toString() {
29+
return "RedirectRule{" +
30+
"sourcePattern=" + sourcePattern +
31+
", targetUrl='" + targetUrl + '\'' +
32+
", statusCode=" + statusCode +
33+
'}';
34+
}
35+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package org.example.filter.redirect;
2+
3+
import java.util.regex.Pattern;
4+
5+
public final class RedirectRulesLoader {
6+
private static final String REGEX_PREFIX = "regex:";
7+
8+
private RedirectRulesLoader() {}
9+
10+
public static Pattern compileSourcePattern(String sourcePath) {
11+
if (sourcePath == null || sourcePath.isBlank()) {
12+
throw new IllegalArgumentException("sourcePath must not be blank");
13+
}
14+
15+
String trimmed = sourcePath.trim();
16+
17+
if (trimmed.startsWith(REGEX_PREFIX)) {
18+
String rawRegex = trimmed.substring(REGEX_PREFIX.length());
19+
if (rawRegex.isBlank()) {
20+
throw new IllegalArgumentException("regex sourcePath must not be blank");
21+
}
22+
return Pattern.compile(rawRegex);
23+
}
24+
25+
String regex;
26+
if (trimmed.contains("*")) {
27+
regex = wildcardToRegex(trimmed);
28+
} else {
29+
regex = Pattern.quote(trimmed);
30+
}
31+
return Pattern.compile("^" + regex + "$");
32+
}
33+
34+
private static String wildcardToRegex(String wildcard) {
35+
StringBuilder sb = new StringBuilder();
36+
for (int i = 0; i < wildcard.length(); i++) {
37+
char c = wildcard.charAt(i);
38+
if (c == '*') {
39+
sb.append("[^/]*");
40+
} else {
41+
sb.append(Pattern.quote(String.valueOf(c)));
42+
}
43+
}
44+
return sb.toString();
45+
}
46+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package org.example.filter.redirect;
2+
3+
import org.example.filter.FilterChain;
4+
import org.example.http.HttpResponseBuilder;
5+
import org.example.httpparser.HttpRequest;
6+
import org.junit.jupiter.api.Test;
7+
8+
import java.nio.charset.StandardCharsets;
9+
import java.util.List;
10+
import java.util.Map;
11+
import java.util.concurrent.atomic.AtomicBoolean;
12+
import java.util.regex.Pattern;
13+
14+
import static org.assertj.core.api.Assertions.assertThat;
15+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
16+
17+
class RedirectFilterTest {
18+
19+
private static HttpRequest request(String path) {
20+
return new HttpRequest(
21+
"GET",
22+
path,
23+
"HTTP/1.1",
24+
Map.of(),
25+
null
26+
);
27+
}
28+
29+
private static String responseAsString(HttpResponseBuilder response) {
30+
return new String(response.build(), StandardCharsets.UTF_8);
31+
}
32+
33+
@Test
34+
void returns_301_redirect_and_stops_pipeline() {
35+
RedirectFilter filter = new RedirectFilter(List.of(
36+
new RedirectRule(Pattern.compile("^/old-page$"), "/new-page", 301)
37+
));
38+
39+
AtomicBoolean chainCalled = new AtomicBoolean(false);
40+
FilterChain chain = (req, res) -> chainCalled.set(true);
41+
42+
HttpResponseBuilder res = new HttpResponseBuilder();
43+
44+
filter.doFilter(request("/old-page"), res, chain);
45+
46+
String raw = responseAsString(res);
47+
assertThat(raw).contains("HTTP/1.1 301 Moved Permanently");
48+
assertThat(raw).contains("Location: /new-page");
49+
assertThat(chainCalled.get()).isFalse();
50+
}
51+
52+
@Test
53+
void returns_302_redirect() {
54+
RedirectFilter filter = new RedirectFilter(List.of(
55+
new RedirectRule(Pattern.compile("^/temp$"), "https://example.com/temporary", 302)
56+
));
57+
58+
FilterChain chain = (req, res) -> res.setStatusCode(200);
59+
60+
HttpResponseBuilder res = new HttpResponseBuilder();
61+
62+
filter.doFilter(request("/temp"), res, chain);
63+
64+
String raw = responseAsString(res);
65+
assertThat(raw).contains("HTTP/1.1 302 Found");
66+
assertThat(raw).contains("Location: https://example.com/temporary");
67+
}
68+
69+
@Test
70+
void no_matching_rule_calls_next_in_chain() {
71+
RedirectFilter filter = new RedirectFilter(List.of(
72+
new RedirectRule(Pattern.compile("^/old-page$"), "/new-page", 301)
73+
));
74+
75+
AtomicBoolean chainCalled = new AtomicBoolean(false);
76+
FilterChain chain = (req, res) -> {
77+
chainCalled.set(true);
78+
res.setStatusCode(200);
79+
res.setBody("terminal");
80+
};
81+
82+
HttpResponseBuilder res = new HttpResponseBuilder();
83+
84+
filter.doFilter(request("/nope"), res, chain);
85+
86+
String raw = responseAsString(res);
87+
assertThat(chainCalled.get()).isTrue();
88+
assertThat(raw).contains("HTTP/1.1 200 OK");
89+
assertThat(raw).doesNotContain("Location:");
90+
}
91+
92+
@Test
93+
void wildcard_matching_docs_star() {
94+
var p = RedirectRulesLoader.compileSourcePattern("/docs/*");
95+
assertThat(p.matcher("/docs/test").matches()).isTrue();
96+
assertThat(p.matcher("/docs/any/path").matches()).isFalse();
97+
assertThat(p.matcher("/doc/test").matches()).isFalse();
98+
}
99+
100+
@Test
101+
void regex_matching_via_loader_prefix() {
102+
var p = RedirectRulesLoader.compileSourcePattern("regex:^/docs/(v1|v2)$");
103+
assertThat(p.matcher("/docs/v1").matches()).isTrue();
104+
assertThat(p.matcher("/docs/v2").matches()).isTrue();
105+
assertThat(p.matcher("/docs/v3").matches()).isFalse();
106+
}
107+
108+
@Test
109+
void redirect_rule_rejects_invalid_status_code() {
110+
assertThatThrownBy(() -> new RedirectRule(Pattern.compile("^/x$"), "/y", 307))
111+
.isInstanceOf(IllegalArgumentException.class);
112+
}
113+
}

0 commit comments

Comments
 (0)