454 lines
16 KiB
Go
454 lines
16 KiB
Go
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))
|
|
}
|