feat: backend service
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/lobby"
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
"galaxy/backend/internal/telemetry"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// respondLobbyError maps lobby-package sentinel errors to the standard
|
||||
// JSON error envelope. Unknown errors land on a 500.
|
||||
func respondLobbyError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, lobby.ErrInvalidInput):
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, err.Error())
|
||||
case errors.Is(err, lobby.ErrNotFound):
|
||||
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "resource was not found")
|
||||
case errors.Is(err, lobby.ErrForbidden):
|
||||
httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, err.Error())
|
||||
case errors.Is(err, lobby.ErrConflict),
|
||||
errors.Is(err, lobby.ErrInvalidStatus),
|
||||
errors.Is(err, lobby.ErrRaceNameTaken),
|
||||
errors.Is(err, lobby.ErrEntitlementExceeded),
|
||||
errors.Is(err, lobby.ErrPendingExpired):
|
||||
httperr.Abort(c, http.StatusConflict, httperr.CodeConflict, err.Error())
|
||||
default:
|
||||
logger.Error(op+" failed",
|
||||
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
|
||||
)
|
||||
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error")
|
||||
}
|
||||
}
|
||||
|
||||
// parseGameIDParam reads `game_id` from the path. Writes 400 envelope on
|
||||
// invalid input and returns false in that case.
|
||||
func parseGameIDParam(c *gin.Context) (uuid.UUID, bool) {
|
||||
parsed, err := uuid.Parse(c.Param("game_id"))
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "game_id must be a valid UUID")
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
func parseApplicationIDParam(c *gin.Context) (uuid.UUID, bool) {
|
||||
parsed, err := uuid.Parse(c.Param("application_id"))
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "application_id must be a valid UUID")
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
func parseInviteIDParam(c *gin.Context) (uuid.UUID, bool) {
|
||||
parsed, err := uuid.Parse(c.Param("invite_id"))
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "invite_id must be a valid UUID")
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
func parseMembershipIDParam(c *gin.Context) (uuid.UUID, bool) {
|
||||
parsed, err := uuid.Parse(c.Param("membership_id"))
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "membership_id must be a valid UUID")
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
// gameSummaryWire mirrors `GameSummary` from openapi.yaml.
|
||||
type gameSummaryWire struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
GameType string `json:"game_type"`
|
||||
Status string `json:"status"`
|
||||
OwnerUserID *string `json:"owner_user_id,omitempty"`
|
||||
MinPlayers int32 `json:"min_players"`
|
||||
MaxPlayers int32 `json:"max_players"`
|
||||
EnrollmentEndsAt string `json:"enrollment_ends_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// lobbyGameDetailWire mirrors `LobbyGameDetail` from openapi.yaml.
|
||||
type lobbyGameDetailWire struct {
|
||||
gameSummaryWire
|
||||
Visibility string `json:"visibility"`
|
||||
Description string `json:"description,omitempty"`
|
||||
TurnSchedule string `json:"turn_schedule"`
|
||||
TargetEngineVersion string `json:"target_engine_version"`
|
||||
StartGapHours int32 `json:"start_gap_hours"`
|
||||
StartGapPlayers int32 `json:"start_gap_players"`
|
||||
CurrentTurn int32 `json:"current_turn"`
|
||||
RuntimeStatus string `json:"runtime_status"`
|
||||
EngineHealth string `json:"engine_health,omitempty"`
|
||||
StartedAt *string `json:"started_at,omitempty"`
|
||||
FinishedAt *string `json:"finished_at,omitempty"`
|
||||
}
|
||||
|
||||
func gameSummaryToWire(g lobby.GameRecord) gameSummaryWire {
|
||||
out := gameSummaryWire{
|
||||
GameID: g.GameID.String(),
|
||||
GameName: g.GameName,
|
||||
GameType: g.Visibility,
|
||||
Status: g.Status,
|
||||
MinPlayers: g.MinPlayers,
|
||||
MaxPlayers: g.MaxPlayers,
|
||||
EnrollmentEndsAt: g.EnrollmentEndsAt.UTC().Format(timestampLayout),
|
||||
CreatedAt: g.CreatedAt.UTC().Format(timestampLayout),
|
||||
UpdatedAt: g.UpdatedAt.UTC().Format(timestampLayout),
|
||||
}
|
||||
if g.OwnerUserID != nil {
|
||||
s := g.OwnerUserID.String()
|
||||
out.OwnerUserID = &s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func lobbyGameDetailToWire(g lobby.GameRecord) lobbyGameDetailWire {
|
||||
out := lobbyGameDetailWire{
|
||||
gameSummaryWire: gameSummaryToWire(g),
|
||||
Visibility: g.Visibility,
|
||||
Description: g.Description,
|
||||
TurnSchedule: g.TurnSchedule,
|
||||
TargetEngineVersion: g.TargetEngineVersion,
|
||||
StartGapHours: g.StartGapHours,
|
||||
StartGapPlayers: g.StartGapPlayers,
|
||||
CurrentTurn: g.RuntimeSnapshot.CurrentTurn,
|
||||
RuntimeStatus: g.RuntimeSnapshot.RuntimeStatus,
|
||||
EngineHealth: g.RuntimeSnapshot.EngineHealth,
|
||||
}
|
||||
if g.StartedAt != nil {
|
||||
s := g.StartedAt.UTC().Format(timestampLayout)
|
||||
out.StartedAt = &s
|
||||
}
|
||||
if g.FinishedAt != nil {
|
||||
s := g.FinishedAt.UTC().Format(timestampLayout)
|
||||
out.FinishedAt = &s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// lobbyGameStateChangeWire mirrors `LobbyGameStateChange`.
|
||||
type lobbyGameStateChangeWire struct {
|
||||
GameID string `json:"game_id"`
|
||||
Status string `json:"status"`
|
||||
RuntimeStatus string `json:"runtime_status,omitempty"`
|
||||
}
|
||||
|
||||
func lobbyGameStateChangeToWire(g lobby.GameRecord) lobbyGameStateChangeWire {
|
||||
return lobbyGameStateChangeWire{
|
||||
GameID: g.GameID.String(),
|
||||
Status: g.Status,
|
||||
RuntimeStatus: g.RuntimeSnapshot.RuntimeStatus,
|
||||
}
|
||||
}
|
||||
|
||||
// lobbyApplicationDetailWire mirrors `LobbyApplicationDetail`.
|
||||
type lobbyApplicationDetailWire struct {
|
||||
ApplicationID string `json:"application_id"`
|
||||
GameID string `json:"game_id"`
|
||||
ApplicantUserID string `json:"applicant_user_id"`
|
||||
RaceName string `json:"race_name"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
DecidedAt *string `json:"decided_at,omitempty"`
|
||||
}
|
||||
|
||||
func lobbyApplicationDetailToWire(a lobby.Application) lobbyApplicationDetailWire {
|
||||
out := lobbyApplicationDetailWire{
|
||||
ApplicationID: a.ApplicationID.String(),
|
||||
GameID: a.GameID.String(),
|
||||
ApplicantUserID: a.ApplicantUserID.String(),
|
||||
RaceName: a.RaceName,
|
||||
Status: a.Status,
|
||||
CreatedAt: a.CreatedAt.UTC().Format(timestampLayout),
|
||||
}
|
||||
if a.DecidedAt != nil {
|
||||
s := a.DecidedAt.UTC().Format(timestampLayout)
|
||||
out.DecidedAt = &s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// lobbyInviteDetailWire mirrors `LobbyInviteDetail`.
|
||||
type lobbyInviteDetailWire struct {
|
||||
InviteID string `json:"invite_id"`
|
||||
GameID string `json:"game_id"`
|
||||
InviterUserID string `json:"inviter_user_id"`
|
||||
InvitedUserID *string `json:"invited_user_id,omitempty"`
|
||||
Code *string `json:"code,omitempty"`
|
||||
RaceName string `json:"race_name"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
DecidedAt *string `json:"decided_at,omitempty"`
|
||||
}
|
||||
|
||||
func lobbyInviteDetailToWire(i lobby.Invite) lobbyInviteDetailWire {
|
||||
out := lobbyInviteDetailWire{
|
||||
InviteID: i.InviteID.String(),
|
||||
GameID: i.GameID.String(),
|
||||
InviterUserID: i.InviterUserID.String(),
|
||||
RaceName: i.RaceName,
|
||||
Status: i.Status,
|
||||
CreatedAt: i.CreatedAt.UTC().Format(timestampLayout),
|
||||
ExpiresAt: i.ExpiresAt.UTC().Format(timestampLayout),
|
||||
}
|
||||
if i.InvitedUserID != nil {
|
||||
s := i.InvitedUserID.String()
|
||||
out.InvitedUserID = &s
|
||||
}
|
||||
if i.Code != "" {
|
||||
c := i.Code
|
||||
out.Code = &c
|
||||
}
|
||||
if i.DecidedAt != nil {
|
||||
s := i.DecidedAt.UTC().Format(timestampLayout)
|
||||
out.DecidedAt = &s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// lobbyMembershipDetailWire mirrors `LobbyMembershipDetail`.
|
||||
type lobbyMembershipDetailWire struct {
|
||||
MembershipID string `json:"membership_id"`
|
||||
GameID string `json:"game_id"`
|
||||
UserID string `json:"user_id"`
|
||||
RaceName string `json:"race_name"`
|
||||
CanonicalKey string `json:"canonical_key"`
|
||||
Status string `json:"status"`
|
||||
JoinedAt string `json:"joined_at"`
|
||||
RemovedAt *string `json:"removed_at,omitempty"`
|
||||
}
|
||||
|
||||
func lobbyMembershipDetailToWire(m lobby.Membership) lobbyMembershipDetailWire {
|
||||
out := lobbyMembershipDetailWire{
|
||||
MembershipID: m.MembershipID.String(),
|
||||
GameID: m.GameID.String(),
|
||||
UserID: m.UserID.String(),
|
||||
RaceName: m.RaceName,
|
||||
CanonicalKey: m.CanonicalKey,
|
||||
Status: m.Status,
|
||||
JoinedAt: m.JoinedAt.UTC().Format(timestampLayout),
|
||||
}
|
||||
if m.RemovedAt != nil {
|
||||
s := m.RemovedAt.UTC().Format(timestampLayout)
|
||||
out.RemovedAt = &s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// raceNameDetailWire mirrors `RaceNameDetail`.
|
||||
type raceNameDetailWire struct {
|
||||
Name string `json:"name"`
|
||||
Canonical string `json:"canonical"`
|
||||
Status string `json:"status"`
|
||||
OwnerUserID string `json:"owner_user_id"`
|
||||
GameID *string `json:"game_id,omitempty"`
|
||||
SourceGameID *string `json:"source_game_id,omitempty"`
|
||||
ReservedAt *string `json:"reserved_at,omitempty"`
|
||||
ExpiresAt *string `json:"expires_at,omitempty"`
|
||||
RegisteredAt *string `json:"registered_at,omitempty"`
|
||||
}
|
||||
|
||||
func raceNameDetailToWire(e lobby.RaceNameEntry) raceNameDetailWire {
|
||||
out := raceNameDetailWire{
|
||||
Name: e.Name,
|
||||
Canonical: string(e.Canonical),
|
||||
Status: e.Status,
|
||||
OwnerUserID: e.OwnerUserID.String(),
|
||||
}
|
||||
if e.GameID != (uuid.UUID{}) {
|
||||
s := e.GameID.String()
|
||||
out.GameID = &s
|
||||
}
|
||||
if e.SourceGameID != nil {
|
||||
s := e.SourceGameID.String()
|
||||
out.SourceGameID = &s
|
||||
}
|
||||
if e.ReservedAt != nil {
|
||||
s := e.ReservedAt.UTC().Format(timestampLayout)
|
||||
out.ReservedAt = &s
|
||||
}
|
||||
if e.ExpiresAt != nil {
|
||||
s := e.ExpiresAt.UTC().Format(timestampLayout)
|
||||
out.ExpiresAt = &s
|
||||
}
|
||||
if e.RegisteredAt != nil {
|
||||
s := e.RegisteredAt.UTC().Format(timestampLayout)
|
||||
out.RegisteredAt = &s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseTimePtrField parses a wire timestamp pointer into a time.Time
|
||||
// pointer. Empty / nil input yields nil. Invalid timestamps return an
|
||||
// error.
|
||||
func parseTimePtrField(raw *string, field string) (*time.Time, error) {
|
||||
if raw == nil || *raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339Nano, *raw)
|
||||
if err != nil {
|
||||
return nil, errors.New(field + " must be RFC 3339")
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
Reference in New Issue
Block a user