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