package server import ( "context" "encoding/json" "errors" "io" "net/http" "strconv" "galaxy/backend/internal/engineclient" "galaxy/backend/internal/runtime" "galaxy/backend/internal/server/handlers" "galaxy/backend/internal/server/httperr" "galaxy/backend/internal/server/middleware/userid" "galaxy/backend/internal/telemetry" "galaxy/model/order" gamerest "galaxy/model/rest" "github.com/gin-gonic/gin" "go.uber.org/zap" ) // UserGamesHandlers groups the engine-proxy handlers under // `/api/v1/user/games/{game_id}/*`. The wiring connects them through // `engineclient` against running engine containers. type UserGamesHandlers struct { runtime *runtime.Service engine *engineclient.Client logger *zap.Logger } // NewUserGamesHandlers constructs the handler set. When runtime or // engine is nil, every handler returns 501 so the contract test still // passes against a partially-wired router. func NewUserGamesHandlers(rt *runtime.Service, engine *engineclient.Client, logger *zap.Logger) *UserGamesHandlers { if logger == nil { logger = zap.NewNop() } return &UserGamesHandlers{runtime: rt, engine: engine, logger: logger.Named("http.user.games")} } // Commands handles POST /api/v1/user/games/{game_id}/commands. func (h *UserGamesHandlers) Commands() gin.HandlerFunc { if h == nil || h.runtime == nil || h.engine == nil { return handlers.NotImplemented("userGamesCommands") } return func(c *gin.Context) { gameID, ok := parseGameIDParam(c) if !ok { return } userID, ok := userid.FromContext(c.Request.Context()) if !ok { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "user id missing") return } body, err := io.ReadAll(c.Request.Body) if err != nil { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body could not be read") return } ctx := c.Request.Context() mapping, err := h.runtime.ResolvePlayerMapping(ctx, gameID, userID) if err != nil { respondGameProxyError(c, h.logger, "user games commands", ctx, err) return } endpoint, err := h.runtime.EngineEndpoint(ctx, gameID) if err != nil { respondGameProxyError(c, h.logger, "user games commands", ctx, err) return } payload, err := rebindActor(body, mapping.RaceName) if err != nil { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be a JSON object") return } resp, err := h.engine.ExecuteCommands(ctx, endpoint, payload) if err != nil { respondEngineProxyError(c, h.logger, "user games commands", ctx, resp, err) return } c.Data(http.StatusOK, "application/json", resp) } } // Orders handles POST /api/v1/user/games/{game_id}/orders. func (h *UserGamesHandlers) Orders() gin.HandlerFunc { if h == nil || h.runtime == nil || h.engine == nil { return handlers.NotImplemented("userGamesOrders") } return func(c *gin.Context) { gameID, ok := parseGameIDParam(c) if !ok { return } userID, ok := userid.FromContext(c.Request.Context()) if !ok { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "user id missing") return } body, err := io.ReadAll(c.Request.Body) if err != nil { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body could not be read") return } ctx := c.Request.Context() mapping, err := h.runtime.ResolvePlayerMapping(ctx, gameID, userID) if err != nil { respondGameProxyError(c, h.logger, "user games orders", ctx, err) return } endpoint, err := h.runtime.EngineEndpoint(ctx, gameID) if err != nil { respondGameProxyError(c, h.logger, "user games orders", ctx, err) return } // Engine binds the order body into `gamerest.Command{Actor, // Commands}` and rejects an empty actor with `notblank`, so // backend rebinds the actor from the runtime player mapping // before forwarding — the same rule as for the command // handler. Per ARCHITECTURE.md §9 backend is the only caller // of the engine, so the body never carries a client-supplied // actor. _ = order.Order{} payload, err := rebindActor(body, mapping.RaceName) if err != nil { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be a JSON object") return } resp, err := h.engine.PutOrders(ctx, endpoint, payload) if err != nil { respondEngineProxyError(c, h.logger, "user games orders", ctx, resp, err) return } c.Data(http.StatusOK, "application/json", resp) } } // Report handles GET /api/v1/user/games/{game_id}/reports/{turn}. func (h *UserGamesHandlers) Report() gin.HandlerFunc { if h == nil || h.runtime == nil || h.engine == nil { return handlers.NotImplemented("userGamesReport") } return func(c *gin.Context) { gameID, ok := parseGameIDParam(c) if !ok { return } turnRaw := c.Param("turn") turn, err := strconv.Atoi(turnRaw) if err != nil || turn < 0 { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "turn must be a non-negative integer") return } userID, ok := userid.FromContext(c.Request.Context()) if !ok { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "user id missing") return } ctx := c.Request.Context() mapping, err := h.runtime.ResolvePlayerMapping(ctx, gameID, userID) if err != nil { respondGameProxyError(c, h.logger, "user games report", ctx, err) return } endpoint, err := h.runtime.EngineEndpoint(ctx, gameID) if err != nil { respondGameProxyError(c, h.logger, "user games report", ctx, err) return } body, err := h.engine.GetReport(ctx, endpoint, mapping.RaceName, turn) if err != nil { respondEngineProxyError(c, h.logger, "user games report", ctx, body, err) return } c.Data(http.StatusOK, "application/json", body) } } // rebindActor decodes a JSON object from raw, sets `actor` to // raceName, and re-encodes. Backend never trusts the actor field // supplied by the client (per ARCHITECTURE.md §9). func rebindActor(raw []byte, raceName string) (json.RawMessage, error) { if len(raw) == 0 { // Empty body — synthesise a minimal envelope so the engine // receives a well-formed request. return json.Marshal(gamerest.Command{Actor: raceName}) } var generic map[string]json.RawMessage if err := json.Unmarshal(raw, &generic); err != nil { return nil, err } actor, _ := json.Marshal(raceName) generic["actor"] = actor return json.Marshal(generic) } func respondGameProxyError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, err error) { switch { case errors.Is(err, runtime.ErrNotFound): httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "no runtime mapping for this user/game") case errors.Is(err, runtime.ErrConflict): 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, "internal error") } } func respondEngineProxyError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, body []byte, err error) { switch { case errors.Is(err, engineclient.ErrEngineValidation): if len(body) > 0 { c.Data(http.StatusBadRequest, "application/json", body) return } httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, err.Error()) case errors.Is(err, engineclient.ErrEngineUnreachable): httperr.Abort(c, http.StatusServiceUnavailable, httperr.CodeServiceUnavailable, "engine is unreachable") case errors.Is(err, engineclient.ErrEngineProtocolViolation): logger.Error(op+" engine protocol violation", append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))..., ) httperr.Abort(c, http.StatusBadGateway, httperr.CodeInternalError, "engine response was malformed") default: logger.Error(op+" failed", append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))..., ) httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "internal error") } }