522 lines
18 KiB
Go
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))
|
|
}
|