Skip to content

Commit a60bbca

Browse files
committed
refactor: migrate routes HTML rendering to Go templates for improved maintainability
1 parent 8565372 commit a60bbca

1 file changed

Lines changed: 99 additions & 82 deletions

File tree

pkg/teapot/routes_helpers.go

Lines changed: 99 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package teapot
33
import (
44
"encoding/json"
55
"fmt"
6-
"html"
6+
"html/template"
77
"io"
88
"log"
99
"net/http"
@@ -125,52 +125,35 @@ func NewListRoutesHandlerWithRoutes(routes []RouteInfo, opts ListRoutesOptions)
125125
}
126126
}
127127

128-
func renderListRoutes(w http.ResponseWriter, req *http.Request, filteredRoutes []RouteInfo, baseURL string) {
129-
// Check Accept header for JSON vs HTML
130-
accept := req.Header.Get("Accept")
131-
if strings.Contains(accept, "application/json") {
132-
w.Header().Set("Content-Type", "application/json")
133-
err := json.NewEncoder(w).Encode(map[string]any{
134-
"count": len(filteredRoutes),
135-
"routes": filteredRoutes,
136-
})
137-
if err != nil {
138-
log.Printf("[teapot-router] failed to encode routes as JSON: %v", err)
139-
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
140-
return
141-
}
142-
return
143-
}
144-
145-
// Default to HTML
146-
w.Header().Set("Content-Type", "text/html; charset=utf-8")
147-
148-
// Only show Query/Headers columns when at least one route uses them
149-
hasQuery := false
150-
hasHeaders := false
151-
for _, rt := range filteredRoutes {
152-
if len(rt.QueryParams) > 0 {
153-
hasQuery = true
154-
}
155-
if len(rt.HeaderParams) > 0 {
156-
hasHeaders = true
157-
}
158-
if hasQuery && hasHeaders {
159-
break
160-
}
161-
}
128+
// routesPageData is the template data for the routes HTML page.
129+
type routesPageData struct {
130+
Count int
131+
HasQuery bool
132+
HasHeaders bool
133+
Routes []routeRowData
134+
}
162135

163-
base := strings.TrimRight(baseURL, "/")
136+
// routeRowData is the per-route data passed to the routes HTML template.
137+
type routeRowData struct {
138+
MethodClass string
139+
Method string
140+
Pattern string
141+
PatternURL string // non-empty only when the pattern has no path parameters
142+
QueryParams string
143+
HeaderParams string
144+
Name string
145+
Action string
146+
}
164147

165-
_, _ = fmt.Fprintf(w, `<!DOCTYPE html>
148+
var routesPageTmpl = template.Must(template.New("routes").Parse(`<!DOCTYPE html>
166149
<html>
167150
<head>
168151
<meta charset="UTF-8">
169152
<title>Routes</title>
170153
<style>
171154
body { font-family: system-ui, -apple-system, sans-serif; margin: 2rem; }
172155
h1 { color: #333; }
173-
table { border-collapse: collapse; width: 100%%; margin-top: 1rem; }
156+
table { border-collapse: collapse; width: 100%; margin-top: 1rem; }
174157
th, td { text-align: left; padding: 0.75rem; border-bottom: 1px solid #ddd; }
175158
th { background-color: #f5f5f5; font-weight: 600; }
176159
tr:hover { background-color: #f9f9f9; }
@@ -193,30 +176,78 @@ func renderListRoutes(w http.ResponseWriter, req *http.Request, filteredRoutes [
193176
</head>
194177
<body>
195178
<h1>Registered Routes</h1>
196-
<p class="count">Total: %d routes</p>
179+
<p class="count">Total: {{.Count}} routes</p>
197180
<table>
198181
<thead>
199182
<tr>
200183
<th>Method</th>
201184
<th>Pattern</th>
202-
`, len(filteredRoutes))
203-
if hasQuery {
204-
_, _ = fmt.Fprint(w, ` <th>Query</th>
205-
`)
206-
}
207-
if hasHeaders {
208-
_, _ = fmt.Fprint(w, ` <th>Headers</th>
209-
`)
210-
}
211-
_, _ = fmt.Fprint(w, ` <th>Name</th>
185+
{{if .HasQuery}}<th>Query</th>{{end}}
186+
{{if .HasHeaders}}<th>Headers</th>{{end}}
187+
<th>Name</th>
212188
<th>Action</th>
213189
</tr>
214190
</thead>
215191
<tbody>
216-
`)
192+
{{range .Routes}}<tr>
193+
<td class="method {{.MethodClass}}">{{.Method}}</td>
194+
<td class="pattern">{{if .PatternURL}}<a href="{{.PatternURL}}">{{.Pattern}}</a>{{else}}{{.Pattern}}{{end}}</td>
195+
{{if $.HasQuery}}<td class="query">{{.QueryParams}}</td>{{end}}
196+
{{if $.HasHeaders}}<td class="query">{{.HeaderParams}}</td>{{end}}
197+
<td class="name">{{.Name}}</td>
198+
<td class="action">{{.Action}}</td>
199+
</tr>
200+
{{end}}
201+
</tbody>
202+
</table>
203+
</body>
204+
</html>`))
205+
206+
func renderListRoutes(w http.ResponseWriter, req *http.Request, filteredRoutes []RouteInfo, baseURL string) {
207+
// Check Accept header for JSON vs HTML
208+
accept := req.Header.Get("Accept")
209+
if strings.Contains(accept, "application/json") {
210+
w.Header().Set("Content-Type", "application/json")
211+
err := json.NewEncoder(w).Encode(map[string]any{
212+
"count": len(filteredRoutes),
213+
"routes": filteredRoutes,
214+
})
215+
if err != nil {
216+
log.Printf("[teapot-router] failed to encode routes as JSON: %v", err)
217+
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
218+
return
219+
}
220+
return
221+
}
217222

218-
for _, route := range filteredRoutes {
219-
methodClass := strings.ToLower(route.Method)
223+
// Default to HTML
224+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
225+
226+
// Only show Query/Headers columns when at least one route uses them.
227+
hasQuery := false
228+
hasHeaders := false
229+
for _, rt := range filteredRoutes {
230+
if len(rt.QueryParams) > 0 {
231+
hasQuery = true
232+
}
233+
if len(rt.HeaderParams) > 0 {
234+
hasHeaders = true
235+
}
236+
if hasQuery && hasHeaders {
237+
break
238+
}
239+
}
240+
241+
base := strings.TrimRight(baseURL, "/")
242+
243+
data := routesPageData{
244+
Count: len(filteredRoutes),
245+
HasQuery: hasQuery,
246+
HasHeaders: hasHeaders,
247+
Routes: make([]routeRowData, len(filteredRoutes)),
248+
}
249+
250+
for i, route := range filteredRoutes {
220251
name := route.Name
221252
if name == "" {
222253
name = "-"
@@ -225,41 +256,27 @@ func renderListRoutes(w http.ResponseWriter, req *http.Request, filteredRoutes [
225256
if action == "" {
226257
action = "-"
227258
}
228-
patternCell := patternHTML(route.Pattern, base)
229-
_, _ = fmt.Fprintf(w, ` <tr>
230-
<td class="method %s">%s</td>
231-
<td class="pattern">%s</td>
232-
`, methodClass, html.EscapeString(route.Method), patternCell)
233-
if hasQuery {
234-
_, _ = fmt.Fprintf(w, ` <td class="query">%s</td>
235-
`, html.EscapeString(formatQueryParams(route.QueryParams)))
259+
260+
var patternURL string
261+
if base != "" && !strings.Contains(route.Pattern, "{") {
262+
patternURL = base + route.Pattern
236263
}
237-
if hasHeaders {
238-
_, _ = fmt.Fprintf(w, ` <td class="query">%s</td>
239-
`, html.EscapeString(formatHeaderParams(route.HeaderParams)))
264+
265+
data.Routes[i] = routeRowData{
266+
MethodClass: strings.ToLower(route.Method),
267+
Method: route.Method,
268+
Pattern: route.Pattern,
269+
PatternURL: patternURL,
270+
QueryParams: formatQueryParams(route.QueryParams),
271+
HeaderParams: formatHeaderParams(route.HeaderParams),
272+
Name: name,
273+
Action: action,
240274
}
241-
_, _ = fmt.Fprintf(w, ` <td class="name">%s</td>
242-
<td class="action">%s</td>
243-
</tr>
244-
`, html.EscapeString(name), html.EscapeString(action))
245275
}
246276

247-
_, _ = fmt.Fprintf(w, ` </tbody>
248-
</table>
249-
</body>
250-
</html>`)
251-
}
252-
253-
// patternHTML returns the HTML for a pattern table cell. When base is
254-
// non-empty and the pattern contains no path-parameter segments (i.e. no
255-
// "{…}"), the pattern is wrapped in an anchor tag pointing to base+pattern.
256-
func patternHTML(pattern, base string) string {
257-
escaped := html.EscapeString(pattern)
258-
if base == "" || strings.Contains(pattern, "{") {
259-
return escaped
277+
if err := routesPageTmpl.Execute(w, data); err != nil {
278+
log.Printf("[teapot-router] failed to render routes HTML: %v", err)
260279
}
261-
href := html.EscapeString(base + pattern)
262-
return fmt.Sprintf(`<a href="%s">%s</a>`, href, escaped)
263280
}
264281

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

0 commit comments

Comments
 (0)