Halte letzten Player-Status im Backend
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
f8a57b3e6b
commit
896eade0fb
6 changed files with 193 additions and 48 deletions
|
|
@ -20,7 +20,7 @@ func New() (*App, error) {
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
server: &http.Server{
|
server: &http.Server{
|
||||||
Addr: cfg.HTTPAddress,
|
Addr: cfg.HTTPAddress,
|
||||||
Handler: httpapi.NewRouter(),
|
Handler: httpapi.NewRouter(httpapi.NewPlayerStatusStore()),
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@ type playerStatusRequest struct {
|
||||||
LastHeartbeatAt string `json:"last_heartbeat_at"`
|
LastHeartbeatAt string `json:"last_heartbeat_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func handlePlayerStatus(w http.ResponseWriter, r *http.Request) {
|
func handlePlayerStatus(store playerStatusStore) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
var request playerStatusRequest
|
var request playerStatusRequest
|
||||||
if err := decodeJSON(r, &request); err != nil {
|
if err := decodeJSON(r, &request); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid_json", "ungueltiger JSON-Body", nil)
|
writeError(w, http.StatusBadRequest, "invalid_json", "ungueltiger JSON-Body", nil)
|
||||||
|
|
@ -54,9 +55,39 @@ func handlePlayerStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
store.Save(playerStatusRecord{
|
||||||
|
ScreenID: request.ScreenID,
|
||||||
|
Timestamp: request.Timestamp,
|
||||||
|
Status: request.Status,
|
||||||
|
ServerURL: request.ServerURL,
|
||||||
|
MQTTBroker: request.MQTTBroker,
|
||||||
|
HeartbeatEverySeconds: request.HeartbeatEverySeconds,
|
||||||
|
StartedAt: request.StartedAt,
|
||||||
|
LastHeartbeatAt: request.LastHeartbeatAt,
|
||||||
|
})
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{
|
writeJSON(w, http.StatusOK, map[string]string{
|
||||||
"status": "accepted",
|
"status": "accepted",
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGetLatestPlayerStatus(store playerStatusStore) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
screenID := strings.TrimSpace(r.PathValue("screenId"))
|
||||||
|
if screenID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "screen_id_required", "screenId ist erforderlich", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
record, ok := store.Get(screenID)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusNotFound, "screen_status_not_found", "Fuer diesen Screen liegt noch kein Status vor", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, record)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateOptionalRFC3339(value string) error {
|
func validateOptionalRFC3339(value string) error {
|
||||||
|
|
|
||||||
45
server/backend/internal/httpapi/playerstatus_store.go
Normal file
45
server/backend/internal/httpapi/playerstatus_store.go
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
package httpapi
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
type playerStatusRecord struct {
|
||||||
|
ScreenID string `json:"screen_id"`
|
||||||
|
Timestamp string `json:"ts"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ServerURL string `json:"server_url,omitempty"`
|
||||||
|
MQTTBroker string `json:"mqtt_broker,omitempty"`
|
||||||
|
HeartbeatEverySeconds int `json:"heartbeat_every_seconds,omitempty"`
|
||||||
|
StartedAt string `json:"started_at,omitempty"`
|
||||||
|
LastHeartbeatAt string `json:"last_heartbeat_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type playerStatusStore interface {
|
||||||
|
Save(record playerStatusRecord)
|
||||||
|
Get(screenID string) (playerStatusRecord, bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
type inMemoryPlayerStatusStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
records map[string]playerStatusRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
func newInMemoryPlayerStatusStore() *inMemoryPlayerStatusStore {
|
||||||
|
return &inMemoryPlayerStatusStore{records: make(map[string]playerStatusRecord)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPlayerStatusStore() playerStatusStore {
|
||||||
|
return newInMemoryPlayerStatusStore()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *inMemoryPlayerStatusStore) Save(record playerStatusRecord) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.records[record.ScreenID] = record
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *inMemoryPlayerStatusStore) Get(screenID string) (playerStatusRecord, bool) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
record, ok := s.records[screenID]
|
||||||
|
return record, ok
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHandlePlayerStatusAccepted(t *testing.T) {
|
func TestHandlePlayerStatusAccepted(t *testing.T) {
|
||||||
|
store := newInMemoryPlayerStatusStore()
|
||||||
body := []byte(`{
|
body := []byte(`{
|
||||||
"screen_id": "info01-dev",
|
"screen_id": "info01-dev",
|
||||||
"ts": "2026-03-22T16:00:00Z",
|
"ts": "2026-03-22T16:00:00Z",
|
||||||
|
|
@ -23,7 +24,7 @@ func TestHandlePlayerStatusAccepted(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handlePlayerStatus(w, req)
|
handlePlayerStatus(store)(w, req)
|
||||||
|
|
||||||
if got, want := w.Code, http.StatusOK; got != want {
|
if got, want := w.Code, http.StatusOK; got != want {
|
||||||
t.Fatalf("status = %d, want %d", got, want)
|
t.Fatalf("status = %d, want %d", got, want)
|
||||||
|
|
@ -40,13 +41,22 @@ func TestHandlePlayerStatusAccepted(t *testing.T) {
|
||||||
if got, want := response.Status, "accepted"; got != want {
|
if got, want := response.Status, "accepted"; got != want {
|
||||||
t.Fatalf("response status = %q, want %q", got, want)
|
t.Fatalf("response status = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stored, ok := store.Get("info01-dev")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("store.Get() ok = false, want true")
|
||||||
|
}
|
||||||
|
|
||||||
|
if got, want := stored.ScreenID, "info01-dev"; got != want {
|
||||||
|
t.Fatalf("stored.ScreenID = %q, want %q", got, want)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandlePlayerStatusRejectsInvalidJSON(t *testing.T) {
|
func TestHandlePlayerStatusRejectsInvalidJSON(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewBufferString("{"))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewBufferString("{"))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handlePlayerStatus(w, req)
|
handlePlayerStatus(newInMemoryPlayerStatusStore())(w, req)
|
||||||
|
|
||||||
if got, want := w.Code, http.StatusBadRequest; got != want {
|
if got, want := w.Code, http.StatusBadRequest; got != want {
|
||||||
t.Fatalf("status = %d, want %d", got, want)
|
t.Fatalf("status = %d, want %d", got, want)
|
||||||
|
|
@ -63,7 +73,7 @@ func TestHandlePlayerStatusRejectsMissingScreenID(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handlePlayerStatus(w, req)
|
handlePlayerStatus(newInMemoryPlayerStatusStore())(w, req)
|
||||||
|
|
||||||
if got, want := w.Code, http.StatusBadRequest; got != want {
|
if got, want := w.Code, http.StatusBadRequest; got != want {
|
||||||
t.Fatalf("status = %d, want %d", got, want)
|
t.Fatalf("status = %d, want %d", got, want)
|
||||||
|
|
@ -79,7 +89,7 @@ func TestHandlePlayerStatusRejectsMissingTimestamp(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handlePlayerStatus(w, req)
|
handlePlayerStatus(newInMemoryPlayerStatusStore())(w, req)
|
||||||
|
|
||||||
if got, want := w.Code, http.StatusBadRequest; got != want {
|
if got, want := w.Code, http.StatusBadRequest; got != want {
|
||||||
t.Fatalf("status = %d, want %d", got, want)
|
t.Fatalf("status = %d, want %d", got, want)
|
||||||
|
|
@ -95,7 +105,7 @@ func TestHandlePlayerStatusRejectsMissingStatus(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handlePlayerStatus(w, req)
|
handlePlayerStatus(newInMemoryPlayerStatusStore())(w, req)
|
||||||
|
|
||||||
if got, want := w.Code, http.StatusBadRequest; got != want {
|
if got, want := w.Code, http.StatusBadRequest; got != want {
|
||||||
t.Fatalf("status = %d, want %d", got, want)
|
t.Fatalf("status = %d, want %d", got, want)
|
||||||
|
|
@ -112,7 +122,7 @@ func TestHandlePlayerStatusRejectsMalformedTimestamps(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handlePlayerStatus(w, req)
|
handlePlayerStatus(newInMemoryPlayerStatusStore())(w, req)
|
||||||
|
|
||||||
if got, want := w.Code, http.StatusBadRequest; got != want {
|
if got, want := w.Code, http.StatusBadRequest; got != want {
|
||||||
t.Fatalf("status = %d, want %d", got, want)
|
t.Fatalf("status = %d, want %d", got, want)
|
||||||
|
|
@ -130,7 +140,7 @@ func TestHandlePlayerStatusRejectsMalformedStartedAt(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handlePlayerStatus(w, req)
|
handlePlayerStatus(newInMemoryPlayerStatusStore())(w, req)
|
||||||
|
|
||||||
if got, want := w.Code, http.StatusBadRequest; got != want {
|
if got, want := w.Code, http.StatusBadRequest; got != want {
|
||||||
t.Fatalf("status = %d, want %d", got, want)
|
t.Fatalf("status = %d, want %d", got, want)
|
||||||
|
|
@ -148,9 +158,54 @@ func TestHandlePlayerStatusRejectsMalformedLastHeartbeatAt(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handlePlayerStatus(w, req)
|
handlePlayerStatus(newInMemoryPlayerStatusStore())(w, req)
|
||||||
|
|
||||||
if got, want := w.Code, http.StatusBadRequest; got != want {
|
if got, want := w.Code, http.StatusBadRequest; got != want {
|
||||||
t.Fatalf("status = %d, want %d", got, want)
|
t.Fatalf("status = %d, want %d", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleGetLatestPlayerStatus(t *testing.T) {
|
||||||
|
store := newInMemoryPlayerStatusStore()
|
||||||
|
store.Save(playerStatusRecord{
|
||||||
|
ScreenID: "info01-dev",
|
||||||
|
Timestamp: "2026-03-22T16:00:00Z",
|
||||||
|
Status: "running",
|
||||||
|
ServerURL: "http://127.0.0.1:8080",
|
||||||
|
MQTTBroker: "tcp://127.0.0.1:1883",
|
||||||
|
HeartbeatEverySeconds: 30,
|
||||||
|
StartedAt: "2026-03-22T15:59:30Z",
|
||||||
|
LastHeartbeatAt: "2026-03-22T16:00:00Z",
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/screens/info01-dev/status", nil)
|
||||||
|
req.SetPathValue("screenId", "info01-dev")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handleGetLatestPlayerStatus(store)(w, req)
|
||||||
|
|
||||||
|
if got, want := w.Code, http.StatusOK; got != want {
|
||||||
|
t.Fatalf("status = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
var response playerStatusRecord
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got, want := response.ScreenID, "info01-dev"; got != want {
|
||||||
|
t.Fatalf("response.ScreenID = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetLatestPlayerStatusNotFound(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/screens/missing/status", nil)
|
||||||
|
req.SetPathValue("screenId", "missing")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handleGetLatestPlayerStatus(newInMemoryPlayerStatusStore())(w, req)
|
||||||
|
|
||||||
|
if got, want := w.Code, http.StatusNotFound; got != want {
|
||||||
|
t.Fatalf("status = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewRouter() http.Handler {
|
func NewRouter(store playerStatusStore) http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
|
@ -26,7 +26,8 @@ func NewRouter() http.Handler {
|
||||||
|
|
||||||
mux.HandleFunc("GET /api/v1/meta", handleMeta)
|
mux.HandleFunc("GET /api/v1/meta", handleMeta)
|
||||||
|
|
||||||
mux.HandleFunc("POST /api/v1/player/status", handlePlayerStatus)
|
mux.HandleFunc("POST /api/v1/player/status", handlePlayerStatus(store))
|
||||||
|
mux.HandleFunc("GET /api/v1/screens/{screenId}/status", handleGetLatestPlayerStatus(store))
|
||||||
|
|
||||||
mux.HandleFunc("POST /api/v1/tools/message-wall/resolve", handleResolveMessageWall)
|
mux.HandleFunc("POST /api/v1/tools/message-wall/resolve", handleResolveMessageWall)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ func TestRouterHealthz(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
NewRouter().ServeHTTP(w, req)
|
NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req)
|
||||||
|
|
||||||
if got, want := w.Code, http.StatusOK; got != want {
|
if got, want := w.Code, http.StatusOK; got != want {
|
||||||
t.Fatalf("status = %d, want %d", got, want)
|
t.Fatalf("status = %d, want %d", got, want)
|
||||||
|
|
@ -40,7 +40,7 @@ func TestRouterBaseAPI(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/v1", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
NewRouter().ServeHTTP(w, req)
|
NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req)
|
||||||
|
|
||||||
if got, want := w.Code, http.StatusOK; got != want {
|
if got, want := w.Code, http.StatusOK; got != want {
|
||||||
t.Fatalf("status = %d, want %d", got, want)
|
t.Fatalf("status = %d, want %d", got, want)
|
||||||
|
|
@ -77,7 +77,7 @@ func TestRouterMeta(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/meta", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/meta", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
NewRouter().ServeHTTP(w, req)
|
NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req)
|
||||||
|
|
||||||
if got, want := w.Code, http.StatusOK; got != want {
|
if got, want := w.Code, http.StatusOK; got != want {
|
||||||
t.Fatalf("status = %d, want %d", got, want)
|
t.Fatalf("status = %d, want %d", got, want)
|
||||||
|
|
@ -122,7 +122,20 @@ func TestRouterPlayerStatusRoute(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewBufferString(`{"screen_id":"demo","ts":"2026-03-22T16:00:00Z","status":"running"}`))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewBufferString(`{"screen_id":"demo","ts":"2026-03-22T16:00:00Z","status":"running"}`))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
NewRouter().ServeHTTP(w, req)
|
NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if got, want := w.Code, http.StatusOK; got != want {
|
||||||
|
t.Fatalf("status = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouterScreenStatusRoute(t *testing.T) {
|
||||||
|
store := newInMemoryPlayerStatusStore()
|
||||||
|
store.Save(playerStatusRecord{ScreenID: "demo", Timestamp: "2026-03-22T16:00:00Z", Status: "running"})
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/screens/demo/status", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
NewRouter(store).ServeHTTP(w, req)
|
||||||
|
|
||||||
if got, want := w.Code, http.StatusOK; got != want {
|
if got, want := w.Code, http.StatusOK; got != want {
|
||||||
t.Fatalf("status = %d, want %d", got, want)
|
t.Fatalf("status = %d, want %d", got, want)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue