Skip to content

Commit

Permalink
Add support for infix wildcard (#46)
Browse files Browse the repository at this point in the history
* feat: wip infix wildcard

* feat: wip test are passing

* feat: wip test are passing

* feat: fix not using interNode from pool

* feat: remove unused function

* feat: minor API improvement

* feat: wip infix wildcard

* feat: cleanup infix wildcard

* feat: improve test coverage

* feat: improve test coverage

* feat: fix lint

* feat: use const for star delim

* feat: update README

* feat: update README

* feat: fix comment
  • Loading branch information
tigerwill90 authored Nov 6, 2024
1 parent 3bea8fb commit c8befa2
Show file tree
Hide file tree
Showing 11 changed files with 1,827 additions and 508 deletions.
68 changes: 47 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,39 +91,57 @@ if errors.Is(err, fox.ErrRouteConflict) {
```

#### Named parameters
A route can be defined using placeholder (e.g `{name}`). The matching segment are recorder into `fox.Param` accessible
via `fox.Context`. `fox.Context.Params` provide an iterator to range over `fox.Param` and `fox.Context.Param` allow
to retrieve directly the value of a parameter using the placeholder name.
Routes can include named parameters using curly braces `{}` to match exactly one non-empty path segment. The matching
segment are recorder into `fox.Param` accessible via `fox.Context`. `fox.Context.Params` provide an iterator to range
over `fox.Param` and `fox.Context.Param` allow to retrieve directly the value of a parameter using the placeholder name.

````
Pattern /avengers/{name}
/avengers/ironman match
/avengers/thor match
/avengers/hulk/angry no match
/avengers/ no match
/avengers/ironman matches
/avengers/thor matches
/avengers/hulk/angry no matches
/avengers/ no matches
Pattern /users/uuid:{id}
/users/uuid:123 match
/users/uuid no match
/users/uuid:123 matches
/users/uuid no matches
````

#### Catch all parameter
Catch-all parameters can be used to match everything at the end of a route. The placeholder start with `*` followed by a regular
named parameter (e.g. `*{name}`).
Catch-all parameters start with an asterisk `*` followed by a name `{param}` and match one or more **non-empty** path segments,
including slashes. They can be placed anywhere in the route path but **cannot be consecutive**. The matching segment are also
accessible via `fox.Context`

**Example with ending catch all**
````
Pattern /src/*{filepath}
/src/ match
/src/conf.txt match
/src/dir/config.txt match
/src/conf.txt matches
/src/dir/config.txt matches
/src/ no matches
Pattern /src/file=*{path}
/src/file=config.txt matches
/src/file=/dir/config.txt matches
/src/file= no matches
````

**Example with infix catch all**
````
Pattern: /assets/*{path}/thumbnail
/assets/images/thumbnail matches
/assets/photos/2021/thumbnail matches
/assets/thumbnail no matches
Patter /src/file=*{path}
Pattern: /assets/path:*{path}/thumbnail
/src/file= match
/src/file=config.txt match
/src/file=/dir/config.txt match
/assets/path:images/thumbnail matches
/assets/path:photos/2021/thumbnail matches
/assets/path:thumbnail no matches
````

#### Priority rules
Expand Down Expand Up @@ -151,9 +169,17 @@ POST /users/{name}/emails

Additionally, let's consider an example to illustrate the prioritization:
````
GET /fs/avengers.txt #1 => match /fs/avengers.txt
GET /fs/{filename} #2 => match /fs/ironman.txt
GET /fs/*{filepath} #3 => match /fs/avengers/ironman.txt
Route Definitions:
1. GET /fs/avengers.txt # Highest priority (static)
2. GET /fs/{filename} # Next priority (named parameter)
3. GET /fs/*{filepath} # Lowest priority (catch-all parameter)
Request Matching:
- /fs/avengers.txt matches Route 1
- /fs/ironman.txt matches Route 2
- /fs/avengers/ironman.txt matches Route 3
````

#### Warning about context
Expand Down
11 changes: 0 additions & 11 deletions context.go
Original file line number Diff line number Diff line change
Expand Up @@ -402,17 +402,6 @@ func (c *cTx) CloneWith(w ResponseWriter, r *http.Request) ContextCloser {
return cp
}

func copyParams(src, dst *Params) {
if cap(*src) > cap(*dst) {
// Grow dst to a least cap(src)
*dst = slices.Grow(*dst, cap(*src))
}
// cap(dst) >= cap(src)
// now constraint into len(src) & cap(src)
*dst = (*dst)[:len(*src):cap(*src)]
copy(*dst, *src)
}

// Scope returns the HandlerScope associated with the current Context.
// This indicates the scope in which the handler is being executed, such as RouteHandler, NoRouteHandler, etc.
func (c *cTx) Scope() HandlerScope {
Expand Down
4 changes: 2 additions & 2 deletions context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,11 +210,11 @@ func TestContext_Annotations(t *testing.T) {
"/foo",
emptyHandler,
WithAnnotations(Annotation{Key: "foo", Value: "bar"}, Annotation{Key: "foo", Value: "baz"}),
WithAnnotation("john", 1),
WithAnnotations(Annotation{Key: "john", Value: 1}),
)
rte := f.Tree().Route(http.MethodGet, "/foo")
require.NotNil(t, rte)
assert.Equal(t, []Annotation{{"foo", "bar"}, {"foo", "baz"}, {"john", 1}}, slices.Collect(rte.Annotations()))
assert.Equal(t, []Annotation{{Key: "foo", Value: "bar"}, {Key: "foo", Value: "baz"}, {Key: "john", Value: 1}}, slices.Collect(rte.Annotations()))
}

func TestContext_Clone(t *testing.T) {
Expand Down
5 changes: 1 addition & 4 deletions error.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,7 @@ type RouteConflictError struct {
isUpdate bool
}

func newConflictErr(method, path, catchAllKey string, matched []string) *RouteConflictError {
if catchAllKey != "" {
path += "*{" + catchAllKey + "}"
}
func newConflictErr(method, path string, matched []string) *RouteConflictError {
return &RouteConflictError{
Method: method,
Path: path,
Expand Down
112 changes: 65 additions & 47 deletions fox.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package fox

import (
"fmt"
"math"
"net"
"net/http"
"path"
Expand Down Expand Up @@ -343,11 +344,11 @@ func (fox *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {

nds := *tree.nodes.Load()
index := findRootNode(r.Method, nds)
if index < 0 {
if index < 0 || len(nds[index].children) == 0 {
goto NoMethodFallback
}

n, tsr = tree.lookup(nds[index], target, c, false)
n, tsr = tree.lookup(nds[index].children[0].Load(), target, c, false)
if !tsr && n != nil {
c.route = n.route
c.tsr = tsr
Expand Down Expand Up @@ -404,11 +405,13 @@ NoMethodFallback:
} else {
// Since different method and route may match (e.g. GET /foo/bar & POST /foo/{name}), we cannot set the path and params.
for i := 0; i < len(nds); i++ {
if n, tsr := tree.lookup(nds[i], target, c, true); n != nil && (!tsr || n.route.ignoreTrailingSlash) {
if sb.Len() > 0 {
sb.WriteString(", ")
if len(nds[i].children) > 0 {
if n, tsr := tree.lookup(nds[i].children[0].Load(), target, c, true); n != nil && (!tsr || n.route.ignoreTrailingSlash) {
if sb.Len() > 0 {
sb.WriteString(", ")
}
sb.WriteString(nds[i].key)
}
sb.WriteString(nds[i].key)
}
}
}
Expand All @@ -425,15 +428,18 @@ NoMethodFallback:
var sb strings.Builder
for i := 0; i < len(nds); i++ {
if nds[i].key != r.Method {
if n, tsr := tree.lookup(nds[i], target, c, true); n != nil && (!tsr || n.route.ignoreTrailingSlash) {
if sb.Len() > 0 {
sb.WriteString(", ")
if len(nds[i].children) > 0 {
if n, tsr := tree.lookup(nds[i].children[0].Load(), target, c, true); n != nil && (!tsr || n.route.ignoreTrailingSlash) {
if sb.Len() > 0 {
sb.WriteString(", ")
}
sb.WriteString(nds[i].key)
}
sb.WriteString(nds[i].key)
}
}
}
if sb.Len() > 0 {
// TODO maybe should add OPTIONS ?
w.Header().Set(HeaderAllow, sb.String())
c.scope = NoMethodHandler
fox.noMethod(c)
Expand Down Expand Up @@ -536,16 +542,16 @@ const (
)

// parseRoute parse and validate the route in a single pass.
func parseRoute(path string) (string, string, int, error) {
func parseRoute(path string) (uint32, error) {

if !strings.HasPrefix(path, "/") {
return "", "", -1, fmt.Errorf("%w: path must start with '/'", ErrInvalidRoute)
return 0, fmt.Errorf("%w: path must start with '/'", ErrInvalidRoute)
}

state := stateDefault
previous := stateDefault
startCatchAll := 0
paramCnt := 0
paramCnt := uint32(0)
countStatic := 0
inParam := false

i := 0
Expand All @@ -554,75 +560,87 @@ func parseRoute(path string) (string, string, int, error) {
case stateParam:
if path[i] == '}' {
if !inParam {
return "", "", -1, fmt.Errorf("%w: missing parameter name between '{}'", ErrInvalidRoute)
return 0, fmt.Errorf("%w: missing parameter name between '{}'", ErrInvalidRoute)
}
inParam = false
if previous != stateCatchAll {
if i+1 < len(path) && path[i+1] != '/' {
return "", "", -1, fmt.Errorf("%w: unexpected character after '{param}'", ErrInvalidRoute)
}
} else {
if i+1 != len(path) {
return "", "", -1, fmt.Errorf("%w: catch-all '*{params}' are allowed only at the end of a route", ErrInvalidRoute)
}
if i+1 < len(path) && path[i+1] != '/' {
return 0, fmt.Errorf("%w: unexpected character after '{param}'", ErrInvalidRoute)
}

countStatic = 0
previous = state
state = stateDefault
i++
continue
}

if path[i] == '/' || path[i] == '*' || path[i] == '{' {
return "", "", -1, fmt.Errorf("%w: unexpected character in '{params}'", ErrInvalidRoute)
return 0, fmt.Errorf("%w: unexpected character in '{params}'", ErrInvalidRoute)
}
inParam = true
i++

case stateCatchAll:
if path[i] != '{' {
return "", "", -1, fmt.Errorf("%w: unexpected character after '*' catch-all delimiter", ErrInvalidRoute)
if path[i] == '}' {
if !inParam {
return 0, fmt.Errorf("%w: missing parameter name between '*{}'", ErrInvalidRoute)
}
inParam = false
if i+1 < len(path) && path[i+1] != '/' {
return 0, fmt.Errorf("%w: unexpected character after '*{param}'", ErrInvalidRoute)
}

if previous == stateCatchAll && countStatic <= 1 {
return 0, fmt.Errorf("%w: consecutive wildcard not allowed", ErrInvalidRoute)
}

countStatic = 0
previous = state
state = stateDefault
i++
continue
}
startCatchAll = i
previous = state
state = stateParam
i++

if path[i] == '/' || path[i] == '*' || path[i] == '{' {
return 0, fmt.Errorf("%w: unexpected character in '*{params}'", ErrInvalidRoute)
}
inParam = true
i++
default:
if path[i] == '{' {
state = stateParam
paramCnt++
} else if path[i] == '*' {
state = stateCatchAll
i++
paramCnt++
} else {
countStatic++
}

if paramCnt > math.MaxUint16 {
return 0, fmt.Errorf("%w: too many params (%d)", ErrInvalidRoute, paramCnt)
}

i++
}
}

if state == stateParam {
return "", "", -1, fmt.Errorf("%w: unclosed '{params}'", ErrInvalidRoute)
}
if state == stateCatchAll {
return "", "", -1, fmt.Errorf("%w: missing '{params}' after '*' catch-all delimiter", ErrInvalidRoute)
return 0, fmt.Errorf("%w: unclosed '{params}'", ErrInvalidRoute)
}

if startCatchAll > 0 {
return path[:startCatchAll-1], path[startCatchAll+1 : len(path)-1], paramCnt, nil
if state == stateCatchAll {
if path[len(path)-1] == '*' {
return 0, fmt.Errorf("%w: missing '{params}' after '*' catch-all delimiter", ErrInvalidRoute)
}
return 0, fmt.Errorf("%w: unclosed '*{params}'", ErrInvalidRoute)
}

return path, "", paramCnt, nil
return paramCnt, nil
}

func getRouteConflict(n *node) []string {
routes := make([]string, 0)

if n.isCatchAll() {
routes = append(routes, n.route.path)
return routes
}

if n.paramChildIndex >= 0 {
n = n.children[n.paramChildIndex].Load()
}
it := newRawIterator(n)
for it.hasNext() {
routes = append(routes, it.current.route.path)
Expand Down
Loading

0 comments on commit c8befa2

Please sign in to comment.