├── .github └── workflows │ └── test.yaml ├── LICENSE ├── README.md ├── context.go ├── context_test.go ├── core.go ├── example ├── 01-simple │ └── main.go ├── 02-group │ ├── main.go │ ├── v2.go │ └── v3.go ├── 03-middleware │ └── main.go └── 04-error-handler │ └── main.go ├── go.mod ├── go.sum ├── group.go ├── malloc_test.go ├── middleware.go ├── middleware_test.go ├── mux.go ├── param.go ├── param_test.go ├── path.go ├── path_test.go ├── radix.go ├── radix_test.go ├── reversebuffer.go ├── reversebuffer_test.go ├── router.go ├── router_bench_test.go ├── router_test.go ├── routescanner.go ├── routescanner_test.go ├── server.go ├── types.go ├── unholy_1.19.go └── unholy_1.20.go /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | branches: 7 | - main 8 | push: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | test: 14 | strategy: 15 | matrix: 16 | go: [ 1.18.x, 1.19.x, 1.20.x, 1.21.x ] 17 | os: [ ubuntu-latest, macos-latest, windows-latest ] 18 | name: Go ${{ matrix.go }} / ${{ matrix.os }} 19 | runs-on: ${{ matrix.os }} 20 | steps: 21 | - uses: actions/checkout@v3 22 | 23 | - name: Setup Go ${{ matrix.go }} on ${{ matrix.os }} 24 | uses: actions/setup-go@v3 25 | with: 26 | go-version: ${{ matrix.go }} 27 | cache: true 28 | 29 | - name: Test 30 | run: go test -v -run '.*Malloc.*' ; go test -race -v ./... -coverprofile ./coverage.txt 31 | 32 | - name: Upload coverage reports to Codecov 33 | uses: codecov/codecov-action@v3 34 | with: 35 | token: ${{ secrets.CODECOV_TOKEN }} 36 | files: ./coverage.txt -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Mohammed Yousuf 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## `shift`: high-performance HTTP router for Go 2 | 3 | [![Go Reference](https://pkg.go.dev/badge/github.com/yousuf64/shift.svg)](https://pkg.go.dev/github.com/yousuf64/shift) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/yousuf64/shift)](https://goreportcard.com/report/github.com/yousuf64/shift) 5 | [![codecov](https://codecov.io/gh/yousuf64/shift/branch/main/graph/badge.svg?token=NK2KPJNYVA)](https://codecov.io/gh/yousuf64/shift) 6 | 7 | High-performance HTTP router for Go, with a focus on speed, simplicity, and ease-of-use. 8 | 9 | ``` 10 | go get -u github.com/yousuf64/shift 11 | ``` 12 | 13 | At the core of its performance, `shift` uses a powerful combination of radix trees and hash maps, setting the standard for lightning-fast routing. 14 | 15 | Why `shift`? 16 | 17 | * `shift` is faster than other mainstream HTTP routers. 18 | * Unlike other fast routers, `shift` strives to remain idiomatic and close to the standard library as much as possible. 19 | * Its primary focus is on routing requests quickly and efficiently, without attempting to become a full-fledged framework. 20 | * Despite its simplicity, `shift` offers powerful routing capabilities. 21 | * `shift` is compatible with `net/http` request handlers and middlewares. 22 | 23 | ## Benchmarks 24 | `shift` is benchmarked against Gin and Echo in the [benchmark suite](https://github.com/yousuf64/http-routing-benchmark/). 25 | 26 | The benchmark suite is also available as a [GitHub Action](https://github.com/yousuf64/http-routing-benchmark/actions/workflows/benchmark.yaml). 27 | 28 | ### Results 29 | Comparison between `shift`, `gin` and `echo` as of Feb 27, 2023 on Go 1.19.4 (windows/amd64) 30 | 31 | Benchmark system specifications: 32 | * 12th Gen Intel Core i7-1265U vPro (12 MB cache, 10 cores, up to 4.80 GHz Turbo) 33 | * 32 GB (2x 16 GB), DDR4-3200 34 | * Windows 10 Enterprise 22H2 35 | * Go 1.19.4 (windows/amd64) 36 | 37 | ``` 38 | BenchmarkShift_CaseInsensitiveAll-12 1750636 635.6 ns/op 0 B/op 0 allocs/op 39 | BenchmarkGin_CaseInsensitiveAll-12 1000000 1066 ns/op 0 B/op 0 allocs/op 40 | BenchmarkShift_GithubAll-12 79966 14575 ns/op 0 B/op 0 allocs/op 41 | BenchmarkGin_GithubAll-12 49107 25962 ns/op 9911 B/op 154 allocs/op 42 | BenchmarkEcho_GithubAll-12 54187 26318 ns/op 0 B/op 0 allocs/op 43 | BenchmarkShift_GPlusAll-12 2492064 632.7 ns/op 0 B/op 0 allocs/op 44 | BenchmarkGin_GPlusAll-12 1415556 837.9 ns/op 0 B/op 0 allocs/op 45 | BenchmarkEcho_GPlusAll-12 1000000 1154 ns/op 0 B/op 0 allocs/op 46 | BenchmarkShift_OverlappingRoutesAll-12 923211 1174 ns/op 0 B/op 0 allocs/op 47 | BenchmarkGin_OverlappingRoutesAll-12 352972 4029 ns/op 1953 B/op 32 allocs/op 48 | BenchmarkEcho_OverlappingRoutesAll-12 552678 2310 ns/op 0 B/op 0 allocs/op 49 | BenchmarkShift_ParseAll-12 1490170 838.6 ns/op 0 B/op 0 allocs/op 50 | BenchmarkGin_ParseAll-12 748366 1492 ns/op 0 B/op 0 allocs/op 51 | BenchmarkEcho_ParseAll-12 697556 1829 ns/op 0 B/op 0 allocs/op 52 | BenchmarkShift_RandomAll-12 817633 1241 ns/op 0 B/op 0 allocs/op 53 | BenchmarkGin_RandomAll-12 292681 4675 ns/op 2201 B/op 34 allocs/op 54 | BenchmarkEcho_RandomAll-12 428557 2717 ns/op 0 B/op 0 allocs/op 55 | BenchmarkShift_StaticAll-12 452316 2595 ns/op 0 B/op 0 allocs/op 56 | BenchmarkGin_StaticAll-12 128896 9701 ns/op 0 B/op 0 allocs/op 57 | BenchmarkEcho_StaticAll-12 106158 10877 ns/op 0 B/op 0 allocs/op 58 | ``` 59 | 60 | * Column 1: Benchmark name 61 | * Column 2: Number of iterations, higher means more confident result 62 | * Column 3: Nanoseconds elapsed per operation (ns/op), lower is better 63 | * Column 4: Number of bytes allocated on heap per operation (B/op), lower is better 64 | * Column 5: Average allocations per operation (allocs/op), lower is better 65 | 66 | ## Features 67 | * Fast and zero heap allocations. 68 | * Middleware support. 69 | * Compatible with `net/http` request handlers and middlewares. 70 | * Route grouping. 71 | * Allows declaring custom HTTP methods. 72 | * Powerful routing system that includes: 73 | * Route prioritization (Static > Param > Wildcard in that order). 74 | * Case-insensitive route matching. 75 | * Trailing slash with (or without) route matching. 76 | * Path autocorrection. 77 | * Allows conflicting/overlapping routes (`/posts/:id` and `/posts/export` can exist together). 78 | * Allows different param names over the same path (`/users/:name` and `/users/:id/delete` can exist without param name conflicts). 79 | * Mid-segment params (`/v:version/jobs`, `/stream_*url`). 80 | * Lightweight. 81 | * Has zero external dependencies. 82 | 83 | ## Quick Start 84 | Minimum Go version: `1.18` 85 | 86 | To install `shift`, simply run: 87 | ``` 88 | go get -u github.com/yousuf64/shift 89 | ``` 90 | 91 | Using `shift` is easy. Here's a simple example: 92 | 93 | ```go 94 | package main 95 | 96 | import ( 97 | "fmt" 98 | "github.com/yousuf64/shift" 99 | "net/http" 100 | ) 101 | 102 | func main() { 103 | router := shift.New() 104 | 105 | router.GET("/", func(w http.ResponseWriter, r *http.Request, route shift.Route) error { 106 | _, err := fmt.Fprint(w, "Hello, world!") 107 | return err 108 | }) 109 | 110 | http.ListenAndServe(":8080", router.Serve()) 111 | } 112 | ``` 113 | 114 | In this example, we create a `shift` router, define a GET route for the root path, and start an HTTP server to listen for incoming requests on port `8080`. 115 | 116 | ## Routing System 117 | `shift` boasts a highly powerful and flexible routing system. 118 | ``` 119 | > Pattern: /foo 120 | /foo match 121 | / no match 122 | /foo/foo no match 123 | 124 | > Pattern: /user/:name 125 | /user/saul match 126 | /user/saul/foo no match 127 | /user/ no match 128 | /user no match 129 | 130 | > Pattern: /user:name 131 | /usersaul match 132 | /user no match 133 | 134 | > Pattern: /user:fname:lname (not allowed, allows only one param within a segment '/.../') 135 | 136 | > Pattern: /stream/*path 137 | /stream/foo/bar/abc.mp4 match 138 | /stream/foo match 139 | /stream/ match 140 | /stream no match 141 | 142 | > Pattern: /stream*path 143 | /streamfoo/bar/abc.mp4 match 144 | /streamfoo match 145 | /stream match 146 | /strea no match 147 | 148 | > Pattern: /*url*directory (not allowed, allows only one wildcard param per route) 149 | ``` 150 | 151 | ## Request Handler 152 | `shift` uses a slightly modified version of the `net/http` request handler, which includes an additional parameter providing route information. 153 | Moreover, the `shift` request handler can return an error, making it convenient to handle errors in middleware without cluttering the handlers. 154 | 155 | ```go 156 | func(w http.ResponseWriter, r *http.Request, route shift.Route) error { 157 | _, err := fmt.Fprintf(w, "Hello 👋") 158 | return err 159 | } 160 | ``` 161 | 162 | You can also use `net/http` request handlers using `HTTPHandlerFunc` adapter. 163 | 164 | ```go 165 | package main 166 | 167 | import ( 168 | "fmt" 169 | "github.com/yousuf64/shift" 170 | "net/http" 171 | ) 172 | 173 | func main() { 174 | router := shift.New() 175 | 176 | // Wrap the net/http handler in HTTPHandlerFunc 177 | router.GET("/", shift.HTTPHandlerFunc(HelloHandler)) 178 | 179 | // ... 180 | } 181 | 182 | func HelloHandler(w http.ResponseWriter, r *http.Request) { 183 | _, _ = fmt.Fprintf(w, "👋👋👋") 184 | } 185 | ``` 186 | 187 | To retrieve route information from a `net/http` request handler, follow these steps: 188 | 1. Attach the `RouteContext` middleware to the router, which will pack route information into the `http.Request` context. 189 | 2. In the request handler, use the `RouteOf()` function to retrieve the `Route` object from the `http.Request` context. 190 | 191 | ```go 192 | router := shift.New() 193 | router.Use(shift.RouteContext()) 194 | router.GET("/hello/:name", shift.HTTPHandlerFunc(HelloUserHandler)) 195 | 196 | func HelloUserHandler(w http.ResponseWriter, r *http.Request) { 197 | route := shift.RouteOf(r) 198 | _, _ = fmt.Fprintf(w, "Hello, %s 😎 from %s route", route.Params.Get("name"), route.Path) 199 | // Writes 'Hello, Max 😎 from /hello/:name route' 200 | } 201 | ``` 202 | 203 | ## Middlewares 204 | `shift` supports both `shift`-style and `net/http`-style middlewares, allowing you to attach any `net/http` compatible middleware. 205 | * The `shift` middleware signature is: `func(next shift.HandlerFunc) shift.HandlerFunc` 206 | * The `net/http` middleware signature is: `func(next http.Handler) http.Handler` 207 | 208 | Middlewares can be scoped to all routes, to a specific group, or even to a single route. 209 | 210 | ```go 211 | func main() { 212 | router := shift.New() 213 | 214 | // Attaches to routes declared after Router.Use() statement. 215 | router.Use(AuthMiddleware, shift.HTTPMiddlewareFunc(TraceMiddleware)) 216 | 217 | router.GET("/", Hello) 218 | router.POST("/users", CreateUser) 219 | 220 | // Attaches to routes declared within the group. 221 | router.With(LoggerMiddleware).Group("/posts", PostsGroup) 222 | 223 | // Attaches only to the chained route. 224 | router.With(CORSMiddleware).GET("/comments", GetComments) 225 | 226 | // ... 227 | } 228 | 229 | func AuthMiddleware(next shift.HandlerFunc) shift.HandlerFunc { 230 | return func(w http.ResponseWriter, r *http.Request, route shift.Route) error { 231 | // Authorization logic... 232 | 233 | // You can conditionally circuit break from a middleware by returning before calling next(). 234 | if someCondition { 235 | return nil 236 | } 237 | 238 | return next(w, r, route) 239 | } 240 | } 241 | 242 | func TraceMiddleware(next http.Handler) http.Handler { ... } 243 | 244 | func LoggerMiddleware(next shift.HandlerFunc) shift.HandlerFunc { ... } 245 | 246 | func CORSMiddleware(next shift.HandlerFunc) shift.HandlerFunc { ... } 247 | ``` 248 | 249 | Note: 250 | * `Router.Use()` can also be used within a group. It will attach the provided middlewares to the routes declared within the group after the `Router.Use()` statement. 251 | * `HTTPMiddlewareFunc` adapter can be used to attach `net/http` middleware. 252 | 253 | ### Built-in Middlewares 254 | 255 | | Middleware handler | Description | 256 | |--------------------|---------------------------------------------------------| 257 | | RouteContext | Packs route information into `http.Request` context | 258 | | Recover | Gracefully handle panics | 259 | 260 | ### Writing Custom Middleware 261 | Check out [middleware examples](/example/03-middleware/main.go). 262 | 263 | ## Not Found Handler 264 | By default, when a matching route is not found, it replies to the request with an HTTP 404 (Not Found) error. 265 | 266 | Use `Router.UseNotFoundHandler()` to register a custom not found handler. 267 | 268 | ```go 269 | router.UseNotFoundHandler(func(w http.ResponseWriter, r *http.Request) { 270 | w.WriteHeader(410) // Replies with a 410 error. 271 | }) 272 | ``` 273 | 274 | ## Method Not Allowed Handler 275 | With this feature enabled, the router will check for matching routes for other HTTP methods when a matching route is not found. 276 | If any are found, it replies with an HTTP 405 (Method Not Allowed) status code and includes the allowed methods in the `Allow` header. 277 | 278 | Use `Router.UseMethodNotAllowedHandler()` to enable this feature. 279 | 280 | ```go 281 | router := shift.New() 282 | router.UseMethodNotAllowedHandler() 283 | 284 | router.GET("/cake", GetCakeHandler) 285 | router.POST("/cake", PostCakeHandler) 286 | ``` 287 | 288 | On `PUT /cake` request, since a `PUT` route is not registered for the `/cake` path, 289 | the router will reply with an HTTP 405 (Method Not Allowed) status code and `GET, POST` in the `Allow` header. 290 | 291 | ## Error Handling 292 | Since `shift` request handlers can return errors, it is easy to handle errors in middleware without cluttering the request handlers. 293 | This helps to keep the request handlers clean and focused on their primary task. 294 | 295 | Check out [error handling examples](/example/04-error-handler/main.go). 296 | 297 | ## Trailing Slash Match 298 | When `Router.UseTrailingSlashMatch()` is set, if the router is unable to find a match for the path, it tries to find a match with or without the trailing slash. 299 | The routing behavior for the matched route is determined by the provided `ActionOption` (See below). 300 | 301 | When `Router.UseTrailingSlashMatch()` is set, if the router is unable to find a match for the requested path, it will try to find a match with or without the trailing slash. 302 | The routing behavior for the matched route is determined by the provided `ActionOption`. 303 | 304 | With `shift.WithExecute()` option, the matched fallback route handler would be executed. 305 | 306 | ```go 307 | router := shift.New() 308 | router.UseTrailingSlashMatch(shift.WithExecute()) 309 | 310 | router.GET("/foo", FooHandler) // Matches /foo and /foo/ 311 | router.GET("/bar/", BarHandler) // Matches /bar/ and /bar 312 | ``` 313 | 314 | In the above example, the first route handler matches both `/foo` and `/foo/` and the second route handler matches both `/bar/` and `/bar`. 315 | 316 | ## Path Correction & Case-Insensitive Match 317 | When `Router.UsePathCorrectionMatch()` is set, if the router is unable to find a match for the path, it will perform path correction and case-insensitive matching in order to find a match for the requested path. 318 | The routing behavior for the matched route is determined by the provided `ActionOption`. 319 | 320 | With `shift.WithRedirect()` option, it will return a HTTP 304 (Moved Permanently) status with a redirect to correct URL. 321 | 322 | ```go 323 | router := shift.New() 324 | router.UsePathCorrectionMatch(shift.WithRedirect()) 325 | 326 | router.GET("/foo", FooHandler) // Matches /foo, /Foo, /fOO, /fOo, and so on... 327 | router.GET("/bar/", BarHandler) // Matches /bar/, /Bar/, /bAr/, /BAR, /baR/, and so on... 328 | ``` 329 | 330 | ## ActionOption 331 | 332 | Both `UseTrailingSlashMatch` and `UsePathCorrectionMatch` expects an `ActionOption` which provides the routing behavior for the matched route, `shift` provides three behavior providers: 333 | * `WithExecute()` - Executes the request handler of the correct route. 334 | * `WithRedirect()` - Returns HTTP 304 (Moved Permanently) status with a redirect to correct URL in the header. 335 | * `WithRedirectCustom(statusCode)` - Is same as `WithRedirect`, except it writes the provided status code (should be in range 3XX). 336 | 337 | ## Route Information 338 | In a `shift` style request handler, access route information such as the route path and route params directly through the `Route` argument. 339 | 340 | In a `net/http` style request handler, attach the `RouteContext` middleware and within the request handler, use `RouteOf()` function to retrieve the `Route` object. 341 | 342 | ### Using Route and Params in GoRoutines 343 | When using `Route` or `Params` object in a Go Routine, make sure to get a clone using `Copy()` which is available for both the objects. 344 | 345 | ```go 346 | func WorkerHandler(w http.ResponseWriter, r *http.Request, route shift.Route) error { 347 | go FooWorker(route.Copy()) // Copies the whole Route object along with the internal Params object. 348 | go BarWorker(route.Params.Copy()) // Copies only the Params object. 349 | return nil 350 | } 351 | 352 | func FooWorker(route shift.Route) { ... } 353 | 354 | func BarWorker(ps *shift.Params) { ... } 355 | ``` 356 | 357 | ## Registering to Multiple HTTP Methods 358 | To register a request handler to multiple HTTP methods, use `Router.Map()`. 359 | 360 | ```go 361 | router := shift.New() 362 | router.Map([]string{"GET", "POST"}, "/zanzibar", func(w http.ResponseWriter, r *http.Request, route shift.Route) error { 363 | _, err := fmt.Fprintf(w, "👊👊👊") 364 | return err 365 | }) 366 | ``` 367 | 368 | This is equivalent to registering a common request handler to the path `/zanzibar` by calling both `Router.GET()` and `Router.POST()`. 369 | 370 | ## Registering to a Custom HTTP Method 371 | You can also use `Router.Map()` to register request handlers to custom HTTP methods. 372 | 373 | ```go 374 | router := shift.New() 375 | router.Map([]string{"FOO"}, "/products", func(w http.ResponseWriter, r *http.Request, route shift.Route) error { 376 | _, err := fmt.Fprintf(w, "Hello, from %s method 👊", r.Method) 377 | return err 378 | }) 379 | ``` 380 | 381 | ```shell 382 | curl --request FOO --url '127.0.0.1:6464/products' 383 | ``` 384 | 385 | The router will reply `Hello, from FOO method 👊` for the above request. 386 | 387 | ## Credits 388 | * Julien Schmidt for [HttpRouter](https://github.com/julienschmidt/httprouter). 389 | * `path.go` file is taken from the `HttpRouter` project for path correction. 390 | 391 | ## License 392 | Licensed under [MIT License](/LICENSE) 393 | 394 | Copyright (c) 2023 Mohammed Yousuf 395 | 396 | ## Status 397 | `shift` is currently pre-1.0. Therefore, there could be minor breaking changes to stabilize the API before the initial stable release. Please open an issue if you have questions, requests, suggestions for improvements, or concerns. 398 | It's intended to release 1.0.0 before the end of January 2024. -------------------------------------------------------------------------------- /context.go: -------------------------------------------------------------------------------- 1 | package shift 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "sync" 7 | ) 8 | 9 | var ctxKey uint8 10 | 11 | // routeCtx embeds and implements context.Context. 12 | // It is used to wrap Route object within a context.Context interface. 13 | // 14 | // When pooling routeCtx in a sync.Pool, make sure to reset the object before putting back to the pool. 15 | // 16 | // pool := sync.Pool{...} 17 | // ctx = shift.WithRoute(ctx, route) 18 | // ctx.reset() 19 | // pool.Put(ctx) 20 | type routeCtx struct { 21 | context.Context 22 | Route 23 | } 24 | 25 | func (ctx *routeCtx) Value(key any) any { 26 | if key == &ctxKey { 27 | return ctx 28 | } 29 | return ctx.Context.Value(key) 30 | } 31 | 32 | // reset resets the routeCtx values to zero values. 33 | func (ctx *routeCtx) reset() { 34 | ctx.Context = nil 35 | ctx.Route = Route{} 36 | } 37 | 38 | // WithRoute returns a context.Context wrapping the provided context.Context and the Route. 39 | func WithRoute(ctx context.Context, route Route) context.Context { 40 | return &routeCtx{ctx, route} 41 | } 42 | 43 | // FromContext unpacks Route from the provided context.Context. 44 | // Returns false as the second return value if a Route was not found within the provided context.Context. 45 | func FromContext(ctx context.Context) (Route, bool) { 46 | if rctx, ok := ctx.Value(&ctxKey).(*routeCtx); ok { 47 | return rctx.Route, true 48 | } 49 | return Route{}, false 50 | } 51 | 52 | // RouteOf unpacks Route information from the provided http.Request context. 53 | // Returns an empty route if a Route was not found within the provided http.Request context. 54 | // Use RouteContext middleware in the middleware stack to pack Route information into http.Request context. 55 | // 56 | // It is a shorthand for, 57 | // 58 | // route, _ := FromContext(r.Context()) 59 | func RouteOf(r *http.Request) Route { 60 | route, _ := FromContext(r.Context()) 61 | return route 62 | } 63 | 64 | // ctxPool pools routeCtx objects for reuse. 65 | var ctxPool = sync.Pool{ 66 | New: func() any { 67 | return &routeCtx{} 68 | }, 69 | } 70 | 71 | func getCtx() *routeCtx { 72 | return ctxPool.Get().(*routeCtx) 73 | } 74 | 75 | func releaseCtx(ctx *routeCtx) { 76 | ctx.reset() 77 | ctxPool.Put(ctx) 78 | } 79 | -------------------------------------------------------------------------------- /context_test.go: -------------------------------------------------------------------------------- 1 | package shift 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | ) 7 | 8 | func TestContext_FromContext(t *testing.T) { 9 | t.Run("param route", func(t *testing.T) { 10 | ip := newInternalParams(1) 11 | ip.setKeys(&[]string{"name"}) 12 | ip.appendValue("dino") 13 | 14 | paramRoute := Route{ 15 | Params: newParams(ip), 16 | Path: "/foo/:name", 17 | } 18 | req, _ := http.NewRequest(http.MethodGet, "/foo/dino", nil) 19 | req = req.WithContext(WithRoute(req.Context(), paramRoute)) 20 | route, ok := FromContext(req.Context()) 21 | 22 | assert(t, ok, "expected to find a route context") 23 | assert(t, route == paramRoute, "expected routes to be equal") 24 | }) 25 | 26 | t.Run("static route", func(t *testing.T) { 27 | staticRoute := Route{ 28 | Params: Params{}, 29 | Path: "/foo", 30 | } 31 | req, _ := http.NewRequest(http.MethodGet, "/foo", nil) 32 | req = req.WithContext(WithRoute(req.Context(), staticRoute)) 33 | route, ok := FromContext(req.Context()) 34 | 35 | assert(t, ok, "expected to find a route context") 36 | assert(t, route == staticRoute, "expected routes to be equal") 37 | }) 38 | } 39 | 40 | func BenchmarkContext_FromContext(b *testing.B) { 41 | b.Run("routeCtx context", func(b *testing.B) { 42 | req, _ := http.NewRequest(http.MethodGet, "/movies/111/segments/222/frames/333", nil) 43 | ctx := req.Context() 44 | 45 | ip := newInternalParams(3) 46 | ip.setKeys(&[]string{"id", "segmentId", "frameId"}) 47 | ip.appendValue("111") 48 | ip.appendValue("222") 49 | ip.appendValue("333") 50 | 51 | req = req.WithContext(WithRoute(ctx, Route{ 52 | Params: newParams(ip), 53 | Path: "/movies/:id/segments/:segmentId/frames/:frameId", 54 | })) 55 | 56 | b.ResetTimer() 57 | b.ReportAllocs() 58 | 59 | for i := 0; i < b.N; i++ { 60 | FromContext(req.Context()) 61 | } 62 | }) 63 | 64 | b.Run("non-routeCtx context", func(b *testing.B) { 65 | req, _ := http.NewRequest(http.MethodGet, "/movies/genres/noir", nil) 66 | 67 | b.ResetTimer() 68 | b.ReportAllocs() 69 | 70 | for i := 0; i < b.N; i++ { 71 | FromContext(req.Context()) 72 | } 73 | }) 74 | } 75 | 76 | func TestContext_RouteOf(t *testing.T) { 77 | abcRoute := Route{ 78 | Params: newParams(nil), 79 | Path: "/abc", 80 | } 81 | req, _ := http.NewRequest("GET", "/abc", nil) 82 | ctx := WithRoute(req.Context(), abcRoute) 83 | req = req.WithContext(ctx) 84 | 85 | assert(t, RouteOf(req) == abcRoute, "expected routes to be equal") 86 | } 87 | -------------------------------------------------------------------------------- /core.go: -------------------------------------------------------------------------------- 1 | package shift 2 | 3 | import "net/http" 4 | 5 | type routeLog struct { 6 | method string 7 | path string 8 | handler HandlerFunc 9 | } 10 | 11 | // Core provides methods to register routes. 12 | type Core struct { 13 | base string 14 | logs *[]routeLog 15 | mws []MiddlewareFunc 16 | } 17 | 18 | // Group groups routes together at the given path with a group-scoped middleware stack inherited from the parent middleware stack. 19 | // It provides the opportunity to maintain groups of routes in different files using the func(g *Group) func signature. 20 | // 21 | // It is also possible to nest groups within groups. 22 | func (c *Core) Group(path string, fn func(g *Group)) { 23 | stack := make([]MiddlewareFunc, len(c.mws), len(c.mws)) 24 | copy(stack, c.mws) 25 | 26 | fn(&Group{Core{ 27 | logs: c.logs, 28 | base: c.base + path, 29 | mws: stack, 30 | }}) 31 | } 32 | 33 | // With returns an instance attaching middlewares to the middleware stack inherited from the parent middleware stack. 34 | // It's useful for registering middlewares for a specific Group or a route. 35 | // To use a net/http idiomatic middleware, wrap the middleware using the HTTPMiddlewareFunc. 36 | func (c *Core) With(middlewares ...MiddlewareFunc) *Core { 37 | stack := make([]MiddlewareFunc, len(c.mws), len(c.mws)+len(middlewares)) 38 | copy(stack, c.mws) 39 | stack = append(stack, middlewares...) 40 | 41 | return &Core{ 42 | c.base, 43 | c.logs, 44 | stack, 45 | } 46 | } 47 | 48 | // Map maps a request handler for the given methods at the given path. 49 | func (c *Core) Map(methods []string, path string, handler HandlerFunc) { 50 | if len(methods) == 0 { 51 | panic("methods cannot be empty") 52 | } 53 | 54 | if handler == nil { 55 | panic("handler cannot be nil") 56 | } 57 | 58 | for _, meth := range methods { 59 | *c.logs = append(*c.logs, routeLog{ 60 | method: meth, 61 | path: c.base + path, 62 | handler: c.chain(handler), 63 | }) 64 | } 65 | } 66 | 67 | // GET maps a request handler for the GET method at the given path. 68 | // It is a shorthand for: 69 | // 70 | // c.Map([]string{http.MethodGet}, path, handler) 71 | func (c *Core) GET(path string, handler HandlerFunc) { 72 | c.Map([]string{http.MethodGet}, path, handler) 73 | } 74 | 75 | // POST maps a request handler for the POST method at the given path. 76 | // It is a shorthand for: 77 | // 78 | // c.Map([]string{http.MethodPost}, path, handler) 79 | func (c *Core) POST(path string, handler HandlerFunc) { 80 | c.Map([]string{http.MethodPost}, path, handler) 81 | } 82 | 83 | // PUT maps a request handler for the PUT method at the given path. 84 | // It is a shorthand for: 85 | // 86 | // c.Map([]string{http.MethodPut}, path, handler) 87 | func (c *Core) PUT(path string, handler HandlerFunc) { 88 | c.Map([]string{http.MethodPut}, path, handler) 89 | } 90 | 91 | // PATCH maps a request handler for the PATCH method at the given path. 92 | // It is a shorthand for: 93 | // 94 | // c.Map([]string{http.MethodPatch}, path, handler) 95 | func (c *Core) PATCH(path string, handler HandlerFunc) { 96 | c.Map([]string{http.MethodPatch}, path, handler) 97 | } 98 | 99 | // DELETE maps a request handler for the DELETE method at the given path. 100 | // It is a shorthand for: 101 | // 102 | // c.Map([]string{http.MethodDelete}, path, handler) 103 | func (c *Core) DELETE(path string, handler HandlerFunc) { 104 | c.Map([]string{http.MethodDelete}, path, handler) 105 | } 106 | 107 | // OPTIONS maps a request handler for the OPTIONS method at the given path. 108 | // It is a shorthand for: 109 | // 110 | // c.Map([]string{http.MethodOptions}, path, handler) 111 | func (c *Core) OPTIONS(path string, handler HandlerFunc) { 112 | c.Map([]string{http.MethodOptions}, path, handler) 113 | } 114 | 115 | // HEAD maps a request handler for the HEAD method at the given path. 116 | // It is a shorthand for: 117 | // 118 | // c.Map([]string{http.MethodHead}, path, handler) 119 | func (c *Core) HEAD(path string, handler HandlerFunc) { 120 | c.Map([]string{http.MethodHead}, path, handler) 121 | } 122 | 123 | // CONNECT maps a request handler for the CONNECT method at the given path. 124 | // It is a shorthand for: 125 | // 126 | // c.Map([]string{http.MethodConnect}, path, handler) 127 | func (c *Core) CONNECT(path string, handler HandlerFunc) { 128 | c.Map([]string{http.MethodConnect}, path, handler) 129 | } 130 | 131 | // TRACE maps a request handler for the TRACE method at the given path. 132 | // It is a shorthand for: 133 | // 134 | // c.Map([]string{http.MethodTrace}, path, handler) 135 | func (c *Core) TRACE(path string, handler HandlerFunc) { 136 | c.Map([]string{http.MethodTrace}, path, handler) 137 | } 138 | 139 | // All maps a request handler for all the built-in HTTP methods and registered custom HTTP methods at the given path. 140 | // It is a shorthand for: 141 | // 142 | // c.Map([]string{""}, path, handler) 143 | func (c *Core) All(path string, handler HandlerFunc) { 144 | c.Map([]string{""}, path, handler) 145 | } 146 | 147 | func (c *Core) chain(handler HandlerFunc) HandlerFunc { 148 | for i := len(c.mws) - 1; i >= 0; i-- { 149 | handler = c.mws[i](handler) 150 | } 151 | return handler 152 | } 153 | -------------------------------------------------------------------------------- /example/01-simple/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/yousuf64/shift" 6 | "net/http" 7 | ) 8 | 9 | func main() { 10 | r := shift.New() 11 | // Static route. 12 | r.GET("/", func(w http.ResponseWriter, r *http.Request, route shift.Route) error { 13 | _, err := w.Write([]byte("hello from shift")) 14 | return err 15 | }) 16 | // Param route. 17 | r.GET("/user/:name", func(w http.ResponseWriter, r *http.Request, route shift.Route) error { 18 | _, err := w.Write([]byte(fmt.Sprintf("hello %s", route.Params.Get("name")))) 19 | return err 20 | }) 21 | // Mid-segment param route. 22 | r.DELETE("/version:number", func(w http.ResponseWriter, r *http.Request, route shift.Route) error { 23 | _, err := w.Write([]byte(fmt.Sprintf("version %s deleted", route.Params.Get("number")))) 24 | return err 25 | }) 26 | // Wildcard route. 27 | r.HEAD("/bucket/*path", func(w http.ResponseWriter, r *http.Request, route shift.Route) error { 28 | _, err := w.Write([]byte(fmt.Sprintf("file found at %s", route.Params.Get("path")))) 29 | return err 30 | }) 31 | // Mid-segment wildcard route. 32 | r.PUT("/vid*url", func(w http.ResponseWriter, r *http.Request, route shift.Route) error { 33 | _, err := w.Write([]byte(fmt.Sprintf("fetched video from %s", route.Params.Get("url")))) 34 | return err 35 | }) 36 | 37 | _ = http.ListenAndServe(":6464", r.Serve()) 38 | } 39 | -------------------------------------------------------------------------------- /example/02-group/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/yousuf64/shift" 5 | "net/http" 6 | ) 7 | 8 | func main() { 9 | r := shift.New() 10 | r.Group("/v1", func(g *shift.Group) { 11 | g.GET("/abc", func(w http.ResponseWriter, r *http.Request, route shift.Route) error { 12 | _, err := w.Write([]byte("inline group v1: abc")) 13 | return err 14 | }) 15 | g.GET("/xyz", func(w http.ResponseWriter, r *http.Request, route shift.Route) error { 16 | _, err := w.Write([]byte("inline group v1: xyz")) 17 | return err 18 | }) 19 | }) 20 | r.Group("/v2", groupV2) 21 | r.Group("/v3", groupV3) 22 | r.Group("/v4", func(g *shift.Group) { 23 | g.Group("/aaa", func(g *shift.Group) { 24 | g.Group("/bbb", func(g *shift.Group) { 25 | g.Group("/ccc", func(g *shift.Group) { 26 | g.GET("", func(w http.ResponseWriter, r *http.Request, route shift.Route) error { 27 | _, err := w.Write([]byte("response from nested group")) 28 | return err 29 | }) 30 | }) 31 | }) 32 | }) 33 | }) 34 | 35 | _ = http.ListenAndServe(":6464", r.Serve()) 36 | } 37 | -------------------------------------------------------------------------------- /example/02-group/v2.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/yousuf64/shift" 5 | "net/http" 6 | ) 7 | 8 | func groupV2(g *shift.Group) { 9 | g.GET("/abc", func(w http.ResponseWriter, r *http.Request, route shift.Route) error { 10 | _, err := w.Write([]byte("v2.go file: abc")) 11 | return err 12 | }) 13 | g.GET("/xyz", func(w http.ResponseWriter, r *http.Request, route shift.Route) error { 14 | _, err := w.Write([]byte("v2.go file: xyz")) 15 | return err 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /example/02-group/v3.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/yousuf64/shift" 5 | "net/http" 6 | ) 7 | 8 | func groupV3(g *shift.Group) { 9 | g.GET("/abc", func(w http.ResponseWriter, r *http.Request, route shift.Route) error { 10 | _, err := w.Write([]byte("v3.go file: abc")) 11 | return err 12 | }) 13 | g.GET("/xyz", func(w http.ResponseWriter, r *http.Request, route shift.Route) error { 14 | _, err := w.Write([]byte("v3.go file: xyz")) 15 | return err 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /example/03-middleware/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/yousuf64/shift" 7 | "log" 8 | "net/http" 9 | "strconv" 10 | "sync/atomic" 11 | "time" 12 | ) 13 | 14 | func main() { 15 | r := shift.New() 16 | r.Use(traceMiddleware) // Apply to all the handlers declared after Use(). 17 | 18 | r.GET("/", func(w http.ResponseWriter, r *http.Request, route shift.Route) error { 19 | _, err := w.Write([]byte("hello from shift")) 20 | return err 21 | }) 22 | 23 | // Apply only to the subsequently chained handler. 24 | r.With(timezoneMiddleware).GET("/bar", func(w http.ResponseWriter, r *http.Request, route shift.Route) error { 25 | _, err := w.Write([]byte(fmt.Sprintf("client timezone: %s", r.Header.Get("Timezone")))) 26 | return err 27 | }) 28 | 29 | // Apply only to the subsequently chained group. 30 | r.With(rateLimiterMiddleware).Group("/foo", func(g *shift.Group) { 31 | g.GET("/aaa", func(w http.ResponseWriter, r *http.Request, route shift.Route) error { 32 | _, err := w.Write([]byte(":)")) 33 | return err 34 | }) 35 | }) 36 | 37 | r.Group("/oof", func(g *shift.Group) { 38 | g.Use(authMiddleware) // Apply to all the handlers declared after Use() within this group scope. 39 | g.GET("/aaa", func(w http.ResponseWriter, r *http.Request, route shift.Route) error { 40 | _, err := w.Write([]byte("hello from authenticated route")) 41 | return err 42 | }) 43 | }) 44 | 45 | _ = http.ListenAndServe(":6464", r.Serve()) 46 | } 47 | 48 | var i int64 = 0 // Fake Trace ID 49 | 50 | func traceMiddleware(next shift.HandlerFunc) shift.HandlerFunc { 51 | return func(w http.ResponseWriter, r *http.Request, route shift.Route) (err error) { 52 | id := i 53 | atomic.AddInt64(&i, 1) 54 | w.Header().Set("Trace-ID", strconv.Itoa(int(id))) 55 | 56 | u := r.URL.String() 57 | t := time.Now() 58 | log.Printf("received request | id: %d, url: %s", id, u) 59 | 60 | err = next(w, r, route) 61 | 62 | log.Printf("completed request | id: %d, url: %s, time elapsed: %dμs", id, u, time.Since(t).Microseconds()) 63 | return 64 | } 65 | } 66 | 67 | func timezoneMiddleware(next shift.HandlerFunc) shift.HandlerFunc { 68 | return func(w http.ResponseWriter, r *http.Request, route shift.Route) error { 69 | if v := r.Header.Get("Timezone"); v != "" { 70 | return next(w, r, route) 71 | } 72 | w.WriteHeader(http.StatusBadRequest) 73 | _, _ = w.Write([]byte("'Timezone' header required")) 74 | return errors.New("'Timezone' header required") 75 | } 76 | } 77 | 78 | var count int64 = 0 79 | var threshold = 5 80 | var interval = time.Second * 10 81 | var timer = time.NewTimer(interval) 82 | 83 | func init() { 84 | go func() { 85 | for range timer.C { 86 | count = 0 87 | timer.Reset(interval) 88 | } 89 | }() 90 | } 91 | 92 | // accepts only x number of requests within y time period. 93 | func rateLimiterMiddleware(next shift.HandlerFunc) shift.HandlerFunc { 94 | return func(w http.ResponseWriter, r *http.Request, route shift.Route) error { 95 | if count >= int64(threshold) { 96 | w.WriteHeader(http.StatusTooManyRequests) 97 | _, _ = w.Write([]byte(fmt.Sprintf("try again in few seconds"))) 98 | return nil 99 | } 100 | atomic.AddInt64(&count, 1) 101 | return next(w, r, route) 102 | } 103 | } 104 | 105 | func authMiddleware(next shift.HandlerFunc) shift.HandlerFunc { 106 | return func(w http.ResponseWriter, r *http.Request, route shift.Route) error { 107 | if v := r.Header.Get("Authorization"); v != "" { 108 | return next(w, r, route) 109 | } 110 | w.WriteHeader(http.StatusBadRequest) 111 | _, _ = w.Write([]byte("'Authorization' header required")) 112 | return errors.New("'Authorization' header required") 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /example/04-error-handler/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "github.com/yousuf64/shift" 6 | "net/http" 7 | ) 8 | 9 | func main() { 10 | r := shift.New() 11 | r.Use(errorHandler) 12 | r.GET("/order", func(w http.ResponseWriter, r *http.Request, route shift.Route) error { 13 | return errors.New("unable to publish the event") 14 | }) 15 | r.GET("/pay", func(w http.ResponseWriter, r *http.Request, route shift.Route) error { 16 | return customError{ 17 | StatusCode: http.StatusPaymentRequired, 18 | Message: "missing payment method", 19 | } 20 | }) 21 | 22 | _ = http.ListenAndServe(":6464", r.Serve()) 23 | } 24 | 25 | type customError struct { 26 | StatusCode int 27 | Message string 28 | } 29 | 30 | func (e customError) Error() string { 31 | return e.Message 32 | } 33 | 34 | func errorHandler(next shift.HandlerFunc) shift.HandlerFunc { 35 | return func(w http.ResponseWriter, r *http.Request, route shift.Route) error { 36 | err := next(w, r, route) 37 | if err != nil { 38 | switch err := err.(type) { 39 | case customError: 40 | w.WriteHeader(err.StatusCode) 41 | _, _ = w.Write([]byte(err.Message)) 42 | default: 43 | w.WriteHeader(http.StatusInternalServerError) 44 | _, _ = w.Write([]byte(err.Error())) 45 | } 46 | } 47 | 48 | return nil 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/yousuf64/shift 2 | 3 | go 1.20 4 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | package shift -------------------------------------------------------------------------------- /group.go: -------------------------------------------------------------------------------- 1 | package shift 2 | 3 | import "strings" 4 | 5 | type core = Core 6 | 7 | // Group builds on top of Core and provides additional Group specific methods. 8 | type Group struct { 9 | core 10 | } 11 | 12 | // Use attaches middlewares to the current middleware stack. 13 | // The middleware stack is executed before the request handler in the order middlewares were registered. 14 | // 15 | // Make sure to register middlewares before registering routes. Otherwise, the routes registered prior to registering 16 | // middlewares wouldn't be executing the middlewares. 17 | // 18 | // Alternatively, Router.With() can be used to register middlewares for a whole group or a specific route. 19 | // 20 | // To use a net/http idiomatic middleware, wrap the middleware in the HTTPMiddlewareFunc. 21 | func (g *Group) Use(middlewares ...MiddlewareFunc) { 22 | g.mws = append(g.mws, middlewares...) 23 | } 24 | 25 | // Base returns the base path of the Group. 26 | // 27 | // For example, 28 | // 29 | // router.Group("/v1/foo", func(group *shift.Group) { 30 | // group.Base() // returns /v1/foo 31 | // }) 32 | func (g *Group) Base() string { 33 | return g.base 34 | } 35 | 36 | // Routes returns the routes registered to the Group. 37 | // To retrieve all the routes, use Router.Routes(). 38 | func (g *Group) Routes() (routes []RouteInfo) { 39 | for _, log := range *g.logs { 40 | if strings.HasPrefix(log.path, g.base) { 41 | routes = append(routes, RouteInfo{ 42 | Method: log.method, 43 | Path: log.path, 44 | }) 45 | } 46 | } 47 | return 48 | } 49 | -------------------------------------------------------------------------------- /malloc_test.go: -------------------------------------------------------------------------------- 1 | //go:build !race 2 | 3 | // Skip race detection on memory allocation tests since race detection may increase memory usage 4 | // by 5-10x and execution time by 2-20x which would cause the malloc tests to fail. 5 | // https://go.dev/doc/articles/race_detector 6 | 7 | package shift 8 | 9 | import ( 10 | "fmt" 11 | "net/http" 12 | "net/http/httptest" 13 | "runtime" 14 | "strings" 15 | "testing" 16 | ) 17 | 18 | func TestRouter_ServeHTTP_MixedRoutes_Malloc(t *testing.T) { 19 | r := newTestRouter() 20 | 21 | paths := map[string]string{ 22 | "/users/find": http.MethodGet, 23 | "/users/find/:name": http.MethodGet, 24 | "/users/:id/delete": http.MethodGet, 25 | "/users/:id/update": http.MethodGet, 26 | "/users/groups/:groupId/dump": http.MethodGet, 27 | "/users/groups/:groupId/export": http.MethodGet, 28 | "/users/delete": http.MethodGet, 29 | "/users/all/dump": http.MethodGet, 30 | "/users/all/export": http.MethodGet, 31 | "/users/any": http.MethodGet, 32 | 33 | "/search": http.MethodPost, 34 | "/search/go": http.MethodPost, 35 | "/search/go1.html": http.MethodPost, 36 | "/search/index.html": http.MethodPost, 37 | "/search/:q": http.MethodPost, 38 | "/search/:q/go": http.MethodPost, 39 | "/search/:q/go1.html": http.MethodPost, 40 | "/search/:q/:w/index.html": http.MethodPost, 41 | 42 | "/src/:dest/invalid": http.MethodPut, 43 | "/src/invalid": http.MethodPut, 44 | "/src1/:dest": http.MethodPut, 45 | "/src1": http.MethodPut, 46 | 47 | "/signal-r/:cmd/reflection": http.MethodPatch, 48 | "/signal-r": http.MethodPatch, 49 | "/signal-r/:cmd": http.MethodPatch, 50 | 51 | "/query/unknown/pages": http.MethodHead, 52 | "/query/:key/:val/:cmd/single": http.MethodHead, 53 | "/query/:key": http.MethodHead, 54 | "/query/:key/:val/:cmd": http.MethodHead, 55 | "/query/:key/:val": http.MethodHead, 56 | "/query/unknown": http.MethodHead, 57 | "/query/untold": http.MethodHead, 58 | 59 | "/questions/:index": http.MethodConnect, 60 | "/questions": http.MethodConnect, 61 | 62 | "/graphql": http.MethodDelete, 63 | "/graph": http.MethodDelete, 64 | "/graphql/cmd": http.MethodDelete, 65 | 66 | "/file": http.MethodDelete, 67 | "/file/remove": http.MethodDelete, 68 | 69 | "/hero-:name": http.MethodGet, 70 | } 71 | 72 | for path, meth := range paths { 73 | r.Map([]string{meth}, path, fakeHandler()) 74 | } 75 | 76 | tt := []srvTestItem{ 77 | {method: http.MethodGet, path: "/users/find", valid: true, pathTemplate: "/users/find"}, 78 | {method: http.MethodGet, path: "/users/find/yousuf", valid: true, pathTemplate: "/users/find/:name", params: map[string]string{"name": "yousuf"}}, 79 | {method: http.MethodGet, path: "/users/john/delete", valid: true, pathTemplate: "/users/:id/delete", params: map[string]string{"id": "john"}}, 80 | {method: http.MethodGet, path: "/users/911/update", valid: true, pathTemplate: "/users/:id/update", params: map[string]string{"id": "911"}}, 81 | {method: http.MethodGet, path: "/users/groups/120/dump", valid: true, pathTemplate: "/users/groups/:groupId/dump", params: map[string]string{"groupId": "120"}}, 82 | {method: http.MethodGet, path: "/users/groups/230/export", valid: true, pathTemplate: "/users/groups/:groupId/export", params: map[string]string{"groupId": "230"}}, 83 | {method: http.MethodGet, path: "/users/delete", valid: true, pathTemplate: "/users/delete"}, 84 | {method: http.MethodGet, path: "/users/all/dump", valid: true, pathTemplate: "/users/all/dump"}, 85 | {method: http.MethodGet, path: "/users/all/export", valid: true, pathTemplate: "/users/all/export"}, 86 | {method: http.MethodGet, path: "/users/any", valid: true, pathTemplate: "/users/any"}, 87 | 88 | {method: http.MethodPost, path: "/search", valid: true, pathTemplate: "/search"}, 89 | {method: http.MethodPost, path: "/search/go", valid: true, pathTemplate: "/search/go"}, 90 | {method: http.MethodPost, path: "/search/go1.html", valid: true, pathTemplate: "/search/go1.html"}, 91 | {method: http.MethodPost, path: "/search/index.html", valid: true, pathTemplate: "/search/index.html"}, 92 | {method: http.MethodPost, path: "/search/contact.html", valid: true, pathTemplate: "/search/:q"}, 93 | {method: http.MethodPost, path: "/search/ducks", valid: true, pathTemplate: "/search/:q", params: map[string]string{"q": "ducks"}}, 94 | {method: http.MethodPost, path: "/search/gophers/go", valid: true, pathTemplate: "/search/:q/go", params: map[string]string{"q": "gophers"}}, 95 | {method: http.MethodPost, path: "/search/nature/go1.html", valid: true, pathTemplate: "/search/:q/go1.html", params: map[string]string{"q": "nature"}}, 96 | {method: http.MethodPost, path: "/search/generics/types/index.html", valid: true, pathTemplate: "/search/:q/:w/index.html", params: map[string]string{"q": "generics", "w": "types"}}, 97 | 98 | {method: http.MethodPut, path: "/src/paris/invalid", valid: true, pathTemplate: "/src/:dest/invalid", params: map[string]string{"dest": "paris"}}, 99 | {method: http.MethodPut, path: "/src/invalid", valid: true, pathTemplate: "/src/invalid"}, 100 | {method: http.MethodPut, path: "/src1/oslo", valid: true, pathTemplate: "/src1/:dest", params: map[string]string{"dest": "oslo"}}, 101 | {method: http.MethodPut, path: "/src1", valid: true, pathTemplate: "/src1"}, 102 | 103 | {method: http.MethodPatch, path: "/signal-r/protos/reflection", valid: true, pathTemplate: "/signal-r/:cmd/reflection", params: map[string]string{"cmd": "protos"}}, 104 | {method: http.MethodPatch, path: "/signal-r", valid: true, pathTemplate: "/signal-r"}, 105 | {method: http.MethodPatch, path: "/signal-r/push", valid: true, pathTemplate: "/signal-r/:cmd", params: map[string]string{"cmd": "push"}}, 106 | {method: http.MethodPatch, path: "/signal-r/connect", valid: true, pathTemplate: "/signal-r/:cmd", params: map[string]string{"cmd": "connect"}}, 107 | 108 | {method: http.MethodHead, path: "/query/unknown/pages", valid: true, pathTemplate: "/query/unknown/pages"}, 109 | {method: http.MethodHead, path: "/query/10/amazing/reset/single", valid: true, pathTemplate: "/query/:key/:val/:cmd/single", params: map[string]string{"key": "10", "val": "amazing", "cmd": "reset"}}, 110 | {method: http.MethodHead, path: "/query/911", valid: true, pathTemplate: "/query/:key", params: map[string]string{"key": "911"}}, 111 | {method: http.MethodHead, path: "/query/99/sup/update-ttl", valid: true, pathTemplate: "/query/:key/:val/:cmd", params: map[string]string{"key": "99", "val": "sup", "cmd": "update-ttl"}}, 112 | {method: http.MethodHead, path: "/query/46/hello", valid: true, pathTemplate: "/query/:key/:val", params: map[string]string{"key": "46", "val": "hello"}}, 113 | {method: http.MethodHead, path: "/query/unknown", valid: true, pathTemplate: "/query/unknown"}, 114 | {method: http.MethodHead, path: "/query/untold", valid: true, pathTemplate: "/query/untold"}, 115 | 116 | {method: http.MethodConnect, path: "/questions/1001", valid: true, pathTemplate: "/questions/:index", params: map[string]string{"index": "1001"}}, 117 | {method: http.MethodConnect, path: "/questions", valid: true, pathTemplate: "/questions"}, 118 | 119 | {method: http.MethodDelete, path: "/graphql", valid: true, pathTemplate: "/graphql"}, 120 | {method: http.MethodDelete, path: "/graph", valid: true, pathTemplate: "/graph"}, 121 | {method: http.MethodDelete, path: "/graphql/cmd", valid: true, pathTemplate: "/graphql/cmd", params: nil}, 122 | 123 | {method: http.MethodDelete, path: "/file", valid: true, pathTemplate: "/file", params: nil}, 124 | {method: http.MethodDelete, path: "/file/remove", valid: true, pathTemplate: "/file/remove", params: nil}, 125 | 126 | {method: http.MethodGet, path: "/hero-goku", valid: true, pathTemplate: "/hero-:name", params: map[string]string{"name": "goku"}}, 127 | {method: http.MethodGet, path: "/hero-thor", valid: true, pathTemplate: "/hero-:name", params: map[string]string{"name": "thor"}}, 128 | } 129 | 130 | requests := make([]*http.Request, len(tt)) 131 | 132 | for i, tx := range tt { 133 | requests[i], _ = http.NewRequest(tx.method, tx.path, nil) 134 | } 135 | 136 | srv := r.Serve() 137 | 138 | allocations := testing.AllocsPerRun(1000, func() { 139 | for _, request := range requests { 140 | srv.ServeHTTP(nil, request) 141 | } 142 | }) 143 | 144 | assert(t, allocations == 0, fmt.Sprintf("expected zero allocations, got %g allocations", allocations)) 145 | } 146 | 147 | func TestRouter_20Params_Malloc(t *testing.T) { 148 | r := New() 149 | 150 | var template strings.Builder 151 | var path strings.Builder 152 | var paramKeys []string 153 | 154 | for i := 1; i <= 20; i++ { 155 | template.WriteString(fmt.Sprintf("/:%d", i)) 156 | path.WriteString("/foo") 157 | paramKeys = append(paramKeys, fmt.Sprintf("%d", i)) 158 | } 159 | 160 | f := func(w http.ResponseWriter, r *http.Request, route Route) error { 161 | for _, key := range paramKeys { 162 | if v := route.Params.Get(key); v != "foo" { 163 | panic(fmt.Sprintf("param value > expected: foo, got: %s", v)) 164 | } 165 | } 166 | return nil 167 | } 168 | 169 | r.GET(template.String(), f) 170 | 171 | srv := r.Serve() 172 | 173 | rw := httptest.NewRecorder() 174 | req, _ := http.NewRequest(http.MethodGet, path.String(), nil) 175 | 176 | allocs := testing.AllocsPerRun(1000, func() { 177 | srv.ServeHTTP(rw, req) 178 | if rw.Code != http.StatusOK { 179 | panic(fmt.Sprintf("http status > expected: %d, got: %d", http.StatusOK, rw.Code)) 180 | } 181 | }) 182 | 183 | assert(t, allocs == 0, fmt.Sprintf("allocs > expected: 0, got: %g", allocs)) 184 | } 185 | 186 | func TestRouter_200Params_Malloc(t *testing.T) { 187 | r := New() 188 | 189 | var template strings.Builder 190 | var path strings.Builder 191 | var paramKeys []string 192 | 193 | for i := 1; i <= 200; i++ { 194 | template.WriteString(fmt.Sprintf("/:%d", i)) 195 | path.WriteString("/foo") 196 | paramKeys = append(paramKeys, fmt.Sprintf("%d", i)) 197 | } 198 | 199 | f := func(w http.ResponseWriter, r *http.Request, route Route) error { 200 | for _, key := range paramKeys { 201 | if v := route.Params.Get(key); v != "foo" { 202 | panic(fmt.Sprintf("param value > expected: foo, got: %s", v)) 203 | } 204 | } 205 | return nil 206 | } 207 | 208 | r.GET(template.String(), f) 209 | 210 | srv := r.Serve() 211 | 212 | rw := httptest.NewRecorder() 213 | req, _ := http.NewRequest(http.MethodGet, path.String(), nil) 214 | 215 | allocs := testing.AllocsPerRun(1000, func() { 216 | srv.ServeHTTP(rw, req) 217 | if rw.Code != http.StatusOK { 218 | panic(fmt.Sprintf("http status > expected: %d, got: %d", http.StatusOK, rw.Code)) 219 | } 220 | }) 221 | 222 | assert(t, allocs == 0, fmt.Sprintf("allocs > expected: 0, got: %g", allocs)) 223 | } 224 | 225 | func TestRouter_2000Params_Malloc(t *testing.T) { 226 | r := New() 227 | 228 | var template strings.Builder 229 | var path strings.Builder 230 | var paramKeys []string 231 | 232 | for i := 1; i <= 2000; i++ { 233 | template.WriteString(fmt.Sprintf("/:%d", i)) 234 | path.WriteString("/foo") 235 | paramKeys = append(paramKeys, fmt.Sprintf("%d", i)) 236 | } 237 | 238 | f := func(w http.ResponseWriter, r *http.Request, route Route) error { 239 | for _, key := range paramKeys { 240 | if v := route.Params.Get(key); v != "foo" { 241 | panic(fmt.Sprintf("param value > expected: foo, got: %s", v)) 242 | } 243 | } 244 | return nil 245 | } 246 | 247 | r.GET(template.String(), f) 248 | 249 | srv := r.Serve() 250 | 251 | rw := httptest.NewRecorder() 252 | req, _ := http.NewRequest(http.MethodGet, path.String(), nil) 253 | 254 | allocs := testing.AllocsPerRun(1000, func() { 255 | srv.ServeHTTP(rw, req) 256 | if rw.Code != http.StatusOK { 257 | panic(fmt.Sprintf("http status > expected: %d, got: %d", http.StatusOK, rw.Code)) 258 | } 259 | }) 260 | 261 | assert(t, allocs == 0, fmt.Sprintf("allocs > expected: 0, got: %g", allocs)) 262 | } 263 | 264 | func TestCleanPath_Malloc(t *testing.T) { 265 | if testing.Short() { 266 | t.Skip("skipping malloc count in short mode") 267 | } 268 | 269 | for _, test := range cleanTests { 270 | test := test 271 | allocs := testing.AllocsPerRun(100, func() { 272 | cleanPath(test.result) 273 | }) 274 | if allocs > 0 { 275 | t.Errorf("CleanPath(%q): %v allocs, want zero", test.result, allocs) 276 | } 277 | } 278 | } 279 | 280 | func TestRouteContextMiddleware_Malloc(t *testing.T) { 281 | if strings.HasPrefix(runtime.Version(), "go1.18") { 282 | return 283 | } 284 | 285 | r := New() 286 | r.Use(RouteContext()) 287 | r.GET("/movies/genres/:name", HTTPHandlerFunc(fakeHttpHandler)) 288 | srv := r.Serve() 289 | 290 | rr := httptest.NewRecorder() 291 | req, _ := http.NewRequest(http.MethodGet, "/movies/genres/western", nil) 292 | 293 | allocs := testing.AllocsPerRun(1000, func() { 294 | srv.ServeHTTP(rr, req) 295 | }) 296 | 297 | assert(t, allocs == 1, fmt.Sprintf("allocations > expected: %d, got: %g", 1, allocs)) 298 | t.Log(allocs) 299 | } 300 | 301 | func TestContext_FromContext_Malloc(t *testing.T) { 302 | req, _ := http.NewRequest(http.MethodGet, "/movies/111/segments/222/frames/333", nil) 303 | ctx := req.Context() 304 | 305 | ip := newInternalParams(3) 306 | ip.setKeys(&[]string{"id", "segmentId", "frameId"}) 307 | ip.appendValue("111") 308 | ip.appendValue("222") 309 | ip.appendValue("333") 310 | 311 | req = req.WithContext(WithRoute(ctx, Route{ 312 | Params: newParams(ip), 313 | Path: "/movies/:id/segments/:segmentId/frames/:frameId", 314 | })) 315 | 316 | allocs := testing.AllocsPerRun(1000, func() { 317 | FromContext(req.Context()) 318 | }) 319 | 320 | assert(t, allocs == 0, fmt.Sprintf("allocations > expected: %d, got: %g", 0, allocs)) 321 | } 322 | -------------------------------------------------------------------------------- /middleware.go: -------------------------------------------------------------------------------- 1 | package shift 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os" 9 | "runtime" 10 | ) 11 | 12 | // Recover gracefully handle panics in the subsequent middlewares in the chain and the request handler. 13 | // It returns HTTP 500 ([http.StatusInternalServerError]) status and write the stack trace to [os.Stderr]. 14 | // 15 | // Use [RecoverWithWriter] to write to a different [io.Writer]. 16 | func Recover() MiddlewareFunc { 17 | return RecoverWithWriter(os.Stderr) 18 | } 19 | 20 | // RecoverWithWriter gracefully handle panics in the subsequent middlewares in the chain and the request handler. 21 | // It returns HTTP 500 ([http.StatusInternalServerError]) status and write the stack trace to the provided [io.Writer]. 22 | // 23 | // Use [Recover] to write to [os.Stderr]. 24 | func RecoverWithWriter(w io.Writer) MiddlewareFunc { 25 | return func(next HandlerFunc) HandlerFunc { 26 | return func(rw http.ResponseWriter, r *http.Request, route Route) error { 27 | defer func() { 28 | rec := recover() 29 | switch rec { 30 | case nil: 31 | // do nothing. 32 | case http.ErrAbortHandler: 33 | panic(rec) 34 | default: 35 | writeStack(w, rec, 3) 36 | rw.WriteHeader(http.StatusInternalServerError) 37 | } 38 | }() 39 | 40 | return next(rw, r, route) 41 | } 42 | } 43 | } 44 | 45 | func writeStack(w io.Writer, rec any, skipFrames int) { 46 | buf := &bytes.Buffer{} 47 | 48 | for i := skipFrames; ; i++ { 49 | pc, file, line, ok := runtime.Caller(i) 50 | if !ok { 51 | break 52 | } 53 | f := runtime.FuncForPC(pc) 54 | fmt.Fprintf(buf, "%s\n", f.Name()) 55 | fmt.Fprintf(buf, " %s:%d (%#x)\n", file, line, pc) 56 | } 57 | 58 | fmt.Fprintf(w, "panic: %v\n%s", rec, buf.String()) 59 | } 60 | 61 | // RouteContext packs Route information into http.Request context. 62 | // 63 | // Use RouteOf to unpack Route information from the http.Request context. 64 | func RouteContext() MiddlewareFunc { 65 | return func(next HandlerFunc) HandlerFunc { 66 | return func(w http.ResponseWriter, r *http.Request, route Route) error { 67 | ctx := getCtx() 68 | ctx.Context = r.Context() 69 | ctx.Route = route 70 | defer releaseCtx(ctx) 71 | 72 | r = r.WithContext(ctx) 73 | return next(w, r, route) 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /middleware_test.go: -------------------------------------------------------------------------------- 1 | package shift 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | ) 9 | 10 | func TestRouteContextMiddleware(t *testing.T) { 11 | r := New() 12 | r.Use(RouteContext()) 13 | r.GET("/foo/:name", func(w http.ResponseWriter, r *http.Request, _ Route) error { 14 | route := RouteOf(r) 15 | assert(t, route.Path == "/foo/:name", fmt.Sprintf("path > expected: /foo/:name, got: %s", route.Path)) 16 | name := route.Params.Get("name") 17 | assert(t, name == "bar", fmt.Sprintf("param > expected: bar, got: %s", name)) 18 | return nil 19 | }) 20 | 21 | srv := r.Serve() 22 | rw := httptest.NewRecorder() 23 | req, _ := http.NewRequest(http.MethodGet, "/foo/bar", nil) 24 | srv.ServeHTTP(rw, req) 25 | 26 | assert(t, rw.Code == http.StatusOK, fmt.Sprintf("http status code > expected: 200, got: %d", rw.Code)) 27 | } 28 | 29 | func BenchmarkRouteContextMiddleware(b *testing.B) { 30 | r := New() 31 | r.Use(RouteContext()) 32 | r.GET("/movies/genres/:name", HTTPHandlerFunc(fakeHttpHandler)) 33 | srv := r.Serve() 34 | 35 | rr := httptest.NewRecorder() 36 | requests := make([]*http.Request, 0, 10) 37 | for _, genre := range []string{"drama", "western", "sci-fi", "thriller", "animation", "adventure", "noir", "fantasy", "crime", "comedy"} { 38 | req, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/movies/genres/%s", genre), nil) 39 | requests = append(requests, req) 40 | } 41 | 42 | b.ResetTimer() 43 | b.ReportAllocs() 44 | 45 | for i := 0; i < b.N; i++ { 46 | for _, req := range requests { 47 | srv.ServeHTTP(rr, req) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /mux.go: -------------------------------------------------------------------------------- 1 | package shift 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | "sync" 8 | ) 9 | 10 | type multiplexer interface { 11 | add(path string, isStatic bool, handler HandlerFunc) 12 | find(path string) (HandlerFunc, *internalParams, string) 13 | findCaseInsensitive(path string, withParams bool) (h HandlerFunc, ps *internalParams, template string, matchedPath string) 14 | } 15 | 16 | // radixMux can store both static and param routes. 17 | // It maps all the routes on a radix tree. 18 | // 19 | // It is recommended to use this multiplexer only when all the routes are param routes. 20 | type radixMux struct { 21 | tree *node 22 | paramsPool *sync.Pool 23 | maxParams int 24 | } 25 | 26 | func newRadixMux() *radixMux { 27 | return &radixMux{ 28 | tree: newRootNode(), 29 | paramsPool: &sync.Pool{}, 30 | maxParams: 0, 31 | } 32 | } 33 | 34 | func (mux *radixMux) add(path string, isStatic bool, handler HandlerFunc) { 35 | // Static routes doesn't need to worry about releasing internalParams. 36 | if isStatic { 37 | mux.tree.insert(path, handler) 38 | return 39 | } 40 | 41 | // Wrap request handler by the release params handler. So that internalParams object is put back to the pool for reuse. 42 | vc := mux.tree.insert(path, releaseParamsHandler(mux.paramsPool, handler)) 43 | 44 | if mux.paramsPool.New == nil || vc > mux.maxParams { 45 | mux.maxParams = vc 46 | mux.paramsPool.New = func() interface{} { 47 | return newInternalParams(vc) 48 | } 49 | } 50 | } 51 | 52 | // releaseParamsHandler releases the Route.Params' underlying internalParams object into the sync.Pool after execution. 53 | func releaseParamsHandler(pool *sync.Pool, handler HandlerFunc) HandlerFunc { 54 | return func(w http.ResponseWriter, r *http.Request, route Route) error { 55 | defer route.Params.release(pool) 56 | return handler(w, r, route) 57 | } 58 | } 59 | 60 | func (mux *radixMux) find(path string) (HandlerFunc, *internalParams, string) { 61 | n, ps := mux.tree.search(path, func() *internalParams { 62 | ps := mux.paramsPool.Get().(*internalParams) 63 | return ps 64 | }) 65 | 66 | if n != nil && n.handler != nil { 67 | return n.handler, ps, n.template 68 | } 69 | 70 | return nil, nil, "" 71 | } 72 | 73 | func (mux *radixMux) findCaseInsensitive(path string, withParams bool) (HandlerFunc, *internalParams, string, string) { 74 | n, ps, matchedPath := mux.tree.caseInsensitiveSearch(path, func() *internalParams { 75 | ps := mux.paramsPool.Get().(*internalParams) 76 | return ps 77 | }) 78 | 79 | if n != nil && n.handler != nil { 80 | // When internalParams object is not required, release it to the pool and return a nil. 81 | if !withParams && ps != nil { 82 | ps.reset() 83 | mux.paramsPool.Put(ps) 84 | ps = nil 85 | } 86 | 87 | return n.handler, ps, n.template, matchedPath 88 | } 89 | 90 | return nil, nil, "", "" 91 | } 92 | 93 | // staticMux can store only static routes. 94 | // It maps the routes' request handlers on a builtin map. 95 | // It also maps route length -> route paths in the byLength matrix. 96 | // Which is useful for membership check and case-insensitive search. 97 | // 98 | // Only use this multiplexer only when all the routes are static routes. 99 | type staticMux struct { 100 | routes map[string]HandlerFunc 101 | byLength [][]string // route length -> route paths. Example: 4 (Length) -> /foo, /bar (Paths) 102 | } 103 | 104 | func newStaticMux() *staticMux { 105 | return &staticMux{ 106 | routes: map[string]HandlerFunc{}, 107 | byLength: make([][]string, 0), 108 | } 109 | } 110 | 111 | func (mux *staticMux) add(path string, isStatic bool, handler HandlerFunc) { 112 | if !isStatic { 113 | return 114 | } 115 | 116 | scanPath(path) 117 | 118 | if len(path) >= len(mux.byLength) { 119 | // Grow slice. 120 | mux.byLength = append(mux.byLength, make([][]string, len(path)-len(mux.byLength)+1)...) 121 | } 122 | 123 | if _, ok := mux.routes[path]; ok { 124 | panic(fmt.Sprintf("route %s already registered", path)) 125 | } 126 | mux.routes[path] = handler 127 | mux.byLength[len(path)] = append(mux.byLength[len(path)], path) 128 | } 129 | 130 | func (mux *staticMux) find(path string) (HandlerFunc, *internalParams, string) { 131 | if len(path) >= len(mux.byLength) { 132 | return nil, nil, "" 133 | } 134 | 135 | if len(mux.byLength[len(path)]) == 0 { 136 | // Found no paths with the size. 137 | return nil, nil, "" 138 | } 139 | 140 | // Lookup the routes map. 141 | return mux.routes[path], nil, path 142 | } 143 | 144 | func (mux *staticMux) findCaseInsensitive(path string, _ bool) (HandlerFunc, *internalParams, string, string) { 145 | if len(path) >= len(mux.byLength) { 146 | return nil, nil, "", "" 147 | } 148 | 149 | // Retrieve all the paths with the provided path's length. 150 | if keys := mux.byLength[len(path)]; len(keys) > 0 { 151 | for _, key := range keys { 152 | // Find a matching path. 153 | if lng := longestPrefixCaseInsensitive(key, path); lng == len(path) { 154 | return mux.routes[key], nil, key, key 155 | } 156 | } 157 | } 158 | 159 | return nil, nil, "", "" 160 | } 161 | 162 | // hybridMux can store both static and param routes. 163 | // It maps static routes on a staticMux and param routes on a radixMux. 164 | // 165 | // It is recommended to use this multiplexer when having both static and param routes. 166 | type hybridMux struct { 167 | static *staticMux 168 | radix *radixMux 169 | } 170 | 171 | func newHybridMux() *hybridMux { 172 | return &hybridMux{newStaticMux(), newRadixMux()} 173 | } 174 | 175 | func (mux *hybridMux) add(path string, isStatic bool, handler HandlerFunc) { 176 | if isStatic { 177 | mux.static.add(path, isStatic, handler) 178 | } else { 179 | mux.radix.add(path, isStatic, handler) 180 | } 181 | } 182 | 183 | func (mux *hybridMux) find(path string) (HandlerFunc, *internalParams, string) { 184 | if handler, ps, template := mux.static.find(path); handler != nil { 185 | return handler, ps, template 186 | } 187 | 188 | return mux.radix.find(path) 189 | } 190 | 191 | func (mux *hybridMux) findCaseInsensitive(path string, withParams bool) (HandlerFunc, *internalParams, string, string) { 192 | if handler, ps, template, matchedPath := mux.static.findCaseInsensitive(path, withParams); handler != nil { 193 | return handler, ps, template, matchedPath 194 | } 195 | 196 | return mux.radix.findCaseInsensitive(path, withParams) 197 | } 198 | 199 | func isStatic(path string) bool { 200 | return strings.IndexFunc(path, func(r rune) bool { 201 | return r == ':' || r == '*' 202 | }) == -1 203 | } 204 | -------------------------------------------------------------------------------- /param.go: -------------------------------------------------------------------------------- 1 | package shift 2 | 3 | import "sync" 4 | 5 | // Param is a key-value pair of request's route params. 6 | type Param struct { 7 | Key string 8 | Value string 9 | } 10 | 11 | // Params stores the request's route params. 12 | // 13 | // When passing [Params] to a goroutine or if it's intended to use [Params] beyond the request lifecycle, 14 | // make sure to pass/store a copy of [Params] using [Params.Copy]. 15 | // 16 | // While [Params] is concurrent safe, it is not designed to be reliably used beyond the request lifecycle. 17 | // The reason being underlying store of [Params] is pooled into a [sync.Pool] when the request is completed, 18 | // which can potentially be used by another request. 19 | type Params struct { 20 | internal *internalParams 21 | } 22 | 23 | func newParams(internalParams *internalParams) Params { 24 | return Params{ 25 | internal: internalParams, 26 | } 27 | } 28 | 29 | // Get retrieves the value associated with the provided key. 30 | func (p *Params) Get(key string) string { 31 | if p.internal == nil { 32 | return "" 33 | } 34 | return p.internal.get(key) 35 | } 36 | 37 | // ForEach iterates through Params in the order params are defined in the route. 38 | func (p *Params) ForEach(fn func(k, v string)) { 39 | if p.internal == nil { 40 | return 41 | } 42 | p.internal.forEach(fn) 43 | } 44 | 45 | // Map returns Params mapped into a [key]value map. 46 | func (p *Params) Map() map[string]string { 47 | if p.internal == nil { 48 | return nil 49 | } 50 | return p.internal.kvMap() 51 | } 52 | 53 | // Slice returns a slice of Param in the order params are defined in the route. 54 | func (p *Params) Slice() []Param { 55 | if p.internal == nil { 56 | return nil 57 | } 58 | return p.internal.slice() 59 | } 60 | 61 | // Len returns the length of Params. 62 | func (p *Params) Len() int { 63 | if p.internal == nil { 64 | return 0 65 | } 66 | return len(p.internal.values) 67 | } 68 | 69 | // Copy returns a deep-copy of Params. 70 | func (p *Params) Copy() Params { 71 | if p.internal == nil { 72 | return *p 73 | } 74 | return Params{ 75 | internal: p.internal.deepCopy(), 76 | } 77 | } 78 | 79 | func (p *Params) release(pool *sync.Pool) { 80 | p.internal.reset() 81 | pool.Put(p.internal) 82 | p.internal = nil 83 | } 84 | 85 | // internalParams is the underlying store of [Params]. To reduce allocations, internalParams are pooled into a [sync.Pool]. 86 | type internalParams struct { 87 | i int 88 | max int // Is the capacity of values. It's meant to prevent overflows. 89 | keys *[]string // Value of keys is immutable (created once at startup and passed around). Therefore, it can be shared by different internalParams concurrently. 90 | values []string 91 | } 92 | 93 | func newInternalParams(cap int) *internalParams { 94 | return &internalParams{ 95 | i: 0, 96 | max: cap, 97 | keys: nil, 98 | values: make([]string, 0, cap), 99 | } 100 | } 101 | 102 | // setKeys replaces keys with the provided keys and expands/shrinks values to the keys' length. 103 | func (p *internalParams) setKeys(keys *[]string) { 104 | p.keys = keys 105 | p.values = p.values[:len(*keys)] 106 | } 107 | 108 | // appendValue appends a value if the max capacity is not reached and increases the counter. 109 | // It accepts values irrespective of the keys' length. 110 | func (p *internalParams) appendValue(value string) { 111 | if p.i >= p.max { 112 | return 113 | } 114 | p.values[p.i] = value 115 | p.i++ 116 | } 117 | 118 | // reset resets the state. 119 | func (p *internalParams) reset() { 120 | p.i = 0 121 | p.keys = nil 122 | p.values = p.values[:0] 123 | } 124 | 125 | // get retrieves the value associated with the provided key. 126 | func (p *internalParams) get(key string) string { 127 | if p.keys != nil { 128 | for i, k := range *p.keys { 129 | if k == key { 130 | return p.values[i] 131 | } 132 | } 133 | } 134 | return "" 135 | } 136 | 137 | // forEach iterates through internalParams in the order params are defined in the route. 138 | func (p *internalParams) forEach(fn func(k, v string)) { 139 | if p.keys != nil { 140 | for i := len(*p.keys) - 1; i >= 0; i-- { 141 | fn((*p.keys)[i], p.values[i]) 142 | } 143 | } 144 | } 145 | 146 | // kvMap returns internalParams mapped into a [key]value map. 147 | func (p *internalParams) kvMap() map[string]string { 148 | params := make(map[string]string, len(*p.keys)) 149 | 150 | for i := len(*p.keys) - 1; i >= 0; i-- { 151 | params[(*p.keys)[i]] = p.values[i] 152 | } 153 | 154 | return params 155 | } 156 | 157 | // slice returns a slice of Param in the order params are defined in the route. 158 | func (p *internalParams) slice() []Param { 159 | params := make([]Param, 0, len(*p.keys)) 160 | 161 | for i := len(*p.keys) - 1; i >= 0; i-- { 162 | params = append(params, Param{ 163 | Key: (*p.keys)[i], 164 | Value: p.values[i], 165 | }) 166 | } 167 | 168 | return params 169 | } 170 | 171 | // deepCopy returns a deep-copy of internalParams. 172 | func (p *internalParams) deepCopy() *internalParams { 173 | values := make([]string, len(p.values)) 174 | copy(values, p.values) 175 | 176 | return &internalParams{ 177 | i: p.i, 178 | max: p.max, 179 | keys: p.keys, 180 | values: values, 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /param_test.go: -------------------------------------------------------------------------------- 1 | package shift 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestParams_Get(t *testing.T) { 9 | ip := newInternalParams(3) 10 | ip.setKeys(&[]string{"k3", "k2", "k1"}) 11 | ip.appendValue("xyz") 12 | ip.appendValue("bar") 13 | ip.appendValue("foo") 14 | 15 | p := newParams(ip) 16 | 17 | tests := map[string]string{ 18 | "k3": "xyz", 19 | "k2": "bar", 20 | "k1": "foo", 21 | } 22 | 23 | for k, v := range tests { 24 | val := p.Get(k) 25 | assert(t, val == v, fmt.Sprintf("expected: %s, got: %s", v, val)) 26 | } 27 | } 28 | 29 | func TestParams_ForEach(t *testing.T) { 30 | ip := newInternalParams(3) 31 | ip.setKeys(&[]string{"k3", "k2", "k1"}) 32 | ip.appendValue("xyz") 33 | ip.appendValue("bar") 34 | ip.appendValue("foo") 35 | 36 | p := newParams(ip) 37 | 38 | tests := []struct{ k, v string }{ 39 | {"k1", "foo"}, 40 | {"k2", "bar"}, 41 | {"k3", "xyz"}, 42 | } 43 | 44 | i := 0 45 | p.ForEach(func(k, v string) { 46 | assert(t, k == tests[i].k, fmt.Sprintf("key > expected: %s, got: %s", tests[i].k, k)) 47 | assert(t, v == tests[i].v, fmt.Sprintf("value > expected: %s, got: %s", tests[i].v, v)) 48 | i++ 49 | }) 50 | assert(t, i == 3, fmt.Sprintf("i > expected: %d, got: %d", 3, i)) 51 | } 52 | 53 | func TestParams_Map(t *testing.T) { 54 | ip := newInternalParams(3) 55 | ip.setKeys(&[]string{"k3", "k2", "k1"}) 56 | ip.appendValue("xyz") 57 | ip.appendValue("bar") 58 | ip.appendValue("foo") 59 | 60 | p := newParams(ip) 61 | 62 | tests := map[string]string{ 63 | "k1": "foo", 64 | "k2": "bar", 65 | "k3": "xyz", 66 | } 67 | 68 | params := p.Map() 69 | for k, v := range tests { 70 | val := params[k] 71 | assert(t, val == v, fmt.Sprintf("value for key %s, expected: %s, got: %s", k, v, val)) 72 | } 73 | assert(t, len(params) == 3, fmt.Sprintf("map length > expected: %d, got: %d", 3, len(params))) 74 | } 75 | 76 | func TestParams_Slice(t *testing.T) { 77 | ip := newInternalParams(3) 78 | ip.setKeys(&[]string{"k3", "k2", "k1"}) 79 | ip.appendValue("xyz") 80 | ip.appendValue("bar") 81 | ip.appendValue("foo") 82 | 83 | p := newParams(ip) 84 | 85 | tests := []struct{ k, v string }{ 86 | {"k1", "foo"}, 87 | {"k2", "bar"}, 88 | {"k3", "xyz"}, 89 | } 90 | 91 | params := p.Slice() 92 | for i := 0; i < len(tests); i++ { 93 | assert(t, params[i].Key == tests[i].k, fmt.Sprintf("key at index %d > expected: %s, got: %s", i, tests[i].k, params[i].Key)) 94 | assert(t, params[i].Value == tests[i].v, fmt.Sprintf("value at index %d > expected: %s, got: %s", i, tests[i].v, params[i].Value)) 95 | } 96 | assert(t, len(params) == 3, fmt.Sprintf("params count > expected: %d, got: %d", 3, len(params))) 97 | } 98 | 99 | func TestParams_Copy(t *testing.T) { 100 | t.Run("copied Params should not be equal to source Params", func(t *testing.T) { 101 | ip := newInternalParams(1) 102 | ip.setKeys(&[]string{"foo"}) 103 | ip.appendValue("bar") 104 | 105 | p := newParams(ip) 106 | cp := p.Copy() 107 | 108 | assert(t, cp != p, "expected to be unequal") 109 | assert(t, cp.internal != p.internal, "expected internal to be unequal") 110 | }) 111 | 112 | t.Run("copied Params should not be affected when source is reset", func(t *testing.T) { 113 | ip := newInternalParams(1) 114 | ip.setKeys(&[]string{"foo"}) 115 | ip.appendValue("bar") 116 | 117 | p := newParams(ip) 118 | cp := p.Copy() 119 | ip.reset() // resets internal of source 120 | 121 | val := cp.Get("foo") 122 | assert(t, val == "bar", fmt.Sprintf("expected: bar, got: %s", val)) 123 | }) 124 | 125 | t.Run("copied Params should not be affected when source is reset and reused", func(t *testing.T) { 126 | ip := newInternalParams(2) 127 | ip.setKeys(&[]string{"foo", "woo"}) 128 | ip.appendValue("bar") 129 | ip.appendValue("abc") 130 | 131 | p := newParams(ip) 132 | cp := p.Copy() 133 | ip.reset() // resets internal of source 134 | ip.setKeys(&[]string{"foo"}) 135 | ip.appendValue("xyz") 136 | 137 | val := cp.Get("foo") 138 | assert(t, val == "bar", fmt.Sprintf("expected: bar, got: %s", val)) 139 | }) 140 | } 141 | 142 | func BenchmarkParams_Copy(b *testing.B) { 143 | b.Run("with non- internal", func(b *testing.B) { 144 | ip := newInternalParams(10) 145 | ip.setKeys(&[]string{"1", "2", "3"}) 146 | ip.appendValue("abc") 147 | ip.appendValue("xyz") 148 | ip.appendValue("cap") 149 | 150 | p := newParams(ip) 151 | 152 | b.ReportAllocs() 153 | b.ResetTimer() 154 | 155 | for i := 0; i < b.N; i++ { 156 | p.Copy() 157 | } 158 | }) 159 | 160 | b.Run("with internal", func(b *testing.B) { 161 | p := newParams(nil) 162 | 163 | b.ReportAllocs() 164 | b.ResetTimer() 165 | 166 | for i := 0; i < b.N; i++ { 167 | p.Copy() 168 | } 169 | }) 170 | } 171 | 172 | func BenchmarkInternalParams_DeepCopy(b *testing.B) { 173 | ip := newInternalParams(10) 174 | ip.setKeys(&[]string{"1", "2", "3"}) 175 | ip.appendValue("abc") 176 | ip.appendValue("xyz") 177 | ip.appendValue("cap") 178 | 179 | b.ReportAllocs() 180 | b.ResetTimer() 181 | 182 | for i := 0; i < b.N; i++ { 183 | ip.deepCopy() 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /path.go: -------------------------------------------------------------------------------- 1 | // Copyright 2013 Julien Schmidt. All rights reserved. 2 | // Based on the path package, Copyright 2009 The Go Authors. 3 | // Use of this source code is governed by a BSD-style license that can be found 4 | // in the LICENSE file. 5 | 6 | package shift 7 | 8 | // cleanPath is the URL version of path.Clean, it returns a canonical URL path 9 | // for p, eliminating . and .. elements. 10 | // 11 | // The following rules are applied iteratively until no further processing can 12 | // be done: 13 | // 1. Replace multiple slashes with a single slash. 14 | // 2. Eliminate each . path name element (the current directory). 15 | // 3. Eliminate each inner .. path name element (the parent directory) 16 | // along with the non-.. element that precedes it. 17 | // 4. Eliminate .. elements that begin a rooted path: 18 | // that is, replace "/.." by "/" at the beginning of a path. 19 | // 20 | // If the result of this process is an empty string, "/" is returned 21 | func cleanPath(p string) string { 22 | const stackBufSize = 128 23 | 24 | // Turn empty string into "/" 25 | if p == "" { 26 | return "/" 27 | } 28 | 29 | // Reasonably sized buffer on stack to avoid allocations in the common case. 30 | // If a larger buffer is required, it gets allocated dynamically. 31 | buf := make([]byte, 0, stackBufSize) 32 | 33 | n := len(p) 34 | 35 | // Invariants: 36 | // reading from path; r is index of next byte to process. 37 | // writing to buf; w is index of next byte to write. 38 | 39 | // path must start with '/' 40 | r := 1 41 | w := 1 42 | 43 | if p[0] != '/' { 44 | r = 0 45 | 46 | if n+1 > stackBufSize { 47 | buf = make([]byte, n+1) 48 | } else { 49 | buf = buf[:n+1] 50 | } 51 | buf[0] = '/' 52 | } 53 | 54 | trailing := n > 1 && p[n-1] == '/' 55 | 56 | // A bit more clunky without a 'lazybuf' like the path package, but the loop 57 | // gets completely inlined (bufApp calls). 58 | // So in contrast to the path package this loop has no expensive function 59 | // calls (except make, if needed). 60 | 61 | for r < n { 62 | switch { 63 | case p[r] == '/': 64 | // empty path element, trailing slash is added after the end 65 | r++ 66 | 67 | case p[r] == '.' && r+1 == n: 68 | trailing = true 69 | r++ 70 | 71 | case p[r] == '.' && p[r+1] == '/': 72 | // . element 73 | r += 2 74 | 75 | case p[r] == '.' && p[r+1] == '.' && (r+2 == n || p[r+2] == '/'): 76 | // .. element: remove to last / 77 | r += 3 78 | 79 | if w > 1 { 80 | // can backtrack 81 | w-- 82 | 83 | if len(buf) == 0 { 84 | for w > 1 && p[w] != '/' { 85 | w-- 86 | } 87 | } else { 88 | for w > 1 && buf[w] != '/' { 89 | w-- 90 | } 91 | } 92 | } 93 | 94 | default: 95 | // Real path element. 96 | // Add slash if needed 97 | if w > 1 { 98 | bufApp(&buf, p, w, '/') 99 | w++ 100 | } 101 | 102 | // Copy element 103 | for r < n && p[r] != '/' { 104 | bufApp(&buf, p, w, p[r]) 105 | w++ 106 | r++ 107 | } 108 | } 109 | } 110 | 111 | // Re-append trailing slash 112 | if trailing && w > 1 { 113 | bufApp(&buf, p, w, '/') 114 | w++ 115 | } 116 | 117 | // If the original string was not modified (or only shortened at the end), 118 | // return the respective substring of the original string. 119 | // Otherwise return a new string from the buffer. 120 | if len(buf) == 0 { 121 | return p[:w] 122 | } 123 | return string(buf[:w]) 124 | } 125 | 126 | // Internal helper to lazily create a buffer if necessary. 127 | // Calls to this function get inlined. 128 | func bufApp(buf *[]byte, s string, w int, c byte) { 129 | b := *buf 130 | if len(b) == 0 { 131 | // No modification of the original string so far. 132 | // If the next character is the same as in the original string, we do 133 | // not yet have to allocate a buffer. 134 | if s[w] == c { 135 | return 136 | } 137 | 138 | // Otherwise use either the stack buffer, if it is large enough, or 139 | // allocate a new buffer on the heap, and copy all previous characters. 140 | if length := len(s); length > cap(b) { 141 | *buf = make([]byte, len(s)) 142 | } else { 143 | *buf = (*buf)[:length] 144 | } 145 | b = *buf 146 | 147 | copy(b, s[:w]) 148 | } 149 | b[w] = c 150 | } 151 | -------------------------------------------------------------------------------- /path_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2013 Julien Schmidt. All rights reserved. 2 | // Based on the path package, Copyright 2009 The Go Authors. 3 | // Use of this source code is governed by a BSD-style license that can be found 4 | // in the LICENSE file. 5 | 6 | package shift 7 | 8 | import ( 9 | "strings" 10 | "testing" 11 | ) 12 | 13 | type cleanPathTest struct { 14 | path, result string 15 | } 16 | 17 | var cleanTests = []cleanPathTest{ 18 | // Already clean 19 | {"/", "/"}, 20 | {"/abc", "/abc"}, 21 | {"/a/b/c", "/a/b/c"}, 22 | {"/abc/", "/abc/"}, 23 | {"/a/b/c/", "/a/b/c/"}, 24 | 25 | // missing root 26 | {"", "/"}, 27 | {"a/", "/a/"}, 28 | {"abc", "/abc"}, 29 | {"abc/def", "/abc/def"}, 30 | {"a/b/c", "/a/b/c"}, 31 | 32 | // Remove doubled slash 33 | {"//", "/"}, 34 | {"/abc//", "/abc/"}, 35 | {"/abc/def//", "/abc/def/"}, 36 | {"/a/b/c//", "/a/b/c/"}, 37 | {"/abc//def//ghi", "/abc/def/ghi"}, 38 | {"//abc", "/abc"}, 39 | {"///abc", "/abc"}, 40 | {"//abc//", "/abc/"}, 41 | 42 | // Remove . elements 43 | {".", "/"}, 44 | {"./", "/"}, 45 | {"/abc/./def", "/abc/def"}, 46 | {"/./abc/def", "/abc/def"}, 47 | {"/abc/.", "/abc/"}, 48 | 49 | // Remove .. elements 50 | {"..", "/"}, 51 | {"../", "/"}, 52 | {"../../", "/"}, 53 | {"../..", "/"}, 54 | {"../../abc", "/abc"}, 55 | {"/abc/def/ghi/../jkl", "/abc/def/jkl"}, 56 | {"/abc/def/../ghi/../jkl", "/abc/jkl"}, 57 | {"/abc/def/..", "/abc"}, 58 | {"/abc/def/../..", "/"}, 59 | {"/abc/def/../../..", "/"}, 60 | {"/abc/def/../../..", "/"}, 61 | {"/abc/def/../../../ghi/jkl/../../../mno", "/mno"}, 62 | 63 | // Combinations 64 | {"abc/./../def", "/def"}, 65 | {"abc//./../def", "/def"}, 66 | {"abc/../../././../def", "/def"}, 67 | } 68 | 69 | func TestPathClean(t *testing.T) { 70 | for _, test := range cleanTests { 71 | if s := cleanPath(test.path); s != test.result { 72 | t.Errorf("CleanPath(%q) = %q, want %q", test.path, s, test.result) 73 | } 74 | if s := cleanPath(test.result); s != test.result { 75 | t.Errorf("CleanPath(%q) = %q, want %q", test.result, s, test.result) 76 | } 77 | } 78 | } 79 | 80 | func BenchmarkPathClean(b *testing.B) { 81 | b.ReportAllocs() 82 | 83 | for i := 0; i < b.N; i++ { 84 | for _, test := range cleanTests { 85 | cleanPath(test.path) 86 | } 87 | } 88 | } 89 | 90 | func genLongPaths() (testPaths []cleanPathTest) { 91 | for i := 1; i <= 1234; i++ { 92 | ss := strings.Repeat("a", i) 93 | 94 | correctPath := "/" + ss 95 | testPaths = append(testPaths, cleanPathTest{ 96 | path: correctPath, 97 | result: correctPath, 98 | }, cleanPathTest{ 99 | path: ss, 100 | result: correctPath, 101 | }, cleanPathTest{ 102 | path: "//" + ss, 103 | result: correctPath, 104 | }, cleanPathTest{ 105 | path: "/" + ss + "/b/..", 106 | result: correctPath, 107 | }) 108 | } 109 | return testPaths 110 | } 111 | 112 | func TestPathCleanLong(t *testing.T) { 113 | cleanTests := genLongPaths() 114 | 115 | for _, test := range cleanTests { 116 | if s := cleanPath(test.path); s != test.result { 117 | t.Errorf("CleanPath(%q) = %q, want %q", test.path, s, test.result) 118 | } 119 | if s := cleanPath(test.result); s != test.result { 120 | t.Errorf("CleanPath(%q) = %q, want %q", test.result, s, test.result) 121 | } 122 | } 123 | } 124 | 125 | func BenchmarkPathCleanLong(b *testing.B) { 126 | cleanTests := genLongPaths() 127 | b.ResetTimer() 128 | b.ReportAllocs() 129 | 130 | for i := 0; i < b.N; i++ { 131 | for _, test := range cleanTests { 132 | cleanPath(test.path) 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /radix.go: -------------------------------------------------------------------------------- 1 | package shift 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strings" 7 | "unicode" 8 | ) 9 | 10 | type node struct { 11 | prefix string 12 | template string 13 | children []*node 14 | param *node 15 | wildcard *node 16 | handler HandlerFunc 17 | paramKeys *[]string // Nil paramKeys denote the route is static. 18 | index struct { 19 | minChar uint8 20 | maxChar uint8 21 | 22 | // Map all the characters between minChar and maxChar. Therefore, length = maxChar - minChar. 23 | // The values point to the relevant children node position for each character. 24 | // 25 | // Value == 0 indicates, there's no matching child node for the character. 26 | // Value > 0 points to the index of the matching child node + 1. 27 | // 28 | // e.g.: 29 | // minChar = 97 (a) 30 | // maxChar = 100 (d) 31 | // 32 | // children[0] = 97 (a) node 33 | // children[2] = 99 (c) node 34 | // children[3] = 100 (d) node 35 | // 36 | // indices[0] = 1 37 | // indices[1] = 0 38 | // indices[2] = 2 39 | // indices[3] = 3 40 | indices []uint8 41 | 42 | // Index the character lengths of child node prefixes following the exact order of indices. 43 | // 44 | // e.g.: 45 | // minChar = 97 (a) 46 | // maxChar = 100 (d) 47 | // 48 | // children[0] = 97 (a) node, prefix = 'apple' 49 | // children[2] = 99 (c) node, prefix = 'castle' 50 | // children[3] = 100 (d) node, prefix = 'dang' 51 | // 52 | // size[0] = 5 53 | // size[1] = 0 54 | // size[2] = 6 55 | // size[3] = 4 56 | size []int 57 | } 58 | } 59 | 60 | func newRootNode() *node { 61 | return &node{ 62 | template: "/", 63 | } 64 | } 65 | 66 | func (n *node) insert(path string, handler HandlerFunc) (varsCount int) { 67 | varsCount = scanPath(path) 68 | 69 | if path == "" { 70 | // Root node. 71 | n.template = "/" 72 | n.handler = handler 73 | return 74 | } 75 | 76 | newNode, paramKeys := n.addNode(path) 77 | if newNode.handler != nil { 78 | panic(fmt.Sprintf("%s conflicts with already registered route %s", path, newNode.template)) 79 | } 80 | 81 | newNode.template = path 82 | newNode.handler = handler 83 | if len(paramKeys) > 0 { 84 | rs := reverseSlice(paramKeys) 85 | newNode.paramKeys = &rs 86 | } 87 | return 88 | } 89 | 90 | func reverseSlice(s []string) (rs []string) { 91 | if len(s) > 1 { 92 | for i := 0; i < len(s)/2; i++ { 93 | (s)[i], (s)[len(s)-1-i] = (s)[len(s)-1-i], (s)[i] 94 | } 95 | } 96 | return s 97 | } 98 | 99 | func (n *node) addNode(path string) (root *node, paramKeys []string) { 100 | if path[0] == '/' { 101 | path = path[1:] 102 | } 103 | 104 | root = n 105 | r := newRouteScanner(path) 106 | 107 | for seg := r.next(); seg != ""; seg = r.next() { 108 | switch seg[0] { 109 | case ':': 110 | paramKeys = append(paramKeys, seg[1:]) 111 | if root.param != nil { 112 | root = root.param 113 | continue 114 | } 115 | 116 | root.param = &node{prefix: ":"} 117 | root = root.param 118 | case '*': 119 | paramKeys = append(paramKeys, seg[1:]) 120 | if root.wildcard != nil { 121 | root = root.wildcard 122 | break 123 | } 124 | 125 | root.wildcard = &node{prefix: "*"} 126 | root = root.wildcard 127 | default: 128 | DFS: 129 | if seg == "" { 130 | continue 131 | } 132 | 133 | candidate, candidateIdx := root.findCandidateByChar(seg[0]) 134 | if candidate == nil { 135 | child := &node{prefix: seg} 136 | root.children = append(root.children, child) 137 | root.reindex() 138 | root = child 139 | continue 140 | } 141 | 142 | longest := longestPrefix(seg, candidate.prefix) 143 | 144 | // Traversal. 145 | // pfx: /posts 146 | // seg: /posts|/upsert 147 | if longest == len(candidate.prefix) { 148 | root = candidate 149 | seg = seg[longest:] 150 | goto DFS 151 | } 152 | 153 | // Expansion. 154 | // pfx: categories|/skus 155 | // seg: categories| 156 | if longest == len(seg) { 157 | // Shift down the candidate node and allocate its prior state to the segment. 158 | branchNode := &node{prefix: candidate.prefix[:longest], children: make([]*node, 1)} 159 | 160 | candidate.prefix = candidate.prefix[longest:] 161 | 162 | branchNode.children[0] = candidate 163 | branchNode.reindex() 164 | 165 | root.children[candidateIdx] = branchNode 166 | root.reindex() 167 | 168 | root = branchNode 169 | continue 170 | } 171 | 172 | // Collision. 173 | // pfx: cat|egories 174 | // seg: cat|woman 175 | 176 | // Split the node into 2 at the point of collision. 177 | newNode := &node{prefix: seg[longest:]} 178 | 179 | branchNode := &node{prefix: candidate.prefix[:longest], children: make([]*node, 2)} 180 | branchNode.children[0] = candidate 181 | branchNode.children[1] = newNode 182 | 183 | candidate.prefix = candidate.prefix[longest:] 184 | 185 | branchNode.reindex() 186 | 187 | root.children[candidateIdx] = branchNode 188 | root.reindex() 189 | 190 | root = newNode 191 | continue 192 | } 193 | } 194 | 195 | return root, paramKeys 196 | } 197 | 198 | // findCandidateByCharAndSize search for a children by matching the first char and length. 199 | // If no match is found, it looks up indexer#trailingSlash to see if there's a possible match who has a trailing slash. 200 | // If found, returns the found children with trailing slash and true for the 2nd return value. 201 | // Otherwise, return nil, false. 202 | // 203 | // When ts (2nd return value) is false, there's a guarantee that len(s) >= len(child prefix). 204 | // When ts is true, len(s) = len(child prefix) - 1. 205 | func (n *node) findCandidateByCharAndSize(c uint8, size int) *node { 206 | if n.index.minChar <= c && c <= n.index.maxChar { 207 | offset := c - n.index.minChar 208 | index := n.index.indices[offset] 209 | if index == 0 { 210 | return nil 211 | } 212 | 213 | childSize := n.index.size[offset] 214 | if size >= childSize { 215 | return n.children[index-1] // Decrease by 1 to get the exact child node index. 216 | } 217 | } 218 | 219 | return nil 220 | } 221 | 222 | func (n *node) findCandidateByChar(c uint8) (*node, uint8) { 223 | if n.index.minChar <= c && c <= n.index.maxChar { 224 | offset := c - n.index.minChar 225 | childIndex := n.index.indices[offset] 226 | if childIndex == 0 { 227 | return nil, 0 228 | } 229 | 230 | return n.children[childIndex-1], childIndex - 1 // Decrease by 1 to get the exact child node index. 231 | } 232 | 233 | return nil, 0 234 | } 235 | 236 | func (n *node) reindex() { 237 | if len(n.children) == 0 { 238 | return 239 | } 240 | 241 | // Sort children by prefix's first char. 242 | sort.Slice(n.children, func(i, j int) bool { 243 | return n.children[i].prefix[0] < n.children[j].prefix[0] 244 | }) 245 | 246 | n.index.minChar = n.children[0].prefix[0] 247 | n.index.maxChar = n.children[len(n.children)-1].prefix[0] 248 | rng := n.index.maxChar - n.index.minChar + 1 249 | 250 | if len(n.index.indices) != int(rng) { 251 | n.index.indices = make([]uint8, rng) 252 | } 253 | 254 | if len(n.index.size) != int(rng) { 255 | n.index.size = make([]int, rng) 256 | } 257 | 258 | for i, child := range n.children { 259 | idx := child.prefix[0] - n.index.minChar 260 | n.index.indices[idx] = uint8(i) + 1 261 | n.index.size[idx] = len(child.prefix) 262 | } 263 | } 264 | 265 | func (n *node) search(path string, paramInjector func() *internalParams) (*node, *internalParams) { 266 | if len(path) > 0 && path[0] == '/' { 267 | path = path[1:] 268 | } 269 | 270 | if path == "" { 271 | return n, nil 272 | } 273 | 274 | return n.searchRecursion(path, nil, paramInjector) 275 | } 276 | 277 | // searchRecursion recursively traverses the radix tree looking for a matching node. 278 | // Returns the matched node if found. 279 | // Returns internalParams only when matched node is a param node. Returns otherwise. 280 | func (n *node) searchRecursion(path string, params *internalParams, paramInjector func() *internalParams) (*node, *internalParams) { 281 | // Search a matching node inside node's children. 282 | // Char could be indexed? 283 | if c := path[0]; n.index.minChar <= c && c <= n.index.maxChar { 284 | // Yes, char could be indexed... 285 | 286 | // Is char really indexed? 287 | if idx := n.index.indices[c-n.index.minChar]; idx != 0 { 288 | // Char is indexed!!! 289 | 290 | if child := n.children[idx-1]; child != nil { 291 | if path == child.prefix { 292 | // Perfect match. 293 | // path: /foobar 294 | // pref: /foobar 295 | 296 | // Dead end #1 297 | if child.handler != nil { 298 | if child.paramKeys != nil { 299 | params = paramInjector() 300 | params.setKeys(child.paramKeys) 301 | } 302 | return child, params 303 | } 304 | 305 | // But a handler is not registered :( 306 | // 307 | // So, lets fallback to wildcard node... 308 | // No need to perform check for handler and paramKeys here 309 | // since a wildcard node must always have a handler and paramKeys. 310 | // 311 | // Dead end #2 312 | if child.wildcard != nil { 313 | params = paramInjector() 314 | params.setKeys(child.wildcard.paramKeys) 315 | params.appendValue(path[len(child.prefix):]) 316 | return child.wildcard, params 317 | } 318 | } else if strings.HasPrefix(path, child.prefix) { 319 | // path: /foobar 320 | // pref: /foo 321 | 322 | // Explore child... 323 | var innerChild *node 324 | innerChild, params = child.searchRecursion(path[len(child.prefix):], params, paramInjector) 325 | if innerChild != nil && innerChild.handler != nil { 326 | return innerChild, params 327 | } 328 | } 329 | } 330 | } 331 | } 332 | 333 | // Couldn't find a matching node within children nodes. 334 | // So lets fallback to param node. 335 | if n.param != nil { 336 | // Check if more sections are left to match in the path. 337 | 338 | // When idx == 0, it means param value in the path is empty. 339 | // Example 1: /posts//comments (should avoid matching route: /posts/:id/comments 340 | // Example 2: /users/ (should avoid matching route: /users:id) 341 | if idx := strings.IndexByte(path, '/'); idx > 0 { 342 | // Traverse the param node until all the path sections are matched. 343 | var innerChild *node 344 | innerChild, params = n.param.searchRecursion(path[idx:], params, paramInjector) 345 | if innerChild != nil && innerChild.handler != nil { 346 | params.appendValue(path[:idx]) 347 | return innerChild, params 348 | } 349 | } else if idx == -1 && n.param.handler != nil { 350 | // No more sections to match and has a valid handler for the param node. 351 | // Dead end #3 352 | params = paramInjector() 353 | params.setKeys(n.param.paramKeys) // Param node would always have paramKeys. 354 | params.appendValue(path) 355 | return n.param, params 356 | } 357 | } 358 | 359 | // No luck with param node :/ 360 | // Lets fallback to wildcard node. 361 | // No need to perform check for handler and paramKeys here 362 | // since a wildcard node must always have a handler and paramKeys. 363 | // 364 | // Dead end #4 365 | if n.wildcard != nil { 366 | params = paramInjector() 367 | params.setKeys(n.wildcard.paramKeys) 368 | params.appendValue(path) 369 | return n.wildcard, params 370 | } 371 | 372 | // No match :((( 373 | return nil, params 374 | } 375 | 376 | func scanPath(path string) (varsCount int) { 377 | if path == "" || path[0] != '/' { 378 | panic("path must have a leading slash") 379 | } 380 | 381 | inParams := false 382 | inWC := false 383 | for i, c := range []byte(path) { 384 | if unicode.IsSpace(rune(c)) { 385 | panic("path shouldn't contain any whitespace") 386 | } 387 | 388 | if inWC { 389 | switch c { 390 | case '/', ':': 391 | panic("another segment shouldn't follow a wildcard segment") 392 | case '*': 393 | panic("only one wildcard segment is allowed") 394 | } 395 | } 396 | 397 | if inParams { 398 | switch c { 399 | case '/': 400 | if path[i-1] == ':' { 401 | panic("param must have a name") 402 | } 403 | inParams = false 404 | continue 405 | case ':': 406 | panic("only one param segment is allowed within the same scope") 407 | case '*': 408 | panic("wildcard segment shouldn't follow the param segment within the same scope") 409 | } 410 | } 411 | 412 | if c == '*' { 413 | inWC = true 414 | varsCount++ 415 | continue 416 | } 417 | 418 | if c == ':' { 419 | inParams = true 420 | varsCount++ 421 | continue 422 | } 423 | } 424 | 425 | if inParams && path[len(path)-1] == ':' { 426 | panic("param must have a name") 427 | } 428 | 429 | if inWC && path[len(path)-1] == '*' { 430 | panic("wildcard must have a name") 431 | } 432 | 433 | return 434 | } 435 | 436 | func (n *node) caseInsensitiveSearch(path string, paramInjector func() *internalParams) (*node, *internalParams, string) { 437 | if len(path) > 0 && path[0] == '/' { 438 | path = path[1:] 439 | } 440 | 441 | if path == "" { 442 | return n, nil, "" 443 | } 444 | 445 | var buf reverseBuffer = newReverseBuffer128() // No heap allocation. 446 | if lng := len(path) + 1; lng > 128 { // Account an additional space for the leading slash. 447 | buf = newSizedReverseBuffer(lng) // For long paths, allocate a sized buffer on heap. 448 | } 449 | 450 | fn, ps := n.caseInsensitiveSearchRecursion(path, nil, paramInjector, buf) 451 | if fn != nil && fn.handler != nil { 452 | buf.WriteString("/") // Write leading slash. 453 | } 454 | return fn, ps, buf.String() 455 | } 456 | 457 | func (n *node) caseInsensitiveSearchRecursion(path string, params *internalParams, paramInjector func() *internalParams, buf reverseBuffer) (*node, *internalParams) { 458 | var swappedChild bool 459 | 460 | // Look for a child node whose first char equals searching path's first char and prefix length 461 | // is less than or equal searching path's length. 462 | child := n.findCandidateByCharAndSize(path[0], len(path)) 463 | 464 | TraverseChild: 465 | if child != nil { 466 | // Find the longest common prefix between child's prefix and searching path. 467 | // If child's prefix is fully matched, continue... 468 | // Otherwise, fallback... 469 | if longest := longestPrefixCaseInsensitive(child.prefix, path); longest == len(child.prefix) { 470 | if longest == len(path) { 471 | // Perfect match. And no further segments are left to cover in the searching path. 472 | // path: /foobar 473 | // pref: /foobar 474 | 475 | // Dead end #1 476 | if child.handler != nil { 477 | if child.paramKeys != nil { 478 | params = paramInjector() 479 | params.setKeys(child.paramKeys) 480 | } 481 | buf.WriteString(child.prefix) 482 | return child, params 483 | } 484 | 485 | // Though there's a matching node, it doesn't have a handler. 486 | // Try to elect matched node's wildcard node. 487 | //// No need to perform nil check for handler and paramKeys here 488 | //// since a wildcard node must always have a handler and paramKeys. 489 | // 490 | // Dead end #2 491 | if child.wildcard != nil { 492 | params = paramInjector() 493 | params.setKeys(child.wildcard.paramKeys) 494 | params.appendValue(path[longest:]) 495 | buf.WriteString(path[longest:]) 496 | return child.wildcard, params 497 | } 498 | 499 | return nil, params 500 | } else { 501 | // There are more segments to cover in the searching path. 502 | 503 | // Traverse the child node recursively until a match is found. 504 | var dfsChild *node 505 | if dfsChild, params = child.caseInsensitiveSearchRecursion(path[len(child.prefix):], params, paramInjector, buf); dfsChild != nil && dfsChild.handler != nil { 506 | // Found a matching node with a registered handler. 507 | buf.WriteString(child.prefix) 508 | return dfsChild, params 509 | } 510 | } 511 | } 512 | } 513 | 514 | // Didn't find a matching node. 515 | 516 | // We could try swapping if we haven't swapped already... 517 | if !swappedChild { 518 | if sc, swapped := swapCase(path[0]); swapped { 519 | child = n.findCandidateByCharAndSize(sc, len(path)) 520 | swappedChild = true 521 | goto TraverseChild 522 | } 523 | } 524 | 525 | // Fallback to param node. 526 | if n.param != nil { 527 | // Check if more segments are left to cover in the searching path. 528 | if idx := strings.IndexByte(path, '/'); idx == -1 { 529 | 530 | // No more segments in the path. 531 | // Dead end #3 532 | if n.param.handler != nil { 533 | params = paramInjector() 534 | params.setKeys(n.param.paramKeys) // Param node would always have paramKeys. 535 | params.appendValue(path) 536 | buf.WriteString(path) 537 | return n.param, params 538 | } 539 | 540 | // The param node might have children who have handlers but no need to explore them 541 | // since the searching path has no more segments left to cover. 542 | // Thus, fallback to the wildcard node. 543 | 544 | } else { 545 | 546 | // Traverse the param node until all the segments are exhausted. 547 | if child, params = n.param.caseInsensitiveSearchRecursion(path[idx:], params, paramInjector, buf); child != nil && child.handler != nil { 548 | params.appendValue(path[:idx]) 549 | buf.WriteString(path[:idx]) 550 | return child, params 551 | } 552 | } 553 | } 554 | 555 | // Fallback to wildcard node. 556 | // 557 | // This also facilitates to fall back to the nearest wildcard node in the recursion stack when no match is found. 558 | //// No need to perform nil check for handler and paramKeys here 559 | //// since a wildcard node must always have a handler and paramKeys. 560 | // 561 | // Dead end #4 562 | if n.wildcard != nil { 563 | params = paramInjector() 564 | params.setKeys(n.wildcard.paramKeys) 565 | params.appendValue(path) 566 | buf.WriteString(path) 567 | return n.wildcard, params 568 | } 569 | 570 | return nil, params 571 | } 572 | 573 | func findParamsCount(path string) (c int) { 574 | for _, b := range []byte(path) { 575 | if b == ':' || b == '*' { 576 | c++ 577 | } 578 | } 579 | return c 580 | } 581 | 582 | func longestPrefix(s1, s2 string) int { 583 | max := len(s1) 584 | if len(s2) < max { 585 | max = len(s2) 586 | } 587 | 588 | i := 0 589 | for ; i < max; i++ { 590 | if s1[i] != s2[i] { 591 | return i 592 | } 593 | } 594 | return i 595 | } 596 | 597 | func longestPrefixCaseInsensitive(s1, s2 string) int { 598 | max := len(s1) 599 | if len(s2) < max { 600 | max = len(s2) 601 | } 602 | 603 | i := 0 604 | for ; i < max; i++ { 605 | if s1[i] != s2[i] { 606 | if sc, swapped := swapCase(s2[i]); swapped && s1[i] == sc { 607 | continue 608 | } 609 | return i 610 | } 611 | } 612 | return i 613 | } 614 | 615 | func swapCase(r uint8) (uint8, bool) { 616 | if r < 'A' || r > 'z' || r > 'Z' && r < 'a' { 617 | return r, false 618 | } 619 | 620 | isLower := r >= 'a' && r <= 'z' 621 | 622 | if isLower { 623 | r -= 'a' - 'A' 624 | } else { 625 | r += 'a' - 'A' 626 | } 627 | 628 | return r, true 629 | } 630 | -------------------------------------------------------------------------------- /radix_test.go: -------------------------------------------------------------------------------- 1 | package shift 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "testing" 7 | ) 8 | 9 | var fakeHttpHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) 10 | 11 | type testItem1 struct { 12 | path string 13 | valid bool 14 | pathTemplate string 15 | } 16 | 17 | type testItem2 struct { 18 | path string 19 | valid bool 20 | pathTemplate string 21 | params map[string]string 22 | } 23 | 24 | type testTable1 = []testItem1 25 | type testTable2 = []testItem2 26 | 27 | func TestStatic(t *testing.T) { 28 | paths := [...]string{ 29 | "/users/find", 30 | "/users/delete", 31 | "/users/all/dump", 32 | "/users/all/export", 33 | "/users/any", 34 | "/search", 35 | "/search/go", 36 | "/search/go1.html", 37 | "/search/index.html", 38 | "/src/invalid", 39 | "/src1", 40 | "/signal-r", 41 | "/query/unknown", 42 | "/query/unknown/pages", 43 | "/query/untold", 44 | "/questions", 45 | "/graphql", 46 | "/graph", 47 | } 48 | 49 | tree := newRootNode() 50 | 51 | paramsCount := 0 52 | for _, path := range paths { 53 | tree.insert(path, HTTPHandlerFunc(fakeHttpHandler)) 54 | pc := findParamsCount(path) 55 | if pc > paramsCount { 56 | paramsCount = pc 57 | } 58 | } 59 | 60 | params := newInternalParams(paramsCount) 61 | 62 | tt := testTable1{ 63 | {path: "/users/find", valid: true, pathTemplate: "/users/find"}, 64 | {path: "/users/delete", valid: true, pathTemplate: "/users/delete"}, 65 | {path: "/users/all/dump", valid: true, pathTemplate: "/users/all/dump"}, 66 | {path: "/users/all/import", valid: false, pathTemplate: ""}, 67 | {path: "/users/all/export", valid: true, pathTemplate: "/users/all/export"}, 68 | {path: "/users/any", valid: true, pathTemplate: "/users/any"}, 69 | {path: "/users/911", valid: false, pathTemplate: ""}, 70 | {path: "/search", valid: true, pathTemplate: "/search"}, 71 | {path: "/search/go", valid: true, pathTemplate: "/search/go"}, 72 | {path: "/search/go1.html", valid: true, pathTemplate: "/search/go1.html"}, 73 | {path: "/search/index.html", valid: true, pathTemplate: "/search/index.html"}, 74 | {path: "/search/index.html/from-cache", valid: false, pathTemplate: ""}, 75 | {path: "/search/contact.html", valid: false, pathTemplate: ""}, 76 | {path: "/src/invalid", valid: true, pathTemplate: "/src/invalid"}, 77 | {path: "/src", valid: false, pathTemplate: ""}, 78 | {path: "/src1", valid: true, pathTemplate: "/src1"}, 79 | {path: "/signal-r", valid: true, pathTemplate: "/signal-r"}, 80 | {path: "/signal-r/connect", valid: false, pathTemplate: ""}, 81 | {path: "/query/unknown", valid: true, pathTemplate: "/query/unknown"}, 82 | {path: "/query/unknown/pages", valid: true, pathTemplate: "/query/unknown/pages"}, 83 | {path: "/query/untold", valid: true, pathTemplate: "/query/untold"}, 84 | {path: "/query", valid: false, pathTemplate: ""}, 85 | {path: "/questions", valid: true, pathTemplate: "/questions"}, 86 | {path: "/graphql", valid: true, pathTemplate: "/graphql"}, 87 | {path: "/graph", valid: true, pathTemplate: "/graph"}, 88 | {path: "/graphq", valid: false, pathTemplate: ""}, 89 | } 90 | 91 | testSearch(t, tree, params, tt) 92 | } 93 | 94 | func TestDynamicRoutes(t *testing.T) { 95 | paths := [...]string{ 96 | "/users/find/:name", 97 | "/users/:id/delete", 98 | "/users/groups/:groupId/dump", 99 | "/users/groups/:groupId/export", 100 | "/users/:id/update", 101 | "/search/:q", 102 | "/search/:q/go", 103 | "/search/:q/go1.html", 104 | "/search/:q/:w/index.html", 105 | "/src/:dest/invalid", 106 | "/src1/:dest", 107 | "/signal-r/:cmd", 108 | "/signal-r/:cmd/reflection", 109 | "/query/:key", 110 | "/query/:key/:val", 111 | "/query/:key/:val/:cmd", 112 | "/query/:key/:val/:cmd/single", 113 | "/questions/:index", 114 | "/graphql/:cmd", 115 | "/:file", 116 | "/:file/remove", 117 | "/hero-:name", 118 | } 119 | 120 | tree := &node{} 121 | 122 | paramsCount := 0 123 | for _, path := range paths { 124 | tree.insert(path, HTTPHandlerFunc(fakeHttpHandler)) 125 | pc := findParamsCount(path) 126 | if pc > paramsCount { 127 | paramsCount = pc 128 | } 129 | } 130 | 131 | params := newInternalParams(paramsCount) 132 | 133 | tt := testTable1{ 134 | {path: "/users/find/yousuf", valid: true, pathTemplate: "/users/find/:name"}, 135 | {path: "/users/find/yousuf/import", valid: false, pathTemplate: ""}, 136 | {path: "/users/john/delete", valid: true, pathTemplate: "/users/:id/delete"}, 137 | {path: "/users/groups/120/dump", valid: true, pathTemplate: "/users/groups/:groupId/dump"}, 138 | {path: "/users/groups/230/export", valid: true, pathTemplate: "/users/groups/:groupId/export"}, 139 | {path: "/users/groups/230/export/csv", valid: false, pathTemplate: ""}, 140 | {path: "/users/911/update", valid: true, pathTemplate: "/users/:id/update"}, 141 | {path: "/search/ducks", valid: true, pathTemplate: "/search/:q"}, 142 | {path: "/search/gophers/go", valid: true, pathTemplate: "/search/:q/go"}, 143 | {path: "/search/gophers/rust", valid: false, pathTemplate: ""}, 144 | {path: "/search/nature/go1.html", valid: true, pathTemplate: "/search/:q/go1.html"}, 145 | {path: "/search/generics/types/index.html", valid: true, pathTemplate: "/search/:q/:w/index.html"}, 146 | {path: "/src/paris/invalid", valid: true, pathTemplate: "/src/:dest/invalid"}, 147 | {path: "/src1/oslo", valid: true, pathTemplate: "/src1/:dest"}, 148 | {path: "/src1/toronto/ontario", valid: false, pathTemplate: ""}, 149 | {path: "/signal-r/push", valid: true, pathTemplate: "/signal-r/:cmd"}, 150 | {path: "/signal-r/protos/reflection", valid: true, pathTemplate: "/signal-r/:cmd/reflection"}, 151 | {path: "/query/911", valid: true, pathTemplate: "/query/:key"}, 152 | {path: "/query/46/hello", valid: true, pathTemplate: "/query/:key/:val"}, 153 | {path: "/query/99/sup/update-ttl", valid: true, pathTemplate: "/query/:key/:val/:cmd"}, 154 | {path: "/query/10/amazing/reset/single", valid: true, pathTemplate: "/query/:key/:val/:cmd/single"}, 155 | {path: "/query/10/amazing/reset/single/1", valid: false, pathTemplate: ""}, 156 | {path: "/questions/1001", valid: true, pathTemplate: "/questions/:index"}, 157 | {path: "/graphql/stream", valid: true, pathTemplate: "/graphql/:cmd"}, 158 | {path: "/graphql/stream/tcp", valid: false, pathTemplate: ""}, 159 | {path: "/gophers.html", valid: true, pathTemplate: "/:file"}, 160 | {path: "/gophers.html/remove", valid: true, pathTemplate: "/:file/remove"}, 161 | {path: "/gophers.html/fetch", valid: false, pathTemplate: ""}, 162 | {path: "/hero-goku", valid: true, pathTemplate: "/hero-:name"}, 163 | {path: "/hero-thor", valid: true, pathTemplate: "/hero-:name"}, 164 | {path: "/hero-", valid: true, pathTemplate: "/:file"}, 165 | } 166 | 167 | testSearch(t, tree, params, tt) 168 | } 169 | 170 | func TestDynamicRoutesWithParams(t *testing.T) { 171 | paths := [...]string{ 172 | "/users/find/:name", 173 | "/users/:id/delete", 174 | "/users/groups/:groupId/dump", 175 | "/users/groups/:groupId/export", 176 | "/users/:id/update", 177 | "/search/:q", 178 | "/search/:q/go", 179 | "/search/:q/go1.html", 180 | "/search/:q/:w/index.html", 181 | "/src/:dest/invalid", 182 | "/src1/:dest", 183 | "/signal-r/:cmd", 184 | "/signal-r/:cmd/reflection", 185 | "/query/:key", 186 | "/query/:key/:val", 187 | "/query/:key/:val/:cmd", 188 | "/query/:key/:val/:cmd/single", 189 | "/questions/:index", 190 | "/graphql/:cmd", 191 | "/:file", 192 | "/:file/remove", 193 | "/hero-:name", 194 | } 195 | 196 | tree := &node{} 197 | 198 | maxParams := 0 199 | for _, path := range paths { 200 | tree.insert(path, HTTPHandlerFunc(fakeHttpHandler)) 201 | 202 | pc := findParamsCount(path) 203 | if pc > maxParams { 204 | maxParams = pc 205 | } 206 | } 207 | 208 | tt := testTable2{ 209 | {path: "/users/find/yousuf", valid: true, pathTemplate: "/users/find/:name", params: map[string]string{"name": "yousuf"}}, 210 | {path: "/users/find/yousuf/import", valid: false, pathTemplate: "", params: nil}, 211 | {path: "/users/john/delete", valid: true, pathTemplate: "/users/:id/delete", params: map[string]string{"id": "john"}}, 212 | {path: "/users/groups/120/dump", valid: true, pathTemplate: "/users/groups/:groupId/dump", params: map[string]string{"groupId": "120"}}, 213 | {path: "/users/groups/230/export", valid: true, pathTemplate: "/users/groups/:groupId/export", params: map[string]string{"groupId": "230"}}, 214 | {path: "/users/groups/230/export/csv", valid: false, pathTemplate: "", params: nil}, 215 | {path: "/users/911/update", valid: true, pathTemplate: "/users/:id/update", params: map[string]string{"id": "911"}}, 216 | {path: "/search/ducks", valid: true, pathTemplate: "/search/:q", params: map[string]string{"q": "ducks"}}, 217 | {path: "/search/gophers/go", valid: true, pathTemplate: "/search/:q/go", params: map[string]string{"q": "gophers"}}, 218 | {path: "/search/gophers/rust", valid: false, pathTemplate: "", params: nil}, 219 | {path: "/search/nature/go1.html", valid: true, pathTemplate: "/search/:q/go1.html", params: map[string]string{"q": "nature"}}, 220 | {path: "/search/generics/types/index.html", valid: true, pathTemplate: "/search/:q/:w/index.html", params: map[string]string{"q": "generics", "w": "types"}}, 221 | {path: "/src/paris/invalid", valid: true, pathTemplate: "/src/:dest/invalid", params: map[string]string{"dest": "paris"}}, 222 | {path: "/src1/oslo", valid: true, pathTemplate: "/src1/:dest", params: map[string]string{"dest": "oslo"}}, 223 | {path: "/src1/toronto/ontario", valid: false, pathTemplate: "", params: nil}, 224 | {path: "/signal-r/push", valid: true, pathTemplate: "/signal-r/:cmd", params: map[string]string{"cmd": "push"}}, 225 | {path: "/signal-r/protos/reflection", valid: true, pathTemplate: "/signal-r/:cmd/reflection", params: map[string]string{"cmd": "protos"}}, 226 | {path: "/query/911", valid: true, pathTemplate: "/query/:key", params: map[string]string{"key": "911"}}, 227 | {path: "/query/46/hello", valid: true, pathTemplate: "/query/:key/:val", params: map[string]string{"key": "46", "val": "hello"}}, 228 | {path: "/query/99/sup/update-ttl", valid: true, pathTemplate: "/query/:key/:val/:cmd", params: map[string]string{"key": "99", "val": "sup", "cmd": "update-ttl"}}, 229 | {path: "/query/10/amazing/reset/single", valid: true, pathTemplate: "/query/:key/:val/:cmd/single", params: map[string]string{"key": "10", "val": "amazing", "cmd": "reset"}}, 230 | {path: "/query/10/amazing/reset/single/1", valid: false, pathTemplate: "", params: nil}, 231 | {path: "/questions/1001", valid: true, pathTemplate: "/questions/:index", params: map[string]string{"index": "1001"}}, 232 | {path: "/graphql/stream", valid: true, pathTemplate: "/graphql/:cmd", params: map[string]string{"cmd": "stream"}}, 233 | {path: "/graphql/stream/tcp", valid: false, pathTemplate: "", params: nil}, 234 | {path: "/gophers.html", valid: true, pathTemplate: "/:file", params: map[string]string{"file": "gophers.html"}}, 235 | {path: "/gophers.html/remove", valid: true, pathTemplate: "/:file/remove", params: map[string]string{"file": "gophers.html"}}, 236 | {path: "/gophers.html/fetch", valid: false, pathTemplate: "", params: nil}, 237 | {path: "/hero-goku", valid: true, pathTemplate: "/hero-:name", params: map[string]string{"name": "goku"}}, 238 | {path: "/hero-thor", valid: true, pathTemplate: "/hero-:name", params: map[string]string{"name": "thor"}}, 239 | {path: "/hero-", valid: true, pathTemplate: "/:file", params: map[string]string{"file": "hero-"}}, 240 | } 241 | 242 | testSearchWithParams(t, tree, maxParams, tt) 243 | } 244 | 245 | func TestWildcard(t *testing.T) { 246 | paths := [...]string{ 247 | "/messages/*action", 248 | "/users/posts/*command", 249 | "/images/*filepath", 250 | "/hero-*dir", 251 | "/netflix*abc", 252 | } 253 | 254 | tree := &node{} 255 | 256 | paramsCount := 0 257 | for _, path := range paths { 258 | tree.insert(path, HTTPHandlerFunc(fakeHttpHandler)) 259 | pc := findParamsCount(path) 260 | if pc > paramsCount { 261 | paramsCount = pc 262 | } 263 | } 264 | 265 | params := newInternalParams(paramsCount) 266 | 267 | tt := testTable1{ 268 | {path: "/messages/publish", valid: true, pathTemplate: "/messages/*action"}, 269 | {path: "/messages/publish/OrderPlaced", valid: true, pathTemplate: "/messages/*action"}, 270 | {path: "/messages/", valid: true, pathTemplate: "/messages/*action"}, 271 | {path: "/messages", valid: false, pathTemplate: ""}, 272 | {path: "/users/posts/", valid: true, pathTemplate: "/users/posts/*command"}, 273 | {path: "/users/posts", valid: false, pathTemplate: ""}, 274 | {path: "/users/posts/push", valid: true, pathTemplate: "/users/posts/*command"}, 275 | {path: "/users/posts/push/911", valid: true, pathTemplate: "/users/posts/*command"}, 276 | {path: "/images/gopher.png", valid: true, pathTemplate: "/images/*filepath"}, 277 | {path: "/images/", valid: true, pathTemplate: "/images/*filepath"}, 278 | {path: "/images", valid: false, pathTemplate: ""}, 279 | {path: "/images/svg/up-icon", valid: true, pathTemplate: "/images/*filepath"}, 280 | {path: "/hero-dc/batman.json", valid: true, pathTemplate: "/hero-*dir"}, 281 | {path: "/hero-dc/superman.json", valid: true, pathTemplate: "/hero-*dir"}, 282 | {path: "/hero-marvel/loki.json", valid: true, pathTemplate: "/hero-*dir"}, 283 | {path: "/hero-", valid: true, pathTemplate: "/hero-*dir"}, 284 | {path: "/hero", valid: false, pathTemplate: ""}, 285 | {path: "/netflix", valid: true, pathTemplate: "/netflix*abc"}, 286 | {path: "/netflix++", valid: true, pathTemplate: "/netflix*abc"}, 287 | {path: "/netflix/drama/better-call-saul", valid: true, pathTemplate: "/netflix*abc"}, 288 | } 289 | 290 | testSearch(t, tree, params, tt) 291 | } 292 | 293 | func TestWildcardParams(t *testing.T) { 294 | paths := [...]string{ 295 | "/messages/*action", 296 | "/users/posts/*command", 297 | "/images/*filepath", 298 | "/hero-*dir", 299 | } 300 | 301 | tree := &node{} 302 | 303 | maxParams := 0 304 | for _, path := range paths { 305 | tree.insert(path, HTTPHandlerFunc(fakeHttpHandler)) 306 | 307 | pc := findParamsCount(path) 308 | if pc > maxParams { 309 | maxParams = pc 310 | } 311 | } 312 | 313 | tt := testTable2{ 314 | {path: "/messages/", valid: true, pathTemplate: "/messages/*action", params: map[string]string{"action": ""}}, // todo: fix this issue 315 | {path: "/messages/publish", valid: true, pathTemplate: "/messages/*action", params: map[string]string{"action": "publish"}}, 316 | {path: "/messages/publish/OrderPlaced", valid: true, pathTemplate: "/messages/*action", params: map[string]string{"action": "publish/OrderPlaced"}}, 317 | {path: "/messages", valid: false, pathTemplate: "", params: nil}, 318 | {path: "/users/posts/", valid: true, pathTemplate: "/users/posts/*command", params: map[string]string{"command": ""}}, 319 | {path: "/users/posts", valid: false, pathTemplate: "", params: nil}, 320 | {path: "/users/posts/push", valid: true, pathTemplate: "/users/posts/*command", params: map[string]string{"command": "push"}}, 321 | {path: "/users/posts/push/911", valid: true, pathTemplate: "/users/posts/*command", params: map[string]string{"command": "push/911"}}, 322 | {path: "/images/gopher.png", valid: true, pathTemplate: "/images/*filepath", params: map[string]string{"filepath": "gopher.png"}}, 323 | {path: "/images/", valid: true, pathTemplate: "/images/*filepath", params: map[string]string{"filepath": ""}}, 324 | {path: "/images", valid: false, pathTemplate: "", params: nil}, 325 | {path: "/images/svg/up-icon", valid: true, pathTemplate: "/images/*filepath", params: map[string]string{"filepath": "svg/up-icon"}}, 326 | {path: "/hero-dc/batman.json", valid: true, pathTemplate: "/hero-*dir", params: map[string]string{"dir": "dc/batman.json"}}, 327 | {path: "/hero-dc/superman.json", valid: true, pathTemplate: "/hero-*dir", params: map[string]string{"dir": "dc/superman.json"}}, 328 | {path: "/hero-marvel/loki.json", valid: true, pathTemplate: "/hero-*dir", params: map[string]string{"dir": "marvel/loki.json"}}, 329 | {path: "/hero-", valid: true, pathTemplate: "/hero-*dir", params: map[string]string{"dir": ""}}, 330 | {path: "/hero", valid: false, pathTemplate: "", params: nil}, 331 | } 332 | 333 | testSearchWithParams(t, tree, maxParams, tt) 334 | } 335 | 336 | func TestNode_Search_TraversalPathChange(t *testing.T) { 337 | t.Run("1", func(t *testing.T) { 338 | paths := [...]string{ 339 | "/search", 340 | "/search/:q/stop", 341 | "/search/*action", 342 | } 343 | 344 | tree := &node{} 345 | 346 | maxParams := 0 347 | for _, path := range paths { 348 | tree.insert(path, HTTPHandlerFunc(fakeHttpHandler)) 349 | 350 | pc := findParamsCount(path) 351 | if pc > maxParams { 352 | maxParams = pc 353 | } 354 | } 355 | 356 | tt := testTable2{ 357 | {path: "/search/cherry/", valid: true, pathTemplate: "/search/*action", params: map[string]string{"action": "cherry/"}}, 358 | {path: "/search/cherry/berry", valid: true, pathTemplate: "/search/*action", params: map[string]string{"action": "cherry/berry"}}, 359 | } 360 | 361 | testSearchWithParams(t, tree, maxParams, tt) 362 | }) 363 | 364 | t.Run("2", func(t *testing.T) { 365 | paths := [...]string{ 366 | "/apple/banana/:f1/:f2/:f3/mango", 367 | "/apple/banana/*wc", 368 | } 369 | 370 | tree := &node{} 371 | 372 | maxParams := 0 373 | for _, path := range paths { 374 | tree.insert(path, HTTPHandlerFunc(fakeHttpHandler)) 375 | 376 | pc := findParamsCount(path) 377 | if pc > maxParams { 378 | maxParams = pc 379 | } 380 | } 381 | 382 | tt := testTable2{ 383 | {path: "/apple/banana/pineapple/guava/cherry/mandarin", valid: true, pathTemplate: "/apple/banana/*wc", params: map[string]string{"wc": "pineapple/guava/cherry/mandarin"}}, 384 | } 385 | 386 | testSearchWithParams(t, tree, maxParams, tt) 387 | }) 388 | 389 | t.Run("3", func(t *testing.T) { 390 | paths := [...]string{ 391 | "/apple/:f1/mango", 392 | "/*wc", 393 | } 394 | 395 | tree := &node{} 396 | 397 | maxParams := 0 398 | for _, path := range paths { 399 | tree.insert(path, HTTPHandlerFunc(fakeHttpHandler)) 400 | 401 | pc := findParamsCount(path) 402 | if pc > maxParams { 403 | maxParams = pc 404 | } 405 | } 406 | 407 | tt := testTable2{ 408 | {path: "/apple/banana", valid: true, pathTemplate: "/*wc", params: map[string]string{"wc": "apple/banana"}}, 409 | } 410 | 411 | testSearchWithParams(t, tree, maxParams, tt) 412 | }) 413 | 414 | t.Run("4", func(t *testing.T) { 415 | paths := [...]string{ 416 | "/cherry/berry/:f2/:f3", 417 | "/cherry/:f4/:f5/:f6/:f7", 418 | } 419 | 420 | tree := &node{} 421 | 422 | maxParams := 0 423 | for _, path := range paths { 424 | tree.insert(path, HTTPHandlerFunc(fakeHttpHandler)) 425 | 426 | pc := findParamsCount(path) 427 | if pc > maxParams { 428 | maxParams = pc 429 | } 430 | } 431 | 432 | tt := testTable2{ 433 | {path: "/cherry/berry/apple/banana/mango", valid: true, pathTemplate: "/cherry/:f4/:f5/:f6/:f7", params: map[string]string{"f4": "berry", "f5": "apple", "f6": "banana", "f7": "mango"}}, 434 | } 435 | 436 | testSearchWithParams(t, tree, maxParams, tt) 437 | }) 438 | 439 | t.Run("5", func(t *testing.T) { 440 | paths := [...]string{ 441 | "/:text", 442 | "/color|:hex", 443 | } 444 | 445 | tree := &node{} 446 | 447 | maxParams := 0 448 | for _, path := range paths { 449 | tree.insert(path, HTTPHandlerFunc(fakeHttpHandler)) 450 | 451 | pc := findParamsCount(path) 452 | if pc > maxParams { 453 | maxParams = pc 454 | } 455 | } 456 | 457 | tt := testTable2{ 458 | {path: "/color|", valid: true, pathTemplate: "/:text", params: map[string]string{"text": "color|"}}, 459 | // Should evaluate /color|:hex first, but should fall back to /:text since param value is not provided. 460 | } 461 | 462 | testSearchWithParams(t, tree, maxParams, tt) 463 | }) 464 | 465 | t.Run("6", func(t *testing.T) { 466 | paths := [...]string{ 467 | "/locations/reviews:id", 468 | "/loc:param/reviews", 469 | } 470 | 471 | tree := &node{} 472 | 473 | maxParams := 0 474 | for _, path := range paths { 475 | tree.insert(path, HTTPHandlerFunc(fakeHttpHandler)) 476 | 477 | pc := findParamsCount(path) 478 | if pc > maxParams { 479 | maxParams = pc 480 | } 481 | } 482 | 483 | tt := testTable2{ 484 | {path: "/locations/reviews", valid: true, pathTemplate: "/loc:param/reviews", params: map[string]string{"param": "ations"}}, 485 | // Should evaluate /locations/reviews:id first, but should fall back to /loc:param/reviews since param value is not provided. 486 | } 487 | 488 | testSearchWithParams(t, tree, maxParams, tt) 489 | }) 490 | 491 | t.Run("7", func(t *testing.T) { 492 | paths := [...]string{ 493 | "/locations/reviews-:id", 494 | "/loc:param/reviews-", 495 | } 496 | 497 | tree := &node{} 498 | 499 | maxParams := 0 500 | for _, path := range paths { 501 | tree.insert(path, HTTPHandlerFunc(fakeHttpHandler)) 502 | 503 | pc := findParamsCount(path) 504 | if pc > maxParams { 505 | maxParams = pc 506 | } 507 | } 508 | 509 | tt := testTable2{ 510 | {path: "/locations/reviews-", valid: true, pathTemplate: "/loc:param/reviews-", params: map[string]string{"param": "ations"}}, 511 | } 512 | 513 | testSearchWithParams(t, tree, maxParams, tt) 514 | }) 515 | } 516 | 517 | func BenchmarkSimple(b *testing.B) { 518 | tree := &node{} 519 | 520 | routes := [...]string{ 521 | "/", 522 | "/cmd/:tool/:sub", 523 | "/cmd/:tool", 524 | "/src/*filepath", 525 | "/search", 526 | "/search/:query", 527 | "/files/:dir/*filepath", 528 | "/doc", 529 | "/doc/go_faq.html", 530 | "/doc/go1.html", 531 | "/info/:user/public", 532 | "/info/:user/project/:project", 533 | "/user_:name", 534 | "/user_:name/about", 535 | } 536 | 537 | paramsCount := 0 538 | for _, route := range routes { 539 | tree.insert(route, HTTPHandlerFunc(fakeHttpHandler)) 540 | pc := findParamsCount(route) 541 | if pc > paramsCount { 542 | paramsCount = pc 543 | } 544 | } 545 | 546 | params := newInternalParams(paramsCount) 547 | 548 | match := [...]string{ 549 | "cmd/test/", 550 | "cmd/test/3", 551 | "src/any", 552 | "src/some/file.png", 553 | "search/", 554 | "search/someth!ng+in+ünìcodé", 555 | "files/js/inc/framework.js", 556 | "doc/go_faq.html", 557 | "doc/go1.html", 558 | "info/gordon/public", 559 | "info/gordon/project/go", 560 | "user_gopher/go", 561 | "user_gopher/about", 562 | } 563 | 564 | b.ResetTimer() 565 | b.ReportAllocs() 566 | 567 | for i := 0; i < b.N; i++ { 568 | for _, s := range match { 569 | tree.search(s, func() *internalParams { 570 | return params 571 | }) 572 | params.reset() 573 | } 574 | } 575 | } 576 | 577 | func BenchmarkSimple2(b *testing.B) { 578 | tree := &node{} 579 | 580 | routes := [...]string{ 581 | //"/users/find/:name", 582 | //"/users/:id/delete", 583 | "/users/groups/:groupId/dump", 584 | "/users/groups/:groupId/export", 585 | //"/users/:id/update", 586 | "/search/:q", 587 | //"/search/:q/go", 588 | //"/search/:q/go1.html", 589 | "/search/:q/:w/index.html", 590 | "/src/:dest/invalid", 591 | "/src1/:dest", 592 | "/signal-r/:cmd", 593 | "/signal-r/:cmd/reflection", 594 | "/query/:key", 595 | "/query/:key/:val", 596 | "/query/:key/:val/:cmd", 597 | "/query/:key/:val/:cmd/single", 598 | "/questions/:index", 599 | "/graphql/:cmd", 600 | //"/:file", 601 | //"/:file/remove", 602 | "/hero-:name", 603 | } 604 | 605 | paramsCount := 0 606 | for _, route := range routes { 607 | tree.insert(route, HTTPHandlerFunc(fakeHttpHandler)) 608 | pc := findParamsCount(route) 609 | if pc > paramsCount { 610 | paramsCount = pc 611 | } 612 | } 613 | 614 | params := newInternalParams(paramsCount) 615 | 616 | match := [...]string{ 617 | //"/users/find/yousuf", 618 | //"/users/john/delete", 619 | "/users/groups/120/dump", 620 | "/users/groups/230/export", 621 | //"/users/911/update", 622 | "/search/ducks", 623 | //"/search/gophers/go", 624 | //"/search/nature/go1.html", 625 | "/search/generics/types/index.html", 626 | "/src/paris/invalid", 627 | "/src1/oslo", 628 | "/signal-r/push", 629 | "/signal-r/protos/reflection", 630 | "/query/911", 631 | "/query/46/hello", 632 | "/query/99/sup/update-ttl", 633 | "/query/10/amazing/reset/single", 634 | //"/query/10/amazing/reset/single/1", 635 | "/questions/1001", 636 | "/graphql/stream", 637 | //"/gophers.html", 638 | //"/gophers.html/remove", 639 | "/hero-goku", 640 | "/hero-thor", 641 | } 642 | 643 | b.ResetTimer() 644 | b.ReportAllocs() 645 | 646 | for i := 0; i < b.N; i++ { 647 | for _, s := range match { 648 | tree.search(s, func() *internalParams { 649 | return params 650 | }) 651 | params.reset() 652 | } 653 | } 654 | } 655 | 656 | func testSearch(t *testing.T, tree *node, params *internalParams, table testTable1) { 657 | for _, tx := range table { 658 | nd, ps := tree.search(tx.path, func() *internalParams { 659 | return params 660 | }) 661 | if tx.valid && (nd == nil || nd.handler == nil) { 662 | t.Errorf("expected: valid handler, got: no handler: %s", tx.path) 663 | } 664 | if !tx.valid && nd != nil && nd.handler != nil { 665 | t.Errorf("expected: no handler, got: valid handler") 666 | } 667 | if tx.pathTemplate != "" && tx.pathTemplate != nd.template { 668 | t.Errorf("%s expected: %s, got: %s", tx.path, tx.pathTemplate, nd.template) 669 | } 670 | if ps != nil { 671 | ps.reset() 672 | } 673 | } 674 | } 675 | 676 | func testSearchWithParams(t *testing.T, tree *node, maxParams int, table testTable2) { 677 | for _, tx := range table { 678 | nd, ps := tree.search(tx.path, func() *internalParams { 679 | return newInternalParams(maxParams) 680 | }) 681 | if tx.valid && (nd == nil || nd.handler == nil) { 682 | t.Errorf("expected: valid handler, got: no handler: %s", tx.path) 683 | } 684 | if !tx.valid && nd != nil && nd.handler != nil { 685 | t.Errorf("expected: no handler, got: valid handler") 686 | } 687 | if tx.pathTemplate != "" && tx.pathTemplate != nd.template { 688 | t.Errorf("expected: %s, got: %s", tx.pathTemplate, nd.template) 689 | } 690 | if tx.params != nil { 691 | for k, v := range tx.params { 692 | pv := ps.get(k) 693 | if v != pv { 694 | t.Errorf("params assertion failed. expected: %s, got: %s", v, pv) 695 | } 696 | } 697 | } 698 | if ps != nil { 699 | ps.reset() 700 | } 701 | } 702 | } 703 | 704 | func TestScanPath(t *testing.T) { 705 | t.Parallel() 706 | t.Run("Whitespace", func(t *testing.T) { 707 | paths := []string{ 708 | " ", 709 | "/ ", 710 | "/\t", 711 | "/\n", 712 | "/\v", 713 | "/\f", 714 | "/\r", 715 | "/hello ", 716 | "/hello\t", 717 | "/hello\n", 718 | "/hello\v", 719 | "/hello\f", 720 | "/hello\r", 721 | "+0085", 722 | "U+00A0", 723 | } 724 | 725 | for _, path := range paths { 726 | if pnk := panicHandler(func() { 727 | scanPath(path) 728 | }); pnk == nil { 729 | panic(fmt.Sprintf("path %s > didn't panic", path)) 730 | } 731 | } 732 | }) 733 | 734 | t.Run("Wildcard", func(t *testing.T) { 735 | paths := []string{ 736 | // Without name. 737 | "/*", 738 | "/hello/*", 739 | 740 | // Successive segments. 741 | "/*action/hello", 742 | "/*action/name", 743 | "/*action_:name", 744 | 745 | // Multiple wildcards. 746 | "/*foo*bar", 747 | "/hello/*foo*bar", 748 | "/hello/*foo*bar*baz", 749 | } 750 | 751 | for _, path := range paths { 752 | if pnk := panicHandler(func() { 753 | scanPath(path) 754 | }); pnk == nil { 755 | panic(fmt.Sprintf("path %s > didn't panic", path)) 756 | } 757 | } 758 | }) 759 | 760 | t.Run("Param", func(t *testing.T) { 761 | paths := []string{ 762 | // Without name. 763 | "/:", 764 | "/hello/:", 765 | "/hello/:/ccc", 766 | "/hello/:/:/:/ccc", 767 | 768 | // param-param / param-wildcard segments within the same scope. 769 | "/:aaa:bbb", 770 | "/:aaa:bbb/ccc", 771 | "/foo/:bar_:baz", 772 | "/foo/:bar_:baz/xyz", 773 | "/foo/:bar_*abc", 774 | } 775 | 776 | for _, path := range paths { 777 | if pnk := panicHandler(func() { 778 | scanPath(path) 779 | }); pnk == nil { 780 | panic(fmt.Sprintf("path %s > didn't panic", path)) 781 | } 782 | } 783 | }) 784 | 785 | t.Run("VarsCount", func(t *testing.T) { 786 | paths := map[string]int{ 787 | "/:foo": 1, 788 | "/*foo": 1, 789 | "/:foo/:bar": 2, 790 | "/:foo/*bar": 2, 791 | "/:foo/:bar/:baz": 3, 792 | "/:foo/:bar/*baz": 3, 793 | "/:foo/:bar/:baz/:abc": 4, 794 | "/:foo/:bar/:baz/*abc": 4, 795 | } 796 | 797 | for path, c := range paths { 798 | vc := scanPath(path) 799 | assert(t, c == vc, fmt.Sprintf("path %s vars count > expected: %d, got: %d", path, c, vc)) 800 | } 801 | }) 802 | } 803 | 804 | func panicHandler(f func()) (rec any) { 805 | defer func() { 806 | rec = recover() 807 | }() 808 | 809 | f() 810 | return 811 | } 812 | -------------------------------------------------------------------------------- /reversebuffer.go: -------------------------------------------------------------------------------- 1 | package shift 2 | 3 | type reverseBuffer interface { 4 | WriteString(s string) 5 | String() string 6 | } 7 | 8 | type sizedReverseBuffer struct { 9 | offset int 10 | b []byte 11 | } 12 | 13 | func newSizedReverseBuffer(size int) *sizedReverseBuffer { 14 | return &sizedReverseBuffer{ 15 | offset: size, 16 | b: make([]byte, size), 17 | } 18 | } 19 | 20 | func (buf *sizedReverseBuffer) WriteString(s string) { 21 | for i, j := buf.offset-1, len(s)-1; i >= 0 && j >= 0; i, j = i-1, j-1 { 22 | buf.b[i] = s[j] 23 | } 24 | buf.offset -= len(s) 25 | if buf.offset < 0 { 26 | buf.offset = 0 27 | } 28 | } 29 | 30 | func (buf *sizedReverseBuffer) String() string { 31 | return bytesToString(buf.b[buf.offset:]) 32 | } 33 | 34 | type reverseBuffer128 struct { 35 | offset int 36 | b [128]byte 37 | } 38 | 39 | func newReverseBuffer128() *reverseBuffer128 { 40 | return &reverseBuffer128{ 41 | offset: 128, 42 | b: [128]byte{}, 43 | } 44 | } 45 | 46 | func (buf *reverseBuffer128) WriteString(s string) { 47 | for i, j := buf.offset-1, len(s)-1; i >= 0 && j >= 0; i, j = i-1, j-1 { 48 | buf.b[i] = s[j] 49 | } 50 | buf.offset -= len(s) 51 | if buf.offset < 0 { 52 | buf.offset = 0 53 | } 54 | } 55 | 56 | func (buf *reverseBuffer128) String() string { 57 | return bytesToString(buf.b[buf.offset:]) 58 | } 59 | -------------------------------------------------------------------------------- /reversebuffer_test.go: -------------------------------------------------------------------------------- 1 | package shift 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestReverseBuffer128(t *testing.T) { 10 | paths := map[string][]string{ 11 | "/xxx/yyy/zzz": {"/zzz", "/yyy", "/xxx"}, 12 | "/abc/xyz/ooo/mmm": {"/mmm", "/ooo", "/xyz", "/abc"}, 13 | "/aaa/bbb/ccc/ddd/eee": {"/eee", "/ddd", "/ccc", "/bbb", "/aaa"}, 14 | } 15 | 16 | for path, parts := range paths { 17 | buf := newReverseBuffer128() 18 | 19 | for _, part := range parts { 20 | buf.WriteString(part) 21 | } 22 | 23 | str := buf.String() 24 | assert(t, str == path, fmt.Sprintf("expected: %s, got: %s", path, str)) 25 | } 26 | } 27 | 28 | func TestReverseBuffer128_PreventOverflow(t *testing.T) { 29 | buf := newReverseBuffer128() 30 | var sb strings.Builder 31 | 32 | for i := 0; i < 125; i++ { 33 | sb.WriteRune('a') 34 | } 35 | 36 | buf.WriteString(sb.String()) 37 | buf.WriteString("qwexyz") 38 | buf.WriteString("foo") 39 | 40 | expectation1 := 128 41 | expectation2 := "xyz" + sb.String() 42 | 43 | str := buf.String() 44 | length := len(str) 45 | assert(t, length == expectation1, fmt.Sprintf("length expected: %d, got: %d", expectation1, length)) 46 | assert(t, str == expectation2, fmt.Sprintf("string expected: %s, got: %s", expectation2, str)) 47 | } 48 | 49 | func TestSizedReverseBuffer(t *testing.T) { 50 | paths := map[string][]string{ 51 | "/xxx/yyy/zzz": {"/zzz", "/yyy", "/xxx"}, 52 | "/abc/xyz/ooo/mmm": {"/mmm", "/ooo", "/xyz", "/abc"}, 53 | "/aaa/bbb/ccc/ddd/eee": {"/eee", "/ddd", "/ccc", "/bbb", "/aaa"}, 54 | } 55 | 56 | for path, parts := range paths { 57 | buf := newSizedReverseBuffer(len(path)) 58 | 59 | for _, part := range parts { 60 | buf.WriteString(part) 61 | } 62 | 63 | str := buf.String() 64 | assert(t, str == path, fmt.Sprintf("expected: %s, got: %s", path, str)) 65 | } 66 | } 67 | 68 | func TestSizedReverseBuffer_PreventOverflow(t *testing.T) { 69 | expectation1 := 5 70 | expectation2 := "yzabc" 71 | buf := newSizedReverseBuffer(expectation1) 72 | 73 | buf.WriteString("abc") 74 | buf.WriteString("xyz") 75 | buf.WriteString("jkl") 76 | 77 | str := buf.String() 78 | length := len(str) 79 | assert(t, length == expectation1, fmt.Sprintf("length expected: %d, got: %d", expectation1, length)) 80 | assert(t, str == expectation2, fmt.Sprintf("string expected: %s, got: %s", expectation2, str)) 81 | } 82 | -------------------------------------------------------------------------------- /router.go: -------------------------------------------------------------------------------- 1 | package shift 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | type routingBehavior uint8 8 | 9 | const ( 10 | behaviorSkip routingBehavior = iota 11 | behaviorRedirect 12 | behaviorExecute 13 | ) 14 | 15 | type Config struct { 16 | trailingSlashMatch *actionConfig 17 | pathCorrectionMatch *actionConfig 18 | notFoundHandler func(w http.ResponseWriter, r *http.Request) 19 | handleMethodNotAllowed bool 20 | } 21 | 22 | var defaultConfig = &Config{ 23 | trailingSlashMatch: &actionConfig{ 24 | behavior: behaviorSkip, 25 | code: 0, 26 | }, 27 | pathCorrectionMatch: &actionConfig{ 28 | behavior: behaviorSkip, 29 | code: 0, 30 | }, 31 | notFoundHandler: http.NotFound, 32 | handleMethodNotAllowed: false, 33 | } 34 | 35 | type group = Group 36 | 37 | // Router builds on top of Group and provides additional Router specific methods. 38 | type Router struct { 39 | group 40 | 41 | config *Config 42 | } 43 | 44 | func New() *Router { 45 | d := 46 | &Router{ 47 | Group{ 48 | Core{ 49 | "", 50 | &[]routeLog{}, 51 | nil, 52 | }, 53 | }, 54 | &Config{ 55 | &actionConfig{ 56 | behavior: defaultConfig.trailingSlashMatch.behavior, 57 | code: defaultConfig.trailingSlashMatch.code, 58 | }, 59 | &actionConfig{ 60 | behavior: defaultConfig.pathCorrectionMatch.behavior, 61 | code: defaultConfig.pathCorrectionMatch.code, 62 | }, 63 | defaultConfig.notFoundHandler, 64 | defaultConfig.handleMethodNotAllowed, 65 | }, 66 | } 67 | 68 | return d 69 | } 70 | 71 | // UseTrailingSlashMatch when enabled, re-performs a search with/without the trailing slash when an exact match has not been found. 72 | // Use WithExecute, WithRedirect or WithRedirectCustom to set the routing behavior. 73 | func (r *Router) UseTrailingSlashMatch(opt ActionOption) { 74 | opt.apply(r.config.trailingSlashMatch) 75 | } 76 | 77 | // UsePathCorrectionMatch when enabled, performs a case-insensitive search after correcting the URL when an exact match has not been found. 78 | // Use WithExecute, WithRedirect or WithRedirectCustom to set the routing behavior. 79 | func (r *Router) UsePathCorrectionMatch(opt ActionOption) { 80 | opt.apply(r.config.pathCorrectionMatch) 81 | } 82 | 83 | // UseMethodNotAllowedHandler responds with HTTP status 405 and a list of registered HTTP methods for the path in the 'Allow' header 84 | // when a match has not been found but the path has been registered for other HTTP methods. 85 | func (r *Router) UseMethodNotAllowedHandler() { 86 | r.config.handleMethodNotAllowed = true 87 | } 88 | 89 | // UseNotFoundHandler registers the handler to execute when a route match is not found. 90 | func (r *Router) UseNotFoundHandler(f func(w http.ResponseWriter, r *http.Request)) { 91 | r.config.notFoundHandler = f 92 | } 93 | 94 | type RouteInfo struct { 95 | Method string 96 | Path string 97 | } 98 | 99 | // Routes returns all the registered routes. 100 | // To retrieve routes registered for a Group, use Group.Routes(). 101 | func (r *Router) Routes() (routes []RouteInfo) { 102 | routes = make([]RouteInfo, 0, len(*r.logs)) 103 | 104 | for _, log := range *r.logs { 105 | routes = append(routes, RouteInfo{ 106 | Method: log.method, 107 | Path: log.path, 108 | }) 109 | } 110 | 111 | return 112 | } 113 | 114 | type methodInfo struct { 115 | staticRoutes int 116 | logs []routeInfo 117 | } 118 | 119 | type routeInfo struct { 120 | method string 121 | path string 122 | handler HandlerFunc 123 | static bool 124 | } 125 | 126 | var builtInMethods = []string{ 127 | http.MethodGet, 128 | http.MethodPost, 129 | http.MethodPut, 130 | http.MethodPatch, 131 | http.MethodDelete, 132 | http.MethodHead, 133 | http.MethodOptions, 134 | http.MethodTrace, 135 | http.MethodConnect, 136 | } 137 | 138 | // Serve generates the Server which implements http.Handler interface. 139 | func (r *Router) Serve() *Server { 140 | svr := &Server{ 141 | [9]multiplexer{}, 142 | nil, 143 | nil, 144 | r.config, 145 | } 146 | 147 | byMethods := groupLogsByMethods(*r.logs) 148 | svr.populateRoutes(byMethods) 149 | 150 | return svr 151 | } 152 | 153 | func groupLogsByMethods(logs []routeLog) (byMethods map[string]*methodInfo) { 154 | byMethods = map[string]*methodInfo{} 155 | var anyRoutes []routeLog 156 | 157 | for _, log := range logs { 158 | if log.method == "" { 159 | anyRoutes = append(anyRoutes, log) 160 | continue 161 | } 162 | 163 | info, ok := byMethods[log.method] 164 | if !ok { 165 | info = &methodInfo{ 166 | staticRoutes: 0, 167 | logs: nil, 168 | } 169 | byMethods[log.method] = info 170 | } 171 | 172 | static := isStatic(log.path) 173 | if static { 174 | info.staticRoutes++ 175 | } 176 | 177 | info.logs = append(info.logs, routeInfo{ 178 | method: log.method, 179 | path: log.path, 180 | handler: log.handler, 181 | static: static, 182 | }) 183 | } 184 | 185 | if len(anyRoutes) > 0 { 186 | // Populate with all the built-in methods. 187 | for _, method := range builtInMethods { 188 | if _, ok := byMethods[method]; !ok { 189 | byMethods[method] = &methodInfo{ 190 | staticRoutes: 0, 191 | logs: nil, 192 | } 193 | } 194 | } 195 | 196 | for _, route := range anyRoutes { 197 | static := isStatic(route.path) 198 | 199 | for method, info := range byMethods { 200 | info.logs = append(info.logs, routeInfo{ 201 | method: method, 202 | path: route.path, 203 | handler: route.handler, 204 | static: static, 205 | }) 206 | 207 | if static { 208 | info.staticRoutes++ 209 | } 210 | } 211 | } 212 | } 213 | 214 | return 215 | } 216 | 217 | type actionConfig struct { 218 | behavior routingBehavior 219 | code int 220 | } 221 | 222 | type ActionOption interface { 223 | apply(c *actionConfig) 224 | } 225 | 226 | type actionOption func(c *actionConfig) 227 | 228 | func (o actionOption) apply(c *actionConfig) { 229 | o(c) 230 | } 231 | 232 | // WithExecute executes the matched request handler immediately. 233 | func WithExecute() ActionOption { 234 | return actionOption(func(c *actionConfig) { 235 | c.behavior = behaviorExecute 236 | c.code = 0 237 | }) 238 | } 239 | 240 | // WithRedirect writes the status code 301 (http.StatusMovedPermanently) and the redirect url to the header. 241 | func WithRedirect() ActionOption { 242 | return WithRedirectCustom(http.StatusMovedPermanently) 243 | } 244 | 245 | // WithRedirectCustom writes the provided status code and the redirect url to the header. 246 | // statusCode should be in the range 3XX. 247 | func WithRedirectCustom(statusCode int) ActionOption { 248 | if statusCode < 300 || statusCode > 399 { 249 | panic("status code should be in the range 3XX") 250 | } 251 | return actionOption(func(c *actionConfig) { 252 | c.behavior = behaviorRedirect 253 | c.code = statusCode 254 | }) 255 | } 256 | -------------------------------------------------------------------------------- /router_bench_test.go: -------------------------------------------------------------------------------- 1 | package shift 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | type mockRW struct { 11 | headers http.Header 12 | } 13 | 14 | func newMockRW() *mockRW { 15 | return &mockRW{ 16 | http.Header{}, 17 | } 18 | } 19 | 20 | func (m *mockRW) Header() (h http.Header) { 21 | return m.headers 22 | } 23 | 24 | func (m *mockRW) Write(p []byte) (n int, err error) { 25 | return len(p), nil 26 | } 27 | 28 | func (m *mockRW) WriteString(s string) (n int, err error) { 29 | return len(s), nil 30 | } 31 | 32 | func (m *mockRW) WriteHeader(int) {} 33 | 34 | func BenchmarkRouter_2000Params(b *testing.B) { 35 | r := newTestRouter() 36 | 37 | var template strings.Builder 38 | var path strings.Builder 39 | var paramKeys []string 40 | 41 | for i := 1; i <= 2000; i++ { 42 | template.WriteString(fmt.Sprintf("/:%d", i)) 43 | path.WriteString("/foo") 44 | paramKeys = append(paramKeys, fmt.Sprintf("%d", i)) 45 | } 46 | 47 | f := func(w http.ResponseWriter, r *http.Request, route Route) error { 48 | for _, key := range paramKeys { 49 | route.Params.Get(key) 50 | } 51 | return nil 52 | } 53 | 54 | r.GET(template.String(), f) 55 | 56 | srv := r.Serve() 57 | 58 | req, _ := http.NewRequest(http.MethodGet, path.String(), nil) 59 | 60 | b.ReportAllocs() 61 | b.ResetTimer() 62 | 63 | for i := 0; i < b.N; i++ { 64 | srv.ServeHTTP(nil, req) 65 | } 66 | } 67 | 68 | func BenchmarkRouter_ServeHTTP_StaticRoutes(b *testing.B) { 69 | r := newTestRouter() 70 | 71 | routes := []string{ 72 | "/users/find", 73 | "/users/delete", 74 | "/users/all/dump", 75 | "/users/all/export", 76 | "/users/any", 77 | "/search", 78 | "/search/go", 79 | "/search/go1.html", 80 | "/search/index.html", 81 | "/src/invalid", 82 | "/src1", 83 | "/signal-r", 84 | "/query/unknown", 85 | "/query/unknown/pages", 86 | "/query/untold", 87 | "/questions", 88 | "/graphql", 89 | "/graph/var", 90 | //"/graph/:var", 91 | } 92 | 93 | tests := []string{ 94 | "/users/find", 95 | "/users/delete", 96 | "/users/all/dump", 97 | "/users/all/export", 98 | "/users/any", 99 | "/search", 100 | "/search/go", 101 | "/search/go1.html", 102 | "/search/index.html", 103 | "/src/invalid", 104 | "/src1", 105 | "/signal-r", 106 | "/query/unknown", 107 | "/query/unknown/pages", 108 | "/query/untold", 109 | "/questions", 110 | "/graphql", 111 | "/graph/var", 112 | //"/graph/2000", 113 | } 114 | 115 | for _, route := range routes { 116 | r.GET(route, fakeHandler()) 117 | } 118 | 119 | requests := make([]*http.Request, len(tests)) 120 | 121 | for i, path := range tests { 122 | requests[i], _ = http.NewRequest("GET", path, nil) 123 | } 124 | 125 | srv := r.Serve() 126 | 127 | b.ResetTimer() 128 | b.ReportAllocs() 129 | 130 | for i := 0; i < b.N; i++ { 131 | for _, request := range requests { 132 | srv.ServeHTTP(nil, request) 133 | } 134 | } 135 | } 136 | 137 | func BenchmarkRouter_ServeHTTP_ParamRoutes_GET(b *testing.B) { 138 | r := newTestRouter() 139 | 140 | routes := []string{ 141 | "/users/find/:name", 142 | "/users/:id/delete", 143 | "/users/groups/:groupId/dump", 144 | "/users/groups/:groupId/export", 145 | "/users/:id/update", 146 | "/search/:q", 147 | "/search/:q/go", 148 | "/search/:q/go1.html", 149 | "/search/:q/:w/index.html", 150 | "/src/:dest/invalid", 151 | "/src1/:dest", 152 | "/signal-r/:cmd", 153 | "/signal-r/:cmd/reflection", 154 | "/query/:key", 155 | "/query/:key/:val", 156 | "/query/:key/:val/:cmd", 157 | "/query/:key/:val/:cmd/single", 158 | "/query/:key/:val/:cmd/single/1", 159 | "/questions/:index", 160 | "/graphql/:cmd", 161 | "/:file", 162 | "/:file/remove", 163 | "/hero-:name", 164 | } 165 | 166 | tests := []string{ 167 | "/users/find/yousuf", 168 | "/users/john/delete", 169 | "/users/groups/120/dump", 170 | "/users/groups/230/export", 171 | "/users/911/update", 172 | "/search/ducks", 173 | "/search/gophers/go", 174 | "/search/nature/go1.html", 175 | "/search/generics/types/index.html", 176 | "/src/paris/invalid", 177 | "/src1/oslo", 178 | "/signal-r/push", 179 | "/signal-r/protos/reflection", 180 | "/query/911", 181 | "/query/46/hello", 182 | "/query/99/sup/update-ttl", 183 | "/query/10/amazing/reset/single", 184 | "/query/10/amazing/reset/single/1", 185 | "/questions/1001", 186 | "/graphql/stream", 187 | "/gophers.html", 188 | "/gophers.html/remove", 189 | "/hero-goku", 190 | "/hero-thor", 191 | } 192 | 193 | for _, route := range routes { 194 | r.GET(route, fakeHandler()) 195 | } 196 | 197 | requests := make([]*http.Request, len(tests)) 198 | 199 | for i, path := range tests { 200 | requests[i], _ = http.NewRequest("GET", path, nil) 201 | } 202 | 203 | srv := r.Serve() 204 | 205 | b.ResetTimer() 206 | b.ReportAllocs() 207 | 208 | for i := 0; i < b.N; i++ { 209 | for _, request := range requests { 210 | srv.ServeHTTP(nil, request) 211 | } 212 | } 213 | } 214 | 215 | func BenchmarkRouter_ServeHTTP_ParamRoutes_RandomMethods(b *testing.B) { 216 | r := newTestRouter() 217 | 218 | routes := map[string]string{ 219 | "/users/find/:name": http.MethodGet, 220 | "/users/:id/delete": http.MethodDelete, 221 | "/users/groups/:groupId/dump": http.MethodPost, 222 | "/users/groups/:groupId/export": http.MethodPost, 223 | "/users/:id/update": http.MethodPut, 224 | "/search/:q": http.MethodGet, 225 | "/search/:q/go": http.MethodGet, 226 | "/search/:q/go1.html": http.MethodGet, 227 | "/search/:q/:w/index.html": http.MethodOptions, 228 | "/src/:dest/invalid": http.MethodGet, 229 | "/src1/:dest": http.MethodGet, 230 | "/signal-r/:cmd": http.MethodGet, 231 | "/signal-r/:cmd/reflection": http.MethodGet, 232 | "/query/:key": http.MethodGet, 233 | "/query/:key/:val": http.MethodGet, 234 | "/query/:key/:val/:cmd": http.MethodGet, 235 | "/query/:key/:val/:cmd/single": http.MethodGet, 236 | "/query/:key/:val/:cmd/single/1": http.MethodGet, 237 | "/questions/:index": http.MethodHead, 238 | "/graphql/:cmd": http.MethodGet, 239 | "/search/generics/types/index.css": http.MethodGet, 240 | "/:file": http.MethodPatch, 241 | "/:file/remove": http.MethodPatch, 242 | "/hero-:name": http.MethodGet, 243 | } 244 | 245 | tests := map[string]string{ 246 | "/users/find/yousuf": http.MethodGet, 247 | "/users/john/delete": http.MethodDelete, 248 | "/users/groups/120/dump": http.MethodPost, 249 | "/users/groups/230/export": http.MethodPost, 250 | "/users/911/update": http.MethodPut, 251 | "/search/ducks": http.MethodGet, 252 | "/search/gophers/go": http.MethodGet, 253 | "/search/nature/go1.html": http.MethodGet, 254 | "/search/generics/types/index.html": http.MethodOptions, 255 | "/src/paris/invalid": http.MethodGet, 256 | "/src1/oslo": http.MethodGet, 257 | "/signal-r/push": http.MethodGet, 258 | "/signal-r/protos/reflection": http.MethodGet, 259 | "/query/911": http.MethodGet, 260 | "/query/46/hello": http.MethodGet, 261 | "/query/99/sup/update-ttl": http.MethodGet, 262 | "/query/10/amazing/reset/single": http.MethodGet, 263 | "/query/10/amazing/reset/single/1": http.MethodGet, 264 | "/questions/1001": http.MethodHead, 265 | "/graphql/stream": http.MethodGet, 266 | "/search/generics/types/index.css": http.MethodGet, 267 | "/gophers.html": http.MethodPatch, 268 | "/gophers.html/remove": http.MethodPatch, 269 | "/hero-goku": http.MethodGet, 270 | "/hero-thor": http.MethodGet, 271 | } 272 | 273 | for route, method := range routes { 274 | r.Map([]string{method}, route, fakeHandler()) 275 | } 276 | 277 | requests := make([]*http.Request, 0, len(tests)) 278 | 279 | for path, method := range tests { 280 | req, _ := http.NewRequest(method, path, nil) 281 | requests = append(requests, req) 282 | } 283 | 284 | srv := r.Serve() 285 | 286 | b.ResetTimer() 287 | b.ReportAllocs() 288 | 289 | for i := 0; i < b.N; i++ { 290 | for _, request := range requests { 291 | srv.ServeHTTP(nil, request) 292 | } 293 | } 294 | } 295 | 296 | func BenchmarkRouter_ServeHTTP_MixedRoutes_1(b *testing.B) { 297 | r := newTestRouter() 298 | 299 | routes := []string{ 300 | "/posts", 301 | "/posts/ants", 302 | "/posts/antonio/cesaro", 303 | "/skus", 304 | "/skus/:id", 305 | "/skus/:id/categories", 306 | "/skus/:id/categories/:cid", 307 | "/skus/:id/categories/all", 308 | "/skus/:id/categories/one", 309 | } 310 | 311 | tests := []string{ 312 | "/posts", 313 | "/posts/ants", 314 | "/posts/antonio/cesaro", 315 | "/skus", 316 | "/skus/123", 317 | "/skus/123/categories", 318 | "/skus/123/categories/899", 319 | "/skus/123/categories/all", 320 | "/skus/123/categories/one", 321 | } 322 | 323 | for _, route := range routes { 324 | r.GET(route, fakeHandler()) 325 | } 326 | 327 | requests := make([]*http.Request, len(tests)) 328 | 329 | for i, path := range tests { 330 | requests[i], _ = http.NewRequest("GET", path, nil) 331 | } 332 | 333 | srv := r.Serve() 334 | 335 | b.ResetTimer() 336 | b.ReportAllocs() 337 | 338 | for i := 0; i < b.N; i++ { 339 | for _, request := range requests { 340 | srv.ServeHTTP(nil, request) 341 | } 342 | } 343 | } 344 | 345 | func BenchmarkRouter_ServeHTTP_MixedRoutes_2(b *testing.B) { 346 | r := newTestRouter() 347 | 348 | routes := map[string]string{ 349 | "/users/find": http.MethodGet, 350 | "/users/find/:name": http.MethodGet, 351 | "/users/:id/delete": http.MethodGet, 352 | "/users/:id/update": http.MethodGet, 353 | "/users/groups/:groupId/dump": http.MethodGet, 354 | "/users/groups/:groupId/export": http.MethodGet, 355 | "/users/delete": http.MethodGet, 356 | "/users/all/dump": http.MethodGet, 357 | "/users/all/export": http.MethodGet, 358 | "/users/any": http.MethodGet, 359 | 360 | "/search": http.MethodGet, 361 | "/search/go": http.MethodGet, 362 | "/search/go1.html": http.MethodGet, 363 | "/search/index.html": http.MethodGet, 364 | "/search/:q": http.MethodGet, 365 | "/search/:q/go": http.MethodGet, 366 | "/search/:q/go1.html": http.MethodGet, 367 | "/search/:q/:w/index.html": http.MethodGet, 368 | 369 | "/src/:dest/invalid": http.MethodGet, 370 | "/src/invalid": http.MethodGet, 371 | "/src1/:dest": http.MethodGet, 372 | "/src1": http.MethodGet, 373 | 374 | "/signal-r/:cmd/reflection": http.MethodGet, 375 | "/signal-r": http.MethodGet, 376 | "/signal-r/:cmd": http.MethodGet, 377 | 378 | "/query/unknown/pages": http.MethodGet, 379 | "/query/:key/:val/:cmd/single": http.MethodGet, 380 | "/query/:key": http.MethodGet, 381 | "/query/:key/:val/:cmd": http.MethodGet, 382 | "/query/:key/:val": http.MethodGet, 383 | "/query/unknown": http.MethodGet, 384 | "/query/untold": http.MethodGet, 385 | 386 | "/questions/:index": http.MethodGet, 387 | "/questions": http.MethodGet, 388 | 389 | "/graphql": http.MethodGet, 390 | "/graph": http.MethodGet, 391 | "/graphql/:cmd": http.MethodGet, 392 | 393 | "/:file": http.MethodGet, 394 | "/:file/remove": http.MethodGet, 395 | 396 | //"/hero-:name": http.MethodGet, 397 | } 398 | 399 | for path, meth := range routes { 400 | r.Map([]string{meth}, path, fakeHandler()) 401 | } 402 | 403 | tests := map[string]string{ 404 | "/users/find": http.MethodGet, 405 | "/users/find/yousuf": http.MethodGet, 406 | //"/users/find/yousuf/import": http.MethodGet, 407 | "/users/john/delete": http.MethodGet, 408 | "/users/911/update": http.MethodGet, 409 | "/users/groups/120/dump": http.MethodGet, 410 | "/users/groups/230/export": http.MethodGet, 411 | //"/users/groups/230/export/csv": http.MethodGet, 412 | "/users/delete": http.MethodGet, 413 | "/users/all/dump": http.MethodGet, 414 | "/users/all/export": http.MethodGet, 415 | //"/users/all/import": http.MethodGet, 416 | "/users/any": http.MethodGet, 417 | //"/users/911": http.MethodGet, 418 | 419 | "/search": http.MethodGet, 420 | "/search/go": http.MethodGet, 421 | "/search/go1.html": http.MethodGet, 422 | "/search/index.html": http.MethodGet, 423 | //"/search/index.html/from-cache": http.MethodGet, 424 | "/search/contact.html": http.MethodGet, 425 | "/search/ducks": http.MethodGet, 426 | "/search/gophers/go": http.MethodGet, 427 | //"/search/gophers/rust": http.MethodGet, 428 | "/search/nature/go1.html": http.MethodGet, 429 | "/search/generics/types/index.html": http.MethodGet, 430 | 431 | "/src/paris/invalid": http.MethodGet, 432 | "/src/invalid": http.MethodGet, 433 | //"/src": http.MethodGet, 434 | "/src1/oslo": http.MethodGet, 435 | "/src1": http.MethodGet, 436 | //"/src1/toronto/ontario": http.MethodGet, 437 | 438 | "/signal-r/protos/reflection": http.MethodGet, 439 | "/signal-r": http.MethodGet, 440 | "/signal-r/push": http.MethodGet, 441 | "/signal-r/connect": http.MethodGet, 442 | 443 | "/query/unknown/pages": http.MethodGet, 444 | "/query/10/amazing/reset/single": http.MethodGet, 445 | //"/query/10/amazing/reset/single/1": http.MethodGet, 446 | "/query/911": http.MethodGet, 447 | "/query/99/sup/update-ttl": http.MethodGet, 448 | "/query/46/hello": http.MethodGet, 449 | "/query/unknown": http.MethodGet, 450 | "/query/untold": http.MethodGet, 451 | //"/query": http.MethodGet, 452 | 453 | "/questions/1001": http.MethodGet, 454 | "/questions": http.MethodGet, 455 | 456 | "/graphql": http.MethodGet, 457 | "/graph": http.MethodGet, 458 | //"/graphq": http.MethodGet, 459 | "/graphql/stream": http.MethodGet, 460 | //"/graphql/stream/tcp": http.MethodGet, 461 | 462 | "/gophers.html": http.MethodGet, 463 | "/gophers.html/remove": http.MethodGet, 464 | //"/gophers.html/fetch": http.MethodGet, 465 | 466 | //"/hero-goku": http.MethodGet, 467 | //"/hero-thor": http.MethodGet, 468 | //"/hero-": http.MethodGet, 469 | } 470 | 471 | requests := make([]*http.Request, 0, len(tests)) 472 | 473 | for path, method := range tests { 474 | req, _ := http.NewRequest(method, path, nil) 475 | requests = append(requests, req) 476 | } 477 | 478 | srv := r.Serve() 479 | 480 | b.ReportAllocs() 481 | b.ResetTimer() 482 | 483 | for i := 0; i < b.N; i++ { 484 | for _, request := range requests { 485 | srv.ServeHTTP(nil, request) 486 | } 487 | } 488 | } 489 | 490 | func BenchmarkRouter_ServeHTTP_MixedRoutes_3(b *testing.B) { 491 | r := newTestRouter() 492 | 493 | paths := map[string]string{ 494 | "/users/find": http.MethodPost, 495 | "/users/find/:name": http.MethodGet, 496 | "/users/:id/delete": http.MethodGet, 497 | "/users/:id/update": http.MethodGet, 498 | "/users/groups/:groupId/dump": http.MethodGet, 499 | "/users/groups/:groupId/export": http.MethodGet, 500 | "/users/delete": http.MethodPost, 501 | "/users/all/dump": http.MethodPost, 502 | "/users/all/export": http.MethodPost, 503 | "/users/any": http.MethodPost, 504 | 505 | "/search": http.MethodPost, 506 | "/search/go": http.MethodPost, 507 | "/search/go1.html": http.MethodPost, 508 | "/search/index.html": http.MethodPost, 509 | "/search/:q": http.MethodGet, 510 | "/search/:q/go": http.MethodGet, 511 | "/search/:q/go1.html": http.MethodGet, 512 | "/search/:q/:w/index.html": http.MethodGet, 513 | 514 | "/src/:dest/invalid": http.MethodGet, 515 | "/src/invalid": http.MethodPost, 516 | "/src1/:dest": http.MethodGet, 517 | "/src1": http.MethodPost, 518 | 519 | "/signal-r/:cmd/reflection": http.MethodGet, 520 | "/signal-r": http.MethodPost, 521 | "/signal-r/:cmd": http.MethodGet, 522 | 523 | "/query/unknown/pages": http.MethodPost, 524 | "/query/:key/:val/:cmd/single": http.MethodGet, 525 | "/query/:key": http.MethodGet, 526 | "/query/:key/:val/:cmd": http.MethodGet, 527 | "/query/:key/:val": http.MethodGet, 528 | "/query/unknown": http.MethodPost, 529 | "/query/untold": http.MethodPost, 530 | 531 | "/questions/:index": http.MethodGet, 532 | "/questions": http.MethodPost, 533 | 534 | "/graphql": http.MethodPost, 535 | "/graph": http.MethodPost, 536 | "/graphql/:cmd": http.MethodGet, 537 | 538 | "/:file": http.MethodGet, 539 | "/:file/remove": http.MethodGet, 540 | 541 | //"/hero-:name": http.MethodGet, 542 | } 543 | 544 | for path, meth := range paths { 545 | r.Map([]string{meth}, path, fakeHandler()) 546 | } 547 | 548 | tests := map[string]string{ 549 | "/users/find": http.MethodPost, 550 | "/users/find/yousuf": http.MethodGet, 551 | //"/users/find/yousuf/import": http.MethodGet, 552 | "/users/john/delete": http.MethodGet, 553 | "/users/911/update": http.MethodGet, 554 | "/users/groups/120/dump": http.MethodGet, 555 | "/users/groups/230/export": http.MethodGet, 556 | //"/users/groups/230/export/csv": http.MethodGet, 557 | "/users/delete": http.MethodPost, 558 | "/users/all/dump": http.MethodPost, 559 | "/users/all/export": http.MethodPost, 560 | //"/users/all/import": http.MethodGet, 561 | "/users/any": http.MethodPost, 562 | // "/users/911": http.MethodGet, 563 | 564 | "/search": http.MethodPost, 565 | "/search/go": http.MethodPost, 566 | "/search/go1.html": http.MethodPost, 567 | "/search/index.html": http.MethodPost, 568 | //"/search/index.html/from-cache": http.MethodGet, 569 | "/search/contact.html": http.MethodGet, 570 | "/search/ducks": http.MethodGet, 571 | "/search/gophers/go": http.MethodGet, 572 | // "/search/gophers/rust": http.MethodGet, 573 | "/search/nature/go1.html": http.MethodGet, 574 | "/search/generics/types/index.html": http.MethodGet, 575 | 576 | "/src/paris/invalid": http.MethodGet, 577 | "/src/invalid": http.MethodPost, 578 | // "/src": http.MethodGet, 579 | "/src1/oslo": http.MethodGet, 580 | "/src1": http.MethodPost, 581 | // "/src1/toronto/ontario": http.MethodGet, 582 | 583 | "/signal-r/protos/reflection": http.MethodGet, 584 | "/signal-r": http.MethodPost, 585 | "/signal-r/push": http.MethodGet, 586 | "/signal-r/connect": http.MethodGet, 587 | 588 | "/query/unknown/pages": http.MethodPost, 589 | "/query/10/amazing/reset/single": http.MethodGet, 590 | // "/query/10/amazing/reset/single/1": http.MethodGet, 591 | "/query/911": http.MethodGet, 592 | "/query/99/sup/update-ttl": http.MethodGet, 593 | "/query/46/hello": http.MethodGet, 594 | "/query/unknown": http.MethodPost, 595 | "/query/untold": http.MethodPost, 596 | // "/query": http.MethodGet, 597 | 598 | "/questions/1001": http.MethodGet, 599 | "/questions": http.MethodPost, 600 | 601 | "/graphql": http.MethodPost, 602 | "/graph": http.MethodPost, 603 | // "/graphq": http.MethodGet, 604 | "/graphql/stream": http.MethodGet, 605 | // "/graphql/stream/tcp": http.MethodGet, 606 | 607 | "/gophers.html": http.MethodGet, 608 | "/gophers.html/remove": http.MethodGet, 609 | // "/gophers.html/fetch": http.MethodGet, 610 | 611 | // "/hero-goku": http.MethodGet, 612 | // "/hero-thor": http.MethodGet, 613 | // "/hero-": http.MethodGet, 614 | } 615 | 616 | requests := make([]*http.Request, 0, len(tests)) 617 | 618 | for path, method := range tests { 619 | req, _ := http.NewRequest(method, path, nil) 620 | requests = append(requests, req) 621 | } 622 | 623 | srv := r.Serve() 624 | 625 | b.ResetTimer() 626 | b.ReportAllocs() 627 | 628 | for i := 0; i < b.N; i++ { 629 | for _, request := range requests { 630 | srv.ServeHTTP(nil, request) 631 | } 632 | } 633 | } 634 | 635 | func BenchmarkRouter_ServeHTTP_MixedRoutes_4(b *testing.B) { 636 | r := newTestRouter() 637 | 638 | paths := map[string]string{ 639 | "/users/find": http.MethodGet, 640 | "/users/find/:name": http.MethodGet, 641 | "/users/:id/delete": http.MethodGet, 642 | "/users/:id/update": http.MethodGet, 643 | "/users/groups/:groupId/dump": http.MethodGet, 644 | "/users/groups/:groupId/export": http.MethodGet, 645 | "/users/delete": http.MethodGet, 646 | "/users/all/dump": http.MethodGet, 647 | "/users/all/export": http.MethodGet, 648 | "/users/any": http.MethodGet, 649 | 650 | "/search": http.MethodPost, 651 | "/search/go": http.MethodPost, 652 | "/search/go1.html": http.MethodPost, 653 | "/search/index.html": http.MethodPost, 654 | "/search/:q": http.MethodPost, 655 | "/search/:q/go": http.MethodPost, 656 | "/search/:q/go1.html": http.MethodPost, 657 | "/search/:q/:w/index.html": http.MethodPost, 658 | 659 | "/src/:dest/invalid": http.MethodPut, 660 | "/src/invalid": http.MethodPut, 661 | "/src1/:dest": http.MethodPut, 662 | "/src1": http.MethodPut, 663 | 664 | "/signal-r/:cmd/reflection": http.MethodPatch, 665 | "/signal-r": http.MethodPatch, 666 | "/signal-r/:cmd": http.MethodPatch, 667 | 668 | "/query/unknown/pages": http.MethodHead, 669 | "/query/:key/:val/:cmd/single": http.MethodHead, 670 | "/query/:key": http.MethodHead, 671 | "/query/:key/:val/:cmd": http.MethodHead, 672 | "/query/:key/:val": http.MethodHead, 673 | "/query/unknown": http.MethodHead, 674 | "/query/untold": http.MethodHead, 675 | 676 | "/questions/:index": http.MethodConnect, 677 | "/questions": http.MethodConnect, 678 | 679 | "/graphql": http.MethodDelete, 680 | "/graph": http.MethodDelete, 681 | "/graphql/cmd": http.MethodDelete, 682 | 683 | "/file": http.MethodDelete, 684 | "/file/remove": http.MethodDelete, 685 | 686 | "/hero-:name": http.MethodGet, 687 | } 688 | 689 | for path, meth := range paths { 690 | r.Map([]string{meth}, path, fakeHandler()) 691 | } 692 | 693 | tests := map[string]string{ 694 | "/users/find": http.MethodGet, 695 | "/users/find/yousuf": http.MethodGet, 696 | "/users/john/delete": http.MethodGet, 697 | "/users/911/update": http.MethodGet, 698 | "/users/groups/120/dump": http.MethodGet, 699 | "/users/groups/230/export": http.MethodGet, 700 | "/users/delete": http.MethodGet, 701 | "/users/all/dump": http.MethodGet, 702 | "/users/all/export": http.MethodGet, 703 | "/users/any": http.MethodGet, 704 | 705 | "/search": http.MethodPost, 706 | "/search/go": http.MethodPost, 707 | "/search/go1.html": http.MethodPost, 708 | "/search/index.html": http.MethodPost, 709 | "/search/contact.html": http.MethodPost, 710 | "/search/ducks": http.MethodPost, 711 | "/search/gophers/go": http.MethodPost, 712 | "/search/nature/go1.html": http.MethodPost, 713 | "/search/generics/types/index.html": http.MethodPost, 714 | 715 | "/src/paris/invalid": http.MethodPut, 716 | "/src/invalid": http.MethodPut, 717 | "/src1/oslo": http.MethodPut, 718 | "/src1": http.MethodPut, 719 | 720 | "/signal-r/protos/reflection": http.MethodPatch, 721 | "/signal-r": http.MethodPatch, 722 | "/signal-r/push": http.MethodPatch, 723 | "/signal-r/connect": http.MethodPatch, 724 | 725 | "/query/unknown/pages": http.MethodHead, 726 | "/query/10/amazing/reset/single": http.MethodHead, 727 | "/query/911": http.MethodHead, 728 | "/query/99/sup/update-ttl": http.MethodHead, 729 | "/query/46/hello": http.MethodHead, 730 | "/query/unknown": http.MethodHead, 731 | "/query/untold": http.MethodHead, 732 | 733 | "/questions/1001": http.MethodConnect, 734 | "/questions": http.MethodConnect, 735 | 736 | "/graphql": http.MethodDelete, 737 | "/graph": http.MethodDelete, 738 | "/graphql/cmd": http.MethodDelete, 739 | 740 | "/file": http.MethodDelete, 741 | "/file/remove": http.MethodDelete, 742 | 743 | "/hero-goku": http.MethodGet, 744 | "/hero-thor": http.MethodGet, 745 | } 746 | 747 | requests := make([]*http.Request, 0, len(tests)) 748 | 749 | for path, method := range tests { 750 | req, _ := http.NewRequest(method, path, nil) 751 | requests = append(requests, req) 752 | } 753 | 754 | srv := r.Serve() 755 | 756 | b.ResetTimer() 757 | b.ReportAllocs() 758 | 759 | for i := 0; i < b.N; i++ { 760 | for _, request := range requests { 761 | srv.ServeHTTP(nil, request) 762 | } 763 | } 764 | } 765 | 766 | func BenchmarkRouter_ServeHTTP_CaseInsensitive_WithRedirect(b *testing.B) { 767 | r := New() 768 | r.UsePathCorrectionMatch(WithRedirect()) 769 | 770 | paths := map[string]string{ 771 | "/users/find": http.MethodGet, 772 | "/users/find/:name": http.MethodGet, 773 | "/users/:id/delete": http.MethodGet, 774 | "/users/:id/update": http.MethodGet, 775 | "/users/groups/:groupId/dump": http.MethodGet, 776 | "/users/groups/:groupId/export": http.MethodGet, 777 | "/users/delete": http.MethodGet, 778 | "/users/all/dump": http.MethodGet, 779 | "/users/all/export": http.MethodGet, 780 | "/users/any": http.MethodGet, 781 | 782 | "/search": http.MethodPost, 783 | "/search/go": http.MethodPost, 784 | "/search/go1.html": http.MethodPost, 785 | "/search/index.html": http.MethodPost, 786 | "/search/:q": http.MethodPost, 787 | "/search/:q/go": http.MethodPost, 788 | "/search/:q/go1.html": http.MethodPost, 789 | "/search/:q/:w/index.html": http.MethodPost, 790 | 791 | "/src/:dest/invalid": http.MethodPut, 792 | "/src/invalid": http.MethodPut, 793 | "/src1/:dest": http.MethodPut, 794 | "/src1": http.MethodPut, 795 | 796 | "/signal-r/:cmd/reflection": http.MethodPatch, 797 | "/signal-r": http.MethodPatch, 798 | "/signal-r/:cmd": http.MethodPatch, 799 | 800 | "/query/unknown/pages": http.MethodHead, 801 | "/query/:key/:val/:cmd/single": http.MethodHead, 802 | "/query/:key": http.MethodHead, 803 | "/query/:key/:val/:cmd": http.MethodHead, 804 | "/query/:key/:val": http.MethodHead, 805 | "/query/unknown": http.MethodHead, 806 | "/query/untold": http.MethodHead, 807 | 808 | "/questions/:index": http.MethodOptions, 809 | "/questions": http.MethodOptions, 810 | 811 | "/graphql": http.MethodDelete, 812 | "/graph": http.MethodDelete, 813 | "/graphql/cmd": http.MethodDelete, 814 | 815 | "/file": http.MethodDelete, 816 | "/file/remove": http.MethodDelete, 817 | 818 | "/hero-:name": http.MethodGet, 819 | } 820 | 821 | for path, meth := range paths { 822 | r.Map([]string{meth}, path, fakeHandler()) 823 | } 824 | 825 | tests := map[string]string{ 826 | "/users/finD": http.MethodGet, 827 | "/users/finD/yousuf": http.MethodGet, 828 | "/users/john/deletE": http.MethodGet, 829 | "/users/911/updatE": http.MethodGet, 830 | "/users/groupS/120/dumP": http.MethodGet, 831 | "/users/groupS/230/exporT": http.MethodGet, 832 | "/users/deletE": http.MethodGet, 833 | "/users/alL/dumP": http.MethodGet, 834 | "/users/alL/exporT": http.MethodGet, 835 | "/users/AnY": http.MethodGet, 836 | 837 | "/seArcH": http.MethodPost, 838 | "/sEarCh/gO": http.MethodPost, 839 | "/SeArcH/Go1.hTMl": http.MethodPost, 840 | "/sEaRch/inDEx.hTMl": http.MethodPost, 841 | "/SEARCH/contact.html": http.MethodPost, 842 | "/SeArCh/ducks": http.MethodPost, 843 | "/sEArCH/gophers/Go": http.MethodPost, 844 | "/sEArCH/nature/go1.HTML": http.MethodPost, 845 | "/search/generics/types/index.html": http.MethodPost, 846 | 847 | "/Src/paris/InValiD": http.MethodPut, 848 | "/SrC/InvaliD": http.MethodPut, 849 | "/SrC1/oslo": http.MethodPut, 850 | "/SrC1": http.MethodPut, 851 | 852 | "/Signal-R/protos/reflection": http.MethodPatch, 853 | "/sIgNaL-r": http.MethodPatch, 854 | "/SIGNAL-R/push": http.MethodPatch, 855 | "/sIGNal-r/connect": http.MethodPatch, 856 | 857 | "/quERy/unKNown/paGEs": http.MethodHead, 858 | "/QUery/10/amazing/reset/SiNglE": http.MethodHead, 859 | "/QueRy/911": http.MethodHead, 860 | "/qUERy/99/sup/update-ttl": http.MethodHead, 861 | "/QueRy/46/hello": http.MethodHead, 862 | "/qUeRy/uNkNoWn": http.MethodHead, 863 | "/QuerY/UntOld": http.MethodHead, 864 | 865 | "/qUestions/1001": http.MethodOptions, 866 | "/quEsTioNs": http.MethodOptions, 867 | 868 | "/GRAPHQL": http.MethodDelete, 869 | "/gRapH": http.MethodDelete, 870 | "/grAphQl/cMd": http.MethodDelete, 871 | 872 | "/File": http.MethodDelete, 873 | "/fIle/rEmOve": http.MethodDelete, 874 | 875 | "/heRO-goku": http.MethodGet, 876 | "/HEro-thor": http.MethodGet, 877 | } 878 | 879 | requests := make([]*http.Request, 0, len(tests)) 880 | 881 | for path, method := range tests { 882 | req, _ := http.NewRequest(method, path, nil) 883 | requests = append(requests, req) 884 | } 885 | 886 | srv := r.Serve() 887 | rw := newMockRW() 888 | 889 | b.ResetTimer() 890 | b.ReportAllocs() 891 | 892 | for i := 0; i < b.N; i++ { 893 | for _, request := range requests { 894 | srv.ServeHTTP(rw, request) 895 | } 896 | } 897 | } 898 | -------------------------------------------------------------------------------- /routescanner.go: -------------------------------------------------------------------------------- 1 | package shift 2 | 3 | type routeScanner struct { 4 | path string 5 | low int 6 | high int 7 | wc bool 8 | } 9 | 10 | func newRouteScanner(path string) *routeScanner { 11 | return &routeScanner{ 12 | path: path, 13 | wc: len(path) > 0 && (path[0] == ':' || path[0] == '*'), 14 | } 15 | } 16 | 17 | func (r *routeScanner) next() string { 18 | if r.high > len(r.path)-1 { 19 | return "" 20 | } 21 | 22 | Loop: 23 | for r.high < len(r.path) { 24 | if r.wc { 25 | if r.path[r.high] == '/' { 26 | break Loop 27 | } 28 | } else { 29 | switch r.path[r.high] { 30 | case ':', '*': 31 | break Loop 32 | } 33 | } 34 | 35 | r.high++ 36 | } 37 | 38 | r.wc = !r.wc 39 | seg := r.path[r.low:r.high] 40 | 41 | r.low = r.high 42 | 43 | return seg 44 | } 45 | -------------------------------------------------------------------------------- /routescanner_test.go: -------------------------------------------------------------------------------- 1 | package shift 2 | 3 | import "testing" 4 | 5 | func TestRouteScanner(t *testing.T) { 6 | table := []struct { 7 | path string 8 | segments []string 9 | }{ 10 | {path: "/blog/posts", segments: []string{"/blog/posts"}}, 11 | {path: "/users/:id/action", segments: []string{"/users/", ":id", "/action"}}, 12 | {path: "/assets/*dir", segments: []string{"/assets/", "*dir"}}, 13 | {path: "/heroes/:name/:power", segments: []string{"/heroes/", ":name", "/", ":power"}}, 14 | } 15 | 16 | for _, item := range table { 17 | r := newRouteScanner(item.path) 18 | for seg, i := r.next(), 0; seg != ""; seg, i = r.next(), i+1 { 19 | if seg != item.segments[i] { 20 | t.Errorf("expected: %s, got: %s", item.segments[i], seg) 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package shift 2 | 3 | import ( 4 | "net/http" 5 | "sort" 6 | "strings" 7 | ) 8 | 9 | type Server struct { 10 | muxes [9]multiplexer // Muxes for default HTTP methods. 11 | muxIndices []int // Indices of non-nil muxes. This index is useful to skip muxes. 12 | customMuxes map[string]multiplexer // Muxes for custom HTTP methods. 13 | config *Config 14 | } 15 | 16 | func (svr *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { 17 | path := r.URL.RawPath 18 | if path == "" { 19 | path = r.URL.Path 20 | } 21 | 22 | var mux multiplexer 23 | if idx := methodIndex(r.Method); idx >= 0 { 24 | mux = svr.muxes[idx] 25 | } else { 26 | mux = svr.customMuxes[r.Method] 27 | } 28 | 29 | if mux == nil { 30 | if svr.config.handleMethodNotAllowed { 31 | svr.handleMethodNotAllowed(path, r.Method, w) 32 | } 33 | 34 | svr.config.notFoundHandler(w, r) 35 | return 36 | } 37 | 38 | handler, ps, template := mux.find(path) 39 | if handler != nil { 40 | _ = handler(w, r, Route{ 41 | Params: newParams(ps), // ps could be as well, but that's okay! 42 | Path: template, 43 | }) 44 | return 45 | } 46 | 47 | // Look with/without trailing slash. 48 | if svr.config.trailingSlashMatch.behavior != behaviorSkip { 49 | var clean string 50 | if len(path) > 0 && path[len(path)-1] == '/' { 51 | clean = path[:len(path)-1] 52 | } else { 53 | clean = path + "/" 54 | } 55 | 56 | handler, ps, template = mux.find(clean) 57 | if handler != nil { 58 | switch svr.config.trailingSlashMatch.behavior { 59 | case behaviorRedirect: 60 | r.URL.Path = clean 61 | http.Redirect(w, r, r.URL.String(), svr.config.trailingSlashMatch.code) 62 | return 63 | case behaviorExecute: 64 | r.URL.Path = clean 65 | _ = handler(w, r, Route{ 66 | Params: newParams(ps), // ps could be here too, but that's okay! 67 | Path: template, 68 | }) 69 | return 70 | } 71 | } 72 | } 73 | 74 | // Correct the path and do a case-insensitive search... 75 | if svr.config.pathCorrectionMatch.behavior != behaviorSkip { 76 | clean := cleanPath(path) 77 | handler, ps, template, matchedPath := mux.findCaseInsensitive(clean, svr.config.pathCorrectionMatch.behavior == behaviorExecute) 78 | if handler != nil { 79 | switch svr.config.pathCorrectionMatch.behavior { 80 | case behaviorRedirect: 81 | r.URL.Path = matchedPath 82 | http.Redirect(w, r, r.URL.String(), svr.config.pathCorrectionMatch.code) 83 | return 84 | case behaviorExecute: 85 | _ = handler(w, r, Route{ 86 | Params: newParams(ps), // ps could be here too, but that's okay! 87 | Path: template, 88 | }) 89 | return 90 | } 91 | } 92 | } 93 | 94 | // Look for allowed methods. 95 | if svr.config.handleMethodNotAllowed { 96 | svr.handleMethodNotAllowed(path, r.Method, w) 97 | } 98 | 99 | svr.config.notFoundHandler(w, r) 100 | return 101 | } 102 | 103 | func (svr *Server) populateRoutes(byMethods map[string]*methodInfo) { 104 | for method, info := range byMethods { 105 | var mux multiplexer 106 | 107 | total := len(info.logs) // Total routes in the method. 108 | staticPercentage := float64(info.staticRoutes) / float64(total) * 100 109 | 110 | // Determine mux variant. 111 | if staticPercentage == 100 { 112 | mux = newStaticMux() 113 | } else if staticPercentage >= 30 { 114 | mux = newHybridMux() 115 | } else { 116 | mux = newRadixMux() 117 | } 118 | 119 | // Register routes. 120 | for _, log := range info.logs { 121 | mux.add(log.path, log.static, log.handler) 122 | } 123 | 124 | // Store mux. 125 | if idx := methodIndex(method); idx >= 0 { 126 | svr.muxes[idx] = mux 127 | 128 | // Store indices of active muxes in ascending order. 129 | svr.muxIndices = append(svr.muxIndices, idx) 130 | sort.Slice(svr.muxIndices, func(i, j int) bool { 131 | return svr.muxIndices[i] < svr.muxIndices[j] 132 | }) 133 | } else { 134 | if svr.customMuxes == nil { 135 | svr.customMuxes = make(map[string]multiplexer) 136 | } 137 | svr.customMuxes[method] = mux 138 | } 139 | } 140 | 141 | } 142 | 143 | func (svr *Server) handleMethodNotAllowed(path string, method string, w http.ResponseWriter) { 144 | allowed := svr.allowedHeader(path, method) 145 | 146 | if len(allowed) > 0 { 147 | w.Header().Add("Allow", allowed) 148 | w.WriteHeader(http.StatusMethodNotAllowed) 149 | } 150 | } 151 | 152 | func (svr *Server) allowedHeader(path string, skipMethod string) string { 153 | var allowed strings.Builder 154 | skipped := false 155 | 156 | if skipMethodIdx := methodIndex(skipMethod); skipMethodIdx != -1 { 157 | for _, idx := range svr.muxIndices { 158 | if !skipped && idx == skipMethodIdx { 159 | skipped = true 160 | continue 161 | } 162 | 163 | if handler, _, _ := svr.muxes[idx].find(path); handler != nil { 164 | if allowed.Len() != 0 { 165 | allowed.WriteString(", ") 166 | } 167 | 168 | allowed.WriteString(methodString(idx)) 169 | } 170 | } 171 | } 172 | 173 | for method, mux := range svr.customMuxes { 174 | if !skipped && method == skipMethod { 175 | continue 176 | } 177 | 178 | if handler, _, _ := mux.find(path); handler != nil { 179 | if allowed.Len() != 0 { 180 | allowed.WriteString(", ") 181 | } 182 | 183 | allowed.WriteString(method) 184 | } 185 | } 186 | 187 | return allowed.String() 188 | } 189 | 190 | func methodIndex(method string) int { 191 | switch method { 192 | case http.MethodGet: 193 | return 0 194 | case http.MethodPost: 195 | return 1 196 | case http.MethodPut: 197 | return 2 198 | case http.MethodPatch: 199 | return 3 200 | case http.MethodDelete: 201 | return 4 202 | case http.MethodHead: 203 | return 5 204 | case http.MethodOptions: 205 | return 6 206 | case http.MethodTrace: 207 | return 7 208 | case http.MethodConnect: 209 | return 8 210 | default: 211 | return -1 212 | } 213 | } 214 | 215 | func methodString(idx int) string { 216 | switch idx { 217 | case 0: 218 | return http.MethodGet 219 | case 1: 220 | return http.MethodPost 221 | case 2: 222 | return http.MethodPut 223 | case 3: 224 | return http.MethodPatch 225 | case 4: 226 | return http.MethodDelete 227 | case 5: 228 | return http.MethodHead 229 | case 6: 230 | return http.MethodOptions 231 | case 7: 232 | return http.MethodTrace 233 | case 8: 234 | return http.MethodConnect 235 | default: 236 | return "" 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /types.go: -------------------------------------------------------------------------------- 1 | package shift 2 | 3 | import "net/http" 4 | 5 | // Route provides route information. 6 | // Route.Params is always non-nil, so it's not necessary to perform a check. 7 | // 8 | // When passing Route to a goroutine, make to sure pass a copy (use Copy method) 9 | // instead of the original Route object. The reason being Route.Params is pooled into a sync.Pool when the 10 | // request is completed. 11 | type Route struct { 12 | Params Params 13 | Path string 14 | } 15 | 16 | // Copy returns a copy of the [Route]. 17 | // It calls [Params.Copy] implicitly to copy the underlying [Route.Params] object. 18 | func (r Route) Copy() Route { 19 | return Route{ 20 | Params: r.Params.Copy(), 21 | Path: r.Path, 22 | } 23 | } 24 | 25 | // HandlerFunc is an extension of the http.HandlerFunc signature taking a third parameter to provide route information 26 | // and returns an error to ease global error handling in the middleware stack. 27 | type HandlerFunc func(w http.ResponseWriter, r *http.Request, route Route) error 28 | 29 | // MiddlewareFunc takes a HandlerFunc and returns a HandlerFunc which can call the provided HandlerFunc. 30 | // This design is useful for chaining handlers and building the middleware stack. 31 | type MiddlewareFunc func(next HandlerFunc) HandlerFunc 32 | 33 | // HTTPHandlerFunc allows to use an idiomatic http.HandlerFunc in place of a HandlerFunc. 34 | // To retrieve Route information, 35 | // 36 | // 1. Use RouteContext middleware to pack Route information into the http.Request context. 37 | // 38 | // router.Use(shift.RouteContext()) 39 | // 40 | // 2. Use RouteOf func to unpack from the http.Request context. 41 | // 42 | // func(w http.ResponseWriter, r *http.Request) error { 43 | // route := RouteOf(r) 44 | // ... 45 | // } 46 | func HTTPHandlerFunc(handler http.HandlerFunc) HandlerFunc { 47 | return func(w http.ResponseWriter, r *http.Request, route Route) error { 48 | handler.ServeHTTP(w, r) 49 | return nil 50 | } 51 | } 52 | 53 | // HTTPMiddlewareFunc allows to use an idiomatic middleware function 54 | // 'func(next http.Handler) http.Handler' in place of a MiddlewareFunc. 55 | // To retrieve Route information, use the RouteContext middleware and RouteOf func. 56 | func HTTPMiddlewareFunc(mw func(next http.Handler) http.Handler) MiddlewareFunc { 57 | return func(next HandlerFunc) HandlerFunc { 58 | return func(w http.ResponseWriter, r *http.Request, route Route) (err error) { 59 | mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 60 | err = next(w, r, route) 61 | })).ServeHTTP(w, r) 62 | return 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /unholy_1.19.go: -------------------------------------------------------------------------------- 1 | //go:build !go1.20 2 | 3 | package shift 4 | 5 | import "unsafe" 6 | 7 | // bytesToString converts provided bytes to string without incurring additional allocations using unsafe type casting. 8 | func bytesToString(b []byte) string { 9 | return *(*string)(unsafe.Pointer(&b)) 10 | } 11 | -------------------------------------------------------------------------------- /unholy_1.20.go: -------------------------------------------------------------------------------- 1 | //go:build go1.20 2 | 3 | package shift 4 | 5 | import "unsafe" 6 | 7 | // bytesToString converts provided bytes to string without incurring additional allocations using unsafe type casting. 8 | func bytesToString(b []byte) string { 9 | return unsafe.String(unsafe.SliceData(b), len(b)) 10 | } 11 | --------------------------------------------------------------------------------