diff --git a/README.md b/README.md index 8ea1c56..a6a7295 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,28 @@ There are a lot of good routers out there. But looking at the ones that were rea ## Handler The handler is a simple function with the prototype `func(w http.ResponseWriter, r *http.Request, params map[string]string)`. The params argument contains the parameters parsed from wildcards and catch-alls in the URL, as described below. This type is aliased as httptreemux.HandlerFunc. +### Using http.HandlerFunc +Due to the inclusion of the [context](https://godoc.org/context) package as of Go 1.7, `httptreemux` now supports handlers of type [http.HandlerFunc](https://godoc.org/net/http#HandlerFunc): + +```go +router := httptreemux.New() + +group := tree.NewGroup("/api") +group.GET("/v1/:id", func(w http.ResponseWriter, r *http.Request, params map[string]string) { + id := params["id"] + fmt.Fprintf(w, "GET /api/v1/%s", id) +}) + +ctxGroup := group.UsingContext() // sibling to 'group' node in tree +ctxGroup.GET("/v2", func(w http.ResponseWriter, r *http.Request) { + params := httptreemux.ContextParams(r.Context()) + id := params["id"] + fmt.Fprintf(w, "GET /api/v2/%s", id) +}) + +http.ListenAndServe(":8080", router) +``` + ## Routing Rules The syntax here is also modeled after httprouter. Each variable in a path may match on one segment only, except for an optional catch-all variable at the end of the URL. @@ -72,15 +94,17 @@ router.GET("/:year/:month/:post", postHandler) router.GET("/:year/:month", archiveHandler) router.GET("/images/*path", staticHandler) router.GET("/favicon.ico", staticHandler) - -/abc will match /:page -/2014/05 will match /:year/:month -/2014/05/really-great-blog-post will match /:year/:month/:post -/images/CoolImage.gif will match /images/*path -/images/2014/05/MayImage.jpg will also match /images/*path, with all the text after /images stored in the variable path. -/favicon.ico will match /favicon.ico ``` +#### Example scenarios + +- `/abc` will match `/:page` +- `/2014/05` will match `/:year/:month` +- `/2014/05/really-great-blog-post` will match `/:year/:month/:post` +- `/images/CoolImage.gif` will match `/images/*path` +- `/images/2014/05/MayImage.jpg` will also match `/images/*path`, with all the text after `/images` stored in the variable path. +- `/favicon.ico` will match `/favicon.ico` + ### Special Method Behavior If TreeMux.HeadCanUseGet is set to true, the router will call the GET handler for a pattern when a HEAD request is processed, if no HEAD handler has been added for that pattern. This behavior is enabled by default. @@ -172,7 +196,6 @@ When matching on parameters in a route, the `gorilla/pat` router will modify query string. `httptreemux` does not do this. See [Issue #26](https://github.com/dimfeld/httptreemux/issues/26) for more details and a code snippet that can perform this transformation for you, should you want it. - ## Middleware This package provides no middleware. But there are a lot of great options out there and it's pretty easy to write your own. diff --git a/context.go b/context.go new file mode 100644 index 0000000..fc3d698 --- /dev/null +++ b/context.go @@ -0,0 +1,99 @@ +// +build go1.7 + +package httptreemux + +import ( + "context" + "net/http" +) + +// ContextGroup is a wrapper around Group, with the purpose of mimicking its API, but with the use of http.HandlerFunc-based handlers. +// Instead of passing a parameter map via the handler (i.e. httptreemux.HandlerFunc), the path parameters are accessed via the request +// object's context. +type ContextGroup struct { + group *Group +} + +// UsingContext wraps the receiver to return a new instance of a ContextGroup. +// The returned ContextGroup is a sibling to its wrapped Group, within the parent TreeMux. +// The choice of using a *Group as the reciever, as opposed to a function parameter, allows chaining +// while method calls between a TreeMux, Group, and ContextGroup. For example: +// +// tree := httptreemux.New() +// group := tree.NewGroup("/api") +// +// group.GET("/v1", func(w http.ResponseWriter, r *http.Request, params map[string]string) { +// w.Write([]byte(`GET /api/v1`)) +// }) +// +// group.UsingContext().GET("/v2", func(w http.ResponseWriter, r *http.Request) { +// w.Write([]byte(`GET /api/v2`)) +// }) +// +// http.ListenAndServe(":8080", tree) +// +func (g *Group) UsingContext() *ContextGroup { + return &ContextGroup{g} +} + +// NewContextGroup adds a child context group to its path. +func (cg *ContextGroup) NewContextGroup(path string) *ContextGroup { + return &ContextGroup{cg.group.NewGroup(path)} +} + +// Handle allows handling HTTP requests via an http.HandlerFunc, as opposed to an httptreemux.HandlerFunc. +// Any parameters from the request URL are stored in via a map[string]string in the request's context. +func (cg *ContextGroup) Handle(method, path string, handler http.HandlerFunc) { + cg.group.Handle(method, path, func(w http.ResponseWriter, r *http.Request, params map[string]string) { + if params != nil { + r = r.WithContext(context.WithValue(r.Context(), ParamsContextKey, params)) + } + handler(w, r) + }) +} + +// GET is convenience method for handling GET requests on a context group. +func (cg *ContextGroup) GET(path string, handler http.HandlerFunc) { + cg.Handle("GET", path, handler) +} + +// POST is convenience method for handling POST requests on a context group. +func (cg *ContextGroup) POST(path string, handler http.HandlerFunc) { + cg.Handle("POST", path, handler) +} + +// PUT is convenience method for handling PUT requests on a context group. +func (cg *ContextGroup) PUT(path string, handler http.HandlerFunc) { + cg.Handle("PUT", path, handler) +} + +// DELETE is convenience method for handling DELETE requests on a context group. +func (cg *ContextGroup) DELETE(path string, handler http.HandlerFunc) { + cg.Handle("DELETE", path, handler) +} + +// PATCH is convenience method for handling PATCH requests on a context group. +func (cg *ContextGroup) PATCH(path string, handler http.HandlerFunc) { + cg.Handle("PATCH", path, handler) +} + +// HEAD is convenience method for handling HEAD requests on a context group. +func (cg *ContextGroup) HEAD(path string, handler http.HandlerFunc) { + cg.Handle("HEAD", path, handler) +} + +// OPTIONS is convenience method for handling OPTIONS requests on a context group. +func (cg *ContextGroup) OPTIONS(path string, handler http.HandlerFunc) { + cg.Handle("OPTIONS", path, handler) +} + +// ContextParams returns the params map associated with the given context if one exists. Otherwise, an empty map is returned. +func ContextParams(ctx context.Context) map[string]string { + if p, ok := ctx.Value(ParamsContextKey).(map[string]string); ok { + return p + } + return map[string]string{} +} + +// ParamsContextKey is used to retrieve a path's params map from a request's context. +const ParamsContextKey = "params.context.key" diff --git a/context_test.go b/context_test.go new file mode 100644 index 0000000..31d9557 --- /dev/null +++ b/context_test.go @@ -0,0 +1,133 @@ +// +build go1.7 + +package httptreemux + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestContextParams(t *testing.T) { + m := map[string]string{"id": "123"} + ctx := context.WithValue(context.Background(), ParamsContextKey, m) + + params := ContextParams(ctx) + if params == nil { + t.Errorf("expected '%#v', but got '%#v'", m, params) + } + + if v := params["id"]; v != "123" { + t.Errorf("expected '%s', but got '%#v'", m["id"], params["id"]) + } +} + +func TestContextGroupMethods(t *testing.T) { + for _, scenario := range scenarios { + t.Log(scenario.description) + testContextGroupMethods(t, scenario.RequestCreator, true) + testContextGroupMethods(t, scenario.RequestCreator, false) + } +} + +func testContextGroupMethods(t *testing.T, reqGen RequestCreator, headCanUseGet bool) { + var result string + makeHandler := func(method string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + result = method + + v, ok := ContextParams(r.Context())["param"] + if !ok { + t.Error("missing key 'param' in context") + } + + if headCanUseGet && method == "GET" && v == "HEAD" { + return + } + + if v != method { + t.Errorf("invalid key 'param' in context; expected '%s' but got '%s'", method, v) + } + } + } + + router := New() + router.HeadCanUseGet = headCanUseGet + + cg := router.UsingContext().NewContextGroup("/base").NewContextGroup("/user") + cg.GET("/:param", makeHandler("GET")) + cg.POST("/:param", makeHandler("POST")) + cg.PATCH("/:param", makeHandler("PATCH")) + cg.PUT("/:param", makeHandler("PUT")) + cg.DELETE("/:param", makeHandler("DELETE")) + + testMethod := func(method, expect string) { + result = "" + w := httptest.NewRecorder() + r, _ := reqGen(method, "/base/user/"+method, nil) + router.ServeHTTP(w, r) + if expect == "" && w.Code != http.StatusMethodNotAllowed { + t.Errorf("Method %s not expected to match but saw code %d", method, w.Code) + } + + if result != expect { + t.Errorf("Method %s got result %s", method, result) + } + } + + testMethod("GET", "GET") + testMethod("POST", "POST") + testMethod("PATCH", "PATCH") + testMethod("PUT", "PUT") + testMethod("DELETE", "DELETE") + + if headCanUseGet { + t.Log("Test implicit HEAD with HeadCanUseGet = true") + testMethod("HEAD", "GET") + } else { + t.Log("Test implicit HEAD with HeadCanUseGet = false") + testMethod("HEAD", "") + } + + cg.HEAD("/:param", makeHandler("HEAD")) + testMethod("HEAD", "HEAD") +} + +func TestNewContextGroup(t *testing.T) { + router := New() + group := router.NewGroup("/api") + + group.GET("/v1", func(w http.ResponseWriter, r *http.Request, params map[string]string) { + w.Write([]byte(`200 OK GET /api/v1`)) + }) + + group.UsingContext().GET("/v2", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`200 OK GET /api/v2`)) + }) + + tests := []struct { + uri, expected string + }{ + {"/api/v1", "200 OK GET /api/v1"}, + {"/api/v2", "200 OK GET /api/v2"}, + } + + for _, tc := range tests { + r, err := http.NewRequest("GET", tc.uri, nil) + if err != nil { + t.Fatal(err) + } + + w := httptest.NewRecorder() + router.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Errorf("GET %s: expected %d, but got %d", tc.uri, http.StatusOK, w.Code) + } + if got := w.Body.String(); got != tc.expected { + t.Errorf("GET %s : expected %q, but got %q", tc.uri, tc.expected, got) + } + + } +}