feat: game lobby service
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
// Package httpcommon hosts cross-router HTTP middleware shared by the
|
||||
// Game Lobby Service public and internal listeners.
|
||||
package httpcommon
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"galaxy/lobby/internal/logging"
|
||||
)
|
||||
|
||||
// RequestIDHeader is the canonical HTTP header used to carry a
|
||||
// caller-supplied request id across service hops.
|
||||
const RequestIDHeader = "X-Request-Id"
|
||||
|
||||
// requestIDTokenBytes controls the entropy of generated request ids. Eight
|
||||
// bytes produce a 13-character base32 token, well above what is needed to
|
||||
// keep collisions vanishingly rare within any single service's logs.
|
||||
const requestIDTokenBytes = 8
|
||||
|
||||
// requestIDMaxLength caps the length of caller-supplied request ids so a
|
||||
// hostile or buggy upstream cannot blow up logs and trace attributes.
|
||||
const requestIDMaxLength = 128
|
||||
|
||||
// base32NoPadding mirrors the encoding used elsewhere in the lobby module
|
||||
// (see `internal/adapters/idgen`) so generated ids stay visually similar.
|
||||
var base32NoPadding = base32.StdEncoding.WithPadding(base32.NoPadding)
|
||||
|
||||
// RequestID is the HTTP middleware that materialises the per-request
|
||||
// `request_id` for downstream loggers. It reads the X-Request-Id header
|
||||
// (case-insensitively); when the header is absent, malformed, or longer
|
||||
// than requestIDMaxLength it generates a fresh token from crypto/rand.
|
||||
// The id is stored on the request context via logging.WithRequestID and
|
||||
// echoed back on the response header.
|
||||
func RequestID(next http.Handler) http.Handler {
|
||||
if next == nil {
|
||||
panic("httpcommon: nil next handler")
|
||||
}
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
requestID := normalizeRequestID(request.Header.Get(RequestIDHeader))
|
||||
if requestID == "" {
|
||||
requestID = generateRequestID()
|
||||
}
|
||||
|
||||
writer.Header().Set(RequestIDHeader, requestID)
|
||||
|
||||
ctx := logging.WithRequestID(request.Context(), requestID)
|
||||
next.ServeHTTP(writer, request.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// normalizeRequestID returns a trimmed copy of value when it satisfies the
|
||||
// per-request constraints, otherwise the empty string. The empty return
|
||||
// signals that the middleware must generate a fresh id.
|
||||
func normalizeRequestID(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if len(trimmed) > requestIDMaxLength {
|
||||
return ""
|
||||
}
|
||||
for _, r := range trimmed {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// generateRequestID returns a fresh opaque id derived from crypto/rand.
|
||||
// Errors from the random source are vanishingly unlikely; the helper
|
||||
// returns the literal "fallback" on the impossible path so the middleware
|
||||
// remains panic-free.
|
||||
func generateRequestID() string {
|
||||
buf := make([]byte, requestIDTokenBytes)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "rid-fallback"
|
||||
}
|
||||
return "rid-" + strings.ToLower(base32NoPadding.EncodeToString(buf))
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package httpcommon_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"galaxy/lobby/internal/api/httpcommon"
|
||||
"galaxy/lobby/internal/logging"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRequestIDPropagatesIncomingHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var observed string
|
||||
handler := httpcommon.RequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
observed = logging.RequestIDFromContext(r.Context())
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||
request.Header.Set(httpcommon.RequestIDHeader, "rid-test-1")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, "rid-test-1", observed)
|
||||
assert.Equal(t, "rid-test-1", recorder.Header().Get(httpcommon.RequestIDHeader))
|
||||
}
|
||||
|
||||
func TestRequestIDGeneratesWhenMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var observed string
|
||||
handler := httpcommon.RequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
observed = logging.RequestIDFromContext(r.Context())
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/foo", nil))
|
||||
|
||||
require.NotEmpty(t, observed)
|
||||
assert.True(t, strings.HasPrefix(observed, "rid-"), "got %q", observed)
|
||||
assert.Equal(t, observed, recorder.Header().Get(httpcommon.RequestIDHeader))
|
||||
}
|
||||
|
||||
func TestRequestIDRejectsControlCharacters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var observed string
|
||||
handler := httpcommon.RequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
observed = logging.RequestIDFromContext(r.Context())
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||
request.Header.Set(httpcommon.RequestIDHeader, "bad\x00id")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
require.NotEqual(t, "bad\x00id", observed)
|
||||
assert.True(t, strings.HasPrefix(observed, "rid-"))
|
||||
}
|
||||
|
||||
func TestRequestIDRejectsOverlongValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var observed string
|
||||
handler := httpcommon.RequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
observed = logging.RequestIDFromContext(r.Context())
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||
request.Header.Set(httpcommon.RequestIDHeader, strings.Repeat("a", 200))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
require.NotEqual(t, strings.Repeat("a", 200), observed)
|
||||
assert.True(t, strings.HasPrefix(observed, "rid-"))
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"galaxy/lobby/internal/domain/application"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/membership"
|
||||
"galaxy/lobby/internal/service/approveapplication"
|
||||
"galaxy/lobby/internal/service/rejectapplication"
|
||||
"galaxy/lobby/internal/service/shared"
|
||||
)
|
||||
|
||||
// Internal HTTP route patterns for the admin application
|
||||
// surface. Submit is intentionally not exposed on the internal port —
|
||||
// applicants are authenticated users, never Admin Service.
|
||||
const (
|
||||
approveApplicationPath = "/api/v1/lobby/games/{game_id}/applications/{application_id}/approve"
|
||||
rejectApplicationPath = "/api/v1/lobby/games/{game_id}/applications/{application_id}/reject"
|
||||
|
||||
applicationIDPathParamValue = "application_id"
|
||||
)
|
||||
|
||||
// applicationRecordResponse mirrors the OpenAPI ApplicationRecord schema
|
||||
// on the internal port.
|
||||
type applicationRecordResponse struct {
|
||||
ApplicationID string `json:"application_id"`
|
||||
GameID string `json:"game_id"`
|
||||
ApplicantUserID string `json:"applicant_user_id"`
|
||||
RaceName string `json:"race_name"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
DecidedAt *int64 `json:"decided_at,omitempty"`
|
||||
}
|
||||
|
||||
func encodeApplicationRecord(record application.Application) applicationRecordResponse {
|
||||
resp := applicationRecordResponse{
|
||||
ApplicationID: record.ApplicationID.String(),
|
||||
GameID: record.GameID.String(),
|
||||
ApplicantUserID: record.ApplicantUserID,
|
||||
RaceName: record.RaceName,
|
||||
Status: string(record.Status),
|
||||
CreatedAt: record.CreatedAt.UTC().UnixMilli(),
|
||||
}
|
||||
if record.DecidedAt != nil {
|
||||
decided := record.DecidedAt.UTC().UnixMilli()
|
||||
resp.DecidedAt = &decided
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// membershipRecordResponse mirrors the OpenAPI MembershipRecord schema.
|
||||
// canonical_key is intentionally omitted from the wire shape.
|
||||
type membershipRecordResponse struct {
|
||||
MembershipID string `json:"membership_id"`
|
||||
GameID string `json:"game_id"`
|
||||
UserID string `json:"user_id"`
|
||||
RaceName string `json:"race_name"`
|
||||
Status string `json:"status"`
|
||||
JoinedAt int64 `json:"joined_at"`
|
||||
RemovedAt *int64 `json:"removed_at,omitempty"`
|
||||
}
|
||||
|
||||
func encodeMembershipRecord(record membership.Membership) membershipRecordResponse {
|
||||
resp := membershipRecordResponse{
|
||||
MembershipID: record.MembershipID.String(),
|
||||
GameID: record.GameID.String(),
|
||||
UserID: record.UserID,
|
||||
RaceName: record.RaceName,
|
||||
Status: string(record.Status),
|
||||
JoinedAt: record.JoinedAt.UTC().UnixMilli(),
|
||||
}
|
||||
if record.RemovedAt != nil {
|
||||
removed := record.RemovedAt.UTC().UnixMilli()
|
||||
resp.RemovedAt = &removed
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func registerApplicationRoutes(mux *http.ServeMux, deps Dependencies, logger *slog.Logger) {
|
||||
h := &applicationHandlers{
|
||||
deps: deps,
|
||||
logger: logger.With("component", "internal_http.applications"),
|
||||
}
|
||||
mux.HandleFunc("POST "+approveApplicationPath, h.handleApprove)
|
||||
mux.HandleFunc("POST "+rejectApplicationPath, h.handleReject)
|
||||
}
|
||||
|
||||
type applicationHandlers struct {
|
||||
deps Dependencies
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (h *applicationHandlers) extractGameID(writer http.ResponseWriter, request *http.Request) (common.GameID, bool) {
|
||||
raw := request.PathValue(gameIDPathParamValue)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_request", "game id is required")
|
||||
return "", false
|
||||
}
|
||||
return common.GameID(raw), true
|
||||
}
|
||||
|
||||
func (h *applicationHandlers) extractApplicationID(writer http.ResponseWriter, request *http.Request) (common.ApplicationID, bool) {
|
||||
raw := request.PathValue(applicationIDPathParamValue)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_request", "application id is required")
|
||||
return "", false
|
||||
}
|
||||
return common.ApplicationID(raw), true
|
||||
}
|
||||
|
||||
func (h *applicationHandlers) handleApprove(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.ApproveApplication == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "approve application service is not wired")
|
||||
return
|
||||
}
|
||||
gameID, ok := h.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
applicationID, ok := h.extractApplicationID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.deps.ApproveApplication.Handle(request.Context(), approveapplication.Input{
|
||||
Actor: shared.NewAdminActor(),
|
||||
GameID: gameID,
|
||||
ApplicationID: applicationID,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, encodeMembershipRecord(record))
|
||||
}
|
||||
|
||||
func (h *applicationHandlers) handleReject(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.RejectApplication == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "reject application service is not wired")
|
||||
return
|
||||
}
|
||||
gameID, ok := h.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
applicationID, ok := h.extractApplicationID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.deps.RejectApplication.Handle(request.Context(), rejectapplication.Input{
|
||||
Actor: shared.NewAdminActor(),
|
||||
GameID: gameID,
|
||||
ApplicationID: applicationID,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, encodeApplicationRecord(record))
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/domain/application"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/membership"
|
||||
"galaxy/lobby/internal/ports"
|
||||
"galaxy/lobby/internal/service/cancelgame"
|
||||
"galaxy/lobby/internal/service/creategame"
|
||||
"galaxy/lobby/internal/service/getgame"
|
||||
"galaxy/lobby/internal/service/listgames"
|
||||
"galaxy/lobby/internal/service/openenrollment"
|
||||
"galaxy/lobby/internal/service/shared"
|
||||
"galaxy/lobby/internal/service/updategame"
|
||||
)
|
||||
|
||||
// Internal HTTP route patterns registered by registerGameRoutes. The Admin
|
||||
// Service path set mirrors the public-port paths (see §internal-openapi.yaml
|
||||
// under the AdminGames tag).
|
||||
const (
|
||||
gamesCollectionPath = "/api/v1/lobby/games"
|
||||
gameItemPath = "/api/v1/lobby/games/{game_id}"
|
||||
openEnrollmentPath = "/api/v1/lobby/games/{game_id}/open-enrollment"
|
||||
cancelGamePath = "/api/v1/lobby/games/{game_id}/cancel"
|
||||
gameIDPathParamValue = "game_id"
|
||||
internalGameItemPath = "/api/v1/internal/games/{game_id}"
|
||||
internalGameMembershipPath = "/api/v1/internal/games/{game_id}/memberships"
|
||||
)
|
||||
|
||||
// errorResponse mirrors the `{ "error": { ... } }` shape documented in the
|
||||
// internal OpenAPI contract.
|
||||
type errorResponse struct {
|
||||
Error errorBody `json:"error"`
|
||||
}
|
||||
|
||||
type errorBody struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// createGameRequest is the JSON shape for POST /api/v1/lobby/games on the
|
||||
// internal port.
|
||||
type createGameRequest struct {
|
||||
GameName string `json:"game_name"`
|
||||
Description string `json:"description"`
|
||||
GameType string `json:"game_type"`
|
||||
MinPlayers int `json:"min_players"`
|
||||
MaxPlayers int `json:"max_players"`
|
||||
StartGapHours int `json:"start_gap_hours"`
|
||||
StartGapPlayers int `json:"start_gap_players"`
|
||||
EnrollmentEndsAt int64 `json:"enrollment_ends_at"`
|
||||
TurnSchedule string `json:"turn_schedule"`
|
||||
TargetEngineVersion string `json:"target_engine_version"`
|
||||
}
|
||||
|
||||
// updateGameRequest is the JSON shape for PATCH /api/v1/lobby/games/{id} on
|
||||
// the internal port. Fields match the AdminGames contract.
|
||||
type updateGameRequest struct {
|
||||
GameName *string `json:"game_name"`
|
||||
Description *string `json:"description"`
|
||||
MinPlayers *int `json:"min_players"`
|
||||
MaxPlayers *int `json:"max_players"`
|
||||
StartGapHours *int `json:"start_gap_hours"`
|
||||
StartGapPlayers *int `json:"start_gap_players"`
|
||||
EnrollmentEndsAt *int64 `json:"enrollment_ends_at"`
|
||||
TurnSchedule *string `json:"turn_schedule"`
|
||||
TargetEngineVersion *string `json:"target_engine_version"`
|
||||
}
|
||||
|
||||
// gameRecordResponse mirrors the GameRecord schema in internal-openapi.yaml.
|
||||
type gameRecordResponse struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
GameType string `json:"game_type"`
|
||||
OwnerUserID string `json:"owner_user_id"`
|
||||
Status string `json:"status"`
|
||||
MinPlayers int `json:"min_players"`
|
||||
MaxPlayers int `json:"max_players"`
|
||||
StartGapHours int `json:"start_gap_hours"`
|
||||
StartGapPlayers int `json:"start_gap_players"`
|
||||
EnrollmentEndsAt int64 `json:"enrollment_ends_at"`
|
||||
TurnSchedule string `json:"turn_schedule"`
|
||||
TargetEngineVersion string `json:"target_engine_version"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
StartedAt *int64 `json:"started_at,omitempty"`
|
||||
FinishedAt *int64 `json:"finished_at,omitempty"`
|
||||
CurrentTurn int `json:"current_turn"`
|
||||
RuntimeStatus string `json:"runtime_status"`
|
||||
EngineHealthSummary string `json:"engine_health_summary"`
|
||||
RuntimeBinding *runtimeBindingResponse `json:"runtime_binding,omitempty"`
|
||||
}
|
||||
|
||||
// runtimeBindingResponse mirrors the RuntimeBinding schema. It is set
|
||||
// only after a successful container start.
|
||||
type runtimeBindingResponse struct {
|
||||
ContainerID string `json:"container_id"`
|
||||
EngineEndpoint string `json:"engine_endpoint"`
|
||||
RuntimeJobID string `json:"runtime_job_id"`
|
||||
BoundAt int64 `json:"bound_at"`
|
||||
}
|
||||
|
||||
// encodeGameRecord converts one domain Game into the wire GameRecord.
|
||||
func encodeGameRecord(record game.Game) gameRecordResponse {
|
||||
resp := gameRecordResponse{
|
||||
GameID: record.GameID.String(),
|
||||
GameName: record.GameName,
|
||||
Description: record.Description,
|
||||
GameType: string(record.GameType),
|
||||
OwnerUserID: record.OwnerUserID,
|
||||
Status: string(record.Status),
|
||||
MinPlayers: record.MinPlayers,
|
||||
MaxPlayers: record.MaxPlayers,
|
||||
StartGapHours: record.StartGapHours,
|
||||
StartGapPlayers: record.StartGapPlayers,
|
||||
EnrollmentEndsAt: record.EnrollmentEndsAt.UTC().Unix(),
|
||||
TurnSchedule: record.TurnSchedule,
|
||||
TargetEngineVersion: record.TargetEngineVersion,
|
||||
CreatedAt: record.CreatedAt.UTC().UnixMilli(),
|
||||
UpdatedAt: record.UpdatedAt.UTC().UnixMilli(),
|
||||
CurrentTurn: record.RuntimeSnapshot.CurrentTurn,
|
||||
RuntimeStatus: record.RuntimeSnapshot.RuntimeStatus,
|
||||
EngineHealthSummary: record.RuntimeSnapshot.EngineHealthSummary,
|
||||
}
|
||||
if record.StartedAt != nil {
|
||||
started := record.StartedAt.UTC().UnixMilli()
|
||||
resp.StartedAt = &started
|
||||
}
|
||||
if record.FinishedAt != nil {
|
||||
finished := record.FinishedAt.UTC().UnixMilli()
|
||||
resp.FinishedAt = &finished
|
||||
}
|
||||
if record.RuntimeBinding != nil {
|
||||
resp.RuntimeBinding = &runtimeBindingResponse{
|
||||
ContainerID: record.RuntimeBinding.ContainerID,
|
||||
EngineEndpoint: record.RuntimeBinding.EngineEndpoint,
|
||||
RuntimeJobID: record.RuntimeBinding.RuntimeJobID,
|
||||
BoundAt: record.RuntimeBinding.BoundAt.UTC().UnixMilli(),
|
||||
}
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func decodeStrictJSON(body io.Reader, target any) error {
|
||||
decoder := json.NewDecoder(body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if decoder.More() {
|
||||
return errors.New("unexpected trailing content after JSON body")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeJSON(writer http.ResponseWriter, statusCode int, payload any) {
|
||||
writer.Header().Set("Content-Type", jsonContentType)
|
||||
writer.WriteHeader(statusCode)
|
||||
_ = json.NewEncoder(writer).Encode(payload)
|
||||
}
|
||||
|
||||
func writeError(writer http.ResponseWriter, statusCode int, code, message string) {
|
||||
writeJSON(writer, statusCode, errorResponse{Error: errorBody{Code: code, Message: message}})
|
||||
}
|
||||
|
||||
func writeErrorFromService(writer http.ResponseWriter, logger *slog.Logger, err error) {
|
||||
switch {
|
||||
case errors.Is(err, shared.ErrForbidden):
|
||||
writeError(writer, http.StatusForbidden, "forbidden", "access denied")
|
||||
case errors.Is(err, game.ErrNotFound),
|
||||
errors.Is(err, application.ErrNotFound),
|
||||
errors.Is(err, membership.ErrNotFound):
|
||||
writeError(writer, http.StatusNotFound, "subject_not_found", "resource not found")
|
||||
case errors.Is(err, game.ErrConflict),
|
||||
errors.Is(err, game.ErrInvalidTransition),
|
||||
errors.Is(err, application.ErrConflict),
|
||||
errors.Is(err, application.ErrInvalidTransition),
|
||||
errors.Is(err, membership.ErrConflict),
|
||||
errors.Is(err, membership.ErrInvalidTransition):
|
||||
writeError(writer, http.StatusConflict, "conflict", "operation not allowed in current status")
|
||||
case errors.Is(err, shared.ErrEligibilityDenied):
|
||||
writeError(writer, http.StatusUnprocessableEntity, "eligibility_denied", "user is not eligible to join games")
|
||||
case errors.Is(err, ports.ErrNameTaken):
|
||||
writeError(writer, http.StatusUnprocessableEntity, "name_taken", "race name is already taken")
|
||||
case errors.Is(err, shared.ErrServiceUnavailable),
|
||||
errors.Is(err, ports.ErrUserServiceUnavailable):
|
||||
writeError(writer, http.StatusServiceUnavailable, "service_unavailable", "service is unavailable")
|
||||
case isValidationError(err):
|
||||
writeError(writer, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
default:
|
||||
if logger != nil {
|
||||
logger.Error("unhandled service error", "err", err.Error())
|
||||
}
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "internal server error")
|
||||
}
|
||||
}
|
||||
|
||||
// isValidationError reports whether err carries a domain-validation
|
||||
// signature. The helper mirrors the one in publichttp and is duplicated
|
||||
// intentionally to keep the two HTTP packages independent.
|
||||
func isValidationError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case strings.Contains(msg, "must "),
|
||||
strings.Contains(msg, "must not"),
|
||||
strings.Contains(msg, "is unsupported"),
|
||||
strings.Contains(msg, "invalid"):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// registerGameRoutes binds the game-lifecycle and
|
||||
// game-read routes on mux using the admin actor shape (trusted caller,
|
||||
// no X-User-ID header).
|
||||
func registerGameRoutes(mux *http.ServeMux, deps Dependencies, logger *slog.Logger) {
|
||||
h := &gameHandlers{
|
||||
deps: deps,
|
||||
logger: logger.With("component", "internal_http.games"),
|
||||
}
|
||||
mux.HandleFunc("POST "+gamesCollectionPath, h.handleCreate)
|
||||
mux.HandleFunc("GET "+gamesCollectionPath, h.handleList)
|
||||
mux.HandleFunc("GET "+gameItemPath, h.handleGet)
|
||||
mux.HandleFunc("PATCH "+gameItemPath, h.handleUpdate)
|
||||
mux.HandleFunc("POST "+openEnrollmentPath, h.handleOpenEnrollment)
|
||||
mux.HandleFunc("POST "+cancelGamePath, h.handleCancel)
|
||||
mux.HandleFunc("GET "+internalGameItemPath, h.handleGet)
|
||||
}
|
||||
|
||||
type gameHandlers struct {
|
||||
deps Dependencies
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (h *gameHandlers) extractGameID(writer http.ResponseWriter, request *http.Request) (common.GameID, bool) {
|
||||
raw := request.PathValue(gameIDPathParamValue)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_request", "game id is required")
|
||||
return "", false
|
||||
}
|
||||
return common.GameID(raw), true
|
||||
}
|
||||
|
||||
func (h *gameHandlers) handleCreate(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.CreateGame == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "create game service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
var body createGameRequest
|
||||
if err := decodeStrictJSON(request.Body, &body); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.deps.CreateGame.Handle(request.Context(), creategame.Input{
|
||||
Actor: shared.NewAdminActor(),
|
||||
GameName: body.GameName,
|
||||
Description: body.Description,
|
||||
GameType: game.GameType(body.GameType),
|
||||
MinPlayers: body.MinPlayers,
|
||||
MaxPlayers: body.MaxPlayers,
|
||||
StartGapHours: body.StartGapHours,
|
||||
StartGapPlayers: body.StartGapPlayers,
|
||||
EnrollmentEndsAt: time.Unix(body.EnrollmentEndsAt, 0).UTC(),
|
||||
TurnSchedule: body.TurnSchedule,
|
||||
TargetEngineVersion: body.TargetEngineVersion,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusCreated, encodeGameRecord(record))
|
||||
}
|
||||
|
||||
func (h *gameHandlers) handleUpdate(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.UpdateGame == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "update game service is not wired")
|
||||
return
|
||||
}
|
||||
gameID, ok := h.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var body updateGameRequest
|
||||
if err := decodeStrictJSON(request.Body, &body); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
input := updategame.Input{
|
||||
Actor: shared.NewAdminActor(),
|
||||
GameID: gameID,
|
||||
GameName: body.GameName,
|
||||
Description: body.Description,
|
||||
MinPlayers: body.MinPlayers,
|
||||
MaxPlayers: body.MaxPlayers,
|
||||
StartGapHours: body.StartGapHours,
|
||||
StartGapPlayers: body.StartGapPlayers,
|
||||
TurnSchedule: body.TurnSchedule,
|
||||
TargetEngineVersion: body.TargetEngineVersion,
|
||||
}
|
||||
if body.EnrollmentEndsAt != nil {
|
||||
t := time.Unix(*body.EnrollmentEndsAt, 0).UTC()
|
||||
input.EnrollmentEndsAt = &t
|
||||
}
|
||||
|
||||
record, err := h.deps.UpdateGame.Handle(request.Context(), input)
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeGameRecord(record))
|
||||
}
|
||||
|
||||
func (h *gameHandlers) handleOpenEnrollment(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.OpenEnrollment == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "open enrollment service is not wired")
|
||||
return
|
||||
}
|
||||
gameID, ok := h.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.deps.OpenEnrollment.Handle(request.Context(), openenrollment.Input{
|
||||
Actor: shared.NewAdminActor(),
|
||||
GameID: gameID,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeGameRecord(record))
|
||||
}
|
||||
|
||||
func (h *gameHandlers) handleCancel(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.CancelGame == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "cancel game service is not wired")
|
||||
return
|
||||
}
|
||||
gameID, ok := h.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.deps.CancelGame.Handle(request.Context(), cancelgame.Input{
|
||||
Actor: shared.NewAdminActor(),
|
||||
GameID: gameID,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeGameRecord(record))
|
||||
}
|
||||
|
||||
// gameListResponse mirrors the OpenAPI GameListResponse schema. Items
|
||||
// are always non-nil so the JSON form carries `[]` rather than `null`
|
||||
// for empty pages.
|
||||
type gameListResponse struct {
|
||||
Items []gameRecordResponse `json:"items"`
|
||||
NextPageToken string `json:"next_page_token,omitempty"`
|
||||
}
|
||||
|
||||
func encodeGameList(items []game.Game, nextPageToken string) gameListResponse {
|
||||
resp := gameListResponse{
|
||||
Items: make([]gameRecordResponse, 0, len(items)),
|
||||
NextPageToken: nextPageToken,
|
||||
}
|
||||
for _, item := range items {
|
||||
resp.Items = append(resp.Items, encodeGameRecord(item))
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// parsePage decodes the `page_size` and `page_token` query parameters
|
||||
// into a shared.Page. On failure it writes the OpenAPI-shaped
|
||||
// invalid_request envelope and returns ok=false so the caller can
|
||||
// short-circuit.
|
||||
func parsePage(writer http.ResponseWriter, request *http.Request) (shared.Page, bool) {
|
||||
page, err := shared.ParsePage(
|
||||
request.URL.Query().Get("page_size"),
|
||||
request.URL.Query().Get("page_token"),
|
||||
)
|
||||
if err != nil {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return shared.Page{}, false
|
||||
}
|
||||
return page, true
|
||||
}
|
||||
|
||||
func (h *gameHandlers) handleGet(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.GetGame == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "get game service is not wired")
|
||||
return
|
||||
}
|
||||
gameID, ok := h.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.deps.GetGame.Handle(request.Context(), getgame.Input{
|
||||
Actor: shared.NewAdminActor(),
|
||||
GameID: gameID,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeGameRecord(record))
|
||||
}
|
||||
|
||||
func (h *gameHandlers) handleList(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.ListGames == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "list games service is not wired")
|
||||
return
|
||||
}
|
||||
page, ok := parsePage(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
out, err := h.deps.ListGames.Handle(request.Context(), listgames.Input{
|
||||
Actor: shared.NewAdminActor(),
|
||||
Page: page,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeGameList(out.Items, out.NextPageToken))
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/ports"
|
||||
"galaxy/lobby/internal/service/cancelgame"
|
||||
"galaxy/lobby/internal/service/creategame"
|
||||
"galaxy/lobby/internal/service/openenrollment"
|
||||
"galaxy/lobby/internal/service/updategame"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type stubIDGenerator struct {
|
||||
next common.GameID
|
||||
}
|
||||
|
||||
func (g *stubIDGenerator) NewGameID() (common.GameID, error) {
|
||||
return g.next, nil
|
||||
}
|
||||
|
||||
func (g *stubIDGenerator) NewApplicationID() (common.ApplicationID, error) {
|
||||
return "application-stub", nil
|
||||
}
|
||||
|
||||
func (g *stubIDGenerator) NewInviteID() (common.InviteID, error) {
|
||||
return "invite-stub", nil
|
||||
}
|
||||
|
||||
func (g *stubIDGenerator) NewMembershipID() (common.MembershipID, error) {
|
||||
return "membership-stub", nil
|
||||
}
|
||||
|
||||
func silentLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func fixedClock(at time.Time) func() time.Time {
|
||||
return func() time.Time { return at }
|
||||
}
|
||||
|
||||
func buildHandler(t *testing.T, store *gamestub.Store, ids ports.IDGenerator, clock func() time.Time) http.Handler {
|
||||
t.Helper()
|
||||
|
||||
logger := silentLogger()
|
||||
|
||||
createSvc, err := creategame.NewService(creategame.Dependencies{
|
||||
Games: store,
|
||||
IDs: ids,
|
||||
Clock: clock,
|
||||
Logger: logger,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
updateSvc, err := updategame.NewService(updategame.Dependencies{
|
||||
Games: store,
|
||||
Clock: clock,
|
||||
Logger: logger,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
openSvc, err := openenrollment.NewService(openenrollment.Dependencies{
|
||||
Games: store,
|
||||
Clock: clock,
|
||||
Logger: logger,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
cancelSvc, err := cancelgame.NewService(cancelgame.Dependencies{
|
||||
Games: store,
|
||||
Clock: clock,
|
||||
Logger: logger,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return newHandler(Dependencies{
|
||||
Logger: logger,
|
||||
CreateGame: createSvc,
|
||||
UpdateGame: updateSvc,
|
||||
OpenEnrollment: openSvc,
|
||||
CancelGame: cancelSvc,
|
||||
}, logger)
|
||||
}
|
||||
|
||||
func doRequest(t *testing.T, handler http.Handler, method, path string, body any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
reader = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
req := httptest.NewRequestWithContext(context.Background(), method, path, reader)
|
||||
if reader != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func decodeGameRecord(t *testing.T, rec *httptest.ResponseRecorder) gameRecordResponse {
|
||||
t.Helper()
|
||||
|
||||
var payload gameRecordResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
||||
return payload
|
||||
}
|
||||
|
||||
func decodeError(t *testing.T, rec *httptest.ResponseRecorder) errorResponse {
|
||||
t.Helper()
|
||||
|
||||
var payload errorResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
||||
return payload
|
||||
}
|
||||
|
||||
func TestAdminCreatesPublicGame(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
handler := buildHandler(t, store, &stubIDGenerator{next: "game-public"}, fixedClock(now))
|
||||
|
||||
body := createGameRequest{
|
||||
GameName: "Winter Open",
|
||||
GameType: "public",
|
||||
MinPlayers: 4,
|
||||
MaxPlayers: 8,
|
||||
StartGapHours: 6,
|
||||
StartGapPlayers: 2,
|
||||
EnrollmentEndsAt: now.Add(48 * time.Hour).Unix(),
|
||||
TurnSchedule: "0 */4 * * *",
|
||||
TargetEngineVersion: "2.0.0",
|
||||
}
|
||||
rec := doRequest(t, handler, http.MethodPost, "/api/v1/lobby/games", body)
|
||||
require.Equal(t, http.StatusCreated, rec.Code)
|
||||
|
||||
decoded := decodeGameRecord(t, rec)
|
||||
require.Equal(t, "public", decoded.GameType)
|
||||
require.Equal(t, "", decoded.OwnerUserID)
|
||||
require.Equal(t, "draft", decoded.Status)
|
||||
}
|
||||
|
||||
func TestAdminCannotCreatePrivateGame(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
handler := buildHandler(t, gamestub.NewStore(), &stubIDGenerator{next: "game-priv"}, fixedClock(now))
|
||||
|
||||
body := createGameRequest{
|
||||
GameName: "Private Lobby",
|
||||
GameType: "private",
|
||||
MinPlayers: 2,
|
||||
MaxPlayers: 4,
|
||||
StartGapHours: 4,
|
||||
StartGapPlayers: 1,
|
||||
EnrollmentEndsAt: now.Add(time.Hour).Unix(),
|
||||
TurnSchedule: "0 0 * * *",
|
||||
TargetEngineVersion: "1.0.0",
|
||||
}
|
||||
rec := doRequest(t, handler, http.MethodPost, "/api/v1/lobby/games", body)
|
||||
require.Equal(t, http.StatusForbidden, rec.Code)
|
||||
decoded := decodeError(t, rec)
|
||||
require.Equal(t, "forbidden", decoded.Error.Code)
|
||||
}
|
||||
|
||||
func TestAdminValidationError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
handler := buildHandler(t, gamestub.NewStore(), &stubIDGenerator{next: "game-bad"}, fixedClock(now))
|
||||
|
||||
body := createGameRequest{
|
||||
GameName: "",
|
||||
GameType: "public",
|
||||
MinPlayers: 2,
|
||||
MaxPlayers: 4,
|
||||
StartGapHours: 4,
|
||||
StartGapPlayers: 1,
|
||||
EnrollmentEndsAt: now.Add(time.Hour).Unix(),
|
||||
TurnSchedule: "0 0 * * *",
|
||||
TargetEngineVersion: "1.0.0",
|
||||
}
|
||||
rec := doRequest(t, handler, http.MethodPost, "/api/v1/lobby/games", body)
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
decoded := decodeError(t, rec)
|
||||
require.Equal(t, "invalid_request", decoded.Error.Code)
|
||||
}
|
||||
|
||||
func TestAdminUpdateAllFieldsInDraft(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
seedDraftForTest(t, store, "game-u", game.GameTypePublic, "", now)
|
||||
|
||||
handler := buildHandler(t, store, &stubIDGenerator{next: "unused"}, fixedClock(now.Add(time.Hour)))
|
||||
|
||||
desc := "Updated by admin"
|
||||
body := updateGameRequest{Description: &desc}
|
||||
rec := doRequest(t, handler, http.MethodPatch, "/api/v1/lobby/games/game-u", body)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
decoded := decodeGameRecord(t, rec)
|
||||
require.Equal(t, "Updated by admin", decoded.Description)
|
||||
}
|
||||
|
||||
func TestAdminOpenEnrollment(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
seedDraftForTest(t, store, "game-oe", game.GameTypePublic, "", now)
|
||||
|
||||
handler := buildHandler(t, store, &stubIDGenerator{next: "unused"}, fixedClock(now.Add(time.Hour)))
|
||||
|
||||
rec := doRequest(t, handler, http.MethodPost, "/api/v1/lobby/games/game-oe/open-enrollment", nil)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
decoded := decodeGameRecord(t, rec)
|
||||
require.Equal(t, "enrollment_open", decoded.Status)
|
||||
}
|
||||
|
||||
func TestAdminCancelFromRunning(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
record := seedDraftForTest(t, store, "game-run", game.GameTypePublic, "", now)
|
||||
// Force status to running to exercise the 409 conflict path.
|
||||
record.Status = game.StatusRunning
|
||||
startedAt := now.Add(time.Minute)
|
||||
record.StartedAt = &startedAt
|
||||
record.UpdatedAt = startedAt
|
||||
require.NoError(t, store.Save(context.Background(), record))
|
||||
|
||||
handler := buildHandler(t, store, &stubIDGenerator{next: "unused"}, fixedClock(now.Add(time.Hour)))
|
||||
|
||||
rec := doRequest(t, handler, http.MethodPost, "/api/v1/lobby/games/game-run/cancel", nil)
|
||||
require.Equal(t, http.StatusConflict, rec.Code)
|
||||
decoded := decodeError(t, rec)
|
||||
require.Equal(t, "conflict", decoded.Error.Code)
|
||||
}
|
||||
|
||||
func TestAdminUpdateNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
handler := buildHandler(t, gamestub.NewStore(), &stubIDGenerator{next: "unused"}, fixedClock(now))
|
||||
|
||||
desc := "x"
|
||||
body := updateGameRequest{Description: &desc}
|
||||
rec := doRequest(t, handler, http.MethodPatch, "/api/v1/lobby/games/game-missing", body)
|
||||
require.Equal(t, http.StatusNotFound, rec.Code)
|
||||
}
|
||||
|
||||
func TestAdminCreateUnknownFieldRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
handler := buildHandler(t, gamestub.NewStore(), &stubIDGenerator{next: "unused"}, fixedClock(now))
|
||||
|
||||
reqBody := map[string]any{
|
||||
"game_name": "x",
|
||||
"game_type": "public",
|
||||
"min_players": 2,
|
||||
"max_players": 4,
|
||||
"start_gap_hours": 4,
|
||||
"start_gap_players": 1,
|
||||
"enrollment_ends_at": now.Add(time.Hour).Unix(),
|
||||
"turn_schedule": "0 0 * * *",
|
||||
"target_engine_version": "1.0.0",
|
||||
"unexpected": "nope",
|
||||
}
|
||||
rec := doRequest(t, handler, http.MethodPost, "/api/v1/lobby/games", reqBody)
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
}
|
||||
|
||||
func seedDraftForTest(
|
||||
t *testing.T,
|
||||
store *gamestub.Store,
|
||||
id common.GameID,
|
||||
gameType game.GameType,
|
||||
ownerUserID string,
|
||||
now time.Time,
|
||||
) game.Game {
|
||||
t.Helper()
|
||||
|
||||
record, err := game.New(game.NewGameInput{
|
||||
GameID: id,
|
||||
GameName: "Seed",
|
||||
GameType: gameType,
|
||||
OwnerUserID: ownerUserID,
|
||||
MinPlayers: 2,
|
||||
MaxPlayers: 4,
|
||||
StartGapHours: 4,
|
||||
StartGapPlayers: 1,
|
||||
EnrollmentEndsAt: now.Add(24 * time.Hour),
|
||||
TurnSchedule: "0 */6 * * *",
|
||||
TargetEngineVersion: "1.0.0",
|
||||
Now: now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, store.Save(context.Background(), record))
|
||||
return record
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/membership"
|
||||
"galaxy/lobby/internal/service/blockmember"
|
||||
"galaxy/lobby/internal/service/listmemberships"
|
||||
"galaxy/lobby/internal/service/removemember"
|
||||
"galaxy/lobby/internal/service/shared"
|
||||
)
|
||||
|
||||
// Internal HTTP route patterns for the membership
|
||||
// operations.
|
||||
const (
|
||||
listMembershipsPath = "/api/v1/lobby/games/{game_id}/memberships"
|
||||
removeMemberPath = "/api/v1/lobby/games/{game_id}/memberships/{membership_id}/remove"
|
||||
blockMemberPath = "/api/v1/lobby/games/{game_id}/memberships/{membership_id}/block"
|
||||
membershipIDPathParamValue = "membership_id"
|
||||
)
|
||||
|
||||
// registerMembershipRoutes binds the membership
|
||||
// routes on the internal port. The actor is always admin (Admin
|
||||
// Service / Game Master are the trusted callers).
|
||||
func registerMembershipRoutes(mux *http.ServeMux, deps Dependencies, logger *slog.Logger) {
|
||||
h := &membershipHandlers{
|
||||
deps: deps,
|
||||
logger: logger.With("component", "internal_http.memberships"),
|
||||
}
|
||||
mux.HandleFunc("GET "+listMembershipsPath, h.handleList)
|
||||
mux.HandleFunc("GET "+internalGameMembershipPath, h.handleList)
|
||||
mux.HandleFunc("POST "+removeMemberPath, h.handleRemove)
|
||||
mux.HandleFunc("POST "+blockMemberPath, h.handleBlock)
|
||||
}
|
||||
|
||||
type membershipHandlers struct {
|
||||
deps Dependencies
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (h *membershipHandlers) extractMembershipID(writer http.ResponseWriter, request *http.Request) (common.MembershipID, bool) {
|
||||
raw := request.PathValue(membershipIDPathParamValue)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_request", "membership id is required")
|
||||
return "", false
|
||||
}
|
||||
return common.MembershipID(raw), true
|
||||
}
|
||||
|
||||
func (h *membershipHandlers) handleRemove(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.RemoveMember == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "remove member service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
games := &gameHandlers{deps: h.deps, logger: h.logger}
|
||||
gameID, ok := games.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
membershipID, ok := h.extractMembershipID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.deps.RemoveMember.Handle(request.Context(), removemember.Input{
|
||||
Actor: shared.NewAdminActor(),
|
||||
GameID: gameID,
|
||||
MembershipID: membershipID,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeMembershipRecord(record))
|
||||
}
|
||||
|
||||
// membershipListResponse mirrors the OpenAPI MembershipListResponse
|
||||
// schema. Items are always non-nil so the JSON form carries `[]`
|
||||
// rather than `null` for empty pages.
|
||||
type membershipListResponse struct {
|
||||
Items []membershipRecordResponse `json:"items"`
|
||||
NextPageToken string `json:"next_page_token,omitempty"`
|
||||
}
|
||||
|
||||
func encodeMembershipList(items []membership.Membership, nextPageToken string) membershipListResponse {
|
||||
resp := membershipListResponse{
|
||||
Items: make([]membershipRecordResponse, 0, len(items)),
|
||||
NextPageToken: nextPageToken,
|
||||
}
|
||||
for _, item := range items {
|
||||
resp.Items = append(resp.Items, encodeMembershipRecord(item))
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func (h *membershipHandlers) handleList(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.ListMemberships == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error",
|
||||
"list memberships service is not wired")
|
||||
return
|
||||
}
|
||||
games := &gameHandlers{deps: h.deps, logger: h.logger}
|
||||
gameID, ok := games.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, ok := parsePage(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
out, err := h.deps.ListMemberships.Handle(request.Context(), listmemberships.Input{
|
||||
Actor: shared.NewAdminActor(),
|
||||
GameID: gameID,
|
||||
Page: page,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeMembershipList(out.Items, out.NextPageToken))
|
||||
}
|
||||
|
||||
func (h *membershipHandlers) handleBlock(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.BlockMember == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "block member service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
games := &gameHandlers{deps: h.deps, logger: h.logger}
|
||||
gameID, ok := games.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
membershipID, ok := h.extractMembershipID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.deps.BlockMember.Handle(request.Context(), blockmember.Input{
|
||||
Actor: shared.NewAdminActor(),
|
||||
GameID: gameID,
|
||||
MembershipID: membershipID,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeMembershipRecord(record))
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"galaxy/lobby/internal/service/pausegame"
|
||||
"galaxy/lobby/internal/service/resumegame"
|
||||
"galaxy/lobby/internal/service/shared"
|
||||
)
|
||||
|
||||
const (
|
||||
pauseGamePath = "/api/v1/lobby/games/{game_id}/pause"
|
||||
resumeGamePath = "/api/v1/lobby/games/{game_id}/resume"
|
||||
)
|
||||
|
||||
// registerPauseResumeRoutes binds the admin pause and resume
|
||||
// routes on the internal port. The actor is always admin (Admin
|
||||
// Service is the trusted caller).
|
||||
func registerPauseResumeRoutes(mux *http.ServeMux, deps Dependencies, logger *slog.Logger) {
|
||||
h := &pauseResumeHandlers{
|
||||
deps: deps,
|
||||
logger: logger.With("component", "internal_http.pauseresume"),
|
||||
}
|
||||
mux.HandleFunc("POST "+pauseGamePath, h.handlePause)
|
||||
mux.HandleFunc("POST "+resumeGamePath, h.handleResume)
|
||||
}
|
||||
|
||||
type pauseResumeHandlers struct {
|
||||
deps Dependencies
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (h *pauseResumeHandlers) handlePause(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.PauseGame == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "pause game service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
games := &gameHandlers{deps: h.deps, logger: h.logger}
|
||||
gameID, ok := games.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.deps.PauseGame.Handle(request.Context(), pausegame.Input{
|
||||
Actor: shared.NewAdminActor(),
|
||||
GameID: gameID,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeGameRecord(record))
|
||||
}
|
||||
|
||||
func (h *pauseResumeHandlers) handleResume(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.ResumeGame == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "resume game service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
games := &gameHandlers{deps: h.deps, logger: h.logger}
|
||||
gameID, ok := games.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.deps.ResumeGame.Handle(request.Context(), resumegame.Input{
|
||||
Actor: shared.NewAdminActor(),
|
||||
GameID: gameID,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeGameRecord(record))
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"galaxy/lobby/internal/service/manualreadytostart"
|
||||
"galaxy/lobby/internal/service/shared"
|
||||
)
|
||||
|
||||
const readyToStartPath = "/api/v1/lobby/games/{game_id}/ready-to-start"
|
||||
|
||||
// registerReadyToStartRoutes binds the admin manual ready-to-start
|
||||
// route on the internal port. The actor is always admin (Admin Service is
|
||||
// the trusted caller; the internal port is not reachable from the public
|
||||
// internet).
|
||||
func registerReadyToStartRoutes(mux *http.ServeMux, deps Dependencies, logger *slog.Logger) {
|
||||
h := &readyToStartHandlers{
|
||||
deps: deps,
|
||||
logger: logger.With("component", "internal_http.ready_to_start"),
|
||||
}
|
||||
mux.HandleFunc("POST "+readyToStartPath, h.handle)
|
||||
}
|
||||
|
||||
type readyToStartHandlers struct {
|
||||
deps Dependencies
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (h *readyToStartHandlers) handle(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.ManualReadyToStart == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "manual ready-to-start service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
games := &gameHandlers{deps: h.deps, logger: h.logger}
|
||||
gameID, ok := games.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.deps.ManualReadyToStart.Handle(request.Context(), manualreadytostart.Input{
|
||||
Actor: shared.NewAdminActor(),
|
||||
GameID: gameID,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeGameRecord(record))
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
// Package internalhttp provides the trusted internal HTTP listener used by
|
||||
// the runnable Game Lobby Service process. In the runnable
|
||||
// skeleton it exposes only the platform liveness and readiness probes;
|
||||
// later stages add Game Master registration and admin routes.
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/api/httpcommon"
|
||||
"galaxy/lobby/internal/service/approveapplication"
|
||||
"galaxy/lobby/internal/service/blockmember"
|
||||
"galaxy/lobby/internal/service/cancelgame"
|
||||
"galaxy/lobby/internal/service/creategame"
|
||||
"galaxy/lobby/internal/service/getgame"
|
||||
"galaxy/lobby/internal/service/listgames"
|
||||
"galaxy/lobby/internal/service/listmemberships"
|
||||
"galaxy/lobby/internal/service/manualreadytostart"
|
||||
"galaxy/lobby/internal/service/openenrollment"
|
||||
"galaxy/lobby/internal/service/pausegame"
|
||||
"galaxy/lobby/internal/service/rejectapplication"
|
||||
"galaxy/lobby/internal/service/removemember"
|
||||
"galaxy/lobby/internal/service/resumegame"
|
||||
"galaxy/lobby/internal/service/retrystartgame"
|
||||
"galaxy/lobby/internal/service/startgame"
|
||||
"galaxy/lobby/internal/service/updategame"
|
||||
"galaxy/lobby/internal/telemetry"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
)
|
||||
|
||||
const jsonContentType = "application/json; charset=utf-8"
|
||||
|
||||
const (
|
||||
// HealthzPath is the internal liveness probe route.
|
||||
HealthzPath = "/healthz"
|
||||
|
||||
// ReadyzPath is the internal readiness probe route.
|
||||
ReadyzPath = "/readyz"
|
||||
)
|
||||
|
||||
// Config describes the trusted internal HTTP listener owned by
|
||||
// Game Lobby Service.
|
||||
type Config struct {
|
||||
// Addr is the TCP listen address used by the internal HTTP server.
|
||||
Addr string
|
||||
|
||||
// ReadHeaderTimeout bounds how long the listener may spend reading request
|
||||
// headers before the server rejects the connection.
|
||||
ReadHeaderTimeout time.Duration
|
||||
|
||||
// ReadTimeout bounds how long the listener may spend reading one request.
|
||||
ReadTimeout time.Duration
|
||||
|
||||
// IdleTimeout bounds how long the listener keeps an idle keep-alive
|
||||
// connection open.
|
||||
IdleTimeout time.Duration
|
||||
}
|
||||
|
||||
// Validate reports whether cfg contains a usable internal HTTP listener
|
||||
// configuration.
|
||||
func (cfg Config) Validate() error {
|
||||
switch {
|
||||
case cfg.Addr == "":
|
||||
return errors.New("internal HTTP addr must not be empty")
|
||||
case cfg.ReadHeaderTimeout <= 0:
|
||||
return errors.New("internal HTTP read header timeout must be positive")
|
||||
case cfg.ReadTimeout <= 0:
|
||||
return errors.New("internal HTTP read timeout must be positive")
|
||||
case cfg.IdleTimeout <= 0:
|
||||
return errors.New("internal HTTP idle timeout must be positive")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Dependencies describes the collaborators used by the internal HTTP
|
||||
// transport layer.
|
||||
type Dependencies struct {
|
||||
// Logger writes structured listener lifecycle logs. When nil,
|
||||
// slog.Default is used.
|
||||
Logger *slog.Logger
|
||||
|
||||
// Telemetry records low-cardinality probe metrics and lifecycle events.
|
||||
Telemetry *telemetry.Runtime
|
||||
|
||||
// CreateGame handles admin-initiated `lobby.game.create` calls routed
|
||||
// through Admin Service. A nil value makes the corresponding route
|
||||
// return `internal_error`; tests that do not exercise the route may
|
||||
// leave it nil.
|
||||
CreateGame *creategame.Service
|
||||
|
||||
// UpdateGame handles admin-initiated `lobby.game.update` calls.
|
||||
UpdateGame *updategame.Service
|
||||
|
||||
// OpenEnrollment handles admin-initiated `lobby.game.open_enrollment`
|
||||
// calls.
|
||||
OpenEnrollment *openenrollment.Service
|
||||
|
||||
// CancelGame handles admin-initiated `lobby.game.cancel` calls.
|
||||
CancelGame *cancelgame.Service
|
||||
|
||||
// ManualReadyToStart handles admin-initiated
|
||||
// `lobby.game.ready_to_start` calls.
|
||||
ManualReadyToStart *manualreadytostart.Service
|
||||
|
||||
// StartGame handles admin-initiated `lobby.game.start` calls
|
||||
//.
|
||||
StartGame *startgame.Service
|
||||
|
||||
// RetryStartGame handles admin-initiated `lobby.game.retry_start`
|
||||
// calls.
|
||||
RetryStartGame *retrystartgame.Service
|
||||
|
||||
// PauseGame handles admin-initiated `lobby.game.pause` calls
|
||||
//.
|
||||
PauseGame *pausegame.Service
|
||||
|
||||
// ResumeGame handles admin-initiated `lobby.game.resume` calls
|
||||
//.
|
||||
ResumeGame *resumegame.Service
|
||||
|
||||
// ApproveApplication handles admin-initiated
|
||||
// `lobby.application.approve` calls. Wired on the internal port for
|
||||
// Admin Service routing.
|
||||
ApproveApplication *approveapplication.Service
|
||||
|
||||
// RejectApplication handles admin-initiated
|
||||
// `lobby.application.reject` calls.
|
||||
RejectApplication *rejectapplication.Service
|
||||
|
||||
// RemoveMember handles admin-initiated `lobby.membership.remove`
|
||||
// calls.
|
||||
RemoveMember *removemember.Service
|
||||
|
||||
// BlockMember handles admin-initiated `lobby.membership.block`
|
||||
// calls.
|
||||
BlockMember *blockmember.Service
|
||||
|
||||
// GetGame handles `internalGetGame` and `adminGetGame` reads
|
||||
//. The handler always passes shared.NewAdminActor() so
|
||||
// the response is unrestricted by visibility rules.
|
||||
GetGame *getgame.Service
|
||||
|
||||
// ListGames handles `adminListGames`. The handler
|
||||
// always passes shared.NewAdminActor() so every status is included.
|
||||
ListGames *listgames.Service
|
||||
|
||||
// ListMemberships handles `internalListMemberships` and
|
||||
// `adminListMemberships` reads. The handler always
|
||||
// passes shared.NewAdminActor() so every membership is returned.
|
||||
ListMemberships *listmemberships.Service
|
||||
}
|
||||
|
||||
// Server owns the trusted internal HTTP listener exposed by
|
||||
// Game Lobby Service.
|
||||
type Server struct {
|
||||
cfg Config
|
||||
|
||||
handler http.Handler
|
||||
logger *slog.Logger
|
||||
metrics *telemetry.Runtime
|
||||
|
||||
stateMu sync.RWMutex
|
||||
server *http.Server
|
||||
listener net.Listener
|
||||
}
|
||||
|
||||
// NewServer constructs one trusted internal HTTP server for cfg and deps.
|
||||
func NewServer(cfg Config, deps Dependencies) (*Server, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("new internal HTTP server: %w", err)
|
||||
}
|
||||
|
||||
logger := deps.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
handler: newHandler(deps, logger),
|
||||
logger: logger.With("component", "internal_http"),
|
||||
metrics: deps.Telemetry,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Addr returns the currently bound listener address after Run is called. It
|
||||
// returns an empty string if the server has not yet bound a listener.
|
||||
func (server *Server) Addr() string {
|
||||
server.stateMu.RLock()
|
||||
defer server.stateMu.RUnlock()
|
||||
if server.listener == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return server.listener.Addr().String()
|
||||
}
|
||||
|
||||
// Run binds the configured listener and serves the internal HTTP surface
|
||||
// until Shutdown closes the server.
|
||||
func (server *Server) Run(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("run internal HTTP server: nil context")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", server.cfg.Addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("run internal HTTP server: listen on %q: %w", server.cfg.Addr, err)
|
||||
}
|
||||
|
||||
httpServer := &http.Server{
|
||||
Handler: server.handler,
|
||||
ReadHeaderTimeout: server.cfg.ReadHeaderTimeout,
|
||||
ReadTimeout: server.cfg.ReadTimeout,
|
||||
IdleTimeout: server.cfg.IdleTimeout,
|
||||
}
|
||||
|
||||
server.stateMu.Lock()
|
||||
server.server = httpServer
|
||||
server.listener = listener
|
||||
server.stateMu.Unlock()
|
||||
|
||||
server.logger.Info("internal HTTP server started", "addr", listener.Addr().String())
|
||||
|
||||
defer func() {
|
||||
server.stateMu.Lock()
|
||||
server.server = nil
|
||||
server.listener = nil
|
||||
server.stateMu.Unlock()
|
||||
}()
|
||||
|
||||
err = httpServer.Serve(listener)
|
||||
switch {
|
||||
case err == nil:
|
||||
return nil
|
||||
case errors.Is(err, http.ErrServerClosed):
|
||||
server.logger.Info("internal HTTP server stopped")
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("run internal HTTP server: serve on %q: %w", server.cfg.Addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown gracefully stops the internal HTTP server within ctx.
|
||||
func (server *Server) Shutdown(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("shutdown internal HTTP server: nil context")
|
||||
}
|
||||
|
||||
server.stateMu.RLock()
|
||||
httpServer := server.server
|
||||
server.stateMu.RUnlock()
|
||||
|
||||
if httpServer == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := httpServer.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return fmt.Errorf("shutdown internal HTTP server: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newHandler(deps Dependencies, logger *slog.Logger) http.Handler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET "+HealthzPath, handleHealthz)
|
||||
mux.HandleFunc("GET "+ReadyzPath, handleReadyz)
|
||||
registerGameRoutes(mux, deps, logger)
|
||||
registerApplicationRoutes(mux, deps, logger)
|
||||
registerReadyToStartRoutes(mux, deps, logger)
|
||||
registerStartRoutes(mux, deps, logger)
|
||||
registerPauseResumeRoutes(mux, deps, logger)
|
||||
registerMembershipRoutes(mux, deps, logger)
|
||||
|
||||
metrics := deps.Telemetry
|
||||
options := []otelhttp.Option{}
|
||||
if metrics != nil {
|
||||
options = append(options,
|
||||
otelhttp.WithTracerProvider(metrics.TracerProvider()),
|
||||
otelhttp.WithMeterProvider(metrics.MeterProvider()),
|
||||
)
|
||||
}
|
||||
|
||||
observable := otelhttp.NewHandler(withObservability(mux, metrics), "lobby.internal_http", options...)
|
||||
return httpcommon.RequestID(observable)
|
||||
}
|
||||
|
||||
func withObservability(next http.Handler, metrics *telemetry.Runtime) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
startedAt := time.Now()
|
||||
recorder := &statusRecorder{
|
||||
ResponseWriter: writer,
|
||||
statusCode: http.StatusOK,
|
||||
}
|
||||
|
||||
next.ServeHTTP(recorder, request)
|
||||
|
||||
route := request.Pattern
|
||||
switch recorder.statusCode {
|
||||
case http.StatusMethodNotAllowed:
|
||||
route = "method_not_allowed"
|
||||
case http.StatusNotFound:
|
||||
route = "not_found"
|
||||
case 0:
|
||||
route = "unmatched"
|
||||
}
|
||||
if route == "" {
|
||||
route = "unmatched"
|
||||
}
|
||||
|
||||
metrics.RecordInternalHTTPRequest(
|
||||
request.Context(),
|
||||
[]attribute.KeyValue{
|
||||
attribute.String("route", route),
|
||||
attribute.String("method", request.Method),
|
||||
attribute.String("status_code", strconv.Itoa(recorder.statusCode)),
|
||||
},
|
||||
time.Since(startedAt),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func handleHealthz(writer http.ResponseWriter, _ *http.Request) {
|
||||
writeStatusResponse(writer, http.StatusOK, "ok")
|
||||
}
|
||||
|
||||
func handleReadyz(writer http.ResponseWriter, _ *http.Request) {
|
||||
writeStatusResponse(writer, http.StatusOK, "ready")
|
||||
}
|
||||
|
||||
func writeStatusResponse(writer http.ResponseWriter, statusCode int, status string) {
|
||||
writer.Header().Set("Content-Type", jsonContentType)
|
||||
writer.WriteHeader(statusCode)
|
||||
_ = json.NewEncoder(writer).Encode(statusResponse{Status: status})
|
||||
}
|
||||
|
||||
type statusResponse struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (recorder *statusRecorder) WriteHeader(statusCode int) {
|
||||
recorder.statusCode = statusCode
|
||||
recorder.ResponseWriter.WriteHeader(statusCode)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConfigValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base := Config{
|
||||
Addr: ":0",
|
||||
ReadHeaderTimeout: time.Second,
|
||||
ReadTimeout: time.Second,
|
||||
IdleTimeout: time.Second,
|
||||
}
|
||||
require.NoError(t, base.Validate())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Config)
|
||||
wantErr string
|
||||
}{
|
||||
{name: "empty addr", mutate: func(cfg *Config) { cfg.Addr = "" }, wantErr: "addr must not be empty"},
|
||||
{name: "zero header", mutate: func(cfg *Config) { cfg.ReadHeaderTimeout = 0 }, wantErr: "read header timeout"},
|
||||
{name: "zero read", mutate: func(cfg *Config) { cfg.ReadTimeout = 0 }, wantErr: "read timeout"},
|
||||
{name: "zero idle", mutate: func(cfg *Config) { cfg.IdleTimeout = 0 }, wantErr: "idle timeout"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := base
|
||||
tt.mutate(&cfg)
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerRoutes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newHandler(Dependencies{}, nil)
|
||||
server := httptest.NewServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
wantStatus int
|
||||
wantStatusBody string
|
||||
}{
|
||||
{name: "healthz", method: http.MethodGet, path: HealthzPath, wantStatus: http.StatusOK, wantStatusBody: "ok"},
|
||||
{name: "readyz", method: http.MethodGet, path: ReadyzPath, wantStatus: http.StatusOK, wantStatusBody: "ready"},
|
||||
{name: "not found", method: http.MethodGet, path: "/nope", wantStatus: http.StatusNotFound},
|
||||
{name: "method not allowed", method: http.MethodPost, path: HealthzPath, wantStatus: http.StatusMethodNotAllowed},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req, err := http.NewRequest(tt.method, server.URL+tt.path, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := server.Client().Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, tt.wantStatus, resp.StatusCode)
|
||||
|
||||
if tt.wantStatusBody != "" {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "application/json; charset=utf-8", resp.Header.Get("Content-Type"))
|
||||
|
||||
var payload statusResponse
|
||||
require.NoError(t, json.Unmarshal(body, &payload))
|
||||
assert.Equal(t, tt.wantStatusBody, payload.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerRunAndShutdown(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
addr := listener.Addr().String()
|
||||
require.NoError(t, listener.Close())
|
||||
|
||||
server, err := NewServer(Config{
|
||||
Addr: addr,
|
||||
ReadHeaderTimeout: time.Second,
|
||||
ReadTimeout: time.Second,
|
||||
IdleTimeout: time.Second,
|
||||
}, Dependencies{})
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
runErr := make(chan error, 1)
|
||||
go func() {
|
||||
runErr <- server.Run(ctx)
|
||||
}()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return server.Addr() != ""
|
||||
}, 2*time.Second, 10*time.Millisecond)
|
||||
|
||||
resp, err := http.Get("http://" + server.Addr() + ReadyzPath)
|
||||
require.NoError(t, err)
|
||||
_ = resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
t.Cleanup(shutdownCancel)
|
||||
require.NoError(t, server.Shutdown(shutdownCtx))
|
||||
|
||||
select {
|
||||
case err := <-runErr:
|
||||
require.NoError(t, err)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("server did not stop after shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShutdownBeforeRunIsNoop(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server, err := NewServer(Config{
|
||||
Addr: "127.0.0.1:0",
|
||||
ReadHeaderTimeout: time.Second,
|
||||
ReadTimeout: time.Second,
|
||||
IdleTimeout: time.Second,
|
||||
}, Dependencies{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, server.Shutdown(context.Background()))
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"galaxy/lobby/internal/service/retrystartgame"
|
||||
"galaxy/lobby/internal/service/shared"
|
||||
"galaxy/lobby/internal/service/startgame"
|
||||
)
|
||||
|
||||
const (
|
||||
startGamePath = "/api/v1/lobby/games/{game_id}/start"
|
||||
retryStartGamePath = "/api/v1/lobby/games/{game_id}/retry-start"
|
||||
)
|
||||
|
||||
// registerStartRoutes binds the admin start and retry-start
|
||||
// routes on the internal port. The actor is always admin (Admin Service
|
||||
// is the trusted caller).
|
||||
func registerStartRoutes(mux *http.ServeMux, deps Dependencies, logger *slog.Logger) {
|
||||
h := &startHandlers{
|
||||
deps: deps,
|
||||
logger: logger.With("component", "internal_http.startgame"),
|
||||
}
|
||||
mux.HandleFunc("POST "+startGamePath, h.handleStart)
|
||||
mux.HandleFunc("POST "+retryStartGamePath, h.handleRetryStart)
|
||||
}
|
||||
|
||||
type startHandlers struct {
|
||||
deps Dependencies
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (h *startHandlers) handleStart(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.StartGame == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "start game service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
games := &gameHandlers{deps: h.deps, logger: h.logger}
|
||||
gameID, ok := games.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.deps.StartGame.Handle(request.Context(), startgame.Input{
|
||||
Actor: shared.NewAdminActor(),
|
||||
GameID: gameID,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeGameRecord(record))
|
||||
}
|
||||
|
||||
func (h *startHandlers) handleRetryStart(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.RetryStartGame == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "retry start game service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
games := &gameHandlers{deps: h.deps, logger: h.logger}
|
||||
gameID, ok := games.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.deps.RetryStartGame.Handle(request.Context(), retrystartgame.Input{
|
||||
Actor: shared.NewAdminActor(),
|
||||
GameID: gameID,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeGameRecord(record))
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package publichttp
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"galaxy/lobby/internal/domain/application"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/membership"
|
||||
"galaxy/lobby/internal/service/approveapplication"
|
||||
"galaxy/lobby/internal/service/rejectapplication"
|
||||
"galaxy/lobby/internal/service/shared"
|
||||
"galaxy/lobby/internal/service/submitapplication"
|
||||
)
|
||||
|
||||
// Public HTTP route patterns for the application surface.
|
||||
const (
|
||||
submitApplicationPath = "/api/v1/lobby/games/{game_id}/applications"
|
||||
approveApplicationPath = "/api/v1/lobby/games/{game_id}/applications/{application_id}/approve"
|
||||
rejectApplicationPath = "/api/v1/lobby/games/{game_id}/applications/{application_id}/reject"
|
||||
|
||||
applicationIDPathParamValue = "application_id"
|
||||
)
|
||||
|
||||
// submitApplicationRequest is the JSON shape for
|
||||
// `POST /api/v1/lobby/games/{game_id}/applications`.
|
||||
type submitApplicationRequest struct {
|
||||
RaceName string `json:"race_name"`
|
||||
}
|
||||
|
||||
// applicationRecordResponse mirrors the OpenAPI ApplicationRecord schema.
|
||||
type applicationRecordResponse struct {
|
||||
ApplicationID string `json:"application_id"`
|
||||
GameID string `json:"game_id"`
|
||||
ApplicantUserID string `json:"applicant_user_id"`
|
||||
RaceName string `json:"race_name"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
DecidedAt *int64 `json:"decided_at,omitempty"`
|
||||
}
|
||||
|
||||
func encodeApplicationRecord(record application.Application) applicationRecordResponse {
|
||||
resp := applicationRecordResponse{
|
||||
ApplicationID: record.ApplicationID.String(),
|
||||
GameID: record.GameID.String(),
|
||||
ApplicantUserID: record.ApplicantUserID,
|
||||
RaceName: record.RaceName,
|
||||
Status: string(record.Status),
|
||||
CreatedAt: record.CreatedAt.UTC().UnixMilli(),
|
||||
}
|
||||
if record.DecidedAt != nil {
|
||||
decided := record.DecidedAt.UTC().UnixMilli()
|
||||
resp.DecidedAt = &decided
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// membershipRecordResponse mirrors the OpenAPI MembershipRecord schema.
|
||||
// canonical_key is intentionally omitted from the wire shape; it is a
|
||||
// lobby-internal field per design.
|
||||
type membershipRecordResponse struct {
|
||||
MembershipID string `json:"membership_id"`
|
||||
GameID string `json:"game_id"`
|
||||
UserID string `json:"user_id"`
|
||||
RaceName string `json:"race_name"`
|
||||
Status string `json:"status"`
|
||||
JoinedAt int64 `json:"joined_at"`
|
||||
RemovedAt *int64 `json:"removed_at,omitempty"`
|
||||
}
|
||||
|
||||
func encodeMembershipRecord(record membership.Membership) membershipRecordResponse {
|
||||
resp := membershipRecordResponse{
|
||||
MembershipID: record.MembershipID.String(),
|
||||
GameID: record.GameID.String(),
|
||||
UserID: record.UserID,
|
||||
RaceName: record.RaceName,
|
||||
Status: string(record.Status),
|
||||
JoinedAt: record.JoinedAt.UTC().UnixMilli(),
|
||||
}
|
||||
if record.RemovedAt != nil {
|
||||
removed := record.RemovedAt.UTC().UnixMilli()
|
||||
resp.RemovedAt = &removed
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// registerApplicationRoutes binds the three application routes.
|
||||
func registerApplicationRoutes(mux *http.ServeMux, deps Dependencies, logger *slog.Logger) {
|
||||
h := &applicationHandlers{
|
||||
deps: deps,
|
||||
logger: logger.With("component", "public_http.applications"),
|
||||
}
|
||||
mux.HandleFunc("POST "+submitApplicationPath, h.handleSubmit)
|
||||
mux.HandleFunc("POST "+approveApplicationPath, h.handleApprove)
|
||||
mux.HandleFunc("POST "+rejectApplicationPath, h.handleReject)
|
||||
}
|
||||
|
||||
type applicationHandlers struct {
|
||||
deps Dependencies
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (h *applicationHandlers) extractGameID(writer http.ResponseWriter, request *http.Request) (common.GameID, bool) {
|
||||
raw := request.PathValue(gameIDPathParamValue)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_request", "game id is required")
|
||||
return "", false
|
||||
}
|
||||
return common.GameID(raw), true
|
||||
}
|
||||
|
||||
func (h *applicationHandlers) extractApplicationID(writer http.ResponseWriter, request *http.Request) (common.ApplicationID, bool) {
|
||||
raw := request.PathValue(applicationIDPathParamValue)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_request", "application id is required")
|
||||
return "", false
|
||||
}
|
||||
return common.ApplicationID(raw), true
|
||||
}
|
||||
|
||||
func (h *applicationHandlers) requireUserActor(writer http.ResponseWriter, request *http.Request) (string, bool) {
|
||||
userID := strings.TrimSpace(request.Header.Get(xUserIDHeader))
|
||||
if userID == "" {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_request",
|
||||
"X-User-ID header is required")
|
||||
return "", false
|
||||
}
|
||||
return userID, true
|
||||
}
|
||||
|
||||
func (h *applicationHandlers) handleSubmit(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.SubmitApplication == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "submit application service is not wired")
|
||||
return
|
||||
}
|
||||
userID, ok := h.requireUserActor(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gameID, ok := h.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var body submitApplicationRequest
|
||||
if err := decodeStrictJSON(request.Body, &body); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.deps.SubmitApplication.Handle(request.Context(), submitapplication.Input{
|
||||
Actor: shared.NewUserActor(userID),
|
||||
GameID: gameID,
|
||||
RaceName: body.RaceName,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusCreated, encodeApplicationRecord(record))
|
||||
}
|
||||
|
||||
func (h *applicationHandlers) handleApprove(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.ApproveApplication == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "approve application service is not wired")
|
||||
return
|
||||
}
|
||||
userID, ok := h.requireUserActor(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gameID, ok := h.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
applicationID, ok := h.extractApplicationID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.deps.ApproveApplication.Handle(request.Context(), approveapplication.Input{
|
||||
Actor: shared.NewUserActor(userID),
|
||||
GameID: gameID,
|
||||
ApplicationID: applicationID,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, encodeMembershipRecord(record))
|
||||
}
|
||||
|
||||
func (h *applicationHandlers) handleReject(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.RejectApplication == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "reject application service is not wired")
|
||||
return
|
||||
}
|
||||
userID, ok := h.requireUserActor(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gameID, ok := h.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
applicationID, ok := h.extractApplicationID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.deps.RejectApplication.Handle(request.Context(), rejectapplication.Input{
|
||||
Actor: shared.NewUserActor(userID),
|
||||
GameID: gameID,
|
||||
ApplicationID: applicationID,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, encodeApplicationRecord(record))
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
package publichttp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/domain/application"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/invite"
|
||||
"galaxy/lobby/internal/domain/membership"
|
||||
"galaxy/lobby/internal/ports"
|
||||
"galaxy/lobby/internal/service/cancelgame"
|
||||
"galaxy/lobby/internal/service/creategame"
|
||||
"galaxy/lobby/internal/service/getgame"
|
||||
"galaxy/lobby/internal/service/listgames"
|
||||
"galaxy/lobby/internal/service/openenrollment"
|
||||
"galaxy/lobby/internal/service/shared"
|
||||
"galaxy/lobby/internal/service/updategame"
|
||||
)
|
||||
|
||||
// xUserIDHeader is the authenticated-user identifier header injected by
|
||||
// Edge Gateway on every public-port request.
|
||||
const xUserIDHeader = "X-User-ID"
|
||||
|
||||
// Public HTTP route patterns registered by registerGameRoutes.
|
||||
const (
|
||||
gamesCollectionPath = "/api/v1/lobby/games"
|
||||
gameItemPath = "/api/v1/lobby/games/{game_id}"
|
||||
openEnrollmentPath = "/api/v1/lobby/games/{game_id}/open-enrollment"
|
||||
cancelGamePath = "/api/v1/lobby/games/{game_id}/cancel"
|
||||
gameIDPathParamValue = "game_id"
|
||||
)
|
||||
|
||||
// errorResponse mirrors the `{ "error": { ... } }` shape documented in the
|
||||
// OpenAPI contract.
|
||||
type errorResponse struct {
|
||||
Error errorBody `json:"error"`
|
||||
}
|
||||
|
||||
type errorBody struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// createGameRequest is the JSON shape for POST /api/v1/lobby/games.
|
||||
type createGameRequest struct {
|
||||
GameName string `json:"game_name"`
|
||||
Description string `json:"description"`
|
||||
GameType string `json:"game_type"`
|
||||
MinPlayers int `json:"min_players"`
|
||||
MaxPlayers int `json:"max_players"`
|
||||
StartGapHours int `json:"start_gap_hours"`
|
||||
StartGapPlayers int `json:"start_gap_players"`
|
||||
EnrollmentEndsAt int64 `json:"enrollment_ends_at"`
|
||||
TurnSchedule string `json:"turn_schedule"`
|
||||
TargetEngineVersion string `json:"target_engine_version"`
|
||||
}
|
||||
|
||||
// updateGameRequest is the JSON shape for PATCH /api/v1/lobby/games/{id}.
|
||||
// Each field is optional; pointer types distinguish "absent" from zero.
|
||||
type updateGameRequest struct {
|
||||
GameName *string `json:"game_name"`
|
||||
Description *string `json:"description"`
|
||||
MinPlayers *int `json:"min_players"`
|
||||
MaxPlayers *int `json:"max_players"`
|
||||
StartGapHours *int `json:"start_gap_hours"`
|
||||
StartGapPlayers *int `json:"start_gap_players"`
|
||||
EnrollmentEndsAt *int64 `json:"enrollment_ends_at"`
|
||||
TurnSchedule *string `json:"turn_schedule"`
|
||||
TargetEngineVersion *string `json:"target_engine_version"`
|
||||
}
|
||||
|
||||
// gameRecordResponse is the JSON shape of GameRecord per the OpenAPI
|
||||
// contract. Timestamps follow the mixed convention frozen by the
|
||||
// `enrollment_ends_at` is Unix seconds; `created_at`, `updated_at`,
|
||||
// `started_at`, `finished_at`, `runtime_binding.bound_at` are Unix
|
||||
// milliseconds.
|
||||
type gameRecordResponse struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
GameType string `json:"game_type"`
|
||||
OwnerUserID string `json:"owner_user_id"`
|
||||
Status string `json:"status"`
|
||||
MinPlayers int `json:"min_players"`
|
||||
MaxPlayers int `json:"max_players"`
|
||||
StartGapHours int `json:"start_gap_hours"`
|
||||
StartGapPlayers int `json:"start_gap_players"`
|
||||
EnrollmentEndsAt int64 `json:"enrollment_ends_at"`
|
||||
TurnSchedule string `json:"turn_schedule"`
|
||||
TargetEngineVersion string `json:"target_engine_version"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
StartedAt *int64 `json:"started_at,omitempty"`
|
||||
FinishedAt *int64 `json:"finished_at,omitempty"`
|
||||
CurrentTurn int `json:"current_turn"`
|
||||
RuntimeStatus string `json:"runtime_status"`
|
||||
EngineHealthSummary string `json:"engine_health_summary"`
|
||||
RuntimeBinding *runtimeBindingResponse `json:"runtime_binding,omitempty"`
|
||||
}
|
||||
|
||||
// runtimeBindingResponse mirrors the RuntimeBinding schema. It is set
|
||||
// only after a successful container start.
|
||||
type runtimeBindingResponse struct {
|
||||
ContainerID string `json:"container_id"`
|
||||
EngineEndpoint string `json:"engine_endpoint"`
|
||||
RuntimeJobID string `json:"runtime_job_id"`
|
||||
BoundAt int64 `json:"bound_at"`
|
||||
}
|
||||
|
||||
// encodeGameRecord converts one domain Game into the wire GameRecord shape.
|
||||
func encodeGameRecord(record game.Game) gameRecordResponse {
|
||||
resp := gameRecordResponse{
|
||||
GameID: record.GameID.String(),
|
||||
GameName: record.GameName,
|
||||
Description: record.Description,
|
||||
GameType: string(record.GameType),
|
||||
OwnerUserID: record.OwnerUserID,
|
||||
Status: string(record.Status),
|
||||
MinPlayers: record.MinPlayers,
|
||||
MaxPlayers: record.MaxPlayers,
|
||||
StartGapHours: record.StartGapHours,
|
||||
StartGapPlayers: record.StartGapPlayers,
|
||||
EnrollmentEndsAt: record.EnrollmentEndsAt.UTC().Unix(),
|
||||
TurnSchedule: record.TurnSchedule,
|
||||
TargetEngineVersion: record.TargetEngineVersion,
|
||||
CreatedAt: record.CreatedAt.UTC().UnixMilli(),
|
||||
UpdatedAt: record.UpdatedAt.UTC().UnixMilli(),
|
||||
CurrentTurn: record.RuntimeSnapshot.CurrentTurn,
|
||||
RuntimeStatus: record.RuntimeSnapshot.RuntimeStatus,
|
||||
EngineHealthSummary: record.RuntimeSnapshot.EngineHealthSummary,
|
||||
}
|
||||
if record.StartedAt != nil {
|
||||
started := record.StartedAt.UTC().UnixMilli()
|
||||
resp.StartedAt = &started
|
||||
}
|
||||
if record.FinishedAt != nil {
|
||||
finished := record.FinishedAt.UTC().UnixMilli()
|
||||
resp.FinishedAt = &finished
|
||||
}
|
||||
if record.RuntimeBinding != nil {
|
||||
resp.RuntimeBinding = &runtimeBindingResponse{
|
||||
ContainerID: record.RuntimeBinding.ContainerID,
|
||||
EngineEndpoint: record.RuntimeBinding.EngineEndpoint,
|
||||
RuntimeJobID: record.RuntimeBinding.RuntimeJobID,
|
||||
BoundAt: record.RuntimeBinding.BoundAt.UTC().UnixMilli(),
|
||||
}
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// decodeStrictJSON decodes body into target rejecting unknown fields and
|
||||
// any trailing content after the first JSON value.
|
||||
func decodeStrictJSON(body io.Reader, target any) error {
|
||||
decoder := json.NewDecoder(body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if decoder.More() {
|
||||
return errors.New("unexpected trailing content after JSON body")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeJSON marshals payload into the response body with the configured
|
||||
// status code.
|
||||
func writeJSON(writer http.ResponseWriter, statusCode int, payload any) {
|
||||
writer.Header().Set("Content-Type", jsonContentType)
|
||||
writer.WriteHeader(statusCode)
|
||||
_ = json.NewEncoder(writer).Encode(payload)
|
||||
}
|
||||
|
||||
// writeError writes one OpenAPI-shaped error envelope.
|
||||
func writeError(writer http.ResponseWriter, statusCode int, code, message string) {
|
||||
writeJSON(writer, statusCode, errorResponse{Error: errorBody{Code: code, Message: message}})
|
||||
}
|
||||
|
||||
// writeErrorFromService translates a service-layer error into the
|
||||
// OpenAPI-shaped error envelope using the stable error-code mapping.
|
||||
func writeErrorFromService(writer http.ResponseWriter, logger *slog.Logger, err error) {
|
||||
switch {
|
||||
case errors.Is(err, shared.ErrForbidden):
|
||||
writeError(writer, http.StatusForbidden, "forbidden", "access denied")
|
||||
case errors.Is(err, game.ErrNotFound),
|
||||
errors.Is(err, application.ErrNotFound),
|
||||
errors.Is(err, invite.ErrNotFound),
|
||||
errors.Is(err, membership.ErrNotFound),
|
||||
errors.Is(err, shared.ErrSubjectNotFound),
|
||||
errors.Is(err, ports.ErrPendingMissing):
|
||||
writeError(writer, http.StatusNotFound, "subject_not_found", "resource not found")
|
||||
case errors.Is(err, game.ErrConflict),
|
||||
errors.Is(err, game.ErrInvalidTransition),
|
||||
errors.Is(err, application.ErrConflict),
|
||||
errors.Is(err, application.ErrInvalidTransition),
|
||||
errors.Is(err, invite.ErrConflict),
|
||||
errors.Is(err, invite.ErrInvalidTransition),
|
||||
errors.Is(err, membership.ErrConflict),
|
||||
errors.Is(err, membership.ErrInvalidTransition):
|
||||
writeError(writer, http.StatusConflict, "conflict", "operation not allowed in current status")
|
||||
case errors.Is(err, shared.ErrEligibilityDenied):
|
||||
writeError(writer, http.StatusUnprocessableEntity, "eligibility_denied", "user is not eligible to join games")
|
||||
case errors.Is(err, ports.ErrNameTaken):
|
||||
writeError(writer, http.StatusUnprocessableEntity, "name_taken", "race name is already taken")
|
||||
case errors.Is(err, ports.ErrPendingExpired):
|
||||
writeError(writer, http.StatusUnprocessableEntity, "race_name_pending_window_expired",
|
||||
"pending race-name registration window has expired")
|
||||
case errors.Is(err, ports.ErrQuotaExceeded):
|
||||
writeError(writer, http.StatusUnprocessableEntity, "race_name_registration_quota_exceeded",
|
||||
"race name registration quota exceeded")
|
||||
case errors.Is(err, shared.ErrServiceUnavailable),
|
||||
errors.Is(err, ports.ErrUserServiceUnavailable):
|
||||
writeError(writer, http.StatusServiceUnavailable, "service_unavailable", "service is unavailable")
|
||||
case isValidationError(err):
|
||||
writeError(writer, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
default:
|
||||
if logger != nil {
|
||||
logger.Error("unhandled service error", "err", err.Error())
|
||||
}
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "internal server error")
|
||||
}
|
||||
}
|
||||
|
||||
// isValidationError reports whether err is one of the domain-validation
|
||||
// errors returned from game.New, Game.Validate, or the ports UpdateStatus /
|
||||
// UpdateRuntimeSnapshot validators. These errors carry no sentinel and
|
||||
// surface as plain fmt.Errorf values, so we detect them structurally: the
|
||||
// cancel-game / update-game / open-enrollment services wrap them with the
|
||||
// service-level prefix so the transport layer only needs to know the
|
||||
// pre-sentinel error classes have already been consumed by earlier
|
||||
// switch arms.
|
||||
func isValidationError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
// Conservative default: treat every remaining non-sentinel error that
|
||||
// carries a "must" / "must not" / "unsupported" substring as validation.
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case strings.Contains(msg, "must "),
|
||||
strings.Contains(msg, "must not"),
|
||||
strings.Contains(msg, "is unsupported"),
|
||||
strings.Contains(msg, "invalid"):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// registerGameRoutes binds the game-lifecycle and
|
||||
// game-read routes on mux.
|
||||
func registerGameRoutes(mux *http.ServeMux, deps Dependencies, logger *slog.Logger) {
|
||||
h := &gameHandlers{
|
||||
deps: deps,
|
||||
logger: logger.With("component", "public_http.games"),
|
||||
}
|
||||
mux.HandleFunc("POST "+gamesCollectionPath, h.handleCreate)
|
||||
mux.HandleFunc("GET "+gamesCollectionPath, h.handleList)
|
||||
mux.HandleFunc("GET "+gameItemPath, h.handleGet)
|
||||
mux.HandleFunc("PATCH "+gameItemPath, h.handleUpdate)
|
||||
mux.HandleFunc("POST "+openEnrollmentPath, h.handleOpenEnrollment)
|
||||
mux.HandleFunc("POST "+cancelGamePath, h.handleCancel)
|
||||
}
|
||||
|
||||
type gameHandlers struct {
|
||||
deps Dependencies
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// requireUserActor extracts the X-User-ID header and returns an Actor. It
|
||||
// writes the HTTP error envelope and returns false when the header is
|
||||
// missing or blank.
|
||||
func (h *gameHandlers) requireUserActor(writer http.ResponseWriter, request *http.Request) (shared.Actor, bool) {
|
||||
userID := strings.TrimSpace(request.Header.Get(xUserIDHeader))
|
||||
if userID == "" {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_request",
|
||||
"X-User-ID header is required")
|
||||
return shared.Actor{}, false
|
||||
}
|
||||
return shared.NewUserActor(userID), true
|
||||
}
|
||||
|
||||
// extractGameID reads the `game_id` path parameter; writes the
|
||||
// invalid_request envelope and returns false on failure. Value
|
||||
// structural validation is deferred to the domain layer.
|
||||
func (h *gameHandlers) extractGameID(writer http.ResponseWriter, request *http.Request) (common.GameID, bool) {
|
||||
raw := request.PathValue(gameIDPathParamValue)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_request", "game id is required")
|
||||
return "", false
|
||||
}
|
||||
return common.GameID(raw), true
|
||||
}
|
||||
|
||||
func (h *gameHandlers) handleCreate(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.CreateGame == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "create game service is not wired")
|
||||
return
|
||||
}
|
||||
actor, ok := h.requireUserActor(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var body createGameRequest
|
||||
if err := decodeStrictJSON(request.Body, &body); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
input := creategame.Input{
|
||||
Actor: actor,
|
||||
GameName: body.GameName,
|
||||
Description: body.Description,
|
||||
GameType: game.GameType(body.GameType),
|
||||
MinPlayers: body.MinPlayers,
|
||||
MaxPlayers: body.MaxPlayers,
|
||||
StartGapHours: body.StartGapHours,
|
||||
StartGapPlayers: body.StartGapPlayers,
|
||||
EnrollmentEndsAt: time.Unix(body.EnrollmentEndsAt, 0).UTC(),
|
||||
TurnSchedule: body.TurnSchedule,
|
||||
TargetEngineVersion: body.TargetEngineVersion,
|
||||
}
|
||||
record, err := h.deps.CreateGame.Handle(request.Context(), input)
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusCreated, encodeGameRecord(record))
|
||||
}
|
||||
|
||||
func (h *gameHandlers) handleUpdate(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.UpdateGame == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "update game service is not wired")
|
||||
return
|
||||
}
|
||||
actor, ok := h.requireUserActor(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gameID, ok := h.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var body updateGameRequest
|
||||
if err := decodeStrictJSON(request.Body, &body); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
input := updategame.Input{
|
||||
Actor: actor,
|
||||
GameID: gameID,
|
||||
GameName: body.GameName,
|
||||
Description: body.Description,
|
||||
MinPlayers: body.MinPlayers,
|
||||
MaxPlayers: body.MaxPlayers,
|
||||
StartGapHours: body.StartGapHours,
|
||||
StartGapPlayers: body.StartGapPlayers,
|
||||
TurnSchedule: body.TurnSchedule,
|
||||
TargetEngineVersion: body.TargetEngineVersion,
|
||||
}
|
||||
if body.EnrollmentEndsAt != nil {
|
||||
t := time.Unix(*body.EnrollmentEndsAt, 0).UTC()
|
||||
input.EnrollmentEndsAt = &t
|
||||
}
|
||||
|
||||
record, err := h.deps.UpdateGame.Handle(request.Context(), input)
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeGameRecord(record))
|
||||
}
|
||||
|
||||
func (h *gameHandlers) handleOpenEnrollment(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.OpenEnrollment == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "open enrollment service is not wired")
|
||||
return
|
||||
}
|
||||
actor, ok := h.requireUserActor(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gameID, ok := h.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.deps.OpenEnrollment.Handle(request.Context(), openenrollment.Input{
|
||||
Actor: actor,
|
||||
GameID: gameID,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeGameRecord(record))
|
||||
}
|
||||
|
||||
func (h *gameHandlers) handleCancel(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.CancelGame == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "cancel game service is not wired")
|
||||
return
|
||||
}
|
||||
actor, ok := h.requireUserActor(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gameID, ok := h.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.deps.CancelGame.Handle(request.Context(), cancelgame.Input{
|
||||
Actor: actor,
|
||||
GameID: gameID,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeGameRecord(record))
|
||||
}
|
||||
|
||||
// gameListResponse mirrors the OpenAPI GameListResponse schema used by
|
||||
// GET /api/v1/lobby/games and the `lobby.my_games.list` route. Items
|
||||
// are always non-nil so the JSON form carries `[]` rather than `null`.
|
||||
type gameListResponse struct {
|
||||
Items []gameRecordResponse `json:"items"`
|
||||
NextPageToken string `json:"next_page_token,omitempty"`
|
||||
}
|
||||
|
||||
func encodeGameList(items []game.Game, nextPageToken string) gameListResponse {
|
||||
resp := gameListResponse{
|
||||
Items: make([]gameRecordResponse, 0, len(items)),
|
||||
NextPageToken: nextPageToken,
|
||||
}
|
||||
for _, item := range items {
|
||||
resp.Items = append(resp.Items, encodeGameRecord(item))
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// parsePage decodes the `page_size` and `page_token` query parameters
|
||||
// into a shared.Page. On failure it writes the OpenAPI-shaped
|
||||
// invalid_request envelope and returns ok=false so the caller can
|
||||
// short-circuit.
|
||||
func parsePage(writer http.ResponseWriter, request *http.Request) (shared.Page, bool) {
|
||||
page, err := shared.ParsePage(
|
||||
request.URL.Query().Get("page_size"),
|
||||
request.URL.Query().Get("page_token"),
|
||||
)
|
||||
if err != nil {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return shared.Page{}, false
|
||||
}
|
||||
return page, true
|
||||
}
|
||||
|
||||
func (h *gameHandlers) handleGet(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.GetGame == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "get game service is not wired")
|
||||
return
|
||||
}
|
||||
actor, ok := h.requireUserActor(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gameID, ok := h.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.deps.GetGame.Handle(request.Context(), getgame.Input{
|
||||
Actor: actor,
|
||||
GameID: gameID,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeGameRecord(record))
|
||||
}
|
||||
|
||||
func (h *gameHandlers) handleList(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.ListGames == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "list games service is not wired")
|
||||
return
|
||||
}
|
||||
actor, ok := h.requireUserActor(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, ok := parsePage(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
out, err := h.deps.ListGames.Handle(request.Context(), listgames.Input{
|
||||
Actor: actor,
|
||||
Page: page,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeGameList(out.Items, out.NextPageToken))
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package publichttp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/ports"
|
||||
"galaxy/lobby/internal/service/cancelgame"
|
||||
"galaxy/lobby/internal/service/creategame"
|
||||
"galaxy/lobby/internal/service/openenrollment"
|
||||
"galaxy/lobby/internal/service/updategame"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type stubIDGenerator struct {
|
||||
next common.GameID
|
||||
}
|
||||
|
||||
func (g *stubIDGenerator) NewGameID() (common.GameID, error) {
|
||||
return g.next, nil
|
||||
}
|
||||
|
||||
func (g *stubIDGenerator) NewApplicationID() (common.ApplicationID, error) {
|
||||
return "application-stub", nil
|
||||
}
|
||||
|
||||
func (g *stubIDGenerator) NewInviteID() (common.InviteID, error) {
|
||||
return "invite-stub", nil
|
||||
}
|
||||
|
||||
func (g *stubIDGenerator) NewMembershipID() (common.MembershipID, error) {
|
||||
return "membership-stub", nil
|
||||
}
|
||||
|
||||
func silentLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func buildHandler(t *testing.T, store *gamestub.Store, ids ports.IDGenerator, clock func() time.Time) http.Handler {
|
||||
t.Helper()
|
||||
|
||||
logger := silentLogger()
|
||||
|
||||
createSvc, err := creategame.NewService(creategame.Dependencies{
|
||||
Games: store,
|
||||
IDs: ids,
|
||||
Clock: clock,
|
||||
Logger: logger,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
updateSvc, err := updategame.NewService(updategame.Dependencies{
|
||||
Games: store,
|
||||
Clock: clock,
|
||||
Logger: logger,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
openSvc, err := openenrollment.NewService(openenrollment.Dependencies{
|
||||
Games: store,
|
||||
Clock: clock,
|
||||
Logger: logger,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
cancelSvc, err := cancelgame.NewService(cancelgame.Dependencies{
|
||||
Games: store,
|
||||
Clock: clock,
|
||||
Logger: logger,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return newHandler(Dependencies{
|
||||
Logger: logger,
|
||||
CreateGame: createSvc,
|
||||
UpdateGame: updateSvc,
|
||||
OpenEnrollment: openSvc,
|
||||
CancelGame: cancelSvc,
|
||||
}, logger)
|
||||
}
|
||||
|
||||
func doRequest(t *testing.T, handler http.Handler, method, path, userID string, body any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
reader = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
req := httptest.NewRequestWithContext(context.Background(), method, path, reader)
|
||||
if userID != "" {
|
||||
req.Header.Set(xUserIDHeader, userID)
|
||||
}
|
||||
if reader != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func decodeGameRecord(t *testing.T, rec *httptest.ResponseRecorder) gameRecordResponse {
|
||||
t.Helper()
|
||||
|
||||
var payload gameRecordResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
||||
return payload
|
||||
}
|
||||
|
||||
func decodeError(t *testing.T, rec *httptest.ResponseRecorder) errorResponse {
|
||||
t.Helper()
|
||||
|
||||
var payload errorResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
||||
return payload
|
||||
}
|
||||
|
||||
func fixedClock(at time.Time) func() time.Time {
|
||||
return func() time.Time { return at }
|
||||
}
|
||||
|
||||
func TestCreateGameHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
handler := buildHandler(t, store, &stubIDGenerator{next: "game-first"}, fixedClock(now))
|
||||
|
||||
body := createGameRequest{
|
||||
GameName: "Friends Game",
|
||||
GameType: "private",
|
||||
MinPlayers: 2,
|
||||
MaxPlayers: 4,
|
||||
StartGapHours: 4,
|
||||
StartGapPlayers: 1,
|
||||
EnrollmentEndsAt: now.Add(12 * time.Hour).Unix(),
|
||||
TurnSchedule: "0 0 * * *",
|
||||
TargetEngineVersion: "1.0.0",
|
||||
}
|
||||
rec := doRequest(t, handler, http.MethodPost, "/api/v1/lobby/games", "user-42", body)
|
||||
require.Equal(t, http.StatusCreated, rec.Code)
|
||||
|
||||
decoded := decodeGameRecord(t, rec)
|
||||
require.Equal(t, "game-first", decoded.GameID)
|
||||
require.Equal(t, "private", decoded.GameType)
|
||||
require.Equal(t, "user-42", decoded.OwnerUserID)
|
||||
require.Equal(t, "draft", decoded.Status)
|
||||
require.Equal(t, body.EnrollmentEndsAt, decoded.EnrollmentEndsAt)
|
||||
require.Equal(t, now.UnixMilli(), decoded.CreatedAt)
|
||||
}
|
||||
|
||||
func TestCreateGameMissingUserIDHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
handler := buildHandler(t, gamestub.NewStore(), &stubIDGenerator{next: "game-x"}, fixedClock(now))
|
||||
|
||||
body := createGameRequest{
|
||||
GameName: "x",
|
||||
GameType: "private",
|
||||
MinPlayers: 2,
|
||||
MaxPlayers: 4,
|
||||
StartGapHours: 4,
|
||||
StartGapPlayers: 1,
|
||||
EnrollmentEndsAt: now.Add(time.Hour).Unix(),
|
||||
TurnSchedule: "0 0 * * *",
|
||||
TargetEngineVersion: "1.0.0",
|
||||
}
|
||||
rec := doRequest(t, handler, http.MethodPost, "/api/v1/lobby/games", "", body)
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
|
||||
decoded := decodeError(t, rec)
|
||||
require.Equal(t, "invalid_request", decoded.Error.Code)
|
||||
require.Contains(t, decoded.Error.Message, "X-User-ID")
|
||||
}
|
||||
|
||||
func TestCreateGameUnknownJSONFieldRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
handler := buildHandler(t, gamestub.NewStore(), &stubIDGenerator{next: "game-x"}, fixedClock(now))
|
||||
|
||||
reqBody := map[string]any{
|
||||
"game_name": "x",
|
||||
"game_type": "private",
|
||||
"min_players": 2,
|
||||
"max_players": 4,
|
||||
"start_gap_hours": 4,
|
||||
"start_gap_players": 1,
|
||||
"enrollment_ends_at": now.Add(time.Hour).Unix(),
|
||||
"turn_schedule": "0 0 * * *",
|
||||
"target_engine_version": "1.0.0",
|
||||
"owner_user_id": "user-42", // unknown — must be rejected
|
||||
}
|
||||
rec := doRequest(t, handler, http.MethodPost, "/api/v1/lobby/games", "user-42", reqBody)
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
}
|
||||
|
||||
func TestCreateGameUserCannotCreatePublic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
handler := buildHandler(t, gamestub.NewStore(), &stubIDGenerator{next: "game-x"}, fixedClock(now))
|
||||
|
||||
body := createGameRequest{
|
||||
GameName: "x",
|
||||
GameType: "public",
|
||||
MinPlayers: 2,
|
||||
MaxPlayers: 4,
|
||||
StartGapHours: 4,
|
||||
StartGapPlayers: 1,
|
||||
EnrollmentEndsAt: now.Add(time.Hour).Unix(),
|
||||
TurnSchedule: "0 0 * * *",
|
||||
TargetEngineVersion: "1.0.0",
|
||||
}
|
||||
rec := doRequest(t, handler, http.MethodPost, "/api/v1/lobby/games", "user-42", body)
|
||||
require.Equal(t, http.StatusForbidden, rec.Code)
|
||||
decoded := decodeError(t, rec)
|
||||
require.Equal(t, "forbidden", decoded.Error.Code)
|
||||
}
|
||||
|
||||
func TestUpdateGameNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
handler := buildHandler(t, gamestub.NewStore(), &stubIDGenerator{next: "game-x"}, fixedClock(now))
|
||||
|
||||
desc := "new"
|
||||
body := updateGameRequest{Description: &desc}
|
||||
rec := doRequest(t, handler, http.MethodPatch, "/api/v1/lobby/games/game-missing", "user-1", body)
|
||||
require.Equal(t, http.StatusNotFound, rec.Code)
|
||||
decoded := decodeError(t, rec)
|
||||
require.Equal(t, "subject_not_found", decoded.Error.Code)
|
||||
}
|
||||
|
||||
func TestOpenEnrollmentHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
|
||||
seedDraftForTest(t, store, "game-oe", game.GameTypePrivate, "user-1", now)
|
||||
|
||||
handler := buildHandler(t, store, &stubIDGenerator{next: "unused"}, fixedClock(now.Add(time.Hour)))
|
||||
|
||||
rec := doRequest(t, handler, http.MethodPost, "/api/v1/lobby/games/game-oe/open-enrollment", "user-1", nil)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
decoded := decodeGameRecord(t, rec)
|
||||
require.Equal(t, "enrollment_open", decoded.Status)
|
||||
}
|
||||
|
||||
func TestOpenEnrollmentForbidden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
|
||||
seedDraftForTest(t, store, "game-oe", game.GameTypePrivate, "user-1", now)
|
||||
|
||||
handler := buildHandler(t, store, &stubIDGenerator{next: "unused"}, fixedClock(now.Add(time.Hour)))
|
||||
|
||||
rec := doRequest(t, handler, http.MethodPost, "/api/v1/lobby/games/game-oe/open-enrollment", "user-2", nil)
|
||||
require.Equal(t, http.StatusForbidden, rec.Code)
|
||||
}
|
||||
|
||||
func TestOpenEnrollmentConflict(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
|
||||
seedDraftForTest(t, store, "game-oe", game.GameTypePrivate, "user-1", now)
|
||||
require.NoError(t, store.UpdateStatus(context.Background(), ports.UpdateStatusInput{
|
||||
GameID: "game-oe",
|
||||
ExpectedFrom: game.StatusDraft,
|
||||
To: game.StatusEnrollmentOpen,
|
||||
Trigger: game.TriggerCommand,
|
||||
At: now.Add(5 * time.Minute),
|
||||
}))
|
||||
|
||||
handler := buildHandler(t, store, &stubIDGenerator{next: "unused"}, fixedClock(now.Add(time.Hour)))
|
||||
|
||||
rec := doRequest(t, handler, http.MethodPost, "/api/v1/lobby/games/game-oe/open-enrollment", "user-1", nil)
|
||||
require.Equal(t, http.StatusConflict, rec.Code)
|
||||
decoded := decodeError(t, rec)
|
||||
require.Equal(t, "conflict", decoded.Error.Code)
|
||||
}
|
||||
|
||||
func TestCancelGameHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
|
||||
seedDraftForTest(t, store, "game-cx", game.GameTypePrivate, "user-1", now)
|
||||
|
||||
handler := buildHandler(t, store, &stubIDGenerator{next: "unused"}, fixedClock(now.Add(time.Hour)))
|
||||
|
||||
rec := doRequest(t, handler, http.MethodPost, "/api/v1/lobby/games/game-cx/cancel", "user-1", nil)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
decoded := decodeGameRecord(t, rec)
|
||||
require.Equal(t, "cancelled", decoded.Status)
|
||||
}
|
||||
|
||||
func seedDraftForTest(
|
||||
t *testing.T,
|
||||
store *gamestub.Store,
|
||||
id common.GameID,
|
||||
gameType game.GameType,
|
||||
ownerUserID string,
|
||||
now time.Time,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
record, err := game.New(game.NewGameInput{
|
||||
GameID: id,
|
||||
GameName: "Seed",
|
||||
GameType: gameType,
|
||||
OwnerUserID: ownerUserID,
|
||||
MinPlayers: 2,
|
||||
MaxPlayers: 4,
|
||||
StartGapHours: 4,
|
||||
StartGapPlayers: 1,
|
||||
EnrollmentEndsAt: now.Add(24 * time.Hour),
|
||||
TurnSchedule: "0 */6 * * *",
|
||||
TargetEngineVersion: "1.0.0",
|
||||
Now: now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, store.Save(context.Background(), record))
|
||||
}
|
||||
|
||||
func TestIsValidationErrorHeuristic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.True(t, isValidationError(errStr("game name must not be empty")))
|
||||
require.True(t, isValidationError(errStr("status \"ghost\" is unsupported")))
|
||||
require.True(t, isValidationError(errStr("invalid cron expression")))
|
||||
require.False(t, isValidationError(nil))
|
||||
require.False(t, isValidationError(errStr("redis down")))
|
||||
}
|
||||
|
||||
type errString string
|
||||
|
||||
func (e errString) Error() string { return string(e) }
|
||||
|
||||
func errStr(s string) error { return errString(s) }
|
||||
@@ -0,0 +1,243 @@
|
||||
package publichttp
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/invite"
|
||||
"galaxy/lobby/internal/service/createinvite"
|
||||
"galaxy/lobby/internal/service/declineinvite"
|
||||
"galaxy/lobby/internal/service/redeeminvite"
|
||||
"galaxy/lobby/internal/service/revokeinvite"
|
||||
"galaxy/lobby/internal/service/shared"
|
||||
)
|
||||
|
||||
// Public HTTP route patterns for the invite surface.
|
||||
const (
|
||||
createInvitePath = "/api/v1/lobby/games/{game_id}/invites"
|
||||
redeemInvitePath = "/api/v1/lobby/games/{game_id}/invites/{invite_id}/redeem"
|
||||
declineInvitePath = "/api/v1/lobby/games/{game_id}/invites/{invite_id}/decline"
|
||||
revokeInvitePath = "/api/v1/lobby/games/{game_id}/invites/{invite_id}/revoke"
|
||||
|
||||
inviteIDPathParamValue = "invite_id"
|
||||
)
|
||||
|
||||
// createInviteRequest is the JSON shape for
|
||||
// `POST /api/v1/lobby/games/{game_id}/invites`.
|
||||
type createInviteRequest struct {
|
||||
InviteeUserID string `json:"invitee_user_id"`
|
||||
}
|
||||
|
||||
// redeemInviteRequest is the JSON shape for
|
||||
// `POST /api/v1/lobby/games/{game_id}/invites/{invite_id}/redeem`.
|
||||
type redeemInviteRequest struct {
|
||||
RaceName string `json:"race_name"`
|
||||
}
|
||||
|
||||
// inviteRecordResponse mirrors the OpenAPI InviteRecord schema. RaceName is
|
||||
// omitted from the wire shape until the invite transitions to redeemed.
|
||||
type inviteRecordResponse struct {
|
||||
InviteID string `json:"invite_id"`
|
||||
GameID string `json:"game_id"`
|
||||
InviterUserID string `json:"inviter_user_id"`
|
||||
InviteeUserID string `json:"invitee_user_id"`
|
||||
RaceName string `json:"race_name,omitempty"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
DecidedAt *int64 `json:"decided_at,omitempty"`
|
||||
}
|
||||
|
||||
func encodeInviteRecord(record invite.Invite) inviteRecordResponse {
|
||||
resp := inviteRecordResponse{
|
||||
InviteID: record.InviteID.String(),
|
||||
GameID: record.GameID.String(),
|
||||
InviterUserID: record.InviterUserID,
|
||||
InviteeUserID: record.InviteeUserID,
|
||||
RaceName: record.RaceName,
|
||||
Status: string(record.Status),
|
||||
CreatedAt: record.CreatedAt.UTC().UnixMilli(),
|
||||
ExpiresAt: record.ExpiresAt.UTC().UnixMilli(),
|
||||
}
|
||||
if record.DecidedAt != nil {
|
||||
decided := record.DecidedAt.UTC().UnixMilli()
|
||||
resp.DecidedAt = &decided
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// registerInviteRoutes binds the four invite routes.
|
||||
func registerInviteRoutes(mux *http.ServeMux, deps Dependencies, logger *slog.Logger) {
|
||||
h := &inviteHandlers{
|
||||
deps: deps,
|
||||
logger: logger.With("component", "public_http.invites"),
|
||||
}
|
||||
mux.HandleFunc("POST "+createInvitePath, h.handleCreate)
|
||||
mux.HandleFunc("POST "+redeemInvitePath, h.handleRedeem)
|
||||
mux.HandleFunc("POST "+declineInvitePath, h.handleDecline)
|
||||
mux.HandleFunc("POST "+revokeInvitePath, h.handleRevoke)
|
||||
}
|
||||
|
||||
type inviteHandlers struct {
|
||||
deps Dependencies
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (h *inviteHandlers) extractGameID(writer http.ResponseWriter, request *http.Request) (common.GameID, bool) {
|
||||
raw := request.PathValue(gameIDPathParamValue)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_request", "game id is required")
|
||||
return "", false
|
||||
}
|
||||
return common.GameID(raw), true
|
||||
}
|
||||
|
||||
func (h *inviteHandlers) extractInviteID(writer http.ResponseWriter, request *http.Request) (common.InviteID, bool) {
|
||||
raw := request.PathValue(inviteIDPathParamValue)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_request", "invite id is required")
|
||||
return "", false
|
||||
}
|
||||
return common.InviteID(raw), true
|
||||
}
|
||||
|
||||
func (h *inviteHandlers) requireUserActor(writer http.ResponseWriter, request *http.Request) (string, bool) {
|
||||
userID := strings.TrimSpace(request.Header.Get(xUserIDHeader))
|
||||
if userID == "" {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_request",
|
||||
"X-User-ID header is required")
|
||||
return "", false
|
||||
}
|
||||
return userID, true
|
||||
}
|
||||
|
||||
func (h *inviteHandlers) handleCreate(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.CreateInvite == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "create invite service is not wired")
|
||||
return
|
||||
}
|
||||
userID, ok := h.requireUserActor(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gameID, ok := h.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var body createInviteRequest
|
||||
if err := decodeStrictJSON(request.Body, &body); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.deps.CreateInvite.Handle(request.Context(), createinvite.Input{
|
||||
Actor: shared.NewUserActor(userID),
|
||||
GameID: gameID,
|
||||
InviteeUserID: body.InviteeUserID,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusCreated, encodeInviteRecord(record))
|
||||
}
|
||||
|
||||
func (h *inviteHandlers) handleRedeem(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.RedeemInvite == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "redeem invite service is not wired")
|
||||
return
|
||||
}
|
||||
userID, ok := h.requireUserActor(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gameID, ok := h.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
inviteID, ok := h.extractInviteID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var body redeemInviteRequest
|
||||
if err := decodeStrictJSON(request.Body, &body); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.deps.RedeemInvite.Handle(request.Context(), redeeminvite.Input{
|
||||
Actor: shared.NewUserActor(userID),
|
||||
GameID: gameID,
|
||||
InviteID: inviteID,
|
||||
RaceName: body.RaceName,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, encodeMembershipRecord(record))
|
||||
}
|
||||
|
||||
func (h *inviteHandlers) handleDecline(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.DeclineInvite == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "decline invite service is not wired")
|
||||
return
|
||||
}
|
||||
userID, ok := h.requireUserActor(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gameID, ok := h.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
inviteID, ok := h.extractInviteID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.deps.DeclineInvite.Handle(request.Context(), declineinvite.Input{
|
||||
Actor: shared.NewUserActor(userID),
|
||||
GameID: gameID,
|
||||
InviteID: inviteID,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, encodeInviteRecord(record))
|
||||
}
|
||||
|
||||
func (h *inviteHandlers) handleRevoke(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.RevokeInvite == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "revoke invite service is not wired")
|
||||
return
|
||||
}
|
||||
userID, ok := h.requireUserActor(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gameID, ok := h.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
inviteID, ok := h.extractInviteID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.deps.RevokeInvite.Handle(request.Context(), revokeinvite.Input{
|
||||
Actor: shared.NewUserActor(userID),
|
||||
GameID: gameID,
|
||||
InviteID: inviteID,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, encodeInviteRecord(record))
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package publichttp
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/membership"
|
||||
"galaxy/lobby/internal/service/blockmember"
|
||||
"galaxy/lobby/internal/service/listmemberships"
|
||||
"galaxy/lobby/internal/service/removemember"
|
||||
)
|
||||
|
||||
// Public HTTP route patterns for the membership routes.
|
||||
const (
|
||||
listMembershipsPath = "/api/v1/lobby/games/{game_id}/memberships"
|
||||
removeMemberPath = "/api/v1/lobby/games/{game_id}/memberships/{membership_id}/remove"
|
||||
blockMemberPath = "/api/v1/lobby/games/{game_id}/memberships/{membership_id}/block"
|
||||
membershipIDPathParamValue = "membership_id"
|
||||
)
|
||||
|
||||
// registerMembershipRoutes binds the membership
|
||||
// routes on the public port. The X-User-ID header is required on every
|
||||
// route; admins use the internal port equivalents.
|
||||
func registerMembershipRoutes(mux *http.ServeMux, deps Dependencies, logger *slog.Logger) {
|
||||
h := &membershipHandlers{
|
||||
deps: deps,
|
||||
logger: logger.With("component", "public_http.memberships"),
|
||||
}
|
||||
mux.HandleFunc("GET "+listMembershipsPath, h.handleList)
|
||||
mux.HandleFunc("POST "+removeMemberPath, h.handleRemove)
|
||||
mux.HandleFunc("POST "+blockMemberPath, h.handleBlock)
|
||||
}
|
||||
|
||||
type membershipHandlers struct {
|
||||
deps Dependencies
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (h *membershipHandlers) extractMembershipID(writer http.ResponseWriter, request *http.Request) (common.MembershipID, bool) {
|
||||
raw := request.PathValue(membershipIDPathParamValue)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_request", "membership id is required")
|
||||
return "", false
|
||||
}
|
||||
return common.MembershipID(raw), true
|
||||
}
|
||||
|
||||
func (h *membershipHandlers) handleRemove(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.RemoveMember == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "remove member service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
games := &gameHandlers{deps: h.deps, logger: h.logger}
|
||||
actor, ok := games.requireUserActor(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gameID, ok := games.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
membershipID, ok := h.extractMembershipID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.deps.RemoveMember.Handle(request.Context(), removemember.Input{
|
||||
Actor: actor,
|
||||
GameID: gameID,
|
||||
MembershipID: membershipID,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeMembershipRecord(record))
|
||||
}
|
||||
|
||||
// membershipListResponse mirrors the OpenAPI MembershipListResponse
|
||||
// schema. Items are always non-nil so the JSON form carries `[]` rather
|
||||
// than `null` for empty pages.
|
||||
type membershipListResponse struct {
|
||||
Items []membershipRecordResponse `json:"items"`
|
||||
NextPageToken string `json:"next_page_token,omitempty"`
|
||||
}
|
||||
|
||||
func encodeMembershipList(items []membership.Membership, nextPageToken string) membershipListResponse {
|
||||
resp := membershipListResponse{
|
||||
Items: make([]membershipRecordResponse, 0, len(items)),
|
||||
NextPageToken: nextPageToken,
|
||||
}
|
||||
for _, item := range items {
|
||||
resp.Items = append(resp.Items, encodeMembershipRecord(item))
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func (h *membershipHandlers) handleList(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.ListMemberships == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "list memberships service is not wired")
|
||||
return
|
||||
}
|
||||
games := &gameHandlers{deps: h.deps, logger: h.logger}
|
||||
actor, ok := games.requireUserActor(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gameID, ok := games.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, ok := parsePage(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
out, err := h.deps.ListMemberships.Handle(request.Context(), listmemberships.Input{
|
||||
Actor: actor,
|
||||
GameID: gameID,
|
||||
Page: page,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeMembershipList(out.Items, out.NextPageToken))
|
||||
}
|
||||
|
||||
func (h *membershipHandlers) handleBlock(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.BlockMember == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "block member service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
games := &gameHandlers{deps: h.deps, logger: h.logger}
|
||||
actor, ok := games.requireUserActor(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gameID, ok := games.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
membershipID, ok := h.extractMembershipID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.deps.BlockMember.Handle(request.Context(), blockmember.Input{
|
||||
Actor: actor,
|
||||
GameID: gameID,
|
||||
MembershipID: membershipID,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeMembershipRecord(record))
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package publichttp
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"galaxy/lobby/internal/service/listmyapplications"
|
||||
"galaxy/lobby/internal/service/listmygames"
|
||||
"galaxy/lobby/internal/service/listmyinvites"
|
||||
)
|
||||
|
||||
// Public HTTP route patterns for the user-facing list routes.
|
||||
const (
|
||||
myGamesPath = "/api/v1/lobby/my/games"
|
||||
myApplicationsPath = "/api/v1/lobby/my/applications"
|
||||
myInvitesPath = "/api/v1/lobby/my/invites"
|
||||
)
|
||||
|
||||
// registerMyListRoutes binds the three «my» routes on the
|
||||
// public port. Every route requires the X-User-ID header and rejects
|
||||
// admin actors at the service layer with shared.ErrForbidden.
|
||||
func registerMyListRoutes(mux *http.ServeMux, deps Dependencies, logger *slog.Logger) {
|
||||
h := &myListHandlers{
|
||||
deps: deps,
|
||||
logger: logger.With("component", "public_http.mylists"),
|
||||
}
|
||||
mux.HandleFunc("GET "+myGamesPath, h.handleListGames)
|
||||
mux.HandleFunc("GET "+myApplicationsPath, h.handleListApplications)
|
||||
mux.HandleFunc("GET "+myInvitesPath, h.handleListInvites)
|
||||
}
|
||||
|
||||
type myListHandlers struct {
|
||||
deps Dependencies
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// myApplicationItem mirrors the OpenAPI MyApplicationItem schema. It
|
||||
// embeds every field of the canonical ApplicationRecord plus the
|
||||
// game-display fields the personal list needs.
|
||||
type myApplicationItem struct {
|
||||
ApplicationID string `json:"application_id"`
|
||||
GameID string `json:"game_id"`
|
||||
ApplicantUserID string `json:"applicant_user_id"`
|
||||
RaceName string `json:"race_name"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
DecidedAt *int64 `json:"decided_at,omitempty"`
|
||||
GameName string `json:"game_name"`
|
||||
GameType string `json:"game_type"`
|
||||
}
|
||||
|
||||
// myApplicationListResponse mirrors MyApplicationListResponse.
|
||||
type myApplicationListResponse struct {
|
||||
Items []myApplicationItem `json:"items"`
|
||||
NextPageToken string `json:"next_page_token,omitempty"`
|
||||
}
|
||||
|
||||
func encodeMyApplicationList(out listmyapplications.Output) myApplicationListResponse {
|
||||
resp := myApplicationListResponse{
|
||||
Items: make([]myApplicationItem, 0, len(out.Items)),
|
||||
NextPageToken: out.NextPageToken,
|
||||
}
|
||||
for _, item := range out.Items {
|
||||
entry := myApplicationItem{
|
||||
ApplicationID: item.Application.ApplicationID.String(),
|
||||
GameID: item.Application.GameID.String(),
|
||||
ApplicantUserID: item.Application.ApplicantUserID,
|
||||
RaceName: item.Application.RaceName,
|
||||
Status: string(item.Application.Status),
|
||||
CreatedAt: item.Application.CreatedAt.UTC().UnixMilli(),
|
||||
GameName: item.GameName,
|
||||
GameType: string(item.GameType),
|
||||
}
|
||||
if item.Application.DecidedAt != nil {
|
||||
decided := item.Application.DecidedAt.UTC().UnixMilli()
|
||||
entry.DecidedAt = &decided
|
||||
}
|
||||
resp.Items = append(resp.Items, entry)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// myInviteItem mirrors the OpenAPI MyInviteItem schema. It embeds
|
||||
// every field of the canonical InviteRecord plus the game-display
|
||||
// fields the personal list needs.
|
||||
type myInviteItem struct {
|
||||
InviteID string `json:"invite_id"`
|
||||
GameID string `json:"game_id"`
|
||||
InviterUserID string `json:"inviter_user_id"`
|
||||
InviteeUserID string `json:"invitee_user_id"`
|
||||
RaceName string `json:"race_name,omitempty"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
DecidedAt *int64 `json:"decided_at,omitempty"`
|
||||
GameName string `json:"game_name"`
|
||||
InviterName string `json:"inviter_name"`
|
||||
}
|
||||
|
||||
// myInviteListResponse mirrors MyInviteListResponse.
|
||||
type myInviteListResponse struct {
|
||||
Items []myInviteItem `json:"items"`
|
||||
NextPageToken string `json:"next_page_token,omitempty"`
|
||||
}
|
||||
|
||||
func encodeMyInviteList(out listmyinvites.Output) myInviteListResponse {
|
||||
resp := myInviteListResponse{
|
||||
Items: make([]myInviteItem, 0, len(out.Items)),
|
||||
NextPageToken: out.NextPageToken,
|
||||
}
|
||||
for _, item := range out.Items {
|
||||
entry := myInviteItem{
|
||||
InviteID: item.Invite.InviteID.String(),
|
||||
GameID: item.Invite.GameID.String(),
|
||||
InviterUserID: item.Invite.InviterUserID,
|
||||
InviteeUserID: item.Invite.InviteeUserID,
|
||||
RaceName: item.Invite.RaceName,
|
||||
Status: string(item.Invite.Status),
|
||||
CreatedAt: item.Invite.CreatedAt.UTC().UnixMilli(),
|
||||
ExpiresAt: item.Invite.ExpiresAt.UTC().UnixMilli(),
|
||||
GameName: item.GameName,
|
||||
InviterName: item.InviterName,
|
||||
}
|
||||
if item.Invite.DecidedAt != nil {
|
||||
decided := item.Invite.DecidedAt.UTC().UnixMilli()
|
||||
entry.DecidedAt = &decided
|
||||
}
|
||||
resp.Items = append(resp.Items, entry)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func (h *myListHandlers) handleListGames(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.ListMyGames == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "list my games service is not wired")
|
||||
return
|
||||
}
|
||||
games := &gameHandlers{deps: h.deps, logger: h.logger}
|
||||
actor, ok := games.requireUserActor(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, ok := parsePage(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
out, err := h.deps.ListMyGames.Handle(request.Context(), listmygames.Input{
|
||||
Actor: actor,
|
||||
Page: page,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeGameList(out.Items, out.NextPageToken))
|
||||
}
|
||||
|
||||
func (h *myListHandlers) handleListApplications(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.ListMyApplications == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error",
|
||||
"list my applications service is not wired")
|
||||
return
|
||||
}
|
||||
games := &gameHandlers{deps: h.deps, logger: h.logger}
|
||||
actor, ok := games.requireUserActor(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, ok := parsePage(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
out, err := h.deps.ListMyApplications.Handle(request.Context(), listmyapplications.Input{
|
||||
Actor: actor,
|
||||
Page: page,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeMyApplicationList(out))
|
||||
}
|
||||
|
||||
func (h *myListHandlers) handleListInvites(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.ListMyInvites == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error",
|
||||
"list my invites service is not wired")
|
||||
return
|
||||
}
|
||||
games := &gameHandlers{deps: h.deps, logger: h.logger}
|
||||
actor, ok := games.requireUserActor(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, ok := parsePage(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
out, err := h.deps.ListMyInvites.Handle(request.Context(), listmyinvites.Input{
|
||||
Actor: actor,
|
||||
Page: page,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeMyInviteList(out))
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package publichttp
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"galaxy/lobby/internal/service/pausegame"
|
||||
"galaxy/lobby/internal/service/resumegame"
|
||||
)
|
||||
|
||||
const (
|
||||
pauseGamePath = "/api/v1/lobby/games/{game_id}/pause"
|
||||
resumeGamePath = "/api/v1/lobby/games/{game_id}/resume"
|
||||
)
|
||||
|
||||
// registerPauseResumeRoutes binds the voluntary pause and
|
||||
// resume routes on the public port. Both routes require the X-User-ID
|
||||
// header so the actor is always a user; admins use the internal port.
|
||||
func registerPauseResumeRoutes(mux *http.ServeMux, deps Dependencies, logger *slog.Logger) {
|
||||
h := &pauseResumeHandlers{
|
||||
deps: deps,
|
||||
logger: logger.With("component", "public_http.pauseresume"),
|
||||
}
|
||||
mux.HandleFunc("POST "+pauseGamePath, h.handlePause)
|
||||
mux.HandleFunc("POST "+resumeGamePath, h.handleResume)
|
||||
}
|
||||
|
||||
type pauseResumeHandlers struct {
|
||||
deps Dependencies
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (h *pauseResumeHandlers) handlePause(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.PauseGame == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "pause game service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
games := &gameHandlers{deps: h.deps, logger: h.logger}
|
||||
actor, ok := games.requireUserActor(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gameID, ok := games.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.deps.PauseGame.Handle(request.Context(), pausegame.Input{
|
||||
Actor: actor,
|
||||
GameID: gameID,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeGameRecord(record))
|
||||
}
|
||||
|
||||
func (h *pauseResumeHandlers) handleResume(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.ResumeGame == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "resume game service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
games := &gameHandlers{deps: h.deps, logger: h.logger}
|
||||
actor, ok := games.requireUserActor(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gameID, ok := games.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.deps.ResumeGame.Handle(request.Context(), resumegame.Input{
|
||||
Actor: actor,
|
||||
GameID: gameID,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeGameRecord(record))
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package publichttp
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/service/listmyracenames"
|
||||
"galaxy/lobby/internal/service/registerracename"
|
||||
)
|
||||
|
||||
// Public HTTP route patterns for the race-name surface owned by
|
||||
// (register) and (self-service list).
|
||||
const (
|
||||
registerRaceNamePath = "/api/v1/lobby/race-names/register"
|
||||
myRaceNamesPath = "/api/v1/lobby/my/race-names"
|
||||
)
|
||||
|
||||
// registerRaceNameRoutes binds the public-port race-name routes:
|
||||
// the `lobby.race_name.register` POST and the
|
||||
// `lobby.race_names.list` GET. Both routes require the X-User-ID
|
||||
// header so the actor is always a user; administrators have no
|
||||
// equivalent admin path on the internal port.
|
||||
func registerRaceNameRoutes(mux *http.ServeMux, deps Dependencies, logger *slog.Logger) {
|
||||
h := &raceNameHandlers{
|
||||
deps: deps,
|
||||
logger: logger.With("component", "public_http.racenames"),
|
||||
}
|
||||
mux.HandleFunc("POST "+registerRaceNamePath, h.handleRegister)
|
||||
mux.HandleFunc("GET "+myRaceNamesPath, h.handleListMy)
|
||||
}
|
||||
|
||||
type raceNameHandlers struct {
|
||||
deps Dependencies
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// registerRaceNameRequest is the JSON shape for
|
||||
// POST /api/v1/lobby/race-names/register.
|
||||
type registerRaceNameRequest struct {
|
||||
RaceName string `json:"race_name"`
|
||||
SourceGameID string `json:"source_game_id"`
|
||||
}
|
||||
|
||||
// registerRaceNameResponse mirrors `ports.RegisteredName` on the wire.
|
||||
// `registered_at_ms` carries the Unix-millisecond timestamp of the
|
||||
// successful Register commit; idempotent retries return the same value
|
||||
// recorded by the original commit.
|
||||
type registerRaceNameResponse struct {
|
||||
CanonicalKey string `json:"canonical_key"`
|
||||
RaceName string `json:"race_name"`
|
||||
SourceGameID string `json:"source_game_id"`
|
||||
RegisteredAtMs int64 `json:"registered_at_ms"`
|
||||
}
|
||||
|
||||
// myRaceNamesResponse is the JSON shape for
|
||||
// GET /api/v1/lobby/my/race-names. The three slices are non-nil but
|
||||
// possibly empty so consumers can iterate without a presence check.
|
||||
type myRaceNamesResponse struct {
|
||||
Registered []registeredRaceNameItem `json:"registered"`
|
||||
Pending []pendingRaceNameItem `json:"pending"`
|
||||
Reservations []raceNameReservationItem `json:"reservations"`
|
||||
}
|
||||
|
||||
// registeredRaceNameItem mirrors `ports.RegisteredName`. It matches the
|
||||
// `RegisteredRaceName` schema field-for-field so the OpenAPI
|
||||
// model can be reused.
|
||||
type registeredRaceNameItem struct {
|
||||
CanonicalKey string `json:"canonical_key"`
|
||||
RaceName string `json:"race_name"`
|
||||
SourceGameID string `json:"source_game_id"`
|
||||
RegisteredAtMs int64 `json:"registered_at_ms"`
|
||||
}
|
||||
|
||||
// pendingRaceNameItem mirrors `ports.PendingRegistration` for the
|
||||
// self-service view. `source_game_id` is the game whose capable finish
|
||||
// promoted the reservation; `eligible_until_ms` is the deadline by
|
||||
// which `lobby.race_name.register` must succeed.
|
||||
type pendingRaceNameItem struct {
|
||||
CanonicalKey string `json:"canonical_key"`
|
||||
RaceName string `json:"race_name"`
|
||||
SourceGameID string `json:"source_game_id"`
|
||||
ReservedAtMs int64 `json:"reserved_at_ms"`
|
||||
EligibleUntilMs int64 `json:"eligible_until_ms"`
|
||||
}
|
||||
|
||||
// raceNameReservationItem mirrors `ports.Reservation` enriched with
|
||||
// the current `game_status` joined from the game store. `game_status`
|
||||
// is empty when the underlying game record cannot be loaded.
|
||||
type raceNameReservationItem struct {
|
||||
CanonicalKey string `json:"canonical_key"`
|
||||
RaceName string `json:"race_name"`
|
||||
GameID string `json:"game_id"`
|
||||
ReservedAtMs int64 `json:"reserved_at_ms"`
|
||||
GameStatus string `json:"game_status"`
|
||||
}
|
||||
|
||||
func (h *raceNameHandlers) handleListMy(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.ListMyRaceNames == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error",
|
||||
"list my race names service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
games := &gameHandlers{deps: h.deps, logger: h.logger}
|
||||
actor, ok := games.requireUserActor(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
out, err := h.deps.ListMyRaceNames.Handle(request.Context(), listmyracenames.Input{
|
||||
Actor: actor,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp := myRaceNamesResponse{
|
||||
Registered: make([]registeredRaceNameItem, 0, len(out.Registered)),
|
||||
Pending: make([]pendingRaceNameItem, 0, len(out.Pending)),
|
||||
Reservations: make([]raceNameReservationItem, 0, len(out.Reservations)),
|
||||
}
|
||||
for _, entry := range out.Registered {
|
||||
resp.Registered = append(resp.Registered, registeredRaceNameItem{
|
||||
CanonicalKey: entry.CanonicalKey,
|
||||
RaceName: entry.RaceName,
|
||||
SourceGameID: entry.SourceGameID,
|
||||
RegisteredAtMs: entry.RegisteredAtMs,
|
||||
})
|
||||
}
|
||||
for _, entry := range out.Pending {
|
||||
resp.Pending = append(resp.Pending, pendingRaceNameItem{
|
||||
CanonicalKey: entry.CanonicalKey,
|
||||
RaceName: entry.RaceName,
|
||||
SourceGameID: entry.SourceGameID,
|
||||
ReservedAtMs: entry.ReservedAtMs,
|
||||
EligibleUntilMs: entry.EligibleUntilMs,
|
||||
})
|
||||
}
|
||||
for _, entry := range out.Reservations {
|
||||
resp.Reservations = append(resp.Reservations, raceNameReservationItem{
|
||||
CanonicalKey: entry.CanonicalKey,
|
||||
RaceName: entry.RaceName,
|
||||
GameID: entry.GameID,
|
||||
ReservedAtMs: entry.ReservedAtMs,
|
||||
GameStatus: entry.GameStatus,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *raceNameHandlers) handleRegister(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.RegisterRaceName == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error",
|
||||
"register race name service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
games := &gameHandlers{deps: h.deps, logger: h.logger}
|
||||
actor, ok := games.requireUserActor(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var body registerRaceNameRequest
|
||||
if err := decodeStrictJSON(request.Body, &body); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
out, err := h.deps.RegisterRaceName.Handle(request.Context(), registerracename.Input{
|
||||
Actor: actor,
|
||||
SourceGameID: common.GameID(body.SourceGameID),
|
||||
RaceName: body.RaceName,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, registerRaceNameResponse{
|
||||
CanonicalKey: out.CanonicalKey,
|
||||
RaceName: out.RaceName,
|
||||
SourceGameID: out.SourceGameID,
|
||||
RegisteredAtMs: out.RegisteredAtMs,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
package publichttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/intentpubstub"
|
||||
"galaxy/lobby/internal/adapters/racenamestub"
|
||||
"galaxy/lobby/internal/adapters/userservicestub"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/ports"
|
||||
"galaxy/lobby/internal/service/listmyracenames"
|
||||
"galaxy/lobby/internal/service/registerracename"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type raceNameFixture struct {
|
||||
now time.Time
|
||||
directory *racenamestub.Directory
|
||||
users *userservicestub.Service
|
||||
intents *intentpubstub.Publisher
|
||||
handler http.Handler
|
||||
}
|
||||
|
||||
func newRaceNameFixture(t *testing.T) *raceNameFixture {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
directory, err := racenamestub.NewDirectory(racenamestub.WithClock(func() time.Time { return now }))
|
||||
require.NoError(t, err)
|
||||
users := userservicestub.NewService()
|
||||
intents := intentpubstub.NewPublisher()
|
||||
|
||||
logger := silentLogger()
|
||||
svc, err := registerracename.NewService(registerracename.Dependencies{
|
||||
Directory: directory,
|
||||
Users: users,
|
||||
Intents: intents,
|
||||
Clock: func() time.Time { return now },
|
||||
Logger: logger,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return &raceNameFixture{
|
||||
now: now,
|
||||
directory: directory,
|
||||
users: users,
|
||||
intents: intents,
|
||||
handler: newHandler(Dependencies{Logger: logger, RegisterRaceName: svc}, logger),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *raceNameFixture) seedPending(t *testing.T, gameID, userID, raceName string, eligibleUntil time.Time) {
|
||||
t.Helper()
|
||||
require.NoError(t, f.directory.Reserve(context.Background(), gameID, userID, raceName))
|
||||
require.NoError(t, f.directory.MarkPendingRegistration(context.Background(), gameID, userID, raceName, eligibleUntil))
|
||||
}
|
||||
|
||||
func TestHandleRegisterRaceNameHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRaceNameFixture(t)
|
||||
f.users.SetEligibility("user-1", ports.Eligibility{Exists: true, MaxRegisteredRaceNames: 2})
|
||||
f.seedPending(t, "game-1", "user-1", "Stellaris", f.now.Add(7*24*time.Hour))
|
||||
|
||||
rec := doRequest(t, f.handler, http.MethodPost, registerRaceNamePath, "user-1", registerRaceNameRequest{
|
||||
RaceName: "Stellaris",
|
||||
SourceGameID: "game-1",
|
||||
})
|
||||
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||
|
||||
var resp registerRaceNameResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
assert.Equal(t, "Stellaris", resp.RaceName)
|
||||
assert.Equal(t, "game-1", resp.SourceGameID)
|
||||
assert.Equal(t, f.now.UnixMilli(), resp.RegisteredAtMs)
|
||||
assert.NotEmpty(t, resp.CanonicalKey)
|
||||
|
||||
require.Len(t, f.intents.Published(), 1)
|
||||
}
|
||||
|
||||
func TestHandleRegisterRaceNameRejectsMissingUserHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRaceNameFixture(t)
|
||||
rec := doRequest(t, f.handler, http.MethodPost, registerRaceNamePath, "", registerRaceNameRequest{
|
||||
RaceName: "Stellaris",
|
||||
SourceGameID: "game-1",
|
||||
})
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
|
||||
var env errorResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &env))
|
||||
assert.Equal(t, "invalid_request", env.Error.Code)
|
||||
}
|
||||
|
||||
func TestHandleRegisterRaceNameRejectsUnknownFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRaceNameFixture(t)
|
||||
rec := doRequest(t, f.handler, http.MethodPost, registerRaceNamePath, "user-1", map[string]string{
|
||||
"race_name": "Stellaris",
|
||||
"source_game_id": "game-1",
|
||||
"extra": "boom",
|
||||
})
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
|
||||
var env errorResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &env))
|
||||
assert.Equal(t, "invalid_request", env.Error.Code)
|
||||
}
|
||||
|
||||
func TestHandleRegisterRaceNamePendingMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRaceNameFixture(t)
|
||||
f.users.SetEligibility("user-1", ports.Eligibility{Exists: true, MaxRegisteredRaceNames: 2})
|
||||
|
||||
rec := doRequest(t, f.handler, http.MethodPost, registerRaceNamePath, "user-1", registerRaceNameRequest{
|
||||
RaceName: "Stellaris",
|
||||
SourceGameID: "game-1",
|
||||
})
|
||||
require.Equal(t, http.StatusNotFound, rec.Code)
|
||||
|
||||
var env errorResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &env))
|
||||
assert.Equal(t, "subject_not_found", env.Error.Code)
|
||||
}
|
||||
|
||||
func TestHandleRegisterRaceNamePendingExpired(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRaceNameFixture(t)
|
||||
f.users.SetEligibility("user-1", ports.Eligibility{Exists: true, MaxRegisteredRaceNames: 2})
|
||||
f.seedPending(t, "game-1", "user-1", "Stellaris", f.now.Add(-time.Minute))
|
||||
|
||||
rec := doRequest(t, f.handler, http.MethodPost, registerRaceNamePath, "user-1", registerRaceNameRequest{
|
||||
RaceName: "Stellaris",
|
||||
SourceGameID: "game-1",
|
||||
})
|
||||
require.Equal(t, http.StatusUnprocessableEntity, rec.Code)
|
||||
|
||||
var env errorResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &env))
|
||||
assert.Equal(t, "race_name_pending_window_expired", env.Error.Code)
|
||||
}
|
||||
|
||||
func TestHandleRegisterRaceNameQuotaExceeded(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRaceNameFixture(t)
|
||||
f.users.SetEligibility("user-1", ports.Eligibility{Exists: true, MaxRegisteredRaceNames: 1})
|
||||
// pre-existing registered race name to exhaust quota
|
||||
f.seedPending(t, "game-old", "user-1", "OldName", f.now.Add(24*time.Hour))
|
||||
require.NoError(t, f.directory.Register(context.Background(), "game-old", "user-1", "OldName"))
|
||||
// fresh pending the user wants to register
|
||||
f.seedPending(t, "game-1", "user-1", "Stellaris", f.now.Add(24*time.Hour))
|
||||
|
||||
rec := doRequest(t, f.handler, http.MethodPost, registerRaceNamePath, "user-1", registerRaceNameRequest{
|
||||
RaceName: "Stellaris",
|
||||
SourceGameID: "game-1",
|
||||
})
|
||||
require.Equal(t, http.StatusUnprocessableEntity, rec.Code)
|
||||
|
||||
var env errorResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &env))
|
||||
assert.Equal(t, "race_name_registration_quota_exceeded", env.Error.Code)
|
||||
}
|
||||
|
||||
func TestHandleRegisterRaceNamePermanentBlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRaceNameFixture(t)
|
||||
f.users.SetEligibility("user-1", ports.Eligibility{
|
||||
Exists: true,
|
||||
PermanentBlocked: true,
|
||||
MaxRegisteredRaceNames: 2,
|
||||
})
|
||||
f.seedPending(t, "game-1", "user-1", "Stellaris", f.now.Add(24*time.Hour))
|
||||
|
||||
rec := doRequest(t, f.handler, http.MethodPost, registerRaceNamePath, "user-1", registerRaceNameRequest{
|
||||
RaceName: "Stellaris",
|
||||
SourceGameID: "game-1",
|
||||
})
|
||||
require.Equal(t, http.StatusForbidden, rec.Code)
|
||||
|
||||
var env errorResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &env))
|
||||
assert.Equal(t, "forbidden", env.Error.Code)
|
||||
}
|
||||
|
||||
func TestHandleRegisterRaceNameUserServiceUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRaceNameFixture(t)
|
||||
f.users.SetFailure("user-1", ports.ErrUserServiceUnavailable)
|
||||
f.seedPending(t, "game-1", "user-1", "Stellaris", f.now.Add(24*time.Hour))
|
||||
|
||||
rec := doRequest(t, f.handler, http.MethodPost, registerRaceNamePath, "user-1", registerRaceNameRequest{
|
||||
RaceName: "Stellaris",
|
||||
SourceGameID: "game-1",
|
||||
})
|
||||
require.Equal(t, http.StatusServiceUnavailable, rec.Code)
|
||||
|
||||
var env errorResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &env))
|
||||
assert.Equal(t, "service_unavailable", env.Error.Code)
|
||||
}
|
||||
|
||||
// myRaceNamesFixture wires the self-service GET handler with
|
||||
// the in-process race-name directory, the in-process game store, and a
|
||||
// silent logger.
|
||||
type myRaceNamesFixture struct {
|
||||
now time.Time
|
||||
directory *racenamestub.Directory
|
||||
games *gamestub.Store
|
||||
handler http.Handler
|
||||
}
|
||||
|
||||
func newMyRaceNamesFixture(t *testing.T) *myRaceNamesFixture {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
directory, err := racenamestub.NewDirectory(racenamestub.WithClock(func() time.Time { return now }))
|
||||
require.NoError(t, err)
|
||||
games := gamestub.NewStore()
|
||||
|
||||
logger := silentLogger()
|
||||
svc, err := listmyracenames.NewService(listmyracenames.Dependencies{
|
||||
Directory: directory,
|
||||
Games: games,
|
||||
Logger: logger,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return &myRaceNamesFixture{
|
||||
now: now,
|
||||
directory: directory,
|
||||
games: games,
|
||||
handler: newHandler(Dependencies{Logger: logger, ListMyRaceNames: svc}, logger),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *myRaceNamesFixture) seedGame(t *testing.T, id common.GameID, status game.Status) {
|
||||
t.Helper()
|
||||
record, err := game.New(game.NewGameInput{
|
||||
GameID: id,
|
||||
GameName: "Seed " + id.String(),
|
||||
GameType: game.GameTypePublic,
|
||||
MinPlayers: 2,
|
||||
MaxPlayers: 4,
|
||||
StartGapHours: 4,
|
||||
StartGapPlayers: 1,
|
||||
EnrollmentEndsAt: f.now.Add(24 * time.Hour),
|
||||
TurnSchedule: "0 */6 * * *",
|
||||
TargetEngineVersion: "1.0.0",
|
||||
Now: f.now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if status != game.StatusDraft {
|
||||
record.Status = status
|
||||
}
|
||||
require.NoError(t, f.games.Save(context.Background(), record))
|
||||
}
|
||||
|
||||
func TestHandleListMyRaceNamesHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newMyRaceNamesFixture(t)
|
||||
const userID = "user-1"
|
||||
|
||||
f.seedGame(t, "game-finished", game.StatusFinished)
|
||||
require.NoError(t, f.directory.Reserve(context.Background(), "game-finished", userID, "Andromeda"))
|
||||
require.NoError(t, f.directory.MarkPendingRegistration(context.Background(), "game-finished", userID, "Andromeda", f.now.Add(7*24*time.Hour)))
|
||||
require.NoError(t, f.directory.Register(context.Background(), "game-finished", userID, "Andromeda"))
|
||||
|
||||
f.seedGame(t, "game-pending", game.StatusFinished)
|
||||
require.NoError(t, f.directory.Reserve(context.Background(), "game-pending", userID, "Vega"))
|
||||
require.NoError(t, f.directory.MarkPendingRegistration(context.Background(), "game-pending", userID, "Vega", f.now.Add(24*time.Hour)))
|
||||
|
||||
f.seedGame(t, "game-running", game.StatusRunning)
|
||||
require.NoError(t, f.directory.Reserve(context.Background(), "game-running", userID, "Orion"))
|
||||
|
||||
rec := doRequest(t, f.handler, http.MethodGet, myRaceNamesPath, userID, nil)
|
||||
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||
|
||||
var resp myRaceNamesResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
|
||||
require.Len(t, resp.Registered, 1)
|
||||
assert.Equal(t, "Andromeda", resp.Registered[0].RaceName)
|
||||
assert.Equal(t, "game-finished", resp.Registered[0].SourceGameID)
|
||||
assert.Equal(t, f.now.UnixMilli(), resp.Registered[0].RegisteredAtMs)
|
||||
|
||||
require.Len(t, resp.Pending, 1)
|
||||
assert.Equal(t, "Vega", resp.Pending[0].RaceName)
|
||||
assert.Equal(t, "game-pending", resp.Pending[0].SourceGameID)
|
||||
assert.Equal(t, f.now.Add(24*time.Hour).UnixMilli(), resp.Pending[0].EligibleUntilMs)
|
||||
|
||||
require.Len(t, resp.Reservations, 1)
|
||||
assert.Equal(t, "Orion", resp.Reservations[0].RaceName)
|
||||
assert.Equal(t, "game-running", resp.Reservations[0].GameID)
|
||||
assert.Equal(t, string(game.StatusRunning), resp.Reservations[0].GameStatus)
|
||||
}
|
||||
|
||||
func TestHandleListMyRaceNamesEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newMyRaceNamesFixture(t)
|
||||
|
||||
rec := doRequest(t, f.handler, http.MethodGet, myRaceNamesPath, "user-empty", nil)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var resp myRaceNamesResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
assert.NotNil(t, resp.Registered)
|
||||
assert.NotNil(t, resp.Pending)
|
||||
assert.NotNil(t, resp.Reservations)
|
||||
assert.Empty(t, resp.Registered)
|
||||
assert.Empty(t, resp.Pending)
|
||||
assert.Empty(t, resp.Reservations)
|
||||
}
|
||||
|
||||
// TestHandleListMyRaceNamesVisibility confirms that one user's RND
|
||||
// state is not exposed through another user's `X-User-ID`. This is the
|
||||
// exit-criteria check from PLAN.md the
|
||||
func TestHandleListMyRaceNamesVisibility(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newMyRaceNamesFixture(t)
|
||||
f.seedGame(t, "game-shared", game.StatusEnrollmentOpen)
|
||||
require.NoError(t, f.directory.Reserve(context.Background(), "game-shared", "user-owner", "Polaris"))
|
||||
|
||||
rec := doRequest(t, f.handler, http.MethodGet, myRaceNamesPath, "user-other", nil)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var resp myRaceNamesResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
assert.Empty(t, resp.Reservations)
|
||||
assert.Empty(t, resp.Pending)
|
||||
assert.Empty(t, resp.Registered)
|
||||
}
|
||||
|
||||
func TestHandleListMyRaceNamesRejectsMissingUserHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newMyRaceNamesFixture(t)
|
||||
rec := doRequest(t, f.handler, http.MethodGet, myRaceNamesPath, "", nil)
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
|
||||
var env errorResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &env))
|
||||
assert.Equal(t, "invalid_request", env.Error.Code)
|
||||
}
|
||||
|
||||
// TestHandleListMyRaceNamesUnwiredService confirms the 500 fallback
|
||||
// when wiring forgets to inject the service.
|
||||
func TestHandleListMyRaceNamesUnwiredService(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := silentLogger()
|
||||
handler := newHandler(Dependencies{Logger: logger}, logger)
|
||||
rec := doRequest(t, handler, http.MethodGet, myRaceNamesPath, "user-1", nil)
|
||||
require.Equal(t, http.StatusInternalServerError, rec.Code)
|
||||
|
||||
var env errorResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &env))
|
||||
assert.Equal(t, "internal_error", env.Error.Code)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package publichttp
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"galaxy/lobby/internal/service/manualreadytostart"
|
||||
)
|
||||
|
||||
const readyToStartPath = "/api/v1/lobby/games/{game_id}/ready-to-start"
|
||||
|
||||
// registerReadyToStartRoutes binds the manual ready-to-start route
|
||||
// on the public port. The route requires the X-User-ID header so the actor
|
||||
// is always a user; admins use the internal port.
|
||||
func registerReadyToStartRoutes(mux *http.ServeMux, deps Dependencies, logger *slog.Logger) {
|
||||
h := &readyToStartHandlers{
|
||||
deps: deps,
|
||||
logger: logger.With("component", "public_http.ready_to_start"),
|
||||
}
|
||||
mux.HandleFunc("POST "+readyToStartPath, h.handle)
|
||||
}
|
||||
|
||||
type readyToStartHandlers struct {
|
||||
deps Dependencies
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (h *readyToStartHandlers) handle(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.ManualReadyToStart == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "manual ready-to-start service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
games := &gameHandlers{deps: h.deps, logger: h.logger}
|
||||
actor, ok := games.requireUserActor(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gameID, ok := games.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.deps.ManualReadyToStart.Handle(request.Context(), manualreadytostart.Input{
|
||||
Actor: actor,
|
||||
GameID: gameID,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeGameRecord(record))
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
// Package publichttp provides the public authenticated HTTP listener used by
|
||||
// the runnable Game Lobby Service process. In the runnable
|
||||
// skeleton it exposes only the platform liveness and readiness probes; later
|
||||
// stages add player-facing routes.
|
||||
package publichttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/api/httpcommon"
|
||||
"galaxy/lobby/internal/service/approveapplication"
|
||||
"galaxy/lobby/internal/service/blockmember"
|
||||
"galaxy/lobby/internal/service/cancelgame"
|
||||
"galaxy/lobby/internal/service/createinvite"
|
||||
"galaxy/lobby/internal/service/creategame"
|
||||
"galaxy/lobby/internal/service/declineinvite"
|
||||
"galaxy/lobby/internal/service/getgame"
|
||||
"galaxy/lobby/internal/service/listgames"
|
||||
"galaxy/lobby/internal/service/listmemberships"
|
||||
"galaxy/lobby/internal/service/listmyapplications"
|
||||
"galaxy/lobby/internal/service/listmygames"
|
||||
"galaxy/lobby/internal/service/listmyinvites"
|
||||
"galaxy/lobby/internal/service/listmyracenames"
|
||||
"galaxy/lobby/internal/service/manualreadytostart"
|
||||
"galaxy/lobby/internal/service/openenrollment"
|
||||
"galaxy/lobby/internal/service/pausegame"
|
||||
"galaxy/lobby/internal/service/redeeminvite"
|
||||
"galaxy/lobby/internal/service/registerracename"
|
||||
"galaxy/lobby/internal/service/rejectapplication"
|
||||
"galaxy/lobby/internal/service/removemember"
|
||||
"galaxy/lobby/internal/service/resumegame"
|
||||
"galaxy/lobby/internal/service/retrystartgame"
|
||||
"galaxy/lobby/internal/service/revokeinvite"
|
||||
"galaxy/lobby/internal/service/startgame"
|
||||
"galaxy/lobby/internal/service/submitapplication"
|
||||
"galaxy/lobby/internal/service/updategame"
|
||||
"galaxy/lobby/internal/telemetry"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
)
|
||||
|
||||
const jsonContentType = "application/json; charset=utf-8"
|
||||
|
||||
const (
|
||||
// HealthzPath is the public liveness probe route.
|
||||
HealthzPath = "/healthz"
|
||||
|
||||
// ReadyzPath is the public readiness probe route.
|
||||
ReadyzPath = "/readyz"
|
||||
)
|
||||
|
||||
// Config describes the public authenticated HTTP listener owned by
|
||||
// Game Lobby Service.
|
||||
type Config struct {
|
||||
// Addr is the TCP listen address used by the public HTTP server.
|
||||
Addr string
|
||||
|
||||
// ReadHeaderTimeout bounds how long the listener may spend reading request
|
||||
// headers before the server rejects the connection.
|
||||
ReadHeaderTimeout time.Duration
|
||||
|
||||
// ReadTimeout bounds how long the listener may spend reading one request.
|
||||
ReadTimeout time.Duration
|
||||
|
||||
// IdleTimeout bounds how long the listener keeps an idle keep-alive
|
||||
// connection open.
|
||||
IdleTimeout time.Duration
|
||||
}
|
||||
|
||||
// Validate reports whether cfg contains a usable public HTTP listener
|
||||
// configuration.
|
||||
func (cfg Config) Validate() error {
|
||||
switch {
|
||||
case cfg.Addr == "":
|
||||
return errors.New("public HTTP addr must not be empty")
|
||||
case cfg.ReadHeaderTimeout <= 0:
|
||||
return errors.New("public HTTP read header timeout must be positive")
|
||||
case cfg.ReadTimeout <= 0:
|
||||
return errors.New("public HTTP read timeout must be positive")
|
||||
case cfg.IdleTimeout <= 0:
|
||||
return errors.New("public HTTP idle timeout must be positive")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Dependencies describes the collaborators used by the public HTTP transport
|
||||
// layer.
|
||||
type Dependencies struct {
|
||||
// Logger writes structured listener lifecycle logs. When nil,
|
||||
// slog.Default is used.
|
||||
Logger *slog.Logger
|
||||
|
||||
// Telemetry records low-cardinality probe metrics and lifecycle events.
|
||||
Telemetry *telemetry.Runtime
|
||||
|
||||
// CreateGame handles the `lobby.game.create` message type. A nil value
|
||||
// makes the corresponding route return `internal_error`; tests that do
|
||||
// not exercise the route may leave it nil.
|
||||
CreateGame *creategame.Service
|
||||
|
||||
// UpdateGame handles the `lobby.game.update` message type.
|
||||
UpdateGame *updategame.Service
|
||||
|
||||
// OpenEnrollment handles the `lobby.game.open_enrollment` message type.
|
||||
OpenEnrollment *openenrollment.Service
|
||||
|
||||
// CancelGame handles the `lobby.game.cancel` message type.
|
||||
CancelGame *cancelgame.Service
|
||||
|
||||
// ManualReadyToStart handles the `lobby.game.ready_to_start` message
|
||||
// type — manual close of enrollment with cascading invite expiry.
|
||||
ManualReadyToStart *manualreadytostart.Service
|
||||
|
||||
// StartGame handles the `lobby.game.start` message type.
|
||||
StartGame *startgame.Service
|
||||
|
||||
// RetryStartGame handles the `lobby.game.retry_start` message type
|
||||
//.
|
||||
RetryStartGame *retrystartgame.Service
|
||||
|
||||
// PauseGame handles the `lobby.game.pause` message type.
|
||||
PauseGame *pausegame.Service
|
||||
|
||||
// ResumeGame handles the `lobby.game.resume` message type
|
||||
//.
|
||||
ResumeGame *resumegame.Service
|
||||
|
||||
// SubmitApplication handles the `lobby.application.submit` message
|
||||
// type. Wired on the public port only.
|
||||
SubmitApplication *submitapplication.Service
|
||||
|
||||
// ApproveApplication handles the `lobby.application.approve` message
|
||||
// type. Wired on the public port for OpenAPI parity; the public
|
||||
// route always returns 403 because UserActor is not admin.
|
||||
ApproveApplication *approveapplication.Service
|
||||
|
||||
// RejectApplication handles the `lobby.application.reject` message
|
||||
// type. Same parity rule as ApproveApplication.
|
||||
RejectApplication *rejectapplication.Service
|
||||
|
||||
// CreateInvite handles the `lobby.invite.create` message type.
|
||||
CreateInvite *createinvite.Service
|
||||
|
||||
// RedeemInvite handles the `lobby.invite.redeem` message type.
|
||||
RedeemInvite *redeeminvite.Service
|
||||
|
||||
// DeclineInvite handles the `lobby.invite.decline` message type.
|
||||
DeclineInvite *declineinvite.Service
|
||||
|
||||
// RevokeInvite handles the `lobby.invite.revoke` message type.
|
||||
RevokeInvite *revokeinvite.Service
|
||||
|
||||
// RemoveMember handles the `lobby.membership.remove` message type
|
||||
//.
|
||||
RemoveMember *removemember.Service
|
||||
|
||||
// BlockMember handles the `lobby.membership.block` message type
|
||||
//.
|
||||
BlockMember *blockmember.Service
|
||||
|
||||
// RegisterRaceName handles the `lobby.race_name.register` message
|
||||
// type.
|
||||
RegisterRaceName *registerracename.Service
|
||||
|
||||
// ListMyRaceNames handles the `lobby.race_names.list` message type
|
||||
//. The service returns the acting user's three RND
|
||||
// views in one response.
|
||||
ListMyRaceNames *listmyracenames.Service
|
||||
|
||||
// GetGame handles the `lobby.game.get` message type.
|
||||
GetGame *getgame.Service
|
||||
|
||||
// ListGames handles the `lobby.games.list` message type.
|
||||
ListGames *listgames.Service
|
||||
|
||||
// ListMemberships handles the `lobby.memberships.list` message type
|
||||
//.
|
||||
ListMemberships *listmemberships.Service
|
||||
|
||||
// ListMyGames handles the `lobby.my_games.list` message type
|
||||
//.
|
||||
ListMyGames *listmygames.Service
|
||||
|
||||
// ListMyApplications handles the `lobby.my_applications.list`
|
||||
// message type.
|
||||
ListMyApplications *listmyapplications.Service
|
||||
|
||||
// ListMyInvites handles the `lobby.my_invites.list` message type
|
||||
//.
|
||||
ListMyInvites *listmyinvites.Service
|
||||
}
|
||||
|
||||
// Server owns the public authenticated HTTP listener exposed by
|
||||
// Game Lobby Service.
|
||||
type Server struct {
|
||||
cfg Config
|
||||
|
||||
handler http.Handler
|
||||
logger *slog.Logger
|
||||
metrics *telemetry.Runtime
|
||||
|
||||
stateMu sync.RWMutex
|
||||
server *http.Server
|
||||
listener net.Listener
|
||||
}
|
||||
|
||||
// NewServer constructs one public authenticated HTTP server for cfg and deps.
|
||||
func NewServer(cfg Config, deps Dependencies) (*Server, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("new public HTTP server: %w", err)
|
||||
}
|
||||
|
||||
logger := deps.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
handler: newHandler(deps, logger),
|
||||
logger: logger.With("component", "public_http"),
|
||||
metrics: deps.Telemetry,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Addr returns the currently bound listener address after Run is called. It
|
||||
// returns an empty string if the server has not yet bound a listener.
|
||||
func (server *Server) Addr() string {
|
||||
server.stateMu.RLock()
|
||||
defer server.stateMu.RUnlock()
|
||||
if server.listener == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return server.listener.Addr().String()
|
||||
}
|
||||
|
||||
// Run binds the configured listener and serves the public HTTP surface until
|
||||
// Shutdown closes the server.
|
||||
func (server *Server) Run(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("run public HTTP server: nil context")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", server.cfg.Addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("run public HTTP server: listen on %q: %w", server.cfg.Addr, err)
|
||||
}
|
||||
|
||||
httpServer := &http.Server{
|
||||
Handler: server.handler,
|
||||
ReadHeaderTimeout: server.cfg.ReadHeaderTimeout,
|
||||
ReadTimeout: server.cfg.ReadTimeout,
|
||||
IdleTimeout: server.cfg.IdleTimeout,
|
||||
}
|
||||
|
||||
server.stateMu.Lock()
|
||||
server.server = httpServer
|
||||
server.listener = listener
|
||||
server.stateMu.Unlock()
|
||||
|
||||
server.logger.Info("public HTTP server started", "addr", listener.Addr().String())
|
||||
|
||||
defer func() {
|
||||
server.stateMu.Lock()
|
||||
server.server = nil
|
||||
server.listener = nil
|
||||
server.stateMu.Unlock()
|
||||
}()
|
||||
|
||||
err = httpServer.Serve(listener)
|
||||
switch {
|
||||
case err == nil:
|
||||
return nil
|
||||
case errors.Is(err, http.ErrServerClosed):
|
||||
server.logger.Info("public HTTP server stopped")
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("run public HTTP server: serve on %q: %w", server.cfg.Addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown gracefully stops the public HTTP server within ctx.
|
||||
func (server *Server) Shutdown(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("shutdown public HTTP server: nil context")
|
||||
}
|
||||
|
||||
server.stateMu.RLock()
|
||||
httpServer := server.server
|
||||
server.stateMu.RUnlock()
|
||||
|
||||
if httpServer == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := httpServer.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return fmt.Errorf("shutdown public HTTP server: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newHandler(deps Dependencies, logger *slog.Logger) http.Handler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET "+HealthzPath, handleHealthz)
|
||||
mux.HandleFunc("GET "+ReadyzPath, handleReadyz)
|
||||
registerGameRoutes(mux, deps, logger)
|
||||
registerApplicationRoutes(mux, deps, logger)
|
||||
registerInviteRoutes(mux, deps, logger)
|
||||
registerReadyToStartRoutes(mux, deps, logger)
|
||||
registerStartRoutes(mux, deps, logger)
|
||||
registerPauseResumeRoutes(mux, deps, logger)
|
||||
registerMembershipRoutes(mux, deps, logger)
|
||||
registerRaceNameRoutes(mux, deps, logger)
|
||||
registerMyListRoutes(mux, deps, logger)
|
||||
|
||||
metrics := deps.Telemetry
|
||||
options := []otelhttp.Option{}
|
||||
if metrics != nil {
|
||||
options = append(options,
|
||||
otelhttp.WithTracerProvider(metrics.TracerProvider()),
|
||||
otelhttp.WithMeterProvider(metrics.MeterProvider()),
|
||||
)
|
||||
}
|
||||
|
||||
observable := otelhttp.NewHandler(withObservability(mux, metrics), "lobby.public_http", options...)
|
||||
return httpcommon.RequestID(observable)
|
||||
}
|
||||
|
||||
func withObservability(next http.Handler, metrics *telemetry.Runtime) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
startedAt := time.Now()
|
||||
recorder := &statusRecorder{
|
||||
ResponseWriter: writer,
|
||||
statusCode: http.StatusOK,
|
||||
}
|
||||
|
||||
next.ServeHTTP(recorder, request)
|
||||
|
||||
route := request.Pattern
|
||||
switch recorder.statusCode {
|
||||
case http.StatusMethodNotAllowed:
|
||||
route = "method_not_allowed"
|
||||
case http.StatusNotFound:
|
||||
route = "not_found"
|
||||
case 0:
|
||||
route = "unmatched"
|
||||
}
|
||||
if route == "" {
|
||||
route = "unmatched"
|
||||
}
|
||||
|
||||
metrics.RecordPublicHTTPRequest(
|
||||
request.Context(),
|
||||
[]attribute.KeyValue{
|
||||
attribute.String("route", route),
|
||||
attribute.String("method", request.Method),
|
||||
attribute.String("status_code", strconv.Itoa(recorder.statusCode)),
|
||||
},
|
||||
time.Since(startedAt),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func handleHealthz(writer http.ResponseWriter, _ *http.Request) {
|
||||
writeStatusResponse(writer, http.StatusOK, "ok")
|
||||
}
|
||||
|
||||
func handleReadyz(writer http.ResponseWriter, _ *http.Request) {
|
||||
writeStatusResponse(writer, http.StatusOK, "ready")
|
||||
}
|
||||
|
||||
func writeStatusResponse(writer http.ResponseWriter, statusCode int, status string) {
|
||||
writer.Header().Set("Content-Type", jsonContentType)
|
||||
writer.WriteHeader(statusCode)
|
||||
_ = json.NewEncoder(writer).Encode(statusResponse{Status: status})
|
||||
}
|
||||
|
||||
type statusResponse struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (recorder *statusRecorder) WriteHeader(statusCode int) {
|
||||
recorder.statusCode = statusCode
|
||||
recorder.ResponseWriter.WriteHeader(statusCode)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package publichttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConfigValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base := Config{
|
||||
Addr: ":0",
|
||||
ReadHeaderTimeout: time.Second,
|
||||
ReadTimeout: time.Second,
|
||||
IdleTimeout: time.Second,
|
||||
}
|
||||
require.NoError(t, base.Validate())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Config)
|
||||
wantErr string
|
||||
}{
|
||||
{name: "empty addr", mutate: func(cfg *Config) { cfg.Addr = "" }, wantErr: "addr must not be empty"},
|
||||
{name: "zero header", mutate: func(cfg *Config) { cfg.ReadHeaderTimeout = 0 }, wantErr: "read header timeout"},
|
||||
{name: "zero read", mutate: func(cfg *Config) { cfg.ReadTimeout = 0 }, wantErr: "read timeout"},
|
||||
{name: "zero idle", mutate: func(cfg *Config) { cfg.IdleTimeout = 0 }, wantErr: "idle timeout"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := base
|
||||
tt.mutate(&cfg)
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerRoutes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newHandler(Dependencies{}, nil)
|
||||
server := httptest.NewServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
wantStatus int
|
||||
wantStatusBody string
|
||||
}{
|
||||
{name: "healthz", method: http.MethodGet, path: HealthzPath, wantStatus: http.StatusOK, wantStatusBody: "ok"},
|
||||
{name: "readyz", method: http.MethodGet, path: ReadyzPath, wantStatus: http.StatusOK, wantStatusBody: "ready"},
|
||||
{name: "not found", method: http.MethodGet, path: "/nope", wantStatus: http.StatusNotFound},
|
||||
{name: "method not allowed", method: http.MethodPost, path: HealthzPath, wantStatus: http.StatusMethodNotAllowed},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req, err := http.NewRequest(tt.method, server.URL+tt.path, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := server.Client().Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, tt.wantStatus, resp.StatusCode)
|
||||
|
||||
if tt.wantStatusBody != "" {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "application/json; charset=utf-8", resp.Header.Get("Content-Type"))
|
||||
|
||||
var payload statusResponse
|
||||
require.NoError(t, json.Unmarshal(body, &payload))
|
||||
assert.Equal(t, tt.wantStatusBody, payload.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShutdownBeforeRunIsNoop(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server, err := NewServer(Config{
|
||||
Addr: "127.0.0.1:0",
|
||||
ReadHeaderTimeout: time.Second,
|
||||
ReadTimeout: time.Second,
|
||||
IdleTimeout: time.Second,
|
||||
}, Dependencies{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, server.Shutdown(context.Background()))
|
||||
}
|
||||
|
||||
func TestServerRunAndShutdown(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
addr := listener.Addr().String()
|
||||
require.NoError(t, listener.Close())
|
||||
|
||||
server, err := NewServer(Config{
|
||||
Addr: addr,
|
||||
ReadHeaderTimeout: time.Second,
|
||||
ReadTimeout: time.Second,
|
||||
IdleTimeout: time.Second,
|
||||
}, Dependencies{})
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
runErr := make(chan error, 1)
|
||||
go func() {
|
||||
runErr <- server.Run(ctx)
|
||||
}()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return server.Addr() != ""
|
||||
}, 2*time.Second, 10*time.Millisecond)
|
||||
|
||||
resp, err := http.Get("http://" + server.Addr() + HealthzPath)
|
||||
require.NoError(t, err)
|
||||
_ = resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
t.Cleanup(shutdownCancel)
|
||||
require.NoError(t, server.Shutdown(shutdownCtx))
|
||||
|
||||
select {
|
||||
case err := <-runErr:
|
||||
require.NoError(t, err)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("server did not stop after shutdown")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package publichttp
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"galaxy/lobby/internal/service/retrystartgame"
|
||||
"galaxy/lobby/internal/service/startgame"
|
||||
)
|
||||
|
||||
const (
|
||||
startGamePath = "/api/v1/lobby/games/{game_id}/start"
|
||||
retryStartGamePath = "/api/v1/lobby/games/{game_id}/retry-start"
|
||||
)
|
||||
|
||||
// registerStartRoutes binds the start and retry-start routes on
|
||||
// the public port. Both routes require the X-User-ID header so the actor
|
||||
// is always a user; admins use the internal port.
|
||||
func registerStartRoutes(mux *http.ServeMux, deps Dependencies, logger *slog.Logger) {
|
||||
h := &startHandlers{
|
||||
deps: deps,
|
||||
logger: logger.With("component", "public_http.startgame"),
|
||||
}
|
||||
mux.HandleFunc("POST "+startGamePath, h.handleStart)
|
||||
mux.HandleFunc("POST "+retryStartGamePath, h.handleRetryStart)
|
||||
}
|
||||
|
||||
type startHandlers struct {
|
||||
deps Dependencies
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (h *startHandlers) handleStart(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.StartGame == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "start game service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
games := &gameHandlers{deps: h.deps, logger: h.logger}
|
||||
actor, ok := games.requireUserActor(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gameID, ok := games.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.deps.StartGame.Handle(request.Context(), startgame.Input{
|
||||
Actor: actor,
|
||||
GameID: gameID,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeGameRecord(record))
|
||||
}
|
||||
|
||||
func (h *startHandlers) handleRetryStart(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.RetryStartGame == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "retry start game service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
games := &gameHandlers{deps: h.deps, logger: h.logger}
|
||||
actor, ok := games.requireUserActor(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gameID, ok := games.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.deps.RetryStartGame.Handle(request.Context(), retrystartgame.Input{
|
||||
Actor: actor,
|
||||
GameID: gameID,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeGameRecord(record))
|
||||
}
|
||||
Reference in New Issue
Block a user