Skip to content

Method-less tree with route-level method matching - #71

Merged
tigerwill90 merged 54 commits into
masterfrom
feat/method-less-tree
Jan 6, 2026
Merged

tigerwill90 merged 54 commits into
masterfrom
feat/method-less-tree

Conversation

@tigerwill90

@tigerwill90 tigerwill90 commented Dec 31, 2025 •

Copy link
Copy Markdown
Collaborator

This PR reworks how the router tree handles HTTP methods. Previously, each method had its own branch in the tree, and MethodAny routes were evaluated as a fallback after all specific methods, which required a second tree lookup. This caused a few annoying issues.

The main problem was that a wildcard route like GET /*{any} would shadow any more specific MethodAny route like ANY /api/users, because the tree would find the GET match first and never fall back to the ANY route. This was particularly problematic for sub-routers, which typically use MethodAny to capture all methods for a prefix before delegating to the mounted router.

Now the tree is indexed by pattern and methods are evaluated after the hostname/path matches but before route matchers, so they're still a higher priority discriminant than matchers. For a given pattern, specific methods always take precedence over method-less routes:

f.MustAdd(fox.MethodGet, "/resource", GetHandler)
f.MustAdd(fox.MethodAny, "/resource", FallbackHandler)
f.MustAdd(fox.MethodGet, "/+{any}", CatchAllHandler)

Here GET /resource hits GetHandler, POST /resource hits FallbackHandler, and CatchAllHandler only kicks in if nothing else matches /resource first.

This makes sub-routers work correctly alongside catch-all routes:

f := fox.MustRouter(fox.DefaultOptions())
f.MustAdd([]string{http.MethodHead, http.MethodGet}, "/+{filepath}", fox.WrapH(http.FileServer(http.Dir("./public/"))))

api := fox.MustRouter(fox.WithMiddleware(AuthMiddleware()))
api.MustAdd(fox.MethodGet, "/users", ListUser)
api.MustAdd(fox.MethodPost, "/users", CreateUser)
f.MustAdd(fox.MethodAny, "/api+{mount}", api.Mount())

Requests to /api/users go to the sub-router because /api+{mount} is more specific than /+{filepath}, regardless of which methods are registered.

Trailing slash handling

The trailing slash handling also needed a rethink. When the router found a potential redirect (e.g. /users/ could redirect to /users), it would store that match and keep evaluating routes looking for a direct match. The problem is that catch-all routes match everything, so a file server route like GET /+{filepath} would capture /users/ instead of letting the redirect happen:

f := fox.MustRouter(fox.WithRedirectTrailingSlash(true))
f.MustAdd(fox.MethodGet, "/users", ListUser)
f.MustAdd(fox.MethodGet, "/+{filepath}", FileServer)

// Before: GET /users/ => FileServer (oops)
// After:  GET /users/ => 308 redirect to /users

Now trailing slash matches are treated as direct matches and returned immediately. This is both more correct and about 40% faster.

Sub-router rework

The sub-router API has been simplified. Previously you had to create for a sub-router, then pass it to NewSubRouter and finally register the route created

// Before
f := fox.MustNew(fox.DefaultOptions())
f.MustHandle(fox.MethodGet, "/+{filepath}", fox.WrapH(http.FileServer(http.Dir("./public/"))))

api := fox.MustNew(fox.WithMiddleware(AuthMiddleware()))
api.MustHandle(http.MethodGet, "/users", ListUser)
api.MustHandle(http.MethodPost, "/users", CreateUser)

route, _ := f.NewSubRouter(fox.MethodAny, "/api/*{any}", api)
f.AddRoute(route)

Now Router.Mount() return an fox.HandlerFunc that can be uses directly. This is also harder to misuse.

// After
f := fox.MustRouter(fox.DefaultOptions())
f.MustAdd(fox.MethodGet, "/+{filepath}", fox.WrapH(http.FileServer(http.Dir("./public/"))))

api := fox.MustRouter(fox.WithMiddleware(AuthMiddleware()))
api.MustAdd(fox.MethodGet, "/users", ListUser)
api.MustAdd(fox.MethodPost, "/users", CreateUser)
f.MustAdd(fox.MethodAny, "/api+{mount}", api.Mount())

Sub-router is also now "zero-alloc" on the hot-path.

Multi-method registration

Routes can now be registered with multiple methods at once. This is useful for handlers that respond identically to different methods, like HEAD and GET:

f.MustAdd([]string{http.MethodHead, http.MethodGet}, "/users", ListUser)

The router still detects conflicts: if you try to register a route with a pattern and matchers that already exist, and the methods overlap, it returns an ErrRouteConflict. This prevents accidentally shadowing routes that would be impossible to match.

Reworked priority rules

For reference, here's the full evaluation order:

  1. Hostname matching:
    • routes with hostnames are evaluated before path-only routes
  2. Pattern matching: longest match wins, most specific first
    • Static segments
    • Named parameters with regex constraints
    • Named parameters without constraints
    • Catch-all with regex constraints
    • Catch-all without constraints
    • Infix catch-all before suffix (e.g. /bucket/*{path}/meta before /bucket/*{path})
    • Same-level regex params evaluated in registration order
  3. Method matching:
    • specific methods are evaluated before method-less routes
  4. Matcher evaluation:
    • routes with matchers are evaluated before routes without, higher priority first

If a match candidate fails (e.g. matcher doesn't pass), the router backtracks and tries the next alternative following the same priority order.

Differential benchmark

Beyond the architectural changes, I found a few optimizations along the way. The trailing slash handling got the biggest win at -40%, but most benchmarks show solid improvements across the board.

goos: darwin
goarch: arm64
pkg: github.com/tigerwill90/fox
cpu: Apple M4 Max
                           │   old.txt    │               new.txt                │
                           │    sec/op    │    sec/op     vs base                │
StaticAll-16                  7.643µ ± 3%   7.273µ ±  2%   -4.83% (p=0.000 n=10)
StaticHostnameAll-16          8.910µ ± 1%   8.329µ ±  1%   -6.52% (p=0.000 n=10)
GithubParamsAll-16            58.75n ± 1%   57.40n ±  1%   -2.31% (p=0.000 n=10)
GithubParamsHostnameAll-16    58.99n ± 1%   58.34n ±  0%   -1.10% (p=0.003 n=10)
InfixCatchAll-16              186.3n ± 0%   195.8n ±  1%   +5.07% (p=0.000 n=10)
LongParam-16                  24.48n ± 1%   22.20n ±  1%   -9.33% (p=0.000 n=10)
OverlappingRoute-16           61.51n ± 0%   58.67n ±  0%   -4.61% (p=0.000 n=10)
WithIgnoreTrailingSlash-16    58.77n ± 0%   35.27n ±  1%  -39.99% (p=0.000 n=10)
StaticParallel-16            48.510n ± 1%   3.899n ±  5%  -91.96% (p=0.000 n=10)
CatchAll-16                   22.96n ± 0%   19.89n ±  1%  -13.39% (p=0.000 n=10)
CatchAllParallel-16          33.275n ± 2%   3.293n ± 44%  -90.10% (p=0.000 n=10)
geomean                       127.5n        75.67n        -40.63%

Note: parallel benchmark gain are due to the fix of a race condition in the benchmark.

@codecov

codecov Bot commented Dec 31, 2025 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.20330% with 115 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.69%. Comparing base (fea3c6c) to head (60491fc).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
error.go 48.07% 25 Missing and 2 partials ⚠️
internal/slicesutil/slicesutil.go 0.00% 25 Missing ⚠️
context.go 78.57% 12 Missing ⚠️
fox.go 94.78% 7 Missing and 4 partials ⚠️
tree.go 88.76% 7 Missing and 3 partials ⚠️
txn.go 83.67% 8 Missing ⚠️
internal/iterutil/iterutil.go 0.00% 6 Missing ⚠️
matcher.go 40.00% 6 Missing ⚠️
node.go 94.73% 4 Missing and 1 partial ⚠️
iter.go 97.05% 2 Missing ⚠️
... and 2 more
Additional details and impacted files
@@            Coverage Diff             @@
##           master      #71      +/-   ##
==========================================
+ Coverage   88.89%   89.69%   +0.79%     
==========================================
  Files          25       26       +1     
  Lines        4135     3977     -158     
==========================================
- Hits         3676     3567     -109     
+ Misses        359      320      -39     
+ Partials      100       90      -10     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tigerwill90 tigerwill90 changed the title feat!: use method-less tree with route-level method matching Method-less tree with route-level method matching Jan 2, 2026
* feat!: change tsr priority to be equivalent to exact match

* fix: exclude wildcard nodes from trailing slash matching

* fix: exclude wildcard nodes from trailing slash matching

* fix: skip tsr matching for routes with strict slash handling

* fix: prioritize exact suffix catch-all over infix wildcard tsr match

* fix: use offset instead of charsMatched for wildcard tsr path matching

* refactor: remove dead code for trailing slash conflict detection

* refactor: simplify route conflict error by replacing kind enum with boolean flag
@tigerwill90
tigerwill90 marked this pull request as ready for review January 4, 2026 00:52
@tigerwill90
tigerwill90 merged commit 224c520 into master Jan 6, 2026
8 of 9 checks passed
@tigerwill90
tigerwill90 deleted the feat/method-less-tree branch January 6, 2026 20:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant