Files
galaxy-game/lobby/internal/api/publichttp/games.go
T
2026-04-25 23:20:55 +02:00

522 lines
18 KiB
Go

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))
}