Skip to content

Commit 080f2f3

Browse files
authored
[RFC] Allow overlap between static routes and wildcards (#134)
This changes the DispatchTrie to allow overlapping wildcard segments in paths with static ones, with a preference for the latter. For example, consider the following routes: ``` @cask.get("/settings") def settings() = "settings" @cask.get("/:id") def user(id: String) = s"user $id" ``` This is currently not allowed. With these changes, it would be allowed, and the static route `settings` would be preferred, with a fallback to the dynamic route `user`: ``` GET /settings => settings GET /foo => user foo GET /bar => user bar ``` --- The reason I'm proposing this change is mostly for use in HTML applications (i.e. not RPC-style JSON APIs). In this scenario, short URLs are useful, since users may type them directly and associate meaning to them. Consider for example the way GitHub structures URLs. If github were written with cask's current routing logic, it would not be possible to have URLs such as `/settings` and `/com-lihaoyi`, and instead some namespacing would need to be introduced (e.g. `/orgs/com-lihaoyi`) to separate these, which might not actually be relevant for users. Of course, with these changes we will no longer catch developer errors that accidentally define wildcard-overlapping routes with non-wildcard ones. It will also be up to the application developer to make sure that there aren't any accidental overlaps between valid values of wildcards and static routes (e.g. in the example above, the application developer would somehow need to make sure that there isn't a user called "settings" in their system). Given these drawbacks, I'd like to hear your thoughts on this in general. Personally I think that it's useful to enforce non-overlaps for API purposes, because this forces you to design a more robust url scheme. However, for HTML application purposes, I think that allowing shorter URLs is useful and probably outweighs the current limitations. Maybe we could also come up with a way to make this configurable.
1 parent 2616b5c commit 080f2f3

File tree

5 files changed

+81
-75
lines changed

5 files changed

+81
-75
lines changed

cask/src/cask/internal/DispatchTrie.scala

Lines changed: 33 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -38,17 +38,25 @@ object DispatchTrie{
3838
validateGroup(groupTerminals, groupContinuations)
3939
}
4040

41+
val dynamicChildren = continuations.filter(_._1.startsWith(":"))
42+
.flatMap(_._2).toIndexedSeq
43+
4144
DispatchTrie[T](
42-
current = terminals.headOption.map(x => x._2 -> x._3),
43-
children = continuations
45+
current = terminals.headOption
46+
.map{ case (path, value, capturesSubpath) =>
47+
val argNames = path.filter(_.startsWith(":")).map(_.drop(1)).toVector
48+
(value, capturesSubpath, argNames)
49+
},
50+
staticChildren = continuations
51+
.filter(!_._1.startsWith(":"))
4452
.map{ case (k, vs) => (k, construct(index + 1, vs)(validationGroups))}
45-
.toMap
53+
.toMap,
54+
dynamicChildren = if (dynamicChildren.isEmpty) None else Some(construct(index + 1, dynamicChildren)(validationGroups))
4655
)
4756
}
4857

4958
def validateGroup[T, V](terminals: collection.Seq[(collection.Seq[String], T, Boolean, V)],
5059
continuations: mutable.Map[String, mutable.Buffer[(collection.IndexedSeq[String], T, Boolean, V)]]) = {
51-
val wildcards = continuations.filter(_._1(0) == ':')
5260

5361
def renderTerminals = terminals
5462
.map{case (path, v, allowSubpath, group) => s"$group${renderPath(path)}"}
@@ -65,12 +73,6 @@ object DispatchTrie{
6573
)
6674
}
6775

