feat: expose documented control plane routes

This commit is contained in:
Josh Creek
2026-08-31 23:00:22 +01:00
parent 6b3db98327
commit fcc5d82763
3 changed files with 175 additions and 2 deletions
+110 -1
View File
@@ -4,7 +4,10 @@
package api
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
@@ -81,6 +84,14 @@ func (s *Service) Handler() http.Handler {
mux.HandleFunc("/v1/assignments/", s.assignment)
mux.HandleFunc("/v1/profile/ranked", s.rankedProfile)
mux.HandleFunc("/v1/probes/", s.probe)
// The public contract is served below /api/v1. Keep the original /v1
// routes for the Godot client while exposing the documented names.
mux.HandleFunc("/api/v1/session/steam", s.steamSession)
mux.HandleFunc("/api/v1/profile", s.profile)
mux.HandleFunc("/api/v1/queue/tickets", s.contractQueueCreate)
mux.HandleFunc("/api/v1/queue/tickets/", s.contractQueueMutation)
mux.HandleFunc("/api/v1/proposals/", s.contractProposalMutation)
mux.HandleFunc("/api/v1/assignments/", s.contractAssignment)
return mux
}
@@ -213,8 +224,80 @@ func (s *Service) queueCreate(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, toQueueResponse(ticket))
}
func (s *Service) contractQueueCreate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
s.queueCreate(w, r)
return
}
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBodyBytes))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request")
return
}
var fields map[string]json.RawMessage
if json.Unmarshal(body, &fields) == nil {
// Ticket IDs are server-assigned for the public contract. Deriving one
// from the authenticated request's idempotency material makes retries
// converge on the same domain command without persisting adapter state.
digest := sha256.Sum256([]byte(r.Header.Get("Authorization") + "\x00" + r.Header.Get("Idempotency-Key")))
id := hex.EncodeToString(digest[:])
if _, exists := fields["ticket_id"]; !exists {
fields["ticket_id"] = json.RawMessage(strconv.Quote(id))
body, _ = json.Marshal(fields)
}
}
r.Body = io.NopCloser(bytes.NewReader(body))
s.queueCreate(w, r)
}
func (s *Service) contractQueueMutation(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/v1/queue/tickets/")
parts := strings.Split(path, "/")
if path == "" || len(parts) > 2 || parts[0] == "" || (len(parts) == 2 && parts[1] != "heartbeat") {
writeError(w, http.StatusNotFound, "not_found")
return
}
clone := r.Clone(r.Context())
clone.URL.Path = "/v1/queue/" + parts[0]
if len(parts) == 2 {
clone.URL.Path += "/heartbeat"
}
if r.Method == http.MethodDelete {
if len(parts) != 1 {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed")
return
}
clone.Method = http.MethodPost
clone.URL.Path += "/cancel"
clone.Header.Set("X-Contract-Delete", "1")
}
s.queueMutation(w, clone)
}
func (s *Service) contractProposalMutation(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/v1/proposals/")
if path == "" {
writeError(w, http.StatusNotFound, "not_found")
return
}
clone := r.Clone(r.Context())
clone.URL.Path = "/v1/proposals/" + path
s.proposalMutation(w, clone)
}
func (s *Service) contractAssignment(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/v1/assignments/")
if path == "" || strings.Contains(path, "/") {
writeError(w, http.StatusNotFound, "not_found")
return
}
clone := r.Clone(r.Context())
clone.URL.Path = "/v1/assignments/" + path
s.assignment(w, clone)
}
func (s *Service) queueMutation(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost && r.Method != http.MethodGet {
if r.Method != http.MethodPost && r.Method != http.MethodGet && r.Method != http.MethodDelete {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed")
return
}
@@ -279,6 +362,10 @@ func (s *Service) queueMutation(w http.ResponseWriter, r *http.Request) {
writeDomainError(w, err)
return
}
if r.Header.Get("X-Contract-Delete") == "1" {
w.WriteHeader(http.StatusNoContent)
return
}
writeJSON(w, http.StatusOK, toQueueResponse(ticket))
}
@@ -387,6 +474,28 @@ type rankedProfileResponse struct {
SeasonID string `json:"season_id,omitempty"`
}
func (s *Service) profile(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
}
writeJSON(w, http.StatusOK, map[string]any{
"player_id": playerID,
"rating": profile.Value,
"rd": profile.RD,
"provisional": domain.RankedIsProvisional(profile),
})
}
func (s *Service) rankedProfile(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed")
+64
View File
@@ -97,6 +97,70 @@ func TestAuthenticatedQueueAPIUsesServerCandidateAndRevisionedMutations(t *testi
_ = response.Body.Close()
}
func TestDocumentedContractRoutesAdaptToServiceAPI(t *testing.T) {
now := time.Unix(1000, 0).UTC()
backend := &queueBackendSpy{}
service := &Service{
SessionBackend: &sessionBackendSpy{},
QueueBackend: backend,
Now: func() time.Time { return now },
}
server := httptest.NewServer(service.Handler())
defer server.Close()
auth := "Bearer session-1:token-1"
create, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/queue/tickets", strings.NewReader(`{"playlist":"casual","client_build":"build-1","protocol_version":1}`))
if err != nil {
t.Fatal(err)
}
create.Header.Set("Authorization", auth)
create.Header.Set("Idempotency-Key", "contract-create-key-123456")
response, err := http.DefaultClient.Do(create)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusCreated || backend.createCalls != 1 {
t.Fatalf("create status = %d, calls = %d", response.StatusCode, backend.createCalls)
}
var ticket queueResponse
if err := json.NewDecoder(response.Body).Decode(&ticket); err != nil {
t.Fatal(err)
}
_ = response.Body.Close()
if ticket.TicketID == "" {
t.Fatal("contract adapter did not assign a ticket id")
}
heartbeat, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/queue/tickets/"+ticket.TicketID+"/heartbeat", nil)
if err != nil {
t.Fatal(err)
}
heartbeat.Header.Set("Authorization", auth)
heartbeat.Header.Set("Idempotency-Key", "contract-heartbeat-key-123")
heartbeat.Header.Set("If-Match-Revision", "0")
response, err = http.DefaultClient.Do(heartbeat)
if err != nil {
t.Fatal(err)
}
_ = response.Body.Close()
if response.StatusCode != http.StatusOK || backend.heartbeatCalls != 1 {
t.Fatalf("heartbeat status = %d, calls = %d", response.StatusCode, backend.heartbeatCalls)
}
cancel, err := http.NewRequest(http.MethodDelete, server.URL+"/api/v1/queue/tickets/"+ticket.TicketID, nil)
if err != nil {
t.Fatal(err)
}
cancel.Header.Set("Authorization", auth)
cancel.Header.Set("Idempotency-Key", "contract-cancel-key-123456")
cancel.Header.Set("If-Match-Revision", "0")
response, err = http.DefaultClient.Do(cancel)
if err != nil {
t.Fatal(err)
}
_ = response.Body.Close()
if response.StatusCode != http.StatusNoContent || backend.cancelCalls != 1 {
t.Fatalf("cancel status = %d, calls = %d", response.StatusCode, backend.cancelCalls)
}
}
func TestQueueAPIRejectsUnauthenticatedUnknownAndOversizedInput(t *testing.T) {
service := &Service{Sessions: domain.NewSessionStore(), Queue: domain.NewQueue(), Candidate: func(string, string) (domain.Candidate, error) { return domain.Candidate{}, nil }}
server := httptest.NewServer(service.Handler())