Skip to content

fix(routers): detect routes registered outside main() for gin's four siblings - #35

Merged
nccapo merged 5 commits into
masterfrom
fix/routers-outside-main
Sep 4, 2026
Merged

fix(routers): detect routes registered outside main() for gin's four siblings#35
nccapo merged 5 commits into
masterfrom
fix/routers-outside-main

Conversation

@nccapo

@nccapo nccapo commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #33 / #34 (now merged). Chi was not alone.

I tested three "routes outside main()" shapes against every router — a cross-package routes.Register(r) helper, a factory that builds its own router, and a method registering on a struct field:

Router cross-pkg helper factory (opaque signature) struct-field method
gin
chi (after #34)
gorilla
stdlib
echo
fiber

Echo and fiber returned a completely empty table for the reporter's layout — the same symptom as the original issue. Gin was the only router already correct, because it walks every function and keys on types.

Two independent defects

1. The function gate chi had. Echo, fiber and gorilla walked a function only if it was main/init or its signature named the router type. A factory returning http.Handler or any is never walked.

2. Receivers recognized by variable name, not type. Echo, fiber and stdlib accepted a registration only when the receiver was a local variable assigned from echo.New() / fiber.New() / http.NewServeMux(). A router arriving as a parameter — the shape of every routes.Register(r) helper — or held in a struct field was invisible even when the function was walked.

Gorilla had only #1, stdlib only #2, echo and fiber both.

What changed

  • registry.go (first commit, no behavior change): the index chi uses to find registration functions and expand their call sites is not chi-specific, so it moves behind routerIndexSpec, which asks each framework only for what differs — packages in scope, what a registration looks like, its router type. All five extractors now share one implementation.
  • stdlib: receiver identified by type. The package-level http.HandleFunc case stays, now confirmed through types.Info rather than by matching the identifier text "http".
  • gorilla: body-based index plus call-site expansion, so a subrouter handed across a package boundary (routes.RegisterItems(api) where api came from r.PathPrefix("/api/v1").Subrouter()) carries its prefix. isGorillaMuxType now compares the package path instead of searching the printed type for "mux.Router" — that only worked because gorilla/mux carries no version suffix, the exact trap that broke chi.
  • echo + fiber: both fixes. Group variables keep their own map, since a group's prefix is what distinguishes it from the instance it was carved from. Fiber's group parameter type is the fiber.Router interface, not *fiber.Group, so both are accepted.

Middleware chains became []MiddlewareRef in gorilla, echo and fiber, so a chain assembled in one package and applied in another stays resolvable.

Safety

Widening the walk means unrelated methods sharing a registration's name come into scope — gorilla matched HandleFunc/Handle on name alone with no receiver check at all. Every extractor now confirms the receiver's type, and each new testdata module contains a decoy (bus.Handle("warm", nil), bus.Any(…), bus.All(…)) asserted not to appear.

Verification

Four new testdata modules — stdlib-multipkg, gorilla-multipkg, echo-multipkg, fiber-multipkg — each mirroring a real service layout, all six routes resolving with correct prefixes. The per-router assertions share one helper (assertRoutesOutsideMain), which also checks no route is emitted twice and none carries an unresolved caveat; the chi and stdlib tests were folded onto it rather than copied a third and fourth time.

go build, go vet and go test ./... pass. Benchmarks unmoved — extraction is a few ms against ~150ms of package loading.

🤖 Generated with Claude Code

nccapo and others added 5 commits September 4, 2026 13:28
…name

A HandleFunc/Handle call counted as a route only when its receiver was a
local variable the same function had assigned from http.NewServeMux(). Name
tracking cannot see a mux that arrives any other way, so a service that
builds its mux in main() and hands it to routes.Register(mux) — or keeps it
in a struct field — produced nothing for those registrations, even though
the stdlib extractor already walks every function.

Check the receiver's type instead. The package-level http.HandleFunc case
stays, now confirmed through types.Info rather than by matching the
identifier text "http", so a variable of that name cannot pose as the
package. Type-checking is also stricter than the name matching it replaces:
Handle is a common method name, and only a *http.ServeMux receiver makes it
a route.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The index chi uses to find route-registering functions and expand their call
sites is not chi-specific: every router whose routes live outside main()
needs the same two things — a way to find registration functions regardless
of what their signature says, and a way to expand a call to one so the
prefix and middleware chain at the call site flow into the routes it
registers.

Move it to registry.go behind routerIndexSpec, which asks each framework
only for what differs: which packages are in scope, what a registration
looks like, and what its router type is. No behavior change; chi is the only
caller so far.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same defect as chi: a function was walked only when it was main/init or its
signature named *mux.Router, so a factory returning http.Handler and a method
registering on a struct field were both invisible. A service that keeps its
routes in a routes package saw only whatever main() registered directly.

Index every function whose body calls a mux.Router method on a router value,
and expand calls to registration functions inline. A subrouter handed across
a package boundary — routes.RegisterItems(api) where api came from
r.PathPrefix("/api/v1").Subrouter() — now carries its prefix into the routes
that registrar adds; registrars nothing reached still emit, with the
unknown-origin caveat.

Registrations were matched on method name alone, with no check on the
receiver. That was survivable while only main() was walked; with every
route-setup function in scope it would turn any bus.Handle("topic", fn) into
a route, so the receiver is now confirmed to be a mux.Router. isGorillaMuxType
compares the owning package path rather than searching the printed type for
"mux.Router", which only worked because gorilla/mux carries no version
suffix — the exact trap that broke chi.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both extractors returned nothing at all for a service that keeps its routes
in a routes package, because both defects were present at once.

A function was walked only when it was main/init or its signature named the
router type, so a factory returning http.Handler and a method registering on
a struct field were never visited. Index every function whose body calls a
registration method on an Echo/App or group value instead, and expand calls
to registration functions inline: a group handed across a package boundary —
routes.RegisterItems(v1) where v1 came from e.Group("/api/v1") — now carries
its prefix and middleware into the routes that registrar adds.

Even when a function was walked, a registration counted only if its receiver
was a local variable the same function had assigned from echo.New() /
fiber.New(). Name tracking cannot see a router that arrives as a parameter,
which is the shape of every routes.Register(e) helper, so echo found nothing
whatsoever and fiber found only what a factory built inline. Receivers are
identified by type now; group variables keep their own map, since a group's
prefix is what distinguishes it from the instance it was carved from.

Fiber's group parameter type is the fiber.Router interface, not *fiber.Group,
so both are accepted. Middleware chains became []MiddlewareRef throughout,
so a chain assembled in one package and applied in another stays resolvable.

The two extractors are near-identical in shape and their tests share a file,
so they land together.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
isEchoType and isFiberAppType lost their last callers when the signature
gates went away; the broader isEchoRouterType / isFiberRouterType cover
every remaining use. Note in the README that route discovery no longer
depends on where a project registers its routes, for every router.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@nccapo
nccapo merged commit 0d6397b into master Sep 4, 2026
3 checks passed
@github-actions
github-actions Bot deleted the fix/routers-outside-main branch September 4, 2026 10:24
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