diff --git a/Benchmarks_test.go b/Benchmarks_test.go index 4d8a7f5..2170ae0 100644 --- a/Benchmarks_test.go +++ b/Benchmarks_test.go @@ -7,32 +7,36 @@ import ( ) func BenchmarkLookup(b *testing.B) { - tree := router.Tree[string]{} + router := router.New[string]() routes := loadRoutes("testdata/github.txt") for _, route := range routes { - tree.Add(route, "") + router.Add(route.method, route.path, "") } + b.ResetTimer() + for i := 0; i < b.N; i++ { for _, route := range routes { - tree.Lookup(route) + router.Lookup(route.method, route.path) } } } func BenchmarkLookupNoAlloc(b *testing.B) { - tree := router.Tree[string]{} + router := router.New[string]() routes := loadRoutes("testdata/github.txt") addParameter := func(string, string) {} for _, route := range routes { - tree.Add(route, "") + router.Add(route.method, route.path, "") } + b.ResetTimer() + for i := 0; i < b.N; i++ { for _, route := range routes { - tree.LookupNoAlloc(route, addParameter) + router.LookupNoAlloc(route.method, route.path, addParameter) } } } diff --git a/README.md b/README.md index 00b70fe..d47b2b9 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,22 @@ HTTP router based on radix trees. +## Example + +We can save any type of data inside the router. Here is an example storing strings for each route: + +```go +router := router.New[string]() + +router.Add("GET", "/hello", "Hello") +router.Add("GET", "/world", "World") +``` + ## Benchmarks -Loading and requesting every single route in [github.txt](testdata/github.txt): +Requesting every single route in [github.txt](testdata/github.txt): ``` -BenchmarkLookup-12 50715 23207 ns/op 11649 B/op 204 allocs/op -BenchmarkLookupNoAlloc-12 148033 7993 ns/op 0 B/op 0 allocs/op +BenchmarkLookup-12 30147 39105 ns/op 19488 B/op 337 allocs/op +BenchmarkLookupNoAlloc-12 85166 14411 ns/op 0 B/op 0 allocs/op ``` \ No newline at end of file diff --git a/Router.go b/Router.go new file mode 100644 index 0000000..0e53df1 --- /dev/null +++ b/Router.go @@ -0,0 +1,84 @@ +package router + +// Router is a high-performance router. +type Router[T comparable] struct { + get Tree[T] + post Tree[T] + delete Tree[T] + put Tree[T] + patch Tree[T] + head Tree[T] + connect Tree[T] + trace Tree[T] + options Tree[T] +} + +// New creates a new router containing trees for every HTTP method. +func New[T comparable]() *Router[T] { + return &Router[T]{} +} + +// Add registers a new handler for the given method and path. +func (router *Router[T]) Add(method string, path string, handler T) { + tree := router.selectTree(method) + tree.Add(path, handler) +} + +// Lookup finds the handler and parameters for the given route. +func (router *Router[T]) Lookup(method string, path string) (T, []Parameter) { + if method[0] == 'G' { + return router.get.Lookup(path) + } + + tree := router.selectTree(method) + return tree.Lookup(path) +} + +// LookupNoAlloc finds the handler and parameters for the given route without using any memory allocations. +func (router *Router[T]) LookupNoAlloc(method string, path string, addParameter func(string, string)) T { + if method[0] == 'G' { + return router.get.LookupNoAlloc(path, addParameter) + } + + tree := router.selectTree(method) + return tree.LookupNoAlloc(path, addParameter) +} + +// Bind traverses all trees and calls the given function on every node. +func (router *Router[T]) Bind(transform func(T) T) { + router.get.Bind(transform) + router.post.Bind(transform) + router.delete.Bind(transform) + router.put.Bind(transform) + router.patch.Bind(transform) + router.head.Bind(transform) + router.connect.Bind(transform) + router.trace.Bind(transform) + router.options.Bind(transform) +} + +// selectTree returns the tree by the given HTTP method. +func (router *Router[T]) selectTree(method string) *Tree[T] { + switch method { + case "GET": + return &router.get + case "POST": + return &router.post + case "DELETE": + return &router.delete + case "PUT": + return &router.put + case "PATCH": + return &router.patch + case "HEAD": + return &router.head + case "CONNECT": + return &router.connect + case "TRACE": + return &router.trace + case "OPTIONS": + return &router.options + default: + return nil + } +} diff --git a/Tree_test.go b/Router_test.go similarity index 52% rename from Tree_test.go rename to Router_test.go index b46ba36..a49ac7c 100644 --- a/Tree_test.go +++ b/Router_test.go @@ -10,38 +10,43 @@ import ( "git.akyoto.dev/go/router" ) +type route struct { + method string + path string +} + func TestHello(t *testing.T) { - tree := router.Tree[string]{} + router := router.New[string]() - tree.Add("/hello", "Hello") - tree.Add("/world", "World") + router.Add("GET", "/hello", "Hello") + router.Add("GET", "/world", "World") - data, params := tree.Lookup("/hello") + data, params := router.Lookup("GET", "/hello") assert.Equal(t, len(params), 0) assert.Equal(t, data, "Hello") - data, params = tree.Lookup("/world") + data, params = router.Lookup("GET", "/world") assert.Equal(t, len(params), 0) assert.Equal(t, data, "World") - data, params = tree.Lookup("/404") + data, params = router.Lookup("GET", "/404") assert.Equal(t, len(params), 0) assert.Equal(t, data, "") } func TestParams(t *testing.T) { - tree := router.Tree[string]{} + router := router.New[string]() - tree.Add("/blog/:slug", "Blog post") - tree.Add("/blog/:slug/comments/:id", "Comment") + router.Add("GET", "/blog/:slug", "Blog post") + router.Add("GET", "/blog/:slug/comments/:id", "Comment") - data, params := tree.Lookup("/blog/hello-world") + data, params := router.Lookup("GET", "/blog/hello-world") assert.Equal(t, len(params), 1) assert.Equal(t, params[0].Key, "slug") assert.Equal(t, params[0].Value, "hello-world") assert.Equal(t, data, "Blog post") - data, params = tree.Lookup("/blog/hello-world/comments/123") + data, params = router.Lookup("GET", "/blog/hello-world/comments/123") assert.Equal(t, len(params), 2) assert.Equal(t, params[0].Key, "slug") assert.Equal(t, params[0].Value, "hello-world") @@ -51,34 +56,38 @@ func TestParams(t *testing.T) { } func TestGitHub(t *testing.T) { - tree := router.Tree[string]{} + tree := router.New[string]() routes := loadRoutes("testdata/github.txt") for _, route := range routes { - tree.Add(route, "octocat") + tree.Add(route.method, route.path, "octocat") } for _, route := range routes { - data, _ := tree.Lookup(route) + data, _ := tree.Lookup(route.method, route.path) assert.Equal(t, data, "octocat") } } -func loadRoutes(fileName string) []string { - var routes []string +func loadRoutes(fileName string) []route { + var routes []route - for route := range routesInFile(fileName) { - routes = append(routes, route) + for line := range linesInFile(fileName) { + parts := strings.Split(line, " ") + routes = append(routes, route{ + method: parts[0], + path: parts[1], + }) } return routes } -func routesInFile(fileName string) <-chan string { - routes := make(chan string) +func linesInFile(fileName string) <-chan string { + lines := make(chan string) go func() { - defer close(routes) + defer close(lines) file, err := os.Open(fileName) if err != nil { @@ -89,9 +98,9 @@ func routesInFile(fileName string) <-chan string { scanner := bufio.NewScanner(file) for scanner.Scan() { - routes <- strings.TrimSpace(scanner.Text()) + lines <- strings.TrimSpace(scanner.Text()) } }() - return routes + return lines } diff --git a/testdata/github.txt b/testdata/github.txt index 5dc1acd..fc85456 100644 --- a/testdata/github.txt +++ b/testdata/github.txt @@ -1,131 +1,203 @@ -/authorizations -/authorizations/:id -/applications/:client_id/tokens/:access_token -/events -/repos/:owner/:repo/events -/networks/:owner/:repo/events -/orgs/:org/events -/users/:user/received_events -/users/:user/received_events/public -/users/:user/events -/users/:user/events/public -/users/:user/events/orgs/:org -/feeds -/notifications -/repos/:owner/:repo/notifications -/notifications/threads/:id -/notifications/threads/:id/subscription -/repos/:owner/:repo/stargazers -/users/:user/starred -/user/starred -/user/starred/:owner/:repo -/repos/:owner/:repo/subscribers -/users/:user/subscriptions -/user/subscriptions -/repos/:owner/:repo/subscription -/user/subscriptions/:owner/:repo -/users/:user/gists -/gists -/gists/:id -/gists/:id/star -/repos/:owner/:repo/git/blobs/:sha -/repos/:owner/:repo/git/commits/:sha -/repos/:owner/:repo/git/refs -/repos/:owner/:repo/git/tags/:sha -/repos/:owner/:repo/git/trees/:sha -/issues -/user/issues -/orgs/:org/issues -/repos/:owner/:repo/issues -/repos/:owner/:repo/issues/:number -/repos/:owner/:repo/assignees -/repos/:owner/:repo/assignees/:assignee -/repos/:owner/:repo/issues/:number/comments -/repos/:owner/:repo/issues/:number/events -/repos/:owner/:repo/labels -/repos/:owner/:repo/labels/:name -/repos/:owner/:repo/issues/:number/labels -/repos/:owner/:repo/milestones/:number/labels -/repos/:owner/:repo/milestones -/repos/:owner/:repo/milestones/:number -/emojis -/gitignore/templates -/gitignore/templates/:name -/meta -/rate_limit -/users/:user/orgs -/user/orgs -/orgs/:org -/orgs/:org/members -/orgs/:org/members/:user -/orgs/:org/public_members -/orgs/:org/public_members/:user -/orgs/:org/teams -/teams/:id -/teams/:id/members -/teams/:id/members/:user -/teams/:id/repos -/teams/:id/repos/:owner/:repo -/user/teams -/repos/:owner/:repo/pulls -/repos/:owner/:repo/pulls/:number -/repos/:owner/:repo/pulls/:number/commits -/repos/:owner/:repo/pulls/:number/files -/repos/:owner/:repo/pulls/:number/merge -/repos/:owner/:repo/pulls/:number/comments -/user/repos -/users/:user/repos -/orgs/:org/repos -/repositories -/repos/:owner/:repo -/repos/:owner/:repo/contributors -/repos/:owner/:repo/languages -/repos/:owner/:repo/teams -/repos/:owner/:repo/tags -/repos/:owner/:repo/branches -/repos/:owner/:repo/branches/:branch -/repos/:owner/:repo/collaborators -/repos/:owner/:repo/collaborators/:user -/repos/:owner/:repo/comments -/repos/:owner/:repo/commits/:sha/comments -/repos/:owner/:repo/comments/:id -/repos/:owner/:repo/commits -/repos/:owner/:repo/commits/:sha -/repos/:owner/:repo/readme -/repos/:owner/:repo/keys -/repos/:owner/:repo/keys/:id -/repos/:owner/:repo/downloads -/repos/:owner/:repo/downloads/:id -/repos/:owner/:repo/forks -/repos/:owner/:repo/hooks -/repos/:owner/:repo/hooks/:id -/repos/:owner/:repo/releases -/repos/:owner/:repo/releases/:id -/repos/:owner/:repo/releases/:id/assets -/repos/:owner/:repo/stats/contributors -/repos/:owner/:repo/stats/commit_activity -/repos/:owner/:repo/stats/code_frequency -/repos/:owner/:repo/stats/participation -/repos/:owner/:repo/stats/punch_card -/repos/:owner/:repo/statuses/:ref -/search/repositories -/search/code -/search/issues -/search/users -/legacy/issues/search/:owner/:repository/:state/:keyword -/legacy/repos/search/:keyword -/legacy/user/search/:keyword -/legacy/user/email/:email -/users/:user -/user -/users -/user/emails -/users/:user/followers -/user/followers -/users/:user/following -/user/following -/user/following/:user -/users/:user/following/:target_user -/users/:user/keys -/user/keys -/user/keys/:id \ No newline at end of file +GET /authorizations +GET /authorizations/:id +POST /authorizations +DELETE /authorizations/:id +GET /applications/:client_id/tokens/:access_token +DELETE /applications/:client_id/tokens +DELETE /applications/:client_id/tokens/:access_token +GET /events +GET /repos/:owner/:repo/events +GET /networks/:owner/:repo/events +GET /orgs/:org/events +GET /users/:user/received_events +GET /users/:user/received_events/public +GET /users/:user/events +GET /users/:user/events/public +GET /users/:user/events/orgs/:org +GET /feeds +GET /notifications +GET /repos/:owner/:repo/notifications +PUT /notifications +PUT /repos/:owner/:repo/notifications +GET /notifications/threads/:id +GET /notifications/threads/:id/subscription +PUT /notifications/threads/:id/subscription +DELETE /notifications/threads/:id/subscription +GET /repos/:owner/:repo/stargazers +GET /users/:user/starred +GET /user/starred +GET /user/starred/:owner/:repo +PUT /user/starred/:owner/:repo +DELETE /user/starred/:owner/:repo +GET /repos/:owner/:repo/subscribers +GET /users/:user/subscriptions +GET /user/subscriptions +GET /repos/:owner/:repo/subscription +PUT /repos/:owner/:repo/subscription +DELETE /repos/:owner/:repo/subscription +GET /user/subscriptions/:owner/:repo +PUT /user/subscriptions/:owner/:repo +DELETE /user/subscriptions/:owner/:repo +GET /users/:user/gists +GET /gists +GET /gists/:id +POST /gists +PUT /gists/:id/star +DELETE /gists/:id/star +GET /gists/:id/star +POST /gists/:id/forks +DELETE /gists/:id +GET /repos/:owner/:repo/git/blobs/:sha +POST /repos/:owner/:repo/git/blobs +GET /repos/:owner/:repo/git/commits/:sha +POST /repos/:owner/:repo/git/commits +GET /repos/:owner/:repo/git/refs +POST /repos/:owner/:repo/git/refs +GET /repos/:owner/:repo/git/tags/:sha +POST /repos/:owner/:repo/git/tags +GET /repos/:owner/:repo/git/trees/:sha +POST /repos/:owner/:repo/git/trees +GET /issues +GET /user/issues +GET /orgs/:org/issues +GET /repos/:owner/:repo/issues +GET /repos/:owner/:repo/issues/:number +POST /repos/:owner/:repo/issues +GET /repos/:owner/:repo/assignees +GET /repos/:owner/:repo/assignees/:assignee +GET /repos/:owner/:repo/issues/:number/comments +POST /repos/:owner/:repo/issues/:number/comments +GET /repos/:owner/:repo/issues/:number/events +GET /repos/:owner/:repo/labels +GET /repos/:owner/:repo/labels/:name +POST /repos/:owner/:repo/labels +DELETE /repos/:owner/:repo/labels/:name +GET /repos/:owner/:repo/issues/:number/labels +POST /repos/:owner/:repo/issues/:number/labels +DELETE /repos/:owner/:repo/issues/:number/labels/:name +PUT /repos/:owner/:repo/issues/:number/labels +DELETE /repos/:owner/:repo/issues/:number/labels +GET /repos/:owner/:repo/milestones/:number/labels +GET /repos/:owner/:repo/milestones +GET /repos/:owner/:repo/milestones/:number +POST /repos/:owner/:repo/milestones +DELETE /repos/:owner/:repo/milestones/:number +GET /emojis +GET /gitignore/templates +GET /gitignore/templates/:name +POST /markdown +POST /markdown/raw +GET /meta +GET /rate_limit +GET /users/:user/orgs +GET /user/orgs +GET /orgs/:org +GET /orgs/:org/members +GET /orgs/:org/members/:user +DELETE /orgs/:org/members/:user +GET /orgs/:org/public_members +GET /orgs/:org/public_members/:user +PUT /orgs/:org/public_members/:user +DELETE /orgs/:org/public_members/:user +GET /orgs/:org/teams +GET /teams/:id +POST /orgs/:org/teams +DELETE /teams/:id +GET /teams/:id/members +GET /teams/:id/members/:user +PUT /teams/:id/members/:user +DELETE /teams/:id/members/:user +GET /teams/:id/repos +GET /teams/:id/repos/:owner/:repo +PUT /teams/:id/repos/:owner/:repo +DELETE /teams/:id/repos/:owner/:repo +GET /user/teams +GET /repos/:owner/:repo/pulls +GET /repos/:owner/:repo/pulls/:number +POST /repos/:owner/:repo/pulls +GET /repos/:owner/:repo/pulls/:number/commits +GET /repos/:owner/:repo/pulls/:number/files +GET /repos/:owner/:repo/pulls/:number/merge +PUT /repos/:owner/:repo/pulls/:number/merge +GET /repos/:owner/:repo/pulls/:number/comments +PUT /repos/:owner/:repo/pulls/:number/comments +GET /user/repos +GET /users/:user/repos +GET /orgs/:org/repos +GET /repositories +POST /user/repos +POST /orgs/:org/repos +GET /repos/:owner/:repo +GET /repos/:owner/:repo/contributors +GET /repos/:owner/:repo/languages +GET /repos/:owner/:repo/teams +GET /repos/:owner/:repo/tags +GET /repos/:owner/:repo/branches +GET /repos/:owner/:repo/branches/:branch +DELETE /repos/:owner/:repo +GET /repos/:owner/:repo/collaborators +GET /repos/:owner/:repo/collaborators/:user +PUT /repos/:owner/:repo/collaborators/:user +DELETE /repos/:owner/:repo/collaborators/:user +GET /repos/:owner/:repo/comments +GET /repos/:owner/:repo/commits/:sha/comments +POST /repos/:owner/:repo/commits/:sha/comments +GET /repos/:owner/:repo/comments/:id +DELETE /repos/:owner/:repo/comments/:id +GET /repos/:owner/:repo/commits +GET /repos/:owner/:repo/commits/:sha +GET /repos/:owner/:repo/readme +GET /repos/:owner/:repo/keys +GET /repos/:owner/:repo/keys/:id +POST /repos/:owner/:repo/keys +DELETE /repos/:owner/:repo/keys/:id +GET /repos/:owner/:repo/downloads +GET /repos/:owner/:repo/downloads/:id +DELETE /repos/:owner/:repo/downloads/:id +GET /repos/:owner/:repo/forks +POST /repos/:owner/:repo/forks +GET /repos/:owner/:repo/hooks +GET /repos/:owner/:repo/hooks/:id +POST /repos/:owner/:repo/hooks +POST /repos/:owner/:repo/hooks/:id/tests +DELETE /repos/:owner/:repo/hooks/:id +POST /repos/:owner/:repo/merges +GET /repos/:owner/:repo/releases +GET /repos/:owner/:repo/releases/:id +POST /repos/:owner/:repo/releases +DELETE /repos/:owner/:repo/releases/:id +GET /repos/:owner/:repo/releases/:id/assets +GET /repos/:owner/:repo/stats/contributors +GET /repos/:owner/:repo/stats/commit_activity +GET /repos/:owner/:repo/stats/code_frequency +GET /repos/:owner/:repo/stats/participation +GET /repos/:owner/:repo/stats/punch_card +GET /repos/:owner/:repo/statuses/:ref +POST /repos/:owner/:repo/statuses/:ref +GET /search/repositories +GET /search/code +GET /search/issues +GET /search/users +GET /legacy/issues/search/:owner/:repository/:state/:keyword +GET /legacy/repos/search/:keyword +GET /legacy/user/search/:keyword +GET /legacy/user/email/:email +GET /users/:user +GET /user +GET /users +GET /user/emails +POST /user/emails +DELETE /user/emails +GET /users/:user/followers +GET /user/followers +GET /users/:user/following +GET /user/following +GET /user/following/:user +GET /users/:user/following/:target_user +PUT /user/following/:user +DELETE /user/following/:user +GET /users/:user/keys +GET /user/keys +GET /user/keys/:id +POST /user/keys +DELETE /user/keys/:id \ No newline at end of file