feat(service): expose build version on GET / mirroring /health

Extract a buildVersionInfo helper from HandleHealth so both endpoints
emit identical version + VCS metadata. JSON callers hitting / now get
the same release context they get from /health; under go run/test
where debug.ReadBuildInfo lacks VCS settings, version falls back to
"0.0.1" and the vcs_* keys are omitted (instead of empty strings).

The HTML branch of / is unchanged — the embedded index.html keeps its
own version-display story.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-17 19:14:05 +02:00
co-authored by Claude Opus 4.7
parent 776e0cfe44
commit e3cd5a3459
3 changed files with 76 additions and 34 deletions
+36 -28
View File
@@ -7,45 +7,53 @@ import (
"time"
)
// HandleHealth returns the health status of the service.
func (s *Server) HandleHealth(w http.ResponseWriter, _ *http.Request) {
version := "0.0.1"
vcsRevision := ""
vcsTime := ""
vcsModified := ""
// buildVersionInfo extracts module version + VCS metadata from the
// runtime/debug build info, falling back to "0.0.1" when the binary
// wasn't built with module info (e.g. local `go run`). Shared between
// HandleHealth and HandleRoot so both endpoints report identical
// release context; keys present-when-non-empty so a `go run` response
// stays minimal instead of carrying empty strings.
func buildVersionInfo() map[string]string {
info := map[string]string{"version": "0.0.1"}
if info, ok := debug.ReadBuildInfo(); ok {
if info.Main.Version != "" && info.Main.Version != "(devel)" {
version = info.Main.Version
}
build, ok := debug.ReadBuildInfo()
if !ok {
return info
}
for _, setting := range info.Settings {
switch setting.Key {
case "vcs.revision":
vcsRevision = setting.Value
case "vcs.time":
vcsTime = setting.Value
case "vcs.modified":
vcsModified = setting.Value
if build.Main.Version != "" && build.Main.Version != "(devel)" {
info["version"] = build.Main.Version
}
for _, setting := range build.Settings {
switch setting.Key {
case "vcs.revision":
if setting.Value != "" {
info["vcs_revision"] = setting.Value
}
case "vcs.time":
if setting.Value != "" {
info["vcs_time"] = setting.Value
}
case "vcs.modified":
if setting.Value != "" {
info["vcs_modified"] = setting.Value
}
}
}
return info
}
// HandleHealth returns the health status of the service.
func (s *Server) HandleHealth(w http.ResponseWriter, _ *http.Request) {
status := map[string]interface{}{
"status": "up",
"timestamp": time.Now().Format(time.RFC3339),
"version": version,
}
if vcsRevision != "" {
status["vcs_revision"] = vcsRevision
}
if vcsTime != "" {
status["vcs_time"] = vcsTime
}
if vcsModified != "" {
status["vcs_modified"] = vcsModified
for k, v := range buildVersionInfo() {
status[k] = v
}
w.Header().Set("Content-Type", "application/json")
+19 -2
View File
@@ -2,7 +2,7 @@ package handlers
import (
"embed"
"fmt"
"encoding/json"
"io/fs"
"net/http"
"strings"
@@ -36,8 +36,25 @@ var swUpdateXML []byte
func (s *Server) HandleRoot(w http.ResponseWriter, r *http.Request) {
accept := r.Header.Get("Accept")
if !strings.Contains(accept, "text/html") && (strings.Contains(accept, "application/json") || accept == "*/*" || accept == "") {
// Mirror the version + VCS metadata exposed by /health so any
// caller hitting "/" gets identical release context. Keys that
// would carry empty strings are omitted by buildVersionInfo.
payload := map[string]string{
"Bose": "AfterTouch",
"service": "Go/Chi",
"docs": "https://gesellix.github.io/Bose-SoundTouch/",
}
for k, v := range buildVersionInfo() {
payload[k] = v
}
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"Bose": "AfterTouch", "service": "Go/Chi", "docs": "https://gesellix.github.io/Bose-SoundTouch/"}`)
if err := json.NewEncoder(w).Encode(payload); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
return
}
+21 -4
View File
@@ -1,6 +1,7 @@
package handlers
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
@@ -66,10 +67,26 @@ func TestRootEndpointJSON(t *testing.T) {
t.Errorf("Expected application/json content type, got %s", contentType)
}
body, _ := io.ReadAll(res.Body)
expected := `{"Bose": "AfterTouch", "service": "Go/Chi", "docs": "https://gesellix.github.io/Bose-SoundTouch/"}`
if strings.TrimSpace(string(body)) != expected {
t.Errorf("Expected body %s, got %s", expected, string(body))
var got map[string]string
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
t.Fatalf("failed to decode root response: %v", err)
}
for _, want := range []struct{ key, value string }{
{"Bose", "AfterTouch"},
{"service", "Go/Chi"},
{"docs", "https://gesellix.github.io/Bose-SoundTouch/"},
} {
if got[want.key] != want.value {
t.Errorf("payload[%q] = %q, want %q", want.key, got[want.key], want.value)
}
}
// Version mirrors /health — falls back to "0.0.1" under `go test`
// (debug.ReadBuildInfo reports Main.Version="(devel)") but must
// always be present so monitoring tools can pin a release.
if got["version"] == "" {
t.Error("expected version field to be present")
}
}