Skip to content

Commit 930d6f5

Browse files
committed
feat: add new ways to work with multiple teapot routers
1 parent 10bd51a commit 930d6f5

7 files changed

Lines changed: 513 additions & 36 deletions

File tree

docs/ROUTES-LISTING.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,22 @@ http.ListenAndServe(":8080", router)
2424

2525
Visit `http://localhost:8080/.internal/routes` to see all routes.
2626

27+
### Aggregated Route List
28+
29+
If you have multiple routers (e.g., an app router on port 80 and an admin router on port 8080), you can merge their routes for a unified listing:
30+
31+
```go
32+
appRouter := setupApp()
33+
adminRouter := setupAdmin()
34+
35+
// Aggregate routes from both
36+
combined := teapot.AggregateRoutes(appRouter, adminRouter)
37+
38+
// Create a handler for the aggregated routes
39+
handler := teapot.NewListRoutesHandlerWithRoutes(combined, nil)
40+
appRouter.GET("/.internal/routes", handler).Name("all.routes")
41+
```
42+
2743
### Response Formats
2844

2945
The debug endpoint supports both JSON and HTML:
@@ -76,6 +92,13 @@ router.RegisterDebugRoute("/.internal/routes", "debug.routes")
7692
router.GET("/.internal/routes", router.RoutesHandler()).
7793
Name("debug.routes").
7894
With(authMiddleware)
95+
96+
// Option 5: Custom filtering
97+
// Only show routes that don't start with /.internal/
98+
filter := func(route teapot.RouteInfo) bool {
99+
return !strings.HasPrefix(route.Pattern, "/.internal/")
100+
}
101+
router.GET("/routes", teapot.NewListRoutesHandler(router, filter))
79102
```
80103

81104
## CLI Route Listing
@@ -195,6 +218,15 @@ routes := router.Routes()
195218
err := teapot.FormatRoutesTable(os.Stdout, routes)
196219
```
197220

221+
### AggregateRoutes
222+
223+
Merge routes from multiple routers for unified output:
224+
225+
```go
226+
allRoutes := teapot.AggregateRoutes(mainRouter, adminRouter, internalRouter)
227+
err := teapot.FormatRoutesTable(os.Stdout, allRoutes)
228+
```
229+
198230
### FormatRoutesJSON
199231

200232
JSON output with count and sorted routes:

docs/USAGE.md

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -160,27 +160,91 @@ key := teapot.URLParam(r, "key")
160160

161161
---
162162

163-
## Route Groups
163+
## Route Groups and Sub-Routers
164+
165+
### Path and Name Groups
164166

165167
Group routes with path and name prefixes:
166168

167169
```go
168170
// Path prefix only
169171
r.Group("/api/v1", func (r *teapot.Router) {
170-
r.GET("/users", listUsers).Name("users.list")
172+
r.GET("/users", listUsers).Name("users.list")
171173
})
172174

173175
// Path + name prefix
174176
r.NamedGroup("/{bucket}", "bucket", func (r *teapot.Router) {
175-
r.GET("", listObjects).Name("list") // name: "bucket.list"
176-
r.GET("", getBucketAcl).Name("acl").Query("acl") // name: "bucket.acl"
177+
r.GET("", listObjects).Name("list") // name: "bucket.list"
178+
r.GET("", getBucketAcl).Name("acl").Query("acl") // name: "bucket.acl"
177179

178-
r.NamedGroup("/{key:.*}", "object", func (r *teapot.Router) {
179-
r.GET("", getObject).Name("get") // name: "bucket.object.get"
180-
})
180+
r.NamedGroup("/{key:.*}", "object", func (r *teapot.Router) {
181+
r.GET("", getObject).Name("get") // name: "bucket.object.get"
182+
})
181183
})
182184
```
183185

186+
### Sub-Routers (Live Propagation)
187+
188+
`SubRouter(prefix)` creates a new child router whose routes are automatically visible in the parent router with the prefix prepended.
189+
190+
Unlike `Group()`, `SubRouter()` returns a separate `Router` instance that can be used independently (e.g., as its own HTTP server) while still reporting its routes to the parent for unified listing and URL generation.
191+
192+
```go
193+
adminRouter := r.SubRouter("/admin")
194+
adminRouter.GET("/dashboard", dashHandler).Name("dashboard")
195+
// "dashboard" route is now visible in both adminRouter and r (as "/admin/dashboard")
196+
```
197+
198+
---
199+
200+
## Mounting Handlers
201+
202+
### Mounting standard http.Handlers
203+
204+
Use `Mount(prefix, handler)` to attach any `http.Handler` to a path prefix:
205+
206+
```go
207+
r.Mount("/static", http.FileServer(http.Dir("./static")))
208+
```
209+
210+
### Mounting teapot Routers (Route Propagation)
211+
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.
213+
214+
```go
215+
apiRouter := teapot.New()
216+
apiRouter.GET("/users", listUsers).Name("users.list")
217+
218+
// Mount propagates all apiRouter's current routes to r
219+
r.Mount("/api/v1", apiRouter)
220+
// r now knows about "/api/v1/users" with name "users.list"
221+
```
222+
223+
---
224+
225+
## Direct Handler Registration
226+
227+
Assign standard `http.Handler` (including `http.HandlerFunc` via `http.HandlerFunc(fn)`) to a method and pattern:
228+
229+
```go
230+
r.Handle("GET", "/health", healthHandler).Name("health")
231+
```
232+
233+
This is a more flexible alternative to `GET()`, `POST()`, etc. when you already have an `http.Handler` instance.
234+
235+
---
236+
237+
## Phantom Routes (External Services)
238+
239+
Use `RegisterExternal(method, pattern, name, action)` to add "phantom" routes to the router. These routes appear in documentation (route lists) and work with the URL builder, but do not dispatch requests locally. This is useful for documenting external services or third-party handlers that you cannot convert to teapot.
240+
241+
```go
242+
r.RegisterExternal("GET", "https://auth.example.com/login", "auth.login", "auth:Login")
243+
244+
// Use for URL generation:
245+
loginURL := r.MustURL("auth.login") // "https://auth.example.com/login"
246+
```
247+
184248
---
185249

