package server import ( "encoding/json" "log/slog" "net/http" "strconv" "time" "git.mkz.me/mycroft/root-me-api/internal/scoreboard" ) // StatsProvider exposes refresh health (implemented by *scoreboard.Refresher). type StatsProvider interface { Stats() (success, failure int64, lastErr error, lastErrAt time.Time) } // HTTPHandler builds the JSON REST mux over the store. stats may be nil. func HTTPHandler(store *scoreboard.Store, stats StatsProvider, logger *slog.Logger) http.Handler { if logger == nil { logger = slog.Default() } h := &httpServer{store: store, stats: stats, logger: logger} mux := http.NewServeMux() mux.HandleFunc("GET /healthz", h.healthz) mux.HandleFunc("GET /readyz", h.readyz) mux.HandleFunc("GET /v1/scoreboard", h.list) mux.HandleFunc("GET /v1/scoreboard/rank/{rank}", h.byRank) mux.HandleFunc("GET /v1/scoreboard/user/{username}", h.byUser) return mux } type httpServer struct { store *scoreboard.Store stats StatsProvider logger *slog.Logger } type listResponse struct { Entries []scoreboard.Entry `json:"entries"` Total int `json:"total"` FetchedAt time.Time `json:"fetched_at"` } func (h *httpServer) healthz(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok\n")) } func (h *httpServer) readyz(w http.ResponseWriter, _ *http.Request) { body := map[string]any{"ready": h.store.Ready(), "entries": h.store.Len()} if h.stats != nil { ok, fail, lastErr, lastErrAt := h.stats.Stats() body["refresh_success"] = ok body["refresh_failure"] = fail if lastErr != nil { body["last_error"] = lastErr.Error() body["last_error_at"] = lastErrAt } } code := http.StatusOK if !h.store.Ready() { code = http.StatusServiceUnavailable } writeJSON(w, code, body) } func (h *httpServer) list(w http.ResponseWriter, r *http.Request) { if !h.store.Ready() { writeError(w, http.StatusServiceUnavailable, "scoreboard not loaded yet") return } offset, err := intParam(r, "offset", 0) if err != nil { writeError(w, http.StatusBadRequest, "invalid offset") return } limit, err := intParam(r, "limit", 0) if err != nil { writeError(w, http.StatusBadRequest, "invalid limit") return } entries, fetchedAt := h.store.List(offset, limit) writeJSON(w, http.StatusOK, listResponse{ Entries: entries, Total: h.store.Len(), FetchedAt: fetchedAt, }) } func (h *httpServer) byRank(w http.ResponseWriter, r *http.Request) { if !h.store.Ready() { writeError(w, http.StatusServiceUnavailable, "scoreboard not loaded yet") return } rank, err := strconv.Atoi(r.PathValue("rank")) if err != nil || rank < 1 { writeError(w, http.StatusBadRequest, "rank must be a positive integer") return } e, ok := h.store.ByRank(rank) if !ok { writeError(w, http.StatusNotFound, "no entry at that rank") return } writeJSON(w, http.StatusOK, e) } func (h *httpServer) byUser(w http.ResponseWriter, r *http.Request) { if !h.store.Ready() { writeError(w, http.StatusServiceUnavailable, "scoreboard not loaded yet") return } e, ok := h.store.ByUsername(r.PathValue("username")) if !ok { writeError(w, http.StatusNotFound, "user not found in scoreboard") return } writeJSON(w, http.StatusOK, e) } func intParam(r *http.Request, name string, def int) (int, error) { v := r.URL.Query().Get(name) if v == "" { return def, nil } return strconv.Atoi(v) } func writeJSON(w http.ResponseWriter, code int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(v) } func writeError(w http.ResponseWriter, code int, msg string) { writeJSON(w, code, map[string]string{"error": msg}) }