feat: backend service
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/lobby"
|
||||
"galaxy/backend/internal/server/handlers"
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
"galaxy/backend/internal/server/middleware/userid"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// UserLobbyGamesHandlers groups the handlers under
|
||||
// `/api/v1/user/lobby/games/*`. The current implementation ships real implementations
|
||||
// backed by `*lobby.Service`; tests that supply a nil service fall back
|
||||
// to the Stage-3 placeholder body so the contract test continues to
|
||||
// validate the OpenAPI envelope without booting a database.
|
||||
type UserLobbyGamesHandlers struct {
|
||||
svc *lobby.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewUserLobbyGamesHandlers constructs the handler set. svc may be nil
|
||||
// — in that case every handler returns 501 not_implemented.
|
||||
func NewUserLobbyGamesHandlers(svc *lobby.Service, logger *zap.Logger) *UserLobbyGamesHandlers {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &UserLobbyGamesHandlers{svc: svc, logger: logger.Named("http.user.lobby.games")}
|
||||
}
|
||||
|
||||
func (h *UserLobbyGamesHandlers) callerUserID(c *gin.Context) (uuid.UUID, bool) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return userID, true
|
||||
}
|
||||
|
||||
// List handles GET /api/v1/user/lobby/games.
|
||||
func (h *UserLobbyGamesHandlers) List() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userLobbyGamesList")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
page := parsePositiveQueryInt(c.Query("page"), 1)
|
||||
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
|
||||
ctx := c.Request.Context()
|
||||
result, err := h.svc.ListPublicGames(ctx, page, pageSize)
|
||||
if err != nil {
|
||||
respondLobbyError(c, h.logger, "user lobby games list", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gameSummaryPageToWire(result))
|
||||
}
|
||||
}
|
||||
|
||||
// Create handles POST /api/v1/user/lobby/games.
|
||||
func (h *UserLobbyGamesHandlers) Create() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userLobbyGamesCreate")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := h.callerUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req lobbyGameCreateRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
if req.Visibility != lobby.VisibilityPrivate {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "user-facing /lobby/games only creates private games; admins use /api/v1/admin/games for public")
|
||||
return
|
||||
}
|
||||
enrollmentEndsAt, err := time.Parse(time.RFC3339Nano, req.EnrollmentEndsAt)
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "enrollment_ends_at must be RFC 3339")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
owner := userID
|
||||
game, err := h.svc.CreateGame(ctx, lobby.CreateGameInput{
|
||||
OwnerUserID: &owner,
|
||||
Visibility: req.Visibility,
|
||||
GameName: req.GameName,
|
||||
Description: req.Description,
|
||||
MinPlayers: req.MinPlayers,
|
||||
MaxPlayers: req.MaxPlayers,
|
||||
StartGapHours: req.StartGapHours,
|
||||
StartGapPlayers: req.StartGapPlayers,
|
||||
EnrollmentEndsAt: enrollmentEndsAt,
|
||||
TurnSchedule: req.TurnSchedule,
|
||||
TargetEngineVersion: req.TargetEngineVersion,
|
||||
})
|
||||
if err != nil {
|
||||
respondLobbyError(c, h.logger, "user lobby games create", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, lobbyGameDetailToWire(game))
|
||||
}
|
||||
}
|
||||
|
||||
// Get handles GET /api/v1/user/lobby/games/{game_id}.
|
||||
func (h *UserLobbyGamesHandlers) Get() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userLobbyGamesGet")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
game, err := h.svc.GetGame(ctx, gameID)
|
||||
if err != nil {
|
||||
respondLobbyError(c, h.logger, "user lobby games get", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, lobbyGameDetailToWire(game))
|
||||
}
|
||||
}
|
||||
|
||||
// Update handles PATCH /api/v1/user/lobby/games/{game_id}.
|
||||
func (h *UserLobbyGamesHandlers) Update() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userLobbyGamesUpdate")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := h.callerUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req lobbyGameUpdateRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
ends, err := parseTimePtrField(req.EnrollmentEndsAt, "enrollment_ends_at")
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
caller := userID
|
||||
updated, err := h.svc.UpdateGame(ctx, &caller, false, gameID, lobby.UpdateGameInput{
|
||||
GameName: req.GameName,
|
||||
Description: req.Description,
|
||||
EnrollmentEndsAt: ends,
|
||||
TurnSchedule: req.TurnSchedule,
|
||||
TargetEngineVersion: req.TargetEngineVersion,
|
||||
MinPlayers: req.MinPlayers,
|
||||
MaxPlayers: req.MaxPlayers,
|
||||
StartGapHours: req.StartGapHours,
|
||||
StartGapPlayers: req.StartGapPlayers,
|
||||
})
|
||||
if err != nil {
|
||||
respondLobbyError(c, h.logger, "user lobby games update", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, lobbyGameDetailToWire(updated))
|
||||
}
|
||||
}
|
||||
|
||||
// transitionHandler is the shared shape for owner-driven state-machine
|
||||
// endpoints. fn captures the lobby Service method to invoke.
|
||||
func (h *UserLobbyGamesHandlers) transitionHandler(opName string, successStatus int, fn func(context.Context, *lobby.Service, *uuid.UUID, uuid.UUID) (lobby.GameRecord, error)) gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented(opName)
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := h.callerUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
caller := userID
|
||||
updated, err := fn(ctx, h.svc, &caller, gameID)
|
||||
if err != nil {
|
||||
respondLobbyError(c, h.logger, "user lobby games "+opName, ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(successStatus, lobbyGameStateChangeToWire(updated))
|
||||
}
|
||||
}
|
||||
|
||||
// OpenEnrollment handles POST /api/v1/user/lobby/games/{game_id}/open-enrollment.
|
||||
func (h *UserLobbyGamesHandlers) OpenEnrollment() gin.HandlerFunc {
|
||||
return h.transitionHandler("openEnrollment", http.StatusOK,
|
||||
func(ctx context.Context, svc *lobby.Service, caller *uuid.UUID, gameID uuid.UUID) (lobby.GameRecord, error) {
|
||||
return svc.OpenEnrollment(ctx, caller, false, gameID)
|
||||
})
|
||||
}
|
||||
|
||||
// ReadyToStart handles POST /api/v1/user/lobby/games/{game_id}/ready-to-start.
|
||||
func (h *UserLobbyGamesHandlers) ReadyToStart() gin.HandlerFunc {
|
||||
return h.transitionHandler("readyToStart", http.StatusOK,
|
||||
func(ctx context.Context, svc *lobby.Service, caller *uuid.UUID, gameID uuid.UUID) (lobby.GameRecord, error) {
|
||||
return svc.ReadyToStart(ctx, caller, false, gameID)
|
||||
})
|
||||
}
|
||||
|
||||
// Start handles POST /api/v1/user/lobby/games/{game_id}/start.
|
||||
func (h *UserLobbyGamesHandlers) Start() gin.HandlerFunc {
|
||||
return h.transitionHandler("start", http.StatusAccepted,
|
||||
func(ctx context.Context, svc *lobby.Service, caller *uuid.UUID, gameID uuid.UUID) (lobby.GameRecord, error) {
|
||||
return svc.Start(ctx, caller, false, gameID)
|
||||
})
|
||||
}
|
||||
|
||||
// Pause handles POST /api/v1/user/lobby/games/{game_id}/pause.
|
||||
func (h *UserLobbyGamesHandlers) Pause() gin.HandlerFunc {
|
||||
return h.transitionHandler("pause", http.StatusOK,
|
||||
func(ctx context.Context, svc *lobby.Service, caller *uuid.UUID, gameID uuid.UUID) (lobby.GameRecord, error) {
|
||||
return svc.Pause(ctx, caller, false, gameID)
|
||||
})
|
||||
}
|
||||
|
||||
// Resume handles POST /api/v1/user/lobby/games/{game_id}/resume.
|
||||
func (h *UserLobbyGamesHandlers) Resume() gin.HandlerFunc {
|
||||
return h.transitionHandler("resume", http.StatusOK,
|
||||
func(ctx context.Context, svc *lobby.Service, caller *uuid.UUID, gameID uuid.UUID) (lobby.GameRecord, error) {
|
||||
return svc.Resume(ctx, caller, false, gameID)
|
||||
})
|
||||
}
|
||||
|
||||
// Cancel handles POST /api/v1/user/lobby/games/{game_id}/cancel.
|
||||
func (h *UserLobbyGamesHandlers) Cancel() gin.HandlerFunc {
|
||||
return h.transitionHandler("cancel", http.StatusOK,
|
||||
func(ctx context.Context, svc *lobby.Service, caller *uuid.UUID, gameID uuid.UUID) (lobby.GameRecord, error) {
|
||||
return svc.Cancel(ctx, caller, false, gameID)
|
||||
})
|
||||
}
|
||||
|
||||
// RetryStart handles POST /api/v1/user/lobby/games/{game_id}/retry-start.
|
||||
func (h *UserLobbyGamesHandlers) RetryStart() gin.HandlerFunc {
|
||||
return h.transitionHandler("retryStart", http.StatusAccepted,
|
||||
func(ctx context.Context, svc *lobby.Service, caller *uuid.UUID, gameID uuid.UUID) (lobby.GameRecord, error) {
|
||||
return svc.RetryStart(ctx, caller, false, gameID)
|
||||
})
|
||||
}
|
||||
|
||||
// lobbyGameCreateRequestWire mirrors `LobbyGameCreateRequest`.
|
||||
type lobbyGameCreateRequestWire struct {
|
||||
GameName string `json:"game_name"`
|
||||
Visibility string `json:"visibility"`
|
||||
Description string `json:"description"`
|
||||
MinPlayers int32 `json:"min_players"`
|
||||
MaxPlayers int32 `json:"max_players"`
|
||||
StartGapHours int32 `json:"start_gap_hours"`
|
||||
StartGapPlayers int32 `json:"start_gap_players"`
|
||||
EnrollmentEndsAt string `json:"enrollment_ends_at"`
|
||||
TurnSchedule string `json:"turn_schedule"`
|
||||
TargetEngineVersion string `json:"target_engine_version"`
|
||||
}
|
||||
|
||||
// lobbyGameUpdateRequestWire mirrors `LobbyGameUpdateRequest`. Optional
|
||||
// fields are pointers so the handler can distinguish "not supplied"
|
||||
// from "empty string".
|
||||
type lobbyGameUpdateRequestWire struct {
|
||||
GameName *string `json:"game_name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
EnrollmentEndsAt *string `json:"enrollment_ends_at,omitempty"`
|
||||
TurnSchedule *string `json:"turn_schedule,omitempty"`
|
||||
TargetEngineVersion *string `json:"target_engine_version,omitempty"`
|
||||
MinPlayers *int32 `json:"min_players,omitempty"`
|
||||
MaxPlayers *int32 `json:"max_players,omitempty"`
|
||||
StartGapHours *int32 `json:"start_gap_hours,omitempty"`
|
||||
StartGapPlayers *int32 `json:"start_gap_players,omitempty"`
|
||||
}
|
||||
|
||||
// gameSummaryPageWire mirrors `GameSummaryPage`.
|
||||
type gameSummaryPageWire struct {
|
||||
Items []gameSummaryWire `json:"items"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
func gameSummaryPageToWire(page lobby.GamePage) gameSummaryPageWire {
|
||||
out := gameSummaryPageWire{
|
||||
Items: make([]gameSummaryWire, 0, len(page.Items)),
|
||||
Page: page.Page,
|
||||
PageSize: page.PageSize,
|
||||
Total: page.Total,
|
||||
}
|
||||
for _, g := range page.Items {
|
||||
out.Items = append(out.Items, gameSummaryToWire(g))
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user