@@ -3,6 +3,7 @@ package teapot
33import (
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.
0 commit comments