Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for infix wildcard #46

Merged
merged 15 commits into from
Nov 6, 2024
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 @@

import (
"fmt"
"math"
"net"
"net/http"
"path"
Expand Down Expand Up @@ -343,11 +344,11 @@

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 @@
} 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 @@
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 @@
)

// 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 @@
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)
}

Check warning on line 605 in fox.go

View check run for this annotation

Codecov / codecov/patch

fox.go#L604-L605

Added lines #L604 - L605 were not covered by tests
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)

Check warning on line 621 in fox.go

View check run for this annotation

Codecov / codecov/patch

fox.go#L621

Added line #L621 was not covered by tests
}

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
Loading