Skip to content

Commit

Permalink
feat: add the ability to rehydrate the Context with a route.
Browse files Browse the repository at this point in the history
  • Loading branch information
tigerwill90 committed Oct 13, 2024
1 parent a25e768 commit 16f4287
Show file tree
Hide file tree
Showing 8 changed files with 572 additions and 524 deletions.
141 changes: 31 additions & 110 deletions context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Check warning on line 147 in context.go

View check run for this annotation

Codecov / codecov/patch

context.go#L147

Added line #L147 was not covered by tests
}

// 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.
Expand Down
136 changes: 118 additions & 18 deletions context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ package fox

import (
"bytes"
"fmt"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"io"
Expand All @@ -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()
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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}", &params))
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)
}
}
Loading

0 comments on commit 16f4287

Please sign in to comment.