godev/cmd/telemetrygodev: use go 1.22 routing

Following up on CL 676756, address some comments and use go 1.22
routing throughout the telemetry frontend.

Change-Id: I84e77d37154ff4bb30ccd660109c52d418889f45
Reviewed-on: https://go-review.googlesource.com/c/telemetry/+/677075
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Auto-Submit: Robert Findley <rfindley@google.com>
Reviewed-by: Jonathan Amsterdam <jba@google.com>
diff --git a/godev/cmd/telemetrygodev/main.go b/godev/cmd/telemetrygodev/main.go
index c732c4b..8794075 100644
--- a/godev/cmd/telemetrygodev/main.go
+++ b/godev/cmd/telemetrygodev/main.go
@@ -70,14 +70,14 @@
 	}
 
 	logger := slog.Default()
-	// TODO(rfindley): use Go 1.22 routing once 1.23 is released and we can bump
-	// the go directive to 1.22.
-	mux.Handle("/", handleRoot(render, fsys, buckets.Chart, logger))
-	mux.Handle("/config", handleConfig(fsys, ucfg))
-	// TODO(rfindley): restrict this routing to POST
-	mux.Handle("/upload/", handleUpload(ucfg, buckets.Upload))
-	mux.Handle("/charts/", handleCharts(render, buckets.Chart))
-	mux.Handle("GET /data/", handleDataList(render, buckets.Merge))
+	mux.Handle("GET /", handleRoot(render, fsys, buckets.Chart, logger))
+	mux.Handle("GET /config", handleConfig(fsys, ucfg))
+	// TODO(rfindley): why do we upload to a dated endpoint, when the handler
+	// doesn't read the path variables?
+	mux.Handle("POST /upload/", handleUpload(ucfg, buckets.Upload))
+	mux.Handle("GET /charts/{$}", handleChartList(render, buckets.Chart))
+	mux.Handle("GET /charts/{date}", handleChart(render, buckets.Chart))
+	mux.Handle("GET /data/{$}", handleDataList(render, buckets.Merge))
 	mux.Handle("GET /data/{date}", handleData(buckets.Merge))
 
 	mw := middleware.Chain(
@@ -180,13 +180,9 @@
 	return []breadcrumb{{Link: "/", Label: "Go Telemetry"}, {Label: "Charts"}}
 }
 
-func handleCharts(render renderer, chartBucket storage.BucketHandle) content.HandlerFunc {
+func handleChartList(render renderer, chartBucket storage.BucketHandle) content.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) error {
-		ctx := r.Context()
-		if p := strings.TrimPrefix(r.URL.Path, "/charts/"); p != "" {
-			return handleChart(ctx, w, p, render, chartBucket)
-		}
-		it := chartBucket.Objects(ctx, "")
+		it := chartBucket.Objects(r.Context(), "")
 		var page chartsPage
 		for {
 			obj, err := it.Next()
@@ -219,19 +215,21 @@
 	}
 }
 
-func handleChart(ctx context.Context, w http.ResponseWriter, date string, render renderer, chartBucket storage.BucketHandle) error {
-	// TODO(rfindley): refactor to return a content.HandlerFunc once we can use Go 1.22 routing.
-	page := chartPage{Date: date}
-	var err error
-	objName := date + ".json"
-	page.ChartTitle = chartTitle(objName)
-	page.Charts, err = loadCharts(ctx, objName, chartBucket)
-	if errors.Is(err, storage.ErrObjectNotExist) {
-		return content.Status(w, http.StatusNotFound)
-	} else if err != nil {
-		return err
+func handleChart(render renderer, chartBucket storage.BucketHandle) content.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) error {
+		date := r.PathValue("date") // may be a range -- 2025-05-20_2025-05-27
+		page := chartPage{Date: date}
+		var err error
+		objName := date + ".json"
+		page.ChartTitle = chartTitle(objName)
+		page.Charts, err = loadCharts(r.Context(), objName, chartBucket)
+		if errors.Is(err, storage.ErrObjectNotExist) {
+			return content.Status(w, http.StatusNotFound)
+		} else if err != nil {
+			return err
+		}
+		return render(w, "charts.html", page)
 	}
-	return render(w, "charts.html", page)
 }
 
 type dataPage struct {
@@ -271,9 +269,8 @@
 		}
 		reader, err := mergeBucket.Object(date + ".json").NewReader(r.Context())
 		if errors.Is(err, storage.ErrObjectNotExist) {
-			return content.Error(fmt.Errorf("date %q not found", date), http.StatusNotFound)
-		}
-		if err != nil {
+			return content.Error(fmt.Errorf("data for %q not found", date), http.StatusNotFound)
+		} else if err != nil {
 			return err
 		}
 		defer reader.Close()
@@ -283,7 +280,7 @@
 		}
 		// Despite the merged report having a .json extension, it's not actually
 		// valid JSON (it's newline-delimited JSON objects). Therefore, it
-		// doesn't actually work well with content aware viewers if we give it
+		// doesn't actually work well with content-aware viewers if we give it
 		// the application/json content type. Furthermore, when this data was
 		// previously served directly out of GCS, it had the text/plain content
 		// type.
@@ -312,31 +309,28 @@
 
 func handleUpload(ucfg *tconfig.Config, uploadBucket storage.BucketHandle) content.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) error {
-		if r.Method == "POST" {
-			ctx := r.Context()
-			var report telemetry.Report
-			if err := json.NewDecoder(r.Body).Decode(&report); err != nil {
-				return content.Error(fmt.Errorf("invalid JSON payload: %v", err), http.StatusBadRequest)
-			}
-			if err := validate(&report, ucfg); err != nil {
-				return content.Error(fmt.Errorf("invalid report: %v", err), http.StatusBadRequest)
-			}
-			// TODO: capture metrics for collisions.
-			name := fmt.Sprintf("%s/%g.json", report.Week, report.X)
-			f, err := uploadBucket.Object(name).NewWriter(ctx)
-			if err != nil {
-				return err
-			}
-			defer f.Close()
-			if err := json.NewEncoder(f).Encode(report); err != nil {
-				return err
-			}
-			if err := f.Close(); err != nil {
-				return err
-			}
-			return content.Status(w, http.StatusOK)
+		ctx := r.Context()
+		var report telemetry.Report
+		if err := json.NewDecoder(r.Body).Decode(&report); err != nil {
+			return content.Error(fmt.Errorf("invalid JSON payload: %v", err), http.StatusBadRequest)
 		}
-		return content.Status(w, http.StatusMethodNotAllowed)
+		if err := validate(&report, ucfg); err != nil {
+			return content.Error(fmt.Errorf("invalid report: %v", err), http.StatusBadRequest)
+		}
+		// TODO: capture metrics for collisions.
+		name := fmt.Sprintf("%s/%g.json", report.Week, report.X)
+		f, err := uploadBucket.Object(name).NewWriter(ctx)
+		if err != nil {
+			return err
+		}
+		if err := json.NewEncoder(f).Encode(report); err != nil {
+			_ = f.Close()
+			return err
+		}
+		if err := f.Close(); err != nil {
+			return err
+		}
+		return content.Status(w, http.StatusOK)
 	}
 }