feat: initial commit
build / test (push) Successful in 9s
build / build (push) Successful in 10s
build / build-image (push) Successful in 1m4s

This commit is contained in:
2026-06-04 14:19:37 +02:00
commit cf961ab94f
28 changed files with 3036 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
// Package server exposes the scoreboard Store over gRPC and HTTP/JSON.
package server
import (
"context"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "git.mkz.me/mycroft/root-me-api/gen/scoreboard/v1"
"git.mkz.me/mycroft/root-me-api/internal/scoreboard"
)
// GRPCServer implements pb.ScoreboardServiceServer over a Store.
type GRPCServer struct {
pb.UnimplementedScoreboardServiceServer
Store *scoreboard.Store
}
// NewGRPC returns a gRPC server backed by the given store.
func NewGRPC(store *scoreboard.Store) *GRPCServer { return &GRPCServer{Store: store} }
func (s *GRPCServer) ListScoreboard(_ context.Context, req *pb.ListScoreboardRequest) (*pb.ListScoreboardResponse, error) {
if !s.Store.Ready() {
return nil, status.Error(codes.Unavailable, "scoreboard not loaded yet")
}
entries, fetchedAt := s.Store.List(int(req.GetOffset()), int(req.GetLimit()))
resp := &pb.ListScoreboardResponse{
Total: int32(s.Store.Len()),
FetchedAt: fetchedAt.Unix(),
}
resp.Entries = make([]*pb.Entry, len(entries))
for i, e := range entries {
resp.Entries[i] = toProto(e)
}
return resp, nil
}
func (s *GRPCServer) GetByRank(_ context.Context, req *pb.GetByRankRequest) (*pb.Entry, error) {
if !s.Store.Ready() {
return nil, status.Error(codes.Unavailable, "scoreboard not loaded yet")
}
e, ok := s.Store.ByRank(int(req.GetRank()))
if !ok {
return nil, status.Errorf(codes.NotFound, "no entry at rank %d", req.GetRank())
}
return toProto(e), nil
}
func (s *GRPCServer) GetByUsername(_ context.Context, req *pb.GetByUsernameRequest) (*pb.Entry, error) {
if !s.Store.Ready() {
return nil, status.Error(codes.Unavailable, "scoreboard not loaded yet")
}
if req.GetUsername() == "" {
return nil, status.Error(codes.InvalidArgument, "username is required")
}
e, ok := s.Store.ByUsername(req.GetUsername())
if !ok {
return nil, status.Errorf(codes.NotFound, "no entry for user %q", req.GetUsername())
}
return toProto(e), nil
}
func toProto(e scoreboard.Entry) *pb.Entry {
return &pb.Entry{
Rank: int32(e.Rank),
Username: e.Username,
ProfilePath: e.ProfilePath,
Country: e.Country,
Grade: e.Grade,
Score: int32(e.Score),
}
}
+139
View File
@@ -0,0 +1,139 @@
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})
}