Skip to content

Commit cf6df9b

Browse files
committed
refactor: replace ListRoutesHandler with NewListRoutesHandler, add filtering and improve test coverage
1 parent 9fefad8 commit cf6df9b

5 files changed

Lines changed: 169 additions & 137 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
**/*.out
12
**/coverage.*
23
**/*.exe

examples/routes-cli/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ func setupRoutes() *teapot.Router {
134134
// ==================== DEBUG ROUTES ====================
135135
// Debug route (conditionally registered)
136136
if isDebug() {
137-
router.RegisterDebugRoute("/.internal/routes", "debug.routes")
137+
router.GET("/.internal/routes", teapot.NewListRoutesHandler(router, nil)).Name("debug.routes")
138138
}
139139

140140
router.GET("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {}).Name("favicon")

pkg/teapot/router.go

Lines changed: 0 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package teapot
22

33
import (
4-
"encoding/json"
54
"fmt"
65
"log"
76
"net/http"
@@ -613,116 +612,6 @@ func (r *Router) Routes() []RouteInfo {
613612
return infos
614613
}
615614

616-
// ListRoutesHandler returns an HTTP handler that displays all registered routes.
617-
// The handler responds with JSON or HTML based on the Accept header.
618-
//
619-
// Example:
620-
//
621-
// if debug {
622-
// r.GET("/.internal/routes", r.ListRoutesHandler()).Name("debug.routes")
623-
// }
624-
func (r *Router) ListRoutesHandler() http.HandlerFunc {
625-
return func(w http.ResponseWriter, req *http.Request) {
626-
routes := r.Routes()
627-
628-
// Check Accept header for JSON vs HTML
629-
accept := req.Header.Get("Accept")
630-
if strings.Contains(accept, "application/json") {
631-
w.Header().Set("Content-Type", "application/json")
632-
err := json.NewEncoder(w).Encode(map[string]any{
633-
"count": len(routes),
634-
"routes": routes,
635-
})
636-
if err != nil {
637-
log.Printf("[teapot-router] failed to encode routes as JSON: %v", err)
638-
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
639-
return
640-
}
641-
return
642-
}
643-
644-
// Default to HTML
645-
w.Header().Set("Content-Type", "text/html; charset=utf-8")
646-
_, _ = fmt.Fprintf(w, `<!DOCTYPE html>
647-
<html>
648-
<head>
649-
<meta charset="UTF-8">
650-
<title>Routes</title>
651-
<style>
652-
body { font-family: system-ui, -apple-system, sans-serif; margin: 2rem; }
653-
h1 { color: #333; }
654-
table { border-collapse: collapse; width: 100%%; margin-top: 1rem; }
655-
th, td { text-align: left; padding: 0.75rem; border-bottom: 1px solid #ddd; }
656-
th { background-color: #f5f5f5; font-weight: 600; }
657-
tr:hover { background-color: #f9f9f9; }
658-
.method { font-family: monospace; font-weight: 600; }
659-
.pattern { font-family: monospace; color: #0066cc; }
660-
.name { color: #666; }
661-
.action { color: #888; font-size: 0.9em; }
662-
.count { color: #666; font-size: 0.9em; }
663-
.get { color: #28a745; }
664-
.post { color: #007bff; }
665-
.put { color: #ffc107; }
666-
.delete { color: #dc3545; }
667-
.head { color: #6c757d; }
668-
.patch { color: #17a2b8; }
669-
.options { color: #6610f2; }
670-
</style>
671-
</head>
672-
<body>
673-
<h1>Registered Routes</h1>
674-
<p class="count">Total: %d routes</p>
675-
<table>
676-
<thead>
677-
<tr>
678-
<th>Method</th>
679-
<th>Pattern</th>
680-
<th>Name</th>
681-
<th>Action</th>
682-
</tr>
683-
</thead>
684-
<tbody>
685-
`, len(routes))
686-
687-
for _, route := range routes {
688-
methodClass := strings.ToLower(route.Method)
689-
name := route.Name
690-
if name == "" {
691-
name = "-"
692-
}
693-
action := route.Action
694-
if action == "" {
695-
action = "-"
696-
}
697-
698-
_, _ = fmt.Fprintf(w, ` <tr>
699-
<td class="method %s">%s</td>
700-
<td class="pattern">%s</td>
701-
<td class="name">%s</td>
702-
<td class="action">%s</td>
703-
</tr>
704-
`, methodClass, route.Method, route.Pattern, name, action)
705-
}
706-
707-
_, _ = fmt.Fprintf(w, ` </tbody>
708-
</table>
709-
</body>
710-
</html>`)
711-
}
712-
}
713-
714-
// RegisterDebugRoute is a convenience method to register a debug endpoint that shows all routes.
715-
// This is useful for development and debugging.
716-
//
717-
// Example:
718-
//
719-
// if debug {
720-
// r.RegisterDebugRoute("/.internal/routes", "debug.routes")
721-
// }
722-
func (r *Router) RegisterDebugRoute(path, name string) *RouteBuilder {
723-
return r.GET(path, r.ListRoutesHandler()).Name(name)
724-
}
725-
726615
// GetAction retrieves the S3 action from the request context
727616
func GetAction(r *http.Request) string {
728617
return core.GetAction(r.Context())

pkg/teapot/routes_helpers.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,137 @@ import (
44
"encoding/json"
55
"fmt"
66
"io"
7+
"log"
8+
"net/http"
79
"sort"
10+
"strings"
811
"text/tabwriter"
912
)
1013

14+
// RouteFilter is a predicate function that determines whether a route
15+
// should be included in output. Return true to include the route.
16+
type RouteFilter func(RouteInfo) bool
17+
18+
// FilterRoutes applies a filter to a slice of routes, returning only
19+
// those for which the filter returns true. If filter is nil, all routes
20+
// are returned unchanged.
21+
func FilterRoutes(routes []RouteInfo, filter RouteFilter) []RouteInfo {
22+
if filter == nil {
23+
return routes
24+
}
25+
filtered := make([]RouteInfo, 0, len(routes))
26+
for _, route := range routes {
27+
if filter(route) {
28+
filtered = append(filtered, route)
29+
}
30+
}
31+
return filtered
32+
}
33+
34+
// NewListRoutesHandler returns an HTTP handler that displays registered routes.
35+
// The handler responds with JSON or HTML based on the Accept header.
36+
// If filter is non-nil, only routes for which filter returns true are included.
37+
// Pass nil to show all routes.
38+
//
39+
// Example:
40+
//
41+
// // Show all routes
42+
// r.GET("/.internal/routes", teapot.NewListRoutesHandler(router, nil))
43+
//
44+
// // Exclude internal routes
45+
// r.GET("/.internal/routes", teapot.NewListRoutesHandler(router, func(route teapot.RouteInfo) bool {
46+
// return !strings.HasPrefix(route.Pattern, "/.internal/")
47+
// }))
48+
func NewListRoutesHandler(router *Router, filter RouteFilter) http.HandlerFunc {
49+
return func(w http.ResponseWriter, req *http.Request) {
50+
routes := FilterRoutes(router.Routes(), filter)
51+
52+
// Check Accept header for JSON vs HTML
53+
accept := req.Header.Get("Accept")
54+
if strings.Contains(accept, "application/json") {
55+
w.Header().Set("Content-Type", "application/json")
56+
err := json.NewEncoder(w).Encode(map[string]any{
57+
"count": len(routes),
58+
"routes": routes,
59+
})
60+
if err != nil {
61+
log.Printf("[teapot-router] failed to encode routes as JSON: %v", err)
62+
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
63+
return
64+
}
65+
return
66+
}
67+
68+
// Default to HTML
69+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
70+
_, _ = fmt.Fprintf(w, `<!DOCTYPE html>
71+
<html>
72+
<head>
73+
<meta charset="UTF-8">
74+
<title>Routes</title>
75+
<style>
76+
body { font-family: system-ui, -apple-system, sans-serif; margin: 2rem; }
77+
h1 { color: #333; }
78+
table { border-collapse: collapse; width: 100%%; margin-top: 1rem; }
79+
th, td { text-align: left; padding: 0.75rem; border-bottom: 1px solid #ddd; }
80+
th { background-color: #f5f5f5; font-weight: 600; }
81+
tr:hover { background-color: #f9f9f9; }
82+
.method { font-family: monospace; font-weight: 600; }
83+
.pattern { font-family: monospace; color: #0066cc; }
84+
.name { color: #666; }
85+
.action { color: #888; font-size: 0.9em; }
86+
.count { color: #666; font-size: 0.9em; }
87+
.get { color: #28a745; }
88+
.post { color: #007bff; }
89+
.put { color: #ffc107; }
90+
.delete { color: #dc3545; }
91+
.head { color: #6c757d; }
92+
.patch { color: #17a2b8; }
93+
.options { color: #6610f2; }
94+
</style>
95+
</head>
96+
<body>
97+
<h1>Registered Routes</h1>
98+
<p class="count">Total: %d routes</p>
99+
<table>
100+
<thead>
101+
<tr>
102+
<th>Method</th>
103+
<th>Pattern</th>
104+
<th>Name</th>
105+
<th>Action</th>
106+
</tr>
107+
</thead>
108+
<tbody>
109+
`, len(routes))
110+
111+
for _, route := range routes {
112+
methodClass := strings.ToLower(route.Method)
113+
name := route.Name
114+
if name == "" {
115+
name = "-"
116+
}
117+
action := route.Action
118+
if action == "" {
119+
action = "-"
120+
}
121+
122+
_, _ = fmt.Fprintf(w, ` <tr>
123+
<td class="method %s">%s</td>
124+
<td class="pattern">%s</td>
125+
<td class="name">%s</td>
126+
<td class="action">%s</td>
127+
</tr>
128+
`, methodClass, route.Method, route.Pattern, name, action)
129+
}
130+
131+
_, _ = fmt.Fprintf(w, ` </tbody>
132+
</table>
133+
</body>
134+
</html>`)
135+
}
136+
}
137+
11138
// FormatRoutesJSON writes routes as JSON to the writer.
12139
// This is useful for CLI commands with --json flag.
13140
//

pkg/teapot/routes_test.go

Lines changed: 40 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ import (
1212
"github.com/mallardduck/teapot-router/pkg/teapot"
1313
)
1414

15-
// TestRoutesHandler verifies the HTTP routes handler returns JSON
16-
func TestRoutesHandler(t *testing.T) {
15+
// TestNewListRoutesHandler verifies the HTTP routes handler returns JSON
16+
func TestNewListRoutesHandler(t *testing.T) {
1717
r := teapot.New()
1818

1919
// Register some routes
@@ -22,8 +22,8 @@ func TestRoutesHandler(t *testing.T) {
2222
r.GET("/users/{id}", dummyHandler).Name("users.show").Action("s3:GetObject")
2323
r.DELETE("/users/{id}", dummyHandler).Name("users.destroy")
2424

25-
// Get the handler
26-
handler := r.ListRoutesHandler()
25+
// Get the handler (no filter - show all)
26+
handler := teapot.NewListRoutesHandler(r, nil)
2727

2828
// Test JSON response (with Accept header)
2929
req := httptest.NewRequest("GET", "/.internal/routes", nil)
@@ -58,14 +58,14 @@ func TestRoutesHandler(t *testing.T) {
5858
}
5959
}
6060

61-
// TestRoutesHandlerHTML verifies HTML output for browsers
62-
func TestRoutesHandlerHTML(t *testing.T) {
61+
// TestNewListRoutesHandlerHTML verifies HTML output for browsers
62+
func TestNewListRoutesHandlerHTML(t *testing.T) {
6363
r := teapot.New()
6464

6565
r.GET("/users", dummyHandler).Name("users.index")
6666
r.GET("/posts", dummyHandler).Name("posts.index")
6767

68-
handler := r.ListRoutesHandler()
68+
handler := teapot.NewListRoutesHandler(r, nil)
6969

7070
// Test HTML response (no Accept header defaults to HTML)
7171
req := httptest.NewRequest("GET", "/.internal/routes", nil)
@@ -90,18 +90,15 @@ func TestRoutesHandlerHTML(t *testing.T) {
9090
}
9191
}
9292

93-
// TestRegisterDebugRoute verifies convenience method
94-
func TestRegisterDebugRoute(t *testing.T) {
93+
// TestNewListRoutesHandlerAsRoute verifies wiring NewListRoutesHandler into a route
94+
func TestNewListRoutesHandlerAsRoute(t *testing.T) {
9595
r := teapot.New()
9696

97-
// Register some routes
9897
r.GET("/api/users", dummyHandler).Name("api.users")
9998
r.GET("/api/posts", dummyHandler).Name("api.posts")
99+
r.GET("/.internal/routes", teapot.NewListRoutesHandler(r, nil)).Name("debug.routes")
100100

101-
// Register debug route
102-
r.RegisterDebugRoute("/.internal/routes", "debug.routes")
103-
104-
// Test it works
101+
// Test it works via ServeHTTP
105102
req := httptest.NewRequest("GET", "/.internal/routes", nil)
106103
req.Header.Set("Accept", "application/json")
107104
w := httptest.NewRecorder()
@@ -274,25 +271,43 @@ func TestRoutesSorting(t *testing.T) {
274271
}
275272
}
276273

277-
// TestConditionalDebugRoute demonstrates conditional registration
278-
func TestConditionalDebugRoute(t *testing.T) {
279-
debug := true // In real app, this would be from config/env
280-
274+
// TestNewListRoutesHandlerWithFilter verifies the filter excludes routes
275+
func TestNewListRoutesHandlerWithFilter(t *testing.T) {
281276
r := teapot.New()
282-
r.GET("/api/users", dummyHandler).Name("api.users")
283277

284-
// Conditionally register debug route
285-
if debug {
286-
r.RegisterDebugRoute("/.internal/routes", "debug.routes")
287-
}
278+
r.GET("/api/users", dummyHandler).Name("api.users")
279+
r.GET("/api/posts", dummyHandler).Name("api.posts")
280+
r.GET("/.internal/routes", teapot.NewListRoutesHandler(r, func(route teapot.RouteInfo) bool {
281+
return !strings.HasPrefix(route.Pattern, "/.internal/")
282+
})).Name("debug.routes")
288283

289-
// Verify debug route exists
290284
req := httptest.NewRequest("GET", "/.internal/routes", nil)
291285
req.Header.Set("Accept", "application/json")
292286
w := httptest.NewRecorder()
293287
r.ServeHTTP(w, req)
294288

295289
if w.Code != 200 {
296-
t.Errorf("expected debug route to work, got status %d", w.Code)
290+
t.Errorf("expected 200, got %d", w.Code)
291+
}
292+
293+
var response map[string]any
294+
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
295+
t.Fatalf("failed to decode JSON: %v", err)
296+
}
297+
298+
// Should have 2 routes - the /.internal/routes route is filtered out
299+
count, ok := response["count"].(float64)
300+
if !ok || count != 2 {
301+
t.Errorf("expected count=2, got %v", response["count"])
302+
}
303+
304+
// Verify no internal routes leaked through
305+
routes, _ := response["routes"].([]any)
306+
for _, route := range routes {
307+
r, _ := route.(map[string]any)
308+
pattern, _ := r["Pattern"].(string)
309+
if strings.HasPrefix(pattern, "/.internal/") {
310+
t.Errorf("internal route %q should have been filtered out", pattern)
311+
}
297312
}
298313
}

0 commit comments

Comments
 (0)