Skip to content

Commit a163b07

Browse files
committed
add ability for late propagation of sub-routers
1 parent 930d6f5 commit a163b07

7 files changed

Lines changed: 476 additions & 34 deletions

File tree

docs/USAGE.md

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -207,19 +207,43 @@ Use `Mount(prefix, handler)` to attach any `http.Handler` to a path prefix:
207207
r.Mount("/static", http.FileServer(http.Dir("./static")))
208208
```
209209

210-
### Mounting teapot Routers (Route Propagation)
210+
### Mounting teapot Routers (Late Propagation & Homing)
211211

212-
If the handler being mounted is also a `*teapot.Router`, all its existing routes are automatically propagated to the parent router with the prefix prepended. This enables unified route listing and URL generation even when routers are developed independently.
212+
If the handler being mounted is also a `*teapot.Router`, all its existing routes are automatically propagated to the parent router with the prefix prepended.
213+
214+
Furthermore, **"Homing"** is established: any future routes added to the sub-router after it has been mounted will also be automatically propagated to the parent. This enables flexible router composition where routers can be developed independently or even after mounting.
213215

214216
```go
215217
apiRouter := teapot.New()
216218
apiRouter.GET("/users", listUsers).Name("users.list")
217219

218220
// Mount propagates all apiRouter's current routes to r
219221
r.Mount("/api/v1", apiRouter)
220-
// r now knows about "/api/v1/users" with name "users.list"
222+
223+
// Homing: even routes added LATER are visible in the parent
224+
apiRouter.GET("/profile", profileHandler).Name("users.profile")
225+
226+
// r now knows about both:
227+
// "/api/v1/users" (name: "users.list")
228+
// "/api/v1/profile" (name: "users.profile")
229+
```
230+
231+
#### Custom Name Prefixes for Mounted Routers
232+
233+
When mounting a router, you can control the name prefix applied to its routes using `MountNamed` or the fluent `.Name()` method on the mount builder:
234+
235+
```go
236+
// Using MountNamed
237+
r.MountNamed("/api/v1", "v1", apiRouter)
238+
// apiRouter route "users.list" becomes "v1.users.list" in r
239+
240+
// Using fluent API
241+
r.Mount("/api/v2", apiRouter).Name("v2")
242+
// apiRouter route "users.list" becomes "v2.users.list" in r
221243
```
222244

245+
If a name is assigned via the fluent `.Name()` after mounting, any already-propagated routes will be renamed throughout the hierarchy automatically.
246+
223247
---
224248

225249
## Direct Handler Registration

internal/core/route.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ type Route struct {
1818
QueryMatchers []dispatch.Matcher
1919
Middlewares []func(http.Handler) http.Handler
2020
WildcardParams map[string]bool // Track which params are wildcards (e.g., "key" -> true)
21+
OriginalRoute *Route // Pointer to original route if this is a propagated copy
2122
}
2223

2324
// TranslatePattern converts our {key:.*} syntax to Chi's wildcard syntax

pkg/teapot/router.go

Lines changed: 201 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,54 @@ type Router struct {
2525
optimizedHandlers *[]*optimizedHandler // for finalization optimization
2626
finalized bool
2727
debugLog bool // enable debug logging for auto-promotion
28+
29+
// Homing support for late propagation
30+
parents []parentRouter // parent routers to notify of new routes
31+
}
32+
33+
type parentRouter struct {
34+
router *Router
35+
pathPrefix string
36+
namePrefix string
37+
}
38+
39+
// MountBuilder provides a fluent API for building mounted routers
40+
type MountBuilder struct {
41+
router *Router
42+
subRouter *Router
43+
pathPrefix string
44+
namePrefix string
45+
}
46+
47+
// Name assigns a name prefix to all routes in the mounted sub-router
48+
func (mb *MountBuilder) Name(name string) *MountBuilder {
49+
if mb.subRouter == nil {
50+
return mb
51+
}
52+
53+
if name != "" && !strings.HasSuffix(name, ".") {
54+
name += "."
55+
}
56+
57+
// Update the prefix for all future routes
58+
newNamePrefix := mb.router.namePrefix + name
59+
60+
// Find the parent entry in the subRouter and update it
61+
for i := range mb.subRouter.parents {
62+
p := &mb.subRouter.parents[i]
63+
if p.router == mb.router && p.pathPrefix == mb.pathPrefix {
64+
// Found it! Update the prefix
65+
oldNamePrefix := p.namePrefix
66+
p.namePrefix = newNamePrefix
67+
68+
// Now we MUST also update any ALREADY propagated routes' names.
69+
// This is because propagateRoutes was called with the old prefix during Mount.
70+
mb.router.propagateRouteNames(oldNamePrefix, newNamePrefix, mb.subRouter)
71+
}
72+
}
73+
74+
mb.namePrefix = newNamePrefix
75+
return mb
2876
}
2977

3078
// RouteBuilder provides a fluent API for building routes
@@ -72,6 +120,11 @@ func (rb *RouteBuilder) Name(name string) *RouteBuilder {
72120
// Register in name index
73121
rb.router.nameIndex[fullName] = rb.route
74122

123+
// Update parents if name was added late (homing)
124+
for _, p := range rb.router.parents {
125+
p.router.propagateRouteName(p.namePrefix, rb.route)
126+
}
127+
75128
return rb
76129
}
77130

@@ -265,6 +318,11 @@ func (r *Router) handleDirect(method, pattern string, handler http.Handler) *Rou
265318

266319
*r.routes = append(*r.routes, rt)
267320

321+
// Propagate to parents (Homing)
322+
for _, p := range r.parents {
323+
p.router.propagateRoute(p.pathPrefix, p.namePrefix, rt)
324+
}
325+
268326
dispatcherKey := method + ":" + chiPattern
269327

270328
// Check if dispatcher already exists (from QueryGET/etc)
@@ -330,6 +388,11 @@ func (r *Router) handleQuery(method, pattern string, handler http.Handler) *Rout
330388

331389
*r.routes = append(*r.routes, rt)
332390

391+
// Propagate to parents (Homing)
392+
for _, p := range r.parents {
393+
p.router.propagateRoute(p.pathPrefix, p.namePrefix, rt)
394+
}
395+
333396
// Get or create dispatcher for this method+pattern
334397
dispatcherKey := method + ":" + chiPattern
335398
disp, exists := r.dispatchers[dispatcherKey]
@@ -460,40 +523,152 @@ func (r *Router) Use(middlewares ...func(http.Handler) http.Handler) {
460523
r.mux.Use(middlewares...)
461524
}
462525

526+
// MountNamed is like Mount, but allows specifying a name prefix for the sub-router's routes.
527+
// The provided namePrefix will be prepended to all routes in the sub-router.
528+
func (r *Router) MountNamed(pattern, namePrefix string, handler http.Handler) *MountBuilder {
529+
fullPattern := r.pathPrefix + pattern
530+
531+
if namePrefix != "" && !strings.HasSuffix(namePrefix, ".") {
532+
namePrefix += "."
533+
}
534+
fullNamePrefix := r.namePrefix + namePrefix
535+
536+
var subRouter *Router
537+
if sr, ok := handler.(*Router); ok {
538+
subRouter = sr
539+
540+
// Check if we are already mounted at this pattern
541+
alreadyMounted := false
542+
for _, p := range subRouter.parents {
543+
if p.router == r && p.pathPrefix == fullPattern {
544+
alreadyMounted = true
545+
break
546+
}
547+
}
548+
549+
if !alreadyMounted {
550+
r.mux.Mount(pattern, handler)
551+
552+
// Late propagation of existing routes
553+
r.propagateRoutes(fullPattern, fullNamePrefix, subRouter)
554+
555+
// Set up "Homing" for future routes
556+
subRouter.parents = append(subRouter.parents, parentRouter{
557+
router: r,
558+
pathPrefix: fullPattern,
559+
namePrefix: fullNamePrefix,
560+
})
561+
}
562+
} else {
563+
r.mux.Mount(pattern, handler)
564+
}
565+
566+
return &MountBuilder{
567+
router: r,
568+
subRouter: subRouter,
569+
pathPrefix: fullPattern,
570+
namePrefix: fullNamePrefix,
571+
}
572+
}
573+
463574
// Mount attaches another http.Handler (typically a router) to a prefix.
464575
// If the handler is a *teapot.Router, all its routes are propagated to the
465576
// parent router with the prefix prepended for unified listing and URL generation.
466-
func (r *Router) Mount(pattern string, handler http.Handler) {
467-
fullPattern := r.pathPrefix + pattern
468-
r.mux.Mount(pattern, handler)
469-
470-
r.propagateRoutes(fullPattern, handler)
471-
}
472-
473-
func (r *Router) propagateRoutes(prefix string, handler http.Handler) {
474-
// Propagate routes if it's a teapot Router
475-
if subRouter, ok := handler.(*Router); ok {
476-
for _, rt := range *subRouter.routes {
477-
// Create a copy of the route with updated pattern and name
478-
newRoute := &core.Route{
479-
Method: rt.Method,
480-
Pattern: prefix + rt.Pattern,
481-
ChiPattern: prefix + rt.ChiPattern,
482-
Handler: rt.Handler,
483-
Name: r.namePrefix + rt.Name,
484-
Action: rt.Action,
485-
QueryMatchers: rt.QueryMatchers,
486-
Middlewares: rt.Middlewares,
487-
WildcardParams: rt.WildcardParams,
577+
// Furthermore, future routes added to the sub-router will also be propagated.
578+
func (r *Router) Mount(pattern string, handler http.Handler) *MountBuilder {
579+
return r.MountNamed(pattern, "", handler)
580+
}
581+
582+
func (r *Router) propagateRoutes(pathPrefix, namePrefix string, subRouter *Router) {
583+
for _, rt := range *subRouter.routes {
584+
r.propagateRoute(pathPrefix, namePrefix, rt)
585+
}
586+
}
587+
588+
func (r *Router) propagateRoute(pathPrefix, namePrefix string, rt *core.Route) {
589+
// Create a copy of the route with updated pattern and name
590+
newRoute := &core.Route{
591+
Method: rt.Method,
592+
Pattern: pathPrefix + rt.Pattern,
593+
ChiPattern: pathPrefix + rt.ChiPattern,
594+
Handler: rt.Handler,
595+
Name: namePrefix + rt.Name,
596+
Action: rt.Action,
597+
QueryMatchers: rt.QueryMatchers,
598+
Middlewares: rt.Middlewares,
599+
WildcardParams: rt.WildcardParams,
600+
OriginalRoute: rt, // Link to original for name updates
601+
}
602+
*r.routes = append(*r.routes, newRoute)
603+
604+
// Update name index for URL generation
605+
if newRoute.Name != "" {
606+
r.nameIndex[newRoute.Name] = newRoute
607+
}
608+
609+
// Propagate further up if this router also has parents
610+
for _, p := range r.parents {
611+
p.router.propagateRoute(p.pathPrefix, p.namePrefix, newRoute)
612+
}
613+
}
614+
615+
func (r *Router) propagateRouteName(namePrefix string, originalRt *core.Route) {
616+
for _, rt := range *r.routes {
617+
if rt.OriginalRoute == originalRt {
618+
rt.Name = namePrefix + originalRt.Name
619+
if rt.Name != "" {
620+
r.nameIndex[rt.Name] = rt
488621
}
489-
*r.routes = append(*r.routes, newRoute)
490622

491-
// Update name index for URL generation
492-
if newRoute.Name != "" {
493-
r.nameIndex[newRoute.Name] = newRoute
623+
// Propagate further up
624+
for _, p := range r.parents {
625+
p.router.propagateRouteName(p.namePrefix, rt)
626+
}
627+
return
628+
}
629+
}
630+
}
631+
632+
func (r *Router) propagateRouteNames(oldPrefix, newPrefix string, subRouter *Router) {
633+
for _, rt := range *r.routes {
634+
if rt.OriginalRoute != nil && strings.HasPrefix(rt.Name, oldPrefix) {
635+
// Check if this route actually belongs to the subRouter tree
636+
if r.belongsTo(rt, subRouter) {
637+
// Remove old name from index
638+
if rt.Name != "" {
639+
delete(r.nameIndex, rt.Name)
640+
}
641+
642+
// Update name
643+
suffix := strings.TrimPrefix(rt.Name, oldPrefix)
644+
rt.Name = newPrefix + suffix
645+
646+
// Re-add to index
647+
if rt.Name != "" {
648+
r.nameIndex[rt.Name] = rt
649+
}
650+
651+
// Propagate further up
652+
for _, p := range r.parents {
653+
p.router.propagateRouteNames(p.namePrefix+oldPrefix, p.namePrefix+newPrefix, subRouter)
654+
}
655+
}
656+
}
657+
}
658+
}
659+
660+
func (r *Router) belongsTo(rt *core.Route, subRouter *Router) bool {
661+
// Follow OriginalRoute chain to see if it leads to one of subRouter's routes
662+
curr := rt.OriginalRoute
663+
for curr != nil {
664+
for _, subRt := range *subRouter.routes {
665+
if curr == subRt {
666+
return true
494667
}
495668
}
669+
curr = curr.OriginalRoute
496670
}
671+
return false
497672
}
498673

499674
// SubRouter creates a new child router whose routes are automatically

tests/exact_match_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,12 @@ func TestExactMatch(t *testing.T) {
1616

1717
r.GET("/{$}", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
1818
w.WriteHeader(http.StatusOK)
19-
w.Write([]byte("root"))
19+
_, _ = w.Write([]byte("root"))
2020
}))
2121

2222
r.GET("/{path}", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
2323
w.WriteHeader(http.StatusOK)
24-
w.Write([]byte("child"))
24+
_, _ = w.Write([]byte("child"))
2525
}))
2626

2727
t.Run("root match", func(t *testing.T) {
@@ -57,11 +57,11 @@ func TestExactMatchInGroups(t *testing.T) {
5757
r.Group("/admin", func(r *teapot.Router) {
5858
r.GET("/{$}", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
5959
w.WriteHeader(http.StatusOK)
60-
w.Write([]byte("admin-root"))
60+
_, _ = w.Write([]byte("admin-root"))
6161
}))
6262
r.GET("/{path}", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
6363
w.WriteHeader(http.StatusOK)
64-
w.Write([]byte("admin-child"))
64+
_, _ = w.Write([]byte("admin-child"))
6565
}))
6666
})
6767

0 commit comments

Comments
 (0)