68-
if (wildcards.size >= 1 && continuations.size > 1) {
69-
throw new Exception(
70-
s"Routes overlap with wildcards: $renderContinuations"
71-
)
72-
}
73-
7476
if (terminals.headOption.exists(_._3) && continuations.size == 1) {
7577
throw new Exception(
7678
s"Routes overlap with subpath capture: $renderTerminals, $renderContinuations"
@@ -88,32 +90,37 @@ object DispatchTrie{
8890
* segments starting with `:`) and any remaining un-used path segments
8991
* (only when `current._2 == true`, indicating this route allows trailing
9092
* segments)
93+
* current = (value, captures subpaths, argument names)
9194
*/
92-
case class DispatchTrie[T](current: Option[(T, Boolean)],
93-
children: Map[String, DispatchTrie[T]]){
95+
case class DispatchTrie[T](
96+
current: Option[(T, Boolean, Vector[String])],
97+
staticChildren: Map[String, DispatchTrie[T]],
98+
dynamicChildren: Option[DispatchTrie[T]]
99+
) {
100+
94101
final def lookup(remainingInput: List[String],
95-
bindings: Map[String, String])
102+
bindings: Vector[String])
96103
: Option[(T, Map[String, String], Seq[String])] = {
97-
remainingInput match{
104+
remainingInput match {
98105
case Nil =>
99-
current.map(x => (x._1, bindings, Nil))
106+
current.map(x => (x._1, x._3.zip(bindings).toMap, Nil))
100107
case head :: rest if current.exists(_._2) =>
101-
current.map(x => (x._1, bindings, head :: rest))
108+
current.map(x => (x._1, x._3.zip(bindings).toMap, head :: rest))
102109
case head :: rest =>
103-
if (children.size == 1 && children.keys.head.startsWith(":")){
104-
children.values.head.lookup(rest, bindings + (children.keys.head.drop(1) -> head))
105-
}else{
106-
children.get(head) match{
107-
case None => None
108-
case Some(continuation) => continuation.lookup(rest, bindings)
109-
}
110+
staticChildren.get(head) match {
111+
case Some(continuation) => continuation.lookup(rest, bindings)
112+
case None =>
113+
dynamicChildren match {
114+
case Some(continuation) => continuation.lookup(rest, bindings :+ head)
115+
case None => None
116+
}
110117
}
111-
112118
}
113119
}
114120

115121
def map[V](f: T => V): DispatchTrie[V] = DispatchTrie(
116-
current.map{case (t, v) => (f(t), v)},
117-
children.map { case (k, v) => (k, v.map(f))}
122+
current.map{case (t, v, a) => (f(t), v, a)},
123+
staticChildren.map { case (k, v) => (k, v.map(f))},
124+
dynamicChildren.map { case v => v.map(f)},
118125
)
119126
}

cask/src/cask/main/Main.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ object Main{
106106
.map(java.net.URLDecoder.decode(_, "UTF-8"))
107107
.toList
108108

109-
dispatchTrie.lookup(decodedSegments, Map()) match {
109+
dispatchTrie.lookup(decodedSegments, Vector()) match {
110110
case None => Main.writeResponse(exchange, handleNotFound(Request(exchange, decodedSegments)))
111111
case Some((methodMap, routeBindings, remaining)) =>
112112
methodMap.get(effectiveMethod) match {

cask/test/src/test/cask/DispatchTrieTests.scala

Lines changed: 21 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ object DispatchTrieTests extends TestSuite {
1111
)(Seq(_))
1212

1313
assert(
14-
x.lookup(List("hello"), Map()) == Some((1, Map(), Nil)),
15-
x.lookup(List("hello", "world"), Map()) == None,
16-
x.lookup(List("world"), Map()) == None
14+
x.lookup(List("hello"), Vector()) == Some((1, Map(), Nil)),
15+
x.lookup(List("hello", "world"), Vector()) == None,
16+
x.lookup(List("world"), Vector()) == None
1717
)
1818
}
1919
"nested" - {
@@ -24,23 +24,23 @@ object DispatchTrieTests extends TestSuite {
2424
)
2525
)(Seq(_))
2626
assert(
27-
x.lookup(List("hello", "world"), Map()) == Some((1, Map(), Nil)),
28-
x.lookup(List("hello", "cow"), Map()) == Some((2, Map(), Nil)),
29-
x.lookup(List("hello"), Map()) == None,
30-
x.lookup(List("hello", "moo"), Map()) == None,
31-
x.lookup(List("hello", "world", "moo"), Map()) == None
27+
x.lookup(List("hello", "world"), Vector()) == Some((1, Map(), Nil)),
28+
x.lookup(List("hello", "cow"), Vector()) == Some((2, Map(), Nil)),
29+
x.lookup(List("hello"), Vector()) == None,
30+
x.lookup(List("hello", "moo"), Vector()) == None,
31+
x.lookup(List("hello", "world", "moo"), Vector()) == None
3232
)
3333
}
3434
"bindings" - {
3535
val x = DispatchTrie.construct(0,
3636
Seq((Vector(":hello", ":world"), 1, false))
3737
)(Seq(_))
3838
assert(
39-
x.lookup(List("hello", "world"), Map()) == Some((1, Map("hello" -> "hello", "world" -> "world"), Nil)),
40-
x.lookup(List("world", "hello"), Map()) == Some((1, Map("hello" -> "world", "world" -> "hello"), Nil)),
39+
x.lookup(List("hello", "world"), Vector()) == Some((1, Map("hello" -> "hello", "world" -> "world"), Nil)),
40+
x.lookup(List("world", "hello"), Vector()) == Some((1, Map("hello" -> "world", "world" -> "hello"), Nil)),
4141

42-
x.lookup(List("hello", "world", "cow"), Map()) == None,
43-
x.lookup(List("hello"), Map()) == None
42+
x.lookup(List("hello", "world", "cow"), Vector()) == None,
43+
x.lookup(List("hello"), Vector()) == None
4444
)
4545
}
4646

@@ -50,35 +50,21 @@ object DispatchTrieTests extends TestSuite {
5050
)(Seq(_))
5151

5252
assert(
53-
x.lookup(List("hello", "world"), Map()) == Some((1,Map(), Seq("world"))),
54-
x.lookup(List("hello", "world", "cow"), Map()) == Some((1,Map(), Seq("world", "cow"))),
55-
x.lookup(List("hello"), Map()) == Some((1,Map(), Seq())),
56-
x.lookup(List(), Map()) == None
53+
x.lookup(List("hello", "world"), Vector()) == Some((1,Map(), Seq("world"))),
54+
x.lookup(List("hello", "world", "cow"), Vector()) == Some((1,Map(), Seq("world", "cow"))),
55+
x.lookup(List("hello"), Vector()) == Some((1,Map(), Seq())),
56+
x.lookup(List(), Vector()) == None
5757
)
5858
}
5959

60-
"errors" - {
60+
"wildcards" - {
6161
test - {
6262
DispatchTrie.construct(0,
6363
Seq(
6464
(Vector("hello", ":world"), 1, false),
65-
(Vector("hello", "world"), 2, false)
65+
(Vector("hello", "world"), 1, false)
6666
)
6767
)(Seq(_))
68-
69-
val ex = intercept[Exception]{
70-
DispatchTrie.construct(0,
71-
Seq(
72-
(Vector("hello", ":world"), 1, false),
73-
(Vector("hello", "world"), 1, false)
74-
)
75-
)(Seq(_))
76-
}
77-
78-
assert(
79-
ex.getMessage ==
80-
"Routes overlap with wildcards: 1 /hello/:world, 1 /hello/world"
81-
)
8268
}
8369
test - {
8470
DispatchTrie.construct(0,
@@ -87,21 +73,9 @@ object DispatchTrieTests extends TestSuite {
8773
(Vector("hello", "world", "omg"), 2, false)
8874
)
8975
)(Seq(_))
90-
91-
val ex = intercept[Exception]{
92-
DispatchTrie.construct(0,
93-
Seq(
94-
(Vector("hello", ":world"), 1, false),
95-
(Vector("hello", "world", "omg"), 1, false)
96-
)
97-
)(Seq(_))
98-
}
99-
100-
assert(
101-
ex.getMessage ==
102-
"Routes overlap with wildcards: 1 /hello/:world, 1 /hello/world/omg"
103-
)
10476
}
77+
}
78+
"errors" - {
10579
test - {
10680
DispatchTrie.construct(0,
10781
Seq(
@@ -143,7 +117,7 @@ object DispatchTrieTests extends TestSuite {
143117

144118
assert(
145119
ex.getMessage ==
146-
"Routes overlap with wildcards: 1 /hello/:world, 1 /hello/:cow"
120+
"More than one endpoint has the same path: 1 /hello/:world, 1 /hello/:cow"
147121
)
148122
}
149123
test - {

example/queryParams/app/src/QueryParams.scala

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ package app
22
object QueryParams extends cask.MainRoutes{
33

44
@cask.get("/article/:articleId") // Mandatory query param, e.g. HOST/article/foo?param=bar
5-
def getArticle(articleId: Int, param: String) = {
5+
def getArticle(articleId: Int, param: String) = {
66
s"Article $articleId $param"
77
}
88

@@ -31,5 +31,20 @@ object QueryParams extends cask.MainRoutes{
3131
s"User $userName " + params.value
3232
}
3333

34+
@cask.get("/statics/foo")
35+
def getStatic() = {
36+
"static route takes precedence"
37+
}
38+
39+
@cask.get("/statics/:foo")
40+
def getDynamics(foo: String) = {
41+
s"dynamic route $foo"
42+
}
43+
44+
@cask.get("/statics/bar")
45+
def getStatic2() = {
46+
"another static route"
47+
}
48+
3449
initialize()
3550
}

example/queryParams/app/test/src/ExampleTests.scala

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,16 @@ object ExampleTests extends TestSuite{
9090
res3 == "User lihaoyi Map(unknown1 -> WrappedArray(123), unknown2 -> WrappedArray(abc))" ||
9191
res3 == "User lihaoyi Map(unknown1 -> ArraySeq(123), unknown2 -> ArraySeq(abc))"
9292
)
93+
94+
assert(
95+
requests.get(s"$host/statics/foo").text() == "static route takes precedence"
96+
)
97+
assert(
98+
requests.get(s"$host/statics/hello").text() == "dynamic route hello"
99+
)
100+
assert(
101+
requests.get(s"$host/statics/bar").text() == "another static route"
102+
)
93103
}
94104
}
95105
}

0 commit comments

Comments
 (0)