feat(go-services): tracker-go — complete the Phase 1 read API

Adds the rest of the read-side endpoints to the Go tracker, all parity-checked
against the live Python service:

- DB reads: /stats/{c}, /portals, /spawns/heatmap, /server-health,
  /character-stats/{c} (stats_data JSONB merged to top level),
  /combat-stats[/{c}], /inventories, /inventory/{c}/search.
- 5-minute totals cache + /total-rares, /total-kills.
- Ingest-only state returned as Python's empty/default shapes (/quest-status,
  /vital-sharing/peers, /equipment-cantrip-state/{c}); /issues (flat file),
  /me (401 until cookie verification lands).
- Streaming reverse proxy to inventory-service (/inventory/{c},
  /inventory-characters, /search/*, /sets/list, /inv/{path...} incl. the SSE
  suitbuilder stream).
- compare/compare_endpoints.py: structural parity for all read endpoints +
  exact-match check for /character-stats and /combat-stats on OFFLINE chars
  (online chars legitimately differ — Python serves a richer live overlay that
  Phase-1 Go lacks until ingest).

Verified live: 14/14 endpoints structural-match, 8/8 rich offline chars
exact-match on /character-stats.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-24 09:38:10 +02:00
parent 1af47520c0
commit c4e8190656
9 changed files with 908 additions and 10 deletions

View file

@ -0,0 +1,74 @@
package main
import (
"net/http"
"net/http/httputil"
"net/url"
)
// initInvProxy builds a streaming reverse proxy to the inventory-service.
// FlushInterval=-1 flushes writes immediately so SSE endpoints (the suitbuilder
// search stream) work. Connection errors map to 503, mirroring the Python
// service's "Inventory service unavailable".
func (s *Server) initInvProxy(target string) error {
u, err := url.Parse(target)
if err != nil {
return err
}
rp := httputil.NewSingleHostReverseProxy(u)
rp.FlushInterval = -1
rp.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
s.log.Error("inventory proxy error", "err", err, "path", r.URL.Path)
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"detail": "Inventory service unavailable"})
}
s.invProxy = rp
return nil
}
// proxyInv returns a handler that rewrites the request path (via rewrite) and
// forwards it to the inventory-service, preserving method, query, headers, and
// body. The original /inv/* prefix etc. is mapped to the upstream path.
func (s *Server) proxyInv(rewrite func(r *http.Request) string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if s.invProxy == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"detail": "Inventory service unavailable"})
return
}
r.URL.Path = rewrite(r)
r.URL.RawPath = "" // force re-encode from the (decoded) Path
s.invProxy.ServeHTTP(w, r)
}
}
func (s *Server) registerProxyRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /inventory/{character_name}", s.proxyInv(func(r *http.Request) string {
return "/inventory/" + r.PathValue("character_name")
}))
mux.HandleFunc("GET /inventory-characters", s.proxyInv(func(r *http.Request) string {
return "/characters/list"
}))
mux.HandleFunc("GET /search/items", s.proxyInv(func(r *http.Request) string {
return "/search/items"
}))
mux.HandleFunc("GET /search/equipped/{character_name}", s.proxyInv(func(r *http.Request) string {
return "/search/equipped/" + r.PathValue("character_name")
}))
mux.HandleFunc("GET /search/upgrades/{character_name}/{slot}", s.proxyInv(func(r *http.Request) string {
return "/search/upgrades/" + r.PathValue("character_name") + "/" + r.PathValue("slot")
}))
mux.HandleFunc("GET /sets/list", s.proxyInv(func(r *http.Request) string {
return "/sets/list"
}))
// /inv/test is a static liveness probe in the Python service.
mux.HandleFunc("GET /inv/test", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"message": "Inventory proxy route is working"})
})
// Generic catch-all proxy: /inv/{path...} -> {SVC}/{path}. Covers GET and
// POST (incl. the SSE suitbuilder search). Registered for both methods.
invAll := s.proxyInv(func(r *http.Request) string {
return "/" + r.PathValue("path")
})
mux.HandleFunc("GET /inv/{path...}", invAll)
mux.HandleFunc("POST /inv/{path...}", invAll)
}