mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 00:14:00 +00:00
203 lines
5.9 KiB
Go
203 lines
5.9 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"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"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
|
|
}
|
|
|
|
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)
|
|
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 {
|
|
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 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))
|
|
}
|
|
|
|
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
|
|
}
|
|
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 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)
|
|
}
|