diff --git a/context.go b/context.go index b41f978..ede0ccc 100644 --- a/context.go +++ b/context.go @@ -92,10 +92,14 @@ type Context interface { Tree() *Tree // Fox returns the Router instance. Fox() *Router - // Rehydrate try to rehydrate the Context with the providing ResponseWriter, http.Request and route which is then suitable - // to use for calling the handler of the provided route. The state of the context is only updated if it succeed. - // TODO improve documentation - Rehydrate(w ResponseWriter, r *http.Request, route *Route) bool + // Rehydrate updates the current Context to serve the provided Route, bypassing the need for a full tree lookup. + // It succeeds only if the Request's URL path strictly matches the given Route. If successful, the internal state + // of the context is updated, allowing the context to serve the route directly, regardless of whether the route + // still exists in the routing tree. This provides a key advantage in concurrent scenarios where routes may be + // modified by other threads, as Rehydrate guarantees success if the path matches, without requiring serial execution + // or tree lookups. Note that the context's state is only mutated if the rehydration is successful. + // This api is EXPERIMENTAL and is likely to change in future release. + Rehydrate(route *Route) bool } // cTx holds request-related information and allows interaction with the ResponseWriter. @@ -128,123 +132,40 @@ func (c *cTx) Reset(w ResponseWriter, r *http.Request) { *c.params = (*c.params)[:0] } -func (c *cTx) Rehydrate(w ResponseWriter, r *http.Request, route *Route) bool { +// Rehydrate updates the current Context to serve the provided Route, bypassing the need for a full tree lookup. +// It succeeds only if the Request's URL path strictly matches the given Route. If successful, the internal state +// of the context is updated, allowing the context to serve the route directly, regardless of whether the route +// still exists in the routing tree. This provides a key advantage in concurrent scenarios where routes may be +// modified by other threads, as Rehydrate guarantees success if the path matches, without requiring serial execution +// or tree lookups. Note that the context's state is only mutated if the rehydration is successful. +// This api is EXPERIMENTAL and is likely to change in future release. +func (c *cTx) Rehydrate(route *Route) bool { - target := r.URL.Path - if len(r.URL.RawPath) > 0 { + target := c.req.URL.Path + if len(c.req.URL.RawPath) > 0 { // Using RawPath to prevent unintended match (e.g. /search/a%2Fb/1) - target = r.URL.RawPath + target = c.req.URL.RawPath } - // This was a static route, too easy. - if target == route.path { - c.req = r - c.w = w - c.tsr = false - c.cachedQuery = nil - c.route = route - c.scope = RouteHandler + var params *Params + if c.tsr { *c.params = (*c.params)[:0] - - return true - } - - var params Params - maxParams := c.tree.maxParams.Load() - if len(*c.params) == 0 { - params = *c.params - } else if len(*c.tsrParams) == 0 { - params = *c.tsrParams - } else if maxParams < 10 { - params = make(Params, 0, 10) // stack allocation + params = c.params } else { - params = make(Params, 0, maxParams) + *c.tsrParams = (*c.tsrParams)[:0] + params = c.tsrParams } - _ = params - - return true -} - -func hydrateParams(path string, route string, params *Params) bool { - // Note that we assume that this is a valid route (validated with parseRoute). - rLen := len(route) - pLen := len(path) - var i, j int - state := stateDefault - -OUTER: - for i < rLen && j < pLen { - switch state { - case stateParam: - - ri := string(route[i]) - _ = ri - pj := string(path[j]) - _ = pj - - startPath := j - idx := strings.IndexByte(path[j:], slashDelim) - if idx > 0 { - j += idx - } else if idx < 0 { - j += len(path[j:]) - } else { - // segment is empty - return false - } - - startRoute := i - idx = strings.IndexByte(route[i:], slashDelim) - if idx >= 0 { - i += idx - } else { - i += len(route[i:]) - } - - *params = append(*params, Param{ - Key: route[startRoute : i-1], - Value: path[startPath:j], - }) - - state = stateDefault - default: - - ri := string(route[i]) - _ = ri - pj := string(path[j]) - _ = pj - - if route[i] == path[j] { - i++ - j++ - continue - } - - if route[i] == '{' { - i++ - state = stateParam - continue - } - - if route[i] == '*' { - state = stateCatchAll - break OUTER - } - - return false - } + if !route.hydrateParams(target, params) { + return false } - if state == stateCatchAll || route[i] == '*' { - *params = append(*params, Param{ - Key: route[i+2 : rLen-1], - Value: path[j:], - }) - return true - } + *c.params, *c.tsrParams = *c.tsrParams, *c.params + c.cachedQuery = nil + c.route = route + c.scope = RouteHandler - return i == rLen && j == pLen + return true } // reset resets the Context to its initial state, attaching the provided http.ResponseWriter and http.Request. diff --git a/context_test.go b/context_test.go index 3dad54c..bb26ae7 100644 --- a/context_test.go +++ b/context_test.go @@ -6,7 +6,6 @@ package fox import ( "bytes" - "fmt" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "io" @@ -17,6 +16,110 @@ import ( "testing" ) +func TestContext_Rehydrate(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "https://example.com/foo/bar/baz", nil) + w := httptest.NewRecorder() + + c := NewTestContextOnly(New(), w, req) + cTx := unwrapContext(t, c) + + cases := []struct { + name string + route *Route + tsr bool + want bool + wantParams Params + }{ + { + name: "succeed using tsr params", + route: &Route{ + path: "/foo/{$1}/{$2}", + }, + tsr: false, + want: true, + wantParams: Params{ + { + Key: "$1", + Value: "bar", + }, + { + Key: "$2", + Value: "baz", + }, + }, + }, + { + name: "succeed using params", + route: &Route{ + path: "/foo/{$1}/{$2}", + }, + tsr: true, + want: true, + wantParams: Params{ + { + Key: "$1", + Value: "bar", + }, + { + Key: "$2", + Value: "baz", + }, + }, + }, + { + name: "fail using tsr params", + route: &Route{ + path: "/foo/{$1}/bili", + }, + tsr: false, + want: false, + wantParams: Params{ + { + Key: "old", + Value: "params", + }, + }, + }, + { + name: "fail using params", + route: &Route{ + path: "/foo/{$1}/bili", + }, + tsr: true, + want: false, + wantParams: Params{ + { + Key: "old", + Value: "tsrParams", + }, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + *cTx.params = Params{{Key: "old", Value: "params"}} + *cTx.tsrParams = Params{{Key: "old", Value: "tsrParams"}} + cTx.tsr = tc.tsr + cTx.cachedQuery = url.Values{"old": []string{"old"}} + cTx.route = nil + cTx.scope = NoRouteHandler + got := c.Rehydrate(tc.route) + require.Equal(t, tc.want, got) + assert.Equal(t, tc.wantParams, Params(slices.Collect(c.Params()))) + if got { + assert.Equal(t, RouteHandler, c.Scope()) + assert.Equal(t, tc.route, c.Route()) + assert.Nil(t, cTx.cachedQuery) + } else { + assert.Equal(t, NoRouteHandler, c.Scope()) + assert.Nil(t, c.Route()) + assert.Equal(t, url.Values{"old": []string{"old"}}, cTx.cachedQuery) + } + }) + } +} + func TestContext_Writer_ReadFrom(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "https://example.com/foo", nil) w := httptest.NewRecorder() @@ -108,19 +211,6 @@ func TestContext_Tags(t *testing.T) { assert.Equal(t, []string{"foo", "bar", "baz"}, slices.Collect(rte.Tags())) } -func TestContext_Tag(t *testing.T) { - t.Parallel() - f := New() - f.MustHandle(http.MethodGet, "/foo", emptyHandler, WithTags("foo", "bar", "baz")) - rte := f.Tree().Route(http.MethodGet, "/foo") - require.NotNil(t, rte) - assert.True(t, rte.Tag("foo")) - assert.True(t, rte.Tag("bar")) - assert.True(t, rte.Tag("baz")) - assert.True(t, rte.Tag("ba*")) - assert.False(t, rte.Tag("boulou")) -} - func TestContext_Clone(t *testing.T) { t.Parallel() wantValues := url.Values{ @@ -492,9 +582,19 @@ func TestWrapH(t *testing.T) { } } -func TestHydrateParams(t *testing.T) { +func BenchmarkContext_Rehydrate(b *testing.B) { + req := httptest.NewRequest(http.MethodGet, "/foo/ab:1/baz/123/y/bo/lo", nil) + w := httptest.NewRecorder() - params := make(Params, 0) - fmt.Println(hydrateParams("/foo/ab:1/baz/123/y/bo/lo", "/foo/ab:{bar}/baz/{x}/{y}/*{zo}", ¶ms)) - fmt.Println(params) + f := New() + f.MustHandle(http.MethodGet, "/foo/ab:{bar}/baz/{x}/{y}/*{zo}", emptyHandler) + rte, c, _ := f.Lookup(&recorder{ResponseWriter: w}, req) + defer c.Close() + + b.ResetTimer() + b.ReportAllocs() + + for range b.N { + c.Rehydrate(rte) + } } diff --git a/fox.go b/fox.go index 6ac6194..f9613da 100644 --- a/fox.go +++ b/fox.go @@ -6,12 +6,10 @@ package fox import ( "fmt" - "iter" "net" "net/http" "path" "regexp" - "slices" "strconv" "strings" "sync" @@ -87,87 +85,6 @@ const ( AllHandlers = RouteHandler | NoRouteHandler | NoMethodHandler | RedirectHandler | OptionsHandler ) -// Route represent a registered route in the route tree. -// Most of the Route API is EXPERIMENTAL and is likely to change in future release. -type Route struct { - ipStrategy ClientIPStrategy - hbase HandlerFunc - hself HandlerFunc - hall HandlerFunc - path string - mws []middleware - tags []string - redirectTrailingSlash bool - ignoreTrailingSlash bool -} - -// Handle calls the handler with the provided Context. See also HandleMiddleware. -func (r *Route) Handle(c Context) { - r.hbase(c) -} - -// HandleMiddleware calls the handler with route-specific middleware applied, using the provided Context. -func (r *Route) HandleMiddleware(c Context, _ ...struct{}) { - // The variadic parameter is intentionally added to prevent this method from having the same signature as HandlerFunc. - // This avoids accidental use of HandleMiddleware where a HandlerFunc is required. - r.hself(c) -} - -// Path returns the route path. -func (r *Route) Path() string { - return r.path -} - -// Tags returns a range iterator over the tags associated with the route. -func (r *Route) Tags() iter.Seq[string] { - return func(yield func(string) bool) { - for _, tag := range r.tags { - if !yield(tag) { - return - } - } - } -} - -// Tag checks if the specified query exists among the route's tags. It handles multiple wildcards (*) at any position. -// Matching Rules: -// - Empty matches are not allowed: wildcards must match at least one character. -// - Prefix wildcard: Matches any sequence of characters before the first occurrence of the following static segment (e.g., *metrics matches group:metrics). -// - Infix wildcard: Matches any sequence of characters between two static segments (e.g., group:*:ro matches group:fox:ro). -// - Suffix wildcard: Matches any sequence of characters following the last static segment (e.g., group* matches group:metrics). -func (r *Route) Tag(query string) bool { - if strings.Contains(query, "*") { - for tag := range r.Tags() { - if matchWildcard(query, tag) { - return true - } - } - return false - } - return slices.Contains(r.tags, query) -} - -// RedirectTrailingSlashEnabled returns whether the route is configured to automatically -// redirect requests that include or omit a trailing slash. -// This api is EXPERIMENTAL and is likely to change in future release. -func (r *Route) RedirectTrailingSlashEnabled() bool { - return r.redirectTrailingSlash -} - -// IgnoreTrailingSlashEnabled returns whether the route is configured to ignore -// trailing slashes in requests when matching routes. -// This api is EXPERIMENTAL and is likely to change in future release. -func (r *Route) IgnoreTrailingSlashEnabled() bool { - return r.ignoreTrailingSlash -} - -// ClientIPStrategyEnabled returns whether the route is configured with a ClientIPStrategy. -// This api is EXPERIMENTAL and is likely to change in future release. -func (r *Route) ClientIPStrategyEnabled() bool { - _, ok := r.ipStrategy.(noClientIPStrategy) - return !ok -} - // Router is a lightweight high performance HTTP request router that support mutation on its routing tree // while handling request concurrently. type Router struct { @@ -811,84 +728,3 @@ type noClientIPStrategy struct{} func (s noClientIPStrategy) ClientIP(_ Context) (*net.IPAddr, error) { return nil, ErrNoClientIPStrategy } - -// matchWildcard matches a query with wildcards against a tag. -// It handles wildcards (*) at any position in the query without allocation. -// Matching Rules: -// - Empty matches are not allowed: wildcards must match at least one character. -// - Prefix wildcard: Matches any sequence of characters before the first occurrence of the following static segment (e.g., *foo matches barfoo). -// - Infix wildcard: Matches any sequence of characters between two static segments (e.g., foo*bar matches fooxbar). -// - Suffix wildcard: Matches any sequence of characters following the last static segment (e.g., foo* matches foobar). -func matchWildcard(query, tag string) bool { - if query == "*" { - return true - } - - tLen := len(tag) - var i, j, pi int - state := stateDefault - previous := state - - for i < len(query) && j < tLen { - switch state { - case stateCatchAll: - // skip consecutive wildcard - if query[i] == '*' { - // clean consecutive leading '*' - if i == 1 { - query = query[i:] - } else { - // clean infix or suffix '*' - // foo*** or foo***bar - query = query[:i] + query[i+1:] - } - - continue - } - - // try to match the next part - for j < tLen && tag[j] != query[i] { - j++ - } - - if j == tLen { - return false - } - - previous = state - pi = i - state = stateDefault - default: - if query[i] == tag[j] { - i++ - j++ - continue - } - - if query[i] == '*' { - if j < i { - return false - } - i++ - previous = state - state = stateCatchAll - continue - } - - if previous == stateCatchAll { - state = stateCatchAll - i = pi - continue - } - - return false - } - } - - // ending with a wildcard, match the rest of the tag - if i == len(query) && state == stateCatchAll { - return true - } - - return i == len(query) && j == tLen && j >= i -} diff --git a/fox_test.go b/fox_test.go index b8649a2..351dea0 100644 --- a/fox_test.go +++ b/fox_test.go @@ -643,24 +643,6 @@ func TestStaticRouteMalloc(t *testing.T) { } } -func TestRoute_HandleMiddlewareMalloc(t *testing.T) { - f := New() - for _, rte := range githubAPI { - require.NoError(t, f.Tree().Handle(rte.method, rte.path, emptyHandler)) - } - - for _, rte := range githubAPI { - req := httptest.NewRequest(rte.method, rte.path, nil) - w := httptest.NewRecorder() - r, c, _ := f.Lookup(&recorder{ResponseWriter: w}, req) - allocs := testing.AllocsPerRun(100, func() { - r.HandleMiddleware(c) - }) - c.Close() - assert.Equal(t, float64(0), allocs) - } -} - func TestParamsRoute(t *testing.T) { rx := regexp.MustCompile("({|\\*{)[A-z]+[}]") r := New() @@ -1204,6 +1186,53 @@ func TestOverlappingRoute(t *testing.T) { }, }, }, + { + name: "param at index 1 with 2 nodes", + path: "/foo/[barr]", + routes: []string{ + "/foo/{bar}", + "/foo/[bar]", + }, + wantMatch: "/foo/{bar}", + wantParams: Params{ + { + Key: "bar", + Value: "[barr]", + }, + }, + }, + { + name: "param at index 1 with 3 nodes", + path: "/foo/|barr|", + routes: []string{ + "/foo/{bar}", + "/foo/[bar]", + "/foo/|bar|", + }, + wantMatch: "/foo/{bar}", + wantParams: Params{ + { + Key: "bar", + Value: "|barr|", + }, + }, + }, + { + name: "param at index 0 with 3 nodes", + path: "/foo/~barr~", + routes: []string{ + "/foo/{bar}", + "/foo/~bar~", + "/foo/|bar|", + }, + wantMatch: "/foo/{bar}", + wantParams: Params{ + { + Key: "bar", + Value: "~barr~", + }, + }, + }, } for _, tc := range cases { @@ -2991,206 +3020,6 @@ func TestTreeSwap(t *testing.T) { }) } -// Rules -func TestRoute_WildcardTag(t *testing.T) { - // TODO empty match should returns false - cases := []struct { - query string - tag string - want bool - }{ - { - query: "*bar", - tag: "john", - want: false, - }, - { - query: "barx", - tag: "bary", - want: false, - }, - { - query: "*foo:*", - tag: "foo:foo", - want: false, - }, - { - query: "*foo:*:bar", - tag: "foo:foo:bar", - want: false, - }, - { - query: "*foo:*:bar", - tag: "fo:foo:bam:bar", - want: true, - }, - { - query: "***:bar:*", - tag: "foo:bar:baz", - want: true, - }, - { - query: "*foo", - tag: "foo", - want: false, - }, - { - query: "****foo", - tag: "foo", - want: false, - }, - { - query: "****foo", - tag: "ffoo", - want: true, - }, - { - query: "foo*", - tag: "foo", - want: false, - }, - { - query: "foo****", - tag: "foo", - want: false, - }, - { - query: "foo***", - tag: "foofoo", - want: true, - }, - { - query: "foo*", - tag: "foofoo", - want: true, - }, - { - query: "foo*baz", - tag: "foo:bar:baz", - want: true, - }, - { - query: "foo***baz", - tag: "foo:bar:baz", - want: true, - }, - { - query: "foo***baz", - tag: "foobaz", - want: false, - }, - { - query: "*o*baz*", - tag: "foo:bar:baz:bill", - want: true, - }, - { - query: "group:*", - tag: "group:read", - want: true, - }, - { - query: "*:read", - tag: "group:read", - want: true, - }, - { - query: "group:*:read", - tag: "group:fox:read", - want: true, - }, - { - query: "*", - tag: "group:fox:read", - want: true, - }, - { - query: "****", - tag: "group:fox:read", - want: true, - }, - } - - for _, tc := range cases { - t.Run(tc.query, func(t *testing.T) { - assert.Equal(t, tc.want, matchWildcard(tc.query, tc.tag)) - }) - } -} - -func TestRouteTagMalloc(t *testing.T) { - rte := &Route{ - tags: []string{"foo:bar:baz"}, - } - - for _, query := range []string{"*:bar:*", "*bar:baz", "foo:bar:baz", "***:bar:*"} { - var res bool - allocs := testing.AllocsPerRun(100, func() { - res = rte.Tag(query) - }) - assert.True(t, res) - assert.Equal(t, float64(0), allocs) - } - -} - -func TestRouteTagsMalloc(t *testing.T) { - rte := &Route{ - tags: []string{"foo:bar:baz", "bar", "boom"}, - } - - allocs := testing.AllocsPerRun(100, func() { - for tag := range rte.Tags() { - _ = tag - } - }) - - assert.Equal(t, float64(0), allocs) -} - -func TestFuzzRouteTag(t *testing.T) { - rte := &Route{ - tags: make([]string, 1), - } - unicodeRanges := fuzz.UnicodeRanges{ - {First: 0x20, Last: 0x04FF}, - } - - f := fuzz.New().NilChance(0).Funcs(unicodeRanges.CustomStringFuzzFunc()) - for range 5000 { - var tag string - f.Fuzz(&tag) - rte.tags[0] = tag - query := injectWildcards(tag) - assert.NotPanicsf(t, func() { - rte.Tag(query) - }, fmt.Sprintf("query: %s, tag: %s", query, tag)) - } -} - -// injectWildcards adds a random number of '*' wildcards at random positions in the query. -func injectWildcards(query string) string { - numWildcards := rand.Intn(4) - - if numWildcards == 0 { - return query - } - - var sb strings.Builder - for _, char := range query { - if rand.Float32() < 0.2 { - sb.WriteRune('*') - } - sb.WriteRune(char) - } - - for i := 0; i < numWildcards; i++ { - sb.WriteRune('*') - } - - return sb.String() -} - func TestFuzzInsertLookupParam(t *testing.T) { // no '*', '{}' and '/' and invalid escape char unicodeRanges := fuzz.UnicodeRanges{ diff --git a/internal/iterutil/iterutil.go b/internal/iterutil/iterutil.go index a869779..e6da8f8 100644 --- a/internal/iterutil/iterutil.go +++ b/internal/iterutil/iterutil.go @@ -31,13 +31,3 @@ func SeqOf[E any](elems ...E) iter.Seq[E] { } } } - -func Map[A, B any](seq iter.Seq[A], f func(A) B) iter.Seq[B] { - return func(yield func(B) bool) { - for a := range seq { - if !yield(f(a)) { - return - } - } - } -} diff --git a/recovery.go b/recovery.go index 5b09ab7..ce03d47 100644 --- a/recovery.go +++ b/recovery.go @@ -7,7 +7,6 @@ package fox import ( "errors" "fmt" - "github.com/tigerwill90/fox/internal/iterutil" "github.com/tigerwill90/fox/internal/slogpretty" "iter" "log/slog" @@ -136,7 +135,11 @@ func stacktrace(skip, nFrames int) string { } func mapParamsToAttr(params iter.Seq[Param]) iter.Seq[any] { - return iterutil.Map(params, func(a Param) any { - return slog.String(a.Key, a.Value) - }) + return func(yield func(any) bool) { + for p := range params { + if !yield(slog.String(p.Key, p.Value)) { + break + } + } + } } diff --git a/route.go b/route.go new file mode 100644 index 0000000..02b7cbe --- /dev/null +++ b/route.go @@ -0,0 +1,143 @@ +package fox + +import ( + "iter" + "strings" +) + +// Route represent a registered route in the route tree. +// Most of the Route API is EXPERIMENTAL and is likely to change in future release. +type Route struct { + ipStrategy ClientIPStrategy + hbase HandlerFunc + hself HandlerFunc + hall HandlerFunc + path string + mws []middleware + tags []string + redirectTrailingSlash bool + ignoreTrailingSlash bool +} + +// Handle calls the handler with the provided Context. See also HandleMiddleware. +func (r *Route) Handle(c Context) { + r.hbase(c) +} + +// HandleMiddleware calls the handler with route-specific middleware applied, using the provided Context. +func (r *Route) HandleMiddleware(c Context, _ ...struct{}) { + // The variadic parameter is intentionally added to prevent this method from having the same signature as HandlerFunc. + // This avoids accidental use of HandleMiddleware where a HandlerFunc is required. + r.hself(c) +} + +// Path returns the route path. +func (r *Route) Path() string { + return r.path +} + +// Tags returns a range iterator over the tags associated with the route. +func (r *Route) Tags() iter.Seq[string] { + return func(yield func(string) bool) { + for _, tag := range r.tags { + if !yield(tag) { + return + } + } + } +} + +// RedirectTrailingSlashEnabled returns whether the route is configured to automatically +// redirect requests that include or omit a trailing slash. +// This api is EXPERIMENTAL and is likely to change in future release. +func (r *Route) RedirectTrailingSlashEnabled() bool { + return r.redirectTrailingSlash +} + +// IgnoreTrailingSlashEnabled returns whether the route is configured to ignore +// trailing slashes in requests when matching routes. +// This api is EXPERIMENTAL and is likely to change in future release. +func (r *Route) IgnoreTrailingSlashEnabled() bool { + return r.ignoreTrailingSlash +} + +// ClientIPStrategyEnabled returns whether the route is configured with a ClientIPStrategy. +// This api is EXPERIMENTAL and is likely to change in future release. +func (r *Route) ClientIPStrategyEnabled() bool { + _, ok := r.ipStrategy.(noClientIPStrategy) + return !ok +} + +func (r *Route) hydrateParams(path string, params *Params) bool { + rLen := len(r.path) + pLen := len(path) + var i, j int + state := stateDefault + + // Note that we assume that this is a valid route (validated with parseRoute). +OUTER: + for i < rLen && j < pLen { + switch state { + case stateParam: + startPath := j + idx := strings.IndexByte(path[j:], slashDelim) + if idx > 0 { + j += idx + } else if idx < 0 { + j += len(path[j:]) + } else { + // segment is empty + return false + } + + startRoute := i + idx = strings.IndexByte(r.path[i:], slashDelim) + if idx >= 0 { + i += idx + } else { + i += len(r.path[i:]) + } + + *params = append(*params, Param{ + Key: r.path[startRoute : i-1], + Value: path[startPath:j], + }) + + state = stateDefault + + default: + if r.path[i] == '{' { + i++ + state = stateParam + continue + } + + if r.path[i] == '*' { + state = stateCatchAll + break OUTER + } + + if r.path[i] == path[j] { + i++ + j++ + continue + } + + return false + } + } + + if state == stateCatchAll || (i < rLen && r.path[i] == '*') { + *params = append(*params, Param{ + Key: r.path[i+2 : rLen-1], + Value: path[j:], + }) + return true + } + + if i == rLen && j == pLen { + return true + } + + return false +} diff --git a/route_test.go b/route_test.go new file mode 100644 index 0000000..0cdb3f6 --- /dev/null +++ b/route_test.go @@ -0,0 +1,226 @@ +package fox + +import ( + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "net/http/httptest" + "testing" +) + +func TestRoute_HydrateParams(t *testing.T) { + cases := []struct { + name string + path string + route *Route + wantParams Params + want bool + }{ + { + name: "static route match", + path: "/foo/bar", + route: &Route{path: "/foo/bar"}, + wantParams: Params{}, + want: true, + }, + { + name: "static route do not match", + path: "/foo/bar", + route: &Route{path: "/foo/ba"}, + wantParams: Params{}, + want: false, + }, + { + name: "static route do not match", + path: "/foo/bar", + route: &Route{path: "/foo/barr"}, + wantParams: Params{}, + want: false, + }, + { + name: "static route do not match", + path: "/foo/bar", + route: &Route{path: "/foo/bax"}, + wantParams: Params{}, + want: false, + }, + { + name: "strict trailing slash", + path: "/foo/bar", + route: &Route{path: "/foo/bar/"}, + wantParams: Params{}, + want: false, + }, + { + name: "strict trailing slash with param and", + path: "/foo/bar", + route: &Route{path: "/foo/{1}/"}, + wantParams: Params{ + { + Key: "1", + Value: "bar", + }, + }, + want: false, + }, + { + name: "strict trailing slash with param", + path: "/foo/bar/", + route: &Route{path: "/foo/{2}"}, + wantParams: Params{ + { + Key: "2", + Value: "bar", + }, + }, + want: false, + }, + { + name: "strict trailing slash", + path: "/foo/bar/", + route: &Route{path: "/foo/bar"}, + wantParams: Params{}, + want: false, + }, + { + name: "multi route params and catch all", + path: "/foo/ab:1/baz/123/y/bo/lo", + route: &Route{path: "/foo/ab:{bar}/baz/{x}/{y}/*{zo}"}, + wantParams: Params{ + { + Key: "bar", + Value: "1", + }, + { + Key: "x", + Value: "123", + }, + { + Key: "y", + Value: "y", + }, + { + Key: "zo", + Value: "bo/lo", + }, + }, + want: true, + }, + { + name: "path with wildcard should be parsed", + path: "/foo/ab:{bar}/baz/{x}/{y}/*{zo}", + route: &Route{path: "/foo/ab:{bar}/baz/{x}/{y}/*{zo}"}, + wantParams: Params{ + { + Key: "bar", + Value: "{bar}", + }, + { + Key: "x", + Value: "{x}", + }, + { + Key: "y", + Value: "{y}", + }, + { + Key: "zo", + Value: "*{zo}", + }, + }, + want: true, + }, + { + name: "empty param end range", + path: "/foo/", + route: &Route{path: "/foo/{bar}"}, + wantParams: Params{}, + want: false, + }, + { + name: "empty param mid range", + path: "/foo//baz", + route: &Route{path: "/foo/{bar}/baz"}, + wantParams: Params{}, + want: false, + }, + { + name: "multiple slash", + path: "/foo/bar///baz", + route: &Route{path: "/foo/{bar}/baz"}, + wantParams: Params{ + { + Key: "bar", + Value: "bar", + }, + }, + want: false, + }, + { + name: "param at end range", + path: "/foo/baz", + route: &Route{path: "/foo/{bar}"}, + wantParams: Params{ + { + Key: "bar", + Value: "baz", + }, + }, + want: true, + }, + { + name: "full path catch all", + path: "/foo/bar/baz", + route: &Route{path: "/*{args}"}, + wantParams: Params{ + { + Key: "args", + Value: "foo/bar/baz", + }, + }, + want: true, + }, + } + + params := make(Params, 0) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + params = params[:0] + got := tc.route.hydrateParams(tc.path, ¶ms) + assert.Equal(t, tc.want, got) + assert.Equal(t, tc.wantParams, params) + }) + } + +} + +func TestRoute_HandleMiddlewareMalloc(t *testing.T) { + f := New() + for _, rte := range githubAPI { + require.NoError(t, f.Tree().Handle(rte.method, rte.path, emptyHandler)) + } + + for _, rte := range githubAPI { + req := httptest.NewRequest(rte.method, rte.path, nil) + w := httptest.NewRecorder() + r, c, _ := f.Lookup(&recorder{ResponseWriter: w}, req) + allocs := testing.AllocsPerRun(100, func() { + r.HandleMiddleware(c) + }) + c.Close() + assert.Equal(t, float64(0), allocs) + } +} + +func TestRoute_HydrateParamsMalloc(t *testing.T) { + rte := &Route{ + path: "/foo/ab:{bar}/baz/{x}/{y}/*{zo}", + } + path := "/foo/ab:1/baz/123/y/bo/lo" + params := make(Params, 0, 4) + + allocs := testing.AllocsPerRun(100, func() { + rte.hydrateParams(path, ¶ms) + params = params[:0] + }) + assert.Equal(t, float64(0), allocs) +}