Skip to content

Commit

Permalink
Allow : and * in path segments.
Browse files Browse the repository at this point in the history
If the : or * occurs at the beginning of the segment, it
must be escaped with a backslash.

Fixes #29
  • Loading branch information
dimfeld committed Mar 31, 2016
1 parent 837a149 commit 4fdc8a5
Show file tree
Hide file tree
Showing 4 changed files with 70 additions and 43 deletions.
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,20 @@ Some examples of valid URL patterns are:

Note that all of the above URL patterns may exist concurrently in the router.

Path elements starting with : indicate a wildcard in the path. A wildcard will only match on a single path segment. That is, the pattern `/post/:postid` will match on `/post/1` or `/post/1/`, but not `/post/1/2`.
Path elements starting with `:` indicate a wildcard in the path. A wildcard will only match on a single path segment. That is, the pattern `/post/:postid` will match on `/post/1` or `/post/1/`, but not `/post/1/2`.

A path element starting with * is a catch-all, whose value will be a string containing all text in the URL matched by the wildcards. For example, with a pattern of `/images/*path` and a requested URL `images/abc/def`, path would contain `abc/def`.
A path element starting with `*` is a catch-all, whose value will be a string containing all text in the URL matched by the wildcards. For example, with a pattern of `/images/*path` and a requested URL `images/abc/def`, path would contain `abc/def`.

#### Using : and * in routing patterns

The characters `:` and `*` can be used at the beginning of a path segment by escaping them with a backslash. A double backslash at the beginning of a segment is interpreted as a single backslash. These escapes are only checked at the very beginning of a path segment; they are not necessary or processed elsewhere in a token.

```go
router.GET("/foo/\\*starToken", handler) // matches /foo/*starToken
router.GET("/foo/star*inTheMiddle", handler) // matches /foo/star*inTheMiddle
router.GET("/foo/starBackslash\\*", handler) // matches /foo/starBackslash\*
router.GET("/foo/\\\\*backslashWithStar") // matches /foo/\*backslashWithStar
```

### Routing Groups
Lets you create a new group of routes with a given path prefix. Makes it easier to create clusters of paths like:
Expand Down
2 changes: 1 addition & 1 deletion group.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ func (g *Group) Handle(method string, path string, handler HandlerFunc) {
path = path[:len(path)-1]
}

node := g.mux.root.addPath(path[1:], nil)
node := g.mux.root.addPath(path[1:], nil, false)
if addSlash {
node.addSlash = true
}
Expand Down
36 changes: 28 additions & 8 deletions tree.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func (n *node) setHandler(verb string, handler HandlerFunc, implicitHead bool) {
}
}

