├── .github └── workflows │ └── go.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── _examples ├── basic │ └── main.go ├── middleware │ └── logging-recovery │ │ └── logging_recovery.go └── params │ └── main.go ├── constants.go ├── go.mod ├── go.sum ├── group.go ├── group_test.go ├── helpers.go ├── helpers_test.go ├── logo.png ├── middleware ├── gzip.go └── gzip_test.go ├── node.go ├── node_test.go ├── pure.go ├── pure_test.go ├── request_vars.go └── util.go /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Lint & Test 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | strategy: 6 | matrix: 7 | go-version: [1.17.x,1.20.x] 8 | platform: [ubuntu-latest, macos-latest, windows-latest] 9 | runs-on: ${{ matrix.platform }} 10 | steps: 11 | - name: Install Go 12 | uses: actions/setup-go@v3 13 | with: 14 | go-version: ${{ matrix.go-version }} 15 | 16 | - name: Priming Cache 17 | uses: actions/cache@v3 18 | with: 19 | path: ~/go/pkg/mod 20 | key: ${{ runner.os }}-v1-go-${{ hashFiles('**/go.sum') }} 21 | restore-keys: | 22 | ${{ runner.os }}-v1-go- 23 | 24 | - name: Checkout code 25 | uses: actions/checkout@v3 26 | 27 | - name: Test 28 | run: go test ./... 29 | 30 | golangci: 31 | name: lint 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v3 35 | - name: golangci-lint 36 | uses: golangci/golangci-lint-action@v3 37 | with: 38 | version: latest -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | *.out 26 | *.html 27 | *.txt -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Dean Karn 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 13 | all 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 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GOCMD=GO111MODULE=on go 2 | 3 | lint: 4 | golangci-lint run 5 | 6 | test: 7 | $(GOCMD) test -cover -race ./... 8 | 9 | bench: 10 | $(GOCMD) test -bench=. -benchmem ./... 11 | 12 | .PHONY: lint test bench -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | package pure 2 | ============ 3 | ![Project status](https://img.shields.io/badge/version-5.3.0-green.svg) 4 | [![Build Status](https://travis-ci.org/go-playground/pure.svg?branch=master)](https://travis-ci.org/go-playground/pure) 5 | [![Coverage Status](https://coveralls.io/repos/github/go-playground/pure/badge.svg?branch=master)](https://coveralls.io/github/go-playground/pure?branch=master) 6 | [![Go Report Card](https://goreportcard.com/badge/github.com/go-playground/pure)](https://goreportcard.com/report/github.com/go-playground/pure) 7 | [![GoDoc](https://godoc.org/github.com/go-playground/pure?status.svg)](https://pkg.go.dev/github.com/go-playground/pure) 8 | ![License](https://img.shields.io/dub/l/vibe-d.svg) 9 | [![Gitter](https://badges.gitter.im/go-playground/pure.svg)](https://gitter.im/go-playground/pure?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) 10 | 11 | Pure is a fast radix-tree based HTTP router that sticks to the native implementations of Go's "net/http" package; 12 | in essence, keeping the handler implementations 'pure' by using Go 1.7's "context" package. 13 | 14 | This makes heavy usage of `github.com/go-playground/pkg/v5` for HTTP abstractions. 15 | 16 | Why Another HTTP Router? 17 | ------------------------ 18 | I initially created [lars](https://github.com/go-playground/lars), which I still maintain, that wraps the native implementation, think of this package as a Go pure implementation of [lars](https://github.com/go-playground/lars) 19 | 20 | Key & Unique Features 21 | -------------- 22 | - [x] It sticks to Go's native implementations while providing helper functions for convenience 23 | - [x] **Fast & Efficient** - pure uses a custom version of [httprouter](https://github.com/julienschmidt/httprouter)'s radix tree, so incredibly fast and efficient. 24 | 25 | Installation 26 | ----------- 27 | 28 | Use go get 29 | 30 | ```shell 31 | go get -u github.com/go-playground/pure/v5 32 | ``` 33 | 34 | Usage 35 | ------ 36 | ```go 37 | package main 38 | 39 | import ( 40 | "net/http" 41 | 42 | "github.com/go-playground/pure/v5" 43 | mw "github.com/go-playground/pure/v5/_examples/middleware/logging-recovery" 44 | ) 45 | 46 | func main() { 47 | 48 | p := pure.New() 49 | p.Use(mw.LoggingAndRecovery(true)) 50 | 51 | p.Get("/", helloWorld) 52 | 53 | http.ListenAndServe(":3007", p.Serve()) 54 | } 55 | 56 | func helloWorld(w http.ResponseWriter, r *http.Request) { 57 | w.Write([]byte("Hello World")) 58 | } 59 | ``` 60 | 61 | RequestVars 62 | ----------- 63 | This is an interface that is used to pass request scoped variables and functions using `context.Context`. 64 | It is implemented in this way because retrieving values from `context` isn't the fastest, and so using this 65 | the router can store multiple pieces of information while reducing lookup time to a single stored `RequestVars`. 66 | 67 | Currently only the URL/SEO params are stored on the `RequestVars` but if/when more is added they can merely be added 68 | to the `RequestVars` and there will be no additional lookup time. 69 | 70 | URL Params 71 | ---------- 72 | 73 | ```go 74 | p := p.New() 75 | 76 | // the matching param will be stored in the context's params with name "id" 77 | p.Get("/user/:id", UserHandler) 78 | 79 | // extract params like so 80 | rv := pure.RequestVars(r) // done this way so only have to extract from context once, read above 81 | rv.URLParam(paramname) 82 | 83 | // serve css, js etc.. pure.RequestVars(r).URLParam(pure.WildcardParam) will return the remaining path if 84 | // you need to use it in a custom handler... 85 | p.Get("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))).ServeHTTP) 86 | 87 | ... 88 | ``` 89 | 90 | **Note:** Since this router has only explicit matches, you can not register static routes and parameters for the same path segment. For example you can not register the patterns /user/new and /user/:user for the same request method at the same time. The routing of different request methods is independent from each other. I was initially against this, however it nearly cost me in a large web application where the dynamic param value say :type actually could have matched another static route and that's just too dangerous and so it is not allowed. 91 | 92 | Groups 93 | ----- 94 | ```go 95 | 96 | p.Use(LoggingAndRecovery, Gzip...) 97 | ... 98 | p.Post("/users/add", ...) 99 | 100 | // creates a group for /user/:userid + inherits all middleware registered previously by p 101 | user := p.Group("/user/:userid") 102 | user.Get("", ...) 103 | user.Post("", ...) 104 | user.Delete("/delete", ...) 105 | 106 | contactInfo := user.Group("/contact-info/:cid") 107 | contactinfo.Delete("/delete", ...) 108 | 109 | // creates a group for /others, inherits all middleware registered previously by p + adds 110 | // OtherHandler to middleware 111 | others := p.GroupWithMore("/others", OtherHandler) 112 | 113 | // creates a group for /admin WITH NO MIDDLEWARE... more can be added using admin.Use() 114 | admin := p.GroupWithNone("/admin") 115 | admin.Use(SomeAdminSecurityMiddleware) 116 | ... 117 | ``` 118 | 119 | Decoding Body 120 | ------------- 121 | currently JSON, XML, FORM, Multipart Form and url.Values are support out of the box; there are also 122 | individual functions for each as well when you know the Content-Type. 123 | ```go 124 | // second argument denotes yes or no I would like URL query parameter fields 125 | // to be included. i.e. 'id' and 'id2' in route '/user/:id?id2=val' should it be included. 126 | if err := pure.Decode(r, true, maxBytes, &user); err != nil { 127 | log.Println(err) 128 | } 129 | ``` 130 | 131 | Misc 132 | ----- 133 | ```go 134 | 135 | // set custom 404 ( not Found ) handler 136 | p.Register404(404Handler, middleware_like_logging) 137 | 138 | // Redirect to or from ending slash if route not found, default is true 139 | p.SetRedirectTrailingSlash(true) 140 | 141 | // Handle 405 ( Method Not allowed ), default is false 142 | p.RegisterMethodNotAllowed(middleware) 143 | 144 | // automatically handle OPTION requests; manually configured 145 | // OPTION handlers take precedence. default false 146 | p.RegisterAutomaticOPTIONS(middleware) 147 | 148 | ``` 149 | 150 | Middleware 151 | ----------- 152 | There are some pre-defined middlewares within the middleware folder; NOTE: that the middleware inside will 153 | comply with the following rule(s): 154 | 155 | * Are completely reusable by the community without modification 156 | 157 | Other middleware will be listed under the _examples/middleware/... folder for a quick copy/paste modify. As an example a LoddingAndRecovery middleware is very application dependent and therefore will be listed under the _examples/middleware/... 158 | 159 | Benchmarks 160 | ----------- 161 | Run on i5-7600 16 GB DDR4-2400 using Go version go1.12.5 darwin/amd64 162 | 163 | NOTICE: pure uses a custom version of [httprouter](https://github.com/julienschmidt/httprouter)'s radix tree, benchmarks can be found [here](https://github.com/deankarn/go-http-routing-benchmark/tree/pure-and-lars) the slowdown is with the use of the `context` package, as you can see when no SEO params are defined, and therefore no need to store anything in the context, it is faster than even lars. 164 | 165 | ```go 166 | go test -bench=. -benchmem=true ./... 167 | #GithubAPI Routes: 203 168 | Pure: 37096 Bytes 169 | 170 | #GPlusAPI Routes: 13 171 | Pure: 2792 Bytes 172 | 173 | #ParseAPI Routes: 26 174 | Pure: 5040 Bytes 175 | 176 | #Static Routes: 157 177 | HttpServeMux: 14992 Bytes 178 | Pure: 21096 Bytes 179 | 180 | 181 | goos: darwin 182 | goarch: arm64 183 | BenchmarkPure_Param 11965519 100.4 ns/op 256 B/op 1 allocs/op 184 | BenchmarkPure_Param5 8756385 138.6 ns/op 256 B/op 1 allocs/op 185 | BenchmarkPure_Param20 4335284 276.5 ns/op 256 B/op 1 allocs/op 186 | BenchmarkPure_ParamWrite 9980685 120.0 ns/op 256 B/op 1 allocs/op 187 | BenchmarkPure_GithubStatic 47743062 24.77 ns/op 0 B/op 0 allocs/op 188 | BenchmarkPure_GithubParam 8514968 139.8 ns/op 256 B/op 1 allocs/op 189 | BenchmarkPure_GithubAll 42250 28333 ns/op 42753 B/op 167 allocs/op 190 | BenchmarkPure_GPlusStatic 87363000 13.39 ns/op 0 B/op 0 allocs/op 191 | BenchmarkPure_GPlusParam 10398274 113.0 ns/op 256 B/op 1 allocs/op 192 | BenchmarkPure_GPlus2Params 9235220 128.7 ns/op 256 B/op 1 allocs/op 193 | BenchmarkPure_GPlusAll 792037 1526 ns/op 2816 B/op 11 allocs/op 194 | BenchmarkPure_ParseStatic 79194198 14.96 ns/op 0 B/op 0 allocs/op 195 | BenchmarkPure_ParseParam 11391336 104.5 ns/op 256 B/op 1 allocs/op 196 | BenchmarkPure_Parse2Params 10103078 116.2 ns/op 256 B/op 1 allocs/op 197 | BenchmarkPure_ParseAll 498306 2417 ns/op 4096 B/op 16 allocs/op 198 | BenchmarkPure_StaticAll 219930 5225 ns/op 0 B/op 0 allocs/op 199 | ``` 200 | 201 | Licenses 202 | -------- 203 | - [MIT License](https://raw.githubusercontent.com/go-playground/pure/master/LICENSE) (MIT), Copyright (c) 2016 Dean Karn 204 | - [BSD License](https://raw.githubusercontent.com/julienschmidt/httprouter/master/LICENSE), Copyright (c) 2013 Julien Schmidt. All rights reserved. 205 | -------------------------------------------------------------------------------- /_examples/basic/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/go-playground/pure/v5" 7 | mw "github.com/go-playground/pure/v5/_examples/middleware/logging-recovery" 8 | ) 9 | 10 | func main() { 11 | 12 | p := pure.New() 13 | p.Use(mw.LoggingAndRecovery(false)) 14 | 15 | p.Get("/", helloWorld) 16 | 17 | http.ListenAndServe(":3007", p.Serve()) 18 | } 19 | 20 | func helloWorld(w http.ResponseWriter, r *http.Request) { 21 | w.Write([]byte("Hello World")) 22 | } 23 | -------------------------------------------------------------------------------- /_examples/middleware/logging-recovery/logging_recovery.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "bufio" 5 | "log" 6 | "net" 7 | "net/http" 8 | "runtime" 9 | "sync" 10 | "time" 11 | 12 | "github.com/go-playground/ansi/v3" 13 | "github.com/go-playground/pure/v5" 14 | ) 15 | 16 | const ( 17 | status500 = ansi.Underline + ansi.Blink + ansi.Red 18 | status400 = ansi.Red 19 | status300 = ansi.Yellow 20 | status = ansi.Green 21 | ) 22 | 23 | type logWriter struct { 24 | http.ResponseWriter 25 | status int 26 | size int64 27 | committed bool 28 | } 29 | 30 | // WriteHeader writes HTTP status code. 31 | // If WriteHeader is not called explicitly, the first call to Write 32 | // will trigger an implicit WriteHeader(http.StatusOK). 33 | // Thus explicit calls to WriteHeader are mainly used to 34 | // send error codes. 35 | func (lw *logWriter) WriteHeader(status int) { 36 | 37 | if lw.committed { 38 | log.Println("response already committed") 39 | return 40 | } 41 | 42 | lw.status = status 43 | lw.ResponseWriter.WriteHeader(status) 44 | lw.committed = true 45 | } 46 | 47 | // Write writes the data to the connection as part of an HTTP reply. 48 | // If WriteHeader has not yet been called, Write calls WriteHeader(http.StatusOK) 49 | // before writing the data. If the Header does not contain a 50 | // Content-Type line, Write adds a Content-Type set to the result of passing 51 | // the initial 512 bytes of written data to DetectContentType. 52 | func (lw *logWriter) Write(b []byte) (int, error) { 53 | 54 | lw.size += int64(len(b)) 55 | 56 | return lw.ResponseWriter.Write(b) 57 | } 58 | 59 | // Status returns the current response's http status code. 60 | func (lw *logWriter) Status() int { 61 | return lw.status 62 | } 63 | 64 | // Size returns the number of bytes written in the response thus far 65 | func (lw *logWriter) Size() int64 { 66 | return lw.size 67 | } 68 | 69 | // Hijack hijacks the current http connection 70 | func (lw *logWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { 71 | return lw.ResponseWriter.(http.Hijacker).Hijack() 72 | } 73 | 74 | // CloseNotify ... 75 | func (lw *logWriter) CloseNotify() <-chan bool { 76 | return lw.ResponseWriter.(http.CloseNotifier).CloseNotify() 77 | } 78 | 79 | var lrpool = sync.Pool{ 80 | New: func() interface{} { 81 | return new(logWriter) 82 | }, 83 | } 84 | 85 | // LoggingAndRecovery handle HTTP request logging + recovery 86 | func LoggingAndRecovery(color bool) pure.Middleware { 87 | 88 | return func(next http.HandlerFunc) http.HandlerFunc { 89 | 90 | if color { 91 | 92 | return func(w http.ResponseWriter, r *http.Request) { 93 | 94 | t1 := time.Now() 95 | 96 | lw := lrpool.Get().(*logWriter) 97 | lw.status = 200 98 | lw.size = 0 99 | lw.committed = false 100 | lw.ResponseWriter = w 101 | 102 | defer func() { 103 | if err := recover(); err != nil { 104 | trace := make([]byte, 1<<16) 105 | n := runtime.Stack(trace, true) 106 | log.Printf(" %srecovering from panic: %+v\nStack Trace:\n %s%s", ansi.Red, err, trace[:n], ansi.Reset) 107 | HandlePanic(lw, r, trace[:n]) 108 | 109 | lrpool.Put(lw) 110 | return 111 | } 112 | 113 | lrpool.Put(lw) 114 | }() 115 | 116 | next(lw, r) 117 | 118 | // var color string 119 | color := status 120 | 121 | code := lw.Status() 122 | 123 | switch { 124 | case code >= http.StatusInternalServerError: 125 | color = status500 126 | case code >= http.StatusBadRequest: 127 | color = status400 128 | case code >= http.StatusMultipleChoices: 129 | color = status300 130 | } 131 | 132 | log.Printf("%s %d %s[%s%s%s] %q %v %d\n", color, code, ansi.Reset, color, r.Method, ansi.Reset, r.URL, time.Since(t1), lw.Size()) 133 | } 134 | } 135 | 136 | return func(w http.ResponseWriter, r *http.Request) { 137 | 138 | t1 := time.Now() 139 | 140 | lw := lrpool.Get().(*logWriter) 141 | lw.status = 200 142 | lw.size = 0 143 | lw.committed = false 144 | lw.ResponseWriter = w 145 | 146 | defer func() { 147 | if err := recover(); err != nil { 148 | trace := make([]byte, 1<<16) 149 | n := runtime.Stack(trace, true) 150 | log.Printf(" %srecovering from panic: %+v\nStack Trace:\n %s%s", ansi.Red, err, trace[:n], ansi.Reset) 151 | HandlePanic(lw, r, trace[:n]) 152 | } 153 | 154 | lrpool.Put(lw) 155 | }() 156 | 157 | next(lw, r) 158 | 159 | log.Printf("%d [%s] %q %v %d\n", lw.Status(), r.Method, r.URL, time.Since(t1), lw.Size()) 160 | } 161 | 162 | } 163 | } 164 | 165 | // HandlePanic handles graceful panic by redirecting to friendly error page or rendering a friendly error page. 166 | // trace passed just in case you want rendered to developer when not running in production 167 | func HandlePanic(w http.ResponseWriter, r *http.Request, trace []byte) { 168 | 169 | // redirect to or directly render friendly error page 170 | } 171 | -------------------------------------------------------------------------------- /_examples/params/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/go-playground/pure/v5" 7 | mw "github.com/go-playground/pure/v5/_examples/middleware/logging-recovery" 8 | ) 9 | 10 | func main() { 11 | 12 | p := pure.New() 13 | p.Use(mw.LoggingAndRecovery(true)) 14 | 15 | p.Get("/user/:id", user) 16 | 17 | http.ListenAndServe(":3007", p.Serve()) 18 | } 19 | 20 | func user(w http.ResponseWriter, r *http.Request) { 21 | rv := pure.RequestVars(r) 22 | 23 | w.Write([]byte("USER_ID:" + rv.URLParam("id"))) 24 | } 25 | -------------------------------------------------------------------------------- /constants.go: -------------------------------------------------------------------------------- 1 | package pure 2 | 3 | // HTTP Constant Terms and Variables 4 | const ( 5 | // use constants from github.com/go-playground/net/http 6 | 7 | WildcardParam = "*wildcard" 8 | basePath = "/" 9 | blank = "" 10 | slashByte = '/' 11 | paramByte = ':' 12 | wildByte = '*' 13 | ) 14 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/go-playground/pure/v5 2 | 3 | require ( 4 | github.com/go-playground/assert/v2 v2.2.0 5 | github.com/go-playground/pkg/v5 v5.21.1 6 | ) 7 | 8 | require github.com/go-playground/form/v4 v4.2.0 // indirect 9 | 10 | go 1.18 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 2 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 3 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 4 | github.com/go-playground/form/v4 v4.2.0 h1:N1wh+Goz61e6w66vo8vJkQt+uwZSoLz50kZPJWR8eic= 5 | github.com/go-playground/form/v4 v4.2.0/go.mod h1:q1a2BY+AQUUzhl6xA/6hBetay6dEIhMHjgvJiGo6K7U= 6 | github.com/go-playground/pkg/v5 v5.21.1 h1:X5yWP+S+wMFHBX1mUMCgsum3yw0FKaabd9vs27xjilg= 7 | github.com/go-playground/pkg/v5 v5.21.1/go.mod h1:eT8XZeFHnqZkfkpkbI8ayjfCw9GohV2/j8STbVmoR6s= 8 | -------------------------------------------------------------------------------- /group.go: -------------------------------------------------------------------------------- 1 | package pure 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | // IRouteGroup interface for router group 10 | type IRouteGroup interface { 11 | IRoutes 12 | GroupWithNone(prefix string) IRouteGroup 13 | GroupWithMore(prefix string, middleware ...Middleware) IRouteGroup 14 | Group(prefix string) IRouteGroup 15 | } 16 | 17 | // IRoutes interface for routes 18 | type IRoutes interface { 19 | Use(...Middleware) 20 | Any(string, http.HandlerFunc) 21 | Get(string, http.HandlerFunc) 22 | Post(string, http.HandlerFunc) 23 | Delete(string, http.HandlerFunc) 24 | Patch(string, http.HandlerFunc) 25 | Put(string, http.HandlerFunc) 26 | Options(string, http.HandlerFunc) 27 | Head(string, http.HandlerFunc) 28 | Connect(string, http.HandlerFunc) 29 | Trace(string, http.HandlerFunc) 30 | } 31 | 32 | // routeGroup struct containing all fields and methods for use. 33 | type routeGroup struct { 34 | prefix string 35 | middleware []Middleware 36 | pure *Mux 37 | } 38 | 39 | var _ IRouteGroup = &routeGroup{} 40 | 41 | func (g *routeGroup) handle(method string, path string, handler http.HandlerFunc) { 42 | 43 | if i := strings.Index(path, "//"); i != -1 { 44 | panic("Bad path '" + path + "' contains duplicate // at index:" + strconv.Itoa(i)) 45 | } 46 | 47 | h := handler 48 | 49 | for i := len(g.middleware) - 1; i >= 0; i-- { 50 | h = g.middleware[i](h) 51 | } 52 | 53 | tree := g.pure.trees[method] 54 | 55 | if tree == nil { 56 | tree = new(node) 57 | g.pure.trees[method] = tree 58 | } 59 | 60 | pCount := tree.add(g.prefix+path, h) 61 | pCount++ 62 | 63 | if pCount > g.pure.mostParams { 64 | g.pure.mostParams = pCount 65 | } 66 | } 67 | 68 | // Use adds a middleware handler to the group middleware chain. 69 | func (g *routeGroup) Use(m ...Middleware) { 70 | g.middleware = append(g.middleware, m...) 71 | } 72 | 73 | // Connect adds a CONNECT route & handler to the router. 74 | func (g *routeGroup) Connect(path string, h http.HandlerFunc) { 75 | g.handle(http.MethodConnect, path, h) 76 | } 77 | 78 | // Delete adds a DELETE route & handler to the router. 79 | func (g *routeGroup) Delete(path string, h http.HandlerFunc) { 80 | g.handle(http.MethodDelete, path, h) 81 | } 82 | 83 | // Get adds a GET route & handler to the router. 84 | func (g *routeGroup) Get(path string, h http.HandlerFunc) { 85 | g.handle(http.MethodGet, path, h) 86 | } 87 | 88 | // Head adds a HEAD route & handler to the router. 89 | func (g *routeGroup) Head(path string, h http.HandlerFunc) { 90 | g.handle(http.MethodHead, path, h) 91 | } 92 | 93 | // Options adds an OPTIONS route & handler to the router. 94 | func (g *routeGroup) Options(path string, h http.HandlerFunc) { 95 | g.handle(http.MethodOptions, path, h) 96 | } 97 | 98 | // Patch adds a PATCH route & handler to the router. 99 | func (g *routeGroup) Patch(path string, h http.HandlerFunc) { 100 | g.handle(http.MethodPatch, path, h) 101 | } 102 | 103 | // Post adds a POST route & handler to the router. 104 | func (g *routeGroup) Post(path string, h http.HandlerFunc) { 105 | g.handle(http.MethodPost, path, h) 106 | } 107 | 108 | // Put adds a PUT route & handler to the router. 109 | func (g *routeGroup) Put(path string, h http.HandlerFunc) { 110 | g.handle(http.MethodPut, path, h) 111 | } 112 | 113 | // Trace adds a TRACE route & handler to the router. 114 | func (g *routeGroup) Trace(path string, h http.HandlerFunc) { 115 | g.handle(http.MethodTrace, path, h) 116 | } 117 | 118 | // Handle allows for any method to be registered with the given 119 | // route & handler. Allows for non standard methods to be used 120 | // like CalDavs PROPFIND and so forth. 121 | func (g *routeGroup) Handle(method string, path string, h http.HandlerFunc) { 122 | g.handle(method, path, h) 123 | } 124 | 125 | // Any adds a route & handler to the router for all HTTP methods. 126 | func (g *routeGroup) Any(path string, h http.HandlerFunc) { 127 | g.Connect(path, h) 128 | g.Delete(path, h) 129 | g.Get(path, h) 130 | g.Head(path, h) 131 | g.Options(path, h) 132 | g.Patch(path, h) 133 | g.Post(path, h) 134 | g.Put(path, h) 135 | g.Trace(path, h) 136 | } 137 | 138 | // Match adds a route & handler to the router for multiple HTTP methods provided. 139 | func (g *routeGroup) Match(methods []string, path string, h http.HandlerFunc) { 140 | for _, m := range methods { 141 | g.handle(m, path, h) 142 | } 143 | } 144 | 145 | // GroupWithNone creates a new sub router with specified prefix and no middleware attached. 146 | func (g *routeGroup) GroupWithNone(prefix string) IRouteGroup { 147 | return &routeGroup{ 148 | prefix: g.prefix + prefix, 149 | pure: g.pure, 150 | middleware: make([]Middleware, 0), 151 | } 152 | } 153 | 154 | // GroupWithMore creates a new sub router with specified prefix, retains existing middleware and adds new middleware. 155 | func (g *routeGroup) GroupWithMore(prefix string, middleware ...Middleware) IRouteGroup { 156 | rg := &routeGroup{ 157 | prefix: g.prefix + prefix, 158 | pure: g.pure, 159 | middleware: make([]Middleware, len(g.middleware)), 160 | } 161 | copy(rg.middleware, g.middleware) 162 | rg.Use(middleware...) 163 | return rg 164 | } 165 | 166 | // Group creates a new sub router with specified prefix and retains existing middleware. 167 | func (g *routeGroup) Group(prefix string) IRouteGroup { 168 | rg := &routeGroup{ 169 | prefix: g.prefix + prefix, 170 | pure: g.pure, 171 | middleware: make([]Middleware, len(g.middleware)), 172 | } 173 | copy(rg.middleware, g.middleware) 174 | return rg 175 | } 176 | -------------------------------------------------------------------------------- /group_test.go: -------------------------------------------------------------------------------- 1 | package pure 2 | 3 | import ( 4 | "io/ioutil" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | . "github.com/go-playground/assert/v2" 10 | ) 11 | 12 | // NOTES: 13 | // - Run "go test" to run tests 14 | // - Run "gocov test | gocov report" to report on test converage by file 15 | // - Run "gocov test | gocov annotate -" to report on all code and functions, those ,marked with "MISS" were never called 16 | // 17 | // or 18 | // 19 | // -- may be a good idea to change to output path to somewherelike /tmp 20 | // go test -coverprofile cover.out && go tool cover -html=cover.out -o cover.html 21 | // 22 | 23 | func TestUseAndGroup(t *testing.T) { 24 | 25 | fn := func(w http.ResponseWriter, r *http.Request) { 26 | if _, err := w.Write([]byte(r.Method)); err != nil { 27 | panic(err) 28 | } 29 | } 30 | 31 | var log string 32 | 33 | logger := func(next http.HandlerFunc) http.HandlerFunc { 34 | return func(w http.ResponseWriter, r *http.Request) { 35 | log = r.URL.Path 36 | next(w, r) 37 | } 38 | } 39 | 40 | p := New() 41 | p.Use(logger) 42 | p.Get("/", fn) 43 | 44 | code, body := request(http.MethodGet, "/", p) 45 | Equal(t, code, http.StatusOK) 46 | Equal(t, body, http.MethodGet) 47 | Equal(t, log, "/") 48 | 49 | g := p.Group("/users") 50 | g.Get("/", fn) 51 | g.Get("/list/", fn) 52 | 53 | code, body = request(http.MethodGet, "/users/", p) 54 | Equal(t, code, http.StatusOK) 55 | Equal(t, body, http.MethodGet) 56 | Equal(t, log, "/users/") 57 | 58 | code, body = request(http.MethodGet, "/users/list/", p) 59 | Equal(t, code, http.StatusOK) 60 | Equal(t, body, http.MethodGet) 61 | Equal(t, log, "/users/list/") 62 | 63 | logger2 := func(next http.HandlerFunc) http.HandlerFunc { 64 | return func(w http.ResponseWriter, r *http.Request) { 65 | log = r.URL.Path + "2" 66 | next(w, r) 67 | } 68 | } 69 | 70 | sh := p.GroupWithMore("/superheros", logger2) 71 | sh.Get("/", fn) 72 | sh.Get("/list/", fn) 73 | 74 | code, body = request(http.MethodGet, "/superheros/", p) 75 | Equal(t, code, http.StatusOK) 76 | Equal(t, body, http.MethodGet) 77 | Equal(t, log, "/superheros/2") 78 | 79 | code, body = request(http.MethodGet, "/superheros/list/", p) 80 | Equal(t, code, http.StatusOK) 81 | Equal(t, body, http.MethodGet) 82 | Equal(t, log, "/superheros/list/2") 83 | 84 | sc := sh.Group("/children") 85 | sc.Get("/", fn) 86 | sc.Get("/list/", fn) 87 | 88 | code, body = request(http.MethodGet, "/superheros/children/", p) 89 | Equal(t, code, http.StatusOK) 90 | Equal(t, body, http.MethodGet) 91 | Equal(t, log, "/superheros/children/2") 92 | 93 | code, body = request(http.MethodGet, "/superheros/children/list/", p) 94 | Equal(t, code, http.StatusOK) 95 | Equal(t, body, http.MethodGet) 96 | Equal(t, log, "/superheros/children/list/2") 97 | 98 | log = "" 99 | 100 | g2 := p.GroupWithNone("/admins") 101 | g2.Get("/", fn) 102 | g2.Get("/list/", fn) 103 | 104 | code, body = request(http.MethodGet, "/admins/", p) 105 | Equal(t, code, http.StatusOK) 106 | Equal(t, body, http.MethodGet) 107 | Equal(t, log, "") 108 | 109 | code, body = request(http.MethodGet, "/admins/list/", p) 110 | Equal(t, code, http.StatusOK) 111 | Equal(t, body, http.MethodGet) 112 | Equal(t, log, "") 113 | } 114 | 115 | func TestMatch(t *testing.T) { 116 | 117 | p := New() 118 | p.Match([]string{http.MethodConnect, http.MethodDelete, http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodPatch, http.MethodPost, http.MethodPut, http.MethodTrace}, "/test", defaultHandler) 119 | 120 | hf := p.Serve() 121 | 122 | tests := []struct { 123 | method string 124 | }{ 125 | { 126 | method: http.MethodConnect, 127 | }, 128 | { 129 | method: http.MethodDelete, 130 | }, 131 | { 132 | method: http.MethodGet, 133 | }, 134 | { 135 | method: http.MethodHead, 136 | }, 137 | { 138 | method: http.MethodOptions, 139 | }, 140 | { 141 | method: http.MethodPatch, 142 | }, 143 | { 144 | method: http.MethodPost, 145 | }, 146 | { 147 | method: http.MethodPut, 148 | }, 149 | { 150 | method: http.MethodTrace, 151 | }, 152 | } 153 | 154 | for _, tt := range tests { 155 | req, err := http.NewRequest(tt.method, "/test", nil) 156 | if err != nil { 157 | t.Errorf("Expected 'nil' Got '%s'", err) 158 | } 159 | 160 | res := httptest.NewRecorder() 161 | 162 | hf.ServeHTTP(res, req) 163 | 164 | if res.Code != http.StatusOK { 165 | t.Errorf("Expected '%d' Got '%d'", http.StatusOK, res.Code) 166 | } 167 | 168 | b, err := ioutil.ReadAll(res.Body) 169 | if err != nil { 170 | t.Errorf("Expected 'nil' Got '%s'", err) 171 | } 172 | 173 | s := string(b) 174 | 175 | if s != tt.method { 176 | t.Errorf("Expected '%s' Got '%s'", tt.method, s) 177 | } 178 | } 179 | } 180 | 181 | func TestGrouplogic(t *testing.T) { 182 | 183 | var aa, bb, cc, tl int 184 | 185 | aM := func(next http.HandlerFunc) http.HandlerFunc { 186 | return func(w http.ResponseWriter, r *http.Request) { 187 | aa++ 188 | next(w, r) 189 | } 190 | } 191 | 192 | bM := func(next http.HandlerFunc) http.HandlerFunc { 193 | return func(w http.ResponseWriter, r *http.Request) { 194 | bb++ 195 | next(w, r) 196 | } 197 | } 198 | 199 | cM := func(next http.HandlerFunc) http.HandlerFunc { 200 | return func(w http.ResponseWriter, r *http.Request) { 201 | cc++ 202 | next(w, r) 203 | } 204 | } 205 | 206 | p := New() 207 | p.Use(func(next http.HandlerFunc) http.HandlerFunc { 208 | return func(w http.ResponseWriter, r *http.Request) { 209 | tl++ 210 | next(w, r) 211 | } 212 | }) 213 | 214 | a := p.GroupWithMore("/a", aM) 215 | a.Get("/test", func(w http.ResponseWriter, r *http.Request) { 216 | _, _ = w.Write([]byte("a-ok")) 217 | }) 218 | 219 | b := a.GroupWithMore("/b", bM) 220 | b.Get("/test", func(w http.ResponseWriter, r *http.Request) { 221 | _, _ = w.Write([]byte("b-ok")) 222 | }) 223 | 224 | c := b.GroupWithMore("/c", cM) 225 | c.Get("/test", func(w http.ResponseWriter, r *http.Request) { 226 | _, _ = w.Write([]byte("c-ok")) 227 | }) 228 | 229 | code, body := request(http.MethodGet, "/a/test", p) 230 | Equal(t, code, http.StatusOK) 231 | Equal(t, body, "a-ok") 232 | Equal(t, tl, 1) 233 | Equal(t, aa, 1) 234 | 235 | code, body = request(http.MethodGet, "/a/b/test", p) 236 | Equal(t, code, http.StatusOK) 237 | Equal(t, body, "b-ok") 238 | Equal(t, tl, 2) 239 | Equal(t, aa, 2) 240 | Equal(t, bb, 1) 241 | 242 | code, body = request(http.MethodGet, "/a/b/c/test", p) 243 | Equal(t, code, http.StatusOK) 244 | Equal(t, body, "c-ok") 245 | Equal(t, tl, 3) 246 | Equal(t, aa, 3) 247 | Equal(t, bb, 2) 248 | Equal(t, cc, 1) 249 | } 250 | -------------------------------------------------------------------------------- /helpers.go: -------------------------------------------------------------------------------- 1 | package pure 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "net/url" 7 | "strings" 8 | 9 | httpext "github.com/go-playground/pkg/v5/net/http" 10 | urlext "github.com/go-playground/pkg/v5/net/url" 11 | ) 12 | 13 | // RequestVars returns the request scoped variables tracked by pure 14 | func RequestVars(r *http.Request) ReqVars { 15 | 16 | rv := r.Context().Value(defaultContextIdentifier) 17 | if rv == nil { 18 | return new(requestVars) 19 | } 20 | 21 | return rv.(*requestVars) 22 | } 23 | 24 | // AcceptedLanguages returns an array of accepted languages denoted by 25 | // the Accept-Language header sent by the browser 26 | func AcceptedLanguages(r *http.Request) (languages []string) { 27 | return httpext.AcceptedLanguages(r) 28 | } 29 | 30 | // Attachment is a helper method for returning an attachement file 31 | // to be downloaded, if you with to open inline see function Inline 32 | func Attachment(w http.ResponseWriter, r io.Reader, filename string) (err error) { 33 | return httpext.Attachment(w, r, filename) 34 | } 35 | 36 | // Inline is a helper method for returning a file inline to 37 | // be rendered/opened by the browser 38 | func Inline(w http.ResponseWriter, r io.Reader, filename string) (err error) { 39 | return httpext.Inline(w, r, filename) 40 | } 41 | 42 | // ClientIP implements a best effort algorithm to return the real client IP, it parses 43 | // X-Real-IP and X-Forwarded-For in order to work properly with reverse-proxies such us: nginx or haproxy. 44 | func ClientIP(r *http.Request) (clientIP string) { 45 | return httpext.ClientIP(r) 46 | } 47 | 48 | // 49 | // JSONStream uses json.Encoder to stream the JSON reponse body. 50 | // 51 | // This differs from the JSON helper which unmarshalls into memory first allowing the capture of JSON encoding errors. 52 | // 53 | func JSONStream(w http.ResponseWriter, status int, i interface{}) error { 54 | return httpext.JSONStream(w, status, i) 55 | } 56 | 57 | // JSON marshals provided interface + returns JSON + status code 58 | func JSON(w http.ResponseWriter, status int, i interface{}) error { 59 | return httpext.JSON(w, status, i) 60 | } 61 | 62 | // JSONBytes returns provided JSON response with status code 63 | func JSONBytes(w http.ResponseWriter, status int, b []byte) (err error) { 64 | return httpext.JSONBytes(w, status, b) 65 | } 66 | 67 | // JSONP sends a JSONP response with status code and uses `callback` to construct 68 | // the JSONP payload. 69 | func JSONP(w http.ResponseWriter, status int, i interface{}, callback string) error { 70 | return httpext.JSONP(w, status, i, callback) 71 | } 72 | 73 | // XML marshals provided interface + returns XML + status code 74 | func XML(w http.ResponseWriter, status int, i interface{}) error { 75 | return httpext.XML(w, status, i) 76 | } 77 | 78 | // XMLBytes returns provided XML response with status code 79 | func XMLBytes(w http.ResponseWriter, status int, b []byte) (err error) { 80 | return httpext.XMLBytes(w, status, b) 81 | } 82 | 83 | // ParseForm calls the underlying http.Request ParseForm 84 | // but also adds the URL params to the request Form as if 85 | // they were defined as query params i.e. ?id=13&ok=true but 86 | // does not add the params to the http.Request.URL.RawQuery 87 | // for SEO purposes 88 | func ParseForm(r *http.Request) error { 89 | if err := r.ParseForm(); err != nil { 90 | return err 91 | } 92 | if rvi := r.Context().Value(defaultContextIdentifier); rvi != nil { 93 | rv := rvi.(*requestVars) 94 | if !rv.formParsed { 95 | for _, p := range rv.params { 96 | r.Form.Add(p.key, p.value) 97 | } 98 | rv.formParsed = true 99 | } 100 | } 101 | return nil 102 | } 103 | 104 | // ParseMultipartForm calls the underlying http.Request ParseMultipartForm 105 | // but also adds the URL params to the request Form as if they were defined 106 | // as query params i.e. ?id=13&ok=true but does not add the params to the 107 | // http.Request.URL.RawQuery for SEO purposes 108 | func ParseMultipartForm(r *http.Request, maxMemory int64) error { 109 | if err := r.ParseMultipartForm(maxMemory); err != nil { 110 | return err 111 | } 112 | if rvi := r.Context().Value(defaultContextIdentifier); rvi != nil { 113 | rv := rvi.(*requestVars) 114 | if !rv.formParsed { 115 | for _, p := range rv.params { 116 | r.Form.Add(p.key, p.value) 117 | } 118 | rv.formParsed = true 119 | } 120 | } 121 | return nil 122 | } 123 | 124 | // Decode takes the request and attempts to discover it's content type via 125 | // the http headers and then decode the request body into the provided struct. 126 | // Example if header was "application/json" would decode using 127 | // json.NewDecoder(ioext.LimitReader(r.Body, maxMemory)).Decode(v). 128 | // 129 | // NOTE: when qp=QueryParams both query params and SEO query params will be parsed and 130 | // included eg. route /user/:id?test=true both 'id' and 'test' are treated as query params and added 131 | // to the request.Form prior to decoding or added to parsed JSON or XML; in short SEO query params are 132 | // treated just like normal query params. 133 | func Decode(r *http.Request, qp httpext.QueryParamsOption, maxMemory int64, v interface{}) (err error) { 134 | typ := r.Header.Get(httpext.ContentType) 135 | if idx := strings.Index(typ, ";"); idx != -1 { 136 | typ = typ[:idx] 137 | } 138 | switch typ { 139 | case httpext.ApplicationForm: 140 | err = DecodeForm(r, qp, v) 141 | case httpext.MultipartForm: 142 | err = DecodeMultipartForm(r, qp, maxMemory, v) 143 | default: 144 | if qp == httpext.QueryParams { 145 | if err = DecodeSEOQueryParams(r, v); err != nil { 146 | return 147 | } 148 | } 149 | err = httpext.Decode(r, qp, maxMemory, v) 150 | } 151 | return 152 | } 153 | 154 | // DecodeForm parses the requests form data into the provided struct. 155 | // 156 | // The Content-Type and http method are not checked. 157 | // 158 | // NOTE: when qp=QueryParams both query params and SEO query params will be parsed and 159 | // included eg. route /user/:id?test=true both 'id' and 'test' are treated as query params and added 160 | // to the request.Form prior to decoding; in short SEO query params are treated just like normal query params. 161 | func DecodeForm(r *http.Request, qp httpext.QueryParamsOption, v interface{}) (err error) { 162 | if qp == httpext.QueryParams { 163 | if err = ParseForm(r); err != nil { 164 | return 165 | } 166 | } 167 | err = httpext.DecodeForm(r, qp, v) 168 | return 169 | } 170 | 171 | // DecodeMultipartForm parses the requests form data into the provided struct. 172 | // 173 | // The Content-Type and http method are not checked. 174 | // 175 | // NOTE: when qp=QueryParams both query params and SEO query params will be parsed and 176 | // included eg. route /user/:id?test=true both 'id' and 'test' are treated as query params and added 177 | // to the request.Form prior to decoding; in short SEO query params are treated just like normal query params. 178 | func DecodeMultipartForm(r *http.Request, qp httpext.QueryParamsOption, maxMemory int64, v interface{}) (err error) { 179 | if qp == httpext.QueryParams { 180 | if err = ParseMultipartForm(r, maxMemory); err != nil { 181 | return 182 | } 183 | } 184 | err = httpext.DecodeMultipartForm(r, qp, maxMemory, v) 185 | return 186 | } 187 | 188 | // DecodeJSON decodes the request body into the provided struct and limits the request size via 189 | // an ioext.LimitReader using the maxMemory param. 190 | // 191 | // The Content-Type e.g. "application/json" and http method are not checked. 192 | // 193 | // NOTE: when qp=QueryParams both query params and SEO query params will be parsed and 194 | // included eg. route /user/:id?test=true both 'id' and 'test' are treated as query params and 195 | // added to parsed JSON; in short SEO query params are treated just like normal query params. 196 | func DecodeJSON(r *http.Request, qp httpext.QueryParamsOption, maxMemory int64, v interface{}) error { 197 | return httpext.DecodeJSON(r, qp, maxMemory, v) 198 | } 199 | 200 | // DecodeXML decodes the request body into the provided struct and limits the request size via 201 | // an ioext.LimitReader using the maxMemory param. 202 | // 203 | // The Content-Type e.g. "application/xml" and http method are not checked. 204 | // 205 | // NOTE: when qp=QueryParams both query params and SEO query params will be parsed and 206 | // included eg. route /user/:id?test=true both 'id' and 'test' are treated as query params and 207 | // added to parsed XML; in short SEO query params are treated just like normal query params. 208 | func DecodeXML(r *http.Request, qp httpext.QueryParamsOption, maxMemory int64, v interface{}) error { 209 | return httpext.DecodeXML(r, qp, maxMemory, v) 210 | } 211 | 212 | // DecodeQueryParams takes the URL Query params, adds SEO params or not based on the includeSEOQueryParams 213 | // flag. 214 | // 215 | // NOTE: DecodeQueryParams is also used/called from Decode when no ContentType is specified 216 | // the only difference is that it will always decode SEO Query Params 217 | func DecodeQueryParams(r *http.Request, qp httpext.QueryParamsOption, v interface{}) error { 218 | return httpext.DefaultFormDecoder.Decode(v, QueryParams(r, qp)) 219 | } 220 | 221 | // DecodeSEOQueryParams decodes the SEO Query params only and ignores the normal URL Query params. 222 | func DecodeSEOQueryParams(r *http.Request, v interface{}) (err error) { 223 | if rvi := r.Context().Value(defaultContextIdentifier); rvi != nil { 224 | rv := rvi.(*requestVars) 225 | values := make(url.Values, len(rv.params)) 226 | for _, p := range rv.params { 227 | values.Add(p.key, p.value) 228 | } 229 | err = httpext.DefaultFormDecoder.Decode(v, values) 230 | } 231 | return 232 | } 233 | 234 | // QueryParams returns the r.URL.Query() values and optionally have them include the 235 | // SEO query params eg. route /users/:id?test=val if qp=QueryParams then 236 | // values will include 'id' as well as 'test' values 237 | func QueryParams(r *http.Request, qp httpext.QueryParamsOption) (values url.Values) { 238 | values = r.URL.Query() 239 | if qp == httpext.QueryParams { 240 | if rvi := r.Context().Value(defaultContextIdentifier); rvi != nil { 241 | rv := rvi.(*requestVars) 242 | for _, p := range rv.params { 243 | values.Add(p.key, p.value) 244 | } 245 | } 246 | } 247 | return 248 | } 249 | 250 | // EncodeToURLValues encodes a struct or field into a set of url.Values 251 | func EncodeToURLValues(v interface{}) (url.Values, error) { 252 | return urlext.EncodeToURLValues(v) 253 | } 254 | -------------------------------------------------------------------------------- /helpers_test.go: -------------------------------------------------------------------------------- 1 | package pure 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "compress/gzip" 7 | "encoding/json" 8 | "encoding/xml" 9 | "io" 10 | "io/ioutil" 11 | "mime/multipart" 12 | "net" 13 | "net/http" 14 | "net/http/httptest" 15 | "net/url" 16 | "os" 17 | "strings" 18 | "sync" 19 | "testing" 20 | 21 | httpext "github.com/go-playground/pkg/v5/net/http" 22 | 23 | . "github.com/go-playground/assert/v2" 24 | ) 25 | 26 | // NOTES: 27 | // - Run "go test" to run tests 28 | // - Run "gocov test | gocov report" to report on test converage by file 29 | // - Run "gocov test | gocov annotate -" to report on all code and functions, those ,marked with "MISS" were never called 30 | // 31 | // or 32 | // 33 | // -- may be a good idea to change to output path to somewherelike /tmp 34 | // go test -coverprofile cover.out && go tool cover -html=cover.out -o cover.html 35 | // 36 | 37 | func TestNoRequestVars(t *testing.T) { 38 | 39 | reqVars := func(w http.ResponseWriter, r *http.Request) { 40 | RequestVars(r) 41 | } 42 | 43 | p := New() 44 | p.Get("/home", reqVars) 45 | 46 | code, _ := request(http.MethodGet, "/home", p) 47 | Equal(t, code, http.StatusOK) 48 | } 49 | 50 | func TestDecode(t *testing.T) { 51 | 52 | type TestStruct struct { 53 | ID int `form:"id"` 54 | Posted string 55 | MultiPartPosted string 56 | } 57 | 58 | test := new(TestStruct) 59 | 60 | p := New() 61 | p.Post("/decode-noquery/:id", func(w http.ResponseWriter, r *http.Request) { 62 | err := Decode(r, httpext.NoQueryParams, 16<<10, test) 63 | Equal(t, err, nil) 64 | }) 65 | p.Post("/decode/:id", func(w http.ResponseWriter, r *http.Request) { 66 | err := Decode(r, httpext.QueryParams, 16<<10, test) 67 | Equal(t, err, nil) 68 | }) 69 | p.Post("/decode2/:id", func(w http.ResponseWriter, r *http.Request) { 70 | err := Decode(r, httpext.NoQueryParams, 16<<10, test) 71 | Equal(t, err, nil) 72 | }) 73 | p.Post("/decode3/:id", func(w http.ResponseWriter, r *http.Request) { 74 | err := Decode(r, httpext.QueryParams, 16<<10, test) 75 | Equal(t, err, nil) 76 | }) 77 | p.Get("/parse-params/:Posted", func(w http.ResponseWriter, r *http.Request) { 78 | err := Decode(r, httpext.QueryParams, 16<<10, test) 79 | Equal(t, err, nil) 80 | }) 81 | 82 | hf := p.Serve() 83 | 84 | r, _ := http.NewRequest(http.MethodGet, "/parse-params/pval?id=5", nil) 85 | w := httptest.NewRecorder() 86 | 87 | hf.ServeHTTP(w, r) 88 | 89 | Equal(t, w.Code, http.StatusOK) 90 | Equal(t, test.ID, 5) 91 | Equal(t, test.Posted, "pval") 92 | Equal(t, test.MultiPartPosted, "") 93 | 94 | form := url.Values{} 95 | form.Add("Posted", "value") 96 | 97 | test = new(TestStruct) 98 | r, _ = http.NewRequest(http.MethodPost, "/decode/14?id=13", strings.NewReader(form.Encode())) 99 | r.Header.Set(httpext.ContentType, httpext.ApplicationForm) 100 | w = httptest.NewRecorder() 101 | 102 | hf.ServeHTTP(w, r) 103 | 104 | Equal(t, w.Code, http.StatusOK) 105 | Equal(t, test.ID, 13) 106 | Equal(t, test.Posted, "value") 107 | Equal(t, test.MultiPartPosted, "") 108 | 109 | test = new(TestStruct) 110 | r, _ = http.NewRequest(http.MethodPost, "/decode/14", strings.NewReader(form.Encode())) 111 | r.Header.Set(httpext.ContentType, httpext.ApplicationForm) 112 | w = httptest.NewRecorder() 113 | 114 | hf.ServeHTTP(w, r) 115 | 116 | Equal(t, w.Code, http.StatusOK) 117 | Equal(t, test.ID, 14) 118 | Equal(t, test.Posted, "value") 119 | Equal(t, test.MultiPartPosted, "") 120 | 121 | test = new(TestStruct) 122 | r, _ = http.NewRequest(http.MethodPost, "/decode2/13", strings.NewReader(form.Encode())) 123 | r.Header.Set(httpext.ContentType, httpext.ApplicationForm) 124 | w = httptest.NewRecorder() 125 | 126 | hf.ServeHTTP(w, r) 127 | 128 | Equal(t, w.Code, http.StatusOK) 129 | Equal(t, test.ID, 0) 130 | Equal(t, test.Posted, "value") 131 | Equal(t, test.MultiPartPosted, "") 132 | 133 | body := &bytes.Buffer{} 134 | writer := multipart.NewWriter(body) 135 | 136 | err := writer.WriteField("MultiPartPosted", "value") 137 | Equal(t, err, nil) 138 | 139 | // Don't forget to close the multipart writer. 140 | // If you don't close it, your request will be missing the terminating boundary. 141 | err = writer.Close() 142 | Equal(t, err, nil) 143 | 144 | test = new(TestStruct) 145 | r, _ = http.NewRequest(http.MethodPost, "/decode/13?id=12", body) 146 | r.Header.Set(httpext.ContentType, writer.FormDataContentType()) 147 | w = httptest.NewRecorder() 148 | 149 | hf.ServeHTTP(w, r) 150 | Equal(t, w.Code, http.StatusOK) 151 | Equal(t, test.ID, 12) 152 | Equal(t, test.Posted, "") 153 | Equal(t, test.MultiPartPosted, "value") 154 | 155 | body = &bytes.Buffer{} 156 | writer = multipart.NewWriter(body) 157 | 158 | err = writer.WriteField("MultiPartPosted", "value") 159 | Equal(t, err, nil) 160 | 161 | // Don't forget to close the multipart writer. 162 | // If you don't close it, your request will be missing the terminating boundary. 163 | err = writer.Close() 164 | Equal(t, err, nil) 165 | 166 | test = new(TestStruct) 167 | r, _ = http.NewRequest(http.MethodPost, "/decode2/13", body) 168 | r.Header.Set(httpext.ContentType, writer.FormDataContentType()) 169 | w = httptest.NewRecorder() 170 | 171 | hf.ServeHTTP(w, r) 172 | Equal(t, w.Code, http.StatusOK) 173 | Equal(t, test.ID, 0) 174 | Equal(t, test.Posted, "") 175 | Equal(t, test.MultiPartPosted, "value") 176 | 177 | body = &bytes.Buffer{} 178 | writer = multipart.NewWriter(body) 179 | 180 | err = writer.WriteField("MultiPartPosted", "value") 181 | Equal(t, err, nil) 182 | 183 | // Don't forget to close the multipart writer. 184 | // If you don't close it, your request will be missing the terminating boundary. 185 | err = writer.Close() 186 | Equal(t, err, nil) 187 | 188 | test = new(TestStruct) 189 | r, _ = http.NewRequest(http.MethodPost, "/decode3/11", body) 190 | r.Header.Set(httpext.ContentType, writer.FormDataContentType()) 191 | w = httptest.NewRecorder() 192 | 193 | hf.ServeHTTP(w, r) 194 | Equal(t, w.Code, http.StatusOK) 195 | Equal(t, test.ID, 11) 196 | Equal(t, test.Posted, "") 197 | Equal(t, test.MultiPartPosted, "value") 198 | 199 | jsonBody := `{"ID":13,"Posted":"value","MultiPartPosted":"value"}` 200 | test = new(TestStruct) 201 | r, _ = http.NewRequest(http.MethodPost, "/decode/13", strings.NewReader(jsonBody)) 202 | r.Header.Set(httpext.ContentType, httpext.ApplicationJSON) 203 | w = httptest.NewRecorder() 204 | 205 | hf.ServeHTTP(w, r) 206 | 207 | Equal(t, w.Code, http.StatusOK) 208 | Equal(t, test.ID, 13) 209 | Equal(t, test.Posted, "value") 210 | Equal(t, test.MultiPartPosted, "value") 211 | 212 | test = new(TestStruct) 213 | r, _ = http.NewRequest(http.MethodPost, "/decode/13?id=14", strings.NewReader(jsonBody)) 214 | r.Header.Set(httpext.ContentType, httpext.ApplicationJSON) 215 | w = httptest.NewRecorder() 216 | 217 | hf.ServeHTTP(w, r) 218 | 219 | Equal(t, w.Code, http.StatusOK) 220 | Equal(t, test.ID, 14) 221 | Equal(t, test.Posted, "value") 222 | Equal(t, test.MultiPartPosted, "value") 223 | 224 | var buff bytes.Buffer 225 | gzw := gzip.NewWriter(&buff) 226 | defer func() { 227 | _ = gzw.Close() 228 | }() 229 | _, err = gzw.Write([]byte(jsonBody)) 230 | Equal(t, err, nil) 231 | 232 | err = gzw.Close() 233 | Equal(t, err, nil) 234 | 235 | test = new(TestStruct) 236 | r, _ = http.NewRequest(http.MethodPost, "/decode/13?id=14", &buff) 237 | r.Header.Set(httpext.ContentType, httpext.ApplicationJSON) 238 | r.Header.Set(httpext.ContentEncoding, httpext.Gzip) 239 | w = httptest.NewRecorder() 240 | 241 | hf.ServeHTTP(w, r) 242 | 243 | Equal(t, w.Code, http.StatusOK) 244 | Equal(t, test.ID, 14) 245 | Equal(t, test.Posted, "value") 246 | Equal(t, test.MultiPartPosted, "value") 247 | 248 | test = new(TestStruct) 249 | r, _ = http.NewRequest(http.MethodPost, "/decode-noquery/13?id=14", strings.NewReader(jsonBody)) 250 | r.Header.Set(httpext.ContentType, httpext.ApplicationJSON) 251 | w = httptest.NewRecorder() 252 | 253 | hf.ServeHTTP(w, r) 254 | 255 | Equal(t, w.Code, http.StatusOK) 256 | Equal(t, test.ID, 13) 257 | Equal(t, test.Posted, "value") 258 | Equal(t, test.MultiPartPosted, "value") 259 | 260 | xmlBody := `13valuevalue` 261 | test = new(TestStruct) 262 | r, _ = http.NewRequest(http.MethodPost, "/decode/13", strings.NewReader(xmlBody)) 263 | r.Header.Set(httpext.ContentType, httpext.ApplicationXML) 264 | w = httptest.NewRecorder() 265 | 266 | hf.ServeHTTP(w, r) 267 | 268 | Equal(t, w.Code, http.StatusOK) 269 | Equal(t, test.ID, 13) 270 | Equal(t, test.Posted, "value") 271 | Equal(t, test.MultiPartPosted, "value") 272 | 273 | test = new(TestStruct) 274 | r, _ = http.NewRequest(http.MethodPost, "/decode/13?id=14", strings.NewReader(xmlBody)) 275 | r.Header.Set(httpext.ContentType, httpext.ApplicationXML) 276 | w = httptest.NewRecorder() 277 | 278 | hf.ServeHTTP(w, r) 279 | 280 | Equal(t, w.Code, http.StatusOK) 281 | Equal(t, test.ID, 14) 282 | Equal(t, test.Posted, "value") 283 | Equal(t, test.MultiPartPosted, "value") 284 | 285 | test = new(TestStruct) 286 | r, _ = http.NewRequest(http.MethodPost, "/decode-noquery/13?id=14", strings.NewReader(xmlBody)) 287 | r.Header.Set(httpext.ContentType, httpext.ApplicationXML) 288 | w = httptest.NewRecorder() 289 | 290 | hf.ServeHTTP(w, r) 291 | 292 | Equal(t, w.Code, http.StatusOK) 293 | Equal(t, test.ID, 13) 294 | Equal(t, test.Posted, "value") 295 | Equal(t, test.MultiPartPosted, "value") 296 | } 297 | 298 | func TestDecodeQueryParams(t *testing.T) { 299 | 300 | type Test struct { 301 | ID int `form:"id"` 302 | } 303 | 304 | test := new(Test) 305 | 306 | p := New() 307 | p.Post("/decode-noquery/:id", func(w http.ResponseWriter, r *http.Request) { 308 | err := DecodeQueryParams(r, httpext.NoQueryParams, test) 309 | Equal(t, err, nil) 310 | }) 311 | p.Post("/decode/:id", func(w http.ResponseWriter, r *http.Request) { 312 | err := DecodeQueryParams(r, httpext.QueryParams, test) 313 | Equal(t, err, nil) 314 | }) 315 | 316 | hf := p.Serve() 317 | 318 | r, _ := http.NewRequest(http.MethodPost, "/decode/13?id=14", nil) 319 | w := httptest.NewRecorder() 320 | 321 | hf.ServeHTTP(w, r) 322 | 323 | Equal(t, w.Code, http.StatusOK) 324 | Equal(t, test.ID, 14) // 14 because 13 was added to the array of 'id' query params 325 | 326 | test = new(Test) 327 | r, _ = http.NewRequest(http.MethodPost, "/decode/13?otheridval=14", nil) 328 | w = httptest.NewRecorder() 329 | 330 | hf.ServeHTTP(w, r) 331 | 332 | Equal(t, w.Code, http.StatusOK) 333 | Equal(t, test.ID, 13) 334 | 335 | test = new(Test) 336 | r, _ = http.NewRequest(http.MethodPost, "/decode-noquery/13?id=14", nil) 337 | w = httptest.NewRecorder() 338 | 339 | hf.ServeHTTP(w, r) 340 | 341 | Equal(t, w.Code, http.StatusOK) 342 | Equal(t, test.ID, 14) 343 | } 344 | 345 | func TestDecodeSEOQueryParams(t *testing.T) { 346 | 347 | type Test struct { 348 | ID int `form:"id"` 349 | } 350 | 351 | test := new(Test) 352 | 353 | p := New() 354 | p.Post("/decode/:id", func(w http.ResponseWriter, r *http.Request) { 355 | err := DecodeSEOQueryParams(r, test) 356 | Equal(t, err, nil) 357 | }) 358 | 359 | hf := p.Serve() 360 | 361 | r, _ := http.NewRequest(http.MethodPost, "/decode/13?id=14", nil) 362 | w := httptest.NewRecorder() 363 | 364 | hf.ServeHTTP(w, r) 365 | 366 | Equal(t, w.Code, http.StatusOK) 367 | Equal(t, test.ID, 13) // 13 because 14 isn;t part of the SEO query params 368 | } 369 | 370 | func TestAcceptedLanguages(t *testing.T) { 371 | 372 | req, _ := http.NewRequest("POST", "/", nil) 373 | req.Header.Set(httpext.AcceptedLanguage, "da, en-GB;q=0.8, en;q=0.7") 374 | 375 | languages := AcceptedLanguages(req) 376 | 377 | Equal(t, languages[0], "da") 378 | Equal(t, languages[1], "en-GB") 379 | Equal(t, languages[2], "en") 380 | 381 | req.Header.Del(httpext.AcceptedLanguage) 382 | 383 | languages = AcceptedLanguages(req) 384 | 385 | Equal(t, len(languages), 0) 386 | 387 | req.Header.Set(httpext.AcceptedLanguage, "") 388 | languages = AcceptedLanguages(req) 389 | 390 | Equal(t, len(languages), 0) 391 | } 392 | 393 | func TestAttachment(t *testing.T) { 394 | 395 | p := New() 396 | 397 | p.Get("/dl", func(w http.ResponseWriter, r *http.Request) { 398 | f, _ := os.Open("logo.png") 399 | if err := Attachment(w, f, "logo.png"); err != nil { 400 | panic(err) 401 | } 402 | }) 403 | 404 | p.Get("/dl-unknown-type", func(w http.ResponseWriter, r *http.Request) { 405 | f, _ := os.Open("logo.png") 406 | if err := Attachment(w, f, "logo"); err != nil { 407 | panic(err) 408 | } 409 | }) 410 | 411 | r, _ := http.NewRequest(http.MethodGet, "/dl", nil) 412 | w := &closeNotifyingRecorder{ 413 | httptest.NewRecorder(), 414 | make(chan bool, 1), 415 | } 416 | hf := p.Serve() 417 | hf.ServeHTTP(w, r) 418 | 419 | Equal(t, w.Code, http.StatusOK) 420 | Equal(t, w.Header().Get(httpext.ContentDisposition), "attachment;filename=logo.png") 421 | Equal(t, w.Header().Get(httpext.ContentType), "image/png") 422 | Equal(t, w.Body.Len(), 20797) 423 | 424 | r, _ = http.NewRequest(http.MethodGet, "/dl-unknown-type", nil) 425 | w = &closeNotifyingRecorder{ 426 | httptest.NewRecorder(), 427 | make(chan bool, 1), 428 | } 429 | hf = p.Serve() 430 | hf.ServeHTTP(w, r) 431 | 432 | Equal(t, w.Code, http.StatusOK) 433 | Equal(t, w.Header().Get(httpext.ContentDisposition), "attachment;filename=logo") 434 | Equal(t, w.Header().Get(httpext.ContentType), "application/octet-stream") 435 | Equal(t, w.Body.Len(), 20797) 436 | } 437 | 438 | func TestInline(t *testing.T) { 439 | 440 | p := New() 441 | p.Get("/dl-inline", func(w http.ResponseWriter, r *http.Request) { 442 | f, _ := os.Open("logo.png") 443 | if err := Inline(w, f, "logo.png"); err != nil { 444 | panic(err) 445 | } 446 | }) 447 | 448 | p.Get("/dl-unknown-type-inline", func(w http.ResponseWriter, r *http.Request) { 449 | f, _ := os.Open("logo.png") 450 | if err := Inline(w, f, "logo"); err != nil { 451 | panic(err) 452 | } 453 | }) 454 | 455 | r, _ := http.NewRequest(http.MethodGet, "/dl-inline", nil) 456 | w := &closeNotifyingRecorder{ 457 | httptest.NewRecorder(), 458 | make(chan bool, 1), 459 | } 460 | hf := p.Serve() 461 | hf.ServeHTTP(w, r) 462 | 463 | Equal(t, w.Code, http.StatusOK) 464 | Equal(t, w.Header().Get(httpext.ContentDisposition), "inline;filename=logo.png") 465 | Equal(t, w.Header().Get(httpext.ContentType), "image/png") 466 | Equal(t, w.Body.Len(), 20797) 467 | 468 | r, _ = http.NewRequest(http.MethodGet, "/dl-unknown-type-inline", nil) 469 | w = &closeNotifyingRecorder{ 470 | httptest.NewRecorder(), 471 | make(chan bool, 1), 472 | } 473 | hf = p.Serve() 474 | hf.ServeHTTP(w, r) 475 | 476 | Equal(t, w.Code, http.StatusOK) 477 | Equal(t, w.Header().Get(httpext.ContentDisposition), "inline;filename=logo") 478 | Equal(t, w.Header().Get(httpext.ContentType), "application/octet-stream") 479 | Equal(t, w.Body.Len(), 20797) 480 | } 481 | 482 | func TestClientIP(t *testing.T) { 483 | 484 | req, _ := http.NewRequest("POST", "/", nil) 485 | 486 | req.Header.Set("X-Real-IP", " 10.10.10.10 ") 487 | req.Header.Set("X-Forwarded-For", " 20.20.20.20, 30.30.30.30") 488 | req.RemoteAddr = " 40.40.40.40:42123 " 489 | 490 | Equal(t, ClientIP(req), "10.10.10.10") 491 | 492 | req.Header.Del("X-Real-IP") 493 | Equal(t, ClientIP(req), "20.20.20.20") 494 | 495 | req.Header.Set("X-Forwarded-For", "30.30.30.30 ") 496 | Equal(t, ClientIP(req), "30.30.30.30") 497 | 498 | req.Header.Del("X-Forwarded-For") 499 | Equal(t, ClientIP(req), "40.40.40.40") 500 | } 501 | 502 | func TestJSON(t *testing.T) { 503 | 504 | jsonData := `{"id":1,"name":"Patient Zero"}` 505 | callbackFunc := "CallbackFunc" 506 | 507 | p := New() 508 | p.Use(Gzip2) 509 | p.Get("/jsonstream", func(w http.ResponseWriter, r *http.Request) { 510 | if err := JSONStream(w, http.StatusOK, zombie{1, "Patient Zero"}); err != nil { 511 | panic(err) 512 | } 513 | }) 514 | p.Get("/json", func(w http.ResponseWriter, r *http.Request) { 515 | if err := JSON(w, http.StatusOK, zombie{1, "Patient Zero"}); err != nil { 516 | panic(err) 517 | } 518 | }) 519 | p.Get("/badjson", func(w http.ResponseWriter, r *http.Request) { 520 | if err := JSON(w, http.StatusOK, func() {}); err != nil { 521 | http.Error(w, err.Error(), http.StatusInternalServerError) 522 | } 523 | }) 524 | p.Get("/jsonbytes", func(w http.ResponseWriter, r *http.Request) { 525 | b, _ := json.Marshal("Patient Zero") 526 | if err := JSONBytes(w, http.StatusOK, b); err != nil { 527 | panic(err) 528 | } 529 | }) 530 | p.Get("/jsonp", func(w http.ResponseWriter, r *http.Request) { 531 | if err := JSONP(w, http.StatusOK, zombie{1, "Patient Zero"}, callbackFunc); err != nil { 532 | panic(err) 533 | } 534 | }) 535 | p.Get("/badjsonp", func(w http.ResponseWriter, r *http.Request) { 536 | if err := JSONP(w, http.StatusOK, func() {}, callbackFunc); err != nil { 537 | http.Error(w, err.Error(), http.StatusInternalServerError) 538 | } 539 | }) 540 | 541 | hf := p.Serve() 542 | 543 | r, _ := http.NewRequest(http.MethodGet, "/jsonstream", nil) 544 | w := httptest.NewRecorder() 545 | hf.ServeHTTP(w, r) 546 | 547 | Equal(t, w.Code, http.StatusOK) 548 | Equal(t, w.Header().Get(httpext.ContentType), httpext.ApplicationJSON) 549 | Equal(t, w.Body.String(), jsonData+"\n") 550 | 551 | r, _ = http.NewRequest(http.MethodGet, "/json", nil) 552 | w = httptest.NewRecorder() 553 | hf.ServeHTTP(w, r) 554 | 555 | Equal(t, w.Code, http.StatusOK) 556 | Equal(t, w.Header().Get(httpext.ContentType), httpext.ApplicationJSON) 557 | Equal(t, w.Body.String(), jsonData) 558 | 559 | r, _ = http.NewRequest(http.MethodGet, "/badjson", nil) 560 | w = httptest.NewRecorder() 561 | hf.ServeHTTP(w, r) 562 | 563 | Equal(t, w.Code, http.StatusInternalServerError) 564 | Equal(t, w.Header().Get(httpext.ContentType), httpext.TextPlain) 565 | Equal(t, w.Body.String(), "json: unsupported type: func()\n") 566 | 567 | r, _ = http.NewRequest(http.MethodGet, "/jsonbytes", nil) 568 | w = httptest.NewRecorder() 569 | hf.ServeHTTP(w, r) 570 | 571 | Equal(t, w.Code, http.StatusOK) 572 | Equal(t, w.Header().Get(httpext.ContentType), httpext.ApplicationJSON) 573 | Equal(t, w.Body.String(), "\"Patient Zero\"") 574 | 575 | r, _ = http.NewRequest(http.MethodGet, "/jsonp", nil) 576 | w = httptest.NewRecorder() 577 | hf.ServeHTTP(w, r) 578 | 579 | Equal(t, w.Code, http.StatusOK) 580 | Equal(t, w.Header().Get(httpext.ContentType), httpext.ApplicationJSON) 581 | Equal(t, w.Body.String(), callbackFunc+"("+jsonData+");") 582 | 583 | r, _ = http.NewRequest(http.MethodGet, "/badjsonp", nil) 584 | w = httptest.NewRecorder() 585 | hf.ServeHTTP(w, r) 586 | 587 | Equal(t, w.Code, http.StatusInternalServerError) 588 | Equal(t, w.Header().Get(httpext.ContentType), httpext.TextPlain) 589 | Equal(t, w.Body.String(), "json: unsupported type: func()\n") 590 | } 591 | 592 | func TestXML(t *testing.T) { 593 | 594 | xmlData := `1Patient Zero` 595 | 596 | p := New() 597 | p.Use(Gzip2) 598 | p.Get("/xml", func(w http.ResponseWriter, r *http.Request) { 599 | if err := XML(w, http.StatusOK, zombie{1, "Patient Zero"}); err != nil { 600 | panic(err) 601 | } 602 | }) 603 | p.Get("/badxml", func(w http.ResponseWriter, r *http.Request) { 604 | if err := XML(w, http.StatusOK, func() {}); err != nil { 605 | http.Error(w, err.Error(), http.StatusInternalServerError) 606 | } 607 | }) 608 | p.Get("/xmlbytes", func(w http.ResponseWriter, r *http.Request) { 609 | b, _ := xml.Marshal(zombie{1, "Patient Zero"}) 610 | if err := XMLBytes(w, http.StatusOK, b); err != nil { 611 | panic(err) 612 | } 613 | }) 614 | 615 | hf := p.Serve() 616 | 617 | r, _ := http.NewRequest(http.MethodGet, "/xml", nil) 618 | w := httptest.NewRecorder() 619 | hf.ServeHTTP(w, r) 620 | 621 | Equal(t, w.Code, http.StatusOK) 622 | Equal(t, w.Header().Get(httpext.ContentType), httpext.ApplicationXML) 623 | Equal(t, w.Body.String(), xml.Header+xmlData) 624 | 625 | r, _ = http.NewRequest(http.MethodGet, "/xmlbytes", nil) 626 | w = httptest.NewRecorder() 627 | hf.ServeHTTP(w, r) 628 | 629 | Equal(t, w.Code, http.StatusOK) 630 | Equal(t, w.Header().Get(httpext.ContentType), httpext.ApplicationXML) 631 | Equal(t, w.Body.String(), xml.Header+xmlData) 632 | 633 | r, _ = http.NewRequest(http.MethodGet, "/badxml", nil) 634 | w = httptest.NewRecorder() 635 | hf.ServeHTTP(w, r) 636 | 637 | Equal(t, w.Code, http.StatusInternalServerError) 638 | Equal(t, w.Header().Get(httpext.ContentType), httpext.TextPlain) 639 | Equal(t, w.Body.String(), "xml: unsupported type: func()\n") 640 | } 641 | 642 | func TestBadParseForm(t *testing.T) { 643 | // successful scenarios tested under TestDecode 644 | p := New() 645 | p.Get("/users/:id", func(w http.ResponseWriter, r *http.Request) { 646 | 647 | if err := ParseForm(r); err != nil { 648 | if _, errr := w.Write([]byte(err.Error())); errr != nil { 649 | panic(errr) 650 | } 651 | return 652 | } 653 | }) 654 | 655 | code, body := request(http.MethodGet, "/users/16?test=%2f%%efg", p) 656 | Equal(t, code, http.StatusOK) 657 | Equal(t, body, "invalid URL escape \"%%e\"") 658 | } 659 | 660 | func TestBadParseMultiPartForm(t *testing.T) { 661 | // successful scenarios tested under TestDecode 662 | p := New() 663 | p.Get("/users/:id", func(w http.ResponseWriter, r *http.Request) { 664 | 665 | if err := ParseMultipartForm(r, 10<<5); err != nil { 666 | if _, errr := w.Write([]byte(err.Error())); errr != nil { 667 | panic(err) 668 | } 669 | return 670 | } 671 | }) 672 | 673 | code, body := requestMultiPart(http.MethodGet, "/users/16?test=%2f%%efg", p) 674 | Equal(t, code, http.StatusOK) 675 | Equal(t, body, "invalid URL escape \"%%e\"") 676 | } 677 | 678 | func TestEncodeToURLValues(t *testing.T) { 679 | type Test struct { 680 | Domain string `form:"domain"` 681 | Next string `form:"next"` 682 | } 683 | 684 | s := Test{Domain: "company.org", Next: "NIDEJ89#(@#NWJK"} 685 | values, err := EncodeToURLValues(s) 686 | Equal(t, err, nil) 687 | Equal(t, len(values), 2) 688 | Equal(t, values.Encode(), "domain=company.org&next=NIDEJ89%23%28%40%23NWJK") 689 | } 690 | 691 | type gzipWriter struct { 692 | io.Writer 693 | http.ResponseWriter 694 | sniffComplete bool 695 | } 696 | 697 | func (w *gzipWriter) Write(b []byte) (int, error) { 698 | 699 | if !w.sniffComplete { 700 | if w.Header().Get(httpext.ContentType) == "" { 701 | w.Header().Set(httpext.ContentType, http.DetectContentType(b)) 702 | } 703 | w.sniffComplete = true 704 | } 705 | 706 | return w.Writer.Write(b) 707 | } 708 | 709 | func (w *gzipWriter) Flush() error { 710 | return w.Writer.(*gzip.Writer).Flush() 711 | } 712 | 713 | func (w *gzipWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { 714 | return w.ResponseWriter.(http.Hijacker).Hijack() 715 | } 716 | 717 | var gzipPool = sync.Pool{ 718 | New: func() interface{} { 719 | return &gzipWriter{Writer: gzip.NewWriter(ioutil.Discard)} 720 | }, 721 | } 722 | 723 | // Gzip returns a middleware which compresses HTTP response using gzip compression 724 | // scheme. 725 | func Gzip2(next http.HandlerFunc) http.HandlerFunc { 726 | 727 | return func(w http.ResponseWriter, r *http.Request) { 728 | 729 | var gz *gzipWriter 730 | var gzr *gzip.Writer 731 | 732 | w.Header().Add(httpext.Vary, httpext.AcceptEncoding) 733 | 734 | if strings.Contains(r.Header.Get(httpext.AcceptEncoding), httpext.Gzip) { 735 | 736 | gz = gzipPool.Get().(*gzipWriter) 737 | gz.sniffComplete = false 738 | gzr = gz.Writer.(*gzip.Writer) 739 | gzr.Reset(w) 740 | gz.ResponseWriter = w 741 | 742 | w.Header().Set(httpext.ContentEncoding, httpext.Gzip) 743 | 744 | w = gz 745 | defer func() { 746 | 747 | // fmt.Println(gz.sniffComplete) 748 | if !gz.sniffComplete { 749 | // We have to reset response to it's pristine state when 750 | // nothing is written to body. 751 | w.Header().Del(httpext.ContentEncoding) 752 | gzr.Reset(ioutil.Discard) 753 | } 754 | 755 | gzr.Close() 756 | gzipPool.Put(gz) 757 | }() 758 | } 759 | 760 | next(w, r) 761 | } 762 | } 763 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-playground/pure/c277882736af847c71a4df88fc93ac1bb0c5ac0c/logo.png -------------------------------------------------------------------------------- /middleware/gzip.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "bufio" 5 | "compress/gzip" 6 | "io" 7 | "io/ioutil" 8 | "net" 9 | "net/http" 10 | "strings" 11 | "sync" 12 | 13 | httpext "github.com/go-playground/pkg/v5/net/http" 14 | 15 | "github.com/go-playground/pure/v5" 16 | ) 17 | 18 | type gzipWriter struct { 19 | io.Writer 20 | http.ResponseWriter 21 | sniffComplete bool 22 | } 23 | 24 | func (w *gzipWriter) Write(b []byte) (int, error) { 25 | 26 | if !w.sniffComplete { 27 | if w.Header().Get(httpext.ContentType) == "" { 28 | w.Header().Set(httpext.ContentType, http.DetectContentType(b)) 29 | } 30 | w.sniffComplete = true 31 | } 32 | 33 | return w.Writer.Write(b) 34 | } 35 | 36 | func (w *gzipWriter) Flush() error { 37 | return w.Writer.(*gzip.Writer).Flush() 38 | } 39 | 40 | func (w *gzipWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { 41 | return w.ResponseWriter.(http.Hijacker).Hijack() 42 | } 43 | 44 | var gzipPool = sync.Pool{ 45 | New: func() interface{} { 46 | return &gzipWriter{Writer: gzip.NewWriter(ioutil.Discard)} 47 | }, 48 | } 49 | 50 | // Gzip returns a middleware which compresses HTTP response using gzip compression 51 | // scheme. 52 | func Gzip(next http.HandlerFunc) http.HandlerFunc { 53 | 54 | return func(w http.ResponseWriter, r *http.Request) { 55 | 56 | w.Header().Add(httpext.Vary, httpext.AcceptEncoding) 57 | 58 | if strings.Contains(r.Header.Get(httpext.AcceptEncoding), httpext.Gzip) { 59 | 60 | gz := gzipPool.Get().(*gzipWriter) 61 | gz.sniffComplete = false 62 | gzr := gz.Writer.(*gzip.Writer) 63 | gzr.Reset(w) 64 | gz.ResponseWriter = w 65 | 66 | w.Header().Set(httpext.ContentEncoding, httpext.Gzip) 67 | 68 | w = gz 69 | defer func() { 70 | 71 | if !gz.sniffComplete { 72 | // We have to reset response to it's pristine state when 73 | // nothing is written to body. 74 | w.Header().Del(httpext.ContentEncoding) 75 | gzr.Reset(ioutil.Discard) 76 | } 77 | 78 | gzr.Close() 79 | gzipPool.Put(gz) 80 | }() 81 | } 82 | 83 | next(w, r) 84 | } 85 | } 86 | 87 | // GzipLevel returns a middleware which compresses HTTP response using gzip compression 88 | // scheme using the level specified 89 | func GzipLevel(level int) pure.Middleware { 90 | 91 | // test gzip level, then don't have to each time one is created 92 | // in the pool 93 | 94 | if _, err := gzip.NewWriterLevel(ioutil.Discard, level); err != nil { 95 | panic(err) 96 | } 97 | 98 | var gzipPool = sync.Pool{ 99 | New: func() interface{} { 100 | z, _ := gzip.NewWriterLevel(ioutil.Discard, level) 101 | 102 | return &gzipWriter{Writer: z} 103 | }, 104 | } 105 | 106 | return func(next http.HandlerFunc) http.HandlerFunc { 107 | return func(w http.ResponseWriter, r *http.Request) { 108 | 109 | w.Header().Add(httpext.Vary, httpext.AcceptEncoding) 110 | 111 | if strings.Contains(r.Header.Get(httpext.AcceptEncoding), httpext.Gzip) { 112 | 113 | gz := gzipPool.Get().(*gzipWriter) 114 | gz.sniffComplete = false 115 | gzr := gz.Writer.(*gzip.Writer) 116 | gzr.Reset(w) 117 | gz.ResponseWriter = w 118 | 119 | w.Header().Set(httpext.ContentEncoding, httpext.Gzip) 120 | 121 | w = gz 122 | defer func() { 123 | 124 | if !gz.sniffComplete { 125 | // We have to reset response to it's pristine state when 126 | // nothing is written to body. 127 | w.Header().Del(httpext.ContentEncoding) 128 | gzr.Reset(ioutil.Discard) 129 | } 130 | 131 | gzr.Close() 132 | gzipPool.Put(gz) 133 | }() 134 | } 135 | 136 | next(w, r) 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /middleware/gzip_test.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "compress/flate" 7 | "compress/gzip" 8 | "io/ioutil" 9 | "net" 10 | "net/http" 11 | "net/http/httptest" 12 | "testing" 13 | 14 | httpext "github.com/go-playground/pkg/v5/net/http" 15 | 16 | . "github.com/go-playground/assert/v2" 17 | "github.com/go-playground/pure/v5" 18 | ) 19 | 20 | // NOTES: 21 | // - Run "go test" to run tests 22 | // - Run "gocov test | gocov report" to report on test converage by file 23 | // - Run "gocov test | gocov annotate -" to report on all code and functions, those ,marked with "MISS" were never called 24 | // 25 | // or 26 | // 27 | // -- may be a good idea to change to output path to somewherelike /tmp 28 | // go test -coverprofile cover.out && go tool cover -html=cover.out -o cover.html 29 | // 30 | 31 | func TestGzip(t *testing.T) { 32 | 33 | p := pure.New() 34 | p.Use(Gzip) 35 | p.Get("/test", func(w http.ResponseWriter, r *http.Request) { 36 | _, _ = w.Write([]byte("test")) 37 | }) 38 | p.Get("/empty", func(w http.ResponseWriter, r *http.Request) { 39 | }) 40 | 41 | server := httptest.NewServer(p.Serve()) 42 | defer server.Close() 43 | 44 | req, _ := http.NewRequest(http.MethodGet, server.URL+"/test", nil) 45 | 46 | client := &http.Client{} 47 | 48 | resp, err := client.Do(req) 49 | Equal(t, err, nil) 50 | Equal(t, resp.StatusCode, http.StatusOK) 51 | 52 | b, err := ioutil.ReadAll(resp.Body) 53 | Equal(t, err, nil) 54 | Equal(t, string(b), "test") 55 | 56 | req, _ = http.NewRequest(http.MethodGet, server.URL+"/test", nil) 57 | req.Header.Set(httpext.AcceptEncoding, "gzip") 58 | 59 | resp, err = client.Do(req) 60 | Equal(t, err, nil) 61 | Equal(t, resp.StatusCode, http.StatusOK) 62 | Equal(t, resp.Header.Get(httpext.ContentEncoding), httpext.Gzip) 63 | Equal(t, resp.Header.Get(httpext.ContentType), httpext.TextPlain) 64 | 65 | r, err := gzip.NewReader(resp.Body) 66 | Equal(t, err, nil) 67 | defer r.Close() 68 | 69 | b, err = ioutil.ReadAll(r) 70 | Equal(t, err, nil) 71 | Equal(t, string(b), "test") 72 | 73 | req, _ = http.NewRequest(http.MethodGet, server.URL+"/empty", nil) 74 | 75 | resp, err = client.Do(req) 76 | Equal(t, err, nil) 77 | Equal(t, resp.StatusCode, http.StatusOK) 78 | } 79 | 80 | func TestGzipLevel(t *testing.T) { 81 | 82 | // bad gzip level 83 | PanicMatches(t, func() { GzipLevel(999) }, "gzip: invalid compression level: 999") 84 | 85 | p := pure.New() 86 | p.Use(GzipLevel(flate.BestCompression)) 87 | p.Get("/test", func(w http.ResponseWriter, r *http.Request) { 88 | _, _ = w.Write([]byte("test")) 89 | }) 90 | p.Get("/empty", func(w http.ResponseWriter, r *http.Request) { 91 | }) 92 | 93 | server := httptest.NewServer(p.Serve()) 94 | defer server.Close() 95 | 96 | req, _ := http.NewRequest(http.MethodGet, server.URL+"/test", nil) 97 | 98 | client := &http.Client{} 99 | 100 | resp, err := client.Do(req) 101 | Equal(t, err, nil) 102 | Equal(t, resp.StatusCode, http.StatusOK) 103 | 104 | b, err := ioutil.ReadAll(resp.Body) 105 | Equal(t, err, nil) 106 | Equal(t, string(b), "test") 107 | 108 | req, _ = http.NewRequest(http.MethodGet, server.URL+"/test", nil) 109 | req.Header.Set(httpext.AcceptEncoding, "gzip") 110 | 111 | resp, err = client.Do(req) 112 | Equal(t, err, nil) 113 | Equal(t, resp.StatusCode, http.StatusOK) 114 | Equal(t, resp.Header.Get(httpext.ContentEncoding), httpext.Gzip) 115 | Equal(t, resp.Header.Get(httpext.ContentType), httpext.TextPlain) 116 | 117 | r, err := gzip.NewReader(resp.Body) 118 | Equal(t, err, nil) 119 | defer r.Close() 120 | 121 | b, err = ioutil.ReadAll(r) 122 | Equal(t, err, nil) 123 | Equal(t, string(b), "test") 124 | 125 | req, _ = http.NewRequest(http.MethodGet, server.URL+"/empty", nil) 126 | 127 | resp, err = client.Do(req) 128 | Equal(t, err, nil) 129 | Equal(t, resp.StatusCode, http.StatusOK) 130 | } 131 | 132 | func TestGzipFlush(t *testing.T) { 133 | 134 | rec := httptest.NewRecorder() 135 | buff := new(bytes.Buffer) 136 | 137 | w := gzip.NewWriter(buff) 138 | gw := gzipWriter{Writer: w, ResponseWriter: rec} 139 | 140 | Equal(t, buff.Len(), 0) 141 | 142 | err := gw.Flush() 143 | Equal(t, err, nil) 144 | 145 | n1 := buff.Len() 146 | NotEqual(t, n1, 0) 147 | 148 | _, err = gw.Write([]byte("x")) 149 | Equal(t, err, nil) 150 | 151 | n2 := buff.Len() 152 | Equal(t, n1, n2) 153 | 154 | err = gw.Flush() 155 | Equal(t, err, nil) 156 | NotEqual(t, n2, buff.Len()) 157 | } 158 | 159 | func TestGzipHijack(t *testing.T) { 160 | 161 | rec := newCloseNotifyingRecorder() 162 | buf := new(bytes.Buffer) 163 | w := gzip.NewWriter(buf) 164 | gw := gzipWriter{Writer: w, ResponseWriter: rec} 165 | 166 | _, bufrw, err := gw.Hijack() 167 | Equal(t, err, nil) 168 | 169 | _, _ = bufrw.WriteString("test") 170 | } 171 | 172 | type closeNotifyingRecorder struct { 173 | *httptest.ResponseRecorder 174 | closed chan bool 175 | } 176 | 177 | func newCloseNotifyingRecorder() *closeNotifyingRecorder { 178 | return &closeNotifyingRecorder{ 179 | httptest.NewRecorder(), 180 | make(chan bool, 1), 181 | } 182 | } 183 | 184 | func (c *closeNotifyingRecorder) Close() { 185 | c.closed <- true 186 | } 187 | 188 | func (c *closeNotifyingRecorder) CloseNotify() <-chan bool { 189 | return c.closed 190 | } 191 | 192 | func (c *closeNotifyingRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { 193 | 194 | reader := bufio.NewReader(c.Body) 195 | writer := bufio.NewWriter(c.Body) 196 | return nil, bufio.NewReadWriter(reader, writer), nil 197 | } 198 | -------------------------------------------------------------------------------- /node.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016 Dean Karn. 2 | // Copyright 2013 Julien Schmidt. 3 | // All rights reserved. 4 | // Use of this source code is governed by a BSD-style license that can be found 5 | // in the LICENSE file at https://raw.githubusercontent.com/julienschmidt/httprouter/master/LICENSE. 6 | 7 | package pure 8 | 9 | import ( 10 | "net/http" 11 | "net/url" 12 | ) 13 | 14 | type nodeType uint8 15 | 16 | const ( 17 | isRoot nodeType = iota + 1 18 | hasParams 19 | matchesAny 20 | ) 21 | 22 | type existingParams map[string]struct{} 23 | 24 | type node struct { 25 | path string 26 | indices string 27 | children []*node 28 | handler http.HandlerFunc 29 | priority uint32 30 | nType nodeType 31 | wildChild bool 32 | } 33 | 34 | func (e existingParams) check(param string, path string) { 35 | if _, ok := e[param]; ok { 36 | panic("Duplicate param name '" + param + "' detected for route '" + path + "'") 37 | } 38 | e[param] = struct{}{} 39 | } 40 | 41 | // increments priority of the given child and reorders if necessary 42 | func (n *node) incrementChildPrio(pos int) int { 43 | n.children[pos].priority++ 44 | prio := n.children[pos].priority 45 | 46 | // adjust position (move to front) 47 | newPos := pos 48 | for newPos > 0 && n.children[newPos-1].priority < prio { 49 | 50 | // swap node positions 51 | n.children[newPos-1], n.children[newPos] = n.children[newPos], n.children[newPos-1] 52 | newPos-- 53 | } 54 | 55 | // build new index char string 56 | if newPos != pos { 57 | n.indices = n.indices[:newPos] + // unchanged prefix, might be empty 58 | n.indices[pos:pos+1] + // the index char we move 59 | n.indices[newPos:pos] + n.indices[pos+1:] // rest without char at 'pos' 60 | } 61 | return newPos 62 | } 63 | 64 | // addRoute adds a node with the given handle to the path. 65 | // here we set a Middleware because we have to transfer all route's middlewares (it's a chain of functions) (with it's handler) to the node 66 | func (n *node) add(path string, handler http.HandlerFunc) (lp uint8) { 67 | var err error 68 | if path == blank { 69 | path = basePath 70 | } 71 | 72 | existing := make(existingParams) 73 | fullPath := path 74 | 75 | if path, err = url.QueryUnescape(path); err != nil { 76 | panic("Query Unescape Error on path '" + fullPath + "': " + err.Error()) 77 | } 78 | 79 | fullPath = path 80 | 81 | n.priority++ 82 | numParams := countParams(path) 83 | lp = numParams 84 | 85 | // non-empty tree 86 | if len(n.path) > 0 || len(n.children) > 0 { 87 | walk: 88 | for { 89 | // Find the longest common prefix. 90 | // This also implies that the common prefix contains no : or * 91 | // since the existing key can't contain those chars. 92 | i := 0 93 | max := min(len(path), len(n.path)) 94 | for i < max && path[i] == n.path[i] { 95 | i++ 96 | } 97 | 98 | // Split edge 99 | if i < len(n.path) { 100 | child := node{ 101 | path: n.path[i:], 102 | wildChild: n.wildChild, 103 | indices: n.indices, 104 | children: n.children, 105 | handler: n.handler, 106 | priority: n.priority - 1, 107 | } 108 | 109 | n.children = []*node{&child} 110 | // []byte for proper unicode char conversion, see httprouter #65 111 | n.indices = string([]byte{n.path[i]}) 112 | n.path = path[:i] 113 | n.handler = nil 114 | n.wildChild = false 115 | } 116 | 117 | // Make new node a child of this node 118 | if i < len(path) { 119 | path = path[i:] 120 | 121 | if n.wildChild { 122 | n = n.children[0] 123 | n.priority++ 124 | numParams-- 125 | 126 | existing.check(n.path, fullPath) 127 | 128 | // Check if the wildcard matches 129 | if len(path) >= len(n.path) && n.path == path[:len(n.path)] { 130 | 131 | // check for longer wildcard, e.g. :name and :names 132 | if len(n.path) >= len(path) || path[len(n.path)] == slashByte { 133 | continue walk 134 | } 135 | } 136 | 137 | panic("path segment '" + path + 138 | "' conflicts with existing wildcard '" + n.path + 139 | "' in path '" + fullPath + "'") 140 | } 141 | 142 | c := path[0] 143 | 144 | // slash after param 145 | if n.nType == hasParams && c == slashByte && len(n.children) == 1 { 146 | n = n.children[0] 147 | n.priority++ 148 | continue walk 149 | } 150 | 151 | // Check if a child with the next path byte exists 152 | for i := 0; i < len(n.indices); i++ { 153 | if c == n.indices[i] { 154 | i = n.incrementChildPrio(i) 155 | n = n.children[i] 156 | continue walk 157 | } 158 | } 159 | 160 | // Otherwise insert it 161 | if c != paramByte && c != wildByte { 162 | 163 | // []byte for proper unicode char conversion, see httprouter #65 164 | n.indices += string([]byte{c}) 165 | child := &node{} 166 | n.children = append(n.children, child) 167 | n.incrementChildPrio(len(n.indices) - 1) 168 | n = child 169 | } 170 | n.insertChild(numParams, existing, path, fullPath, handler) 171 | return 172 | 173 | } else if i == len(path) { // Make node a (in-path) leaf 174 | if n.handler != nil { 175 | panic("handlers are already registered for path '" + fullPath + "'") 176 | } 177 | n.handler = handler 178 | } 179 | return 180 | } 181 | } else { // Empty tree 182 | n.insertChild(numParams, existing, path, fullPath, handler) 183 | n.nType = isRoot 184 | } 185 | return 186 | } 187 | 188 | func (n *node) insertChild(numParams uint8, existing existingParams, path string, fullPath string, handler http.HandlerFunc) { 189 | var offset int // already handled bytes of the path 190 | 191 | // find prefix until first wildcard (beginning with paramByte' or wildByte') 192 | for i, max := 0, len(path); numParams > 0; i++ { 193 | 194 | c := path[i] 195 | if c != paramByte && c != wildByte { 196 | continue 197 | } 198 | 199 | // find wildcard end (either '/' or path end) 200 | end := i + 1 201 | for end < max && path[end] != slashByte { 202 | switch path[end] { 203 | // the wildcard name must not contain ':' and '*' 204 | case paramByte, wildByte: 205 | panic("only one wildcard per path segment is allowed, has: '" + 206 | path[i:] + "' in path '" + fullPath + "'") 207 | default: 208 | end++ 209 | } 210 | } 211 | 212 | // check if this Node existing children which would be 213 | // unreachable if we insert the wildcard here 214 | if len(n.children) > 0 { 215 | panic("wildcard route '" + path[i:end] + 216 | "' conflicts with existing children in path '" + fullPath + "'") 217 | } 218 | 219 | if c == paramByte { // param 220 | // check if the wildcard has a name 221 | if end-i < 2 { 222 | panic("wildcards must be named with a non-empty name in path '" + fullPath + "'") 223 | } 224 | 225 | // split path at the beginning of the wildcard 226 | if i > 0 { 227 | n.path = path[offset:i] 228 | offset = i 229 | } 230 | 231 | child := &node{ 232 | nType: hasParams, 233 | } 234 | n.children = []*node{child} 235 | n.wildChild = true 236 | n = child 237 | n.priority++ 238 | numParams-- 239 | 240 | // if the path doesn't end with the wildcard, then there 241 | // will be another non-wildcard subpath starting with '/' 242 | if end < max { 243 | 244 | existing.check(path[offset:end], fullPath) 245 | 246 | n.path = path[offset:end] 247 | offset = end 248 | 249 | child := &node{ 250 | priority: 1, 251 | } 252 | n.children = []*node{child} 253 | n = child 254 | } 255 | 256 | } else { // catchAll 257 | if end != max || numParams > 1 { 258 | panic("Character after the * symbol is not permitted, path '" + fullPath + "'") 259 | } 260 | 261 | if len(n.path) > 0 && n.path[len(n.path)-1] == '/' { 262 | panic("catch-all conflicts with existing handle for the path segment root in path '" + fullPath + "'") 263 | } 264 | 265 | // currently fixed width 1 for '/' 266 | i-- 267 | if path[i] != slashByte { 268 | panic("no / before catch-all in path '" + fullPath + "'") 269 | } 270 | 271 | n.path = path[offset:i] 272 | 273 | // first node: catchAll node with empty path 274 | child := &node{ 275 | wildChild: true, 276 | nType: matchesAny, 277 | } 278 | n.children = []*node{child} 279 | n.indices = string(path[i]) 280 | n = child 281 | n.priority++ 282 | 283 | // second node: node holding the variable 284 | child = &node{ 285 | path: path[i:], 286 | nType: matchesAny, 287 | handler: handler, 288 | priority: 1, 289 | } 290 | n.children = []*node{child} 291 | return 292 | } 293 | } 294 | if n.nType == hasParams { 295 | existing.check(path[offset:], fullPath) 296 | } 297 | 298 | // insert remaining path part and handle to the leaf 299 | n.path = path[offset:] 300 | n.handler = handler 301 | } 302 | 303 | // Returns the handle registered with the given path (key). 304 | func (n *node) find(path string, mux *Mux) (handler http.HandlerFunc, rv *requestVars) { 305 | 306 | walk: // Outer loop for walking the tree 307 | for { 308 | if len(path) > len(n.path) { 309 | if path[:len(n.path)] == n.path { 310 | path = path[len(n.path):] 311 | 312 | // If this node does not have a wildcard (param or catchAll) 313 | // child, we can just look up the next child node and continue 314 | // to walk down the tree 315 | if !n.wildChild { 316 | c := path[0] 317 | for i := 0; i < len(n.indices); i++ { 318 | if c == n.indices[i] { 319 | n = n.children[i] 320 | continue walk 321 | } 322 | } 323 | return 324 | } 325 | 326 | // handle wildcard child 327 | n = n.children[0] 328 | switch n.nType { 329 | case hasParams: 330 | // find param end (either '/' or path end) 331 | end := 0 332 | for end < len(path) && path[end] != slashByte { 333 | end++ 334 | } 335 | if rv == nil { 336 | rv = mux.pool.Get().(*requestVars) 337 | rv.params = rv.params[0:0] 338 | } 339 | 340 | // save param value 341 | i := len(rv.params) 342 | rv.params = rv.params[:i+1] // expand slice within preallocated capacity 343 | rv.params[i].key = n.path[1:] 344 | rv.params[i].value = path[:end] 345 | 346 | // we need to go deeper! 347 | if end < len(path) { 348 | if len(n.children) > 0 { 349 | path = path[end:] 350 | n = n.children[0] 351 | continue walk 352 | } 353 | return 354 | } 355 | if n.handler != nil { 356 | handler = n.handler 357 | return 358 | } 359 | return 360 | 361 | case matchesAny: 362 | if rv == nil { 363 | rv = mux.pool.Get().(*requestVars) 364 | rv.params = rv.params[0:0] 365 | } 366 | // save param value 367 | i := len(rv.params) 368 | rv.params = rv.params[:i+1] // expand slice within preallocated capacity 369 | rv.params[i].key = WildcardParam 370 | rv.params[i].value = path[1:] 371 | handler = n.handler 372 | return 373 | } 374 | } 375 | 376 | } else if path == n.path { 377 | // We should have reached the node containing the handle. 378 | // Check if this node has a handle registered. 379 | if n.handler != nil { 380 | handler = n.handler 381 | } 382 | } 383 | // Nothing found 384 | return 385 | } 386 | } 387 | -------------------------------------------------------------------------------- /node_test.go: -------------------------------------------------------------------------------- 1 | package pure 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | . "github.com/go-playground/assert/v2" 8 | ) 9 | 10 | // NOTES: 11 | // - Run "go test" to run tests 12 | // - Run "gocov test | gocov report" to report on test converage by file 13 | // - Run "gocov test | gocov annotate -" to report on all code and functions, those ,marked with "MISS" were never called 14 | // 15 | // or 16 | // 17 | // -- may be a good idea to change to output path to somewherelike /tmp 18 | // go test -coverprofile cover.out && go tool cover -html=cover.out -o cover.html 19 | // 20 | 21 | func TestAddChain(t *testing.T) { 22 | p := New() 23 | 24 | p.Get("/home", defaultHandler) 25 | 26 | PanicMatches(t, func() { p.Get("/home", defaultHandler) }, "handlers are already registered for path '/home'") 27 | } 28 | 29 | func TestBadWildcard(t *testing.T) { 30 | 31 | p := New() 32 | PanicMatches(t, func() { p.Get("/test/:test*test", defaultHandler) }, "only one wildcard per path segment is allowed, has: ':test*test' in path '/test/:test*test'") 33 | 34 | p.Get("/users/:id/contact-info/:cid", defaultHandler) 35 | PanicMatches(t, func() { p.Get("/users/:id/*", defaultHandler) }, "wildcard route '*' conflicts with existing children in path '/users/:id/*'") 36 | PanicMatches(t, func() { p.Get("/admin/:/", defaultHandler) }, "wildcards must be named with a non-empty name in path '/admin/:/'") 37 | PanicMatches(t, func() { p.Get("/admin/events*", defaultHandler) }, "no / before catch-all in path '/admin/events*'") 38 | 39 | l2 := New() 40 | l2.Get("/", defaultHandler) 41 | PanicMatches(t, func() { l2.Get("/*", defaultHandler) }, "catch-all conflicts with existing handle for the path segment root in path '/*'") 42 | 43 | code, _ := request(http.MethodGet, "/home", l2) 44 | Equal(t, code, http.StatusNotFound) 45 | 46 | l3 := New() 47 | l3.Get("/testers/:id", defaultHandler) 48 | 49 | code, _ = request(http.MethodGet, "/testers/13/test", l3) 50 | Equal(t, code, http.StatusNotFound) 51 | } 52 | 53 | func TestDuplicateParams(t *testing.T) { 54 | 55 | p := New() 56 | p.Get("/store/:id", defaultHandler) 57 | PanicMatches(t, func() { p.Get("/store/:id/employee/:id", defaultHandler) }, "Duplicate param name ':id' detected for route '/store/:id/employee/:id'") 58 | 59 | p.Get("/company/:id/", defaultHandler) 60 | PanicMatches(t, func() { p.Get("/company/:id/employee/:id/", defaultHandler) }, "Duplicate param name ':id' detected for route '/company/:id/employee/:id/'") 61 | } 62 | 63 | func TestWildcardParam(t *testing.T) { 64 | p := New() 65 | p.Get("/users/*", func(w http.ResponseWriter, r *http.Request) { 66 | 67 | rv := RequestVars(r) 68 | if _, err := w.Write([]byte(rv.URLParam(WildcardParam))); err != nil { 69 | panic(err) 70 | } 71 | }) 72 | 73 | code, body := request(http.MethodGet, "/users/testwild", p) 74 | Equal(t, code, http.StatusOK) 75 | Equal(t, body, "testwild") 76 | 77 | code, body = request(http.MethodGet, "/users/testwildslash/", p) 78 | Equal(t, code, http.StatusOK) 79 | Equal(t, body, "testwildslash/") 80 | } 81 | 82 | func TestBadRoutes(t *testing.T) { 83 | p := New() 84 | 85 | PanicMatches(t, func() { p.Get("/users//:id", defaultHandler) }, "Bad path '/users//:id' contains duplicate // at index:6") 86 | } 87 | -------------------------------------------------------------------------------- /pure.go: -------------------------------------------------------------------------------- 1 | package pure 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "strings" 7 | "sync" 8 | 9 | httpext "github.com/go-playground/pkg/v5/net/http" 10 | ) 11 | 12 | var ( 13 | defaultContextIdentifier = &struct { 14 | name string 15 | }{ 16 | name: "pure", 17 | } 18 | ) 19 | 20 | // Mux is the main request multiplexer 21 | type Mux struct { 22 | routeGroup 23 | trees map[string]*node 24 | 25 | // pool is used for reusable request scoped RequestVars content 26 | pool sync.Pool 27 | 28 | http404 http.HandlerFunc // 404 Not Found 29 | http405 http.HandlerFunc // 405 Method Not Allowed 30 | httpOPTIONS http.HandlerFunc 31 | 32 | // mostParams used to keep track of the most amount of 33 | // params in any URL and this will set the default capacity 34 | // of each Params 35 | mostParams uint8 36 | 37 | // Enables automatic redirection if the current route can't be matched but a 38 | // handler for the path with (without) the trailing slash exists. 39 | // For example if /foo/ is requested but a route only exists for /foo, the 40 | // client is redirected to /foo with http status code 301 for GET requests 41 | // and 307 for all other request methods. 42 | redirectTrailingSlash bool 43 | 44 | // If enabled, the router checks if another method is allowed for the 45 | // current route, if the current request can not be routed. 46 | // If this is the case, the request is answered with 'Method Not Allowed' 47 | // and HTTP status code 405. 48 | // If no other Method is allowed, the request is delegated to the NotFound 49 | // handler. 50 | handleMethodNotAllowed bool 51 | 52 | // if enabled automatically handles OPTION requests; manually configured OPTION 53 | // handlers take presidence. default true 54 | automaticallyHandleOPTIONS bool 55 | } 56 | 57 | type urlParam struct { 58 | key string 59 | value string 60 | } 61 | 62 | type urlParams []urlParam 63 | 64 | // Get returns the URL parameter for the given key, or blank if not found 65 | func (p urlParams) Get(key string) (param string) { 66 | for i := 0; i < len(p); i++ { 67 | if p[i].key == key { 68 | param = p[i].value 69 | return 70 | } 71 | } 72 | return 73 | } 74 | 75 | // Middleware is pure's middleware definition 76 | type Middleware func(h http.HandlerFunc) http.HandlerFunc 77 | 78 | var ( 79 | default404Handler = func(w http.ResponseWriter, r *http.Request) { 80 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 81 | } 82 | 83 | methodNotAllowedHandler = func(w http.ResponseWriter, r *http.Request) { 84 | w.WriteHeader(http.StatusMethodNotAllowed) 85 | } 86 | 87 | automaticOPTIONSHandler = func(w http.ResponseWriter, r *http.Request) { 88 | w.WriteHeader(http.StatusOK) 89 | } 90 | ) 91 | 92 | // New Creates and returns a new Pure instance 93 | func New() *Mux { 94 | p := &Mux{ 95 | routeGroup: routeGroup{ 96 | middleware: make([]Middleware, 0), 97 | }, 98 | trees: make(map[string]*node), 99 | mostParams: 0, 100 | http404: default404Handler, 101 | http405: methodNotAllowedHandler, 102 | httpOPTIONS: automaticOPTIONSHandler, 103 | redirectTrailingSlash: true, 104 | handleMethodNotAllowed: false, 105 | automaticallyHandleOPTIONS: false, 106 | } 107 | p.routeGroup.pure = p 108 | p.pool.New = func() interface{} { 109 | 110 | rv := &requestVars{ 111 | params: make(urlParams, p.mostParams), 112 | } 113 | 114 | rv.ctx = context.WithValue(context.Background(), defaultContextIdentifier, rv) 115 | 116 | return rv 117 | } 118 | return p 119 | } 120 | 121 | // Register404 alows for overriding of the not found handler function. 122 | // NOTE: this is run after not finding a route even after redirecting with the trailing slash 123 | func (p *Mux) Register404(notFound http.HandlerFunc, middleware ...Middleware) { 124 | h := notFound 125 | for i := len(middleware) - 1; i >= 0; i-- { 126 | h = middleware[i](h) 127 | } 128 | p.http404 = h 129 | } 130 | 131 | // RegisterAutomaticOPTIONS tells pure whether to 132 | // automatically handle OPTION requests; manually configured 133 | // OPTION handlers take precedence. default true 134 | func (p *Mux) RegisterAutomaticOPTIONS(middleware ...Middleware) { 135 | p.automaticallyHandleOPTIONS = true 136 | h := automaticOPTIONSHandler 137 | 138 | for i := len(middleware) - 1; i >= 0; i-- { 139 | h = middleware[i](h) 140 | } 141 | p.httpOPTIONS = h 142 | } 143 | 144 | // SetRedirectTrailingSlash tells pure whether to try 145 | // and fix a URL by trying to find it 146 | // lowercase -> with or without slash -> 404 147 | func (p *Mux) SetRedirectTrailingSlash(set bool) { 148 | p.redirectTrailingSlash = set 149 | } 150 | 151 | // RegisterMethodNotAllowed tells pure whether to 152 | // handle the http 405 Method Not Allowed status code 153 | func (p *Mux) RegisterMethodNotAllowed(middleware ...Middleware) { 154 | p.handleMethodNotAllowed = true 155 | h := methodNotAllowedHandler 156 | 157 | for i := len(middleware) - 1; i >= 0; i-- { 158 | h = middleware[i](h) 159 | } 160 | p.http405 = h 161 | } 162 | 163 | // Serve returns an http.Handler to be used. 164 | func (p *Mux) Serve() http.Handler { 165 | // reserved for any logic that needs to happen before serving starts. 166 | // i.e. although this router does not use priority to determine route order 167 | // could add sorting of tree nodes here.... 168 | return http.HandlerFunc(p.serveHTTP) 169 | } 170 | 171 | // Conforms to the http.Handler interface. 172 | func (p *Mux) serveHTTP(w http.ResponseWriter, r *http.Request) { 173 | tree := p.trees[r.Method] 174 | var h http.HandlerFunc 175 | var rv *requestVars 176 | 177 | if tree != nil { 178 | if h, rv = tree.find(r.URL.Path, p); h == nil { 179 | if p.redirectTrailingSlash && len(r.URL.Path) > 1 { 180 | // find again all lowercase 181 | orig := r.URL.Path 182 | lc := strings.ToLower(orig) 183 | 184 | if lc != r.URL.Path { 185 | 186 | if h, _ = tree.find(lc, p); h != nil { 187 | r.URL.Path = lc 188 | h = p.redirect(r.Method, r.URL.String()) 189 | r.URL.Path = orig 190 | goto END 191 | } 192 | } 193 | if lc[len(lc)-1:] == basePath { 194 | lc = lc[:len(lc)-1] 195 | } else { 196 | lc = lc + basePath 197 | } 198 | if h, _ = tree.find(lc, p); h != nil { 199 | r.URL.Path = lc 200 | h = p.redirect(r.Method, r.URL.String()) 201 | r.URL.Path = orig 202 | goto END 203 | } 204 | } 205 | } else { 206 | goto END 207 | } 208 | } 209 | 210 | if p.automaticallyHandleOPTIONS && r.Method == http.MethodOptions { 211 | if r.URL.Path == "*" { // check server-wide OPTIONS 212 | 213 | for m := range p.trees { 214 | 215 | if m == http.MethodOptions { 216 | continue 217 | } 218 | 219 | w.Header().Add(httpext.Allow, m) 220 | } 221 | } else { 222 | for m, ctree := range p.trees { 223 | 224 | if m == r.Method || m == http.MethodOptions { 225 | continue 226 | } 227 | if h, _ = ctree.find(r.URL.Path, p); h != nil { 228 | w.Header().Add(httpext.Allow, m) 229 | } 230 | } 231 | } 232 | w.Header().Add(httpext.Allow, http.MethodOptions) 233 | h = p.httpOPTIONS 234 | goto END 235 | } 236 | 237 | if p.handleMethodNotAllowed { 238 | var found bool 239 | for m, ctree := range p.trees { 240 | 241 | if m == r.Method { 242 | continue 243 | } 244 | 245 | if h, _ = ctree.find(r.URL.Path, p); h != nil { 246 | w.Header().Add(httpext.Allow, m) 247 | found = true 248 | } 249 | } 250 | if found { 251 | h = p.http405 252 | goto END 253 | } 254 | } 255 | // not found 256 | h = p.http404 257 | 258 | END: 259 | 260 | if rv != nil { 261 | rv.formParsed = false 262 | // store on context 263 | r = r.WithContext(rv.ctx) 264 | } 265 | 266 | h(w, r) 267 | 268 | if rv != nil { 269 | p.pool.Put(rv) 270 | } 271 | } 272 | 273 | func (p *Mux) redirect(method string, to string) (h http.HandlerFunc) { 274 | code := http.StatusMovedPermanently 275 | if method != http.MethodGet { 276 | code = http.StatusPermanentRedirect 277 | } 278 | h = func(w http.ResponseWriter, r *http.Request) { 279 | http.Redirect(w, r, to, code) 280 | } 281 | 282 | for i := len(p.middleware) - 1; i >= 0; i-- { 283 | h = p.middleware[i](h) 284 | } 285 | return 286 | } 287 | -------------------------------------------------------------------------------- /pure_test.go: -------------------------------------------------------------------------------- 1 | package pure 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "mime/multipart" 9 | "net/http" 10 | "net/http/httptest" 11 | "strconv" 12 | "testing" 13 | 14 | . "github.com/go-playground/assert/v2" 15 | httpext "github.com/go-playground/pkg/v5/net/http" 16 | ) 17 | 18 | // NOTES: 19 | // - Run "go test" to run tests 20 | // - Run "gocov test | gocov report" to report on test converage by file 21 | // - Run "gocov test | gocov annotate -" to report on all code and functions, those ,marked with "MISS" were never called 22 | // 23 | // or 24 | // 25 | // -- may be a good idea to change to output path to somewherelike /tmp 26 | // go test -coverprofile cover.out && go tool cover -html=cover.out -o cover.html 27 | // 28 | 29 | var ( 30 | defaultHandler = func(w http.ResponseWriter, r *http.Request) { 31 | if _, err := w.Write([]byte(r.Method)); err != nil { 32 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 33 | } 34 | } 35 | 36 | idHandler = func(w http.ResponseWriter, r *http.Request) { 37 | rv := RequestVars(r) 38 | if _, err := w.Write([]byte(rv.URLParam("id"))); err != nil { 39 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 40 | } 41 | } 42 | 43 | params2Handler = func(w http.ResponseWriter, r *http.Request) { 44 | rv := RequestVars(r) 45 | if _, err := w.Write([]byte(rv.URLParam("p1") + "|" + rv.URLParam("p2"))); err != nil { 46 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 47 | } 48 | } 49 | 50 | defaultMiddleware = func(next http.HandlerFunc) http.HandlerFunc { 51 | return func(w http.ResponseWriter, r *http.Request) { 52 | next(w, r) 53 | } 54 | } 55 | ) 56 | 57 | func TestAllMethods(t *testing.T) { 58 | 59 | p := New() 60 | p.Use(func(next http.HandlerFunc) http.HandlerFunc { 61 | return func(w http.ResponseWriter, r *http.Request) { 62 | next(w, r) 63 | } 64 | }) 65 | 66 | tests := []struct { 67 | method string 68 | path string 69 | url string 70 | handler http.HandlerFunc 71 | code int 72 | body string 73 | // panicExpected bool 74 | // panicMsg string 75 | }{ 76 | { 77 | method: http.MethodGet, 78 | path: "/get", 79 | url: "/get", 80 | handler: defaultHandler, 81 | code: http.StatusOK, 82 | body: http.MethodGet, 83 | }, 84 | { 85 | method: http.MethodPost, 86 | path: "/post", 87 | url: "/post", 88 | handler: defaultHandler, 89 | code: http.StatusOK, 90 | body: http.MethodPost, 91 | }, 92 | { 93 | method: http.MethodHead, 94 | path: "/head", 95 | url: "/head", 96 | handler: defaultHandler, 97 | code: http.StatusOK, 98 | body: http.MethodHead, 99 | }, 100 | { 101 | method: http.MethodPut, 102 | path: "/put", 103 | url: "/put", 104 | handler: defaultHandler, 105 | code: http.StatusOK, 106 | body: http.MethodPut, 107 | }, 108 | { 109 | method: http.MethodDelete, 110 | path: "/delete", 111 | url: "/delete", 112 | handler: defaultHandler, 113 | code: http.StatusOK, 114 | body: http.MethodDelete, 115 | }, 116 | { 117 | method: http.MethodConnect, 118 | path: "/connect", 119 | url: "/connect", 120 | handler: defaultHandler, 121 | code: http.StatusOK, 122 | body: http.MethodConnect, 123 | }, 124 | { 125 | method: http.MethodOptions, 126 | path: "/options", 127 | url: "/options", 128 | handler: defaultHandler, 129 | code: http.StatusOK, 130 | body: http.MethodOptions, 131 | }, 132 | { 133 | method: http.MethodPatch, 134 | path: "/patch", 135 | url: "/patch", 136 | handler: defaultHandler, 137 | code: http.StatusOK, 138 | body: http.MethodPatch, 139 | }, 140 | { 141 | method: http.MethodTrace, 142 | path: "/trace", 143 | url: "/trace", 144 | handler: defaultHandler, 145 | code: http.StatusOK, 146 | body: http.MethodTrace, 147 | }, 148 | { 149 | method: "PROPFIND", 150 | path: "/propfind", 151 | url: "/propfind", 152 | handler: defaultHandler, 153 | code: http.StatusOK, 154 | body: "PROPFIND", 155 | }, 156 | { 157 | method: http.MethodGet, 158 | path: "/users/:id", 159 | url: "/users/13", 160 | handler: idHandler, 161 | code: http.StatusOK, 162 | body: "13", 163 | }, 164 | { 165 | method: http.MethodGet, 166 | path: "/2params/:p1", 167 | url: "/2params/10", 168 | handler: params2Handler, 169 | code: http.StatusOK, 170 | body: "10|", 171 | }, 172 | { 173 | method: http.MethodGet, 174 | path: "/2params/:p1/params/:p2", 175 | url: "/2params/13/params/12", 176 | handler: params2Handler, 177 | code: http.StatusOK, 178 | body: "13|12", 179 | }, 180 | { 181 | method: http.MethodGet, 182 | path: "/redirect", 183 | url: "/redirect/", 184 | handler: defaultHandler, 185 | code: http.StatusMovedPermanently, 186 | body: "", 187 | }, 188 | { 189 | method: http.MethodPost, 190 | path: "/redirect", 191 | url: "/redirect/", 192 | handler: defaultHandler, 193 | code: http.StatusPermanentRedirect, 194 | body: "", 195 | }, 196 | } 197 | 198 | for _, tt := range tests { 199 | 200 | switch tt.method { 201 | case http.MethodGet: 202 | p.Get(tt.path, tt.handler) 203 | case http.MethodPost: 204 | p.Post(tt.path, tt.handler) 205 | case http.MethodHead: 206 | p.Head(tt.path, tt.handler) 207 | case http.MethodPut: 208 | p.Put(tt.path, tt.handler) 209 | case http.MethodDelete: 210 | p.Delete(tt.path, tt.handler) 211 | case http.MethodConnect: 212 | p.Connect(tt.path, tt.handler) 213 | case http.MethodOptions: 214 | p.Options(tt.path, tt.handler) 215 | case http.MethodPatch: 216 | p.Patch(tt.path, tt.handler) 217 | case http.MethodTrace: 218 | p.Trace(tt.path, tt.handler) 219 | default: 220 | p.Handle(tt.method, tt.path, tt.handler) 221 | } 222 | } 223 | 224 | hf := p.Serve() 225 | 226 | for _, tt := range tests { 227 | 228 | req, err := http.NewRequest(tt.method, tt.url, nil) 229 | if err != nil { 230 | t.Errorf("Expected 'nil' Got '%s'", err) 231 | } 232 | 233 | res := httptest.NewRecorder() 234 | 235 | hf.ServeHTTP(res, req) 236 | 237 | if res.Code != tt.code { 238 | t.Errorf("Expected '%d' Got '%d'", tt.code, res.Code) 239 | } 240 | 241 | if len(tt.body) > 0 { 242 | 243 | b, err := ioutil.ReadAll(res.Body) 244 | if err != nil { 245 | t.Errorf("Expected 'nil' Got '%s'", err) 246 | } 247 | 248 | s := string(b) 249 | 250 | if s != tt.body { 251 | t.Errorf("Expected '%s' Got '%s'", tt.body, s) 252 | } 253 | } 254 | } 255 | 256 | // test any 257 | 258 | p2 := New() 259 | p2.Any("/test", defaultHandler) 260 | 261 | hf = p2.Serve() 262 | 263 | test2 := []struct { 264 | method string 265 | }{ 266 | { 267 | method: http.MethodConnect, 268 | }, 269 | { 270 | method: http.MethodDelete, 271 | }, 272 | { 273 | method: http.MethodGet, 274 | }, 275 | { 276 | method: http.MethodHead, 277 | }, 278 | { 279 | method: http.MethodOptions, 280 | }, 281 | { 282 | method: http.MethodPatch, 283 | }, 284 | { 285 | method: http.MethodPost, 286 | }, 287 | { 288 | method: http.MethodPut, 289 | }, 290 | { 291 | method: http.MethodTrace, 292 | }, 293 | } 294 | 295 | for _, tt := range test2 { 296 | req, err := http.NewRequest(tt.method, "/test", nil) 297 | if err != nil { 298 | t.Errorf("Expected 'nil' Got '%s'", err) 299 | } 300 | 301 | res := httptest.NewRecorder() 302 | 303 | hf.ServeHTTP(res, req) 304 | 305 | if res.Code != http.StatusOK { 306 | t.Errorf("Expected '%d' Got '%d'", http.StatusOK, res.Code) 307 | } 308 | 309 | b, err := ioutil.ReadAll(res.Body) 310 | if err != nil { 311 | t.Errorf("Expected 'nil' Got '%s'", err) 312 | } 313 | 314 | s := string(b) 315 | 316 | if s != tt.method { 317 | t.Errorf("Expected '%s' Got '%s'", tt.method, s) 318 | } 319 | } 320 | } 321 | 322 | func TestTooManyParams(t *testing.T) { 323 | 324 | s := "/" 325 | 326 | for i := 0; i < 256; i++ { 327 | s += ":id" + strconv.Itoa(i) 328 | } 329 | 330 | p := New() 331 | PanicMatches(t, func() { p.Get(s, defaultHandler) }, "too many parameters defined in path, max is 255") 332 | } 333 | 334 | func TestRouterAPI(t *testing.T) { 335 | p := New() 336 | 337 | for _, route := range githubAPI { 338 | p.handle(route.method, route.path, func(w http.ResponseWriter, r *http.Request) { 339 | if _, err := w.Write([]byte(r.URL.Path)); err != nil { 340 | panic(err) 341 | } 342 | }) 343 | } 344 | 345 | for _, route := range githubAPI { 346 | code, body := request(route.method, route.path, p) 347 | Equal(t, body, route.path) 348 | Equal(t, code, http.StatusOK) 349 | } 350 | } 351 | 352 | func TestMethodNotAllowed(t *testing.T) { 353 | p := New() 354 | p.RegisterMethodNotAllowed(defaultMiddleware) 355 | 356 | p.Put("/home/", defaultHandler) 357 | p.Post("/home/", defaultHandler) 358 | p.Head("/home/", defaultHandler) 359 | p.Delete("/home/", defaultHandler) 360 | p.Connect("/home/", defaultHandler) 361 | p.Options("/home/", defaultHandler) 362 | p.Patch("/home/", defaultHandler) 363 | p.Trace("/home/", defaultHandler) 364 | p.Handle("PROPFIND", "/home/", defaultHandler) 365 | p.Handle("PROPFIND2", "/home/", defaultHandler) 366 | 367 | code, _ := request(http.MethodPut, "/home/", p) 368 | Equal(t, code, http.StatusOK) 369 | 370 | r, _ := http.NewRequest(http.MethodGet, "/home/", nil) 371 | w := httptest.NewRecorder() 372 | p.serveHTTP(w, r) 373 | 374 | Equal(t, w.Code, http.StatusMethodNotAllowed) 375 | 376 | allow, ok := w.Header()[httpext.Allow] 377 | Equal(t, ok, true) 378 | Equal(t, len(allow), 10) 379 | 380 | r, _ = http.NewRequest("PROPFIND2", "/home/1", nil) 381 | w = httptest.NewRecorder() 382 | p.serveHTTP(w, r) 383 | 384 | Equal(t, w.Code, http.StatusNotFound) 385 | } 386 | 387 | func TestMethodNotAllowed2(t *testing.T) { 388 | p := New() 389 | p.RegisterMethodNotAllowed() 390 | 391 | p.Get("/home/", defaultHandler) 392 | p.Head("/home/", defaultHandler) 393 | 394 | code, _ := request(http.MethodGet, "/home/", p) 395 | Equal(t, code, http.StatusOK) 396 | 397 | r, _ := http.NewRequest(http.MethodPost, "/home/", nil) 398 | w := httptest.NewRecorder() 399 | p.serveHTTP(w, r) 400 | 401 | Equal(t, w.Code, http.StatusMethodNotAllowed) 402 | 403 | allow, ok := w.Header()[httpext.Allow] 404 | 405 | // Sometimes this array is out of order for whatever reason? 406 | if allow[0] == http.MethodGet { 407 | Equal(t, ok, true) 408 | Equal(t, allow[0], http.MethodGet) 409 | Equal(t, allow[1], http.MethodHead) 410 | } else { 411 | Equal(t, ok, true) 412 | Equal(t, allow[1], http.MethodGet) 413 | Equal(t, allow[0], http.MethodHead) 414 | } 415 | } 416 | 417 | func TestAutomaticallyHandleOPTIONS(t *testing.T) { 418 | 419 | p := New() 420 | p.RegisterAutomaticOPTIONS(defaultMiddleware) 421 | p.Get("/home", defaultHandler) 422 | p.Post("/home", defaultHandler) 423 | p.Delete("/home", defaultHandler) 424 | p.Head("/home", defaultHandler) 425 | p.Put("/home", defaultHandler) 426 | p.Connect("/home", defaultHandler) 427 | p.Patch("/home", defaultHandler) 428 | p.Trace("/home", defaultHandler) 429 | p.Handle("PROPFIND", "/home", defaultHandler) 430 | p.Options("/options", defaultHandler) 431 | 432 | code, _ := request(http.MethodGet, "/home", p) 433 | Equal(t, code, http.StatusOK) 434 | 435 | r, _ := http.NewRequest(http.MethodOptions, "/home", nil) 436 | w := httptest.NewRecorder() 437 | p.serveHTTP(w, r) 438 | 439 | Equal(t, w.Code, http.StatusOK) 440 | 441 | allow, ok := w.Header()[httpext.Allow] 442 | 443 | Equal(t, ok, true) 444 | Equal(t, len(allow), 10) 445 | 446 | r, _ = http.NewRequest(http.MethodOptions, "*", nil) 447 | w = httptest.NewRecorder() 448 | p.serveHTTP(w, r) 449 | 450 | Equal(t, w.Code, http.StatusOK) 451 | 452 | allow, ok = w.Header()[httpext.Allow] 453 | 454 | Equal(t, ok, true) 455 | Equal(t, len(allow), 10) 456 | } 457 | 458 | func TestRedirect(t *testing.T) { 459 | 460 | p := New() 461 | 462 | p.Get("/home/", defaultHandler) 463 | p.Post("/home/", defaultHandler) 464 | 465 | code, _ := request(http.MethodGet, "/home/", p) 466 | Equal(t, code, http.StatusOK) 467 | 468 | code, _ = request(http.MethodPost, "/home/", p) 469 | Equal(t, code, http.StatusOK) 470 | 471 | code, _ = request(http.MethodGet, "/home", p) 472 | Equal(t, code, http.StatusMovedPermanently) 473 | 474 | code, _ = request(http.MethodGet, "/Home/", p) 475 | Equal(t, code, http.StatusMovedPermanently) 476 | 477 | code, _ = request(http.MethodPost, "/home", p) 478 | Equal(t, code, http.StatusPermanentRedirect) 479 | 480 | p.SetRedirectTrailingSlash(false) 481 | 482 | code, _ = request(http.MethodGet, "/home/", p) 483 | Equal(t, code, http.StatusOK) 484 | 485 | code, _ = request(http.MethodPost, "/home/", p) 486 | Equal(t, code, http.StatusOK) 487 | 488 | code, _ = request(http.MethodGet, "/home", p) 489 | Equal(t, code, http.StatusNotFound) 490 | 491 | code, _ = request(http.MethodGet, "/Home/", p) 492 | Equal(t, code, http.StatusNotFound) 493 | 494 | code, _ = request(http.MethodPost, "/home", p) 495 | Equal(t, code, http.StatusNotFound) 496 | 497 | p.SetRedirectTrailingSlash(true) 498 | 499 | p.Get("/users/:id", defaultHandler) 500 | p.Get("/users/:id/profile", defaultHandler) 501 | 502 | code, _ = request(http.MethodGet, "/users/10", p) 503 | Equal(t, code, http.StatusOK) 504 | 505 | code, _ = request(http.MethodGet, "/users/10/", p) 506 | Equal(t, code, http.StatusMovedPermanently) 507 | 508 | p.SetRedirectTrailingSlash(false) 509 | 510 | code, _ = request(http.MethodGet, "/users/10", p) 511 | Equal(t, code, http.StatusOK) 512 | 513 | code, _ = request(http.MethodGet, "/users/10/", p) 514 | Equal(t, code, http.StatusNotFound) 515 | } 516 | 517 | func TestNotFound(t *testing.T) { 518 | 519 | notFound := func(w http.ResponseWriter, r *http.Request) { 520 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 521 | } 522 | 523 | p := New() 524 | p.Register404(notFound, func(next http.HandlerFunc) http.HandlerFunc { 525 | return func(w http.ResponseWriter, r *http.Request) { 526 | next(w, r) 527 | } 528 | }) 529 | 530 | p.Get("/home/", defaultHandler) 531 | p.Post("/home/", defaultHandler) 532 | p.Get("/users/:id", defaultHandler) 533 | p.Get("/users/:id/:id2/:id3", defaultHandler) 534 | 535 | code, _ := request("BAD_METHOD", "/home/", p) 536 | Equal(t, code, http.StatusNotFound) 537 | 538 | code, _ = request(http.MethodGet, "/users/14/more", p) 539 | Equal(t, code, http.StatusNotFound) 540 | } 541 | 542 | func TestBadAdd(t *testing.T) { 543 | fn := func(w http.ResponseWriter, r *http.Request) { 544 | if _, err := w.Write([]byte(r.Method)); err != nil { 545 | panic(err) 546 | } 547 | } 548 | 549 | p := New() 550 | PanicMatches(t, func() { p.Get("/%%%2frs#@$/", fn) }, "Query Unescape Error on path '/%%%2frs#@$/': invalid URL escape \"%%%\"") 551 | 552 | // bad existing params 553 | 554 | p.Get("/user/:id", fn) 555 | PanicMatches(t, func() { p.Get("/user/:user_id/profile", fn) }, "path segment ':user_id/profile' conflicts with existing wildcard ':id' in path '/user/:user_id/profile'") 556 | p.Get("/user/:id/profile", fn) 557 | 558 | p.Get("/admin/:id/profile", fn) 559 | PanicMatches(t, func() { p.Get("/admin/:admin_id", fn) }, "path segment ':admin_id' conflicts with existing wildcard ':id' in path '/admin/:admin_id'") 560 | 561 | PanicMatches(t, func() { p.Get("/assets/*/test", fn) }, "Character after the * symbol is not permitted, path '/assets/*/test'") 562 | 563 | p.Get("/superhero/*", fn) 564 | PanicMatches(t, func() { p.Get("/superhero/:id", fn) }, "path segment '/:id' conflicts with existing wildcard '/*' in path '/superhero/:id'") 565 | PanicMatches(t, func() { p.Get("/superhero/*", fn) }, "handlers are already registered for path '/superhero/*'") 566 | PanicMatches(t, func() { p.Get("/superhero/:id/", fn) }, "path segment '/:id/' conflicts with existing wildcard '/*' in path '/superhero/:id/'") 567 | 568 | p.Get("/supervillain/:id", fn) 569 | PanicMatches(t, func() { p.Get("/supervillain/*", fn) }, "path segment '*' conflicts with existing wildcard ':id' in path '/supervillain/*'") 570 | PanicMatches(t, func() { p.Get("/supervillain/:id", fn) }, "handlers are already registered for path '/supervillain/:id'") 571 | } 572 | 573 | func TestBasePath(t *testing.T) { 574 | 575 | p := New() 576 | p.Get("", defaultHandler) 577 | 578 | code, _ := request(http.MethodGet, "/", p) 579 | Equal(t, code, http.StatusOK) 580 | 581 | } 582 | 583 | type zombie struct { 584 | ID int `json:"id" xml:"id"` 585 | Name string `json:"name" xml:"name"` 586 | } 587 | 588 | type route struct { 589 | method string 590 | path string 591 | } 592 | 593 | var githubAPI = []route{ 594 | // OAuth Authorizations 595 | {"GET", "/authorizations"}, 596 | {"GET", "/authorizations/:id"}, 597 | {"POST", "/authorizations"}, 598 | //{"PUT", "/authorizations/clients/:client_id"}, 599 | //{"PATCH", "/authorizations/:id"}, 600 | {"DELETE", "/authorizations/:id"}, 601 | {"GET", "/applications/:client_id/tokens/:access_token"}, 602 | {"DELETE", "/applications/:client_id/tokens"}, 603 | {"DELETE", "/applications/:client_id/tokens/:access_token"}, 604 | 605 | // Activity 606 | {"GET", "/events"}, 607 | {"GET", "/repos/:owner/:repo/events"}, 608 | {"GET", "/networks/:owner/:repo/events"}, 609 | {"GET", "/orgs/:org/events"}, 610 | {"GET", "/users/:user/received_events"}, 611 | {"GET", "/users/:user/received_events/public"}, 612 | {"GET", "/users/:user/events"}, 613 | {"GET", "/users/:user/events/public"}, 614 | {"GET", "/users/:user/events/orgs/:org"}, 615 | {"GET", "/feeds"}, 616 | {"GET", "/notifications"}, 617 | {"GET", "/repos/:owner/:repo/notifications"}, 618 | {"PUT", "/notifications"}, 619 | {"PUT", "/repos/:owner/:repo/notifications"}, 620 | {"GET", "/notifications/threads/:id"}, 621 | //{"PATCH", "/notifications/threads/:id"}, 622 | {"GET", "/notifications/threads/:id/subscription"}, 623 | {"PUT", "/notifications/threads/:id/subscription"}, 624 | {"DELETE", "/notifications/threads/:id/subscription"}, 625 | {"GET", "/repos/:owner/:repo/stargazers"}, 626 | {"GET", "/users/:user/starred"}, 627 | {"GET", "/user/starred"}, 628 | {"GET", "/user/starred/:owner/:repo"}, 629 | {"PUT", "/user/starred/:owner/:repo"}, 630 | {"DELETE", "/user/starred/:owner/:repo"}, 631 | {"GET", "/repos/:owner/:repo/subscribers"}, 632 | {"GET", "/users/:user/subscriptions"}, 633 | {"GET", "/user/subscriptions"}, 634 | {"GET", "/repos/:owner/:repo/subscription"}, 635 | {"PUT", "/repos/:owner/:repo/subscription"}, 636 | {"DELETE", "/repos/:owner/:repo/subscription"}, 637 | {"GET", "/user/subscriptions/:owner/:repo"}, 638 | {"PUT", "/user/subscriptions/:owner/:repo"}, 639 | {"DELETE", "/user/subscriptions/:owner/:repo"}, 640 | 641 | // Gists 642 | {"GET", "/users/:user/gists"}, 643 | {"GET", "/gists"}, 644 | //{"GET", "/gists/public"}, 645 | //{"GET", "/gists/starred"}, 646 | {"GET", "/gists/:id"}, 647 | {"POST", "/gists"}, 648 | //{"PATCH", "/gists/:id"}, 649 | {"PUT", "/gists/:id/star"}, 650 | {"DELETE", "/gists/:id/star"}, 651 | {"GET", "/gists/:id/star"}, 652 | {"POST", "/gists/:id/forks"}, 653 | {"DELETE", "/gists/:id"}, 654 | 655 | // Git Data 656 | {"GET", "/repos/:owner/:repo/git/blobs/:sha"}, 657 | {"POST", "/repos/:owner/:repo/git/blobs"}, 658 | {"GET", "/repos/:owner/:repo/git/commits/:sha"}, 659 | {"POST", "/repos/:owner/:repo/git/commits"}, 660 | //{"GET", "/repos/:owner/:repo/git/refs/*ref"}, 661 | {"GET", "/repos/:owner/:repo/git/refs"}, 662 | {"POST", "/repos/:owner/:repo/git/refs"}, 663 | //{"PATCH", "/repos/:owner/:repo/git/refs/*ref"}, 664 | //{"DELETE", "/repos/:owner/:repo/git/refs/*ref"}, 665 | {"GET", "/repos/:owner/:repo/git/tags/:sha"}, 666 | {"POST", "/repos/:owner/:repo/git/tags"}, 667 | {"GET", "/repos/:owner/:repo/git/trees/:sha"}, 668 | {"POST", "/repos/:owner/:repo/git/trees"}, 669 | 670 | // Issues 671 | {"GET", "/issues"}, 672 | {"GET", "/user/issues"}, 673 | {"GET", "/orgs/:org/issues"}, 674 | {"GET", "/repos/:owner/:repo/issues"}, 675 | {"GET", "/repos/:owner/:repo/issues/:number"}, 676 | {"POST", "/repos/:owner/:repo/issues"}, 677 | //{"PATCH", "/repos/:owner/:repo/issues/:number"}, 678 | {"GET", "/repos/:owner/:repo/assignees"}, 679 | {"GET", "/repos/:owner/:repo/assignees/:assignee"}, 680 | {"GET", "/repos/:owner/:repo/issues/:number/comments"}, 681 | //{"GET", "/repos/:owner/:repo/issues/comments"}, 682 | //{"GET", "/repos/:owner/:repo/issues/comments/:id"}, 683 | {"POST", "/repos/:owner/:repo/issues/:number/comments"}, 684 | //{"PATCH", "/repos/:owner/:repo/issues/comments/:id"}, 685 | //{"DELETE", "/repos/:owner/:repo/issues/comments/:id"}, 686 | {"GET", "/repos/:owner/:repo/issues/:number/events"}, 687 | //{"GET", "/repos/:owner/:repo/issues/events"}, 688 | //{"GET", "/repos/:owner/:repo/issues/events/:id"}, 689 | {"GET", "/repos/:owner/:repo/labels"}, 690 | {"GET", "/repos/:owner/:repo/labels/:name"}, 691 | {"POST", "/repos/:owner/:repo/labels"}, 692 | //{"PATCH", "/repos/:owner/:repo/labels/:name"}, 693 | {"DELETE", "/repos/:owner/:repo/labels/:name"}, 694 | {"GET", "/repos/:owner/:repo/issues/:number/labels"}, 695 | {"POST", "/repos/:owner/:repo/issues/:number/labels"}, 696 | {"DELETE", "/repos/:owner/:repo/issues/:number/labels/:name"}, 697 | {"PUT", "/repos/:owner/:repo/issues/:number/labels"}, 698 | {"DELETE", "/repos/:owner/:repo/issues/:number/labels"}, 699 | {"GET", "/repos/:owner/:repo/milestones/:number/labels"}, 700 | {"GET", "/repos/:owner/:repo/milestones"}, 701 | {"GET", "/repos/:owner/:repo/milestones/:number"}, 702 | {"POST", "/repos/:owner/:repo/milestones"}, 703 | //{"PATCH", "/repos/:owner/:repo/milestones/:number"}, 704 | {"DELETE", "/repos/:owner/:repo/milestones/:number"}, 705 | 706 | // Miscellaneous 707 | {"GET", "/emojis"}, 708 | {"GET", "/gitignore/templates"}, 709 | {"GET", "/gitignore/templates/:name"}, 710 | {"POST", "/markdown"}, 711 | {"POST", "/markdown/raw"}, 712 | {"GET", "/meta"}, 713 | {"GET", "/rate_limit"}, 714 | 715 | // Organizations 716 | {"GET", "/users/:user/orgs"}, 717 | {"GET", "/user/orgs"}, 718 | {"GET", "/orgs/:org"}, 719 | //{"PATCH", "/orgs/:org"}, 720 | {"GET", "/orgs/:org/members"}, 721 | {"GET", "/orgs/:org/members/:user"}, 722 | {"DELETE", "/orgs/:org/members/:user"}, 723 | {"GET", "/orgs/:org/public_members"}, 724 | {"GET", "/orgs/:org/public_members/:user"}, 725 | {"PUT", "/orgs/:org/public_members/:user"}, 726 | {"DELETE", "/orgs/:org/public_members/:user"}, 727 | {"GET", "/orgs/:org/teams"}, 728 | {"GET", "/teams/:id"}, 729 | {"POST", "/orgs/:org/teams"}, 730 | //{"PATCH", "/teams/:id"}, 731 | {"DELETE", "/teams/:id"}, 732 | {"GET", "/teams/:id/members"}, 733 | {"GET", "/teams/:id/members/:user"}, 734 | {"PUT", "/teams/:id/members/:user"}, 735 | {"DELETE", "/teams/:id/members/:user"}, 736 | {"GET", "/teams/:id/repos"}, 737 | {"GET", "/teams/:id/repos/:owner/:repo"}, 738 | {"PUT", "/teams/:id/repos/:owner/:repo"}, 739 | {"DELETE", "/teams/:id/repos/:owner/:repo"}, 740 | {"GET", "/user/teams"}, 741 | 742 | // Pull Requests 743 | {"GET", "/repos/:owner/:repo/pulls"}, 744 | {"GET", "/repos/:owner/:repo/pulls/:number"}, 745 | {"POST", "/repos/:owner/:repo/pulls"}, 746 | //{"PATCH", "/repos/:owner/:repo/pulls/:number"}, 747 | {"GET", "/repos/:owner/:repo/pulls/:number/commits"}, 748 | {"GET", "/repos/:owner/:repo/pulls/:number/files"}, 749 | {"GET", "/repos/:owner/:repo/pulls/:number/merge"}, 750 | {"PUT", "/repos/:owner/:repo/pulls/:number/merge"}, 751 | {"GET", "/repos/:owner/:repo/pulls/:number/comments"}, 752 | //{"GET", "/repos/:owner/:repo/pulls/comments"}, 753 | //{"GET", "/repos/:owner/:repo/pulls/comments/:number"}, 754 | {"PUT", "/repos/:owner/:repo/pulls/:number/comments"}, 755 | //{"PATCH", "/repos/:owner/:repo/pulls/comments/:number"}, 756 | //{"DELETE", "/repos/:owner/:repo/pulls/comments/:number"}, 757 | 758 | // Repositories 759 | {"GET", "/user/repos"}, 760 | {"GET", "/users/:user/repos"}, 761 | {"GET", "/orgs/:org/repos"}, 762 | {"GET", "/repositories"}, 763 | {"POST", "/user/repos"}, 764 | {"POST", "/orgs/:org/repos"}, 765 | {"GET", "/repos/:owner/:repo"}, 766 | //{"PATCH", "/repos/:owner/:repo"}, 767 | {"GET", "/repos/:owner/:repo/contributors"}, 768 | {"GET", "/repos/:owner/:repo/languages"}, 769 | {"GET", "/repos/:owner/:repo/teams"}, 770 | {"GET", "/repos/:owner/:repo/tags"}, 771 | {"GET", "/repos/:owner/:repo/branches"}, 772 | {"GET", "/repos/:owner/:repo/branches/:branch"}, 773 | {"DELETE", "/repos/:owner/:repo"}, 774 | {"GET", "/repos/:owner/:repo/collaborators"}, 775 | {"GET", "/repos/:owner/:repo/collaborators/:user"}, 776 | {"PUT", "/repos/:owner/:repo/collaborators/:user"}, 777 | {"DELETE", "/repos/:owner/:repo/collaborators/:user"}, 778 | {"GET", "/repos/:owner/:repo/comments"}, 779 | {"GET", "/repos/:owner/:repo/commits/:sha/comments"}, 780 | {"POST", "/repos/:owner/:repo/commits/:sha/comments"}, 781 | {"GET", "/repos/:owner/:repo/comments/:id"}, 782 | //{"PATCH", "/repos/:owner/:repo/comments/:id"}, 783 | {"DELETE", "/repos/:owner/:repo/comments/:id"}, 784 | {"GET", "/repos/:owner/:repo/commits"}, 785 | {"GET", "/repos/:owner/:repo/commits/:sha"}, 786 | {"GET", "/repos/:owner/:repo/readme"}, 787 | //{"GET", "/repos/:owner/:repo/contents/*path"}, 788 | //{"PUT", "/repos/:owner/:repo/contents/*path"}, 789 | //{"DELETE", "/repos/:owner/:repo/contents/*path"}, 790 | //{"GET", "/repos/:owner/:repo/:archive_format/:ref"}, 791 | {"GET", "/repos/:owner/:repo/keys"}, 792 | {"GET", "/repos/:owner/:repo/keys/:id"}, 793 | {"POST", "/repos/:owner/:repo/keys"}, 794 | //{"PATCH", "/repos/:owner/:repo/keys/:id"}, 795 | {"DELETE", "/repos/:owner/:repo/keys/:id"}, 796 | {"GET", "/repos/:owner/:repo/downloads"}, 797 | {"GET", "/repos/:owner/:repo/downloads/:id"}, 798 | {"DELETE", "/repos/:owner/:repo/downloads/:id"}, 799 | {"GET", "/repos/:owner/:repo/forks"}, 800 | {"POST", "/repos/:owner/:repo/forks"}, 801 | {"GET", "/repos/:owner/:repo/hooks"}, 802 | {"GET", "/repos/:owner/:repo/hooks/:id"}, 803 | {"POST", "/repos/:owner/:repo/hooks"}, 804 | //{"PATCH", "/repos/:owner/:repo/hooks/:id"}, 805 | {"POST", "/repos/:owner/:repo/hooks/:id/tests"}, 806 | {"DELETE", "/repos/:owner/:repo/hooks/:id"}, 807 | {"POST", "/repos/:owner/:repo/merges"}, 808 | {"GET", "/repos/:owner/:repo/releases"}, 809 | {"GET", "/repos/:owner/:repo/releases/:id"}, 810 | {"POST", "/repos/:owner/:repo/releases"}, 811 | //{"PATCH", "/repos/:owner/:repo/releases/:id"}, 812 | {"DELETE", "/repos/:owner/:repo/releases/:id"}, 813 | {"GET", "/repos/:owner/:repo/releases/:id/assets"}, 814 | {"GET", "/repos/:owner/:repo/stats/contributors"}, 815 | {"GET", "/repos/:owner/:repo/stats/commit_activity"}, 816 | {"GET", "/repos/:owner/:repo/stats/code_frequency"}, 817 | {"GET", "/repos/:owner/:repo/stats/participation"}, 818 | {"GET", "/repos/:owner/:repo/stats/punch_card"}, 819 | {"GET", "/repos/:owner/:repo/statuses/:ref"}, 820 | {"POST", "/repos/:owner/:repo/statuses/:ref"}, 821 | 822 | // Search 823 | {"GET", "/search/repositories"}, 824 | {"GET", "/search/code"}, 825 | {"GET", "/search/issues"}, 826 | {"GET", "/search/users"}, 827 | {"GET", "/legacy/issues/search/:owner/:repository/:state/:keyword"}, 828 | {"GET", "/legacy/repos/search/:keyword"}, 829 | {"GET", "/legacy/user/search/:keyword"}, 830 | {"GET", "/legacy/user/email/:email"}, 831 | 832 | // Users 833 | {"GET", "/users/:user"}, 834 | {"GET", "/user"}, 835 | //{"PATCH", "/user"}, 836 | {"GET", "/users"}, 837 | {"GET", "/user/emails"}, 838 | {"POST", "/user/emails"}, 839 | {"DELETE", "/user/emails"}, 840 | {"GET", "/users/:user/followers"}, 841 | {"GET", "/user/followers"}, 842 | {"GET", "/users/:user/following"}, 843 | {"GET", "/user/following"}, 844 | {"GET", "/user/following/:user"}, 845 | {"GET", "/users/:user/following/:target_user"}, 846 | {"PUT", "/user/following/:user"}, 847 | {"DELETE", "/user/following/:user"}, 848 | {"GET", "/users/:user/keys"}, 849 | {"GET", "/user/keys"}, 850 | {"GET", "/user/keys/:id"}, 851 | {"POST", "/user/keys"}, 852 | //{"PATCH", "/user/keys/:id"}, 853 | {"DELETE", "/user/keys/:id"}, 854 | } 855 | 856 | type closeNotifyingRecorder struct { 857 | *httptest.ResponseRecorder 858 | closed chan bool 859 | } 860 | 861 | func (c *closeNotifyingRecorder) Close() { 862 | c.closed <- true 863 | } 864 | 865 | func (c *closeNotifyingRecorder) CloseNotify() <-chan bool { 866 | return c.closed 867 | } 868 | 869 | func request(method, path string, p *Mux) (int, string) { 870 | r, _ := http.NewRequest(method, path, nil) 871 | w := &closeNotifyingRecorder{ 872 | httptest.NewRecorder(), 873 | make(chan bool, 1), 874 | } 875 | hf := p.Serve() 876 | hf.ServeHTTP(w, r) 877 | return w.Code, w.Body.String() 878 | } 879 | 880 | func requestMultiPart(method string, url string, p *Mux) (int, string) { 881 | 882 | body := &bytes.Buffer{} 883 | writer := multipart.NewWriter(body) 884 | 885 | part, err := writer.CreateFormFile("file", "test.txt") 886 | if err != nil { 887 | fmt.Println("ERR FILE:", err) 888 | } 889 | 890 | buff := bytes.NewBufferString("FILE TEST DATA") 891 | _, err = io.Copy(part, buff) 892 | if err != nil { 893 | fmt.Println("ERR COPY:", err) 894 | } 895 | 896 | err = writer.WriteField("username", "joeybloggs") 897 | if err != nil { 898 | fmt.Println("ERR:", err) 899 | } 900 | 901 | err = writer.Close() 902 | if err != nil { 903 | fmt.Println("ERR:", err) 904 | } 905 | 906 | r, _ := http.NewRequest(method, url, body) 907 | r.Header.Set(httpext.ContentType, writer.FormDataContentType()) 908 | wr := &closeNotifyingRecorder{ 909 | httptest.NewRecorder(), 910 | make(chan bool, 1), 911 | } 912 | hf := p.Serve() 913 | hf.ServeHTTP(wr, r) 914 | 915 | return wr.Code, wr.Body.String() 916 | } 917 | -------------------------------------------------------------------------------- /request_vars.go: -------------------------------------------------------------------------------- 1 | package pure 2 | 3 | import "context" 4 | 5 | // ReqVars is the interface of request scoped variables 6 | // tracked by pure 7 | type ReqVars interface { 8 | URLParam(pname string) string 9 | } 10 | 11 | type requestVars struct { 12 | ctx context.Context // holds a copy of it's parent requestVars 13 | params urlParams 14 | formParsed bool 15 | } 16 | 17 | // Params returns the current routes Params 18 | func (r *requestVars) URLParam(pname string) string { 19 | return r.params.Get(pname) 20 | } 21 | -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | package pure 2 | 3 | func min(a, b int) int { 4 | if a <= b { 5 | return a 6 | } 7 | return b 8 | } 9 | 10 | func countParams(path string) uint8 { 11 | var n uint // add one just as a buffer 12 | for i := 0; i < len(path); i++ { 13 | if path[i] == paramByte || path[i] == wildByte { 14 | n++ 15 | } 16 | } 17 | if n >= 255 { 18 | panic("too many parameters defined in path, max is 255") 19 | } 20 | return uint8(n) 21 | } 22 | --------------------------------------------------------------------------------