Compare commits

..

10 commits

Author SHA1 Message Date
e35d5715d1
Updated dependencies
All checks were successful
/ test (push) Successful in 15s
2025-06-01 18:22:31 +02:00
ebcb5fad18
Fixed incorrect path traversal 2025-03-01 14:27:26 +01:00
7ba55e445e
Updated module path 2025-02-25 16:38:39 +01:00
f11c90da34
Improved performance 2024-03-15 09:52:34 +01:00
e348332fa9
Improved performance 2024-03-15 00:07:44 +01:00
b04aadea09
Improved performance 2024-03-14 23:26:59 +01:00
a7d64037a7
Added trailing slash for static routes 2024-03-13 13:04:03 +01:00
0323126f2c
Improved test coverage 2023-09-06 16:52:22 +02:00
763686679f
Improved test coverage 2023-09-06 11:44:04 +02:00
7df818ffff
Reduced memory usage 2023-09-02 09:19:11 +02:00
13 changed files with 436 additions and 294 deletions

View file

@ -1,6 +1,6 @@
package router
// Parameter is a URL parameter.
// Parameter represents a URL parameter.
type Parameter struct {
Key string
Value string

View file

@ -11,7 +11,7 @@ HTTP router based on radix trees.
## Installation
```shell
go get git.akyoto.dev/go/router
go get git.urbach.dev/go/router
```
## Usage
@ -45,22 +45,31 @@ data := router.LookupNoAlloc("GET", "/users/42", func(key string, value string)
```
PASS: TestStatic
PASS: TestParameter
PASS: TestMixed
PASS: TestWildcard
PASS: TestMap
PASS: TestMethods
PASS: TestGitHub
coverage: 76.9% of statements
PASS: TestTrailingSlash
PASS: TestTrailingSlashOverwrite
PASS: TestOverwrite
PASS: TestInvalidMethod
coverage: 100.0% of statements
```
## Benchmarks
```
BenchmarkLookup-12 6965749 171.2 ns/op 96 B/op 2 allocs/op
BenchmarkLookupNoAlloc-12 24243546 48.5 ns/op 0 B/op 0 allocs/op
BenchmarkBlog/Len1-Param0-12 211814850 5.646 ns/op 0 B/op 0 allocs/op
BenchmarkBlog/Len1-Param1-12 132838722 8.978 ns/op 0 B/op 0 allocs/op
BenchmarkGitHub/Len7-Param0-12 84768382 14.14 ns/op 0 B/op 0 allocs/op
BenchmarkGitHub/Len7-Param1-12 55290044 20.74 ns/op 0 B/op 0 allocs/op
BenchmarkGitHub/Len7-Param2-12 26057244 46.08 ns/op 0 B/op 0 allocs/op
```
## License
Please see the [license documentation](https://akyoto.dev/license).
Please see the [license documentation](https://urbach.dev/license).
## Copyright

View file

@ -1,7 +1,5 @@
package router
import "os"
// Router is a high-performance router.
type Router[T any] struct {
get Tree[T]
@ -46,23 +44,17 @@ func (router *Router[T]) LookupNoAlloc(method string, path string, addParameter
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)
}
// Print shows a pretty print of all the routes.
func (router *Router[T]) Print(method string) {
tree := router.selectTree(method)
tree.root.PrettyPrint(os.Stdout)
// Map traverses all trees and calls the given function on every node.
func (router *Router[T]) Map(transform func(T) T) {
router.get.Map(transform)
router.post.Map(transform)
router.delete.Map(transform)
router.put.Map(transform)
router.patch.Map(transform)
router.head.Map(transform)
router.connect.Map(transform)
router.trace.Map(transform)
router.options.Map(transform)
}
// selectTree returns the tree by the given HTTP method.

View file

@ -1,82 +1,195 @@
package router_test
import (
"strings"
"testing"
"git.akyoto.dev/go/assert"
"git.akyoto.dev/go/router"
"git.urbach.dev/go/assert"
"git.urbach.dev/go/router"
"git.urbach.dev/go/router/testdata"
)
func TestStatic(t *testing.T) {
router := router.New[string]()
router.Add("GET", "/hello", "Hello")
router.Add("GET", "/world", "World")
func TestHello(t *testing.T) {
r := router.New[string]()
r.Add("GET", "/blog", "Blog")
r.Add("GET", "/blog/post", "Blog post")
data, params := router.Lookup("GET", "/hello")
data, params := r.Lookup("GET", "/blog")
assert.Equal(t, len(params), 0)
assert.Equal(t, data, "Blog")
data, params = r.Lookup("GET", "/blog/post")
assert.Equal(t, len(params), 0)
assert.Equal(t, data, "Blog post")
}
func TestStatic(t *testing.T) {
r := router.New[string]()
r.Add("GET", "/hello", "Hello")
r.Add("GET", "/world", "World")
data, params := r.Lookup("GET", "/hello")
assert.Equal(t, len(params), 0)
assert.Equal(t, data, "Hello")
data, params = router.Lookup("GET", "/world")
data, params = r.Lookup("GET", "/world")
assert.Equal(t, len(params), 0)
assert.Equal(t, data, "World")
data, params = router.Lookup("GET", "/404")
assert.Equal(t, len(params), 0)
assert.Equal(t, data, "")
notFound := []string{
"",
"?",
"/404",
"/hell",
"/hall",
"/helloo",
}
for _, path := range notFound {
data, params = r.Lookup("GET", path)
assert.Equal(t, len(params), 0)
assert.Equal(t, data, "")
}
}
func TestParameter(t *testing.T) {
router := router.New[string]()
router.Add("GET", "/blog/:slug", "Blog post")
router.Add("GET", "/blog/:slug/comments/:id", "Comment")
r := router.New[string]()
r.Add("GET", "/blog/:post", "Blog post")
r.Add("GET", "/blog/:post/comments/:id", "Comment")
data, params := router.Lookup("GET", "/blog/hello-world")
data, params := r.Lookup("GET", "/blog/hello-world")
assert.Equal(t, len(params), 1)
assert.Equal(t, params[0].Key, "slug")
assert.Equal(t, params[0].Key, "post")
assert.Equal(t, params[0].Value, "hello-world")
assert.Equal(t, data, "Blog post")
data, params = router.Lookup("GET", "/blog/hello-world/comments/123")
data, params = r.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].Key, "post")
assert.Equal(t, params[0].Value, "hello-world")
assert.Equal(t, params[1].Key, "id")
assert.Equal(t, params[1].Value, "123")
assert.Equal(t, data, "Comment")
}
func TestWildcard(t *testing.T) {
router := router.New[string]()
router.Add("GET", "/", "Front page")
router.Add("GET", "/:slug", "Blog post")
router.Add("GET", "/users/:id", "Parameter")
router.Add("GET", "/images/*path", "Wildcard")
func TestMixed(t *testing.T) {
r := router.New[string]()
r.Add("GET", "/", "Frontpage")
r.Add("GET", "/blog", "Blog")
r.Add("GET", "/:post", "Post")
r.Add("GET", "/sitemap.txt", "Sitemap")
data, params := router.Lookup("GET", "/")
data, params := r.Lookup("GET", "/")
assert.Equal(t, len(params), 0)
assert.Equal(t, data, "Front page")
assert.Equal(t, data, "Frontpage")
data, params = router.Lookup("GET", "/blog-post")
data, params = r.Lookup("GET", "/blog")
assert.Equal(t, len(params), 0)
assert.Equal(t, data, "Blog")
data, params = r.Lookup("GET", "/software")
assert.Equal(t, len(params), 1)
assert.Equal(t, params[0].Key, "slug")
assert.Equal(t, params[0].Key, "post")
assert.Equal(t, params[0].Value, "software")
assert.Equal(t, data, "Post")
data, params = r.Lookup("GET", "/sitemap.txt")
assert.Equal(t, len(params), 0)
assert.Equal(t, data, "Sitemap")
}
func TestWildcard(t *testing.T) {
r := router.New[string]()
r.Add("GET", "/", "Frontpage")
r.Add("GET", "/users/:id", "Parameter")
r.Add("GET", "/images/static", "Static")
r.Add("GET", "/images/*path", "Wildcard")
r.Add("GET", "/:post", "Blog post")
r.Add("GET", "/*any", "Wildcard")
r.Add("GET", "*root", "Root wildcard")
data, params := r.Lookup("GET", "/")
assert.Equal(t, len(params), 0)
assert.Equal(t, data, "Frontpage")
data, params = r.Lookup("GET", "/blog-post")
assert.Equal(t, len(params), 1)
assert.Equal(t, params[0].Key, "post")
assert.Equal(t, params[0].Value, "blog-post")
assert.Equal(t, data, "Blog post")
data, params = router.Lookup("GET", "/users/42")
data, params = r.Lookup("GET", "/users/42")
assert.Equal(t, len(params), 1)
assert.Equal(t, params[0].Key, "id")
assert.Equal(t, params[0].Value, "42")
assert.Equal(t, data, "Parameter")
data, params = router.Lookup("GET", "/images/favicon/256.png")
data, _ = r.Lookup("GET", "/users/42/test.txt")
assert.Equal(t, data, "Wildcard")
data, params = r.Lookup("GET", "/images/static")
assert.Equal(t, len(params), 0)
assert.Equal(t, data, "Static")
data, params = r.Lookup("GET", "/images/ste")
assert.Equal(t, len(params), 1)
assert.Equal(t, params[0].Key, "path")
assert.Equal(t, params[0].Value, "ste")
assert.Equal(t, data, "Wildcard")
data, params = r.Lookup("GET", "/images/sta")
assert.Equal(t, len(params), 1)
assert.Equal(t, params[0].Key, "path")
assert.Equal(t, params[0].Value, "sta")
assert.Equal(t, data, "Wildcard")
data, params = r.Lookup("GET", "/images/favicon/256.png")
assert.Equal(t, len(params), 1)
assert.Equal(t, params[0].Key, "path")
assert.Equal(t, params[0].Value, "favicon/256.png")
assert.Equal(t, data, "Wildcard")
data, params = r.Lookup("GET", "not-a-path")
assert.Equal(t, len(params), 1)
assert.Equal(t, params[0].Key, "root")
assert.Equal(t, params[0].Value, "not-a-path")
assert.Equal(t, data, "Root wildcard")
}
func TestMap(t *testing.T) {
r := router.New[string]()
r.Add("GET", "/hello", "Hello")
r.Add("GET", "/world", "World")
r.Add("GET", "/user/:user", "User")
r.Add("GET", "/*path", "Path")
r.Add("GET", "*root", "Root")
r.Map(func(data string) string {
return strings.Repeat(data, 2)
})
data, params := r.Lookup("GET", "/hello")
assert.Equal(t, len(params), 0)
assert.Equal(t, data, "HelloHello")
data, params = r.Lookup("GET", "/world")
assert.Equal(t, len(params), 0)
assert.Equal(t, data, "WorldWorld")
data, params = r.Lookup("GET", "/user/123")
assert.Equal(t, len(params), 1)
assert.Equal(t, data, "UserUser")
data, params = r.Lookup("GET", "/test.txt")
assert.Equal(t, len(params), 1)
assert.Equal(t, data, "PathPath")
data, params = r.Lookup("GET", "test.txt")
assert.Equal(t, len(params), 1)
assert.Equal(t, data, "RootRoot")
}
func TestMethods(t *testing.T) {
router := router.New[string]()
methods := []string{
"GET",
"POST",
@ -89,26 +202,107 @@ func TestMethods(t *testing.T) {
"OPTIONS",
}
r := router.New[string]()
for _, method := range methods {
router.Add(method, "/", method)
r.Add(method, "/", method)
}
for _, method := range methods {
data, _ := router.Lookup(method, "/")
data, _ := r.Lookup(method, "/")
assert.Equal(t, data, method)
}
}
func TestGitHub(t *testing.T) {
router := router.New[string]()
routes := loadRoutes("testdata/github.txt")
routes := testdata.Routes("testdata/github.txt")
r := router.New[string]()
for _, route := range routes {
router.Add(route.method, route.path, "octocat")
r.Add(route.Method, route.Path, "octocat")
}
for _, route := range routes {
data, _ := router.Lookup(route.method, route.path)
data, _ := r.Lookup(route.Method, route.Path)
assert.Equal(t, data, "octocat")
data = r.LookupNoAlloc(route.Method, route.Path, func(string, string) {})
assert.Equal(t, data, "octocat")
}
}
func TestTrailingSlash(t *testing.T) {
r := router.New[string]()
r.Add("GET", "/hello", "Hello 1")
data, params := r.Lookup("GET", "/hello")
assert.Equal(t, len(params), 0)
assert.Equal(t, data, "Hello 1")
data, params = r.Lookup("GET", "/hello/")
assert.Equal(t, len(params), 0)
assert.Equal(t, data, "Hello 1")
}
func TestTrailingSlashOverwrite(t *testing.T) {
r := router.New[string]()
r.Add("GET", "/hello", "route 1")
r.Add("GET", "/hello/", "route 2")
r.Add("GET", "/:param", "route 3")
r.Add("GET", "/:param/", "route 4")
r.Add("GET", "/*any", "route 5")
data, params := r.Lookup("GET", "/hello")
assert.Equal(t, len(params), 0)
assert.Equal(t, data, "route 1")
data, params = r.Lookup("GET", "/hello/")
assert.Equal(t, len(params), 0)
assert.Equal(t, data, "route 2")
data, params = r.Lookup("GET", "/param")
assert.Equal(t, len(params), 1)
assert.Equal(t, data, "route 3")
data, params = r.Lookup("GET", "/param/")
assert.Equal(t, len(params), 1)
assert.Equal(t, data, "route 4")
data, _ = r.Lookup("GET", "/wild/card/")
assert.Equal(t, data, "route 5")
}
func TestOverwrite(t *testing.T) {
r := router.New[string]()
r.Add("GET", "/", "1")
r.Add("GET", "/", "2")
r.Add("GET", "/", "3")
r.Add("GET", "/", "4")
r.Add("GET", "/", "5")
data, params := r.Lookup("GET", "/")
assert.Equal(t, len(params), 0)
assert.Equal(t, data, "5")
}
func TestInvalidMethod(t *testing.T) {
defer func() {
if recover() == nil {
t.FailNow()
}
}()
r := router.New[string]()
r.Add("?", "/hello", "Hello")
}
func TestMemoryUsage(t *testing.T) {
escape := func(a any) {}
result := testing.Benchmark(func(b *testing.B) {
r := router.New[string]()
escape(r)
})
t.Logf("%d bytes", result.MemBytes)
}

168
Tree.go
View file

@ -1,38 +1,12 @@
package router
import (
"strings"
)
// controlFlow tells the main loop what it should do next.
type controlFlow int
// controlFlow values.
const (
controlStop controlFlow = 0
controlBegin controlFlow = 1
controlNext controlFlow = 2
)
// Tree represents a radix tree.
type Tree[T any] struct {
static map[string]T
root treeNode[T]
canBeStatic [2048]bool
root treeNode[T]
}
// Add adds a new element to the tree.
func (tree *Tree[T]) Add(path string, data T) {
if !strings.Contains(path, ":") && !strings.Contains(path, "*") {
if tree.static == nil {
tree.static = map[string]T{}
}
tree.static[path] = data
tree.canBeStatic[len(path)] = true
return
}
// Search tree for equal parts until we can no longer proceed
i := 0
offset := 0
@ -52,17 +26,8 @@ func (tree *Tree[T]) Add(path string, data T) {
// When we hit a separator, we'll search for a fitting child.
if path[i] == separator {
var control controlFlow
node, offset, control = node.end(path, data, i, offset)
switch control {
case controlStop:
return
case controlBegin:
goto begin
case controlNext:
goto next
}
node, offset, _ = node.end(path, data, i, offset)
goto next
}
default:
@ -86,15 +51,15 @@ func (tree *Tree[T]) Add(path string, data T) {
// node: /|
// path: /|blog
if i-offset == len(node.prefix) {
var control controlFlow
var control flow
node, offset, control = node.end(path, data, i, offset)
switch control {
case controlStop:
case flowStop:
return
case controlBegin:
case flowBegin:
goto begin
case controlNext:
case flowNext:
goto next
}
}
@ -126,48 +91,34 @@ func (tree *Tree[T]) Lookup(path string) (T, []Parameter) {
// LookupNoAlloc finds the data for the given path without using any memory allocations.
func (tree *Tree[T]) LookupNoAlloc(path string, addParameter func(key string, value string)) T {
if tree.canBeStatic[len(path)] {
handler, found := tree.static[path]
if found {
return handler
}
}
var (
i uint
offset uint
lastWildcardOffset uint
lastWildcard *treeNode[T]
empty T
node = &tree.root
i uint
parameterPath string
wildcardPath string
parameter *treeNode[T]
wildcard *treeNode[T]
node = &tree.root
)
// Skip the first loop iteration if the starting characters are equal
if len(path) > 0 && len(node.prefix) > 0 && path[0] == node.prefix[0] {
i = 1
}
begin:
// Search tree for equal parts until we can no longer proceed
for {
// We reached the end.
if i == uint(len(path)) {
// node: /blog|
// path: /blog|
if i-offset == uint(len(node.prefix)) {
return node.data
}
// node: /blog|feed
// path: /blog|
return empty
}
for i < uint(len(path)) {
// The node we just checked is entirely included in our path.
// node: /|
// path: /|blog
if i-offset == uint(len(node.prefix)) {
if i == uint(len(node.prefix)) {
if node.wildcard != nil {
lastWildcard = node.wildcard
lastWildcardOffset = i
wildcard = node.wildcard
wildcardPath = path[i:]
}
parameter = node.parameter
parameterPath = path[i:]
char := path[i]
if char >= node.startIndex && char < node.endIndex {
@ -175,8 +126,8 @@ begin:
if index != 0 {
node = node.children[index]
offset = i
i++
path = path[i:]
i = 1
continue
}
}
@ -185,64 +136,69 @@ begin:
// path: /|blog
if node.parameter != nil {
node = node.parameter
offset = i
i++
for {
// We reached the end.
if i == uint(len(path)) {
addParameter(node.prefix, path[offset:i])
return node.data
}
path = path[i:]
i = 1
for i < uint(len(path)) {
// node: /:id|/posts
// path: /123|/posts
if path[i] == separator {
addParameter(node.prefix, path[offset:i])
addParameter(node.prefix, path[:i])
index := node.indices[separator-node.startIndex]
node = node.children[index]
offset = i
i++
path = path[i:]
i = 1
goto begin
}
i++
}
addParameter(node.prefix, path[:i])
return node.data
}
// node: /|*any
// path: /|image.png
if node.wildcard != nil {
addParameter(node.wildcard.prefix, path[i:])
return node.wildcard.data
}
return empty
goto notFound
}
// We got a conflict.
// node: /b|ag
// path: /b|riefcase
if path[i] != node.prefix[i-offset] {
if lastWildcard != nil {
addParameter(lastWildcard.prefix, path[lastWildcardOffset:])
return lastWildcard.data
}
return empty
if path[i] != node.prefix[i] {
goto notFound
}
i++
}
// node: /blog|
// path: /blog|
if i == uint(len(node.prefix)) {
return node.data
}
// node: /|*any
// path: /|image.png
notFound:
if parameter != nil {
addParameter(parameter.prefix, parameterPath)
return parameter.data
}
if wildcard != nil {
addParameter(wildcard.prefix, wildcardPath)
return wildcard.data
}
var empty T
return empty
}
// Bind binds all handlers to a new one provided by the callback.
func (tree *Tree[T]) Bind(transform func(T) T) {
// Map binds all handlers to a new one provided by the callback.
func (tree *Tree[T]) Map(transform func(T) T) {
tree.root.each(func(node *treeNode[T]) {
node.data = transform(node.data)
})
for key, value := range tree.static {
tree.static[key] = transform(value)
}
}

View file

@ -3,36 +3,57 @@ package router_test
import (
"testing"
"git.akyoto.dev/go/router"
"git.urbach.dev/go/router"
"git.urbach.dev/go/router/testdata"
)
func BenchmarkLookup(b *testing.B) {
router := router.New[string]()
routes := loadRoutes("testdata/github.txt")
func BenchmarkBlog(b *testing.B) {
routes := testdata.Routes("testdata/blog.txt")
r := router.New[string]()
for _, route := range routes {
router.Add(route.method, route.path, "")
r.Add(route.Method, route.Path, "")
}
b.ResetTimer()
b.Run("Len1-Param0", func(b *testing.B) {
for i := 0; i < b.N; i++ {
r.LookupNoAlloc("GET", "/", noop)
}
})
for i := 0; i < b.N; i++ {
router.Lookup("GET", "/repos/:owner/:repo/issues")
}
b.Run("Len1-Param1", func(b *testing.B) {
for i := 0; i < b.N; i++ {
r.LookupNoAlloc("GET", "/:id", noop)
}
})
}
func BenchmarkLookupNoAlloc(b *testing.B) {
router := router.New[string]()
routes := loadRoutes("testdata/github.txt")
addParameter := func(string, string) {}
func BenchmarkGitHub(b *testing.B) {
routes := testdata.Routes("testdata/github.txt")
r := router.New[string]()
for _, route := range routes {
router.Add(route.method, route.path, "")
r.Add(route.Method, route.Path, "")
}
b.ResetTimer()
b.Run("Len7-Param0", func(b *testing.B) {
for i := 0; i < b.N; i++ {
r.LookupNoAlloc("GET", "/issues", noop)
}
})
for i := 0; i < b.N; i++ {
router.LookupNoAlloc("GET", "/repos/:owner/:repo/issues", addParameter)
}
b.Run("Len7-Param1", func(b *testing.B) {
for i := 0; i < b.N; i++ {
r.LookupNoAlloc("GET", "/gists/:id", noop)
}
})
b.Run("Len7-Param2", func(b *testing.B) {
for i := 0; i < b.N; i++ {
r.LookupNoAlloc("GET", "/repos/:owner/:repo/issues", noop)
}
})
}
// noop serves as an empty addParameter function.
func noop(string, string) {}

11
flow.go Normal file
View file

@ -0,0 +1,11 @@
package router
// flow tells the main loop what it should do next.
type flow int
// Control flow values.
const (
flowStop flow = iota
flowBegin
flowNext
)

6
go.mod
View file

@ -1,5 +1,5 @@
module git.akyoto.dev/go/router
module git.urbach.dev/go/router
go 1.21
go 1.24
require git.akyoto.dev/go/assert v0.1.3
require git.urbach.dev/go/assert v0.0.0-20250225153414-7a6ed8be9b6e

4
go.sum
View file

@ -1,2 +1,2 @@
git.akyoto.dev/go/assert v0.1.3 h1:QwCUbmG4aZYsNk/OuRBz1zWVKmGlDUHhOnnDBfn8Qw8=
git.akyoto.dev/go/assert v0.1.3/go.mod h1:0GzMaM0eURuDwtGkJJkCsI7r2aUKr+5GmWNTFPgDocM=
git.urbach.dev/go/assert v0.0.0-20250225153414-7a6ed8be9b6e h1:lDTetvmGktDiMem+iBU3e5cGv52qUIbsqW8sV9u3gAQ=
git.urbach.dev/go/assert v0.0.0-20250225153414-7a6ed8be9b6e/go.mod h1:y9jGII9JFiF1HNIju0u87OyPCt82xKCtqnAFyEreCDo=

View file

@ -1,52 +0,0 @@
package router_test
import (
"bufio"
"os"
"strings"
)
// route represents a single line in the router test file.
type route struct {
method string
path string
}
// loadRoutes loads all routes from a text file.
func loadRoutes(fileName string) []route {
var routes []route
for line := range linesInFile(fileName) {
line = strings.TrimSpace(line)
parts := strings.Split(line, " ")
routes = append(routes, route{
method: parts[0],
path: parts[1],
})
}
return routes
}
// linesInFile is a utility function to easily read every line in a text file.
func linesInFile(fileName string) <-chan string {
lines := make(chan string)
go func() {
defer close(lines)
file, err := os.Open(fileName)
if err != nil {
return
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
lines <- scanner.Text()
}
}()
return lines
}

52
testdata/Route.go vendored Normal file
View file

@ -0,0 +1,52 @@
package testdata
import (
"bufio"
"os"
"strings"
)
// Route represents a single line in the router test file.
type Route struct {
Method string
Path string
}
// Routes loads all routes from a text file.
func Routes(fileName string) []Route {
var routes []Route
for line := range Lines(fileName) {
line = strings.TrimSpace(line)
parts := strings.Split(line, " ")
routes = append(routes, Route{
Method: parts[0],
Path: parts[1],
})
}
return routes
}
// Lines is a utility function to easily read every line in a text file.
func Lines(fileName string) <-chan string {
lines := make(chan string)
go func() {
defer close(lines)
file, err := os.Open(fileName)
if err != nil {
return
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
lines <- scanner.Text()
}
}()
return lines
}

4
testdata/blog.txt vendored Normal file
View file

@ -0,0 +1,4 @@
GET /
GET /:slug
GET /tags
GET /tag/:tag

View file

@ -1,8 +1,6 @@
package router
import (
"fmt"
"io"
"strings"
)
@ -159,6 +157,7 @@ func (node *treeNode[T]) append(path string, data T) {
if node.prefix == "" {
node.prefix = path
node.data = data
node.addTrailingSlash(data)
return
}
@ -231,7 +230,7 @@ func (node *treeNode[T]) append(path string, data T) {
// end is called when the node was fully parsed
// and needs to decide the next control flow.
// end is only called from `tree.Add`.
func (node *treeNode[T]) end(path string, data T, i int, offset int) (*treeNode[T], int, controlFlow) {
func (node *treeNode[T]) end(path string, data T, i int, offset int) (*treeNode[T], int, flow) {
char := path[i]
if char >= node.startIndex && char < node.endIndex {
@ -240,7 +239,7 @@ func (node *treeNode[T]) end(path string, data T, i int, offset int) (*treeNode[
if index != 0 {
node = node.children[index]
offset = i
return node, offset, controlNext
return node, offset, flowNext
}
}
@ -248,7 +247,7 @@ func (node *treeNode[T]) end(path string, data T, i int, offset int) (*treeNode[
// If no prefix is set, this is the starting node.
if node.prefix == "" {
node.append(path[i:], data)
return node, offset, controlStop
return node, offset, flowStop
}
// node: /user/|:id
@ -256,11 +255,11 @@ func (node *treeNode[T]) end(path string, data T, i int, offset int) (*treeNode[
if node.parameter != nil && path[i] == parameter {
node = node.parameter
offset = i
return node, offset, controlBegin
return node, offset, flowBegin
}
node.append(path[i:], data)
return node, offset, controlStop
return node, offset, flowStop
}
// each traverses the tree and calls the given function on every node.
@ -283,47 +282,3 @@ func (node *treeNode[T]) each(callback func(*treeNode[T])) {
node.wildcard.each(callback)
}
}
// PrettyPrint prints a human-readable form of the tree to the given writer.
func (node *treeNode[T]) PrettyPrint(writer io.Writer) {
node.prettyPrint(writer, -1)
}
// prettyPrint is the underlying pretty printer.
func (node *treeNode[T]) prettyPrint(writer io.Writer, level int) {
prefix := ""
if level >= 0 {
prefix = strings.Repeat(" ", level) + "|_ "
}
switch node.kind {
case ':':
prefix += ":"
case '*':
prefix += "*"
}
fmt.Fprintf(writer, "%s%s [%v]\n", prefix, node.prefix, node.data)
for _, child := range node.children {
if child == nil {
continue
}
child.prettyPrint(writer, level+1)
}
if node.parameter != nil {
node.parameter.prettyPrint(writer, level+1)
}
if node.wildcard != nil {
node.wildcard.prettyPrint(writer, level+1)
}
}
// String returns the node prefix.
func (node *treeNode[T]) String() string {
return node.prefix
}