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