Skip to content

Commit fcff227

Browse files
committed
router: Add option to detect or prevent duplicate route registrations
- RouterOptions.failOnDuplicateRoutes - fix #3741
1 parent 3e13382 commit fcff227

File tree

8 files changed

+163
-21
lines changed

8 files changed

+163
-21
lines changed

jooby/src/main/java/io/jooby/Route.java

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import java.util.concurrent.Executor;
1616
import java.util.function.Consumer;
1717
import java.util.function.Predicate;
18+
import java.util.stream.Stream;
1819

1920
import edu.umd.cs.findbugs.annotations.NonNull;
2021
import edu.umd.cs.findbugs.annotations.Nullable;
@@ -24,6 +25,7 @@
2425
import io.jooby.exception.NotFoundException;
2526
import io.jooby.exception.StatusCodeException;
2627
import io.jooby.exception.UnsupportedMediaType;
28+
import io.jooby.internal.RouterImpl;
2729

2830
/**
2931
* Route contains information about the HTTP method, path pattern, which content types consumes and
@@ -317,6 +319,16 @@ public interface Handler extends Serializable, Aware {
317319
}
318320
}
319321

322+
/**
323+
* Route location.
324+
*
325+
* @param filename File name.
326+
* @param line Line.
327+
*/
328+
public record Location(String filename, int line) {}
329+
330+
private static final Location NO_LOCATION = new Location("<<Unknown>>", -1);
331+
320332
/** Handler for {@link StatusCode#NOT_FOUND} responses. */
321333
public static final Handler NOT_FOUND =
322334
ctx -> ctx.sendError(new NotFoundException(ctx.getRequestPath()));
@@ -466,6 +478,8 @@ public MethodHandle toMethodHandle() {
466478

467479
private boolean httpHead;
468480

481+
private final Location location;
482+
469483
/**
470484
* Creates a new route.
471485
*
@@ -477,6 +491,33 @@ public Route(@NonNull String method, @NonNull String pattern, @NonNull Handler h
477491
this.method = method.toUpperCase();
478492
this.pattern = pattern;
479493
this.handler = handler;
494+
this.location =
495+
StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE)
496+
.walk(
497+
frame ->
498+
frame
499+
.dropWhile(
500+
f ->
501+
Stream.of(
502+
Route.class.getName(),
503+
RouterImpl.class.getName(),
504+
Router.class.getName(),
505+
Jooby.class.getName(),
506+
"io.jooby.kt.Kooby",
507+
"io.jooby.kt.CoroutineRouter")
508+
.anyMatch(it -> it.equals(f.getDeclaringClass().getName())))
509+
.findFirst()
510+
.map(it -> new Location(it.getFileName(), it.getLineNumber()))
511+
.orElse(NO_LOCATION));
512+
}
513+
514+
/**
515+
* Where the route was defined.
516+
*
517+
* @return Where the route was defined.
518+
*/
519+
public Location getLocation() {
520+
return location;
480521
}
481522

482523
/**

jooby/src/main/java/io/jooby/RouterOptions.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@
2525
* @since 2.4.0
2626
*/
2727
public class RouterOptions {
28+
/**
29+
* Detect or prevent duplicate route registrations. This option must be set before creating
30+
* routes.
31+
*/
32+
private boolean failOnDuplicateRoutes = false;
33+
2834
/**
2935
* Indicates whenever routing algorithm does case-sensitive matching on an incoming request path.
3036
* Default is <code>case sensitive</code>.
@@ -81,6 +87,27 @@ public RouterOptions setIgnoreCase(boolean ignoreCase) {
8187
return this;
8288
}
8389

90+
/**
91+
* Detect or prevent duplicate route registrations. This option must be set before creating
92+
* routes.
93+
*
94+
* @return Detect or prevent duplicate route registrations.
95+
*/
96+
public boolean isFailOnDuplicateRoutes() {
97+
return failOnDuplicateRoutes;
98+
}
99+
100+
/**
101+
* Detect or prevent duplicate route registrations.
102+
*
103+
* @param failOnDuplicateRoutes True for detect or prevent duplicate route registrations.
104+
* @return This options.
105+
*/
106+
public RouterOptions setFailOnDuplicateRoutes(boolean failOnDuplicateRoutes) {
107+
this.failOnDuplicateRoutes = failOnDuplicateRoutes;
108+
return this;
109+
}
110+
84111
/**
85112
* Indicates whenever routing algorithm does case-sensitive matching on an incoming request path.
86113
* Default is <code>false (case sensitive)</code>.

jooby/src/main/java/io/jooby/internal/Chi.java

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -674,7 +674,7 @@ public String toString() {
674674
return node.toString();
675675
}
676676

677-
Node insertRoute(String method, String pattern, Route route) {
677+
Node insertRoute(String method, String pattern, Route route, boolean failOnDuplicateRoutes) {
678678
Node n = this;
679679
Node parent;
680680
String search = pattern;
@@ -683,7 +683,7 @@ Node insertRoute(String method, String pattern, Route route) {
683683
// Handle key exhaustion
684684
if (search.isEmpty()) {
685685
// Insert or update the node's leaf handler
686-
n.setEndpoint(method, route);
686+
n.setEndpoint(method, route, failOnDuplicateRoutes);
687687
return n;
688688
}
689689

@@ -716,7 +716,7 @@ Node insertRoute(String method, String pattern, Route route) {
716716
if (n == null) {
717717
Node child = new Node().label(label).tail(seg.tail).prefix(search);
718718
Node hn = parent.addChild(child, search);
719-
hn.setEndpoint(method, route);
719+
hn.setEndpoint(method, route, failOnDuplicateRoutes);
720720
return hn;
721721
}
722722

@@ -752,14 +752,14 @@ Node insertRoute(String method, String pattern, Route route) {
752752
// If the new key is a subset, set the method/handler on this node and finish.
753753
search = search.substring(commonPrefix);
754754
if (search.isEmpty()) {
755-
child.setEndpoint(method, route);
755+
child.setEndpoint(method, route, failOnDuplicateRoutes);
756756
return child;
757757
}
758758

759759
// Create a new edge for the node
760760
Node subchild = new Node().typ(ntStatic).label(search.charAt(0)).prefix(search);
761761
Node hn = child.addChild(subchild, search);
762-
hn.setEndpoint(method, route);
762+
hn.setEndpoint(method, route, failOnDuplicateRoutes);
763763
return hn;
764764
}
765765
}
@@ -871,7 +871,7 @@ Node getEdge(int ntyp, char label, char tail, String prefix) {
871871
return null;
872872
}
873873

874-
void setEndpoint(String method, Route route) {
874+
void setEndpoint(String method, Route route, boolean failOnDuplicateRoutes) {
875875
Node n = this;
876876
// Set the handler for the method type on the node
877877
if (n.endpoints == null) {
@@ -893,6 +893,20 @@ void setEndpoint(String method, Route route) {
893893
// h.paramKeys = paramKeys;
894894
// }
895895
// } else {
896+
if (failOnDuplicateRoutes) {
897+
var existing = n.endpoints.get(method);
898+
if (existing != null) {
899+
throw new IllegalArgumentException(
900+
"Route already exists: "
901+
+ method
902+
+ " "
903+
+ existing.getPattern()
904+
+ " at "
905+
+ existing.getLocation().filename()
906+
+ ":"
907+
+ existing.getLocation().line());
908+
}
909+
}
896910
n.endpoints.put(method, route);
897911
// Endpoint h = n.endpoints.computeIfAbsent(method, k -> new Endpoint(handler));
898912
// h.handler = handler;
@@ -1193,6 +1207,12 @@ public void destroy() {
11931207

11941208
private StaticMap staticPaths = StaticMap.INIT;
11951209

1210+
boolean failOnDuplicateRoutes;
1211+
1212+
public Chi(boolean failOnDuplicateRoutes) {
1213+
this.failOnDuplicateRoutes = failOnDuplicateRoutes;
1214+
}
1215+
11961216
public void insert(String method, String pattern, Route route) {
11971217
String baseCatchAll = baseCatchAll(pattern);
11981218
if (baseCatchAll.length() > 1) {
@@ -1209,7 +1229,7 @@ public void insert(String method, String pattern, Route route) {
12091229
staticPaths = staticPaths.put(pattern, staticRoute);
12101230
staticRoute.put(method, route);
12111231
}
1212-
root.insertRoute(method, pattern, route);
1232+
root.insertRoute(method, pattern, route, failOnDuplicateRoutes);
12131233
}
12141234

12151235
private String baseCatchAll(String pattern) {

jooby/src/main/java/io/jooby/internal/RouterImpl.java

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ public Stack executor(Executor executor) {
126126

127127
private Map<String, StatusCode> errorCodes;
128128

129-
private RouteTree chi = new Chi();
129+
private RouteTree chi = new Chi(false);
130130

131131
private LinkedList<Stack> stack = new LinkedList<>();
132132

@@ -200,6 +200,7 @@ public RouterOptions getRouterOptions() {
200200
@NonNull @Override
201201
public Router setRouterOptions(@NonNull RouterOptions options) {
202202
this.routerOptions = options;
203+
((Chi) chi).failOnDuplicateRoutes = options.isFailOnDuplicateRoutes();
203204
return this;
204205
}
205206

@@ -257,7 +258,7 @@ public Route.Set domain(@NonNull String domain, @NonNull Router subrouter) {
257258

258259
@NonNull @Override
259260
public Route.Set mount(@NonNull Predicate<Context> predicate, @NonNull Runnable body) {
260-
var tree = new Chi();
261+
var tree = new Chi(routerOptions.isFailOnDuplicateRoutes());
261262
putPredicate(predicate, tree);
262263
int start = this.routes.size();
263264
newStack(tree, "/", body);
@@ -270,7 +271,7 @@ public Router install(
270271
@NonNull SneakyThrows.Supplier<Jooby> factory) {
271272
var existingRouter = this.chi;
272273
try {
273-
var tree = new Chi();
274+
var tree = new Chi(routerOptions.isFailOnDuplicateRoutes());
274275
this.chi = tree;
275276
putPredicate(predicate, tree);
276277
path(path, factory::get);
@@ -693,7 +694,7 @@ public Match match(@NonNull Context ctx) {
693694

694695
@Override
695696
public boolean match(@NonNull String pattern, @NonNull String path) {
696-
Chi chi = new Chi();
697+
Chi chi = new Chi(false);
697698
chi.insert(Router.GET, pattern, ROUTE_MARK);
698699
return chi.exists(Router.GET, path);
699700
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* Jooby https://jooby.io
3+
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
4+
* Copyright 2014 Edgar Espina
5+
*/
6+
package io.jooby;
7+
8+
import static org.junit.jupiter.api.Assertions.assertThrows;
9+
import static org.junit.jupiter.api.Assertions.assertTrue;
10+
11+
import org.junit.jupiter.api.Test;
12+
13+
public class Issue3741 {
14+
public static class RouterBase extends Jooby {
15+
{
16+
get("/3741/{id}", ctx -> "Path Var");
17+
18+
get("/3741/static", ctx -> "Static");
19+
}
20+
}
21+
22+
@Test
23+
public void shouldDetectDuplicatedRoutes() {
24+
var main = new Jooby();
25+
main.setRouterOptions(new RouterOptions().setFailOnDuplicateRoutes(true));
26+
main.mount(new RouterBase());
27+
var cause =
28+
assertThrows(
29+
IllegalArgumentException.class, () -> main.get("/3741/{id}", ctx -> "Something"));
30+
assertTrue(cause.getMessage().startsWith("Route already exists"));
31+
32+
cause =
33+
assertThrows(
34+
IllegalArgumentException.class, () -> main.get("/3741/static", ctx -> "Static"));
35+
assertTrue(cause.getMessage().startsWith("Route already exists"));
36+
}
37+
38+
@Test
39+
public void shouldDetectDuplicatedRoutesOnPaths() {
40+
var main = new Jooby();
41+
main.setRouterOptions(new RouterOptions().setFailOnDuplicateRoutes(true));
42+
main.mount("/path", new RouterBase());
43+
var cause =
44+
assertThrows(
45+
IllegalArgumentException.class, () -> main.get("/path/3741/{id}", ctx -> "Something"));
46+
assertTrue(cause.getMessage().startsWith("Route already exists"));
47+
48+
cause =
49+
assertThrows(
50+
IllegalArgumentException.class, () -> main.get("/path/3741/static", ctx -> "Static"));
51+
assertTrue(cause.getMessage().startsWith("Route already exists"));
52+
}
53+
}

jooby/src/test/java/io/jooby/internal/ChiTest.java

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
public class ChiTest {
2323
@Test
2424
public void routeOverride() {
25-
Chi router = new Chi();
25+
Chi router = new Chi(false);
2626
Route foo = route("GET", "/abcd", stringHandler("foo"));
2727
Route bar = route("GET", "/abcd", stringHandler("bar"));
2828
router.insert(foo);
@@ -35,7 +35,7 @@ public void routeOverride() {
3535

3636
@Test
3737
public void staticMap6() {
38-
Chi router = new Chi();
38+
Chi router = new Chi(false);
3939
router.insert(route("GET", "/1", stringHandler("1")));
4040
router.insert(route("GET", "/2", stringHandler("2")));
4141
router.insert(route("GET", "/3", stringHandler("3")));
@@ -49,7 +49,7 @@ public void staticMap6() {
4949

5050
@Test
5151
public void routeCase() {
52-
Chi router = new Chi();
52+
Chi router = new Chi(false);
5353
Route foo = route("GET", "/abcd", stringHandler("foo"));
5454
Route foos = route("GET", "/abcd/", stringHandler("foo/"));
5555
router.insert(foo);
@@ -62,7 +62,7 @@ public void routeCase() {
6262

6363
@Test
6464
public void wildOnRoot() throws Exception {
65-
Chi router = new Chi();
65+
Chi router = new Chi(false);
6666

6767
router.insert(route("GET", "/foo/?*", stringHandler("foo")));
6868
router.insert(route("GET", "/bar/*", stringHandler("bar")));
@@ -127,7 +127,7 @@ public void wildOnRoot() throws Exception {
127127

128128
@Test
129129
public void searchString() throws Exception {
130-
Chi router = new Chi();
130+
Chi router = new Chi(false);
131131

132132
router.insert(route("GET", "/regex/{nid:[0-9]+}", stringHandler("nid")));
133133
router.insert(route("GET", "/regex/{zid:[0-9]+}/edit", stringHandler("zid")));
@@ -161,7 +161,7 @@ public void searchString() throws Exception {
161161

162162
@Test
163163
public void searchParam() throws Exception {
164-
Chi router = new Chi();
164+
Chi router = new Chi(false);
165165

166166
router.insert(route("GET", "/catchallWithVarPrefix/{id}/*path", stringHandler("path")));
167167

@@ -195,7 +195,7 @@ public void searchParam() throws Exception {
195195

196196
@Test
197197
public void multipleRegex() {
198-
Chi router = new Chi();
198+
Chi router = new Chi(false);
199199

200200
router.insert(route("GET", "/{lang:[a-z][a-z]}/{page:[^.]+}/", stringHandler("1515")));
201201

@@ -226,7 +226,7 @@ public void multipleRegex() {
226226

227227
@Test
228228
public void regexWithQuantity() {
229-
Chi router = new Chi();
229+
Chi router = new Chi(false);
230230

231231
router.insert(route("GET", "/{lang:[a-z]{2}}/", stringHandler("qx")));
232232

jooby/src/test/java/io/jooby/internal/Issue1822.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ public class Issue1822 {
1919
public void routerExistsShouldNotReturnsFalsePositives() {
2020
String pattern = "/api/*";
2121
String path = "/";
22-
Chi router = new Chi();
22+
Chi router = new Chi(false);
2323
router.insert(route("GET", pattern, stringHandler("api")));
2424
assertFalse(router.exists("GET", path));
2525
assertTrue(router.exists("GET", "/api/v1"));

0 commit comments

Comments
 (0)