Files
CosmicClash/server/api/service.go
T
2026-08-31 21:29:33 +01:00

313 lines
9.6 KiB
Go

// Package api exposes the small authenticated HTTP boundary around domain
// policies. Persistent adapters can replace the in-memory dependencies without
// changing request authentication or validation rules.
package api
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/cosmic-clash/cosmic-clash/server/domain"
)
const maxBodyBytes = 8 << 10
type CandidateProvider func(playerID, ticketID string) (domain.Candidate, error)
type Service struct {
Sessions *domain.SessionStore
Queue *domain.Queue
Candidate CandidateProvider
Now func() time.Time
Proposals map[string]*domain.Proposal
RankedProfiles map[string]domain.RankedProfile
TierPolicy domain.TierPolicy
proposalMu sync.Mutex
}
func (s *Service) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", s.health)
mux.HandleFunc("/v1/queue", s.queueCreate)
mux.HandleFunc("/v1/queue/", s.queueMutation)
mux.HandleFunc("/v1/proposals/", s.proposalMutation)
mux.HandleFunc("/v1/profile/ranked", s.rankedProfile)
return mux
}
func (s *Service) health(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
type queueCreateRequest struct {
TicketID string `json:"ticket_id"`
}
type queueResponse struct {
TicketID string `json:"ticket_id"`
PlayerID string `json:"player_id"`
State string `json:"state"`
Revision uint64 `json:"revision"`
EnqueuedAt time.Time `json:"enqueued_at"`
ExpiresAt time.Time `json:"expires_at"`
}
func (s *Service) queueCreate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed")
return
}
playerID, ok := s.authenticate(w, r)
if !ok {
return
}
if s.Queue == nil || s.Candidate == nil {
writeError(w, http.StatusServiceUnavailable, "queue_unavailable")
return
}
var input queueCreateRequest
if !decodeBody(w, r, &input) {
return
}
if input.TicketID == "" {
writeError(w, http.StatusBadRequest, "invalid_request")
return
}
key := r.Header.Get("Idempotency-Key")
if len(key) < 16 || len(key) > 128 {
writeError(w, http.StatusBadRequest, "invalid_idempotency_key")
return
}
now := s.now()
candidate, err := s.Candidate(playerID, input.TicketID)
if err != nil {
writeError(w, http.StatusUnprocessableEntity, "candidate_unavailable")
return
}
ticket, err := s.Queue.Create(playerID, input.TicketID, key, candidate, now)
if err != nil {
writeDomainError(w, err)
return
}
writeJSON(w, http.StatusCreated, toQueueResponse(ticket))
}
func (s *Service) queueMutation(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost && r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed")
return
}
playerID, ok := s.authenticate(w, r)
if !ok {
return
}
if s.Queue == nil {
writeError(w, http.StatusServiceUnavailable, "queue_unavailable")
return
}
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/queue/"), "/")
if r.Method == http.MethodGet {
if len(parts) != 1 || parts[0] == "" {
writeError(w, http.StatusNotFound, "not_found")
return
}
ticket, err := s.Queue.Get(playerID, parts[0], s.now())
if err != nil {
writeDomainError(w, err)
return
}
writeJSON(w, http.StatusOK, toQueueResponse(ticket))
return
}
if len(parts) != 2 || parts[0] == "" || (parts[1] != "heartbeat" && parts[1] != "cancel") {
writeError(w, http.StatusNotFound, "not_found")
return
}
ticketID, key := parts[0], r.Header.Get("Idempotency-Key")
if len(key) < 16 || len(key) > 128 {
writeError(w, http.StatusBadRequest, "invalid_idempotency_key")
return
}
revision, err := strconv.ParseUint(r.Header.Get("If-Match-Revision"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_revision")
return
}
now := s.now()
var ticket domain.QueueTicket
if parts[1] == "heartbeat" {
ticket, err = s.Queue.Heartbeat(playerID, ticketID, key, revision, now)
} else {
ticket, err = s.Queue.Cancel(playerID, ticketID, key, revision, now)
}
if err != nil {
writeDomainError(w, err)
return
}
writeJSON(w, http.StatusOK, toQueueResponse(ticket))
}
type proposalResponse struct {
ProposalID string `json:"proposal_id"`
Playlist string `json:"playlist"`
State string `json:"state"`
Revision uint64 `json:"revision"`
ExpiresAt time.Time `json:"expires_at"`
Participants []domain.ProposalParticipant `json:"participants"`
}
func (s *Service) proposalMutation(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed")
return
}
playerID, ok := s.authenticate(w, r)
if !ok {
return
}
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/proposals/"), "/")
if len(parts) != 2 || parts[0] == "" || (parts[1] != "accept" && parts[1] != "decline") {
writeError(w, http.StatusNotFound, "not_found")
return
}
key := r.Header.Get("Idempotency-Key")
if len(key) < 16 || len(key) > 128 {
writeError(w, http.StatusBadRequest, "invalid_idempotency_key")
return
}
revision, err := strconv.ParseUint(r.Header.Get("If-Match-Revision"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_revision")
return
}
s.proposalMu.Lock()
defer s.proposalMu.Unlock()
proposal, exists := s.Proposals[parts[0]]
if !exists || proposal == nil {
writeError(w, http.StatusNotFound, "not_found")
return
}
updated, err := proposal.Respond(playerID, key, parts[1] == "accept", revision, s.now())
if err != nil {
writeDomainError(w, err)
return
}
writeJSON(w, http.StatusOK, toProposalResponse(updated))
}
type rankedProfileResponse struct {
Rating float64 `json:"rating"`
RD float64 `json:"rd"`
Volatility float64 `json:"volatility"`
RankedGames int `json:"ranked_games"`
Tier string `json:"tier"`
Provisional bool `json:"provisional"`
SeasonID string `json:"season_id,omitempty"`
}
func (s *Service) rankedProfile(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed")
return
}
playerID, ok := s.authenticate(w, r)
if !ok {
return
}
profile, exists := s.RankedProfiles[playerID]
if !exists {
writeError(w, http.StatusNotFound, "not_found")
return
}
tier, err := domain.RankedTier(profile, s.TierPolicy)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "ranked_profile_unavailable")
return
}
writeJSON(w, http.StatusOK, rankedProfileResponse{Rating: profile.Value, RD: profile.RD, Volatility: profile.Volatility, RankedGames: profile.RankedGames, Tier: string(tier), Provisional: domain.RankedIsProvisional(profile), SeasonID: profile.LastSeasonID})
}
func (s *Service) authenticate(w http.ResponseWriter, r *http.Request) (string, bool) {
if s.Sessions == nil {
writeError(w, http.StatusServiceUnavailable, "auth_unavailable")
return "", false
}
parts := strings.Fields(r.Header.Get("Authorization"))
if len(parts) != 2 || parts[0] != "Bearer" {
writeError(w, http.StatusUnauthorized, "unauthorized")
return "", false
}
separator := strings.IndexByte(parts[1], ':')
if separator <= 0 || separator == len(parts[1])-1 {
writeError(w, http.StatusUnauthorized, "unauthorized")
return "", false
}
session, err := s.Sessions.Authenticate(parts[1][:separator], parts[1][separator+1:], s.now())
if err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized")
return "", false
}
return session.PlayerID, true
}
func (s *Service) now() time.Time {
if s.Now != nil {
return s.Now()
}
return time.Now().UTC()
}
func decodeBody(w http.ResponseWriter, r *http.Request, target any) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request")
return false
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
writeError(w, http.StatusBadRequest, "invalid_request")
return false
}
return true
}
func toQueueResponse(ticket domain.QueueTicket) queueResponse {
return queueResponse{TicketID: ticket.TicketID, PlayerID: ticket.PlayerID, State: string(ticket.State), Revision: ticket.Revision, EnqueuedAt: ticket.EnqueuedAt, ExpiresAt: ticket.ExpiresAt}
}
func toProposalResponse(proposal domain.Proposal) proposalResponse {
return proposalResponse{ProposalID: proposal.ProposalID, Playlist: string(proposal.Playlist), State: string(proposal.State), Revision: proposal.Revision, ExpiresAt: proposal.ExpiresAt, Participants: proposal.Participants}
}
func writeDomainError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, domain.ErrPlayerQueued), errors.Is(err, domain.ErrConflict), errors.Is(err, domain.ErrStaleRevision):
writeError(w, http.StatusConflict, "conflict")
case errors.Is(err, domain.ErrTicketExpired):
writeError(w, http.StatusGone, "expired")
case errors.Is(err, domain.ErrNotTicketOwner):
writeError(w, http.StatusForbidden, "forbidden")
case errors.Is(err, domain.ErrTicketNotFound):
writeError(w, http.StatusNotFound, "not_found")
default:
writeError(w, http.StatusUnprocessableEntity, "invalid_request")
}
}
func writeError(w http.ResponseWriter, status int, code string) {
writeJSON(w, status, map[string]string{"error": code})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}