diff --git a/.gitignore b/.gitignore index 51eb4fd..f248fab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .idea .prof -.test \ No newline at end of file +.test +go.work +go.work.sum \ No newline at end of file diff --git a/README.md b/README.md index 6f2d811..6217e67 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,9 @@ reads** while allowing **concurrent writes**. The router tree is optimized for high-concurrency and high performance reads, and low-concurrency write. Fox has a small memory footprint, and in many case, it does not do a single heap allocation while handling request. +Fox supports various use cases, but it is especially designed for applications that require changes at runtime to their +routing structure based on user input, configuration changes, or other runtime events. + ## Disclaimer The current api is not yet stabilize. Breaking changes may occur before `v1.0.0` and will be noted on the release note. @@ -23,7 +26,7 @@ name. Due to Fox design, wildcard route are cheap and scale really well. **Detect panic:** You can register a fallback handler that is fire in case of panics occurring during handling an HTTP request. -**Get the current route:** You can easily retrieve the route for the current matched request. This actually makes it easier to integrate +**Get the current route:** You can easily retrieve the route of the matched request. This actually makes it easier to integrate observability middleware like open telemetry (disable by default). **Only explicit matches:** Inspired from [httprouter](https://github.com/julienschmidt/httprouter), a request can only match @@ -116,7 +119,7 @@ Pattern /users/uuid_:id /users/uuid no match ``` -#### Catch all parameter +### 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 name. ``` Pattern /src/*filepath @@ -140,9 +143,33 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, params fox.P } ``` -### Adding, updating and removing route -In this example, the handler for `route/:action` allow to dynamically register, update and remove handler for the given route and method. -Due to Fox design, those actions are perfectly safe and may be executed concurrently. +## Concurrency +Fox implements a [Concurrent Radix Tree](https://github.com/npgall/concurrent-trees/blob/master/documentation/TreeDesign.md) that supports **lock-free** +reads while allowing **concurrent writes**, by calculating the changes which would be made to the tree were it mutable, and assembling those changes +into a **patch**, which is then applied to the tree in a **single atomic operation**. + +For example, here we are inserting the new key `toast` into to the tree which require an existing node to be split: + +

+ +

+ +When traversing the tree during a patch, reading threads will either see the **old version** or the **new version** of the (sub-)tree, but both version are +consistent view of the tree. + +#### Other key points + +- Routing requests is lock-free (reading thread never block, even while writes are ongoing) +- The router always see a consistent version of the tree while routing request +- Reading threads do not block writing threads (adding, updating or removing a handler can be done concurrently) +- Writing threads block each other but never block reading threads + +As such threads that route requests should never encounter latency due to ongoing writes or other concurrent readers. + +### Managing routes a runtime +#### Routing mutation +In this example, the handler for `routes/:action` allow to dynamically register, update and remove handler for the +given route and method. Thanks to Fox's design, those actions are perfectly safe and may be executed concurrently. ```go package main @@ -214,35 +241,115 @@ func Must(err error) { } ``` -## Concurrency -Fox implements a [Concurrent Radix Tree](https://github.com/npgall/concurrent-trees/blob/master/documentation/TreeDesign.md) that supports **lock-free** -reads while allowing **concurrent writes**, by calculating the changes which would be made to the tree were it mutable, and assembling those changes -into a **patch**, which is then applied to the tree in a **single atomic operation**. +#### Tree swapping +Fox also enables you to replace the entire tree in a single atomic operation using the `Store` and `Swap` methods. +Note that router's options apply automatically on the new tree. +````go +package main -For example, here we are inserting the new key `toast` into to the tree which require an existing node to be split: +import ( + "fox-by-example/db" + "github.com/tigerwill90/fox" + "html/template" + "io" + "log" + "net/http" + "strings" + "time" +) -

- -

