Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,9 +249,10 @@ type Routes interface {
```

Each routing method accepts a URL `pattern` and chain of `handlers`. The URL pattern
supports named params (ie. `/users/{userID}`) and wildcards (ie. `/admin/*`). URL parameters
can be fetched at runtime by calling `chi.URLParam(r, "userID")` for named parameters
and `chi.URLParam(r, "*")` for a wildcard parameter.
supports named params (ie. `/users/{userID}`), named path params that can contain slashes
(ie. `/files/{path:*}` or `/files/{path:+}`), and wildcards (ie. `/admin/*`). URL
parameters can be fetched at runtime by calling `chi.URLParam(r, "userID")` for named
parameters and `chi.URLParam(r, "*")` for a wildcard parameter.


### Middleware handlers
Expand Down
10 changes: 8 additions & 2 deletions chi.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,21 @@
// matched. An anonymous regexp pattern is allowed, using an empty string
// before the colon in the placeholder, such as {:\\d+}
//
// A placeholder with a name followed by :* matches zero or more path
// segments, including / characters, up to the next matching segment in
// the route. A placeholder with :+ matches one or more non-empty path
// segments. Either form may be suffixed with ? to make the match
// non-greedy, such as {name:*?} or {name:+?}.
//
// The special placeholder of asterisk matches the rest of the requested
// URL. Any trailing characters in the pattern are ignored. This is the only
// placeholder which will match / characters.
// URL. Any trailing characters in the pattern are ignored.
//
// Examples:
//
// "/user/{name}" matches "/user/jsmith" but not "/user/jsmith/info" or "/user/jsmith/"
// "/user/{name}/info" matches "/user/jsmith/info"
// "/page/*" matches "/page/intro/latest"
// "/page/{name:*}/latest" matches "/page/intro/latest"
// "/page/{other}/latest" also matches "/page/intro/latest"
// "/date/{yyyy:\\d\\d\\d\\d}/{mm:\\d\\d}/{dd:\\d\\d}" matches "/date/2017/04/01"
package chi
Expand Down
39 changes: 39 additions & 0 deletions mux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1650,6 +1650,45 @@ func TestMuxRegexp3(t *testing.T) {
}
}

func TestMuxNamedPathParam(t *testing.T) {
r := NewRouter()
r.Get("/foo/bar/{other-stuff:*}/fizz/buzz", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(URLParam(r, "other-stuff")))
})

ts := httptest.NewServer(r)
defer ts.Close()

if _, body := testRequest(t, ts, "GET", "/foo/bar/one/two/three/fizz/buzz", nil); body != "one/two/three" {
t.Fatalf("expecting %q, got %q", "one/two/three", body)
}
if _, body := testRequest(t, ts, "GET", "/foo/bar/fizz/buzz", nil); body != "" {
t.Fatalf("expecting empty body, got %q", body)
}
}

func TestMuxNamedPathParamInNestedRoute(t *testing.T) {
r := NewRouter()
r.Route("/stuff", func(r Router) {
r.Route("/{owner}/{group:*}/{repo}", func(r Router) {
r.Route("/src", func(r Router) {
r.Route("/branch/{branch:*}", func(r Router) {
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "%s:%s:%s:%s", URLParam(r, "owner"), URLParam(r, "group"), URLParam(r, "repo"), URLParam(r, "branch"))
})
})
})
})
})

ts := httptest.NewServer(r)
defer ts.Close()

if _, body := testRequest(t, ts, "GET", "/stuff/owner/foo/repo/src/branch/feature", nil); body != "owner:foo:repo:feature" {
t.Fatalf("expecting %q, got %q", "owner:foo:repo:feature", body)
}
}

func TestMuxSubrouterWildcardParam(t *testing.T) {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "param:%v *:%v", URLParam(r, "param"), URLParam(r, "*"))
Expand Down
20 changes: 19 additions & 1 deletion path_value_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ func TestPathValue(t *testing.T) {
requestPath string
expectedBody string
pathKeys []string
allowEmpty bool
}{
{
name: "Basic path value",
Expand All @@ -40,6 +41,23 @@ func TestPathValue(t *testing.T) {
requestPath: "/users/Gojo/friends/all-of-them/and/more",
expectedBody: "Gojo all-of-them/and/more",
},
{
name: "Named path value with slashes",
pattern: "/foo/bar/{other-stuff:*}/fizz/buzz",
method: "GET",
pathKeys: []string{"other-stuff"},
requestPath: "/foo/bar/one/two/three/fizz/buzz",
expectedBody: "one/two/three",
},
{
name: "Empty named path value",
pattern: "/foo/bar/{other-stuff:*}/fizz/buzz",
method: "GET",
pathKeys: []string{"other-stuff"},
requestPath: "/foo/bar/fizz/buzz",
expectedBody: "",
allowEmpty: true,
},
}

for _, tc := range testCases {
Expand All @@ -50,7 +68,7 @@ func TestPathValue(t *testing.T) {
pathValues := []string{}
for _, pathKey := range tc.pathKeys {
pathValue := r.PathValue(pathKey)
if pathValue == "" {
if pathValue == "" && !tc.allowEmpty {
pathValue = "NOT_FOUND:" + pathKey
}

Expand Down
194 changes: 185 additions & 9 deletions tree.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,14 @@ func RegisterMethod(method string) {
type nodeTyp uint8

const (
ntStatic nodeTyp = iota // /home
ntRegexp // /{id:[0-9]+}
ntParam // /{user}
ntCatchAll // /api/v1/*
ntStatic nodeTyp = iota // /home
ntRegexp // /{id:[0-9]+}
ntParam // /{user}
ntPathParamOne // /{path:+}
ntPathParamOneNonGreedy // /{path:+?}
ntPathParam // /{path:*}
ntPathParamNonGreedy // /{path:*?}
ntCatchAll // /api/v1/*
)

type node struct {
Expand All @@ -105,7 +109,7 @@ type node struct {
// first byte of the child prefix
tail byte

// node type: static, regexp, param, catchAll
// node type: static, regexp, param, pathParam, catchAll
typ nodeTyp

// first byte of the prefix
Expand Down Expand Up @@ -492,6 +496,56 @@ func (n *node) findRoute(rctx *Context, method methodTyp, path string) *node {

rctx.routeParams.Values = append(rctx.routeParams.Values, "")

case ntPathParamOne, ntPathParamOneNonGreedy, ntPathParam, ntPathParamNonGreedy:
for _, xn = range nds {
prevlen := len(rctx.routeParams.Values)
prevKeyLen := len(rctx.routeParams.Keys)
var fallback *node
var fallbackValues []string
var fallbackKeys []string
var subroute *node
var subrouteValues []string
var subrouteKeys []string
for _, p := range pathParamCandidates(xsearch, xn.tail, ntyp) {
if fin := xn.findRoutePathParamCandidate(rctx, method, xsearch, p, prevlen); fin != nil {
if endpointPatternEndsWithWildcard(fin.endpoints[method]) {
if fin.subroutes != nil {
if subroutesMatchRoutePath(fin.subroutes, method, routeParamWildcardValue(rctx.routeParams)) {
return fin
}
if subroute == nil {
subroute = fin
subrouteValues = append(subrouteValues[:0], rctx.routeParams.Values...)
subrouteKeys = append(subrouteKeys[:0], rctx.routeParams.Keys...)
}
rctx.routeParams.Values = rctx.routeParams.Values[:prevlen]
rctx.routeParams.Keys = rctx.routeParams.Keys[:prevKeyLen]
continue
}
return fin
}
if fallback == nil {
fallback = fin
fallbackValues = append(fallbackValues[:0], rctx.routeParams.Values...)
fallbackKeys = append(fallbackKeys[:0], rctx.routeParams.Keys...)
}
rctx.routeParams.Values = rctx.routeParams.Values[:prevlen]
rctx.routeParams.Keys = rctx.routeParams.Keys[:prevKeyLen]
}
}
if fallback != nil {
rctx.routeParams.Values = append(rctx.routeParams.Values[:prevlen], fallbackValues[prevlen:]...)
rctx.routeParams.Keys = append(rctx.routeParams.Keys[:prevKeyLen], fallbackKeys[prevKeyLen:]...)
return fallback
}
if subroute != nil {
rctx.routeParams.Values = append(rctx.routeParams.Values[:prevlen], subrouteValues[prevlen:]...)
rctx.routeParams.Keys = append(rctx.routeParams.Keys[:prevKeyLen], subrouteKeys[prevKeyLen:]...)
return subroute
}
}
continue

default:
// catch-all nodes
rctx.routeParams.Values = append(rctx.routeParams.Values, search)
Expand Down Expand Up @@ -543,13 +597,124 @@ func (n *node) findRoute(rctx *Context, method methodTyp, path string) *node {
return nil
}

func endpointPatternEndsWithWildcard(e *endpoint) bool {
return e != nil && strings.HasSuffix(e.pattern, "/*")
}

func routeParamWildcardValue(params RouteParams) string {
if len(params.Values) == 0 {
return ""
}
return params.Values[len(params.Values)-1]
}

func subroutesMatchRoutePath(subroutes Routes, method methodTyp, routePath string) bool {
if subroutes == nil {
return false
}
if routePath == "" {
routePath = "/"
} else {
routePath = "/" + routePath
}

if methodName, ok := reverseMethodMap[method]; ok {
if subroutes.Match(NewRouteContext(), methodName, routePath) {
return true
}
}
for _, methodName := range reverseMethodMap {
if subroutes.Match(NewRouteContext(), methodName, routePath) {
return true
}
}
return false
}

func (n *node) findRoutePathParamCandidate(rctx *Context, method methodTyp, search string, p, prevlen int) *node {
rctx.routeParams.Values = append(rctx.routeParams.Values, search[:p])
xsearch := search[p:]
if p == 0 && n.tail == '/' && len(search) > 0 && search[0] != '/' {
xsearch = "/" + xsearch
}

if len(xsearch) == 0 {
if n.isLeaf() {
h := n.endpoints[method]
if h != nil && h.handler != nil {
rctx.routeParams.Keys = append(rctx.routeParams.Keys, h.paramKeys...)
return n
}

for endpoints := range n.endpoints {
if endpoints == mALL || endpoints == mSTUB {
continue
}
rctx.methodsAllowed = append(rctx.methodsAllowed, endpoints)
}

// flag that the routing context found a route, but not a corresponding
// supported method
rctx.methodNotAllowed = true
}
}

fin := n.findRoute(rctx, method, xsearch)
if fin != nil {
return fin
}

rctx.routeParams.Values = rctx.routeParams.Values[:prevlen]
return nil
}

func pathParamCandidates(search string, tail byte, ntyp nodeTyp) []int {
oneOrMore := ntyp == ntPathParamOne || ntyp == ntPathParamOneNonGreedy
nonGreedy := ntyp == ntPathParamOneNonGreedy || ntyp == ntPathParamNonGreedy
candidates := []int{}

if !oneOrMore {
candidates = append(candidates, 0)
}

for p := 0; p < len(search); p++ {
if p == 0 && !oneOrMore {
continue
}
if search[p] == tail && (!oneOrMore || hasNonEmptyPathSegment(search[:p])) {
candidates = append(candidates, p)
}
}

if len(search) > 0 && (!oneOrMore || hasNonEmptyPathSegment(search)) {
candidates = append(candidates, len(search))
}

if !nonGreedy {
for i, j := 0, len(candidates)-1; i < j; i, j = i+1, j-1 {
candidates[i], candidates[j] = candidates[j], candidates[i]
}
}

return candidates
}

func hasNonEmptyPathSegment(value string) bool {
for _, segment := range strings.Split(value, "/") {
if segment != "" {
return true
}
}
return false
}

func (n *node) findEdge(ntyp nodeTyp, label byte) *node {
nds := n.children[ntyp]
num := len(nds)
idx := 0

switch ntyp {
case ntStatic, ntParam, ntRegexp:
case ntStatic, ntParam, ntRegexp, ntPathParamOne, ntPathParamOneNonGreedy, ntPathParam, ntPathParamNonGreedy:
i, j := 0, num-1
for i <= j {
idx = i + (j-i)/2
Expand Down Expand Up @@ -597,7 +762,7 @@ func (n *node) findPattern(pattern string) bool {
continue
}

case ntParam, ntRegexp:
case ntParam, ntRegexp, ntPathParamOne, ntPathParamOneNonGreedy, ntPathParam, ntPathParamNonGreedy:
idx = strings.IndexByte(pattern, '}') + 1

case ntCatchAll:
Expand Down Expand Up @@ -730,10 +895,21 @@ func patNextSegment(pattern string) (nodeTyp, string, string, byte, int, int) {

key, rexpat, isRegexp := strings.Cut(key, ":")
if isRegexp {
nt = ntRegexp
switch rexpat {
case "*":
nt = ntPathParam
case "*?":
nt = ntPathParamNonGreedy
case "+":
nt = ntPathParamOne
case "+?":
nt = ntPathParamOneNonGreedy
default:
nt = ntRegexp
}
}

if len(rexpat) > 0 {
if nt == ntRegexp && len(rexpat) > 0 {
if rexpat[0] != '^' {
rexpat = "^" + rexpat
}
Expand Down
Loading