Skip to content

Commit

Permalink
Ignore trailing slash (#32)
Browse files Browse the repository at this point in the history
* test: improve tsr tests

* feat: fix tsr edge case when exact match on a intermediary leaf node

* feat: implement ignore trailing slash new feature

* feat: enable ignore trailing slash for tree method (has, match and methods) & add tests

* docs: fix inconsistent documentation for Tree.Methods

* feat: fix Tree.Match

* docs(readme): update road to v1 section

* feat: enable ignore trailing slash for tree method (has, match and methods) also when redirect trailing slash.

* feat: disable ignore trailing slash for Tree.Has

* feat: attach fox instance to every tree

* docs: update lookup methods documentation

* feat: improve local redirect

* docs: fix localRedirect docs
  • Loading branch information
tigerwill90 authored Jun 24, 2024
1 parent 2ba5953 commit 670e306
Show file tree
Hide file tree
Showing 9 changed files with 866 additions and 151 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ priority rules, ensuring that there are no unintended matches and maintaining hi
**Redirect trailing slashes:** Inspired from [httprouter](https://github.com/julienschmidt/httprouter), the router automatically
redirects the client, at no extra cost, if another route match with or without a trailing slash.

**Ignore trailing slashes:** In contrast to redirecting, this option allows the router to handle requests regardless of an extra
or missing trailing slash, at no extra cost.

**Automatic OPTIONS replies:** Inspired from [httprouter](https://github.com/julienschmidt/httprouter), the router has built-in native
support for [OPTIONS requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/OPTIONS).

Expand Down Expand Up @@ -620,6 +623,7 @@ BenchmarkPat_GithubAll 424 2899405 ns/op 1843501
- [x] [Update route syntax](https://github.com/tigerwill90/fox/pull/10#issue-1643728309) @v0.6.0
- [x] [Route overlapping](https://github.com/tigerwill90/fox/pull/9#issue-1642887919) @v0.7.0
- [x] [Route overlapping (catch-all and params)](https://github.com/tigerwill90/fox/pull/24#issue-1784686061) @v0.10.0
- [x] [Ignore trailing slash](https://github.com/tigerwill90/fox/pull/32) @v0.14.0
- [ ] Improving performance and polishing

## Contributions
Expand Down
19 changes: 10 additions & 9 deletions context.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,12 @@ type Context interface {
CloneWith(w ResponseWriter, r *http.Request) ContextCloser
// Tree is a local copy of the Tree in use to serve the request.
Tree() *Tree
// Fox returns the Router in use to serve the request.
// Fox returns the Router instance.
Fox() *Router
// Reset resets the Context to its initial state, attaching the provided Router,
// http.ResponseWriter, and *http.Request.
Reset(fox *Router, w http.ResponseWriter, r *http.Request)
// Reset resets the Context to its initial state, attaching the provided Router, http.ResponseWriter, and *http.Request.
// Caution: You should always pass the original http.ResponseWriter to this method, not the ResponseWriter itself, to
// avoid wrapping the ResponseWriter within itself. Use wisely!
Reset(w http.ResponseWriter, r *http.Request)
}

// context holds request-related information and allows interaction with the ResponseWriter.
Expand All @@ -102,13 +103,13 @@ type context struct {
}

// Reset resets the Context to its initial state, attaching the provided Router, http.ResponseWriter, and *http.Request.
// Caution: You should pass the original http.ResponseWriter to this method, not the ResponseWriter itself, to avoid
// wrapping the ResponseWriter within itself.
func (c *context) Reset(fox *Router, w http.ResponseWriter, r *http.Request) {
// Caution: You should always pass the original http.ResponseWriter to this method, not the ResponseWriter itself, to
// avoid wrapping the ResponseWriter within itself. Use wisely!
func (c *context) Reset(w http.ResponseWriter, r *http.Request) {
c.rec.reset(w)
c.req = r
c.w = &c.rec
c.fox = fox
c.fox = c.tree.fox
c.path = ""
c.cachedQuery = nil
*c.params = (*c.params)[:0]
Expand Down Expand Up @@ -233,7 +234,7 @@ func (c *context) Tree() *Tree {
return c.tree
}

// Fox returns the Router in use to serve the request.
// Fox returns the Router instance.
func (c *context) Fox() *Router {
return c.fox
}
Expand Down
170 changes: 126 additions & 44 deletions fox.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ import (
"net/http"
"path"
"regexp"
"strconv"
"strings"
"sync"
"sync/atomic"
"unicode/utf8"
)

const verb = 4
Expand Down Expand Up @@ -54,6 +56,7 @@ type Router struct {
handleMethodNotAllowed bool
handleOptions bool
redirectTrailingSlash bool
ignoreTrailingSlash bool
}

type middleware struct {
Expand Down Expand Up @@ -84,13 +87,42 @@ func New(opts ...Option) *Router {
return r
}

// MethodNotAllowedEnabled returns whether the router is configured to handle
// requests with methods that are not allowed.
// This api is EXPERIMENTAL and is likely to change in future release.
func (fox *Router) MethodNotAllowedEnabled() bool {
return fox.handleMethodNotAllowed
}

// AutoOptionsEnabled returns whether the router is configured to automatically
// respond to OPTIONS requests.
// This api is EXPERIMENTAL and is likely to change in future release.
func (fox *Router) AutoOptionsEnabled() bool {
return fox.handleOptions
}

// RedirectTrailingSlashEnabled returns whether the router 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 (fox *Router) RedirectTrailingSlashEnabled() bool {
return fox.redirectTrailingSlash
}

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

// NewTree returns a fresh routing Tree that inherits all registered router options. It's safe to create multiple Tree
// concurrently. However, a Tree itself is not thread-safe and all its APIs that perform write operations 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.mws = fox.mws
tree.fox = fox

// Pre instantiate nodes for common http verb
nds := make([]*node, len(commonVerbs))
Expand Down Expand Up @@ -165,38 +197,14 @@ func (fox *Router) Remove(method, path string) error {
return t.Remove(method, path)
}

// Lookup performs a manual route lookup for a given http.Request, returning the matched HandlerFunc along with a ContextCloser,
// and a boolean indicating if a trailing slash redirect is recommended. The ContextCloser should always be closed if non-nil.
// This method is primarily intended for integrating the fox router into custom routing solutions. It requires the use of the
// original http.ResponseWriter, typically obtained from ServeHTTP. This function is safe for concurrent use by multiple goroutine
// and while mutation on Tree are ongoing.
// 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,
// 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 http.ResponseWriter, r *http.Request) (handler HandlerFunc, cc ContextCloser, tsr bool) {
tree := fox.tree.Load()

nds := *tree.nodes.Load()
index := findRootNode(r.Method, nds)

if index < 0 {
return
}

c := tree.ctx.Get().(*context)
c.Reset(fox, w, r)

target := r.URL.Path
if len(r.URL.RawPath) > 0 {
// Using RawPath to prevent unintended match (e.g. /search/a%2Fb/1)
target = r.URL.RawPath
}

n, tsr := tree.lookup(nds[index], target, c.params, c.skipNds, false)
if n != nil {
c.path = n.path
return n.handler, c, tsr
}
c.Close()
return nil, nil, tsr
return tree.Lookup(w, r)
}

// SkipMethod is used as a return value from WalkFunc to indicate that
Expand Down Expand Up @@ -293,7 +301,7 @@ func (fox *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {

tree := fox.tree.Load()
c := tree.ctx.Get().(*context)
c.Reset(fox, w, r)
c.Reset(w, r)

nds := *tree.nodes.Load()
index := findRootNode(r.Method, nds)
Expand All @@ -302,7 +310,7 @@ func (fox *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}

n, tsr = tree.lookup(nds[index], target, c.params, c.skipNds, false)
if n != nil {
if !tsr && n != nil {
c.path = n.path
n.handler(c)
// Put back the context, if not extended more than max params or max depth, allowing
Expand All @@ -313,15 +321,27 @@ func (fox *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}

// Reset params as it may have recorded wildcard segment
*c.params = (*c.params)[:0]
if r.Method != http.MethodConnect && r.URL.Path != "/" && tsr {
if fox.ignoreTrailingSlash {
c.path = n.path
n.handler(c)
c.Close()
return
}

if r.Method != http.MethodConnect && r.URL.Path != "/" && tsr && fox.redirectTrailingSlash && target == CleanPath(target) {
fox.tsrRedirect(c)
c.Close()
return
if fox.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]
fox.tsrRedirect(c)
c.Close()
return
}
}

// Reset params as it may have recorded wildcard segment (the context may still be used in no route, no method and
// automatic option handler or middleware)
*c.params = (*c.params)[:0]

NoMethodFallback:
if r.Method == http.MethodOptions && fox.handleOptions {
var sb strings.Builder
Expand All @@ -338,7 +358,7 @@ NoMethodFallback:
}
} else {
for i := 0; i < len(nds); i++ {
if n, _ := tree.lookup(nds[i], target, c.params, c.skipNds, true); n != nil {
if n, tsr := tree.lookup(nds[i], target, c.params, c.skipNds, true); n != nil && (!tsr || fox.ignoreTrailingSlash) {
if sb.Len() > 0 {
sb.WriteString(", ")
} else {
Expand All @@ -361,7 +381,7 @@ NoMethodFallback:
var sb strings.Builder
for i := 0; i < len(nds); i++ {
if nds[i].key != r.Method {
if n, _ := tree.lookup(nds[i], target, c.params, c.skipNds, true); n != nil {
if n, tsr := tree.lookup(nds[i], target, c.params, c.skipNds, true); n != nil && (!tsr || fox.ignoreTrailingSlash) {
if sb.Len() > 0 {
sb.WriteString(", ")
}
Expand Down Expand Up @@ -590,14 +610,32 @@ func applyMiddleware(scope MiddlewareScope, mws []middleware, h HandlerFunc) Han
return m
}

// localRedirect redirect the client to the new path.
// It does not convert relative paths to absolute paths like Redirect does.
func localRedirect(w http.ResponseWriter, r *http.Request, newPath string, code int) {
// localRedirect redirect the client to the new path, but it does not convert relative paths to absolute paths
// like Redirect does. If the Content-Type header has not been set, localRedirect sets it to "text/html; charset=utf-8"
// and writes a small HTML body. Setting the Content-Type header to any value, including nil, disables that behavior.
func localRedirect(w http.ResponseWriter, r *http.Request, path string, code int) {
if q := r.URL.RawQuery; q != "" {
newPath += "?" + q
path += "?" + q
}

h := w.Header()

// RFC 7231 notes that a short HTML body is usually included in
// the response because older user agents may not understand 301/307.
// Do it only if the request didn't already have a Content-Type header.
_, hadCT := h["Content-Type"]

h.Set(HeaderLocation, hexEscapeNonASCII(path))
if !hadCT && (r.Method == "GET" || r.Method == "HEAD") {
h.Set(HeaderContentType, MIMETextHTMLCharsetUTF8)
}
w.Header().Set(HeaderLocation, newPath)
w.WriteHeader(code)

// Shouldn't send the body for POST or HEAD; that leaves GET.
if !hadCT && r.Method == "GET" {
body := "<a href=\"" + htmlEscape(path) + "\">" + http.StatusText(code) + "</a>.\n"
_, _ = fmt.Fprintln(w, body)
}
}

// grow increases the slice's capacity, if necessary, to guarantee space for
Expand All @@ -613,3 +651,47 @@ func grow[S ~[]E, E any](s S, n int) S {
}
return s
}

func hexEscapeNonASCII(s string) string {
newLen := 0
for i := 0; i < len(s); i++ {
if s[i] >= utf8.RuneSelf {
newLen += 3
} else {
newLen++
}
}
if newLen == len(s) {
return s
}
b := make([]byte, 0, newLen)
var pos int
for i := 0; i < len(s); i++ {
if s[i] >= utf8.RuneSelf {
if pos < i {
b = append(b, s[pos:i]...)
}
b = append(b, '%')
b = strconv.AppendInt(b, int64(s[i]), 16)
pos = i + 1
}
}
if pos < len(s) {
b = append(b, s[pos:]...)
}
return string(b)
}

var htmlReplacer = strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
// "&#34;" is shorter than "&quot;".
`"`, "&#34;",
// "&#39;" is shorter than "&apos;" and apos was not in HTML until HTML5.
"'", "&#39;",
)

func htmlEscape(s string) string {
return htmlReplacer.Replace(s)
}
Loading

0 comments on commit 670e306

Please sign in to comment.