+type HtmlRenderer struct { + Template template.HTML +} -When traversing the tree during a patch, reading threads will either see the **old version** or the **new version** of the (sub-)tree, but both version are -consistent view of the tree. +func (h *HtmlRenderer) ServeHTTP(w http.ResponseWriter, r *http.Request, params fox.Params) { + log.Printf("matched route: %s", params.Get(fox.RouteKey)) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = io.Copy(w, strings.NewReader(string(h.Template))) +} -#### Other key points +func main() { + r := fox.New(fox.WithSaveMatchedRoute(true)) -- Routing requests is lock-free (reading thread never block, even while writes are ongoing) -- The router always see a consistent version of the tree while routing request -- Reading threads do not block writing threads (adding, updating or removing a handler can be done concurrently) -- Writing threads block each other but never block reading threads + routes := db.GetRoutes() + for _, rte := range routes { + Must(r.Handler(rte.Method, rte.Path, &HtmlRenderer{Template: rte.Template})) + } -As such threads that route requests should never encounter latency due to ongoing writes or other concurrent readers. + go Reload(r) + + log.Fatalln(http.ListenAndServe(":8080", r)) +} + +func Reload(r *fox.Router) { + for range time.Tick(10 * time.Second) { + routes := db.GetRoutes() + tree := r.NewTree() + for _, rte := range routes { + if err := tree.Handler(rte.Method, rte.Path, &HtmlRenderer{Template: rte.Template}); err != nil { + log.Printf("error reloading route: %s\n", err) + continue + } + } + // Replace the currently in-use routing tree with the new provided. + r.Use(tree) + log.Println("route reloaded") + } +} + +func Must(err error) { + if err != nil { + panic(err) + } +} +```` + +#### Advanced usage: consistent view updates +In certain situations, it's necessary to maintain a consistent view of the tree while performing updates. +The `Tree` API allow you to take control of the internal `sync.Mutex` to prevent concurrent writes from +other threads. **Remember that all write operation should be run serially.** + +In the following example, the `Upsert` function needs to perform a lookup on the tree to check if a handler +is already registered for the provided method and path. By locking the `Tree`, this operation ensures +atomicity, as it prevents other threads from modifying the tree between the lookup and the write operation. +Note that all read operation on the tree remain lock-free. +````go +func Upsert(t *fox.Tree, method, path string, handler fox.Handler) error { + t.Lock() + defer t.Unlock() + if fox.Has(t, method, path) { + return t.Update(method, path, handler) + } + return t.Handler(method, path, handler) +} +```` + +#### Concurrent safety and proper usage of Tree APIs +Some important consideration to keep in mind when using `Tree` API. Each instance as its own `sync.Mutex` and `sync.Pool` +that may be used to serialize write and reduce memory allocation. Since the router tree may be swapped at any +given time, you **MUST always copy the pointer locally** to avoid inadvertently releasing Params to the wrong pool +or worst, causing a deadlock by locking/unlocking the wrong `Tree`. + +````go +// Good +t := r.Tree() +t.Lock() +defer t.Unlock() + +// Dramatically bad, may cause deadlock: +r.Tree().Lock() +defer r.Tree().Unlock() +```` + +This principle also applies to the `fox.Lookup` function, which requires releasing the `fox.Params` slice by calling `params.Free(tree)`. +Always ensure that the `Tree` pointer passed as a parameter to `params.Free` is the same as the one passed to the `fox.Lookup` function. ## Working with http.Handler Fox itself implements the `http.Handler` interface which make easy to chain any compatible middleware before the router. Moreover, the router provides convenient `fox.WrapF` and `fox.WrapH` adapter to be use with `http.Handler`. Named and catch all parameters are forwarded via the request context ```go -_ = r.Handler(http.MethodGet, "/users/:id", fox.WrapF(func(w http.ResponseWriter, r *http.Request) { +_ = r.Tree().Handler(http.MethodGet, "/users/:id", fox.WrapF(func(w http.ResponseWriter, r *http.Request) { params := fox.ParamsFromContext(r.Context()) _, _ = fmt.Fprintf(w, "user id: %s\n", params.Get("id")) })) diff --git a/iter.go b/iter.go index 6bec48e..00e3819 100644 --- a/iter.go +++ b/iter.go @@ -5,7 +5,7 @@ import ( ) type Iterator struct { - r *Router + tree *Tree method string current *node stacks []stack @@ -17,14 +17,14 @@ type Iterator struct { // An Iterator is safe to use when the router is serving request, when routing updates are ongoing or // in parallel with other Iterators. Note that changes that happen while iterating over routes may not be reflected // by the Iterator. This api is EXPERIMENTAL and is likely to change in future release. -func (fox *Router) NewIterator() *Iterator { +func NewIterator(t *Tree) *Iterator { return &Iterator{ - r: fox, + tree: t, } } func (it *Iterator) methods() map[string]*node { - nds := *it.r.trees.Load() + nds := it.tree.load() m := make(map[string]*node, len(nds)) for i := range nds { if len(nds[i].children) > 0 { @@ -40,7 +40,7 @@ func (it *Iterator) SeekPrefix(key string) { nds := it.methods() keys := make([]string, 0, len(nds)) for method, n := range nds { - result := it.r.search(n, key) + result := it.tree.search(n, key) if result.isExactMatch() || result.isKeyMidEdge() { nds[method] = result.matched keys = append(keys, method) @@ -82,7 +82,7 @@ func (it *Iterator) SeekMethodPrefix(method, key string) { stacks := make([]stack, 0, 1) n, ok := nds[method] if ok { - result := it.r.search(n, key) + result := it.tree.search(n, key) if result.isExactMatch() || result.isKeyMidEdge() { stacks = append(stacks, stack{ edges: []*node{result.matched}, @@ -194,8 +194,8 @@ type rawIterator struct { } type stack struct { - edges []*node method string + edges []*node } func (it *rawIterator) fullPath() string { diff --git a/iter_test.go b/iter_test.go index 4a86588..0295d11 100644 --- a/iter_test.go +++ b/iter_test.go @@ -11,16 +11,16 @@ import ( var routesCases = []string{"/fox/router", "/foo/bar/:baz", "/foo/bar/:baz/:name", "/john/doe/*args", "/john/doe"} func TestIterator_Rewind(t *testing.T) { - r := New() + tree := New().Tree() for _, rte := range routesCases { - require.NoError(t, r.Handler(http.MethodGet, rte, emptyHandler)) - require.NoError(t, r.Handler(http.MethodPost, rte, emptyHandler)) - require.NoError(t, r.Handler(http.MethodHead, rte, emptyHandler)) + require.NoError(t, tree.Handler(http.MethodGet, rte, emptyHandler)) + require.NoError(t, tree.Handler(http.MethodPost, rte, emptyHandler)) + require.NoError(t, tree.Handler(http.MethodHead, rte, emptyHandler)) } results := make(map[string][]string) - it := r.NewIterator() + it := NewIterator(tree) for it.Rewind(); it.Valid(); it.Next() { assert.NotNil(t, it.Handler()) results[it.Method()] = append(results[it.method], it.Path()) @@ -32,16 +32,16 @@ func TestIterator_Rewind(t *testing.T) { } func TestIterator_SeekMethod(t *testing.T) { - r := New() + tree := New().Tree() for _, rte := range routesCases { - require.NoError(t, r.Handler(http.MethodGet, rte, emptyHandler)) - require.NoError(t, r.Handler(http.MethodPost, rte, emptyHandler)) - require.NoError(t, r.Handler(http.MethodHead, rte, emptyHandler)) + require.NoError(t, tree.Handler(http.MethodGet, rte, emptyHandler)) + require.NoError(t, tree.Handler(http.MethodPost, rte, emptyHandler)) + require.NoError(t, tree.Handler(http.MethodHead, rte, emptyHandler)) } results := make(map[string][]string) - it := r.NewIterator() + it := NewIterator(tree) for it.SeekMethod(http.MethodHead); it.Valid(); it.Next() { assert.NotNil(t, it.Handler()) results[it.Method()] = append(results[it.method], it.Path()) @@ -52,17 +52,17 @@ func TestIterator_SeekMethod(t *testing.T) { } func TestIterator_SeekPrefix(t *testing.T) { - r := New() + tree := New().Tree() for _, rte := range routesCases { - require.NoError(t, r.Handler(http.MethodGet, rte, emptyHandler)) - require.NoError(t, r.Handler(http.MethodPost, rte, emptyHandler)) - require.NoError(t, r.Handler(http.MethodHead, rte, emptyHandler)) + require.NoError(t, tree.Handler(http.MethodGet, rte, emptyHandler)) + require.NoError(t, tree.Handler(http.MethodPost, rte, emptyHandler)) + require.NoError(t, tree.Handler(http.MethodHead, rte, emptyHandler)) } want := []string{"/foo/bar/:baz", "/foo/bar/:baz/:name"} results := make(map[string][]string) - it := r.NewIterator() + it := NewIterator(tree) for it.SeekPrefix("/foo"); it.Valid(); it.Next() { assert.NotNil(t, it.Handler()) results[it.Method()] = append(results[it.method], it.Path()) @@ -74,17 +74,17 @@ func TestIterator_SeekPrefix(t *testing.T) { } func TestIterator_SeekMethodPrefix(t *testing.T) { - r := New() + tree := New().Tree() for _, rte := range routesCases { - require.NoError(t, r.Handler(http.MethodGet, rte, emptyHandler)) - require.NoError(t, r.Handler(http.MethodPost, rte, emptyHandler)) - require.NoError(t, r.Handler(http.MethodHead, rte, emptyHandler)) + require.NoError(t, tree.Handler(http.MethodGet, rte, emptyHandler)) + require.NoError(t, tree.Handler(http.MethodPost, rte, emptyHandler)) + require.NoError(t, tree.Handler(http.MethodHead, rte, emptyHandler)) } want := []string{"/foo/bar/:baz", "/foo/bar/:baz/:name"} results := make(map[string][]string) - it := r.NewIterator() + it := NewIterator(tree) for it.SeekMethodPrefix(http.MethodHead, "/foo"); it.Valid(); it.Next() { results[it.Method()] = append(results[it.method], it.Path()) } @@ -93,9 +93,9 @@ func TestIterator_SeekMethodPrefix(t *testing.T) { assert.ElementsMatch(t, want, results[http.MethodHead]) } -func ExampleRouter_NewIterator() { +func ExampleNewIterator() { r := New() - it := r.NewIterator() + it := NewIterator(r.Tree()) // Iterate over all routes for it.Rewind(); it.Valid(); it.Next() { diff --git a/locked_router.go b/locked_router.go deleted file mode 100644 index df03288..0000000 --- a/locked_router.go +++ /dev/null @@ -1,97 +0,0 @@ -package fox - -type LockedRouter struct { - r *Router - locked bool -} - -// LockRouter acquire a lock on the router which allow to perform multiple mutation while -// keeping a consistent view of the routing tree. LockedRouter's holder must always ensure -// to call Release in order to unlock the router. The LockedRouter api is EXPERIMENTAL and is -// likely to change in future release. -func (fox *Router) LockRouter() *LockedRouter { - fox.mu.Lock() - return &LockedRouter{ - r: fox, - locked: true, - } -} - -// Handler registers a new handler for the given method and path. This function return an error if the route -// is already registered or conflict with another. It's perfectly safe to add a new handler while serving requests. -// This function is NOT safe for concurrent use by multiple goroutine and panic if called after lr.Release(). -func (lr *LockedRouter) Handler(method, path string, handler Handler) error { - lr.assertLock() - p, catchAllKey, n, err := parseRoute(path) - if err != nil { - return err - } - if lr.r.AddRouteParam { - n += 1 - } - return lr.r.insert(method, p, catchAllKey, uint32(n), handler) -} - -// Update override an existing handler for the given method and path. If the route does not exist, -// the function return an ErrRouteNotFound. It's perfectly safe to update a handler while serving requests. -// This function is NOT safe for concurrent use by multiple goroutine and panic if called after lr.Release(). -func (lr *LockedRouter) Update(method, path string, handler Handler) error { - lr.assertLock() - p, catchAllKey, _, err := parseRoute(path) - if err != nil { - return err - } - - return lr.r.update(method, p, catchAllKey, handler) -} - -// Remove delete an existing handler for the given method and path. If the route does not exist, the function -// return an ErrRouteNotFound. It's perfectly safe to remove a handler while serving requests. This function is -// NOT safe for concurrent use by multiple goroutine and panic if called after lr.Release(). -func (lr *LockedRouter) Remove(method, path string) error { - lr.assertLock() - path, _, _, err := parseRoute(path) - if err != nil { - return err - } - - if !lr.r.remove(method, path) { - return ErrRouteNotFound - } - return nil -} - -// Lookup allow to do manual lookup of a route. Please note that params are only valid until fn callback returns (see Handler interface). -// If lazy is set to true, params are not parsed. This function is safe for concurrent use by multiple goroutine. -func (lr *LockedRouter) Lookup(method, path string, lazy bool, fn func(handler Handler, params Params, tsr bool)) { - lr.r.Lookup(method, path, lazy, fn) -} - -// Match perform a lazy lookup and return true if the requested method and path match a registered route. -// This function is safe for concurrent use by multiple goroutine. -func (lr *LockedRouter) Match(method, path string) bool { - return lr.r.Match(method, path) -} - -// NewIterator returns an Iterator that traverses all registered routes in lexicographic order. -// An Iterator is safe to use when the router is serving request, when routing updates are ongoing or -// in parallel with other Iterators. Note that changes that happen while iterating over routes may not be reflected -// by the Iterator. -func (lr *LockedRouter) NewIterator() *Iterator { - return lr.r.NewIterator() -} - -// Release unlock the router. Calling this function on a released LockedRouter is a noop. -func (lr *LockedRouter) Release() { - if !lr.locked { - return - } - lr.locked = false - lr.r.mu.Unlock() -} - -func (lr *LockedRouter) assertLock() { - if !lr.locked { - panic("lock already released") - } -} diff --git a/options.go b/options.go new file mode 100644 index 0000000..2fd2f2e --- /dev/null +++ b/options.go @@ -0,0 +1,80 @@ +package fox + +import "net/http" + +type Option interface { + apply(*Router) +} + +type optionFunc func(*Router) + +func (o optionFunc) apply(r *Router) { + o(r) +} + +// WithNotFoundHandler register a http.Handler which is called when no matching route is found. +// By default, http.NotFound is used. +func WithNotFoundHandler(handler http.Handler) Option { + return optionFunc(func(r *Router) { + if handler != nil { + r.notFound = handler + } + }) +} + +// WithNotAllowedHandler register a http.Handler which is called when the request cannot be routed, +// but the same route exist for other methods. The "Allow" header it automatically set +// before calling the handler. Mount WithHandleMethodNotAllowed to enable this option. By default, +// http.Error with http.StatusMethodNotAllowed is used. +func WithNotAllowedHandler(handler http.Handler) Option { + return optionFunc(func(r *Router) { + if handler != nil { + r.methodNotAllowed = handler + } + }) +} + +// WithPanicHandler register a function to handle panics recovered from http handlers. +func WithPanicHandler(fn func(http.ResponseWriter, *http.Request, interface{})) Option { + return optionFunc(func(r *Router) { + if fn != nil { + r.panicHandler = fn + } + }) +} + +// WithHandleMethodNotAllowed enable to returns 405 Method Not Allowed instead of 404 Not Found +// when the route exist for another http verb. +func WithHandleMethodNotAllowed(enable bool) Option { + return optionFunc(func(r *Router) { + r.handleMethodNotAllowed = enable + }) +} + +// WithRedirectFixedPath enable automatic redirection fallback when the current request does not match but +// another handler is found after cleaning up superfluous path elements (see CleanPath). E.g. /../foo/bar request +// does not match but /foo/bar would. The client is redirected with a http status code 301 for GET requests +// and 308 for all other methods. +func WithRedirectFixedPath(enable bool) Option { + return optionFunc(func(r *Router) { + r.redirectFixedPath = enable + }) +} + +// WithRedirectTrailingSlash enable automatic redirection fallback when the current request does not match but +// another handler is found with/without an additional trailing slash. E.g. /foo/bar/ request does not match +// but /foo/bar would match. The client is redirected with a http status code 301 for GET requests and 308 for +// all other methods. +func WithRedirectTrailingSlash(enable bool) Option { + return optionFunc(func(r *Router) { + r.redirectTrailingSlash = enable + }) +} + +// WithSaveMatchedRoute configure the router to make the matched route accessible as a Handler parameter. +// Usage: p.Get(fox.RouteKey) +func WithSaveMatchedRoute(enable bool) Option { + return optionFunc(func(r *Router) { + r.saveMatchedRoute = enable + }) +} diff --git a/params.go b/params.go index 195f65c..a7dacb9 100644 --- a/params.go +++ b/params.go @@ -3,7 +3,6 @@ package fox import ( "context" "net/http" - "sync/atomic" ) const RouteKey = "$k/fox" @@ -36,25 +35,13 @@ func (p *Params) Clone() Params { return cloned } -func (fox *Router) newParams() *Params { - return fox.p.Get().(*Params) -} - -func (p *Params) free(fox *Router) { - if cap(*p) < int(atomic.LoadUint32(&fox.maxParams)) { +// Free release the params to be reused later. +func (p *Params) Free(t *Tree) { + if cap(*p) < int(t.maxParams.Load()) { return } - *p = (*p)[:0] - fox.p.Put(p) -} - -// updateMaxParams perform an update only if max is greater than the current -// max params. This function should be guarded by mutex. -func (fox *Router) updateMaxParams(max uint32) { - if max > atomic.LoadUint32(&fox.maxParams) { - atomic.StoreUint32(&fox.maxParams, max) - } + t.p.Put(p) } // ParamsFromContext is a helper function to retrieve parameters from the request context. diff --git a/params_test.go b/params_test.go index 1793fb7..e79d3e9 100644 --- a/params_test.go +++ b/params_test.go @@ -8,7 +8,7 @@ import ( ) func TestWrapHandler(t *testing.T) { - r := New() + tree := New().Tree() cases := []struct { name string @@ -71,8 +71,8 @@ func TestWrapHandler(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - params := r.newParams() - defer params.free(r) + params := tree.newParams() + defer params.Free(tree) *params = append(*params, tc.params...) req := httptest.NewRequest(http.MethodGet, "/", nil) @@ -82,9 +82,9 @@ func TestWrapHandler(t *testing.T) { } func TestParamsClone(t *testing.T) { - r := New() - params := r.newParams() - defer params.free(r) + tree := New().Tree() + params := tree.newParams() + defer params.Free(tree) *params = append(*params, Param{ Key: "foo", diff --git a/path.go b/path.go index 8aa5ddf..571227e 100644 --- a/path.go +++ b/path.go @@ -1,6 +1,6 @@ // Copyright 2013 Julien Schmidt. All rights reserved. // Based on the path package, Copyright 2009 The Go Authors. -// Use of this source code is governed by a BSD-style license that can be found +// Mount of this source code is governed by a BSD-style license that can be found // at https://github.com/julienschmidt/httprouter/blob/master/LICENSE. package fox diff --git a/router.go b/router.go index 8bca827..303c9fa 100644 --- a/router.go +++ b/router.go @@ -34,143 +34,176 @@ func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request, params Pa f(w, r, params) } +// Router is a lightweight high performance HTTP request router that support mutation on its routing tree +// while handling request concurrently. type Router struct { - p sync.Pool - // User-configurable http.Handler which is called when no matching route is found. // By default, http.NotFound is used. - NotFound http.Handler + notFound http.Handler // User-configurable http.Handler which is called when the request cannot be routed, // but the same route exist for other methods. The "Allow" header it automatically set // before calling the handler. Set HandleMethodNotAllowed to true to enable this option. By default, // http.Error with http.StatusMethodNotAllowed is used. - MethodNotAllowed http.Handler + methodNotAllowed http.Handler // Register a function to handle panics recovered from http handlers. - PanicHandler func(http.ResponseWriter, *http.Request, interface{}) + panicHandler func(http.ResponseWriter, *http.Request, interface{}) - trees *atomic.Pointer[[]*node] - mu sync.Mutex - maxParams uint32 + tree atomic.Pointer[Tree] // If enabled, fox return a 405 Method Not Allowed instead of 404 Not Found when the route exist for another http verb. - HandleMethodNotAllowed bool - - // If enabled, the matched route will be accessible as a Handler parameter. - // Usage: p.Get(fox.RouteKey) - AddRouteParam bool + handleMethodNotAllowed bool // Enable automatic redirection fallback when the current request does not match but another handler is found // after cleaning up superfluous path elements (see CleanPath). E.g. /../foo/bar request does not match but /foo/bar would. // The client is redirected with a http status code 301 for GET requests and 308 for all other methods. - RedirectFixedPath bool + redirectFixedPath bool // Enable automatic redirection fallback when the current request does not match but another handler is found // with/without an additional trailing slash. E.g. /foo/bar/ request does not match but /foo/bar would match. // The client is redirected with a http status code 301 for GET requests and 308 for all other methods. - RedirectTrailingSlash bool + redirectTrailingSlash bool + + // If enabled, the matched route will be accessible as a Handler parameter. + // Usage: p.Get(fox.RouteKey) + saveMatchedRoute bool } var _ http.Handler = (*Router)(nil) // New returns a ready to use Router. -func New() *Router { - var ptr atomic.Pointer[[]*node] +func New(opts ...Option) *Router { + r := new(Router) + for _, opt := range opts { + opt.apply(r) + } + r.tree.Store(r.NewTree()) + return r +} + +// NewTree returns a fresh routing Tree which allow to register, update and delete route. +// It's safe to create multiple Tree concurrently. However, a Tree itself is not thread safe +// and all its APIs should be run serially. Note that a Tree give direct access to the +// underlying sync.Mutex. +// This api is EXPERIMENTAL and is likely to change in future release. +func (fox *Router) NewTree() *Tree { + tree := new(Tree) + tree.saveRoute = fox.saveMatchedRoute // Pre instantiate nodes for common http verb nds := make([]*node, len(commonVerbs)) for i := range commonVerbs { nds[i] = new(node) nds[i].key = commonVerbs[i] } - ptr.Store(&nds) + tree.nodes.Store(&nds) - mux := &Router{ - trees: &ptr, - } - mux.p = sync.Pool{ - New: func() interface{} { - params := make(Params, 0, atomic.LoadUint32(&mux.maxParams)) + tree.p = sync.Pool{ + New: func() any { + params := make(Params, 0, tree.maxParams.Load()) return ¶ms }, } - return mux + + return tree } // Handler registers a new handler for the given method and path. This function return an error if the route -// is already registered or conflict with another. It's perfectly safe to add a new handler while serving requests. -// This function is safe for concurrent use by multiple goroutine. To override an existing route, use Update. +// is already registered or conflict with another. It's perfectly safe to add a new handler while the tree is in use +// for serving requests. This function is safe for concurrent use by multiple goroutine. +// To override an existing route, use Update. func (fox *Router) Handler(method, path string, handler Handler) error { - return fox.addRoute(method, path, handler) + t := fox.Tree() + t.Lock() + defer t.Unlock() + return t.Handler(method, path, handler) } // Update override an existing handler for the given method and path. If the route does not exist, -// the function return an ErrRouteNotFound. It's perfectly safe to update a handler while serving requests. -// This function is safe for concurrent use by multiple goroutine. To add new handler, use Handler method. +// the function return an ErrRouteNotFound. It's perfectly safe to update a handler while the tree is in use for +// serving requests. This function is safe for concurrent use by multiple goroutine. +// To add new handler, use Handler method. func (fox *Router) Update(method, path string, handler Handler) error { - p, catchAllKey, _, err := parseRoute(path) - if err != nil { - return err - } - - fox.mu.Lock() - defer fox.mu.Unlock() - - return fox.update(method, p, catchAllKey, handler) + t := fox.Tree() + t.Lock() + defer t.Unlock() + return t.Update(method, path, handler) } // Remove delete an existing handler for the given method and path. If the route does not exist, the function -// return an ErrRouteNotFound. It's perfectly safe to remove a handler while serving requests. This -// function is safe for concurrent use by multiple goroutine. +// return an ErrRouteNotFound. It's perfectly safe to remove a handler while the tree is in use for serving requests. +// This function is safe for concurrent use by multiple goroutine. func (fox *Router) Remove(method, path string) error { - fox.mu.Lock() - defer fox.mu.Unlock() + t := fox.Tree() + t.Lock() + defer t.Unlock() + return t.Remove(method, path) +} - path, _, _, err := parseRoute(path) - if err != nil { - return err - } +// Tree atomically loads and return the currently in-use routing tree. +// This API is EXPERIMENTAL and is likely to change in future release. +func (fox *Router) Tree() *Tree { + return fox.tree.Load() +} - if !fox.remove(method, path) { - return ErrRouteNotFound - } - return nil +// Swap atomically replaces the currently in-use routing tree with the provided new tree, and returns the previous tree. +// This API is EXPERIMENTAL and is likely to change in future release. +func (fox *Router) Swap(new *Tree) (old *Tree) { + return fox.tree.Swap(new) } -// Lookup allow to do manual lookup of a route. Please note that params are only valid until fn callback returns (see Handler interface). -// If lazy is set to true, params are not parsed. This function is safe for concurrent use by multiple goroutine. -func (fox *Router) Lookup(method, path string, lazy bool, fn func(handler Handler, params Params, tsr bool)) { - nds := *fox.trees.Load() +// Use atomically replaces the currently in-use routing tree with the provided new tree. +// This API is EXPERIMENTAL and is likely to change in future release. +func (fox *Router) Use(new *Tree) { + fox.tree.Store(new) +} + +// Lookup allow to do manual lookup of a route and return the matched handler along with parsed params and +// trailing slash redirect recommendation. Note that you should always free Params if NOT nil by calling +// params.Free(t). If lazy is set to true, route params are not parsed. This function is safe for concurrent use +// by multiple goroutine and while mutation on Tree are ongoing. +func Lookup(t *Tree, method, path string, lazy bool) (handler Handler, params *Params, tsr bool) { + nds := t.load() index := findRootNode(method, nds) if index < 0 { - fn(nil, nil, false) - return + return nil, nil, false } - n, params, tsr := fox.lookup(nds[index], path, lazy) + n, ps, tsr := t.lookup(nds[index], path, lazy) if n != nil { - if params != nil { - fn(n.handler, *params, tsr) - params.free(fox) - return - } - fn(n.handler, nil, tsr) - return + return n.handler, ps, tsr } - fn(nil, nil, tsr) + return nil, nil, tsr } -// Match perform a lazy lookup and return true if the requested method and path match a registered route. -// This function is safe for concurrent use by multiple goroutine. -func (fox *Router) Match(method, path string) bool { - nds := *fox.trees.Load() +// Has allows to check if the given method and path exactly match a registered route. This function is safe for +// concurrent use by multiple goroutine and while mutation on Tree are ongoing. +// This api is EXPERIMENTAL and is likely to change in future release. +func Has(t *Tree, method, path string) bool { + nds := t.load() index := findRootNode(method, nds) if index < 0 { return false } - n, _, _ := fox.lookup(nds[index], path, true) - return n != nil + n, _, _ := t.lookup(nds[index], path, true) + return n != nil && n.path == path +} + +// Reverse perform a lookup on the tree for the given method and path and return the matching registered route if any. +// This function is safe for concurrent use by multiple goroutine and while mutation on Tree are ongoing. +// This api is EXPERIMENTAL and is likely to change in future release. +func Reverse(t *Tree, method, path string) string { + nds := t.load() + index := findRootNode(method, nds) + if index < 0 { + return "" + } + n, _, _ := t.lookup(nds[index], path, true) + if n == nil { + return "" + } + return n.path } // SkipMethod is used as a return value from WalkFunc to indicate that @@ -182,9 +215,10 @@ type WalkFunc func(method, path string, handler Handler) error // Walk allow to walk over all registered route in lexicographical order. If the function // return the special value SkipMethod, Walk skips the current method. This function is -// safe for concurrent use by multiple goroutine. -func (fox *Router) Walk(fn WalkFunc) error { - nds := *fox.trees.Load() +// safe for concurrent use by multiple goroutine and while mutation are ongoing. +// This api is EXPERIMENTAL and is likely to change in future release. +func Walk(tree *Tree, fn WalkFunc) error { + nds := tree.load() NEXT: for i := range nds { method := nds[i].key @@ -204,7 +238,7 @@ NEXT: } func (fox *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if fox.PanicHandler != nil { + if fox.panicHandler != nil { defer fox.recover(w, r) } @@ -214,26 +248,25 @@ func (fox *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) { tsr bool ) - path := r.URL.Path - - nds := *fox.trees.Load() + tree := fox.Tree() + nds := tree.load() index := findRootNode(r.Method, nds) if index < 0 { goto NO_METHOD_FALLBACK } - n, params, tsr = fox.lookup(nds[index], path, false) + n, params, tsr = tree.lookup(nds[index], r.URL.Path, false) if n != nil { if params != nil { n.handler.ServeHTTP(w, r, *params) - params.free(fox) + params.Free(tree) return } n.handler.ServeHTTP(w, r, nil) return } - if r.Method != http.MethodConnect && path != "/" { + if r.Method != http.MethodConnect && r.URL.Path != "/" { code := http.StatusMovedPermanently if r.Method != http.MethodGet { @@ -241,21 +274,21 @@ func (fox *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) { code = http.StatusPermanentRedirect } - if tsr && fox.RedirectTrailingSlash { - r.URL.Path = fixTrailingSlash(path) + if tsr && fox.redirectTrailingSlash { + r.URL.Path = fixTrailingSlash(r.URL.Path) http.Redirect(w, r, r.URL.String(), code) return } - if fox.RedirectFixedPath { - cleanedPath := CleanPath(path) - n, _, tsr := fox.lookup(nds[index], cleanedPath, true) + if fox.redirectFixedPath { + cleanedPath := CleanPath(r.URL.Path) + n, _, tsr := tree.lookup(nds[index], cleanedPath, true) if n != nil { r.URL.Path = cleanedPath http.Redirect(w, r, r.URL.String(), code) return } - if tsr && fox.RedirectTrailingSlash { + if tsr && fox.redirectTrailingSlash { r.URL.Path = fixTrailingSlash(cleanedPath) http.Redirect(w, r, r.URL.String(), code) return @@ -265,11 +298,11 @@ func (fox *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) { } NO_METHOD_FALLBACK: - if fox.HandleMethodNotAllowed { + if fox.handleMethodNotAllowed { var sb strings.Builder for i := 0; i < len(nds); i++ { if nds[i].key != r.Method { - if n, _, _ := fox.lookup(nds[i], path, true); n != nil { + if n, _, _ := tree.lookup(nds[i], r.URL.Path, true); n != nil { if sb.Len() > 0 { sb.WriteString(", ") } @@ -280,8 +313,8 @@ NO_METHOD_FALLBACK: allowed := sb.String() if allowed != "" { w.Header().Set("Allow", allowed) - if fox.MethodNotAllowed != nil { - fox.MethodNotAllowed.ServeHTTP(w, r) + if fox.methodNotAllowed != nil { + fox.methodNotAllowed.ServeHTTP(w, r) return } http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) @@ -289,601 +322,19 @@ NO_METHOD_FALLBACK: } } - if fox.NotFound != nil { - fox.NotFound.ServeHTTP(w, r) + if fox.notFound != nil { + fox.notFound.ServeHTTP(w, r) return } http.NotFound(w, r) } -func (fox *Router) addRoute(method, path string, handler Handler) error { - p, catchAllKey, n, err := parseRoute(path) - if err != nil { - return err - } - - fox.mu.Lock() - defer fox.mu.Unlock() - - if fox.AddRouteParam { - n += 1 - } - - return fox.insert(method, p, catchAllKey, uint32(n), handler) -} - func (fox *Router) recover(w http.ResponseWriter, r *http.Request) { if val := recover(); val != nil { if abortErr, ok := val.(error); ok && errors.Is(abortErr, http.ErrAbortHandler) { panic(abortErr) } - fox.PanicHandler(w, r, val) - } -} - -func (fox *Router) lookup(rootNode *node, path string, lazy bool) (n *node, params *Params, tsr bool) { - var ( - charsMatched int - charsMatchedInNodeFound int - ) - - current := rootNode -STOP: - for charsMatched < len(path) { - idx := linearSearch(current.childKeys, path[charsMatched]) - if idx < 0 { - if !current.paramChild { - break STOP - } - idx = 0 - } - - current = current.get(idx) - charsMatchedInNodeFound = 0 - for i := 0; charsMatched < len(path); i++ { - if i >= len(current.key) { - break - } - - if current.key[i] != path[charsMatched] || path[charsMatched] == ':' { - if current.key[i] == ':' { - startPath := charsMatched - idx := strings.Index(path[charsMatched:], "/") - if idx >= 0 { - charsMatched += idx - } else { - charsMatched += len(path[charsMatched:]) - } - startKey := charsMatchedInNodeFound - idx = strings.Index(current.key[startKey:], "/") - if idx >= 0 { - // -1 since on the next incrementation, if any, 'i' are going to be incremented - i += idx - 1 - charsMatchedInNodeFound += idx - } else { - // -1 since on the next incrementation, if any, 'i' are going to be incremented - i += len(current.key[charsMatchedInNodeFound:]) - 1 - charsMatchedInNodeFound += len(current.key[charsMatchedInNodeFound:]) - } - if !lazy { - if params == nil { - params = fox.newParams() - } - // :n where n > 0 - *params = append(*params, Param{Key: current.key[startKey+1 : charsMatchedInNodeFound], Value: path[startPath:charsMatched]}) - } - continue - } - break STOP - } - - charsMatched++ - charsMatchedInNodeFound++ - } - } - - if !current.isLeaf() { - return nil, params, false - } - - if charsMatched == len(path) { - if charsMatchedInNodeFound == len(current.key) { - // Exact match, note that if we match a wildcard node, the param value is always '/' - if !lazy && (fox.AddRouteParam || current.isCatchAll()) { - if params == nil { - params = fox.newParams() - } - - if fox.AddRouteParam { - *params = append(*params, Param{Key: RouteKey, Value: current.path}) - } - - if current.isCatchAll() { - *params = append(*params, Param{Key: current.catchAllKey, Value: path[charsMatched-1:]}) - } - - return current, params, false - } - return current, params, false - } else if charsMatchedInNodeFound < len(current.key) { - // Key end mid-edge - // Tsr recommendation: add an extra trailing slash (got an exact match) - remainingSuffix := current.key[charsMatchedInNodeFound:] - return nil, nil, len(remainingSuffix) == 1 && remainingSuffix[0] == '/' - } - } - - // Incomplete match to end of edge - if charsMatched < len(path) && charsMatchedInNodeFound == len(current.key) { - if current.isCatchAll() { - if !lazy { - if params == nil { - params = fox.newParams() - } - *params = append(*params, Param{Key: current.catchAllKey, Value: path[charsMatched-1:]}) - if fox.AddRouteParam { - *params = append(*params, Param{Key: RouteKey, Value: current.path}) - } - return current, params, false - } - // Same as exact match, no tsr recommendation - return current, params, false - } - // Tsr recommendation: remove the extra trailing slash (got an exact match) - remainingKeySuffix := path[charsMatched:] - return nil, nil, len(remainingKeySuffix) == 1 && remainingKeySuffix[0] == '/' - } - - return nil, nil, false -} - -// updateRoot is not safe for concurrent use. -func (fox *Router) updateRoot(n *node) bool { - nds := *fox.trees.Load() - index := findRootNode(n.key, nds) - if index < 0 { - return false - } - newNds := make([]*node, 0, len(nds)) - newNds = append(newNds, nds[:index]...) - newNds = append(newNds, n) - newNds = append(newNds, nds[index+1:]...) - fox.trees.Store(&newNds) - return true -} - -// addRoot is not safe for concurrent use. -func (fox *Router) addRoot(n *node) { - nds := *fox.trees.Load() - newNds := make([]*node, 0, len(nds)+1) - newNds = append(newNds, nds...) - newNds = append(newNds, n) - fox.trees.Store(&newNds) -} - -// removeRoot is not safe for concurrent use. -func (fox *Router) removeRoot(method string) bool { - nds := *fox.trees.Load() - index := findRootNode(method, nds) - if index < 0 { - return false - } - newNds := make([]*node, 0, len(nds)-1) - newNds = append(newNds, nds[:index]...) - newNds = append(newNds, nds[index+1:]...) - fox.trees.Store(&newNds) - return true -} - -// update is not safe for concurrent use. -func (fox *Router) update(method string, path, catchAllKey string, handler Handler) error { - // Note that we need a consistent view of the tree during the patching so search must imperatively be locked. - nds := *fox.trees.Load() - index := findRootNode(method, nds) - if index < 0 { - return fmt.Errorf("route [%s] %s is not registered: %w", method, path, ErrRouteNotFound) - } - - result := fox.search(nds[index], path) - if !result.isExactMatch() || !result.matched.isLeaf() { - return fmt.Errorf("route [%s] %s is not registered: %w", method, path, ErrRouteNotFound) - } - - if catchAllKey != "" && len(result.matched.children) > 0 { - return newConflictErr(method, path, catchAllKey, getRouteConflict(result.matched)[1:]) - } - - // We are updating an existing node (could be a leaf or not). We only need to create a new node from - // the matched one with the updated/added value (handler and wildcard). - n := newNodeFromRef( - result.matched.key, - handler, - result.matched.children, - result.matched.childKeys, - catchAllKey, - result.matched.paramChild, - path, - ) - result.p.updateEdge(n) - return nil -} - -// insert is not safe for concurrent use. -func (fox *Router) insert(method, path, catchAllKey string, paramsN uint32, handler Handler) error { - // Note that we need a consistent view of the tree during the patching so search must imperatively be locked. - if method == "" { - return fmt.Errorf("http method is missing: %w", ErrInvalidRoute) - } - - var rootNode *node - nds := *fox.trees.Load() - index := findRootNode(method, nds) - if index < 0 { - rootNode = &node{key: method} - fox.addRoot(rootNode) - } else { - rootNode = nds[index] - } - - isCatchAll := catchAllKey != "" - - result := fox.search(rootNode, path) - switch result.classify() { - case exactMatch: - // e.g. matched exactly "te" node when inserting "te" key. - // te - // ├── st - // └── am - // Create a new node from "st" reference and update the "te" (parent) reference to "st" node. - if result.matched.isLeaf() { - return fmt.Errorf("route [%s] %s conflict: %w", method, path, ErrRouteExist) - } - - // The matched node can only be the result of a previous split and therefore has children. - if isCatchAll { - return newConflictErr(method, path, catchAllKey, getRouteConflict(result.matched)) - } - // We are updating an existing node. We only need to create a new node from - // the matched one with the updated/added value (handler and wildcard). - n := newNodeFromRef(result.matched.key, handler, result.matched.children, result.matched.childKeys, catchAllKey, result.matched.paramChild, path) - - fox.updateMaxParams(paramsN) - result.p.updateEdge(n) - case keyEndMidEdge: - // e.g. matched until "s" for "st" node when inserting "tes" key. - // te - // ├── st - // └── am - // - // After patching - // te - // ├── am - // └── s - // └── t - // It requires to split "st" node. - // 1. Create a "t" node from "st" reference. - // 2. Create a new "s" node for "tes" key and link it to the child "t" node. - // 3. Update the "te" (parent) reference to the new "s" node (we are swapping old "st" to new "s" node, first - // char remain the same). - - if isCatchAll { - return newConflictErr(method, path, catchAllKey, getRouteConflict(result.matched)) - } - - keyCharsFromStartOfNodeFound := path[result.charsMatched-result.charsMatchedInNodeFound:] - cPrefix := commonPrefix(keyCharsFromStartOfNodeFound, result.matched.key) - suffixFromExistingEdge := strings.TrimPrefix(result.matched.key, cPrefix) - // Rule: a node with :param has no child or has a separator before the end of the key or its child - // start with a separator - if !strings.HasPrefix(suffixFromExistingEdge, "/") { - for i := len(cPrefix) - 1; i >= 0; i-- { - if cPrefix[i] == '/' { - break - } - if cPrefix[i] == ':' { - return newConflictErr(method, path, catchAllKey, getRouteConflict(result.matched)) - } - } - } - - child := newNodeFromRef( - suffixFromExistingEdge, - result.matched.handler, - result.matched.children, - result.matched.childKeys, - result.matched.catchAllKey, - result.matched.paramChild, - result.matched.path, - ) - - parent := newNode( - cPrefix, - handler, - []*node{child}, - catchAllKey, - // e.g. tree encode /tes/:t and insert /tes/ - // /tes/ (paramChild) - // ├── :t - // since /tes/xyz will match until /tes/ and when looking for next child, 'x' will match nothing - // if paramChild == true { - // next = current.get(0) - // } - strings.HasPrefix(suffixFromExistingEdge, ":"), - path, - ) - - fox.updateMaxParams(paramsN) - result.p.updateEdge(parent) - case incompleteMatchToEndOfEdge: - // e.g. matched until "st" for "st" node but still have remaining char (ify) when inserting "testify" key. - // te - // ├── st - // └── am - // - // After patching - // te - // ├── am - // └── st - // └── ify - // 1. Create a new "ify" child node. - // 2. Recreate the "st" node and link it to it's existing children and the new "ify" node. - // 3. Update the "te" (parent) node to the new "st" node. - - if result.matched.isCatchAll() { - return newConflictErr(method, path, catchAllKey, getRouteConflict(result.matched)) - } - - keySuffix := path[result.charsMatched:] - // Rule: a node with :param has no child or has a separator before the end of the key - // make sure than and existing params :x is not extended to :xy - // :x/:y is of course valid - if !strings.HasPrefix(keySuffix, "/") { - for i := len(result.matched.key) - 1; i >= 0; i-- { - if result.matched.key[i] == '/' { - break - } - if result.matched.key[i] == ':' { - return newConflictErr(method, path, catchAllKey, getRouteConflict(result.matched)) - } - } - } - - // No children, so no paramChild - child := newNode(keySuffix, handler, nil, catchAllKey, false, path) - edges := result.matched.getEdgesShallowCopy() - edges = append(edges, child) - n := newNode( - result.matched.key, - result.matched.handler, - edges, - result.matched.catchAllKey, - // e.g. tree encode /tes/ and insert /tes/:t - // /tes/ (paramChild) - // ├── :t - // since /tes/xyz will match until /tes/ and when looking for next child, 'x' will match nothing - // if paramChild == true { - // next = current.get(0) - // } - strings.HasPrefix(keySuffix, ":"), - result.matched.path, - ) - - fox.updateMaxParams(paramsN) - if result.matched == rootNode { - n.key = method - fox.updateRoot(n) - break - } - result.p.updateEdge(n) - case incompleteMatchToMiddleOfEdge: - // e.g. matched until "s" for "st" node but still have remaining char ("s") which does not match anything - // when inserting "tess" key. - // te - // ├── st - // └── am - // - // After patching - // te - // ├── am - // └── s - // ├── s - // └── t - // It requires to split "st" node. - // 1. Create a new "s" child node for "tess" key. - // 2. Create a new "t" node from "st" reference (link "st" children to new "t" node). - // 3. Create a new "s" node and link it to "s" and "t" node. - // 4. Update the "te" (parent) node to the new "s" node (we are swapping old "st" to new "s" node, first - // char remain the same). - - keyCharsFromStartOfNodeFound := path[result.charsMatched-result.charsMatchedInNodeFound:] - cPrefix := commonPrefix(keyCharsFromStartOfNodeFound, result.matched.key) - - // Rule: a node with :param has no child or has a separator before the end of the key - for i := len(cPrefix) - 1; i >= 0; i-- { - if cPrefix[i] == '/' { - break - } - if cPrefix[i] == ':' { - return newConflictErr(method, path, catchAllKey, getRouteConflict(result.matched)) - } - } - - suffixFromExistingEdge := strings.TrimPrefix(result.matched.key, cPrefix) - // Rule: parent's of a node with :param have only one node or are prefixed by a char (e.g /:param) - if strings.HasPrefix(suffixFromExistingEdge, ":") { - return newConflictErr(method, path, catchAllKey, getRouteConflict(result.matched)) - } - - keySuffix := path[result.charsMatched:] - // Rule: parent's of a node with :param have only one node or are prefixed by a char (e.g /:param) - if strings.HasPrefix(keySuffix, ":") { - return newConflictErr(method, path, catchAllKey, getRouteConflict(result.matched)) - } - - // No children, so no paramChild - n1 := newNodeFromRef(keySuffix, handler, nil, nil, catchAllKey, false, path) // inserted node - n2 := newNodeFromRef( - suffixFromExistingEdge, - result.matched.handler, - result.matched.children, - result.matched.childKeys, - result.matched.catchAllKey, - result.matched.paramChild, - result.matched.path, - ) // previous matched node - - // n3 children never start with a param - n3 := newNode(cPrefix, nil, []*node{n1, n2}, "", false, "") // intermediary node - - fox.updateMaxParams(paramsN) - result.p.updateEdge(n3) - default: - // safeguard against introducing a new result type - panic("internal error: unexpected result type") - } - return nil -} - -// remove is not safe for concurrent use. -func (fox *Router) remove(method, path string) bool { - nds := *fox.trees.Load() - index := findRootNode(method, nds) - if index < 0 { - return false - } - - result := fox.search(nds[index], path) - if result.classify() != exactMatch { - return false - } - - // This node was created after a split (KEY_END_MID_EGGE operation), therefore we cannot delete - // this node. - if !result.matched.isLeaf() { - return false - } - - if len(result.matched.children) > 1 { - n := newNodeFromRef( - result.matched.key, - nil, - result.matched.children, - result.matched.childKeys, - "", - result.matched.paramChild, - "", - ) - result.p.updateEdge(n) - return true - } - - if len(result.matched.children) == 1 { - child := result.matched.get(0) - mergedPath := fmt.Sprintf("%s%s", result.matched.key, child.key) - n := newNodeFromRef( - mergedPath, - child.handler, - child.children, - child.childKeys, - child.catchAllKey, - child.paramChild, - child.path, - ) - result.p.updateEdge(n) - return true - } - - // recreate the parent edges without the removed node - parentEdges := make([]*node, len(result.p.children)-1) - added := 0 - for i := 0; i < len(result.p.children); i++ { - n := result.p.get(i) - if n != result.matched { - parentEdges[added] = n - added++ - } - } - - parentIsRoot := result.p == nds[index] - var parent *node - if len(parentEdges) == 1 && !result.p.isLeaf() && !parentIsRoot { - child := parentEdges[0] - mergedPath := fmt.Sprintf("%s%s", result.p.key, child.key) - parent = newNodeFromRef( - mergedPath, - child.handler, - child.children, - child.childKeys, - child.catchAllKey, - child.paramChild, - child.path, - ) - } else { - parent = newNode( - result.p.key, - result.p.handler, - parentEdges, - result.p.catchAllKey, - result.p.paramChild, - result.p.path, - ) - } - - if parentIsRoot { - if len(parent.children) == 0 && isRemovable(method) { - return fox.removeRoot(method) - } - parent.key = method - fox.updateRoot(parent) - return true - } - - result.pp.updateEdge(parent) - return true -} - -func (fox *Router) search(rootNode *node, path string) searchResult { - current := rootNode - - var ( - pp *node - p *node - charsMatched int - charsMatchedInNodeFound int - ) - -STOP: - for charsMatched < len(path) { - next := current.getEdge(path[charsMatched]) - if next == nil { - break STOP - } - - pp = p - p = current - current = next - charsMatchedInNodeFound = 0 - for i := 0; charsMatched < len(path); i++ { - if i >= len(current.key) { - break - } - - if current.key[i] != path[charsMatched] { - break STOP - } - - charsMatched++ - charsMatchedInNodeFound++ - } - } - - return searchResult{ - path: path, - matched: current, - charsMatched: charsMatched, - charsMatchedInNodeFound: charsMatchedInNodeFound, - p: p, - pp: pp, + fox.panicHandler(w, r, val) } } diff --git a/router_test.go b/router_test.go index 4f330d1..55927d7 100644 --- a/router_test.go +++ b/router_test.go @@ -469,15 +469,36 @@ func benchRouteParallel(b *testing.B, router http.Handler, rte route) { func BenchmarkStaticAll(b *testing.B) { r := New() for _, route := range staticRoutes { - require.NoError(b, r.Handler(route.method, route.path, HandlerFunc(func(w http.ResponseWriter, r *http.Request, p Params) {}))) + require.NoError(b, r.Tree().Handler(route.method, route.path, HandlerFunc(func(w http.ResponseWriter, r *http.Request, p Params) {}))) } + benchRoutes(b, r, staticRoutes) } +func BenchmarkLookup(b *testing.B) { + r := New() + for _, route := range staticRoutes { + require.NoError(b, r.Tree().Handler(route.method, route.path, HandlerFunc(func(w http.ResponseWriter, r *http.Request, p Params) {}))) + } + + b.ReportAllocs() + b.ResetTimer() + + tree := r.Tree() + for i := 0; i < b.N; i++ { + for _, route := range staticRoutes { + _, p, _ := Lookup(tree, route.method, route.path, false) + if p != nil { + p.Free(tree) + } + } + } +} + func BenchmarkGithubParamsAll(b *testing.B) { r := New() for _, route := range githubAPI { - require.NoError(b, r.Handler(route.method, route.path, HandlerFunc(func(w http.ResponseWriter, r *http.Request, p Params) {}))) + require.NoError(b, r.Tree().Handler(route.method, route.path, HandlerFunc(func(w http.ResponseWriter, r *http.Request, p Params) {}))) } req := httptest.NewRequest("GET", "/repos/sylvain/fox/hooks/1500", nil) @@ -494,14 +515,14 @@ func BenchmarkGithubParamsAll(b *testing.B) { func BenchmarkStaticParallel(b *testing.B) { r := New() for _, route := range staticRoutes { - require.NoError(b, r.Handler(route.method, route.path, HandlerFunc(func(_ http.ResponseWriter, _ *http.Request, _ Params) {}))) + require.NoError(b, r.Tree().Handler(route.method, route.path, HandlerFunc(func(_ http.ResponseWriter, _ *http.Request, _ Params) {}))) } benchRouteParallel(b, r, route{"GET", "/progs/image_package4.out"}) } func BenchmarkCatchAll(b *testing.B) { r := New() - require.NoError(b, r.Handler(http.MethodGet, "/something/*args", HandlerFunc(func(w http.ResponseWriter, r *http.Request, _ Params) {}))) + require.NoError(b, r.Tree().Handler(http.MethodGet, "/something/*args", HandlerFunc(func(w http.ResponseWriter, r *http.Request, _ Params) {}))) w := new(mockResponseWriter) req := httptest.NewRequest("GET", "/something/awesome", nil) @@ -515,7 +536,7 @@ func BenchmarkCatchAll(b *testing.B) { func BenchmarkCatchAllParallel(b *testing.B) { r := New() - require.NoError(b, r.Handler(http.MethodGet, "/something/*args", HandlerFunc(func(w http.ResponseWriter, r *http.Request, _ Params) {}))) + require.NoError(b, r.Tree().Handler(http.MethodGet, "/something/*args", HandlerFunc(func(w http.ResponseWriter, r *http.Request, _ Params) {}))) w := new(mockResponseWriter) req := httptest.NewRequest("GET", "/something/awesome", nil) @@ -534,7 +555,7 @@ func TestStaticRoute(t *testing.T) { h := HandlerFunc(func(w http.ResponseWriter, r *http.Request, _ Params) { _, _ = w.Write([]byte(r.URL.Path)) }) for _, route := range staticRoutes { - require.NoError(t, r.Handler(route.method, route.path, h)) + require.NoError(t, r.Tree().Handler(route.method, route.path, h)) } for _, route := range staticRoutes { @@ -547,9 +568,8 @@ func TestStaticRoute(t *testing.T) { } func TestParamsRoute(t *testing.T) { - rx := regexp.MustCompile("(:|\\*)[A-z_]+") - r := New() - r.AddRouteParam = true + rx := regexp.MustCompile("([:*])[A-z_]+") + r := New(WithSaveMatchedRoute(true)) h := HandlerFunc(func(w http.ResponseWriter, r *http.Request, params Params) { matches := rx.FindAllString(r.URL.Path, -1) for _, match := range matches { @@ -564,7 +584,7 @@ func TestParamsRoute(t *testing.T) { _, _ = w.Write([]byte(r.URL.Path)) }) for _, route := range githubAPI { - require.NoError(t, r.Handler(route.method, route.path, h)) + require.NoError(t, r.Tree().Handler(route.method, route.path, h)) } for _, route := range githubAPI { req := httptest.NewRequest(route.method, route.path, nil) @@ -589,7 +609,7 @@ func TestRouterWildcard(t *testing.T) { } for _, route := range routes { - require.NoError(t, r.Handler(http.MethodGet, route.path, h)) + require.NoError(t, r.Tree().Handler(http.MethodGet, route.path, h)) } for _, route := range routes { @@ -602,7 +622,7 @@ func TestRouterWildcard(t *testing.T) { } func TestRouteWithParams(t *testing.T) { - r := New() + tree := New().Tree() routes := [...]string{ "/", "/cmd/:tool/:sub", @@ -620,11 +640,12 @@ func TestRouteWithParams(t *testing.T) { "/info/:user/project/:project", } for _, rte := range routes { - require.NoError(t, r.addRoute(http.MethodGet, rte, emptyHandler)) + require.NoError(t, tree.Handler(http.MethodGet, rte, emptyHandler)) } - nds := *r.trees.Load() + + nds := tree.load() for _, rte := range routes { - n, _, _ := r.lookup(nds[0], rte, false) + n, _, _ := tree.lookup(nds[0], rte, false) require.NotNil(t, n) assert.Equal(t, rte, n.path) } @@ -690,13 +711,13 @@ func TestInsertWildcardConflict(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - r := New() + tree := New().Tree() for _, rte := range tc.routes { var catchAllKey string if rte.wildcard { catchAllKey = "args" } - err := r.insert(http.MethodGet, rte.path, catchAllKey, 0, h) + err := tree.insert(http.MethodGet, rte.path, catchAllKey, 0, h) assert.ErrorIs(t, err, rte.wantErr) if cErr, ok := err.(*RouteConflictError); ok { assert.Equal(t, rte.wantMatch, cErr.Matched) @@ -900,9 +921,9 @@ func TestInsertParamsConflict(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - r := New() + tree := New().Tree() for _, rte := range tc.routes { - err := r.insert(http.MethodGet, rte.path, rte.wildcard, 0, emptyHandler) + err := tree.insert(http.MethodGet, rte.path, rte.wildcard, 0, emptyHandler) if rte.wantErr != nil { assert.ErrorIs(t, err, rte.wantErr) if cErr, ok := err.(*RouteConflictError); ok { @@ -967,15 +988,15 @@ func TestSwapWildcardConflict(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - r := New() + tree := New().Tree() for _, rte := range tc.routes { var catchAllKey string if rte.wildcard { catchAllKey = "args" } - require.NoError(t, r.insert(http.MethodGet, rte.path, catchAllKey, 0, h)) + require.NoError(t, tree.insert(http.MethodGet, rte.path, catchAllKey, 0, h)) } - err := r.update(http.MethodGet, tc.path, tc.wildcard, h) + err := tree.update(http.MethodGet, tc.path, tc.wildcard, h) assert.ErrorIs(t, err, tc.wantErr) if cErr, ok := err.(*RouteConflictError); ok { assert.Equal(t, tc.wantMatch, cErr.Matched) @@ -1034,10 +1055,9 @@ func TestUpdateRoute(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - r := New() - r.AddRouteParam = true - require.NoError(t, r.Handler(http.MethodGet, tc.path, h)) - require.NoError(t, r.Update(http.MethodGet, tc.newPath+tc.newWildcardKey, tc.newHandler)) + r := New(WithSaveMatchedRoute(true)) + require.NoError(t, r.Tree().Handler(http.MethodGet, tc.path, h)) + require.NoError(t, r.Tree().Update(http.MethodGet, tc.newPath+tc.newWildcardKey, tc.newHandler)) req := httptest.NewRequest(http.MethodGet, tc.newPath, nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -1179,7 +1199,7 @@ func TestParseRoute(t *testing.T) { } } -func TestLookupTsr(t *testing.T) { +func TestTree_LookupTsr(t *testing.T) { h := HandlerFunc(func(w http.ResponseWriter, r *http.Request, _ Params) {}) cases := []struct { @@ -1224,10 +1244,10 @@ func TestLookupTsr(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - r := New() - require.NoError(t, r.insert(http.MethodGet, tc.path, "", 0, h)) - nds := *r.trees.Load() - _, _, got := r.lookup(nds[0], tc.key, true) + tree := New().Tree() + require.NoError(t, tree.insert(http.MethodGet, tc.path, "", 0, h)) + nds := tree.load() + _, _, got := tree.lookup(nds[0], tc.key, true) assert.Equal(t, tc.want, got) }) } @@ -1303,9 +1323,8 @@ func TestRedirectTrailingSlash(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - r := New() - r.RedirectTrailingSlash = true - require.NoError(t, r.Handler(tc.method, tc.path, h)) + r := New(WithRedirectTrailingSlash(true)) + require.NoError(t, r.Tree().Handler(tc.method, tc.path, h)) req := httptest.NewRequest(tc.method, tc.key, nil) w := httptest.NewRecorder() @@ -1357,10 +1376,8 @@ func TestRedirectFixedPath(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - r := New() - r.RedirectFixedPath = true - r.RedirectTrailingSlash = tc.tsr - require.NoError(t, r.Handler(http.MethodGet, tc.path, h)) + r := New(WithRedirectFixedPath(true), WithRedirectTrailingSlash(tc.tsr)) + require.NoError(t, r.Tree().Handler(http.MethodGet, tc.path, h)) req, _ := http.NewRequest(http.MethodGet, tc.key, nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -1373,8 +1390,7 @@ func TestRedirectFixedPath(t *testing.T) { } func TestRouterWithAllowedMethod(t *testing.T) { - r := New() - r.HandleMethodNotAllowed = true + r := New(WithHandleMethodNotAllowed(true)) h := HandlerFunc(func(w http.ResponseWriter, r *http.Request, _ Params) {}) cases := []struct { @@ -1410,7 +1426,7 @@ func TestRouterWithAllowedMethod(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { for _, method := range tc.methods { - require.NoError(t, r.Handler(method, tc.path, h)) + require.NoError(t, r.Tree().Handler(method, tc.path, h)) } req := httptest.NewRequest(tc.target, tc.path, nil) w := httptest.NewRecorder() @@ -1422,18 +1438,18 @@ func TestRouterWithAllowedMethod(t *testing.T) { } func TestPanicHandler(t *testing.T) { - r := New() - r.PanicHandler = func(w http.ResponseWriter, r *http.Request, i interface{}) { + r := New(WithPanicHandler(func(w http.ResponseWriter, r *http.Request, i interface{}) { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(i.(string))) - } + })) + const errMsg = "unexpected error" h := HandlerFunc(func(w http.ResponseWriter, r *http.Request, _ Params) { func() { panic(errMsg) }() w.Write([]byte("foo")) }) - require.NoError(t, r.Handler(http.MethodPost, "/", h)) + require.NoError(t, r.Tree().Handler(http.MethodPost, "/", h)) req := httptest.NewRequest(http.MethodPost, "/", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -1441,19 +1457,201 @@ func TestPanicHandler(t *testing.T) { assert.Equal(t, errMsg, w.Body.String()) } -func TestAbortHandler(t *testing.T) { +func TestHas(t *testing.T) { + routes := []string{ + "/foo/bar", + "/welcome/:name", + "/users/uid_:id", + } + + r := New() + for _, rte := range routes { + require.NoError(t, r.Handler(http.MethodGet, rte, emptyHandler)) + } + + cases := []struct { + name string + path string + want bool + }{ + { + name: "strict match static route", + path: "/foo/bar", + want: true, + }, + { + name: "no match static route", + path: "/foo/bar/", + }, + { + name: "strict match route params", + path: "/welcome/:name", + want: true, + }, + { + name: "no match route params", + path: "/welcome/fox", + }, + { + name: "strict match mid route params", + path: "/users/uid_:id", + want: true, + }, + { + name: "no match mid route params", + path: "/users/uid_123", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, Has(r.Tree(), http.MethodGet, tc.path)) + }) + } +} + +func TestReverse(t *testing.T) { + routes := []string{ + "/foo/bar", + "/welcome/:name", + "/users/uid_:id", + } + + r := New() + for _, rte := range routes { + require.NoError(t, r.Handler(http.MethodGet, rte, emptyHandler)) + } + + cases := []struct { + name string + path string + want string + }{ + { + name: "reverse static route", + path: "/foo/bar", + want: "/foo/bar", + }, + { + name: "reverse params route", + path: "/welcome/fox", + want: "/welcome/:name", + }, + { + name: "reverse mid params route", + path: "/users/uid_123", + want: "/users/uid_:id", + }, + { + name: "reverse no match", + path: "/users/fox", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, Reverse(r.Tree(), http.MethodGet, tc.path)) + }) + } +} + +func TestLookup(t *testing.T) { + routes := []string{ + "/foo/bar", + "/welcome/:name", + "/users/uid_:id", + "/john/doe/", + } + r := New() - r.PanicHandler = func(w http.ResponseWriter, r *http.Request, i interface{}) { + for _, rte := range routes { + require.NoError(t, r.Handler(http.MethodGet, rte, emptyHandler)) + } + + cases := []struct { + name string + path string + paramKey string + wantHandler bool + wantParamValue string + wantTsr bool + }{ + { + name: "matching static route", + path: "/foo/bar", + wantHandler: true, + }, + { + name: "tsr remove slash for static route", + path: "/foo/bar/", + wantTsr: true, + }, + { + name: "tsr add slash for static route", + path: "/john/doe", + wantTsr: true, + }, + { + name: "tsr for static route", + path: "/foo/bar/", + wantTsr: true, + }, + { + name: "matching params route", + path: "/welcome/fox", + wantHandler: true, + paramKey: "name", + wantParamValue: "fox", + }, + { + name: "tsr for params route", + path: "/welcome/fox/", + wantTsr: true, + }, + { + name: "matching mid route params", + path: "/users/uid_123", + wantHandler: true, + paramKey: "id", + wantParamValue: "123", + }, + { + name: "matching mid route params", + path: "/users/uid_123/", + wantTsr: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + handler, params, tsr := Lookup(r.Tree(), http.MethodGet, tc.path, false) + if params != nil { + defer params.Free(r.Tree()) + } + if tc.wantHandler { + assert.NotNil(t, handler) + } + assert.Equal(t, tc.wantTsr, tsr) + if tc.paramKey != "" { + require.NotNil(t, params) + assert.Equal(t, tc.wantParamValue, params.Get(tc.paramKey)) + } + }) + } +} + +func TestAbortHandler(t *testing.T) { + r := New(WithPanicHandler(func(w http.ResponseWriter, r *http.Request, i interface{}) { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(i.(error).Error())) - } + })) h := HandlerFunc(func(w http.ResponseWriter, r *http.Request, _ Params) { func() { panic(http.ErrAbortHandler) }() w.Write([]byte("foo")) }) - require.NoError(t, r.Handler(http.MethodPost, "/", h)) + require.NoError(t, r.Tree().Handler(http.MethodPost, "/", h)) req := httptest.NewRequest(http.MethodPost, "/", nil) w := httptest.NewRecorder() @@ -1476,7 +1674,7 @@ func TestFuzzInsertLookupParam(t *testing.T) { {First: 0x3B, Last: 0x04FF}, } - r := New() + tree := New().Tree() h := HandlerFunc(func(w http.ResponseWriter, r *http.Request, _ Params) {}) f := fuzz.New().NilChance(0).Funcs(unicodeRanges.CustomStringFuzzFunc()) routeFormat := "/%s/:%s/%s/:%s/:%s" @@ -1491,10 +1689,10 @@ func TestFuzzInsertLookupParam(t *testing.T) { if s1 == "" || s2 == "" || e1 == "" || e2 == "" || e3 == "" { continue } - if err := r.insert(http.MethodGet, fmt.Sprintf(routeFormat, s1, e1, s2, e2, e3), "", 3, h); err == nil { - nds := *r.trees.Load() + if err := tree.insert(http.MethodGet, fmt.Sprintf(routeFormat, s1, e1, s2, e2, e3), "", 3, h); err == nil { + nds := tree.load() - n, params, _ := r.lookup(nds[0], fmt.Sprintf(reqFormat, s1, "xxxx", s2, "xxxx", "xxxx"), false) + n, params, _ := tree.lookup(nds[0], fmt.Sprintf(reqFormat, s1, "xxxx", s2, "xxxx", "xxxx"), false) require.NotNil(t, n) assert.Equal(t, fmt.Sprintf(routeFormat, s1, e1, s2, e2, e3), n.path) assert.Equal(t, "xxxx", params.Get(e1)) @@ -1506,7 +1704,7 @@ func TestFuzzInsertLookupParam(t *testing.T) { func TestFuzzInsertNoPanics(t *testing.T) { f := fuzz.New().NilChance(0).NumElements(5000, 10000) - r := New() + tree := New().Tree() h := HandlerFunc(func(w http.ResponseWriter, r *http.Request, _ Params) {}) routes := make(map[string]struct{}) @@ -1519,7 +1717,7 @@ func TestFuzzInsertNoPanics(t *testing.T) { continue } require.NotPanicsf(t, func() { - _ = r.insert(http.MethodGet, rte, catchAllKey, 0, h) + _ = tree.insert(http.MethodGet, rte, catchAllKey, 0, h) }, fmt.Sprintf("rte: %s, catch all: %s", rte, catchAllKey)) } } @@ -1533,40 +1731,40 @@ func TestFuzzInsertLookupUpdateAndDelete(t *testing.T) { } f := fuzz.New().NilChance(0).NumElements(1000, 2000).Funcs(unicodeRanges.CustomStringFuzzFunc()) - r := New() + tree := New().Tree() h := HandlerFunc(func(w http.ResponseWriter, r *http.Request, _ Params) {}) routes := make(map[string]struct{}) f.Fuzz(&routes) for rte := range routes { - err := r.insert(http.MethodGet, "/"+rte, "", 0, h) + err := tree.insert(http.MethodGet, "/"+rte, "", 0, h) require.NoError(t, err) } countPath := 0 - require.NoError(t, r.Walk(func(method, path string, handler Handler) error { + require.NoError(t, Walk(tree, func(method, path string, handler Handler) error { countPath++ return nil })) assert.Equal(t, len(routes), countPath) for rte := range routes { - nds := *r.trees.Load() - n, _, _ := r.lookup(nds[0], "/"+rte, true) + nds := tree.load() + n, _, _ := tree.lookup(nds[0], "/"+rte, true) require.NotNilf(t, n, "route /%s", rte) require.Truef(t, n.isLeaf(), "route /%s", rte) require.Equal(t, "/"+rte, n.path) - require.NoError(t, r.update(http.MethodGet, "/"+rte, "", h)) + require.NoError(t, tree.update(http.MethodGet, "/"+rte, "", h)) } for rte := range routes { - deleted := r.remove(http.MethodGet, "/"+rte) + deleted := tree.remove(http.MethodGet, "/"+rte) require.True(t, deleted) } countPath = 0 - require.NoError(t, r.Walk(func(method, path string, handler Handler) error { + require.NoError(t, Walk(tree, func(method, path string, handler Handler) error { countPath++ return nil })) @@ -1596,7 +1794,6 @@ func TestDataRace(t *testing.T) { go func(method, route string) { wait() r.Update(method, route, newH) - // r.Update("PING", route, newH) wg.Done() }(rte.method, rte.path) @@ -1614,8 +1811,7 @@ func TestDataRace(t *testing.T) { } func TestConcurrentRequestHandling(t *testing.T) { - r := New() - r.AddRouteParam = true + r := New(WithSaveMatchedRoute(true)) // /repos/:owner/:repo/keys h1 := HandlerFunc(func(w http.ResponseWriter, r *http.Request, params Params) { @@ -1695,10 +1891,9 @@ func atomicSync() (start func(), wait func()) { return } -// When AddRouteParam is enabled, the route matching the current request will be available in parameters. +// When WithSaveMatchedRoute is enabled, the route matching the current request will be available in parameters. func ExampleNew() { - r := New() - r.AddRouteParam = true + r := New(WithSaveMatchedRoute(true)) metrics := func(next HandlerFunc) Handler { return HandlerFunc(func(w http.ResponseWriter, r *http.Request, params Params) { @@ -1712,3 +1907,65 @@ func ExampleNew() { _, _ = fmt.Fprintf(w, "Hello %s\n", params.Get("name")) })) } + +// This example demonstrates some important considerations when using the Lookup function. +func ExampleLookup() { + r := New() + _ = r.Handler(http.MethodGet, "/hello/:name", HandlerFunc(func(w http.ResponseWriter, r *http.Request, params Params) { + _, _ = fmt.Fprintf(w, "Hello, %s\n", params.Get("name")) + })) + + req := httptest.NewRequest(http.MethodGet, "/hello/fox", nil) + + // Each tree as its own sync.Pool that is used to reuse Params slice. Since the router tree may be swapped at + // any given time, it's recommended to copy the pointer locally so when the params is released, + // it returns to the correct pool. + tree := r.Tree() + handler, params, _ := Lookup(tree, http.MethodGet, req.URL.Path, false) + // If not nit, Params should be freed to reduce memory allocation. + if params != nil { + defer params.Free(tree) + } + + // Bad, instead make a local copy of the tree! + handler, params, _ = Lookup(r.Tree(), http.MethodGet, req.URL.Path, false) + if params != nil { + defer params.Free(r.Tree()) + } + + w := httptest.NewRecorder() + handler.ServeHTTP(w, req, nil) + fmt.Print(w.Body.String()) +} + +// This example demonstrates some important considerations when using the Tree API. +func ExampleRouter_Tree() { + r := New() + + // Each tree as its own sync.Mutex that is used to lock write on the tree. Since the router tree may be swapped at + // any given time, you MUST always copy the pointer locally, This ensures that you do not inadvertently cause a + // deadlock by locking/unlocking the wrong tree. + tree := r.Tree() + upsert := func(method, path string, handler Handler) error { + tree.Lock() + defer tree.Unlock() + if Has(tree, method, path) { + return tree.Update(method, path, handler) + } + return tree.Handler(method, path, handler) + } + + _ = upsert(http.MethodGet, "/foo/bar", HandlerFunc(func(w http.ResponseWriter, r *http.Request, params Params) { + _, _ = fmt.Fprintln(w, "foo bar") + })) + + // Bad, instead make a local copy of the tree! + upsert = func(method, path string, handler Handler) error { + r.Tree().Lock() + defer r.Tree().Unlock() + if Has(r.Tree(), method, path) { + return r.Tree().Update(method, path, handler) + } + return r.Tree().Handler(method, path, handler) + } +} diff --git a/tree.go b/tree.go new file mode 100644 index 0000000..6748cca --- /dev/null +++ b/tree.go @@ -0,0 +1,665 @@ +package fox + +import ( + "fmt" + "strings" + "sync" + "sync/atomic" +) + +// Tree implements a Concurrent Radix Tree that supports lock-free reads while allowing concurrent writes. +// The caller is responsible for ensuring that all writes are run serially. +// +// IMPORTANT: +// Each tree as its own sync.Mutex and sync.Pool that may be used to serialize write and reduce memory allocation. +// Since the router tree may be swapped at any given time, you MUST always copy the pointer locally +// to avoid inadvertently releasing Params to the wrong pool or worst, causing a deadlock by locking/unlocking the +// wrong Tree. +// +// Good: +// t := r.Tree() +// t.Lock() +// defer t.Unlock() +// +// Dramatically bad, may cause deadlock +// r.Tree().Lock() +// defer r.Tree().Unlock() +// +// This principle also applies to the Lookup function, which requires releasing the Params slice, if not nil, by +// calling params.Free(tree). Always ensure that the Tree pointer passed as a parameter to params.Free is the same +// as the one passed to the Lookup function. +type Tree struct { + p sync.Pool + nodes atomic.Pointer[[]*node] + sync.Mutex + maxParams atomic.Uint32 + saveRoute bool +} + +// Handler registers a new handler for the given method and path. This function return an error if the route +// is already registered or conflict with another. It's perfectly safe to add a new handler while the tree is in use +// for serving requests. However, this function is NOT thread safe and should be run serially, along with +// all other Tree's APIs. To override an existing route, use Update. +func (t *Tree) Handler(method, path string, handler Handler) error { + p, catchAllKey, n, err := parseRoute(path) + if err != nil { + return err + } + + if t.saveRoute { + n += 1 + } + + return t.insert(method, p, catchAllKey, uint32(n), handler) +} + +// Update override an existing handler for the given method and path. If the route does not exist, +// the function return an ErrRouteNotFound. It's perfectly safe to update a handler while the tree is in use for +// serving requests. However, this function is NOT thread safe and should be run serially, along with +// all other Tree's APIs. To add new handler, use Handler method. +func (t *Tree) Update(method, path string, handler Handler) error { + p, catchAllKey, _, err := parseRoute(path) + if err != nil { + return err + } + + return t.update(method, p, catchAllKey, handler) +} + +// Remove delete an existing handler for the given method and path. If the route does not exist, the function +// return an ErrRouteNotFound. It's perfectly safe to remove a handler while the tree is in use for serving requests. +// However, this function is NOT thread safe and should be run serially, along with all other Tree's APIs. +func (t *Tree) Remove(method, path string) error { + path, _, _, err := parseRoute(path) + if err != nil { + return err + } + + if !t.remove(method, path) { + return ErrRouteNotFound + } + + return nil +} + +// insert is not safe for concurrent use. +func (t *Tree) insert(method, path, catchAllKey string, paramsN uint32, handler Handler) error { + // Note that we need a consistent view of the tree during the patching so search must imperatively be locked. + if method == "" { + return fmt.Errorf("http method is missing: %w", ErrInvalidRoute) + } + + var rootNode *node + nds := *t.nodes.Load() + index := findRootNode(method, nds) + if index < 0 { + rootNode = &node{key: method} + t.addRoot(rootNode) + } else { + rootNode = nds[index] + } + + isCatchAll := catchAllKey != "" + + result := t.search(rootNode, path) + switch result.classify() { + case exactMatch: + // e.g. matched exactly "te" node when inserting "te" key. + // te + // ├── st + // └── am + // Create a new node from "st" reference and update the "te" (parent) reference to "st" node. + if result.matched.isLeaf() { + return fmt.Errorf("route [%s] %s conflict: %w", method, path, ErrRouteExist) + } + + // The matched node can only be the result of a previous split and therefore has children. + if isCatchAll { + return newConflictErr(method, path, catchAllKey, getRouteConflict(result.matched)) + } + // We are updating an existing node. We only need to create a new node from + // the matched one with the updated/added value (handler and wildcard). + n := newNodeFromRef(result.matched.key, handler, result.matched.children, result.matched.childKeys, catchAllKey, result.matched.paramChild, path) + + t.updateMaxParams(paramsN) + result.p.updateEdge(n) + case keyEndMidEdge: + // e.g. matched until "s" for "st" node when inserting "tes" key. + // te + // ├── st + // └── am + // + // After patching + // te + // ├── am + // └── s + // └── t + // It requires to split "st" node. + // 1. Create a "t" node from "st" reference. + // 2. Create a new "s" node for "tes" key and link it to the child "t" node. + // 3. Update the "te" (parent) reference to the new "s" node (we are swapping old "st" to new "s" node, first + // char remain the same). + + if isCatchAll { + return newConflictErr(method, path, catchAllKey, getRouteConflict(result.matched)) + } + + keyCharsFromStartOfNodeFound := path[result.charsMatched-result.charsMatchedInNodeFound:] + cPrefix := commonPrefix(keyCharsFromStartOfNodeFound, result.matched.key) + suffixFromExistingEdge := strings.TrimPrefix(result.matched.key, cPrefix) + // Rule: a node with :param has no child or has a separator before the end of the key or its child + // start with a separator + if !strings.HasPrefix(suffixFromExistingEdge, "/") { + for i := len(cPrefix) - 1; i >= 0; i-- { + if cPrefix[i] == '/' { + break + } + if cPrefix[i] == ':' { + return newConflictErr(method, path, catchAllKey, getRouteConflict(result.matched)) + } + } + } + + child := newNodeFromRef( + suffixFromExistingEdge, + result.matched.handler, + result.matched.children, + result.matched.childKeys, + result.matched.catchAllKey, + result.matched.paramChild, + result.matched.path, + ) + + parent := newNode( + cPrefix, + handler, + []*node{child}, + catchAllKey, + // e.g. tree encode /tes/:t and insert /tes/ + // /tes/ (paramChild) + // ├── :t + // since /tes/xyz will match until /tes/ and when looking for next child, 'x' will match nothing + // if paramChild == true { + // next = current.get(0) + // } + strings.HasPrefix(suffixFromExistingEdge, ":"), + path, + ) + + t.updateMaxParams(paramsN) + result.p.updateEdge(parent) + case incompleteMatchToEndOfEdge: + // e.g. matched until "st" for "st" node but still have remaining char (ify) when inserting "testify" key. + // te + // ├── st + // └── am + // + // After patching + // te + // ├── am + // └── st + // └── ify + // 1. Create a new "ify" child node. + // 2. Recreate the "st" node and link it to it's existing children and the new "ify" node. + // 3. Update the "te" (parent) node to the new "st" node. + + if result.matched.isCatchAll() { + return newConflictErr(method, path, catchAllKey, getRouteConflict(result.matched)) + } + + keySuffix := path[result.charsMatched:] + // Rule: a node with :param has no child or has a separator before the end of the key + // make sure than and existing params :x is not extended to :xy + // :x/:y is of course valid + if !strings.HasPrefix(keySuffix, "/") { + for i := len(result.matched.key) - 1; i >= 0; i-- { + if result.matched.key[i] == '/' { + break + } + if result.matched.key[i] == ':' { + return newConflictErr(method, path, catchAllKey, getRouteConflict(result.matched)) + } + } + } + + // No children, so no paramChild + child := newNode(keySuffix, handler, nil, catchAllKey, false, path) + edges := result.matched.getEdgesShallowCopy() + edges = append(edges, child) + n := newNode( + result.matched.key, + result.matched.handler, + edges, + result.matched.catchAllKey, + // e.g. tree encode /tes/ and insert /tes/:t + // /tes/ (paramChild) + // ├── :t + // since /tes/xyz will match until /tes/ and when looking for next child, 'x' will match nothing + // if paramChild == true { + // next = current.get(0) + // } + strings.HasPrefix(keySuffix, ":"), + result.matched.path, + ) + + t.updateMaxParams(paramsN) + if result.matched == rootNode { + n.key = method + t.updateRoot(n) + break + } + result.p.updateEdge(n) + case incompleteMatchToMiddleOfEdge: + // e.g. matched until "s" for "st" node but still have remaining char ("s") which does not match anything + // when inserting "tess" key. + // te + // ├── st + // └── am + // + // After patching + // te + // ├── am + // └── s + // ├── s + // └── t + // It requires to split "st" node. + // 1. Create a new "s" child node for "tess" key. + // 2. Create a new "t" node from "st" reference (link "st" children to new "t" node). + // 3. Create a new "s" node and link it to "s" and "t" node. + // 4. Update the "te" (parent) node to the new "s" node (we are swapping old "st" to new "s" node, first + // char remain the same). + + keyCharsFromStartOfNodeFound := path[result.charsMatched-result.charsMatchedInNodeFound:] + cPrefix := commonPrefix(keyCharsFromStartOfNodeFound, result.matched.key) + + // Rule: a node with :param has no child or has a separator before the end of the key + for i := len(cPrefix) - 1; i >= 0; i-- { + if cPrefix[i] == '/' { + break + } + if cPrefix[i] == ':' { + return newConflictErr(method, path, catchAllKey, getRouteConflict(result.matched)) + } + } + + suffixFromExistingEdge := strings.TrimPrefix(result.matched.key, cPrefix) + // Rule: parent's of a node with :param have only one node or are prefixed by a char (e.g /:param) + if strings.HasPrefix(suffixFromExistingEdge, ":") { + return newConflictErr(method, path, catchAllKey, getRouteConflict(result.matched)) + } + + keySuffix := path[result.charsMatched:] + // Rule: parent's of a node with :param have only one node or are prefixed by a char (e.g /:param) + if strings.HasPrefix(keySuffix, ":") { + return newConflictErr(method, path, catchAllKey, getRouteConflict(result.matched)) + } + + // No children, so no paramChild + n1 := newNodeFromRef(keySuffix, handler, nil, nil, catchAllKey, false, path) // inserted node + n2 := newNodeFromRef( + suffixFromExistingEdge, + result.matched.handler, + result.matched.children, + result.matched.childKeys, + result.matched.catchAllKey, + result.matched.paramChild, + result.matched.path, + ) // previous matched node + + // n3 children never start with a param + n3 := newNode(cPrefix, nil, []*node{n1, n2}, "", false, "") // intermediary node + + t.updateMaxParams(paramsN) + result.p.updateEdge(n3) + default: + // safeguard against introducing a new result type + panic("internal error: unexpected result type") + } + return nil +} + +// update is not safe for concurrent use. +func (t *Tree) update(method string, path, catchAllKey string, handler Handler) error { + // Note that we need a consistent view of the tree during the patching so search must imperatively be locked. + nds := *t.nodes.Load() + index := findRootNode(method, nds) + if index < 0 { + return fmt.Errorf("route [%s] %s is not registered: %w", method, path, ErrRouteNotFound) + } + + result := t.search(nds[index], path) + if !result.isExactMatch() || !result.matched.isLeaf() { + return fmt.Errorf("route [%s] %s is not registered: %w", method, path, ErrRouteNotFound) + } + + if catchAllKey != "" && len(result.matched.children) > 0 { + return newConflictErr(method, path, catchAllKey, getRouteConflict(result.matched)[1:]) + } + + // We are updating an existing node (could be a leaf or not). We only need to create a new node from + // the matched one with the updated/added value (handler and wildcard). + n := newNodeFromRef( + result.matched.key, + handler, + result.matched.children, + result.matched.childKeys, + catchAllKey, + result.matched.paramChild, + path, + ) + result.p.updateEdge(n) + return nil +} + +// remove is not safe for concurrent use. +func (t *Tree) remove(method, path string) bool { + nds := *t.nodes.Load() + index := findRootNode(method, nds) + if index < 0 { + return false + } + + result := t.search(nds[index], path) + if result.classify() != exactMatch { + return false + } + + // This node was created after a split (KEY_END_MID_EGGE operation), therefore we cannot delete + // this node. + if !result.matched.isLeaf() { + return false + } + + if len(result.matched.children) > 1 { + n := newNodeFromRef( + result.matched.key, + nil, + result.matched.children, + result.matched.childKeys, + "", + result.matched.paramChild, + "", + ) + result.p.updateEdge(n) + return true + } + + if len(result.matched.children) == 1 { + child := result.matched.get(0) + mergedPath := fmt.Sprintf("%s%s", result.matched.key, child.key) + n := newNodeFromRef( + mergedPath, + child.handler, + child.children, + child.childKeys, + child.catchAllKey, + child.paramChild, + child.path, + ) + result.p.updateEdge(n) + return true + } + + // recreate the parent edges without the removed node + parentEdges := make([]*node, len(result.p.children)-1) + added := 0 + for i := 0; i < len(result.p.children); i++ { + n := result.p.get(i) + if n != result.matched { + parentEdges[added] = n + added++ + } + } + + parentIsRoot := result.p == nds[index] + var parent *node + if len(parentEdges) == 1 && !result.p.isLeaf() && !parentIsRoot { + child := parentEdges[0] + mergedPath := fmt.Sprintf("%s%s", result.p.key, child.key) + parent = newNodeFromRef( + mergedPath, + child.handler, + child.children, + child.childKeys, + child.catchAllKey, + child.paramChild, + child.path, + ) + } else { + parent = newNode( + result.p.key, + result.p.handler, + parentEdges, + result.p.catchAllKey, + result.p.paramChild, + result.p.path, + ) + } + + if parentIsRoot { + if len(parent.children) == 0 && isRemovable(method) { + return t.removeRoot(method) + } + parent.key = method + t.updateRoot(parent) + return true + } + + result.pp.updateEdge(parent) + return true +} + +func (t *Tree) lookup(rootNode *node, path string, lazy bool) (n *node, params *Params, tsr bool) { + var ( + charsMatched int + charsMatchedInNodeFound int + ) + + current := rootNode +STOP: + for charsMatched < len(path) { + idx := linearSearch(current.childKeys, path[charsMatched]) + if idx < 0 { + if !current.paramChild { + break STOP + } + idx = 0 + } + + current = current.get(idx) + charsMatchedInNodeFound = 0 + for i := 0; charsMatched < len(path); i++ { + if i >= len(current.key) { + break + } + + if current.key[i] != path[charsMatched] || path[charsMatched] == ':' { + if current.key[i] == ':' { + startPath := charsMatched + idx := strings.Index(path[charsMatched:], "/") + if idx >= 0 { + charsMatched += idx + } else { + charsMatched += len(path[charsMatched:]) + } + startKey := charsMatchedInNodeFound + idx = strings.Index(current.key[startKey:], "/") + if idx >= 0 { + // -1 since on the next incrementation, if any, 'i' are going to be incremented + i += idx - 1 + charsMatchedInNodeFound += idx + } else { + // -1 since on the next incrementation, if any, 'i' are going to be incremented + i += len(current.key[charsMatchedInNodeFound:]) - 1 + charsMatchedInNodeFound += len(current.key[charsMatchedInNodeFound:]) + } + if !lazy { + if params == nil { + params = t.newParams() + } + // :n where n > 0 + *params = append(*params, Param{Key: current.key[startKey+1 : charsMatchedInNodeFound], Value: path[startPath:charsMatched]}) + } + continue + } + break STOP + } + + charsMatched++ + charsMatchedInNodeFound++ + } + } + + if !current.isLeaf() { + return nil, params, false + } + + if charsMatched == len(path) { + if charsMatchedInNodeFound == len(current.key) { + // Exact match, note that if we match a wildcard node, the param value is always '/' + if !lazy && (t.saveRoute || current.isCatchAll()) { + if params == nil { + params = t.newParams() + } + + if t.saveRoute { + *params = append(*params, Param{Key: RouteKey, Value: current.path}) + } + + if current.isCatchAll() { + *params = append(*params, Param{Key: current.catchAllKey, Value: path[charsMatched-1:]}) + } + + return current, params, false + } + return current, params, false + } else if charsMatchedInNodeFound < len(current.key) { + // Key end mid-edge + // Tsr recommendation: add an extra trailing slash (got an exact match) + remainingSuffix := current.key[charsMatchedInNodeFound:] + return nil, nil, len(remainingSuffix) == 1 && remainingSuffix[0] == '/' + } + } + + // Incomplete match to end of edge + if charsMatched < len(path) && charsMatchedInNodeFound == len(current.key) { + if current.isCatchAll() { + if !lazy { + if params == nil { + params = t.newParams() + } + *params = append(*params, Param{Key: current.catchAllKey, Value: path[charsMatched-1:]}) + if t.saveRoute { + *params = append(*params, Param{Key: RouteKey, Value: current.path}) + } + return current, params, false + } + // Same as exact match, no tsr recommendation + return current, params, false + } + // Tsr recommendation: remove the extra trailing slash (got an exact match) + remainingKeySuffix := path[charsMatched:] + return nil, nil, len(remainingKeySuffix) == 1 && remainingKeySuffix[0] == '/' + } + + return nil, nil, false +} + +func (t *Tree) search(rootNode *node, path string) searchResult { + current := rootNode + + var ( + pp *node + p *node + charsMatched int + charsMatchedInNodeFound int + ) + +STOP: + for charsMatched < len(path) { + next := current.getEdge(path[charsMatched]) + if next == nil { + break STOP + } + + pp = p + p = current + current = next + charsMatchedInNodeFound = 0 + for i := 0; charsMatched < len(path); i++ { + if i >= len(current.key) { + break + } + + if current.key[i] != path[charsMatched] { + break STOP + } + + charsMatched++ + charsMatchedInNodeFound++ + } + } + + return searchResult{ + path: path, + matched: current, + charsMatched: charsMatched, + charsMatchedInNodeFound: charsMatchedInNodeFound, + p: p, + pp: pp, + } +} + +// addRoot is not safe for concurrent use. +func (t *Tree) addRoot(n *node) { + nds := *t.nodes.Load() + newNds := make([]*node, 0, len(nds)+1) + newNds = append(newNds, nds...) + newNds = append(newNds, n) + t.nodes.Store(&newNds) +} + +// updateRoot is not safe for concurrent use. +func (t *Tree) updateRoot(n *node) bool { + nds := *t.nodes.Load() + index := findRootNode(n.key, nds) + if index < 0 { + return false + } + newNds := make([]*node, 0, len(nds)) + newNds = append(newNds, nds[:index]...) + newNds = append(newNds, n) + newNds = append(newNds, nds[index+1:]...) + t.nodes.Store(&newNds) + return true +} + +// removeRoot is not safe for concurrent use. +func (t *Tree) removeRoot(method string) bool { + nds := *t.nodes.Load() + index := findRootNode(method, nds) + if index < 0 { + return false + } + newNds := make([]*node, 0, len(nds)-1) + newNds = append(newNds, nds[:index]...) + newNds = append(newNds, nds[index+1:]...) + t.nodes.Store(&newNds) + return true +} + +// updateMaxParams perform an update only if max is greater than the current +// max params. This function should be guarded by mutex. +func (t *Tree) updateMaxParams(max uint32) { + if max > t.maxParams.Load() { + t.maxParams.Store(max) + } +} + +func (t *Tree) load() []*node { + return *t.nodes.Load() +} + +func (t *Tree) newParams() *Params { + return t.p.Get().(*Params) +}