186250
## Middleware

internal/core/route.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,23 @@ func TranslatePattern(pattern string) (string, map[string]bool) {
5858
break // Only one wildcard per pattern is supported by Chi
5959
}
6060

61+
// Check for Go 1.22+ exact match operator {$}
62+
if paramDef == "$" {
63+
// Chi handles exact matches with the standard trailing slash / pattern.
64+
// We translate /{ $ } to / so that Chi routes correctly.
65+
// Example: /users/{$} becomes /users/
66+
prefix := result[:idx]
67+
suffix := result[endIdx+1:]
68+
69+
// If we have something like /admin/{$}, we want it to be /admin/
70+
// If it's just /{$}, it becomes /
71+
result = prefix + suffix
72+
73+
// Don't advance start, re-scan from where suffix begins
74+
start = idx
75+
continue
76+
}
77+
6178
start = endIdx + 1
6279
}
6380

pkg/teapot/router.go

Lines changed: 130 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,33 @@ func (r *Router) QueryOPTIONS(pattern string, handler http.Handler) *RouteBuilde
212212
return r.handleQuery("OPTIONS", pattern, handler)
213213
}
214214

215+
// Handle registers an arbitrary http.Handler with a pattern and method
216+
// Supports both direct registration and query-based multiplexing via RouteBuilder
217+
func (r *Router) Handle(method, pattern string, handler http.Handler) *RouteBuilder {
218+
return r.handleDirect(method, pattern, handler)
219+
}
220+
221+
// RegisterExternal adds a "phantom" route to the router.
222+
// Phantom routes are only for documentation and do not dispatch requests.
223+
// They appear in the Routes() listing and can be used for URL generation.
224+
func (r *Router) RegisterExternal(method, pattern, name, action string) {
225+
// Create a dummy route metadata
226+
rt := &core.Route{
227+
Method: method,
228+
Pattern: r.pathPrefix + pattern,
229+
Name: r.namePrefix + name,
230+
Action: action,
231+
}
232+
233+
// Add to routes list for listing
234+
*r.routes = append(*r.routes, rt)
235+
236+
// Add to name index for URL generation
237+
if rt.Name != "" {
238+
r.nameIndex[rt.Name] = rt
239+
}
240+
}
241+
215242
// handleDirect registers a route directly with Chi (no dispatcher, best performance)
216243
// This is used by GET, POST, PUT, DELETE, etc.
217244
// Automatically promotes to dispatcher-based routing if multiple routes exist on same method+pattern
@@ -250,7 +277,7 @@ func (r *Router) handleDirect(method, pattern string, handler http.Handler) *Rou
250277

251278
// Check if another direct route already exists
252279
if existingRoute, exists := r.directRoutes[dispatcherKey]; exists {
253-
r.debugLogf("Auto-promoting to dispatcher: %s %s (multiple routes on same path)", method, fullPattern)
280+
r.debugLogf("Auto-promoting to dispatcher: %s %s (chi: %s) (multiple routes on same path)", method, fullPattern, chiPattern)
254281
// Promote to dispatcher!
255282
disp := &core.Dispatcher{Routes: make([]*core.Route, 0)}
256283
disp.AddRoute(existingRoute) // Old route becomes fallback
@@ -271,6 +298,7 @@ func (r *Router) handleDirect(method, pattern string, handler http.Handler) *Rou
271298
}
272299
*r.optimizedHandlers = append(*r.optimizedHandlers, optHandler)
273300

301+
r.debugLogf("Registering direct route with Chi: %s %s (chi: %s)", method, fullPattern, chiPattern)
274302
r.mux.Method(method, chiPattern, optHandler)
275303
r.directRoutes[dispatcherKey] = rt
276304

@@ -432,6 +460,68 @@ func (r *Router) Use(middlewares ...func(http.Handler) http.Handler) {
432460
r.mux.Use(middlewares...)
433461
}
434462

