From 2886f337c0ba3c24fb517b783011ef3235c1b4d9 Mon Sep 17 00:00:00 2001 From: Burak Koken Date: Wed, 4 Mar 2026 23:55:46 +0300 Subject: [PATCH 1/2] feat(http): implement radix tree matcher for efficient route matching --- http/context.go | 30 +- http/endpoint.go | 8 +- http/matcher.go | 647 +++++++++++++++++++++++++++++++++++++++++++ http/matcher_test.go | 224 +++++++++++++++ http/request.go | 59 +++- 5 files changed, 958 insertions(+), 10 deletions(-) create mode 100644 http/matcher.go create mode 100644 http/matcher_test.go diff --git a/http/context.go b/http/context.go index 39e97a2..c7067c3 100644 --- a/http/context.go +++ b/http/context.go @@ -30,13 +30,31 @@ type serverContext interface { // Context represents the context for an HTTP request and response. type Context struct { - req *ServerRequest - res *ServerResponse + endpoint *Endpoint + req *ServerRequest + res *ServerResponse values map[any]any err error } +func NewContext(req *http.Request, writer http.ResponseWriter) *Context { + ctx := &Context{ + values: make(map[any]any), + req: &ServerRequest{ + nativeReq: req, + }, + res: &ServerResponse{ + writer: writer, + }, + } + + ctx.req.ctx = ctx + ctx.res.ctx = ctx + + return ctx +} + // Deadline method returns the time when work done on behalf of // this context should be canceled. func (c *Context) Deadline() (deadline time.Time, ok bool) { @@ -79,6 +97,14 @@ func (c *Context) Response() *ServerResponse { return c.res } +func (c *Context) Endpoint() *Endpoint { + return c.endpoint +} + +func (c *Context) SetEndpoint(endpoint *Endpoint) { + c.endpoint = endpoint +} + // reset clears the context state and assigns a new HTTP request and response writer. func (c *Context) reset(w http.ResponseWriter, r *http.Request) { c.err = nil diff --git a/http/endpoint.go b/http/endpoint.go index 7b174e3..ec91706 100644 --- a/http/endpoint.go +++ b/http/endpoint.go @@ -21,14 +21,14 @@ import ( // Endpoint represents a fully described HTTP endpoint definition. type Endpoint struct { - // path is the route pattern associated with this endpoint - // (e.g. "/users/{id}", "/health", "/files/**"). - path string - // method is the HTTP method this endpoint responds to // (e.g. GET, POST, PUT). method Method + // path is the route pattern associated with this endpoint + // (e.g. "/users/{id}", "/health", "/files/**"). + path string + // delegate is the request handler invoked when this endpoint // matches an incoming request. delegate RequestDelegate diff --git a/http/matcher.go b/http/matcher.go new file mode 100644 index 0000000..8939505 --- /dev/null +++ b/http/matcher.go @@ -0,0 +1,647 @@ +// Copyright 2026 Codnect +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import "fmt" + +// nodeKind represents the type of node stored in the radix tree. +// Each kind corresponds to a different path matching strategy. +type nodeKind uint + +const ( + // nodeKindStatic represents a static path segment. + nodeKindStatic nodeKind = iota + + // nodeKindParam represents a named parameter segment (e.g. /users/{id}). + nodeKindParam + + // nodeKindWildcard represents a single-segment wildcard (*). + nodeKindWildcard + + // nodeKindDoubleWildcard represents a multi-segment wildcard (**). + nodeKindDoubleWildcard + + // nodeKindPattern represents a segment containing pattern characters (* or ?), + // such as "file-*.json". + nodeKindPattern + + // maxMethods defines the number of supported HTTP method slots per node. + maxMethods = 10 + + // maxParams defines the maximum number of path parameters per route. + maxParams = 16 +) + +// routeEntry stores metadata for a registered route pattern. +type routeEntry struct { + endpoint *Endpoint // handler endpoint + + pattern string // original route pattern + + // parameter metadata extracted from the pattern + paramNames [maxParams]string + paramCount int + + // information about ** wildcard usage + hasDoubleWildcard bool + doubleWildcardIndex int +} + +// radixNode represents a node in the radix tree used for routing. +type radixNode struct { + prefix string // compressed path prefix + kind nodeKind // node type + + // sorted first-byte index for static children + indices []byte + children []*radixNode + + // special child nodes for dynamic segments + paramChild *radixNode + doubleWildcardChild *radixNode + wildcardChild *radixNode + patternChildren []*radixNode + + // method-specific route entries + routes [maxMethods]*routeEntry +} + +// addChild inserts a static child node while keeping indices sorted. +// This allows fast lookup using the first byte of the segment. +func (n *radixNode) addChild(child *radixNode) { + + // first byte used for indexing + b := child.prefix[0] + + pos := 0 + for pos < len(n.indices) && n.indices[pos] < b { + pos++ + } + + // insert index while preserving order + n.indices = append(n.indices, 0) + copy(n.indices[pos+1:], n.indices[pos:]) + n.indices[pos] = b + + // insert child in same position + n.children = append(n.children, nil) + copy(n.children[pos+1:], n.children[pos:]) + n.children[pos] = child +} + +// findChild finds a static child node using the first byte of the segment. +func (n *radixNode) findChild(b byte) (*radixNode, bool) { + for i, c := range n.indices { + if c == b { + return n.children[i], true + } + if c > b { + break + } + } + return nil, false +} + +// radixTreeMatcher is a router implementation based on a radix tree. +type radixTreeMatcher struct { + root *radixNode +} + +// newRadixTreeMatcher creates a new empty radix-tree router. +func newRadixTreeMatcher() *radixTreeMatcher { + return &radixTreeMatcher{root: &radixNode{}} +} + +// insertStatic inserts a static path fragment into the radix tree. +// The function performs prefix compression and splits nodes when necessary. +func (t *radixTreeMatcher) insertStatic(n *radixNode, path string) *radixNode { + + for { + if len(path) == 0 { + return n + } + + child, exists := n.findChild(path[0]) + if !exists { + newNode := &radixNode{kind: nodeKindStatic, prefix: path} + n.addChild(newNode) + return newNode + } + + // determine longest common prefix + commonLen := 0 + minLen := len(child.prefix) + if len(path) < minLen { + minLen = len(path) + } + + for commonLen < minLen && child.prefix[commonLen] == path[commonLen] { + commonLen++ + } + + // split node if prefixes diverge + if commonLen < len(child.prefix) { + + splitNode := &radixNode{ + kind: nodeKindStatic, + prefix: child.prefix[:commonLen], + } + + child.prefix = child.prefix[commonLen:] + splitNode.addChild(child) + + // replace original child reference + for i, c := range n.indices { + if c == splitNode.prefix[0] { + n.children[i] = splitNode + break + } + } + + if commonLen == len(path) { + return splitNode + } + + path = path[commonLen:] + n = splitNode + continue + } + + path = path[commonLen:] + n = child + } +} + +// addEndpoint registers a new endpoint into the radix tree. +func (t *radixTreeMatcher) addEndpoint(endpoint *Endpoint) error { + + // normalize path + pattern := endpoint.path + if len(pattern) == 0 || pattern[0] != '/' { + pattern = "/" + pattern + } + if len(pattern) > 1 && pattern[len(pattern)-1] == '/' { + pattern = pattern[:len(pattern)-1] + } + + // build metadata for route + entry, err := buildRouteEntry(pattern, endpoint) + if err != nil { + return err + } + + n := t.root + p := pattern[1:] + + pos := 0 + staticStart := 0 + + // parse path segment-by-segment + for pos <= len(p) { + + segStart := pos + segEnd := segStart + for segEnd < len(p) && p[segEnd] != '/' { + segEnd++ + } + + seg := p[segStart:segEnd] + + // detect segment type + isDoubleWildcard := seg == "**" + isWildcard := seg == "*" + isParam := isParamSeg(seg) + isPattern := !isWildcard && !isDoubleWildcard && hasPatternChars(seg) + + // handle dynamic segment types + if isDoubleWildcard || isWildcard || isParam || isPattern { + + if staticStart < segStart { + n = t.insertStatic(n, p[staticStart:segStart]) + } + + switch { + + case isDoubleWildcard: + if n.doubleWildcardChild == nil { + n.doubleWildcardChild = &radixNode{ + kind: nodeKindDoubleWildcard, + prefix: "**", + } + } + n = n.doubleWildcardChild + + case isWildcard: + if n.wildcardChild == nil { + n.wildcardChild = &radixNode{ + kind: nodeKindWildcard, + prefix: "*", + } + } + n = n.wildcardChild + + case isParam: + if n.paramChild == nil { + n.paramChild = &radixNode{ + kind: nodeKindParam, + prefix: "{}", + } + } + n = n.paramChild + + default: + var child *radixNode + for _, pc := range n.patternChildren { + if pc.prefix == seg { + child = pc + break + } + } + if child == nil { + child = &radixNode{ + kind: nodeKindPattern, + prefix: seg, + } + n.patternChildren = append(n.patternChildren, child) + } + n = child + } + + pos = segEnd + if pos < len(p) && p[pos] == '/' { + pos++ + } + + staticStart = pos + continue + } + + if segEnd >= len(p) { + break + } + + pos = segEnd + 1 + } + + // insert remaining static tail + if staticStart < len(p) { + n = t.insertStatic(n, p[staticStart:]) + } + + mi := methodIndex(endpoint.method) + + if n.routes[mi] != nil { + return fmt.Errorf( + "route already exists for path %s and method %s", + endpoint.path, + endpoint.method, + ) + } + + n.routes[mi] = entry + + return nil +} + +// match recursively matches the request path against the radix tree. +func (t *radixTreeMatcher) match(n *radixNode, path string, ctx *Context, mi int) *radixNode { + request := ctx.Request() + + for { + + // if path fully consumed, check for endpoint + if len(path) == 0 { + if n.routes[mi] != nil { + return n + } + + if n.doubleWildcardChild != nil && + n.doubleWildcardChild.routes[mi] != nil { + return n.doubleWildcardChild + } + + return nil + } + + // 1) Static segment match + if child, ok := n.findChild(path[0]); ok { + + prefixLen := len(child.prefix) + + if len(path) >= prefixLen { + match := true + + for i := 0; i < prefixLen; i++ { + if path[i] != child.prefix[i] { + match = false + break + } + } + + if match { + if result := t.match(child, path[prefixLen:], ctx, mi); result != nil { + return result + } + } + } + } + + // split next segment + segEnd := 0 + for segEnd < len(path) && path[segEnd] != '/' { + segEnd++ + } + + segment := path[:segEnd] + + var rest string + if segEnd < len(path) && path[segEnd] == '/' { + rest = path[segEnd+1:] + } + + // 2) Pattern match (e.g. file-*.json) + if len(n.patternChildren) > 0 && len(segment) > 0 { + for _, pc := range n.patternChildren { + if matchPattern(pc.prefix, segment) { + if result := t.match(pc, rest, ctx, mi); result != nil { + return result + } + } + } + } + + // 3) Parameter segment + if n.paramChild != nil && len(segment) > 0 { + + savedLen := request.pathValues.len() + + if request.pathValues.pushRaw(segment) { + if result := t.match(n.paramChild, rest, ctx, mi); result != nil { + return result + } + } + + request.pathValues.setLen(savedLen) + } + + // 4) Single wildcard (*) + if n.wildcardChild != nil && len(segment) > 0 { + if result := t.match(n.wildcardChild, rest, ctx, mi); result != nil { + return result + } + } + + // 5) Double wildcard (**) + if n.doubleWildcardChild != nil { + + // zero segment match + if result := t.match(n.doubleWildcardChild, path, ctx, mi); result != nil { + return result + } + + // consume segments progressively + p := path + + for len(p) > 0 { + + nextSeg := 0 + for nextSeg < len(p) && p[nextSeg] != '/' { + nextSeg++ + } + + if nextSeg < len(p) { + p = p[nextSeg+1:] + } else { + p = "" + } + + if result := t.match(n.doubleWildcardChild, p, ctx, mi); result != nil { + return result + } + } + } + + return nil + } +} + +// Match resolves the incoming request to a registered endpoint. +func (t *radixTreeMatcher) Match(ctx *Context) (*Endpoint, bool) { + + request := ctx.Request() + path := request.Path() + + // normalize path + if len(path) == 0 || path[0] != '/' { + return nil, false + } + + if len(path) > 1 && path[len(path)-1] == '/' { + path = path[:len(path)-1] + } + + mi := methodIndex(request.Method()) + + request.pathValues.reset() + + node := t.match(t.root, path[1:], ctx, mi) + + if node == nil { + return nil, false + } + + route := node.routes[mi] + + if route == nil { + return nil, false + } + + // assign parameter names + limit := route.paramCount + + if request.pathValues.count < limit { + limit = request.pathValues.count + } + + for i := 0; i < limit; i++ { + request.pathValues.setName(i, route.paramNames[i]) + } + + return route.endpoint, true +} + +// buildRouteEntry extracts metadata (parameters and wildcards) +// from a route pattern. +func buildRouteEntry(path string, endpoint *Endpoint) (*routeEntry, error) { + entry := &routeEntry{ + pattern: path, + endpoint: endpoint, + doubleWildcardIndex: -1, + } + + i := 0 + + for i < len(path) { + + switch { + + // parameter segment {id} + case path[i] == '{': + end := i + 1 + for end < len(path) && path[end] != '}' { + end++ + } + + if end >= len(path) { + return nil, fmt.Errorf("unclosed parameter in path: %s", path) + } + + name := path[i+1 : end] + + if len(name) == 0 { + return nil, fmt.Errorf("empty parameter name in path: %s", path) + } + + if entry.paramCount >= maxParams { + return nil, fmt.Errorf("too many parameters in path: %s", path) + } + + entry.paramNames[entry.paramCount] = name + entry.paramCount++ + + i = end + 1 + + // double wildcard ** + case i+1 < len(path) && path[i] == '*' && path[i+1] == '*': + entry.hasDoubleWildcard = true + entry.doubleWildcardIndex = entry.paramCount + + i += 2 + if i < len(path) && path[i] == '/' { + i++ + } + + // single wildcard * + case path[i] == '*': + i++ + + default: + i++ + } + } + + return entry, nil +} + +// matchPattern performs glob-style pattern matching +// supporting '*' and '?' characters. +func matchPattern(pat, s string) bool { + + pi, si := 0, 0 + starPi, starSi := -1, -1 + + for si < len(s) { + + if pi < len(pat) { + + switch pat[pi] { + + case '?': + pi++ + si++ + continue + + case '*': + starPi = pi + starSi = si + pi++ + continue + + default: + if pat[pi] == s[si] { + pi++ + si++ + continue + } + } + } + + if starPi != -1 { + pi = starPi + 1 + starSi++ + si = starSi + continue + } + + return false + } + + for pi < len(pat) && pat[pi] == '*' { + pi++ + } + + return pi == len(pat) +} + +// isParamSeg checks whether a segment is a path parameter. +func isParamSeg(seg string) bool { + return len(seg) >= 3 && seg[0] == '{' && seg[len(seg)-1] == '}' +} + +// hasPatternChars checks whether a segment contains pattern characters. +func hasPatternChars(seg string) bool { + for i := 0; i < len(seg); i++ { + if seg[i] == '*' || seg[i] == '?' { + return true + } + } + return false +} + +// methodIndex maps HTTP methods to a compact index used in route tables. +func methodIndex(m Method) int { + + if len(m) == 0 { + return 9 + } + + switch m[0] { + case 'G': + return 0 + case 'P': + if len(m) > 1 { + switch m[1] { + case 'O': + return 1 + case 'U': + return 2 + case 'A': + return 4 + } + } + case 'D': + return 3 + case 'H': + return 5 + case 'O': + return 6 + case 'C': + return 7 + case 'T': + return 8 + } + + return 9 +} diff --git a/http/matcher_test.go b/http/matcher_test.go new file mode 100644 index 0000000..90b4157 --- /dev/null +++ b/http/matcher_test.go @@ -0,0 +1,224 @@ +// Copyright 2026 Codnect +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "net/http" + "testing" +) + +func TestRadixTreeMatcher_Match(t *testing.T) { + t.Run("static routes", func(t *testing.T) { + tree := newRadixTreeMatcher() + tree.addEndpoint(&Endpoint{"/users", "GET", nil}) + tree.addEndpoint(&Endpoint{"/users/profile", "GET", nil}) + tree.addEndpoint(&Endpoint{"/users", "POST", nil}) + + testCases := []struct { + method Method + path string + match bool + }{ + {"GET", "/users", true}, + {"GET", "/users/profile", true}, + {"POST", "/users", true}, + {"DELETE", "/users", false}, + } + + for _, tc := range testCases { + req, _ := http.NewRequest(string(tc.method), tc.path, nil) + ctx := NewContext(req, nil) + ep, ok := tree.Match(ctx) + if ok != tc.match { + t.Errorf("%s %s: got match=%v, want %v", tc.method, tc.path, ok, tc.match) + } + if ok && ep.method != tc.method { + t.Errorf("%s %s: got method=%s, want %s", tc.method, tc.path, ep.method, tc.method) + } + } + }) + + t.Run("param routes", func(t *testing.T) { + tree := newRadixTreeMatcher() + tree.addEndpoint(&Endpoint{"GET", "/users/{id}", nil}) + tree.addEndpoint(&Endpoint{"GET", "/users/{id}/posts/{postId}", nil}) + + testCases := []struct { + method Method + path string + match bool + paramCount int + }{ + {"GET", "/users/42", true, 1}, + {"GET", "/users/42/posts/7", true, 2}, + {"GET", "/users", false, 0}, + } + + for _, tc := range testCases { + req, _ := http.NewRequest(string(tc.method), tc.path, nil) + ctx := NewContext(req, nil) + _, ok := tree.Match(ctx) + if ok != tc.match { + t.Errorf("%s %s: got match=%v, want %v", tc.method, tc.path, ok, tc.match) + } + if ok { + req := ctx.Request() + if req.pathValues.count != tc.paramCount { + t.Errorf("%s %s: got paramCount=%d, want %d", + tc.method, tc.path, req.pathValues.count, tc.paramCount) + } + } + } + }) + + t.Run("wildcard routes", func(t *testing.T) { + tree := newRadixTreeMatcher() + tree.addEndpoint(&Endpoint{"GET", "/files/*/download", nil}) + + testCases := []struct { + method Method + path string + match bool + }{ + {"GET", "/files/image.png/download", true}, + {"GET", "/files/doc.pdf/download", true}, + {"GET", "/files/download", false}, // * boş segment eşlemez + {"GET", "/files/a/b/download", false}, // * tek segment + } + + for _, testCase := range testCases { + req, _ := http.NewRequest(string(testCase.method), testCase.path, nil) + ctx := NewContext(req, nil) + _, ok := tree.Match(ctx) + if ok != testCase.match { + t.Errorf("%s %s: got match=%v, want %v", testCase.method, testCase.path, ok, testCase.match) + } + } + }) + + t.Run("double wildcard routes", func(t *testing.T) { + tree := newRadixTreeMatcher() + tree.addEndpoint(&Endpoint{"GET", "/static/**", nil}) + tree.addEndpoint(&Endpoint{"GET", "/api/**/health", nil}) + + tests := []struct { + method Method + path string + match bool + }{ + {"GET", "/static", true}, // ** zero-match + {"GET", "/static/css/main.css", true}, // ** çoklu segment + {"GET", "/static/js/app.js", true}, + {"GET", "/api/health", true}, // ** zero-match + {"GET", "/api/v1/health", true}, // ** tek segment + {"GET", "/api/v1/v2/health", true}, // ** çoklu segment + } + + for _, tc := range tests { + req, _ := http.NewRequest(string(tc.method), tc.path, nil) + ctx := NewContext(req, nil) + _, ok := tree.Match(ctx) + if ok != tc.match { + t.Errorf("%s %s: got match=%v, want %v", tc.method, tc.path, ok, tc.match) + } + } + }) + + t.Run("pattern routes", func(t *testing.T) { + tree := newRadixTreeMatcher() + tree.addEndpoint(&Endpoint{"GET", "/files/*.json", nil}) + tree.addEndpoint(&Endpoint{"GET", "/images/img?.png", nil}) + + tests := []struct { + method Method + path string + match bool + }{ + {"GET", "/files/data.json", true}, + {"GET", "/files/config.json", true}, + {"GET", "/files/data.xml", false}, + {"GET", "/images/img1.png", true}, + {"GET", "/images/img2.png", true}, + {"GET", "/images/img12.png", false}, // ? tek karakter + } + + for _, tc := range tests { + req, _ := http.NewRequest(string(tc.method), tc.path, nil) + ctx := NewContext(req, nil) + _, ok := tree.Match(ctx) + if ok != tc.match { + t.Errorf("%s %s: got match=%v, want %v", tc.method, tc.path, ok, tc.match) + } + } + }) + + t.Run("priority: static > pattern > param > wildcard > double wildcard", func(t *testing.T) { + tree := newRadixTreeMatcher() + tree.addEndpoint(&Endpoint{"GET", "/files/exact", nil}) + tree.addEndpoint(&Endpoint{"GET", "/files/*.json", nil}) + tree.addEndpoint(&Endpoint{"GET", "/files/{id}", nil}) + tree.addEndpoint(&Endpoint{"GET", "/files/*", nil}) + tree.addEndpoint(&Endpoint{"GET", "/files/**", nil}) + + tests := []struct { + path string + wantPattern string + }{ + {"/files/exact", "/files/exact"}, + {"/files/data.json", "/files/*.json"}, + {"/files/42", "/files/{id}"}, + {"/files/anything", "/files/{id}"}, // param önce gelir + {"/files/a/b/c", "/files/**"}, // ** çoklu segment + } + + for _, tc := range tests { + req, _ := http.NewRequest("GET", tc.path, nil) + ctx := NewContext(req, nil) + ep, ok := tree.Match(ctx) + if !ok { + t.Errorf("GET %s: no match, want %s", tc.path, tc.wantPattern) + continue + } + if ep.path != tc.wantPattern { + t.Errorf("GET %s: matched %s, want %s", tc.path, ep.path, tc.wantPattern) + } + } + }) + + t.Run("trailing slash normalization", func(t *testing.T) { + tree := newRadixTreeMatcher() + tree.addEndpoint(&Endpoint{"GET", "/users", nil}) + + req, _ := http.NewRequest("GET", "/users/", nil) + ctx := NewContext(req, nil) + _, ok := tree.Match(ctx) + if !ok { + t.Error("GET /users/ should match /users (trailing slash stripped)") + } + }) + + t.Run("duplicate route returns error", func(t *testing.T) { + tree := newRadixTreeMatcher() + err := tree.addEndpoint(&Endpoint{"GET", "/users", nil}) + if err != nil { + t.Fatalf("first add failed: %v", err) + } + err = tree.addEndpoint(&Endpoint{"GET", "/users", nil}) + if err == nil { + t.Error("duplicate route should return error") + } + }) + +} diff --git a/http/request.go b/http/request.go index a49724d..e914b75 100644 --- a/http/request.go +++ b/http/request.go @@ -23,6 +23,52 @@ import ( // RequestDelegate represents the next middleware or handler in the pipeline. type RequestDelegate func(ctx *Context) error +type pathValue struct { + name string + Value string +} + +type pathValues struct { + values [maxParams]pathValue + count int +} + +func (p *pathValues) get(name string) string { + for i := 0; i < p.count; i++ { + if p.values[i].name == name { + return p.values[i].Value + } + } + return "" +} + +func (p *pathValues) reset() { + p.count = 0 +} + +func (p *pathValues) len() int { + return p.count +} + +func (p *pathValues) setLen(n int) { + p.count = n +} + +func (p *pathValues) pushRaw(value string) bool { + if p.count >= maxParams { + return false + } + p.values[p.count] = pathValue{Value: value} + p.count++ + return true +} + +func (p *pathValues) setName(i int, name string) { + if i >= 0 && i < p.count { + p.values[i].name = name + } +} + // ServerRequest wraps an incoming HTTP request and provides convenience accessors // with small, per-request caches for cookies and query parameters. type ServerRequest struct { @@ -31,6 +77,8 @@ type ServerRequest struct { cookiesCache []*Cookie queryCache url.Values + + pathValues pathValues } // Context returns the Context associated with the ServerRequest. @@ -159,13 +207,16 @@ func (r *ServerRequest) Path() string { // PathValue returns the value of a path parameter captured by the route matcher. // If the parameter does not exist, it returns an empty string. func (r *ServerRequest) PathValue(name string) string { - // TODO: Implement path parameter extraction based on the routing mechanism. - return "" + if name == "" { + return "" + } + + return r.pathValues.get(name) } // Method returns the HTTP method (GET, POST, PUT, ...). -func (r *ServerRequest) Method() string { - return r.nativeReq.Method +func (r *ServerRequest) Method() Method { + return Method(r.nativeReq.Method) } // Body returns the request body as an io.ReadCloser. From 262df98fe62f69de2f5a783e00ef953a8f2fb22b Mon Sep 17 00:00:00 2001 From: Burak Koken Date: Fri, 6 Mar 2026 17:25:18 +0300 Subject: [PATCH 2/2] feat(http): implement radix tree matcher for efficient route matching --- http/matcher.go | 43 +++- http/matcher_test.go | 507 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 501 insertions(+), 49 deletions(-) diff --git a/http/matcher.go b/http/matcher.go index 8939505..7c32f37 100644 --- a/http/matcher.go +++ b/http/matcher.go @@ -332,6 +332,18 @@ func (t *radixTreeMatcher) match(n *radixNode, path string, ctx *Context, mi int return n.doubleWildcardChild } + // static child whose remaining prefix is just "/" may have a ** beneath it + for i, c := range n.indices { + child := n.children[i] + _ = c + if len(child.prefix) == 1 && child.prefix[0] == '/' { + if child.doubleWildcardChild != nil && + child.doubleWildcardChild.routes[mi] != nil { + return child.doubleWildcardChild + } + } + } + return nil } @@ -356,6 +368,27 @@ func (t *radixTreeMatcher) match(n *radixNode, path string, ctx *Context, mi int } } } + + // partial match: path is shorter than child prefix + // if the unmatched suffix is just "/" and child has **, try it + if len(path) < prefixLen && len(path) > 0 { + match := true + for i := 0; i < len(path); i++ { + if path[i] != child.prefix[i] { + match = false + break + } + } + if match { + remaining := child.prefix[len(path):] + if remaining == "/" { + if child.doubleWildcardChild != nil && + child.doubleWildcardChild.routes[mi] != nil { + return child.doubleWildcardChild + } + } + } + } } // split next segment @@ -439,7 +472,6 @@ func (t *radixTreeMatcher) match(n *radixNode, path string, ctx *Context, mi int // Match resolves the incoming request to a registered endpoint. func (t *radixTreeMatcher) Match(ctx *Context) (*Endpoint, bool) { - request := ctx.Request() path := request.Path() @@ -464,17 +496,9 @@ func (t *radixTreeMatcher) Match(ctx *Context) (*Endpoint, bool) { route := node.routes[mi] - if route == nil { - return nil, false - } - // assign parameter names limit := route.paramCount - if request.pathValues.count < limit { - limit = request.pathValues.count - } - for i := 0; i < limit; i++ { request.pathValues.setName(i, route.paramNames[i]) } @@ -612,7 +636,6 @@ func hasPatternChars(seg string) bool { // methodIndex maps HTTP methods to a compact index used in route tables. func methodIndex(m Method) int { - if len(m) == 0 { return 9 } diff --git a/http/matcher_test.go b/http/matcher_test.go index 90b4157..52da475 100644 --- a/http/matcher_test.go +++ b/http/matcher_test.go @@ -22,9 +22,14 @@ import ( func TestRadixTreeMatcher_Match(t *testing.T) { t.Run("static routes", func(t *testing.T) { tree := newRadixTreeMatcher() - tree.addEndpoint(&Endpoint{"/users", "GET", nil}) - tree.addEndpoint(&Endpoint{"/users/profile", "GET", nil}) - tree.addEndpoint(&Endpoint{"/users", "POST", nil}) + tree.addEndpoint(&Endpoint{"GET", "/users", nil}) + tree.addEndpoint(&Endpoint{"GET", "/users/profile", nil}) + tree.addEndpoint(&Endpoint{"POST", "/users", nil}) + tree.addEndpoint(&Endpoint{"GET", "/", nil}) + tree.addEndpoint(&Endpoint{"GET", "/a/b/c/d/e", nil}) + tree.addEndpoint(&Endpoint{"GET", "/abcdef", nil}) + tree.addEndpoint(&Endpoint{"GET", "/abc", nil}) + tree.addEndpoint(&Endpoint{"GET", "/abcxyz", nil}) testCases := []struct { method Method @@ -35,6 +40,17 @@ func TestRadixTreeMatcher_Match(t *testing.T) { {"GET", "/users/profile", true}, {"POST", "/users", true}, {"DELETE", "/users", false}, + {"GET", "/", true}, + {"GET", "/a/b/c/d/e", true}, + {"GET", "/a/b/c/d", false}, + {"GET", "/a/b/c/d/e/f", false}, + {"GET", "/notfound", false}, + {"GET", "", false}, + {"GET", "/abcdef", true}, + {"GET", "/abc", true}, + {"GET", "/abcxyz", true}, + {"GET", "/abcd", false}, + {"GET", "/ab", false}, } for _, tc := range testCases { @@ -50,20 +66,74 @@ func TestRadixTreeMatcher_Match(t *testing.T) { } }) + t.Run("static routes with shared prefixes", func(t *testing.T) { + tree := newRadixTreeMatcher() + tree.addEndpoint(&Endpoint{"GET", "/users", nil}) + tree.addEndpoint(&Endpoint{"GET", "/users/profile", nil}) + tree.addEndpoint(&Endpoint{"GET", "/users/password", nil}) + tree.addEndpoint(&Endpoint{"GET", "/us", nil}) + tree.addEndpoint(&Endpoint{"GET", "/api/v1/items", nil}) + tree.addEndpoint(&Endpoint{"GET", "/api/v2/items", nil}) + tree.addEndpoint(&Endpoint{"GET", "/api/v1/orders", nil}) + tree.addEndpoint(&Endpoint{"GET", "/abcdef", nil}) + tree.addEndpoint(&Endpoint{"GET", "/abc", nil}) + tree.addEndpoint(&Endpoint{"GET", "/abcxyz", nil}) + + testCases := []struct { + path string + match bool + }{ + {"/users", true}, + {"/users/profile", true}, + {"/users/password", true}, + {"/us", true}, + {"/u", false}, + {"/user", false}, + {"/users/p", false}, + {"/api/v1/items", true}, + {"/api/v2/items", true}, + {"/api/v1/orders", true}, + {"/api/v1", false}, + {"/api/v3/items", false}, + {"/abcdef", true}, + {"/abc", true}, + {"/abcxyz", true}, + {"/abcd", false}, + {"/ab", false}, + {"/abqz", false}, + {"/usez", false}, + } + + for _, tc := range testCases { + req, _ := http.NewRequest("GET", tc.path, nil) + ctx := NewContext(req, nil) + _, ok := tree.Match(ctx) + if ok != tc.match { + t.Errorf("GET %s: got match=%v, want %v", tc.path, ok, tc.match) + } + } + }) + t.Run("param routes", func(t *testing.T) { tree := newRadixTreeMatcher() tree.addEndpoint(&Endpoint{"GET", "/users/{id}", nil}) tree.addEndpoint(&Endpoint{"GET", "/users/{id}/posts/{postId}", nil}) + tree.addEndpoint(&Endpoint{"GET", "/{root}", nil}) testCases := []struct { - method Method - path string - match bool - paramCount int + method Method + path string + match bool + params map[string]string }{ - {"GET", "/users/42", true, 1}, - {"GET", "/users/42/posts/7", true, 2}, - {"GET", "/users", false, 0}, + {"GET", "/users/42", true, map[string]string{"id": "42"}}, + {"GET", "/users/42/posts/7", true, map[string]string{"id": "42", "postId": "7"}}, + {"GET", "/users/abc/posts/xyz", true, map[string]string{"id": "abc", "postId": "xyz"}}, + {"GET", "/users", true, map[string]string{"root": "users"}}, + {"GET", "/users/42/posts", false, nil}, + {"GET", "/anything", true, map[string]string{"root": "anything"}}, + {"GET", "/123", true, map[string]string{"root": "123"}}, + {"GET", "/a/b/c", false, nil}, } for _, tc := range testCases { @@ -74,18 +144,44 @@ func TestRadixTreeMatcher_Match(t *testing.T) { t.Errorf("%s %s: got match=%v, want %v", tc.method, tc.path, ok, tc.match) } if ok { - req := ctx.Request() - if req.pathValues.count != tc.paramCount { - t.Errorf("%s %s: got paramCount=%d, want %d", - tc.method, tc.path, req.pathValues.count, tc.paramCount) + r := ctx.Request() + + for k := range tc.params { + if r.PathValue(k) != tc.params[k] { + t.Errorf("%s %s: param %s: got %q, want %q", tc.method, tc.path, k, r.PathValue(k), tc.params[k]) + } } } } }) + t.Run("param value capture", func(t *testing.T) { + tree := newRadixTreeMatcher() + tree.addEndpoint(&Endpoint{"GET", "/users/{id}/posts/{postId}", nil}) + + req, _ := http.NewRequest("GET", "/users/42/posts/7", nil) + ctx := NewContext(req, nil) + _, ok := tree.Match(ctx) + if !ok { + t.Fatal("expected match") + } + + r := ctx.Request() + id := r.PathValue("id") + postId := r.PathValue("postId") + + if id != "42" { + t.Errorf("id: got %q, want %q", id, "42") + } + if postId != "7" { + t.Errorf("postId: got %q, want %q", postId, "7") + } + }) + t.Run("wildcard routes", func(t *testing.T) { tree := newRadixTreeMatcher() tree.addEndpoint(&Endpoint{"GET", "/files/*/download", nil}) + tree.addEndpoint(&Endpoint{"GET", "/a/*/b/*/c", nil}) testCases := []struct { method Method @@ -94,16 +190,19 @@ func TestRadixTreeMatcher_Match(t *testing.T) { }{ {"GET", "/files/image.png/download", true}, {"GET", "/files/doc.pdf/download", true}, - {"GET", "/files/download", false}, // * boş segment eşlemez - {"GET", "/files/a/b/download", false}, // * tek segment + {"GET", "/files/anything/download", true}, + {"GET", "/files/download", false}, + {"GET", "/files/a/b/download", false}, + {"GET", "/a/x/b/y/c", true}, + {"GET", "/a/x/b/y/z", false}, } - for _, testCase := range testCases { - req, _ := http.NewRequest(string(testCase.method), testCase.path, nil) + for _, tc := range testCases { + req, _ := http.NewRequest(string(tc.method), tc.path, nil) ctx := NewContext(req, nil) _, ok := tree.Match(ctx) - if ok != testCase.match { - t.Errorf("%s %s: got match=%v, want %v", testCase.method, testCase.path, ok, testCase.match) + if ok != tc.match { + t.Errorf("%s %s: got match=%v, want %v", tc.method, tc.path, ok, tc.match) } } }) @@ -112,21 +211,34 @@ func TestRadixTreeMatcher_Match(t *testing.T) { tree := newRadixTreeMatcher() tree.addEndpoint(&Endpoint{"GET", "/static/**", nil}) tree.addEndpoint(&Endpoint{"GET", "/api/**/health", nil}) + tree.addEndpoint(&Endpoint{"GET", "/assets/**", nil}) + tree.addEndpoint(&Endpoint{"GET", "/x/**", nil}) + tree.addEndpoint(&Endpoint{"GET", "/xy", nil}) - tests := []struct { + testCases := []struct { method Method path string match bool }{ - {"GET", "/static", true}, // ** zero-match - {"GET", "/static/css/main.css", true}, // ** çoklu segment + {"GET", "/static", true}, + {"GET", "/static/a", true}, + {"GET", "/static/a/b", true}, + {"GET", "/static/a/b/c", true}, + {"GET", "/static/css/main.css", true}, {"GET", "/static/js/app.js", true}, - {"GET", "/api/health", true}, // ** zero-match - {"GET", "/api/v1/health", true}, // ** tek segment - {"GET", "/api/v1/v2/health", true}, // ** çoklu segment + {"GET", "/api/health", true}, + {"GET", "/api/health/health", true}, + {"GET", "/api/v1/health", true}, + {"GET", "/api/v1/v2/health", true}, + {"GET", "/api/v1/v2/v3/health", true}, + {"GET", "/assets", true}, + {"GET", "/assets/a/b/c", true}, + {"GET", "/x", true}, + {"GET", "/x/a/b", true}, + {"GET", "/xy", true}, } - for _, tc := range tests { + for _, tc := range testCases { req, _ := http.NewRequest(string(tc.method), tc.path, nil) ctx := NewContext(req, nil) _, ok := tree.Match(ctx) @@ -136,25 +248,99 @@ func TestRadixTreeMatcher_Match(t *testing.T) { } }) + t.Run("double wildcard at root", func(t *testing.T) { + tree := newRadixTreeMatcher() + tree.addEndpoint(&Endpoint{"GET", "/**", nil}) + + testCases := []struct { + path string + match bool + }{ + {"/", true}, + {"/a", true}, + {"/a/b", true}, + {"/a/b/c/d/e", true}, + } + + for _, tc := range testCases { + req, _ := http.NewRequest("GET", tc.path, nil) + ctx := NewContext(req, nil) + _, ok := tree.Match(ctx) + if ok != tc.match { + t.Errorf("GET %s: got match=%v, want %v", tc.path, ok, tc.match) + } + } + }) + + t.Run("double wildcard in middle with suffix", func(t *testing.T) { + tree := newRadixTreeMatcher() + tree.addEndpoint(&Endpoint{"GET", "/a/**/b/c", nil}) + + testCases := []struct { + path string + match bool + }{ + {"/a/b/c", true}, + {"/a/x/b/c", true}, + {"/a/x/y/b/c", true}, + {"/a/x/y/z/b/c", true}, + {"/a/b", false}, + {"/a/b/c/d", false}, + {"/a/b/b/c", true}, + } + + for _, tc := range testCases { + req, _ := http.NewRequest("GET", tc.path, nil) + ctx := NewContext(req, nil) + _, ok := tree.Match(ctx) + if ok != tc.match { + t.Errorf("GET %s: got match=%v, want %v", tc.path, ok, tc.match) + } + } + }) + t.Run("pattern routes", func(t *testing.T) { tree := newRadixTreeMatcher() tree.addEndpoint(&Endpoint{"GET", "/files/*.json", nil}) tree.addEndpoint(&Endpoint{"GET", "/images/img?.png", nil}) + tree.addEndpoint(&Endpoint{"GET", "/docs/report-*.pdf", nil}) + tree.addEndpoint(&Endpoint{"GET", "/data/??-*.csv", nil}) + tree.addEndpoint(&Endpoint{"GET", "/logs/app**.log", nil}) + tree.addEndpoint(&Endpoint{"GET", "/files/*.json/meta", nil}) + tree.addEndpoint(&Endpoint{"GET", "/assets/img*", nil}) - tests := []struct { + testCases := []struct { method Method path string match bool }{ {"GET", "/files/data.json", true}, {"GET", "/files/config.json", true}, + {"GET", "/files/.json", true}, {"GET", "/files/data.xml", false}, + {"GET", "/files/data.jsonx", false}, {"GET", "/images/img1.png", true}, {"GET", "/images/img2.png", true}, - {"GET", "/images/img12.png", false}, // ? tek karakter + {"GET", "/images/img12.png", false}, + {"GET", "/images/img.png", false}, + {"GET", "/docs/report-2024.pdf", true}, + {"GET", "/docs/report-.pdf", true}, + {"GET", "/docs/report.pdf", false}, + {"GET", "/data/ab-test.csv", true}, + {"GET", "/data/xy-data.csv", true}, + {"GET", "/data/a-test.csv", false}, + {"GET", "/data/abc-test.csv", false}, + {"GET", "/logs/app.log", true}, + {"GET", "/logs/app123.log", true}, + {"GET", "/logs/app.txt", false}, + {"GET", "/files/data.json/meta", true}, + {"GET", "/files/x.json/meta", true}, + {"GET", "/assets/img", true}, + {"GET", "/assets/img123", true}, + {"GET", "/assets/im", false}, } - for _, tc := range tests { + for _, tc := range testCases { req, _ := http.NewRequest(string(tc.method), tc.path, nil) ctx := NewContext(req, nil) _, ok := tree.Match(ctx) @@ -172,18 +358,18 @@ func TestRadixTreeMatcher_Match(t *testing.T) { tree.addEndpoint(&Endpoint{"GET", "/files/*", nil}) tree.addEndpoint(&Endpoint{"GET", "/files/**", nil}) - tests := []struct { + testCases := []struct { path string wantPattern string }{ {"/files/exact", "/files/exact"}, {"/files/data.json", "/files/*.json"}, {"/files/42", "/files/{id}"}, - {"/files/anything", "/files/{id}"}, // param önce gelir - {"/files/a/b/c", "/files/**"}, // ** çoklu segment + {"/files/anything", "/files/{id}"}, + {"/files/a/b/c", "/files/**"}, } - for _, tc := range tests { + for _, tc := range testCases { req, _ := http.NewRequest("GET", tc.path, nil) ctx := NewContext(req, nil) ep, ok := tree.Match(ctx) @@ -197,15 +383,192 @@ func TestRadixTreeMatcher_Match(t *testing.T) { } }) - t.Run("trailing slash normalization", func(t *testing.T) { + t.Run("param vs wildcard priority", func(t *testing.T) { tree := newRadixTreeMatcher() - tree.addEndpoint(&Endpoint{"GET", "/users", nil}) + tree.addEndpoint(&Endpoint{"GET", "/items/{id}/detail", nil}) + tree.addEndpoint(&Endpoint{"GET", "/items/*/detail", nil}) - req, _ := http.NewRequest("GET", "/users/", nil) + req, _ := http.NewRequest("GET", "/items/42/detail", nil) ctx := NewContext(req, nil) - _, ok := tree.Match(ctx) + ep, ok := tree.Match(ctx) if !ok { - t.Error("GET /users/ should match /users (trailing slash stripped)") + t.Fatal("expected match") + } + if ep.path != "/items/{id}/detail" { + t.Errorf("got %s, want /items/{id}/detail (param has higher priority)", ep.path) + } + }) + + t.Run("multiple methods same path", func(t *testing.T) { + tree := newRadixTreeMatcher() + tree.addEndpoint(&Endpoint{"GET", "/resource", nil}) + tree.addEndpoint(&Endpoint{"POST", "/resource", nil}) + tree.addEndpoint(&Endpoint{"PUT", "/resource", nil}) + tree.addEndpoint(&Endpoint{"DELETE", "/resource", nil}) + tree.addEndpoint(&Endpoint{"PATCH", "/resource", nil}) + tree.addEndpoint(&Endpoint{"HEAD", "/resource", nil}) + tree.addEndpoint(&Endpoint{"OPTIONS", "/resource", nil}) + tree.addEndpoint(&Endpoint{"CONNECT", "/resource", nil}) + tree.addEndpoint(&Endpoint{"TRACE", "/resource", nil}) + tree.addEndpoint(&Endpoint{"", "/resource", nil}) + + testCases := []struct { + method Method + match bool + }{ + {"GET", true}, + {"POST", true}, + {"PUT", true}, + {"DELETE", true}, + {"PATCH", true}, + {"HEAD", true}, + {"OPTIONS", true}, + {"CONNECT", true}, + {"TRACE", true}, + } + + for _, tc := range testCases { + req, _ := http.NewRequest(string(tc.method), "/resource", nil) + ctx := NewContext(req, nil) + ep, ok := tree.Match(ctx) + if ok != tc.match { + t.Errorf("%s /resource: got match=%v, want %v", tc.method, ok, tc.match) + continue + } + if ok && ep.method != tc.method { + t.Errorf("%s /resource: got method=%s", tc.method, ep.method) + } + } + }) + + t.Run("mixed route types in same tree", func(t *testing.T) { + tree := newRadixTreeMatcher() + tree.addEndpoint(&Endpoint{"GET", "/api/users", nil}) + tree.addEndpoint(&Endpoint{"GET", "/api/users/{id}", nil}) + tree.addEndpoint(&Endpoint{"GET", "/api/users/{id}/roles/*", nil}) + tree.addEndpoint(&Endpoint{"GET", "/api/**/health", nil}) + tree.addEndpoint(&Endpoint{"GET", "/api/files/*.json", nil}) + + testCases := []struct { + path string + match bool + wantPattern string + }{ + {"/api/users", true, "/api/users"}, + {"/api/users/42", true, "/api/users/{id}"}, + {"/api/users/42/roles/admin", true, "/api/users/{id}/roles/*"}, + {"/api/health", true, "/api/**/health"}, + {"/api/v1/v2/health", true, "/api/**/health"}, + {"/api/files/data.json", true, "/api/files/*.json"}, + {"/api/files/data.xml", false, ""}, + {"/api/users/42/roles", false, ""}, + } + + for _, tc := range testCases { + req, _ := http.NewRequest("GET", tc.path, nil) + ctx := NewContext(req, nil) + ep, ok := tree.Match(ctx) + if ok != tc.match { + t.Errorf("GET %s: got match=%v, want %v", tc.path, ok, tc.match) + continue + } + if ok && ep.path != tc.wantPattern { + t.Errorf("GET %s: matched %s, want %s", tc.path, ep.path, tc.wantPattern) + } + } + }) + + t.Run("param to wildcard backtracking", func(t *testing.T) { + tree := newRadixTreeMatcher() + tree.addEndpoint(&Endpoint{"GET", "/files/{id}/detail", nil}) + tree.addEndpoint(&Endpoint{"GET", "/files/*/other", nil}) + + testCases := []struct { + path string + match bool + }{ + {"/files/test/detail", true}, + {"/files/test/other", true}, + } + + for _, tc := range testCases { + req, _ := http.NewRequest("GET", tc.path, nil) + ctx := NewContext(req, nil) + _, ok := tree.Match(ctx) + if ok != tc.match { + t.Errorf("GET %s: got match=%v, want %v", tc.path, ok, tc.match) + } + } + }) + + t.Run("pattern child backtracking", func(t *testing.T) { + tree := newRadixTreeMatcher() + tree.addEndpoint(&Endpoint{"GET", "/files/*.json/download", nil}) + tree.addEndpoint(&Endpoint{"GET", "/files/*.txt/upload", nil}) + + testCases := []struct { + path string + match bool + }{ + {"/files/data.json/download", true}, + {"/files/data.txt/upload", true}, + {"/files/data.txt/download", false}, + {"/files/data.json/upload", false}, + } + + for _, tc := range testCases { + req, _ := http.NewRequest("GET", tc.path, nil) + ctx := NewContext(req, nil) + _, ok := tree.Match(ctx) + if ok != tc.match { + t.Errorf("GET %s: got match=%v, want %v", tc.path, ok, tc.match) + } + } + }) + + t.Run("trailing slash normalization", func(t *testing.T) { + tree := newRadixTreeMatcher() + tree.addEndpoint(&Endpoint{"GET", "/users", nil}) + tree.addEndpoint(&Endpoint{"GET", "/a/b/c", nil}) + + testCases := []struct { + path string + match bool + }{ + {"/users/", true}, + {"/a/b/c/", true}, + } + + for _, tc := range testCases { + req, _ := http.NewRequest("GET", tc.path, nil) + ctx := NewContext(req, nil) + _, ok := tree.Match(ctx) + if ok != tc.match { + t.Errorf("GET %s: got match=%v, want %v", tc.path, ok, tc.match) + } + } + }) + + t.Run("path normalization on add", func(t *testing.T) { + tree := newRadixTreeMatcher() + tree.addEndpoint(&Endpoint{"GET", "users", nil}) + tree.addEndpoint(&Endpoint{"GET", "/items/", nil}) + + testCases := []struct { + path string + match bool + }{ + {"/users", true}, + {"/items", true}, + } + + for _, tc := range testCases { + req, _ := http.NewRequest("GET", tc.path, nil) + ctx := NewContext(req, nil) + _, ok := tree.Match(ctx) + if ok != tc.match { + t.Errorf("GET %s: got match=%v, want %v", tc.path, ok, tc.match) + } } }) @@ -221,4 +584,70 @@ func TestRadixTreeMatcher_Match(t *testing.T) { } }) + t.Run("buildRouteEntry errors", func(t *testing.T) { + testCases := []struct { + name string + path string + }{ + {"unclosed param", "/users/{id"}, + {"empty param name", "/users/{}"}, + {"too many params", "/a/{p1}/{p2}/{p3}/{p4}/{p5}/{p6}/{p7}/{p8}/{p9}/{p10}/{p11}/{p12}/{p13}/{p14}/{p15}/{p16}/{p17}"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tree := newRadixTreeMatcher() + err := tree.addEndpoint(&Endpoint{"GET", tc.path, nil}) + if err == nil { + t.Errorf("expected error for path %q", tc.path) + } + }) + } + }) + +} + +func BenchmarkRadixTreeMatcher_Match(b *testing.B) { + tree := newRadixTreeMatcher() + tree.addEndpoint(&Endpoint{"GET", "/users", nil}) + tree.addEndpoint(&Endpoint{"GET", "/users/{id}", nil}) + tree.addEndpoint(&Endpoint{"GET", "/users/{id}/posts/{postId}", nil}) + tree.addEndpoint(&Endpoint{"GET", "/files/*/download", nil}) + tree.addEndpoint(&Endpoint{"GET", "/static/**", nil}) + tree.addEndpoint(&Endpoint{"GET", "/api/**/health", nil}) + tree.addEndpoint(&Endpoint{"GET", "/files/*.json", nil}) + tree.addEndpoint(&Endpoint{"POST", "/users", nil}) + tree.addEndpoint(&Endpoint{"PUT", "/users/{id}", nil}) + tree.addEndpoint(&Endpoint{"DELETE", "/users/{id}", nil}) + + benchCases := []struct { + name string + method string + path string + }{ + {"static", "GET", "/users"}, + {"param_1", "GET", "/users/42"}, + {"param_2", "GET", "/users/42/posts/7"}, + {"wildcard", "GET", "/files/image.png/download"}, + {"double_wildcard_short", "GET", "/static/css/main.css"}, + {"double_wildcard_deep", "GET", "/static/a/b/c/d/e/f"}, + {"double_wildcard_middle", "GET", "/api/v1/v2/health"}, + {"pattern", "GET", "/files/data.json"}, + {"not_found", "GET", "/notfound/path"}, + } + + for _, bc := range benchCases { + b.Run(bc.name, func(b *testing.B) { + req, _ := http.NewRequest(bc.method, bc.path, nil) + ctx := NewContext(req, nil) + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + ctx.Request().pathValues.reset() + tree.Match(ctx) + } + }) + } }