Skip to content

Commit 2b76d3b

Browse files
committed
refactor: introduce ListRoutesOptions for better flexibility in NewListRoutesHandler
1 parent fd5ddc1 commit 2b76d3b

4 files changed

Lines changed: 178 additions & 84 deletions

File tree

examples/routes-cli/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ func setupRoutes() *teapot.Router {
184184
// ==================== DEBUG ROUTES ====================
185185
// Debug route (conditionally registered)
186186
if isDebug() {
187-
router.GET("/.internal/routes", teapot.NewListRoutesHandler(router, nil)).Name("debug.routes")
187+
router.GET("/.internal/routes", teapot.NewListRoutesHandler(router, teapot.ListRoutesOptions{})).Name("debug.routes")
188188
}
189189

190190
// TODO add a handler to output the ico file

pkg/teapot/internal_coverage_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ func TestNewListRoutesHandlerHTMLWithHeaders(t *testing.T) {
339339
d.When(m.HeaderEquals("X-Copy", "source")).FuncDo(testutil.NoopResponse).Name("hht.copy")
340340
})
341341

342-
handler := NewListRoutesHandler(r, nil)
342+
handler := NewListRoutesHandler(r, ListRoutesOptions{})
343343

344344
req := httptest.NewRequest("GET", "/.internal/routes", nil)
345345
w := httptest.NewRecorder()

pkg/teapot/routes_helpers.go