463+
// Mount attaches another http.Handler (typically a router) to a prefix.
464+
// If the handler is a *teapot.Router, all its routes are propagated to the
465+
// 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,
488+
}
489+
*r.routes = append(*r.routes, newRoute)
490+
491+
// Update name index for URL generation
492+
if newRoute.Name != "" {
493+
r.nameIndex[newRoute.Name] = newRoute
494+
}
495+
}
496+
}
497+
}
498+
499+
// SubRouter creates a new child router whose routes are automatically
500+
// visible in the parent router with the prefix prepended.
501+
//
502+
// Unlike Group(), SubRouter() returns a separate Router instance that
503+
// can be used independently (e.g., as its own HTTP server) but still
504+
// reports its routes to the parent.
505+
func (r *Router) SubRouter(prefix string) *Router {
506+
subRouter := New()
507+
// Create a proxy that notifies the parent when routes are added
508+
// Actually, easier to just wrap the subRouter's routes slice
509+
// but that might be messy with patterns.
510+
511+
// Instead, we can make SubRouter aware of its parent
512+
// or just rely on the fact that SubRouter is empty now,
513+
// and we want it to propagate FUTURE routes too.
514+
515+
// Let's change the approach for SubRouter to support live propagation.
516+
subRouter.routes = r.routes
517+
subRouter.nameIndex = r.nameIndex
518+
subRouter.pathPrefix = r.pathPrefix + prefix
519+
subRouter.namePrefix = r.namePrefix // SubRouter usually doesn't prefix names unless told
520+
521+
r.mux.Mount(prefix, subRouter)
522+
return subRouter
523+
}
524+
435525
// findMatchingRoute manually matches a request against registered routes
436526
// This is used as a fallback when Chi's RouteContext isn't available (e.g., in global middleware)
437527
func (r *Router) findMatchingRoute(method, path string) *core.Route {
@@ -576,6 +666,9 @@ func (r *Router) URL(name string, params ...string) (string, error) {
576666
url = strings.ReplaceAll(url, "{"+key+":.*}", value)
577667
}
578668

669+
// Remove Go 1.22+ exact match operator {$} for URL generation
670+
url = strings.ReplaceAll(url, "{$}", "")
671+
579672
// Check if any parameters remain unreplaced
580673
if strings.Contains(url, "{") {
581674
return "", fmt.Errorf("missing parameters for route %q", name)
@@ -625,31 +718,45 @@ type HeaderParam struct {
625718
func (r *Router) Routes() []RouteInfo {
626719
var infos []RouteInfo
627720
for _, rt := range *r.routes {
628-
var queryParams []QueryParam
629-
var headerParams []HeaderParam
630-
for _, matcher := range rt.QueryMatchers {
631-
switch m := matcher.(type) {
632-
case dispatch.QueryExistsMatcher:
633-
queryParams = append(queryParams, QueryParam{Key: m.Key})
634-
case dispatch.QueryValueMatcher:
635-
queryParams = append(queryParams, QueryParam{Key: m.Key, Value: m.Value})
636-
case dispatch.HeaderExistsMatcher:
637-
headerParams = append(headerParams, HeaderParam{Key: m.Key})
638-
case dispatch.HeaderValueMatcher:
639-
headerParams = append(headerParams, HeaderParam{Key: m.Key, Value: m.Value})
640-
}
721+
infos = append(infos, transformRoute(rt))
722+
}
723+
return infos
724+
}
725+
726+
// AggregateRoutes merges routes from multiple routers into a single slice.
727+
// This is useful for unified route listings across routers on different ports.
728+
func AggregateRoutes(routers ...*Router) []RouteInfo {
729+
var allRoutes []RouteInfo
730+
for _, r := range routers {
731+
allRoutes = append(allRoutes, r.Routes()...)
732+
}
733+
return allRoutes
734+
}
735+
736+
func transformRoute(rt *core.Route) RouteInfo {
737+
var queryParams []QueryParam
738+
var headerParams []HeaderParam
739+
for _, matcher := range rt.QueryMatchers {
740+
switch m := matcher.(type) {
741+
case dispatch.QueryExistsMatcher:
742+
queryParams = append(queryParams, QueryParam{Key: m.Key})
743+
case dispatch.QueryValueMatcher:
744+
queryParams = append(queryParams, QueryParam{Key: m.Key, Value: m.Value})
745+
case dispatch.HeaderExistsMatcher:
746+
headerParams = append(headerParams, HeaderParam{Key: m.Key})
747+
case dispatch.HeaderValueMatcher:
748+
headerParams = append(headerParams, HeaderParam{Key: m.Key, Value: m.Value})
641749
}
750+
}
642751

643-
infos = append(infos, RouteInfo{
644-
Method: rt.Method,
645-
Pattern: rt.Pattern,
646-
Name: rt.Name,
647-
Action: rt.Action,
648-
QueryParams: queryParams,
649-
HeaderParams: headerParams,
650-
})
752+
return RouteInfo{
753+
Method: rt.Method,
754+
Pattern: rt.Pattern,
755+
Name: rt.Name,
756+
Action: rt.Action,
757+
QueryParams: queryParams,
758+
HeaderParams: headerParams,
651759
}
652-
return infos
653760
}
654761

655762
// GetAction retrieves the S3 action from the request context

0 commit comments

Comments
 (0)