Skip to content

Commit 04ce19a

Browse files
committed
feat: Add PathPattern for extras wildcard matching
1 parent 07490a3 commit 04ce19a

2 files changed

Lines changed: 148 additions & 0 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package com.retailsvc.http.internal;
2+
3+
import java.util.regex.Pattern;
4+
5+
public final class PathPattern {
6+
7+
private final String raw;
8+
private final Pattern regex;
9+
private final boolean wildcard;
10+
11+
private PathPattern(String raw, Pattern regex, boolean wildcard) {
12+
this.raw = raw;
13+
this.regex = regex;
14+
this.wildcard = wildcard;
15+
}
16+
17+
public static PathPattern compile(String raw) {
18+
if (raw == null || !raw.startsWith("/")) {
19+
throw new IllegalArgumentException("path must start with '/': " + raw);
20+
}
21+
String[] segments = raw.substring(1).split("/", -1);
22+
StringBuilder rx = new StringBuilder("^");
23+
boolean hasWildcard = false;
24+
String prev = null;
25+
for (int i = 0; i < segments.length; i++) {
26+
String seg = segments[i];
27+
if (seg.isEmpty()
28+
&& !(i == segments.length - 1 && segments.length > 1 && raw.endsWith("/"))) {
29+
throw new IllegalArgumentException("empty segment in path: " + raw);
30+
}
31+
if (seg.contains("*") && !seg.equals("*") && !seg.equals("**")) {
32+
throw new IllegalArgumentException(
33+
"'*' and '**' must be a whole segment, not " + seg + " in " + raw);
34+
}
35+
if ("**".equals(seg) && "**".equals(prev)) {
36+
throw new IllegalArgumentException("adjacent '**' segments in " + raw);
37+
}
38+
boolean trailing = i == segments.length - 1;
39+
switch (seg) {
40+
case "*" -> {
41+
rx.append("/[^/]+");
42+
hasWildcard = true;
43+
}
44+
case "**" -> {
45+
if (trailing) {
46+
// Slash is required; anything (including empty string) may follow it.
47+
rx.append("/.*");
48+
} else {
49+
// At least one character and a slash must appear before the next segment.
50+
rx.append("/.+");
51+
}
52+
hasWildcard = true;
53+
}
54+
default -> rx.append("/").append(Pattern.quote(seg));
55+
}
56+
prev = seg;
57+
}
58+
rx.append("$");
59+
return new PathPattern(raw, Pattern.compile(rx.toString()), hasWildcard);
60+
}
61+
62+
public boolean hasWildcard() {
63+
return wildcard;
64+
}
65+
66+
public boolean matches(String path) {
67+
return regex.matcher(path).matches();
68+
}
69+
70+
public String raw() {
71+
return raw;
72+
}
73+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package com.retailsvc.http.internal;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
5+
6+
import org.junit.jupiter.api.Test;
7+
8+
class PathPatternTest {
9+
10+
@Test
11+
void exactPathHasNoWildcardAndMatchesItself() {
12+
PathPattern p = PathPattern.compile("/alive");
13+
assertThat(p.hasWildcard()).isFalse();
14+
assertThat(p.matches("/alive")).isTrue();
15+
assertThat(p.matches("/alive/")).isFalse();
16+
assertThat(p.matches("/alive232")).isFalse();
17+
}
18+
19+
@Test
20+
void singleStarMatchesOneSegment() {
21+
PathPattern p = PathPattern.compile("/files/*");
22+
assertThat(p.hasWildcard()).isTrue();
23+
assertThat(p.matches("/files/a")).isTrue();
24+
assertThat(p.matches("/files/abc.txt")).isTrue();
25+
assertThat(p.matches("/files/")).isFalse();
26+
assertThat(p.matches("/files/a/b")).isFalse();
27+
}
28+
29+
@Test
30+
void doubleStarMatchesAnyDepth() {
31+
PathPattern p = PathPattern.compile("/files/**");
32+
assertThat(p.matches("/files/")).isTrue();
33+
assertThat(p.matches("/files/a")).isTrue();
34+
assertThat(p.matches("/files/a/b/c")).isTrue();
35+
assertThat(p.matches("/files")).isFalse();
36+
assertThat(p.matches("/filesx/a")).isFalse();
37+
}
38+
39+
@Test
40+
void midPathDoubleStarSurroundedByLiterals() {
41+
PathPattern p = PathPattern.compile("/schemas/**/openapi.yaml");
42+
assertThat(p.matches("/schemas/a/openapi.yaml")).isTrue();
43+
assertThat(p.matches("/schemas/a/b/openapi.yaml")).isTrue();
44+
assertThat(p.matches("/schemas/openapi.yaml")).isFalse();
45+
assertThat(p.matches("/schemas/a/openapi.yamlx")).isFalse();
46+
}
47+
48+
@Test
49+
void mixedSegmentRejected() {
50+
assertThatThrownBy(() -> PathPattern.compile("/files/prefix-*.json"))
51+
.isInstanceOf(IllegalArgumentException.class)
52+
.hasMessageContaining("must be a whole segment");
53+
}
54+
55+
@Test
56+
void emptySegmentRejected() {
57+
assertThatThrownBy(() -> PathPattern.compile("/files//a"))
58+
.isInstanceOf(IllegalArgumentException.class)
59+
.hasMessageContaining("empty segment");
60+
}
61+
62+
@Test
63+
void adjacentDoubleStarsRejected() {
64+
assertThatThrownBy(() -> PathPattern.compile("/a/**/**/b"))
65+
.isInstanceOf(IllegalArgumentException.class)
66+
.hasMessageContaining("adjacent");
67+
}
68+
69+
@Test
70+
void mustStartWithSlash() {
71+
assertThatThrownBy(() -> PathPattern.compile("files/*"))
72+
.isInstanceOf(IllegalArgumentException.class)
73+
.hasMessageContaining("must start with '/'");
74+
}
75+
}

0 commit comments

Comments
 (0)