├── LICENSE ├── README.md ├── flow.go ├── flow_test.go └── go.mod /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Alex Edwards 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | ![Flow](https://raw.githubusercontent.com/alexedwards/flow/assets/flow-sm.png) 4 | 5 | [![Go Reference](https://pkg.go.dev/badge/github.com/alexedwards/flow.svg)](https://pkg.go.dev/github.com/alexedwards/flow) [![Go Report Card](https://goreportcard.com/badge/github.com/alexedwards/flow)](https://goreportcard.com/report/github.com/alexedwards/flow) [![MIT](https://img.shields.io/github/license/alexedwards/flow)](https://img.shields.io/github/license/alexedwards/flow) ![Code size](https://img.shields.io/github/languages/code-size/alexedwards/flow) 6 | 7 | A delightfully tiny but powerful HTTP router for Go web applications 8 |
9 | 10 | --- 11 | 12 | Flow packs in a bunch of features that you'll probably like: 13 | 14 | * Use **named parameters**, **wildcards** and (optionally) **regexp patterns** in your routes. 15 | * Create route **groups which use different middleware** (a bit like chi). 16 | * **Customizable handlers** for `404 Not Found` and `405 Method Not Allowed` responses. 17 | * **Automatic handling** of `OPTIONS` and `HEAD` requests. 18 | * Works with `http.Handler`, `http.HandlerFunc`, and standard Go middleware. 19 | * Zero dependencies. 20 | * Tiny, readable, codebase (~160 lines of code). 21 | 22 | --- 23 | 24 | ### Project status 25 | 26 | This package has reached a **stable** status. It is actively maintained with ongoing bug fixes and essential updates, but significant alterations to the API or behavior are not expected. 27 | 28 | --- 29 | 30 | ### Installation 31 | 32 | ``` 33 | $ go get github.com/alexedwards/flow@latest 34 | ``` 35 | 36 | ### Basic example 37 | 38 | ```go 39 | package main 40 | 41 | import ( 42 | "fmt" 43 | "log" 44 | "net/http" 45 | 46 | "github.com/alexedwards/flow" 47 | ) 48 | 49 | func main() { 50 | // Initialize a new router. 51 | mux := flow.New() 52 | 53 | // Add a `GET /greet/:name` route. The : character is used to denote a 54 | // named parameter in the URL path, which acts like a 'wildcard'. 55 | mux.HandleFunc("/greet/:name", greet, "GET") 56 | 57 | err := http.ListenAndServe(":2323", mux) 58 | log.Fatal(err) 59 | } 60 | 61 | func greet(w http.ResponseWriter, r *http.Request) { 62 | // Retrieve the value of the named parameter from the request. 63 | name := r.PathValue("name") 64 | 65 | fmt.Fprintf(w, "Hello %s", name) 66 | } 67 | ``` 68 | 69 | ### Kitchen-sink example 70 | 71 | ```go 72 | mux := flow.New() 73 | 74 | // The Use() method can be used to register middleware. Middleware declared at 75 | // the top level will be used on all routes (including error handlers and OPTIONS 76 | // responses). 77 | mux.Use(exampleMiddleware1) 78 | 79 | // Routes can use multiple HTTP methods. 80 | mux.HandleFunc("/profile/:name", exampleHandlerFunc1, "GET", "POST") 81 | 82 | // Optionally, regular expressions can be used to enforce a specific pattern 83 | // for a named parameter. 84 | mux.HandleFunc("/profile/:name/:age|^[0-9]{1,3}$", exampleHandlerFunc2, "GET") 85 | 86 | // The wildcard ... can be used to match the remainder of a request path. 87 | // Notice that HTTP methods are also optional (if not provided, all HTTP 88 | // methods will match the route). The value of the wildcard can be retrieved 89 | // by calling r.PathValue("..."). 90 | mux.Handle("/static/...", exampleHandler) 91 | 92 | // You can create route 'groups'. 93 | mux.Group(func(mux *flow.Mux) { 94 | // Middleware declared within the group will only be used on the routes 95 | // in the group. 96 | mux.Use(exampleMiddleware2) 97 | 98 | mux.HandleFunc("/admin", exampleHandlerFunc3, "GET") 99 | 100 | // Groups can be nested. 101 | mux.Group(func(mux *flow.Mux) { 102 | mux.Use(exampleMiddleware3) 103 | 104 | mux.HandleFunc("/admin/passwords", exampleHandlerFunc4, "GET") 105 | }) 106 | }) 107 | ``` 108 | 109 | ### Notes 110 | 111 | * Conflicting routes are permitted (e.g. `/posts/:id` and `posts/new`). Routes are matched in the order that they are declared. 112 | * Trailing slashes are significant (`/profile/:id` and `/profile/:id/` are not the same). 113 | * An `Allow` header is automatically set for all `OPTIONS` and `405 Method Not Allowed` responses (including when using custom handlers). 114 | * Once the `flow.Mux` type is being used by your server, it is *not safe* to add more middleware or routes concurrently. 115 | * Middleware must be declared *before* a route in order to be used by that route. Any middleware declared after a route won't act on that route. For example: 116 | 117 | ```go 118 | mux := flow.New() 119 | mux.Use(middleware1) 120 | mux.HandleFunc("/foo", ...) // This route will use middleware1 only. 121 | mux.Use(middleware2) 122 | mux.HandleFunc("/bar", ...) // This route will use both middleware1 and middleware2. 123 | ``` 124 | 125 | ### Thanks 126 | 127 | The pattern matching logic for Flow was heavily inspired by [matryer/way](https://github.com/matryer/way). 128 | -------------------------------------------------------------------------------- /flow.go: -------------------------------------------------------------------------------- 1 | // Package flow is a delightfully simple, readable, and tiny HTTP router for Go web applications. Its features include: 2 | // 3 | // * Use named parameters, wildcards and (optionally) regexp patterns in your routes. 4 | // * Create route groups which use different middleware (a bit like chi). 5 | // * Customizable handlers for 404 Not Found and 405 Method Not Allowed responses. 6 | // * Automatic handling of OPTIONS and HEAD requests. 7 | // * Works with http.Handler, http.HandlerFunc, and standard Go middleware. 8 | // 9 | // Example code: 10 | // 11 | // package main 12 | // 13 | // import ( 14 | // "fmt" 15 | // "log" 16 | // "net/http" 17 | // 18 | // "github.com/alexedwards/flow" 19 | // ) 20 | // 21 | // func main() { 22 | // mux := flow.New() 23 | // 24 | // // The Use() method can be used to register middleware. Middleware declared at 25 | // // the top level will used on all routes (including error handlers and OPTIONS 26 | // // responses). 27 | // mux.Use(exampleMiddleware1) 28 | // 29 | // // Routes can use multiple HTTP methods. 30 | // mux.HandleFunc("/profile/:name", exampleHandlerFunc1, "GET", "POST") 31 | // 32 | // // Optionally, regular expressions can be used to enforce a specific pattern 33 | // // for a named parameter. 34 | // mux.HandleFunc("/profile/:name/:age|^[0-9]{1,3}$", exampleHandlerFunc2, "GET") 35 | // 36 | // // The wildcard ... can be used to match the remainder of a request path. 37 | // // Notice that HTTP methods are also optional (if not provided, all HTTP 38 | // // methods will match the route). 39 | // mux.Handle("/static/...", exampleHandler) 40 | // 41 | // // You can create route 'groups'. 42 | // mux.Group(func(mux *flow.Mux) { 43 | // // Middleware declared within in the group will only be used on the routes 44 | // // in the group. 45 | // mux.Use(exampleMiddleware2) 46 | // 47 | // mux.HandleFunc("/admin", exampleHandlerFunc3, "GET") 48 | // 49 | // // Groups can be nested. 50 | // mux.Group(func(mux *flow.Mux) { 51 | // mux.Use(exampleMiddleware3) 52 | // 53 | // mux.HandleFunc("/admin/passwords", exampleHandlerFunc4, "GET") 54 | // }) 55 | // }) 56 | // 57 | // err := http.ListenAndServe(":2323", mux) 58 | // log.Fatal(err) 59 | // } 60 | package flow 61 | 62 | import ( 63 | "context" 64 | "net/http" 65 | "regexp" 66 | "slices" 67 | "strings" 68 | ) 69 | 70 | // AllMethods is a slice containing all HTTP request methods. 71 | var AllMethods = []string{http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, http.MethodConnect, http.MethodOptions, http.MethodTrace} 72 | 73 | var compiledRXPatterns = map[string]*regexp.Regexp{} 74 | 75 | type contextKey string 76 | 77 | // Deprecated: Use r.PathValue instead (https://pkg.go.dev/net/http#Request.PathValue). 78 | func Param(ctx context.Context, param string) string { 79 | s, ok := ctx.Value(contextKey(param)).(string) 80 | if !ok { 81 | return "" 82 | } 83 | 84 | return s 85 | } 86 | 87 | // Mux is a http.Handler which dispatches requests to different handlers. 88 | type Mux struct { 89 | NotFound http.Handler 90 | MethodNotAllowed http.Handler 91 | Options http.Handler 92 | routes *[]route 93 | middlewares []func(http.Handler) http.Handler 94 | } 95 | 96 | // New returns a new initialized Mux instance. 97 | func New() *Mux { 98 | return &Mux{ 99 | NotFound: http.NotFoundHandler(), 100 | MethodNotAllowed: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 101 | http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) 102 | }), 103 | Options: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 104 | w.WriteHeader(http.StatusNoContent) 105 | }), 106 | routes: &[]route{}, 107 | } 108 | } 109 | 110 | // Handle registers a new handler for the given request path pattern and HTTP 111 | // methods. 112 | func (m *Mux) Handle(pattern string, handler http.Handler, methods ...string) { 113 | if slices.Contains(methods, http.MethodGet) && !slices.Contains(methods, http.MethodHead) { 114 | methods = append(methods, http.MethodHead) 115 | } 116 | 117 | if len(methods) == 0 { 118 | methods = AllMethods 119 | } 120 | 121 | for _, method := range methods { 122 | route := route{ 123 | method: strings.ToUpper(method), 124 | segments: strings.Split(pattern, "/"), 125 | wildcard: strings.HasSuffix(pattern, "/..."), 126 | handler: m.wrap(handler), 127 | } 128 | 129 | *m.routes = append(*m.routes, route) 130 | } 131 | 132 | // Compile any regular expression patterns and add them to the 133 | // compiledRXPatterns map. 134 | for _, segment := range strings.Split(pattern, "/") { 135 | if strings.HasPrefix(segment, ":") { 136 | _, rxPattern, containsRx := strings.Cut(segment, "|") 137 | if containsRx { 138 | compiledRXPatterns[rxPattern] = regexp.MustCompile(rxPattern) 139 | } 140 | } 141 | } 142 | } 143 | 144 | // HandleFunc is an adapter which allows using a http.HandlerFunc as a handler. 145 | func (m *Mux) HandleFunc(pattern string, fn http.HandlerFunc, methods ...string) { 146 | m.Handle(pattern, fn, methods...) 147 | } 148 | 149 | // Use registers middleware with the Mux instance. Middleware must have the 150 | // signature `func(http.Handler) http.Handler`. 151 | func (m *Mux) Use(mw ...func(http.Handler) http.Handler) { 152 | m.middlewares = append(m.middlewares, mw...) 153 | } 154 | 155 | // Group is used to create 'groups' of routes in a Mux. Middleware registered 156 | // inside the group will only be used on the routes in that group. See the 157 | // example code at the start of the package documentation for how to use this 158 | // feature. 159 | func (m *Mux) Group(fn func(*Mux)) { 160 | mm := *m 161 | fn(&mm) 162 | } 163 | 164 | // ServeHTTP makes the router implement the http.Handler interface. 165 | func (m *Mux) ServeHTTP(w http.ResponseWriter, r *http.Request) { 166 | urlSegments := strings.Split(r.URL.EscapedPath(), "/") 167 | allowedMethods := []string{} 168 | 169 | for _, route := range *m.routes { 170 | ctx, ok := route.match(r.Context(), r, urlSegments) 171 | if ok { 172 | if r.Method == route.method { 173 | route.handler.ServeHTTP(w, r.WithContext(ctx)) 174 | return 175 | } 176 | if !slices.Contains(allowedMethods, route.method) { 177 | allowedMethods = append(allowedMethods, route.method) 178 | } 179 | } 180 | } 181 | 182 | if len(allowedMethods) > 0 { 183 | w.Header().Set("Allow", strings.Join(append(allowedMethods, http.MethodOptions), ", ")) 184 | if r.Method == http.MethodOptions { 185 | m.wrap(m.Options).ServeHTTP(w, r) 186 | } else { 187 | m.wrap(m.MethodNotAllowed).ServeHTTP(w, r) 188 | } 189 | return 190 | } 191 | 192 | m.wrap(m.NotFound).ServeHTTP(w, r) 193 | } 194 | 195 | func (m *Mux) wrap(handler http.Handler) http.Handler { 196 | for i := len(m.middlewares) - 1; i >= 0; i-- { 197 | handler = m.middlewares[i](handler) 198 | } 199 | 200 | return handler 201 | } 202 | 203 | type route struct { 204 | method string 205 | segments []string 206 | wildcard bool 207 | handler http.Handler 208 | } 209 | 210 | func (r *route) match(ctx context.Context, rq *http.Request, urlSegments []string) (context.Context, bool) { 211 | if !r.wildcard && len(urlSegments) != len(r.segments) { 212 | return ctx, false 213 | } 214 | 215 | for i, routeSegment := range r.segments { 216 | if i > len(urlSegments)-1 { 217 | return ctx, false 218 | } 219 | 220 | if routeSegment == "..." { 221 | rq.SetPathValue("...", strings.Join(urlSegments[i:], "/")) 222 | ctx = context.WithValue(ctx, contextKey("..."), strings.Join(urlSegments[i:], "/")) 223 | return ctx, true 224 | } 225 | 226 | if strings.HasPrefix(routeSegment, ":") { 227 | key, rxPattern, containsRx := strings.Cut(strings.TrimPrefix(routeSegment, ":"), "|") 228 | 229 | if containsRx { 230 | if compiledRXPatterns[rxPattern].MatchString(urlSegments[i]) { 231 | rq.SetPathValue(key, urlSegments[i]) 232 | ctx = context.WithValue(ctx, contextKey(key), urlSegments[i]) 233 | continue 234 | } 235 | } 236 | 237 | if !containsRx && urlSegments[i] != "" { 238 | rq.SetPathValue(key, urlSegments[i]) 239 | ctx = context.WithValue(ctx, contextKey(key), urlSegments[i]) 240 | continue 241 | } 242 | 243 | return ctx, false 244 | } 245 | 246 | if urlSegments[i] != routeSegment { 247 | return ctx, false 248 | } 249 | } 250 | 251 | return ctx, true 252 | } 253 | -------------------------------------------------------------------------------- /flow_test.go: -------------------------------------------------------------------------------- 1 | package flow 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | ) 10 | 11 | func TestMatching(t *testing.T) { 12 | var tests = []struct { 13 | RouteMethods []string 14 | RoutePattern string 15 | 16 | RequestMethod string 17 | RequestPath string 18 | 19 | ExpectedStatus int 20 | ExpectedParams map[string]string 21 | ExpectedAllowHeader string 22 | }{ 23 | // simple path matching 24 | { 25 | []string{"GET"}, "/one", 26 | "GET", "/one", 27 | http.StatusOK, nil, "", 28 | }, 29 | { 30 | []string{"GET"}, "/one", 31 | "GET", "/two", 32 | http.StatusNotFound, nil, "", 33 | }, 34 | // nested 35 | { 36 | []string{"GET"}, "/parent/child/one", 37 | "GET", "/parent/child/one", 38 | http.StatusOK, nil, "", 39 | }, 40 | { 41 | []string{"GET"}, "/parent/child/one", 42 | "GET", "/parent/child/two", 43 | http.StatusNotFound, nil, "", 44 | }, 45 | // misc no matches 46 | { 47 | []string{"GET"}, "/not/enough", 48 | "GET", "/not/enough/items", 49 | http.StatusNotFound, nil, "", 50 | }, 51 | { 52 | []string{"GET"}, "/not/enough/items", 53 | "GET", "/not/enough", 54 | http.StatusNotFound, nil, "", 55 | }, 56 | // wilcards 57 | { 58 | []string{"GET"}, "/prefix/...", 59 | "GET", "/prefix/anything/else", 60 | http.StatusOK, map[string]string{"...": "anything/else"}, "", 61 | }, 62 | { 63 | []string{"GET"}, "/prefix/...", 64 | "GET", "/prefix/", 65 | http.StatusOK, map[string]string{"...": ""}, "", 66 | }, 67 | { 68 | []string{"GET"}, "/prefix/...", 69 | "GET", "/prefix", 70 | http.StatusNotFound, nil, "", 71 | }, 72 | { 73 | []string{"GET"}, "/prefix", 74 | "GET", "/prefix/anything/else", 75 | http.StatusNotFound, nil, "", 76 | }, 77 | { 78 | []string{"GET"}, "/prefix/", 79 | "GET", "/prefix/anything/else", 80 | http.StatusNotFound, nil, "", 81 | }, 82 | { 83 | []string{"GET"}, "/prefix...", 84 | "GET", "/prefix/anything/else", 85 | http.StatusNotFound, nil, "", 86 | }, 87 | // path params 88 | { 89 | []string{"GET"}, "/path-params/:era/:group/:member", 90 | "GET", "/path-params/60/beatles/lennon", 91 | http.StatusOK, map[string]string{"era": "60", "group": "beatles", "member": "lennon"}, "", 92 | }, 93 | { 94 | []string{"GET"}, "/path-params/:era/:group/:member/foo", 95 | "GET", "/path-params/60/beatles/lennon/bar", 96 | http.StatusNotFound, map[string]string{"era": "60", "group": "beatles", "member": "lennon"}, "", 97 | }, 98 | { 99 | []string{"GET"}, "/path-params/:era", 100 | "GET", "/path-params/a%3A%2F%2Fb%2Fc", 101 | http.StatusOK, map[string]string{"era": "a%3A%2F%2Fb%2Fc"}, "", 102 | }, 103 | // regexp 104 | { 105 | []string{"GET"}, "/path-params/:era|^[0-9]{2}$/:group|^[a-z].+$", 106 | "GET", "/path-params/60/beatles", 107 | http.StatusOK, map[string]string{"era": "60", "group": "beatles"}, "", 108 | }, 109 | { 110 | []string{"GET"}, "/path-params/:era|^[0-9]{2}$/:group|^[a-z].+$", 111 | "GET", "/path-params/abc/123", 112 | http.StatusNotFound, nil, "", 113 | }, 114 | // kitchen sink 115 | { 116 | []string{"GET"}, "/path-params/:id/:era|^[0-9]{2}$/...", 117 | "GET", "/path-params/abc/12/foo/bar/baz", 118 | http.StatusOK, map[string]string{"id": "abc", "era": "12", "...": "foo/bar/baz"}, "", 119 | }, 120 | { 121 | []string{"GET"}, "/path-params/:id/:era|^[0-9]{2}$/...", 122 | "GET", "/path-params/abc/12", 123 | http.StatusNotFound, nil, "", 124 | }, 125 | // leading and trailing slashes 126 | { 127 | []string{"GET"}, "slashes/one", 128 | "GET", "/slashes/one", 129 | http.StatusNotFound, nil, "", 130 | }, 131 | { 132 | []string{"GET"}, "/slashes/two", 133 | "GET", "slashes/two", 134 | http.StatusNotFound, nil, "", 135 | }, 136 | { 137 | []string{"GET"}, "/slashes/three/", 138 | "GET", "/slashes/three", 139 | http.StatusNotFound, nil, "", 140 | }, 141 | { 142 | []string{"GET"}, "/slashes/four", 143 | "GET", "/slashes/four/", 144 | http.StatusNotFound, nil, "", 145 | }, 146 | // empty segments 147 | { 148 | []string{"GET"}, "/baz/:id/:age", 149 | "GET", "/baz/123/", 150 | http.StatusNotFound, nil, "", 151 | }, 152 | { 153 | []string{"GET"}, "/baz/:id/:age/", 154 | "GET", "/baz/123//", 155 | http.StatusNotFound, nil, "", 156 | }, 157 | { 158 | []string{"GET"}, "/baz/:id/:age", 159 | "GET", "/baz//21", 160 | http.StatusNotFound, nil, "", 161 | }, 162 | { 163 | []string{"GET"}, "/baz//:age", 164 | "GET", "/baz//21", 165 | http.StatusOK, nil, "", 166 | }, 167 | { 168 | // with a regexp to specifically allow empty segments 169 | []string{"GET"}, "/baz/:id|^$/:age/", 170 | "GET", "/baz//21/", 171 | http.StatusOK, nil, "", 172 | }, 173 | // methods 174 | { 175 | []string{"POST"}, "/one", 176 | "POST", "/one", 177 | http.StatusOK, nil, "", 178 | }, 179 | { 180 | []string{"GET"}, "/one", 181 | "POST", "/one", 182 | http.StatusMethodNotAllowed, nil, "", 183 | }, 184 | // multiple methods 185 | { 186 | []string{"GET", "POST", "PUT"}, "/one", 187 | "POST", "/one", 188 | http.StatusOK, nil, "", 189 | }, 190 | { 191 | []string{"GET", "POST", "PUT"}, "/one", 192 | "PUT", "/one", 193 | http.StatusOK, nil, "", 194 | }, 195 | { 196 | []string{"GET", "POST", "PUT"}, "/one", 197 | "DELETE", "/one", 198 | http.StatusMethodNotAllowed, nil, "", 199 | }, 200 | // all methods 201 | { 202 | []string{}, "/one", 203 | "GET", "/one", 204 | http.StatusOK, nil, "", 205 | }, 206 | { 207 | []string{}, "/one", 208 | "DELETE", "/one", 209 | http.StatusOK, nil, "", 210 | }, 211 | // method casing 212 | { 213 | []string{"gEt"}, "/one", 214 | "GET", "/one", 215 | http.StatusOK, nil, "", 216 | }, 217 | // head requests 218 | { 219 | []string{"GET"}, "/one", 220 | "HEAD", "/one", 221 | http.StatusOK, nil, "", 222 | }, 223 | { 224 | []string{"HEAD"}, "/one", 225 | "HEAD", "/one", 226 | http.StatusOK, nil, "", 227 | }, 228 | { 229 | []string{"HEAD"}, "/one", 230 | "GET", "/one", 231 | http.StatusMethodNotAllowed, nil, "", 232 | }, 233 | // allow header 234 | { 235 | []string{"GET", "PUT"}, "/one", 236 | "DELETE", "/one", 237 | http.StatusMethodNotAllowed, nil, "GET, PUT, HEAD, OPTIONS", 238 | }, 239 | // options 240 | { 241 | []string{"GET", "PUT"}, "/one", 242 | "OPTIONS", "/one", 243 | http.StatusNoContent, nil, "GET, PUT, HEAD, OPTIONS", 244 | }, 245 | } 246 | 247 | for _, test := range tests { 248 | m := New() 249 | 250 | hf := func(w http.ResponseWriter, r *http.Request) { 251 | w.WriteHeader(http.StatusOK) 252 | } 253 | 254 | m.HandleFunc(test.RoutePattern, hf, test.RouteMethods...) 255 | 256 | r, err := http.NewRequest(test.RequestMethod, test.RequestPath, nil) 257 | if err != nil { 258 | t.Errorf("NewRequest: %s", err) 259 | } 260 | 261 | rr := httptest.NewRecorder() 262 | m.ServeHTTP(rr, r) 263 | 264 | rs := rr.Result() 265 | 266 | if rs.StatusCode != test.ExpectedStatus { 267 | t.Errorf("%s %s: expected status %d but was %d", test.RequestMethod, test.RequestPath, test.ExpectedStatus, rr.Code) 268 | continue 269 | } 270 | 271 | if rs.StatusCode == http.StatusOK && len(test.ExpectedParams) > 0 { 272 | for expK, expV := range test.ExpectedParams { 273 | actualValStr := r.PathValue(expK) 274 | if actualValStr != expV { 275 | t.Errorf("r.PathValue: value %s expected \"%s\" but was \"%s\"", expK, expV, actualValStr) 276 | } 277 | } 278 | } 279 | 280 | if test.ExpectedAllowHeader != "" { 281 | actualAllowHeader := rs.Header.Get("Allow") 282 | if actualAllowHeader != test.ExpectedAllowHeader { 283 | t.Errorf("%s %s: expected Allow header %q but was %q", test.RequestMethod, test.RequestPath, test.ExpectedAllowHeader, actualAllowHeader) 284 | } 285 | } 286 | 287 | } 288 | } 289 | 290 | func TestMiddleware(t *testing.T) { 291 | used := "" 292 | 293 | mw1 := func(next http.Handler) http.Handler { 294 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 295 | used += "1" 296 | next.ServeHTTP(w, r) 297 | }) 298 | } 299 | 300 | mw2 := func(next http.Handler) http.Handler { 301 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 302 | used += "2" 303 | next.ServeHTTP(w, r) 304 | }) 305 | } 306 | 307 | mw3 := func(next http.Handler) http.Handler { 308 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 309 | used += "3" 310 | next.ServeHTTP(w, r) 311 | }) 312 | } 313 | 314 | mw4 := func(next http.Handler) http.Handler { 315 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 316 | used += "4" 317 | next.ServeHTTP(w, r) 318 | }) 319 | } 320 | 321 | mw5 := func(next http.Handler) http.Handler { 322 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 323 | used += "5" 324 | next.ServeHTTP(w, r) 325 | }) 326 | } 327 | 328 | mw6 := func(next http.Handler) http.Handler { 329 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 330 | used += "6" 331 | next.ServeHTTP(w, r) 332 | }) 333 | } 334 | 335 | hf := func(w http.ResponseWriter, r *http.Request) {} 336 | 337 | m := New() 338 | m.Use(mw1) 339 | m.Use(mw2) 340 | 341 | m.HandleFunc("/", hf, "GET") 342 | 343 | m.Group(func(m *Mux) { 344 | m.Use(mw3, mw4) 345 | m.HandleFunc("/foo", hf, "GET") 346 | 347 | m.Group(func(m *Mux) { 348 | m.Use(mw5) 349 | m.HandleFunc("/nested/foo", hf, "GET") 350 | }) 351 | }) 352 | 353 | m.Group(func(m *Mux) { 354 | m.Use(mw6) 355 | m.HandleFunc("/bar", hf, "GET") 356 | }) 357 | 358 | m.HandleFunc("/baz", hf, "GET") 359 | 360 | var tests = []struct { 361 | RequestMethod string 362 | RequestPath string 363 | ExpectedUsed string 364 | ExpectedStatus int 365 | }{ 366 | { 367 | RequestMethod: "GET", 368 | RequestPath: "/", 369 | ExpectedUsed: "12", 370 | ExpectedStatus: http.StatusOK, 371 | }, 372 | { 373 | RequestMethod: "GET", 374 | RequestPath: "/foo", 375 | ExpectedUsed: "1234", 376 | ExpectedStatus: http.StatusOK, 377 | }, 378 | { 379 | RequestMethod: "GET", 380 | RequestPath: "/nested/foo", 381 | ExpectedUsed: "12345", 382 | ExpectedStatus: http.StatusOK, 383 | }, 384 | { 385 | RequestMethod: "GET", 386 | RequestPath: "/bar", 387 | ExpectedUsed: "126", 388 | ExpectedStatus: http.StatusOK, 389 | }, 390 | { 391 | RequestMethod: "GET", 392 | RequestPath: "/baz", 393 | ExpectedUsed: "12", 394 | ExpectedStatus: http.StatusOK, 395 | }, 396 | // Check top-level middleware used on errors and OPTIONS 397 | { 398 | RequestMethod: "GET", 399 | RequestPath: "/notfound", 400 | ExpectedUsed: "12", 401 | ExpectedStatus: http.StatusNotFound, 402 | }, 403 | { 404 | RequestMethod: "POST", 405 | RequestPath: "/nested/foo", 406 | ExpectedUsed: "12", 407 | ExpectedStatus: http.StatusMethodNotAllowed, 408 | }, 409 | { 410 | RequestMethod: "OPTIONS", 411 | RequestPath: "/nested/foo", 412 | ExpectedUsed: "12", 413 | ExpectedStatus: http.StatusNoContent, 414 | }, 415 | } 416 | 417 | for _, test := range tests { 418 | used = "" 419 | 420 | r, err := http.NewRequest(test.RequestMethod, test.RequestPath, nil) 421 | if err != nil { 422 | t.Errorf("NewRequest: %s", err) 423 | } 424 | 425 | rr := httptest.NewRecorder() 426 | m.ServeHTTP(rr, r) 427 | 428 | rs := rr.Result() 429 | 430 | if rs.StatusCode != test.ExpectedStatus { 431 | t.Errorf("%s %s: expected status %d but was %d", test.RequestMethod, test.RequestPath, test.ExpectedStatus, rs.StatusCode) 432 | } 433 | 434 | if used != test.ExpectedUsed { 435 | t.Errorf("%s %s: middleware used: expected %q; got %q", test.RequestMethod, test.RequestPath, test.ExpectedUsed, used) 436 | } 437 | } 438 | } 439 | 440 | func TestCustomHandlers(t *testing.T) { 441 | hf := func(w http.ResponseWriter, r *http.Request) {} 442 | 443 | m := New() 444 | m.NotFound = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 445 | w.Write([]byte("custom not found handler")) 446 | }) 447 | m.MethodNotAllowed = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 448 | w.Write([]byte("custom method not allowed handler")) 449 | }) 450 | m.Options = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 451 | w.Write([]byte("custom options handler")) 452 | }) 453 | 454 | m.HandleFunc("/", hf, "GET") 455 | 456 | var tests = []struct { 457 | RequestMethod string 458 | RequestPath string 459 | 460 | ExpectedBody string 461 | }{ 462 | { 463 | RequestMethod: "GET", 464 | RequestPath: "/notfound", 465 | ExpectedBody: "custom not found handler", 466 | }, 467 | { 468 | RequestMethod: "POST", 469 | RequestPath: "/", 470 | ExpectedBody: "custom method not allowed handler", 471 | }, 472 | { 473 | RequestMethod: "OPTIONS", 474 | RequestPath: "/", 475 | ExpectedBody: "custom options handler", 476 | }, 477 | } 478 | 479 | for _, test := range tests { 480 | r, err := http.NewRequest(test.RequestMethod, test.RequestPath, nil) 481 | if err != nil { 482 | t.Errorf("NewRequest: %s", err) 483 | } 484 | 485 | rr := httptest.NewRecorder() 486 | m.ServeHTTP(rr, r) 487 | 488 | rs := rr.Result() 489 | 490 | defer rs.Body.Close() 491 | body, err := io.ReadAll(rs.Body) 492 | if err != nil { 493 | t.Fatal(err) 494 | } 495 | 496 | if string(body) != test.ExpectedBody { 497 | t.Errorf("%s %s: expected body %q; got %q", test.RequestMethod, test.RequestPath, test.ExpectedBody, string(body)) 498 | } 499 | } 500 | } 501 | 502 | func TestPathValue(t *testing.T) { 503 | var tests = []struct { 504 | RouteMethods []string 505 | RoutePattern string 506 | 507 | RequestMethod string 508 | RequestPath string 509 | 510 | ParamName string 511 | HasParam bool 512 | ParamValue string 513 | }{ 514 | { 515 | []string{"GET"}, "/foo/:id", 516 | "GET", "/foo/123", 517 | "id", true, "123", 518 | }, 519 | { 520 | []string{"GET"}, "/foo/:id", 521 | "GET", "/foo/123", 522 | "missing", false, "", 523 | }, 524 | } 525 | 526 | for _, test := range tests { 527 | m := New() 528 | 529 | hf := func(w http.ResponseWriter, r *http.Request) { 530 | w.WriteHeader(http.StatusOK) 531 | } 532 | 533 | m.HandleFunc(test.RoutePattern, hf, test.RouteMethods...) 534 | 535 | r, err := http.NewRequest(test.RequestMethod, test.RequestPath, nil) 536 | if err != nil { 537 | t.Errorf("NewRequest: %s", err) 538 | } 539 | 540 | rr := httptest.NewRecorder() 541 | m.ServeHTTP(rr, r) 542 | 543 | actualValStr := r.PathValue(test.ParamName) 544 | if actualValStr != test.ParamValue { 545 | t.Errorf("expected \"%s\" but was \"%s\"", test.ParamValue, actualValStr) 546 | } 547 | } 548 | } 549 | 550 | func TestParams(t *testing.T) { 551 | var tests = []struct { 552 | RouteMethods []string 553 | RoutePattern string 554 | 555 | RequestMethod string 556 | RequestPath string 557 | 558 | ParamName string 559 | HasParam bool 560 | ParamValue string 561 | }{ 562 | { 563 | []string{"GET"}, "/foo/:id", 564 | "GET", "/foo/123", 565 | "id", true, "123", 566 | }, 567 | { 568 | []string{"GET"}, "/foo/:id", 569 | "GET", "/foo/123", 570 | "missing", false, "", 571 | }, 572 | } 573 | 574 | for _, test := range tests { 575 | m := New() 576 | 577 | var ctx context.Context 578 | 579 | hf := func(w http.ResponseWriter, r *http.Request) { 580 | ctx = r.Context() 581 | } 582 | 583 | m.HandleFunc(test.RoutePattern, hf, test.RouteMethods...) 584 | 585 | r, err := http.NewRequest(test.RequestMethod, test.RequestPath, nil) 586 | if err != nil { 587 | t.Errorf("NewRequest: %s", err) 588 | } 589 | 590 | rr := httptest.NewRecorder() 591 | m.ServeHTTP(rr, r) 592 | 593 | actualValStr := Param(ctx, test.ParamName) 594 | if actualValStr != test.ParamValue { 595 | t.Errorf("expected \"%s\" but was \"%s\"", test.ParamValue, actualValStr) 596 | } 597 | } 598 | } 599 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/alexedwards/flow 2 | 3 | go 1.22 4 | --------------------------------------------------------------------------------