func (n *node) addPath(path string, wildcards []string) *node {
func (n *node) addPath(path string, wildcards []string, inStaticToken bool) *node {
leaf := len(path) == 0
if leaf {
if wildcards != nil {
Expand Down Expand Up @@ -90,6 +90,7 @@ func (n *node) addPath(path string, wildcards []string) *node {
var tokenEnd int

if c == '/' {
// Done processing the previous token, so reset inStaticToken to false.
thisToken = "/"
tokenEnd = 1
} else if nextSlash == -1 {
Expand All @@ -101,7 +102,7 @@ func (n *node) addPath(path string, wildcards []string) *node {
}
remainingPath := path[tokenEnd:]

if c == '*' {
if c == '*' && !inStaticToken {
// Token starts with a *, so it's a catch-all
thisToken = thisToken[1:]
if n.catchAllChild == nil {
Expand All @@ -125,7 +126,7 @@ func (n *node) addPath(path string, wildcards []string) *node {
n.catchAllChild.leafWildcardNames = wildcards

return n.catchAllChild
} else if c == ':' {
} else if c == ':' && !inStaticToken {
// Token starts with a :
thisToken = thisToken[1:]

Expand All @@ -139,22 +140,41 @@ func (n *node) addPath(path string, wildcards []string) *node {
n.wildcardChild = &node{path: "wildcard"}
}

return n.wildcardChild.addPath(remainingPath, wildcards)
return n.wildcardChild.addPath(remainingPath, wildcards, false)

} else {
if strings.ContainsAny(thisToken, ":*") {
panic("* or : in middle of path component " + path)
// if strings.ContainsAny(thisToken, ":*") {
// panic("* or : in middle of path component " + path)
// }

unescaped := false
if len(thisToken) >= 2 && !inStaticToken {
if thisToken[0] == '\\' && (thisToken[1] == '*' || thisToken[1] == ':' || thisToken[1] == '\\') {
// The token starts with a character escaped by a backslash. Drop the backslash.
c = thisToken[1]
thisToken = thisToken[1:]
unescaped = true
}
}

// Set inStaticToken to ensure that the rest of this token is not mistaken
// for a wildcard if a prefix split occurs at a '*' or ':'.
inStaticToken = (c != '/')

// Do we have an existing node that starts with the same letter?
for i, index := range n.staticIndices {
if c == index {
// Yes. Split it based on the common prefix of the existing
// node and the new one.
child, prefixSplit := n.splitCommonPrefix(i, thisToken)

child.priority++
n.sortStaticChild(i)
return child.addPath(path[prefixSplit:], wildcards)
if unescaped {
// Account for the removed backslash.
prefixSplit++
}
return child.addPath(path[prefixSplit:], wildcards, inStaticToken)
}
}

Expand All @@ -168,7 +188,7 @@ func (n *node) addPath(path string, wildcards []string) *node {
n.staticIndices = append(n.staticIndices, c)
n.staticChild = append(n.staticChild, child)
}
return child.addPath(remainingPath, wildcards)
return child.addPath(remainingPath, wildcards, inStaticToken)
}
}

Expand Down
60 changes: 28 additions & 32 deletions tree_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package httptreemux

import (
"net/http"
"strings"
"testing"
)

Expand All @@ -12,7 +11,7 @@ func dummyHandler(w http.ResponseWriter, r *http.Request, urlParams map[string]s

func addPath(t *testing.T, tree *node, path string) {
t.Logf("Adding path %s", path)
n := tree.addPath(path[1:], nil)
n := tree.addPath(path[1:], nil, false)
handler := func(w http.ResponseWriter, r *http.Request, urlParams map[string]string) {
urlParams["path"] = path
}
Expand All @@ -27,8 +26,6 @@ func testPath(t *testing.T, tree *node, path string, expectPath string, expected
t.FailNow()
}

expectCatchAll := strings.Contains(expectPath, "/*")

t.Log("Testing", path)
n, foundHandler, paramList := tree.search("GET", path[1:])
if expectPath != "" && n == nil {
Expand All @@ -44,10 +41,6 @@ func testPath(t *testing.T, tree *node, path string, expectPath string, expected
return
}

if expectCatchAll != n.isCatchAll {
t.Errorf("For path %s expectCatchAll %v but saw %v", path, expectCatchAll, n.isCatchAll)
}

handler, ok := n.leafHandler["GET"]
if !ok {
t.Errorf("Path %s returned node without handler", path)
Expand Down Expand Up @@ -123,6 +116,8 @@ func TestTree(t *testing.T) {
addPath(t, tree, "/images")
addPath(t, tree, "/images/abc.jpg")
addPath(t, tree, "/images/:imgname")
addPath(t, tree, "/images/\\*path")
addPath(t, tree, "/images/\\*patch")
addPath(t, tree, "/images/*path")
addPath(t, tree, "/ima")
addPath(t, tree, "/ima/:par")
Expand All @@ -133,6 +128,7 @@ func TestTree(t *testing.T) {
addPath(t, tree, "/apples1")
addPath(t, tree, "/appeasement")
addPath(t, tree, "/appealing")
addPath(t, tree, "/date/\\:year/\\:month")
addPath(t, tree, "/date/:year/:month")
addPath(t, tree, "/date/:year/month")
addPath(t, tree, "/date/:year/:month/abc")
Expand All @@ -146,6 +142,10 @@ func TestTree(t *testing.T) {
addPath(t, tree, "/users/:id/updatePassword")
addPath(t, tree, "/:something/abc")
addPath(t, tree, "/:something/def")
addPath(t, tree, "/apples/ab:cde/:fg/*hi")
addPath(t, tree, "/apples/ab*cde/:fg/*hi")
addPath(t, tree, "/apples/ab\\*cde/:fg/*hi")
addPath(t, tree, "/apples/ab*dde")

testPath(t, tree, "/users/abc/updatePassword", "/users/:id/updatePassword",
map[string]string{"id": "abc"})
Expand Down Expand Up @@ -197,6 +197,22 @@ func TestTree(t *testing.T) {
testPath(t, tree, "/post/ab%2fdef/page/2%2f", "/post/:post/page/:page",
map[string]string{"post": "ab/def", "page": "2/"})

// Test paths with escaped wildcard characters.
testPath(t, tree, "/images/*path", "/images/\\*path", nil)
testPath(t, tree, "/images/*patch", "/images/\\*patch", nil)
testPath(t, tree, "/date/:year/:month", "/date/\\:year/\\:month", nil)
testPath(t, tree, "/apples/ab*cde/lala/baba/dada", "/apples/ab*cde/:fg/*hi",
map[string]string{"fg": "lala", "hi": "baba/dada"})
testPath(t, tree, "/apples/ab\\*cde/lala/baba/dada", "/apples/ab\\*cde/:fg/*hi",
map[string]string{"fg": "lala", "hi": "baba/dada"})
testPath(t, tree, "/apples/ab:cde/:fg/*hi", "/apples/ab:cde/:fg/*hi",
map[string]string{"fg": ":fg", "hi": "*hi"})
testPath(t, tree, "/apples/ab*cde/:fg/*hi", "/apples/ab*cde/:fg/*hi",
map[string]string{"fg": ":fg", "hi": "*hi"})
testPath(t, tree, "/apples/ab*cde/one/two/three", "/apples/ab*cde/:fg/*hi",
map[string]string{"fg": "one", "hi": "two/three"})
testPath(t, tree, "/apples/ab*dde", "/apples/ab*dde", nil)

testPath(t, tree, "/ima/bcd/fgh", "", nil)
testPath(t, tree, "/date/2014//month", "", nil)
testPath(t, tree, "/date/2014/05/", "", nil) // Empty catchall should not match
Expand All @@ -209,7 +225,7 @@ func TestTree(t *testing.T) {
t.Log("Test retrieval of duplicate paths")
params := make(map[string]string)
p := "date/:year/:month/abc"
n := tree.addPath(p, nil)
n := tree.addPath(p, nil, false)
if n == nil {
t.Errorf("Duplicate add of %s didn't return a node", p)
} else {
Expand Down Expand Up @@ -247,7 +263,7 @@ func TestPanics(t *testing.T) {
defer panicHandler()
tree := &node{path: "/"}
for _, path := range p {
tree.addPath(path, nil)
tree.addPath(path, nil, false)
}
}

Expand Down Expand Up @@ -277,26 +293,6 @@ func TestPanics(t *testing.T) {
t.Error("Expected panic when adding a duplicate handler for a pattern")
}

addPathPanic("abc/ab:cd")
if !sawPanic {
t.Error("Expected panic with : in middle of path segment")
}

addPathPanic("abc/ab", "abc/ab:cd")
if !sawPanic {
t.Error("Expected panic with : in middle of path segment with existing path")
}

addPathPanic("abc/ab*cd")
if !sawPanic {
t.Error("Expected panic with * in middle of path segment")
}

addPathPanic("abc/ab", "abc/ab*cd")
if !sawPanic {
t.Error("Expected panic with * in middle of path segment with existing path")
}

twoPathPanic := func(first, second string) {
addPathPanic(first, second)
if !sawPanic {
Expand Down Expand Up @@ -333,7 +329,7 @@ func BenchmarkTreeOneStatic(b *testing.B) {
"GET": dummyHandler,
},
}
tree.addPath("abc", nil)
tree.addPath("abc", nil, false)

b.ResetTimer()
for i := 0; i < b.N; i++ {
Expand All @@ -349,7 +345,7 @@ func BenchmarkTreeOneParam(b *testing.B) {
},
}
b.ReportAllocs()
tree.addPath(":abc", nil)
tree.addPath(":abc", nil, false)

b.ResetTimer()
for i := 0; i < b.N; i++ {
Expand Down

0 comments on commit 4fdc8a5

Please sign in to comment.