Skip to content

Commit

Permalink
Allow to register middleware for a specific handler scope (404, 405, …
Browse files Browse the repository at this point in the history
…etc...) (#14)

* Allow to apply middleware to specific handler kind such as NotFound and MethodNotAllowed handler

* Improve README example

* Improve naming

* Typo

* Fix tsr edge case

* Improve test coverage
  • Loading branch information
tigerwill90 authored Apr 17, 2023
1 parent 44384c1 commit d46096a
Show file tree
Hide file tree
Showing 6 changed files with 240 additions and 233 deletions.
56 changes: 32 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ routing structure based on user input, configuration changes, or other runtime e
The current api is not yet stabilize. Breaking changes may occur before `v1.0.0` and will be noted on the release note.

## Features
**Routing mutation:** Register, update and remove route handler safely at any time without impact on performance. Fox never block while serving
**Runtime updates:** Register, update and remove route handler safely at any time without impact on performance. Fox never block while serving
request!

**Wildcard pattern:** Route can be registered using wildcard parameters. The matched path segment can then be easily retrieved by
Expand All @@ -33,9 +33,6 @@ even for complex routing pattern.
**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 (disable by default).

**Path auto-correction:** Inspired from [httprouter](https://github.com/julienschmidt/httprouter), the router can remove superfluous path
elements like `../` or `//` and automatically redirect the client if the cleaned path match a handler (disable by default).

Of course, you can also register custom `NotFound` and `MethodNotAllowed` handlers.

## Getting started
Expand Down Expand Up @@ -63,19 +60,19 @@ func (h *Greeting) Greet(c fox.Context) {
}

func main() {
r := fox.New(fox.DefaultOptions())
f := fox.New(fox.DefaultOptions())

err := r.Handle(http.MethodGet, "/", func(c fox.Context) {
err := f.Handle(http.MethodGet, "/", func(c fox.Context) {
_ = c.String(http.StatusOK, "Welcome\n")
})
if err != nil {
panic(err)
}

h := Greeting{Say: "Hello"}
r.MustHandle(http.MethodGet, "/hello/{name}", h.Greet)
f.MustHandle(http.MethodGet, "/hello/{name}", h.Greet)

log.Fatalln(http.ListenAndServe(":8080", r))
log.Fatalln(http.ListenAndServe(":8080", f))
}
````
#### Error handling
Expand Down Expand Up @@ -245,9 +242,9 @@ func Action(c fox.Context) {
}

func main() {
r := fox.New()
r.MustHandle(http.MethodPost, "/routes/{action}", Action)
log.Fatalln(http.ListenAndServe(":8080", r))
f := fox.New()
f.MustHandle(http.MethodPost, "/routes/{action}", Action)
log.Fatalln(http.ListenAndServe(":8080", f))
}
````

Expand Down Expand Up @@ -282,11 +279,9 @@ func (h *HtmlRenderer) Render(c fox.Context) {
}

func main() {
r := fox.New()

go Reload(r)

log.Fatalln(http.ListenAndServe(":8080", r))
f := fox.New()
go Reload(f)
log.Fatalln(http.ListenAndServe(":8080", f))
}

func Reload(r *fox.Router) {
Expand Down Expand Up @@ -373,21 +368,21 @@ articles := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintln(w, "get articles")
})

r := fox.New(fox.DefaultOptions())
r.MustHandle(http.MethodGet, "/articles", fox.WrapH(httpRateLimiter.RateLimit(articles)))
f := fox.New(fox.DefaultOptions())
f.MustHandle(http.MethodGet, "/articles", fox.WrapH(httpRateLimiter.RateLimit(articles)))
```

Wrapping an `http.Handler` compatible middleware
````go
r := fox.New(fox.DefaultOptions(), fox.WithMiddleware(fox.WrapM(httpRateLimiter.RateLimit)))
r.MustHandle(http.MethodGet, "/articles/{id}", func(c fox.Context) {
f := fox.New(fox.DefaultOptions(), fox.WithMiddleware(fox.WrapM(httpRateLimiter.RateLimit)))
f.MustHandle(http.MethodGet, "/articles/{id}", func(c fox.Context) {
_ = c.String(http.StatusOK, "Article id: %s\n", c.Param("id"))
})
````

## Middleware
Middlewares can be registered globally using the `fox.WithMiddleware` option. The example below demonstrates how
to create and apply automatically a simple logging middleware to all route.
to create and apply automatically a simple logging middleware to all routes (including 404, 405, etc...).

````go
package main
Expand All @@ -413,9 +408,9 @@ func Logger(next fox.HandlerFunc) fox.HandlerFunc {
}

func main() {
r := fox.New(fox.WithMiddleware(Logger))
f := fox.New(fox.WithMiddleware(Logger))

r.MustHandle(http.MethodGet, "/", func(c fox.Context) {
f.MustHandle(http.MethodGet, "/", func(c fox.Context) {
resp, err := http.Get("https://api.coindesk.com/v1/bpi/currentprice.json")
if err != nil {
http.Error(c.Writer(), http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
Expand All @@ -425,10 +420,22 @@ func main() {
_ = c.Stream(http.StatusOK, fox.MIMEApplicationJSON, resp.Body)
})

log.Fatalln(http.ListenAndServe(":8080", r))
log.Fatalln(http.ListenAndServe(":8080", f))
}
````

Additionally, `fox.WithMiddlewareFor` option provide a more fine-grained control over where a middleware is applied, such as
only for 404 or 405 handlers. Possible scopes include `fox.RouteHandlers` (regular routes), `fox.NotFoundHandler`, `fox.MethodNotAllowedHandler`,
`RedirectHandler`, and any combination of these.

````go
f := fox.New(
fox.WithMethodNotAllowed(true),
fox.WithMiddlewareFor(fox.RouteHandlers, fox.Recovery(fox.DefaultHandleRecovery), Logger),
fox.WithMiddlewareFor(fox.NotFoundHandler|fox.MethodNotAllowedHandler, SpecialLogger),
)
````

## Benchmark
The primary goal of Fox is to be a lightweight, high performance router which allow routes modification at runtime.
The following benchmarks attempt to compare Fox to various popular alternatives, including both fully-featured web frameworks
Expand Down Expand Up @@ -577,3 +584,4 @@ The intention behind these choices is that it can serve as a building block for
- [npgall/concurrent-trees](https://github.com/npgall/concurrent-trees): Fox design is largely inspired from Niall Gallagher's Concurrent Trees design.
- [julienschmidt/httprouter](https://github.com/julienschmidt/httprouter): some feature that implements Fox are inspired from Julien Schmidt's router. Most notably,
this package uses the optimized [httprouter.Cleanpath](https://github.com/julienschmidt/httprouter/blob/master/path.go) function.
- The router API is influenced by popular routers such as [gin](https://github.com/gin-gonic/gin) and [https://github.com/labstack/echo](echo).
117 changes: 50 additions & 67 deletions fox.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,26 +41,35 @@ type MiddlewareFunc func(next HandlerFunc) HandlerFunc
type Router struct {
noRoute HandlerFunc
noMethod HandlerFunc
tsrRedirect HandlerFunc
tree atomic.Pointer[Tree]
mws []MiddlewareFunc
mws []middleware
handleMethodNotAllowed bool
redirectFixedPath bool
redirectTrailingSlash bool
}

type middleware struct {
m MiddlewareFunc
scope MiddlewareScope
}

var _ http.Handler = (*Router)(nil)

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

r.noRoute = NotFoundHandler()
r.noMethod = MethodNotAllowedHandler()
r.noRoute = DefaultNotFoundHandler()
r.noMethod = DefaultMethodNotAllowedHandler()

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

r.noRoute = applyMiddleware(NotFoundHandler, r.mws, r.noRoute)
r.noMethod = applyMiddleware(MethodNotAllowedHandler, r.mws, r.noMethod)
r.tsrRedirect = applyMiddleware(RedirectHandler, r.mws, defaultRedirectTrailingSlash())

r.tree.Store(r.NewTree())
return r
}
Expand Down Expand Up @@ -215,22 +224,47 @@ Next:
return nil
}

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

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

func defaultRedirectTrailingSlash() HandlerFunc {
return func(c Context) {
req := c.Request()

code := http.StatusMovedPermanently
if req.Method != http.MethodGet {
// Will be redirected only with the same method (SEO friendly)
code = http.StatusPermanentRedirect
}

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

if url[len(url)-1] == '/' {
localRedirect(c.Writer(), req, path.Base(url)+"/", code)
return
}
localRedirect(c.Writer(), req, "../"+path.Base(url), code)
}
}

func (fox *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {

var (
Expand Down Expand Up @@ -268,53 +302,10 @@ func (fox *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Reset params as it may have recorded wildcard segment
*c.params = (*c.params)[:0]

if r.Method != http.MethodConnect && r.URL.Path != "/" {

code := http.StatusMovedPermanently
if r.Method != http.MethodGet {
// Will be redirected only with the same method (SEO friendly)
code = http.StatusPermanentRedirect
}

cleanedPath := CleanPath(target)
if tsr && fox.redirectTrailingSlash && target == cleanedPath {
redirectTrailingSlash(w, r, cleanedPath, code)
c.Close()
return
}

if fox.redirectFixedPath {
n, tsr := tree.lookup(nds[index], cleanedPath, c.params, c.skipNds, true)
if n != nil {
if len(r.URL.RawPath) > 0 {
// RawPath needs to be equal to escape(r.URL.Path)
// or the URL.String will use the r.URL.Path.
r.URL.RawPath = cleanedPath
r.URL.Path = CleanPath(r.URL.Path)
} else {
r.URL.Path = cleanedPath
}
http.Redirect(w, r, r.URL.String(), code)
c.Close()
return
}

if tsr && fox.redirectTrailingSlash {
redirected := fixTrailingSlash(cleanedPath)
if len(r.URL.RawPath) > 0 {
// RawPath needs to be equal to escape(r.URL.Path)
// or the URL.String will use the r.URL.Path.
r.URL.RawPath = redirected
r.URL.Path = fixTrailingSlash(CleanPath(r.URL.Path))
} else {
r.URL.Path = redirected
}
http.Redirect(w, r, r.URL.String(), code)
c.Close()
return
}
}

if r.Method != http.MethodConnect && r.URL.Path != "/" && tsr && fox.redirectTrailingSlash && target == CleanPath(target) {
fox.tsrRedirect(c)
c.Close()
return
}

NoMethodFallback:
Expand Down Expand Up @@ -542,10 +533,12 @@ func isRemovable(method string) bool {
return true
}

func applyMiddleware(mws []MiddlewareFunc, h HandlerFunc) HandlerFunc {
func applyMiddleware(scope MiddlewareScope, mws []middleware, h HandlerFunc) HandlerFunc {
m := h
for i := len(mws) - 1; i >= 0; i-- {
m = mws[i](m)
if mws[i].scope&scope != 0 {
m = mws[i].m(m)
}
}
return m
}
Expand All @@ -559,13 +552,3 @@ func localRedirect(w http.ResponseWriter, r *http.Request, newPath string, code
w.Header().Set(HeaderLocation, newPath)
w.WriteHeader(code)
}

// redirectTrailingSlash redirect the client to the target path with or without an extra trailing slash.
func redirectTrailingSlash(w http.ResponseWriter, r *http.Request, target string, code int) {
url := fixTrailingSlash(target)
if url[len(url)-1] == '/' {
localRedirect(w, r, path.Base(url)+"/", code)
return
}
localRedirect(w, r, "../"+path.Base(url), code)
}
Loading

0 comments on commit d46096a

Please sign in to comment.