Lines changed: 115 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package teapot
33
import (
44
"encoding/json"
55
"fmt"
6+
"html"
67
"io"
78
"log"
89
"net/http"
@@ -31,74 +32,98 @@ func FilterRoutes(routes []RouteInfo, filter RouteFilter) []RouteInfo {
3132
return filtered
3233
}
3334

35+
// ListRoutesOptions controls the behaviour of [NewListRoutesHandler] and
36+
// [NewListRoutesHandlerWithRoutes].
37+
type ListRoutesOptions struct {
38+
// Filter, when non-nil, is applied to each route; only routes for which it
39+
// returns true are included in the output.
40+
Filter RouteFilter
41+
42+
// BaseURL, when non-empty, is prepended to each route pattern in the HTML
43+
// output to generate a clickable link. Only patterns without path
44+
// parameters (i.e. no "{…}" segments) are linked, since those are directly
45+
// navigable in a browser.
46+
//
47+
// Example: "https://myapp.example.com" or "http://localhost:8080"
48+
BaseURL string
49+
}
50+
3451
// NewListRoutesHandler returns an HTTP handler that displays registered routes.
3552
// 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.
3853
//
3954
// Example:
4055
//
4156
// // Show all routes
42-
// r.GET("/.internal/routes", teapot.NewListRoutesHandler(router, nil))
57+
// r.GET("/.internal/routes", teapot.NewListRoutesHandler(router, teapot.ListRoutesOptions{}))
4358
//
4459
// // Exclude internal routes
45-
// r.GET("/.internal/routes", teapot.NewListRoutesHandler(router, func(route teapot.RouteInfo) bool {
46-
// return !strings.HasPrefix(route.Pattern, "/.internal/")
60+
// r.GET("/.internal/routes", teapot.NewListRoutesHandler(router, teapot.ListRoutesOptions{
61+
// Filter: func(route teapot.RouteInfo) bool {
62+
// return !strings.HasPrefix(route.Pattern, "/.internal/")
63+
// },
4764
// }))
4865
//
49-
// NewListRoutesHandler returns an HTTP handler that displays registered routes.
50-
// The handler responds with JSON or HTML based on the Accept header.
51-
// If filter is non-nil, only routes for which filter returns true are included.
52-
// Pass nil to show all routes.
66+
// // With clickable links in the HTML output
67+
// r.GET("/.internal/routes", teapot.NewListRoutesHandler(router, teapot.ListRoutesOptions{
68+
// BaseURL: "http://localhost:8080",
69+
// }))
5370
//
5471
// If you want to merge routes from multiple routers, use [AggregateRoutes] and
5572
// then [NewListRoutesHandlerWithRoutes].
56-
func NewListRoutesHandler(router *Router, filter RouteFilter) http.HandlerFunc {
73+
func NewListRoutesHandler(router *Router, opts ListRoutesOptions) http.HandlerFunc {
5774
return func(w http.ResponseWriter, req *http.Request) {
58-
NewListRoutesHandlerWithRoutes(router.Routes(), filter)(w, req)
75+
NewListRoutesHandlerWithRoutes(router.Routes(), opts)(w, req)
5976
}
6077
}
6178

62-
// NewListRoutesHandlerWithRoutes returns an HTTP handler that displays the provided routes.
63-
func NewListRoutesHandlerWithRoutes(routes []RouteInfo, filter RouteFilter) http.HandlerFunc {
79+
// NewListRoutesHandlerWithRoutes returns an HTTP handler that displays the
80+
// provided routes slice. This is useful when combining routes from multiple
81+
// routers via [AggregateRoutes].
82+
func NewListRoutesHandlerWithRoutes(routes []RouteInfo, opts ListRoutesOptions) http.HandlerFunc {
6483
return func(w http.ResponseWriter, req *http.Request) {
65-
filteredRoutes := FilterRoutes(routes, filter)
66-
67-
// Check Accept header for JSON vs HTML
68-
accept := req.Header.Get("Accept")
69-
if strings.Contains(accept, "application/json") {
70-
w.Header().Set("Content-Type", "application/json")
71-
err := json.NewEncoder(w).Encode(map[string]any{
72-
"count": len(filteredRoutes),
73-
"routes": filteredRoutes,
74-
})
75-
if err != nil {
76-
log.Printf("[teapot-router] failed to encode routes as JSON: %v", err)
77-
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
78-
return
79-
}
84+
filteredRoutes := FilterRoutes(routes, opts.Filter)
85+
renderListRoutes(w, req, filteredRoutes, opts.BaseURL)
86+
}
87+
}
88+
89+
func renderListRoutes(w http.ResponseWriter, req *http.Request, filteredRoutes []RouteInfo, baseURL string) {
90+
// Check Accept header for JSON vs HTML
91+
accept := req.Header.Get("Accept")
92+
if strings.Contains(accept, "application/json") {
93+
w.Header().Set("Content-Type", "application/json")
94+
err := json.NewEncoder(w).Encode(map[string]any{
95+
"count": len(filteredRoutes),
96+
"routes": filteredRoutes,
97+
})
98+
if err != nil {
99+
log.Printf("[teapot-router] failed to encode routes as JSON: %v", err)
100+
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
80101
return
81102
}
103+
return
104+
}
82105

83-
// Default to HTML
84-
w.Header().Set("Content-Type", "text/html; charset=utf-8")
85-
86-
// Only show Query/Headers columns when at least one route uses them
87-
hasQuery := false
88-
hasHeaders := false
89-
for _, rt := range filteredRoutes {
90-
if len(rt.QueryParams) > 0 {
91-
hasQuery = true
92-
}
93-
if len(rt.HeaderParams) > 0 {
94-
hasHeaders = true
95-
}
96-
if hasQuery && hasHeaders {
97-
break
98-
}
106+
// Default to HTML
107+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
108+
109+
// Only show Query/Headers columns when at least one route uses them
110+
hasQuery := false
111+
hasHeaders := false
112+
for _, rt := range filteredRoutes {
113+
if len(rt.QueryParams) > 0 {
114+
hasQuery = true
115+
}
116+
if len(rt.HeaderParams) > 0 {
117+
hasHeaders = true
118+
}
119+
if hasQuery && hasHeaders {
120+
break
99121
}
122+
}
123+
124+
base := strings.TrimRight(baseURL, "/")
100125

101-
_, _ = fmt.Fprintf(w, `<!DOCTYPE html>
126+
_, _ = fmt.Fprintf(w, `<!DOCTYPE html>
102127
<html>
103128
<head>
104129
<meta charset="UTF-8">
@@ -112,6 +137,8 @@ func NewListRoutesHandlerWithRoutes(routes []RouteInfo, filter RouteFilter) http
112137
tr:hover { background-color: #f9f9f9; }
113138
.method { font-family: monospace; font-weight: 600; }
114139
.pattern { font-family: monospace; color: #0066cc; }
140+
.pattern a { color: inherit; text-decoration: none; }
141+
.pattern a:hover { text-decoration: underline; }
115142
.query { font-family: monospace; color: #6c757d; }
116143
.name { color: #666; }
117144
.action { color: #888; font-size: 0.9em; }
@@ -134,54 +161,66 @@ func NewListRoutesHandlerWithRoutes(routes []RouteInfo, filter RouteFilter) http
134161
<th>Method</th>
135162
<th>Pattern</th>
136163
`, len(filteredRoutes))
137-
if hasQuery {
138-
_, _ = fmt.Fprint(w, ` <th>Query</th>
164+
if hasQuery {
165+
_, _ = fmt.Fprint(w, ` <th>Query</th>
139166
`)
140-
}
141-
if hasHeaders {
142-
_, _ = fmt.Fprint(w, ` <th>Headers</th>
167+
}
168+
if hasHeaders {
169+
_, _ = fmt.Fprint(w, ` <th>Headers</th>
143170
`)
144-
}
145-
_, _ = fmt.Fprint(w, ` <th>Name</th>
171+
}
172+
_, _ = fmt.Fprint(w, ` <th>Name</th>
146173
<th>Action</th>
147174
</tr>
148175
</thead>
149176
<tbody>
150177
`)
151178

152-
for _, route := range filteredRoutes {
153-
methodClass := strings.ToLower(route.Method)
154-
name := route.Name
155-
if name == "" {
156-
name = "-"
157-
}
158-
action := route.Action
159-
if action == "" {
160-
action = "-"
161-
}
162-
_, _ = fmt.Fprintf(w, ` <tr>
179+
for _, route := range filteredRoutes {
180+
methodClass := strings.ToLower(route.Method)
181+
name := route.Name
182+
if name == "" {
183+
name = "-"
184+
}
185+
action := route.Action
186+
if action == "" {
187+
action = "-"
188+
}
189+
patternCell := patternHTML(route.Pattern, base)
190+
_, _ = fmt.Fprintf(w, ` <tr>
163191
<td class="method %s">%s</td>
164192
<td class="pattern">%s</td>
165-
`, methodClass, route.Method, route.Pattern)
166-
if hasQuery {
167-
_, _ = fmt.Fprintf(w, ` <td class="query">%s</td>
168-
`, formatQueryParams(route.QueryParams))
169-
}
170-
if hasHeaders {
171-
_, _ = fmt.Fprintf(w, ` <td class="query">%s</td>
172-
`, formatHeaderParams(route.HeaderParams))
173-
}
174-
_, _ = fmt.Fprintf(w, ` <td class="name">%s</td>
193+
`, methodClass, html.EscapeString(route.Method), patternCell)
194+
if hasQuery {
195+
_, _ = fmt.Fprintf(w, ` <td class="query">%s</td>
196+
`, html.EscapeString(formatQueryParams(route.QueryParams)))
197+
}
198+
if hasHeaders {
199+
_, _ = fmt.Fprintf(w, ` <td class="query">%s</td>
200+
`, html.EscapeString(formatHeaderParams(route.HeaderParams)))
201+
}
202+
_, _ = fmt.Fprintf(w, ` <td class="name">%s</td>
175203
<td class="action">%s</td>
176204
</tr>
177-
`, name, action)
178-
}
205+
`, html.EscapeString(name), html.EscapeString(action))
206+
}
179207

180-
_, _ = fmt.Fprintf(w, ` </tbody>
208+
_, _ = fmt.Fprintf(w, ` </tbody>
181209
</table>
182210
</body>
183211
</html>`)
212+
}
213+
214+
// patternHTML returns the HTML for a pattern table cell. When base is
215+
// non-empty and the pattern contains no path-parameter segments (i.e. no
216+
// "{…}"), the pattern is wrapped in an anchor tag pointing to base+pattern.
217+
func patternHTML(pattern, base string) string {
218+
escaped := html.EscapeString(pattern)
219+
if base == "" || strings.Contains(pattern, "{") {
220+
return escaped
184221
}
222+
href := html.EscapeString(base + pattern)
223+
return fmt.Sprintf(`<a href="%s">%s</a>`, href, escaped)
185224
}
186225

187226
// FormatRoutesJSON writes routes as JSON to the writer.

pkg/teapot/routes_test.go

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ func TestNewListRoutesHandler(t *testing.T) {
2323
r.DELETE("/users/{id}", dummyHandler).Name("users.destroy")
2424

2525
// Get the handler (no filter - show all)
26-
handler := teapot.NewListRoutesHandler(r, nil)
26+
handler := teapot.NewListRoutesHandler(r, teapot.ListRoutesOptions{})
2727

2828
// Test JSON response (with Accept header)
2929
req := httptest.NewRequest("GET", "/.internal/routes", nil)
@@ -65,7 +65,7 @@ func TestNewListRoutesHandlerHTML(t *testing.T) {
6565
r.GET("/users", dummyHandler).Name("users.index")
6666
r.GET("/posts", dummyHandler).Name("posts.index")
6767

68-
handler := teapot.NewListRoutesHandler(r, nil)
68+
handler := teapot.NewListRoutesHandler(r, teapot.ListRoutesOptions{})
6969

7070
// Test HTML response (no Accept header defaults to HTML)
7171
req := httptest.NewRequest("GET", "/.internal/routes", nil)
@@ -97,7 +97,7 @@ func TestNewListRoutesHandlerHTMLQueryParams(t *testing.T) {
9797
r.QueryGET("/bucket", dummyHandler).Query("acl").Name("bucket.get-acl")
9898
r.QueryGET("/bucket", dummyHandler).QueryValue("list-type", "v2").Name("bucket.list-v2")
9999

100-
handler := teapot.NewListRoutesHandler(r, nil)
100+
handler := teapot.NewListRoutesHandler(r, teapot.ListRoutesOptions{})
101101

102102
req := httptest.NewRequest("GET", "/.internal/routes", nil)
103103
w := httptest.NewRecorder()
@@ -118,7 +118,7 @@ func TestNewListRoutesHandlerAsRoute(t *testing.T) {
118118

119119
r.GET("/api/users", dummyHandler).Name("api.users")
120120
r.GET("/api/posts", dummyHandler).Name("api.posts")
121-
r.GET("/.internal/routes", teapot.NewListRoutesHandler(r, nil)).Name("debug.routes")
121+
r.GET("/.internal/routes", teapot.NewListRoutesHandler(r, teapot.ListRoutesOptions{})).Name("debug.routes")
122122

123123
// Test it works via ServeHTTP
124124
req := httptest.NewRequest("GET", "/.internal/routes", nil)
@@ -142,6 +142,59 @@ func TestNewListRoutesHandlerAsRoute(t *testing.T) {
142142
}
143143
}
144144

145+
// TestNewListRoutesHandlerBaseURL verifies that HTML output contains links
146+
// when BaseURL is set, and plain text for patterns with path parameters.
147+
func TestNewListRoutesHandlerBaseURL(t *testing.T) {
148+
r := teapot.New()
149+
150+
r.GET("/users", dummyHandler).Name("users.index")
151+
r.GET("/users/{id}", dummyHandler).Name("users.show")
152+
r.POST("/users", dummyHandler).Name("users.store")
153+
154+
handler := teapot.NewListRoutesHandler(r, teapot.ListRoutesOptions{
155+
BaseURL: "http://localhost:8080",
156+
})
157+
158+
req := httptest.NewRequest("GET", "/.internal/routes", nil)
159+
w := httptest.NewRecorder()
160+
handler(w, req)
161+
162+
assert.Equal(t, 200, w.Code)
163+
body := w.Body.String()
164+
165+
// Static pattern with no path params → should be a link
166+
assert.Contains(t, body, `<a href="http://localhost:8080/users">/users</a>`)
167+
168+
// Pattern with path param → plain text, no link
169+
assert.NotContains(t, body, `href="http://localhost:8080/users/{id}"`)
170+
assert.Contains(t, body, `/users/{id}`)
171+
172+
// BaseURL with trailing slash should still produce correct URLs
173+
handler2 := teapot.NewListRoutesHandler(r, teapot.ListRoutesOptions{
174+
BaseURL: "http://localhost:8080/",
175+
})
176+
w2 := httptest.NewRecorder()
177+
handler2(w2, httptest.NewRequest("GET", "/.internal/routes", nil))
178+
assert.Contains(t, w2.Body.String(), `href="http://localhost:8080/users"`)
179+
}
180+
181+
// TestNewListRoutesHandlerNoBaseURL verifies no links are rendered when BaseURL is empty.
182+
func TestNewListRoutesHandlerNoBaseURL(t *testing.T) {
183+
r := teapot.New()
184+
r.GET("/users", dummyHandler).Name("users.index")
185+
186+
handler := teapot.NewListRoutesHandler(r, teapot.ListRoutesOptions{})
187+
188+
req := httptest.NewRequest("GET", "/.internal/routes", nil)
189+
w := httptest.NewRecorder()
190+
handler(w, req)
191+
192+
assert.Equal(t, 200, w.Code)
193+
body := w.Body.String()
194+
assert.NotContains(t, body, "<a href=")
195+
assert.Contains(t, body, "/users")
196+
}
197+
145198
// TestFormatRoutesJSON verifies JSON formatting helper
146199
func TestFormatRoutesJSON(t *testing.T) {
147200
r := teapot.New()
@@ -299,8 +352,10 @@ func TestNewListRoutesHandlerWithFilter(t *testing.T) {
299352

300353
r.GET("/api/users", dummyHandler).Name("api.users")
301354
r.GET("/api/posts", dummyHandler).Name("api.posts")
302-
r.GET("/.internal/routes", teapot.NewListRoutesHandler(r, func(route teapot.RouteInfo) bool {
303-
return !strings.HasPrefix(route.Pattern, "/.internal/")
355+
r.GET("/.internal/routes", teapot.NewListRoutesHandler(r, teapot.ListRoutesOptions{
356+
Filter: func(route teapot.RouteInfo) bool {
357+
return !strings.HasPrefix(route.Pattern, "/.internal/")
358+
},
304359
})).Name("debug.routes")
305360

306361
req := httptest.NewRequest("GET", "/.internal/routes", nil)

0 commit comments

Comments
 (0)