package backendclient import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "galaxy/gateway/internal/downstream" lobbymodel "galaxy/model/lobby" "galaxy/transcoder" ) const ( lobbyResultCodeOK = "ok" defaultLobbyErrorCodeInvalid = "invalid_request" defaultLobbyErrorCodeNoSubj = "subject_not_found" defaultLobbyErrorCodeForbid = "forbidden" defaultLobbyErrorCodeConfl = "conflict" defaultLobbyErrorCodeIntErr = "internal_error" ) var stableLobbyErrorMessages = map[string]string{ defaultLobbyErrorCodeInvalid: "request is invalid", defaultLobbyErrorCodeNoSubj: "subject not found", defaultLobbyErrorCodeForbid: "operation is forbidden for the calling user", defaultLobbyErrorCodeConfl: "request conflicts with current state", defaultLobbyErrorCodeIntErr: "internal server error", } // ExecuteLobbyCommand routes one authenticated lobby command into // backend's `/api/v1/user/lobby/*` endpoints. func (c *RESTClient) ExecuteLobbyCommand(ctx context.Context, command downstream.AuthenticatedCommand) (downstream.UnaryResult, error) { if c == nil || c.httpClient == nil { return downstream.UnaryResult{}, errors.New("backendclient: execute lobby command: nil client") } if ctx == nil { return downstream.UnaryResult{}, errors.New("backendclient: execute lobby command: nil context") } if err := ctx.Err(); err != nil { return downstream.UnaryResult{}, err } if strings.TrimSpace(command.UserID) == "" { return downstream.UnaryResult{}, errors.New("backendclient: execute lobby command: user_id must not be empty") } switch command.MessageType { case lobbymodel.MessageTypeMyGamesList: if _, err := transcoder.PayloadToMyGamesListRequest(command.PayloadBytes); err != nil { return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command %q: %w", command.MessageType, err) } return c.executeLobbyMyGames(ctx, command.UserID) case lobbymodel.MessageTypeOpenEnrollment: req, err := transcoder.PayloadToOpenEnrollmentRequest(command.PayloadBytes) if err != nil { return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command %q: %w", command.MessageType, err) } return c.executeLobbyOpenEnrollment(ctx, command.UserID, req) default: return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command: unsupported message type %q", command.MessageType) } } func (c *RESTClient) executeLobbyMyGames(ctx context.Context, userID string) (downstream.UnaryResult, error) { body, status, err := c.do(ctx, http.MethodGet, c.baseURL+"/api/v1/user/lobby/my/games", userID, nil) if err != nil { return downstream.UnaryResult{}, fmt.Errorf("execute lobby.my.games.list: %w", err) } if status == http.StatusOK { var response lobbymodel.MyGamesListResponse if err := decodeStrictJSON(body, &response); err != nil { return downstream.UnaryResult{}, fmt.Errorf("decode success response: %w", err) } payloadBytes, err := transcoder.MyGamesListResponseToPayload(&response) if err != nil { return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err) } return downstream.UnaryResult{ ResultCode: lobbyResultCodeOK, PayloadBytes: payloadBytes, }, nil } return projectLobbyErrorResponse(status, body) } func (c *RESTClient) executeLobbyOpenEnrollment(ctx context.Context, userID string, req *lobbymodel.OpenEnrollmentRequest) (downstream.UnaryResult, error) { if req == nil || strings.TrimSpace(req.GameID) == "" { return downstream.UnaryResult{}, errors.New("execute lobby.game.open-enrollment: game_id must not be empty") } target := c.baseURL + "/api/v1/user/lobby/games/" + url.PathEscape(req.GameID) + "/open-enrollment" body, status, err := c.do(ctx, http.MethodPost, target, userID, struct{}{}) if err != nil { return downstream.UnaryResult{}, fmt.Errorf("execute lobby.game.open-enrollment: %w", err) } if status == http.StatusOK { // Backend returns the full LobbyGameDetail; gateway projects the // minimal {game_id, status} pair onto the existing wire shape. var detail struct { GameID string `json:"game_id"` Status string `json:"status"` } if err := json.NewDecoder(bytes.NewReader(body)).Decode(&detail); err != nil { return downstream.UnaryResult{}, fmt.Errorf("decode success response: %w", err) } payloadBytes, err := transcoder.OpenEnrollmentResponseToPayload(&lobbymodel.OpenEnrollmentResponse{ GameID: detail.GameID, Status: detail.Status, }) if err != nil { return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err) } return downstream.UnaryResult{ ResultCode: lobbyResultCodeOK, PayloadBytes: payloadBytes, }, nil } return projectLobbyErrorResponse(status, body) } func projectLobbyErrorResponse(statusCode int, payload []byte) (downstream.UnaryResult, error) { switch { case statusCode == http.StatusServiceUnavailable: return downstream.UnaryResult{}, downstream.ErrDownstreamUnavailable case statusCode >= 400 && statusCode <= 599: errResp, err := decodeLobbyError(statusCode, payload) if err != nil { return downstream.UnaryResult{}, fmt.Errorf("decode error response: %w", err) } payloadBytes, err := transcoder.LobbyErrorResponseToPayload(errResp) if err != nil { return downstream.UnaryResult{}, fmt.Errorf("encode error response payload: %w", err) } return downstream.UnaryResult{ ResultCode: errResp.Error.Code, PayloadBytes: payloadBytes, }, nil default: return downstream.UnaryResult{}, fmt.Errorf("unexpected HTTP status %d", statusCode) } } func decodeLobbyError(statusCode int, payload []byte) (*lobbymodel.ErrorResponse, error) { var response lobbymodel.ErrorResponse decoder := json.NewDecoder(bytes.NewReader(payload)) decoder.DisallowUnknownFields() if err := decoder.Decode(&response); err != nil { return nil, err } if err := decoder.Decode(&struct{}{}); err != io.EOF { if err == nil { return nil, errors.New("unexpected trailing JSON input") } return nil, err } response.Error.Code = normalizeLobbyErrorCode(statusCode, response.Error.Code) response.Error.Message = normalizeLobbyErrorMessage(response.Error.Code, response.Error.Message) if strings.TrimSpace(response.Error.Code) == "" { return nil, errors.New("missing error code") } if strings.TrimSpace(response.Error.Message) == "" { return nil, errors.New("missing error message") } return &response, nil } func normalizeLobbyErrorCode(statusCode int, code string) string { if trimmed := strings.TrimSpace(code); trimmed != "" { return trimmed } switch statusCode { case http.StatusBadRequest: return defaultLobbyErrorCodeInvalid case http.StatusForbidden: return defaultLobbyErrorCodeForbid case http.StatusNotFound: return defaultLobbyErrorCodeNoSubj case http.StatusConflict: return defaultLobbyErrorCodeConfl default: return defaultLobbyErrorCodeIntErr } } func normalizeLobbyErrorMessage(code, message string) string { if trimmed := strings.TrimSpace(message); trimmed != "" { return trimmed } if stable, ok := stableLobbyErrorMessages[code]; ok { return stable } return stableLobbyErrorMessages[defaultLobbyErrorCodeIntErr] }