Skip to content

Commit 7bce13d

Browse files
committed
feat: Add ExtrasRouter with exact and wildcard matching
1 parent 38ed0e7 commit 7bce13d

2 files changed

Lines changed: 213 additions & 0 deletions

File tree

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 com.retailsvc.http.NotFoundException;
4+
import com.retailsvc.http.Request;
5+
import com.retailsvc.http.RequestHandler;
6+
import com.retailsvc.http.Response;
7+
import com.retailsvc.http.spec.HttpMethod;
8+
import com.sun.net.httpserver.HttpExchange;
9+
import com.sun.net.httpserver.HttpHandler;
10+
import java.io.IOException;
11+
import java.util.ArrayList;
12+
import java.util.LinkedHashMap;
13+
import java.util.List;
14+
import java.util.Map;
15+
16+
/** Dispatches extra-route requests using exact and wildcard path matching. */
17+
public final class ExtrasRouter implements HttpHandler {
18+
19+
private record Entry(PathPattern pattern, RequestHandler handler) {}
20+
21+
private final Map<String, RequestHandler> exact;
22+
private final List<Entry> wildcards;
23+
private final ResponseRenderer renderer;
24+
25+
public ExtrasRouter(Map<String, RequestHandler> extras, ResponseRenderer renderer) {
26+
this.renderer = renderer;
27+
Map<String, RequestHandler> exactBuilder = new LinkedHashMap<>();
28+
List<Entry> wildcardBuilder = new ArrayList<>();
29+
for (Map.Entry<String, RequestHandler> e : extras.entrySet()) {
30+
PathPattern p = PathPattern.compile(e.getKey());
31+
if (p.hasWildcard()) {
32+
wildcardBuilder.add(new Entry(p, e.getValue()));
33+
} else {
34+
exactBuilder.put(p.raw(), e.getValue());
35+
}
36+
}
37+
this.exact = Map.copyOf(exactBuilder);
38+
this.wildcards = List.copyOf(wildcardBuilder);
39+
}
40+
41+
@Override
42+
public void handle(HttpExchange exchange) throws IOException {
43+
String decoded = ExtrasPathValidator.validateAndDecode(exchange.getRequestURI());
44+
45+
RequestHandler hit = exact.get(decoded);
46+
if (hit == null) {
47+
for (Entry e : wildcards) {
48+
if (e.pattern().matches(decoded)) {
49+
hit = e.handler();
50+
break;
51+
}
52+
}
53+
}
54+
if (hit == null) {
55+
throw new NotFoundException(exchange.getRequestMethod() + " " + decoded);
56+
}
57+
58+
byte[] body = exchange.getRequestBody().readAllBytes();
59+
HttpMethod method = HttpMethod.parse(exchange.getRequestMethod());
60+
var headers = exchange.getRequestHeaders();
61+
Request request =
62+
new Request(
63+
body,
64+
null,
65+
null,
66+
null,
67+
Map.of(),
68+
exchange.getRequestURI().getRawQuery(),
69+
headers::getFirst,
70+
Map.of(),
71+
method);
72+
Response response = hit.handle(request);
73+
renderer.render(exchange, response);
74+
}
75+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
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+
import static org.mockito.Mockito.mock;
6+
import static org.mockito.Mockito.when;
7+
8+
import com.retailsvc.http.BadRequestException;
9+
import com.retailsvc.http.GsonTypeMapper;
10+
import com.retailsvc.http.NotFoundException;
11+
import com.retailsvc.http.RequestHandler;
12+
import com.retailsvc.http.Response;
13+
import com.retailsvc.http.TypeMapper;
14+
import com.sun.net.httpserver.Headers;
15+
import com.sun.net.httpserver.HttpExchange;
16+
import java.io.ByteArrayInputStream;
17+
import java.io.ByteArrayOutputStream;
18+
import java.net.URI;
19+
import java.util.LinkedHashMap;
20+
import java.util.Map;
21+
import java.util.concurrent.atomic.AtomicReference;
22+
import org.junit.jupiter.api.Test;
23+
24+
class ExtrasRouterTest {
25+
26+
@Test
27+
void exactMatchDispatches() throws Exception {
28+
AtomicReference<String> hit = new AtomicReference<>();
29+
Map<String, RequestHandler> extras = new LinkedHashMap<>();
30+
extras.put(
31+
"/alive",
32+
req -> {
33+
hit.set("alive");
34+
return Response.empty();
35+
});
36+
ExtrasRouter router = newRouter(extras);
37+
38+
invoke(router, "/alive");
39+
40+
assertThat(hit.get()).isEqualTo("alive");
41+
}
42+
43+
@Test
44+
void exactMatchRequiresExactPath() {
45+
Map<String, RequestHandler> extras = new LinkedHashMap<>();
46+
extras.put("/alive", req -> Response.empty());
47+
ExtrasRouter router = newRouter(extras);
48+
49+
assertThatThrownBy(() -> invoke(router, "/alive232")).isInstanceOf(NotFoundException.class);
50+
assertThatThrownBy(() -> invoke(router, "/alive/34")).isInstanceOf(NotFoundException.class);
51+
}
52+
53+
@Test
54+
void singleStarMatchesOneSegment() throws Exception {
55+
AtomicReference<String> hit = new AtomicReference<>();
56+
Map<String, RequestHandler> extras = new LinkedHashMap<>();
57+
extras.put(
58+
"/static/*",
59+
req -> {
60+
hit.set("static");
61+
return Response.empty();
62+
});
63+
ExtrasRouter router = newRouter(extras);
64+
65+
invoke(router, "/static/style.css");
66+
assertThat(hit.get()).isEqualTo("static");
67+
}
68+
69+
@Test
70+
void doubleStarMatchesAnyDepth() throws Exception {
71+
AtomicReference<String> hit = new AtomicReference<>();
72+
Map<String, RequestHandler> extras = new LinkedHashMap<>();
73+
extras.put(
74+
"/files/**",
75+
req -> {
76+
hit.set("files");
77+
return Response.empty();
78+
});
79+
ExtrasRouter router = newRouter(extras);
80+
81+
invoke(router, "/files/a/b/c");
82+
assertThat(hit.get()).isEqualTo("files");
83+
}
84+
85+
@Test
86+
void exactWinsOverWildcard() throws Exception {
87+
AtomicReference<String> hit = new AtomicReference<>();
88+
Map<String, RequestHandler> extras = new LinkedHashMap<>();
89+
extras.put(
90+
"/files/**",
91+
req -> {
92+
hit.set("wild");
93+
return Response.empty();
94+
});
95+
extras.put(
96+
"/files/special",
97+
req -> {
98+
hit.set("exact");
99+
return Response.empty();
100+
});
101+
ExtrasRouter router = newRouter(extras);
102+
103+
invoke(router, "/files/special");
104+
assertThat(hit.get()).isEqualTo("exact");
105+
}
106+
107+
@Test
108+
void noMatchThrowsNotFound() {
109+
ExtrasRouter router = newRouter(Map.of());
110+
assertThatThrownBy(() -> invoke(router, "/nope")).isInstanceOf(NotFoundException.class);
111+
}
112+
113+
@Test
114+
void traversalRejected() {
115+
Map<String, RequestHandler> extras = new LinkedHashMap<>();
116+
extras.put("/files/**", req -> Response.empty());
117+
ExtrasRouter router = newRouter(extras);
118+
119+
assertThatThrownBy(() -> invoke(router, "/files/../etc/passwd"))
120+
.isInstanceOf(BadRequestException.class);
121+
}
122+
123+
private static ExtrasRouter newRouter(Map<String, RequestHandler> extras) {
124+
Map<String, TypeMapper> mappers = Map.of("application/json", new GsonTypeMapper());
125+
return new ExtrasRouter(extras, new ResponseRenderer(mappers));
126+
}
127+
128+
private static void invoke(ExtrasRouter router, String path) throws Exception {
129+
HttpExchange ex = mock(HttpExchange.class);
130+
when(ex.getRequestMethod()).thenReturn("GET");
131+
when(ex.getRequestURI()).thenReturn(URI.create(path));
132+
when(ex.getRequestHeaders()).thenReturn(new Headers());
133+
when(ex.getRequestBody()).thenReturn(new ByteArrayInputStream(new byte[0]));
134+
when(ex.getResponseHeaders()).thenReturn(new Headers());
135+
when(ex.getResponseBody()).thenReturn(new ByteArrayOutputStream());
136+
router.handle(ex);
137+
}
138+
}

0 commit comments

Comments
 (0)