Skip to content

Commit

Permalink
Introducing per route options (#37)
Browse files Browse the repository at this point in the history
* feat: wip per route options

* feat: wip per route options

* feat: wip per route options

* Revert "feat: wip per route options"

This reverts commit fb7f2e1.

* feat: make sure that tree from different router cannot be swapped

* feat: wip per route options

* feat: wip per route options

* feat: improve test coverage

* feat: improve test coverage

* feat: better handle stack frame skipping with recovery handler

* feat: improve test coverage

* feat: improve test coverage

* feat: disable some lint rules

* feat: replace sort.Slice by slices.SortFunc to improve sort performance

* docs: fix comment for options that can be applied on a route basis.

* feat: remove Route.HandleWithMiddleware API as it's not clear for now how and when to use it.

* feat: make FixTrailingSlash public

* feat: rework on the Match method
  • Loading branch information
tigerwill90 authored Oct 7, 2024
1 parent 1f1c12a commit 9172015
Show file tree
Hide file tree
Showing 11 changed files with 540 additions and 217 deletions.
32 changes: 20 additions & 12 deletions context.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,13 @@ type cTx struct {
params *Params
tsrParams *Params
skipNds *skippedNodes
route *Route

// tree at allocation (read-only, no reset)
tree *Tree
// router at allocation (read-only, no reset)
fox *Router
cachedQuery url.Values
path string
rec recorder
tsr bool
}
Expand All @@ -112,9 +112,9 @@ type cTx struct {
func (c *cTx) Reset(w ResponseWriter, r *http.Request) {
c.req = r
c.w = w
c.path = ""
c.tsr = false
c.cachedQuery = nil
c.route = nil
*c.params = (*c.params)[:0]
}

Expand All @@ -125,16 +125,16 @@ func (c *cTx) reset(w http.ResponseWriter, r *http.Request) {
c.rec.reset(w)
c.req = r
c.w = &c.rec
c.path = ""
c.cachedQuery = nil
c.route = nil
*c.params = (*c.params)[:0]
}

func (c *cTx) resetNil() {
c.req = nil
c.w = nil
c.path = ""
c.cachedQuery = nil
c.route = nil
*c.params = (*c.params)[:0]
}

Expand Down Expand Up @@ -186,8 +186,12 @@ func (c *cTx) RemoteIP() *net.IPAddr {
// worthy of panicking.
// This api is EXPERIMENTAL and is likely to change in future release.
func (c *cTx) ClientIP() (*net.IPAddr, error) {
ipStrategy := c.Fox().ipStrategy
return ipStrategy.ClientIP(c)
// We may be in a handler which does not match a route like NotFound handler.
if c.route == nil {
ipStrategy := c.fox.ipStrategy
return ipStrategy.ClientIP(c)
}
return c.route.ipStrategy.ClientIP(c)
}

// Params returns a Params slice containing the matched
Expand Down Expand Up @@ -235,7 +239,10 @@ func (c *cTx) Header(key string) string {

// Path returns the registered path for the handler.
func (c *cTx) Path() string {
return c.path
if c.route == nil {
return ""
}
return c.route.path
}

// String sends a formatted string with the specified status code.
Expand Down Expand Up @@ -287,10 +294,11 @@ func (c *cTx) Fox() *Router {
// Any attempt to write on the ResponseWriter will panic with the error ErrDiscardedResponseWriter.
func (c *cTx) Clone() Context {
cp := cTx{
rec: c.rec,
req: c.req.Clone(c.req.Context()),
fox: c.fox,
tree: c.tree,
rec: c.rec,
req: c.req.Clone(c.req.Context()),
fox: c.fox,
tree: c.tree,
route: c.route,
}

cp.rec.ResponseWriter = noopWriter{c.rec.Header().Clone()}
Expand All @@ -311,7 +319,7 @@ func (c *cTx) CloneWith(w ResponseWriter, r *http.Request) ContextCloser {
cp := c.tree.ctx.Get().(*cTx)
cp.req = r
cp.w = w
cp.path = c.path
cp.route = c.route
cp.cachedQuery = nil
if cap(*c.params) > cap(*cp.params) {
// Grow cp.params to a least cap(c.params)
Expand Down
126 changes: 84 additions & 42 deletions fox.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,49 @@ func (f ClientIPStrategyFunc) ClientIP(c Context) (*net.IPAddr, error) {
return f(c)
}

// Route represent a registered route in the route tree.
// Most of the Route API is EXPERIMENTAL and is likely to change in future release.
type Route struct {
ipStrategy ClientIPStrategy
base HandlerFunc
handler HandlerFunc
path string
mws []middleware
redirectTrailingSlash bool
ignoreTrailingSlash bool
}

// Handle calls the base handler with the provided Context.
func (r *Route) Handle(c Context) {
r.base(c)
}

// Path returns the route path.
func (r *Route) Path() string {
return r.path
}

// RedirectTrailingSlashEnabled returns whether the route is configured to automatically
// redirect requests that include or omit a trailing slash.
// This api is EXPERIMENTAL and is likely to change in future release.
func (r *Route) RedirectTrailingSlashEnabled() bool {
return r.redirectTrailingSlash
}

// IgnoreTrailingSlashEnabled returns whether the route is configured to ignore
// trailing slashes in requests when matching routes.
// This api is EXPERIMENTAL and is likely to change in future release.
func (r *Route) IgnoreTrailingSlashEnabled() bool {
return r.ignoreTrailingSlash
}

// ClientIPStrategyEnabled returns whether the route is configured with a ClientIPStrategy.
// This api is EXPERIMENTAL and is likely to change in future release.
func (r *Route) ClientIPStrategyEnabled() bool {
_, ok := r.ipStrategy.(noClientIPStrategy)
return !ok
}

// Router is a lightweight high performance HTTP request router that support mutation on its routing tree
// while handling request concurrently.
type Router struct {
Expand All @@ -91,16 +134,16 @@ type middleware struct {
var _ http.Handler = (*Router)(nil)

// New returns a ready to use instance of Fox router.
func New(opts ...Option) *Router {
func New(opts ...GlobalOption) *Router {
r := new(Router)

r.noRoute = DefaultNotFoundHandler()
r.noMethod = DefaultMethodNotAllowedHandler()
r.autoOptions = DefaultOptionsHandler()
r.noRoute = DefaultNotFoundHandler
r.noMethod = DefaultMethodNotAllowedHandler
r.autoOptions = DefaultOptionsHandler
r.ipStrategy = noClientIPStrategy{}

for _, opt := range opts {
opt.apply(r)
opt.applyGlob(r)
}

r.noRoute = applyMiddleware(NoRouteHandler, r.mws, r.noRoute)
Expand Down Expand Up @@ -181,29 +224,34 @@ func (fox *Router) Tree() *Tree {
}

// 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.
// Note that the swap will panic if the current tree belongs to a different instance of the router, preventing accidental
// replacement of trees from different routers.
func (fox *Router) Swap(new *Tree) (old *Tree) {
current := fox.tree.Load()
if current.fox != new.fox {
panic("swap failed: current and new routing trees belong to different router instances")
}
return fox.tree.Swap(new)
}

// Handle 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. This function is safe for concurrent use by multiple goroutine.
// To override an existing route, use Update.
func (fox *Router) Handle(method, path string, handler HandlerFunc) error {
func (fox *Router) Handle(method, path string, handler HandlerFunc, opts ...PathOption) error {
t := fox.Tree()
t.Lock()
defer t.Unlock()
return t.Handle(method, path, handler)
return t.Handle(method, path, handler, opts...)
}

// MustHandle registers a new handler for the given method and path. This function is a convenience
// wrapper for the Handle function. It will panic if the route is already registered or conflicts
// with another route. 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 goroutines.
// To override an existing route, use Update.
func (fox *Router) MustHandle(method, path string, handler HandlerFunc) {
if err := fox.Handle(method, path, handler); err != nil {
func (fox *Router) MustHandle(method, path string, handler HandlerFunc, opts ...PathOption) {
if err := fox.Handle(method, path, handler, opts...); err != nil {
panic(err)
}
}
Expand All @@ -212,11 +260,11 @@ func (fox *Router) MustHandle(method, path string, handler HandlerFunc) {
// 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 Handle method.
func (fox *Router) Update(method, path string, handler HandlerFunc) error {
func (fox *Router) Update(method, path string, handler HandlerFunc, opts ...PathOption) error {
t := fox.Tree()
t.Lock()
defer t.Unlock()
return t.Update(method, path, handler)
return t.Update(method, path, handler, opts...)
}

// Remove delete an existing handler for the given method and path. If the route does not exist, the function
Expand All @@ -230,11 +278,11 @@ func (fox *Router) Remove(method, path string) error {
}

// Lookup is a helper that calls Tree.Lookup. For more details, refer to Tree.Lookup.
// It performs a manual route lookup for a given http.Request, returning the matched HandlerFunc along with a ContextCloser,
// It performs a manual route lookup for a given http.Request, returning the matched Route along with a ContextCloser,
// and a boolean indicating if a trailing slash action (e.g. redirect) is recommended (tsr). The ContextCloser should always
// be closed if non-nil.
// This API is EXPERIMENTAL and is likely to change in future release.
func (fox *Router) Lookup(w ResponseWriter, r *http.Request) (handler HandlerFunc, cc ContextCloser, tsr bool) {
func (fox *Router) Lookup(w ResponseWriter, r *http.Request) (route *Route, cc ContextCloser, tsr bool) {
tree := fox.tree.Load()
return tree.Lookup(w, r)
}
Expand All @@ -257,7 +305,7 @@ Next:
method := nds[i].key
it := newRawIterator(nds[i])
for it.hasNext() {
err := fn(method, it.path, it.current.handler)
err := fn(method, it.path, it.current.route.handler)
if err != nil {
if errors.Is(err, SkipMethod) {
continue Next
Expand All @@ -270,27 +318,21 @@ Next:
return nil
}

// DefaultNotFoundHandler returns a simple HandlerFunc that replies to each request
// DefaultNotFoundHandler is a simple HandlerFunc that replies to each request
// with a “404 page not found” reply.
func DefaultNotFoundHandler() HandlerFunc {
return func(c Context) {
http.Error(c.Writer(), "404 page not found", http.StatusNotFound)
}
func DefaultNotFoundHandler(c Context) {
http.Error(c.Writer(), "404 page not found", http.StatusNotFound)
}

// DefaultMethodNotAllowedHandler returns a simple HandlerFunc that replies to each request
// DefaultMethodNotAllowedHandler is a simple HandlerFunc that replies to each request
// with a “405 Method Not Allowed” reply.
func DefaultMethodNotAllowedHandler() HandlerFunc {
return func(c Context) {
http.Error(c.Writer(), http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
}
func DefaultMethodNotAllowedHandler(c Context) {
http.Error(c.Writer(), http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
}

// DefaultOptionsHandler returns a simple HandlerFunc that replies to each request with a "200 OK" reply.
func DefaultOptionsHandler() HandlerFunc {
return func(c Context) {
c.Writer().WriteHeader(http.StatusOK)
}
// DefaultOptionsHandler is a simple HandlerFunc that replies to each request with a "200 OK" reply.
func DefaultOptionsHandler(c Context) {
c.Writer().WriteHeader(http.StatusOK)
}

func defaultRedirectTrailingSlashHandler(c Context) {
Expand All @@ -304,9 +346,9 @@ func defaultRedirectTrailingSlashHandler(c Context) {

var url string
if len(req.URL.RawPath) > 0 {
url = fixTrailingSlash(req.URL.RawPath)
url = FixTrailingSlash(req.URL.RawPath)
} else {
url = fixTrailingSlash(req.URL.Path)
url = FixTrailingSlash(req.URL.Path)
}

if url[len(url)-1] == '/' {
Expand Down Expand Up @@ -343,9 +385,9 @@ func (fox *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {

n, tsr = tree.lookup(nds[index], target, c, false)
if !tsr && n != nil {
c.path = n.path
c.route = n.route
c.tsr = tsr
n.handler(c)
n.route.handler(c)
// Put back the context, if not extended more than max params or max depth, allowing
// the slice to naturally grow within the constraint.
if cap(*c.params) <= int(tree.maxParams.Load()) && cap(*c.skipNds) <= int(tree.maxDepth.Load()) {
Expand All @@ -355,15 +397,15 @@ func (fox *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}

if r.Method != http.MethodConnect && r.URL.Path != "/" && tsr {
if fox.ignoreTrailingSlash {
c.path = n.path
if n.route.ignoreTrailingSlash {
c.route = n.route
c.tsr = tsr
n.handler(c)
n.route.handler(c)
c.Close()
return
}

if fox.redirectTrailingSlash && target == CleanPath(target) {
if n.route.redirectTrailingSlash && target == CleanPath(target) {
// Reset params as it may have recorded wildcard segment (the context may still be used in a middleware)
*c.params = (*c.params)[:0]
c.tsr = false
Expand Down Expand Up @@ -395,7 +437,7 @@ NoMethodFallback:
} else {
// Since different method and route may match (e.g. GET /foo/bar & POST /foo/{name}), we cannot set the path and params.
for i := 0; i < len(nds); i++ {
if n, tsr := tree.lookup(nds[i], target, c, true); n != nil && (!tsr || fox.ignoreTrailingSlash) {
if n, tsr := tree.lookup(nds[i], target, c, true); n != nil && (!tsr || n.route.ignoreTrailingSlash) {
if sb.Len() > 0 {
sb.WriteString(", ")
}
Expand All @@ -415,7 +457,7 @@ NoMethodFallback:
var sb strings.Builder
for i := 0; i < len(nds); i++ {
if nds[i].key != r.Method {
if n, tsr := tree.lookup(nds[i], target, c, true); n != nil && (!tsr || fox.ignoreTrailingSlash) {
if n, tsr := tree.lookup(nds[i], target, c, true); n != nil && (!tsr || n.route.ignoreTrailingSlash) {
if sb.Len() > 0 {
sb.WriteString(", ")
}
Expand Down Expand Up @@ -604,7 +646,7 @@ func getRouteConflict(n *node) []string {
routes := make([]string, 0)

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

Expand All @@ -613,7 +655,7 @@ func getRouteConflict(n *node) []string {
}
it := newRawIterator(n)
for it.hasNext() {
routes = append(routes, it.current.path)
routes = append(routes, it.current.route.path)
}
return routes
}
Expand Down
Loading

0 comments on commit 9172015

Please sign in to comment.