// Package lobbyservice implements the authenticated Gateway -> Game Lobby // downstream adapter. It forwards verified authenticated commands as // trusted-internal HTTP requests against Game Lobby's public REST surface, // transporting the calling user identity through the `X-User-Id` header. package lobbyservice import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "galaxy/gateway/internal/downstream" lobbymodel "galaxy/model/lobby" "galaxy/transcoder" ) const ( myGamesListPath = "/api/v1/lobby/my/games" openEnrollmentPathFormat = "/api/v1/lobby/games/%s/open-enrollment" resultCodeOK = "ok" defaultErrorCodeBadRequest = "invalid_request" defaultErrorCodeNotFound = "subject_not_found" defaultErrorCodeForbidden = "forbidden" defaultErrorCodeConflict = "conflict" defaultErrorCodeInternalError = "internal_error" headerCallingUserID = "X-User-Id" ) var stableErrorMessages = map[string]string{ defaultErrorCodeBadRequest: "request is invalid", defaultErrorCodeNotFound: "subject not found", defaultErrorCodeForbidden: "operation is forbidden for the calling user", defaultErrorCodeConflict: "request conflicts with current state", defaultErrorCodeInternalError: "internal server error", } // HTTPClient implements downstream.Client against the trusted Game Lobby // public REST API while preserving FlatBuffers at the external authenticated // gateway boundary. type HTTPClient struct { baseURL string httpClient *http.Client } // NewHTTPClient constructs one Game Lobby downstream client backed by the // public REST API at baseURL. func NewHTTPClient(baseURL string) (*HTTPClient, error) { transport, ok := http.DefaultTransport.(*http.Transport) if !ok { return nil, errors.New("new lobby service HTTP client: default transport is not *http.Transport") } return newHTTPClient(baseURL, &http.Client{ Transport: transport.Clone(), }) } func newHTTPClient(baseURL string, httpClient *http.Client) (*HTTPClient, error) { if httpClient == nil { return nil, errors.New("new lobby service HTTP client: http client must not be nil") } trimmedBaseURL := strings.TrimSpace(baseURL) if trimmedBaseURL == "" { return nil, errors.New("new lobby service HTTP client: base URL must not be empty") } parsedBaseURL, err := url.Parse(strings.TrimRight(trimmedBaseURL, "/")) if err != nil { return nil, fmt.Errorf("new lobby service HTTP client: parse base URL: %w", err) } if parsedBaseURL.Scheme == "" || parsedBaseURL.Host == "" { return nil, errors.New("new lobby service HTTP client: base URL must be absolute") } return &HTTPClient{ baseURL: parsedBaseURL.String(), httpClient: httpClient, }, nil } // Close releases idle HTTP connections owned by the client transport. func (c *HTTPClient) Close() error { if c == nil || c.httpClient == nil { return nil } type idleCloser interface { CloseIdleConnections() } if transport, ok := c.httpClient.Transport.(idleCloser); ok { transport.CloseIdleConnections() } return nil } // ExecuteCommand routes one authenticated gateway command to the matching // trusted Game Lobby public REST route. func (c *HTTPClient) ExecuteCommand(ctx context.Context, command downstream.AuthenticatedCommand) (downstream.UnaryResult, error) { if c == nil || c.httpClient == nil { return downstream.UnaryResult{}, errors.New("execute lobby service command: nil client") } if ctx == nil { return downstream.UnaryResult{}, errors.New("execute lobby service command: nil context") } if err := ctx.Err(); err != nil { return downstream.UnaryResult{}, err } if strings.TrimSpace(command.UserID) == "" { return downstream.UnaryResult{}, errors.New("execute lobby service 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("execute lobby service command %q: %w", command.MessageType, err) } return c.executeMyGamesList(ctx, command.UserID) case lobbymodel.MessageTypeOpenEnrollment: request, err := transcoder.PayloadToOpenEnrollmentRequest(command.PayloadBytes) if err != nil { return downstream.UnaryResult{}, fmt.Errorf("execute lobby service command %q: %w", command.MessageType, err) } return c.executeOpenEnrollment(ctx, command.UserID, request) default: return downstream.UnaryResult{}, fmt.Errorf("execute lobby service command: unsupported message type %q", command.MessageType) } } func (c *HTTPClient) executeMyGamesList(ctx context.Context, userID string) (downstream.UnaryResult, error) { payload, statusCode, err := c.doRequest(ctx, http.MethodGet, c.baseURL+myGamesListPath, userID, nil) if err != nil { return downstream.UnaryResult{}, fmt.Errorf("execute my games list: %w", err) } if statusCode == http.StatusOK { var response lobbymodel.MyGamesListResponse if err := decodeStrictJSONPayload(payload, &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: resultCodeOK, PayloadBytes: payloadBytes, }, nil } return projectErrorResponse(statusCode, payload) } func (c *HTTPClient) executeOpenEnrollment(ctx context.Context, userID string, request *lobbymodel.OpenEnrollmentRequest) (downstream.UnaryResult, error) { if request == nil || strings.TrimSpace(request.GameID) == "" { return downstream.UnaryResult{}, errors.New("execute open enrollment: game_id must not be empty") } target := c.baseURL + fmt.Sprintf(openEnrollmentPathFormat, url.PathEscape(request.GameID)) payload, statusCode, err := c.doRequest(ctx, http.MethodPost, target, userID, struct{}{}) if err != nil { return downstream.UnaryResult{}, fmt.Errorf("execute open enrollment: %w", err) } if statusCode == http.StatusOK { // Lobby's open-enrollment endpoint returns the full game record; // the gateway boundary projects the minimal status pair. var fullRecord struct { GameID string `json:"game_id"` Status string `json:"status"` } if err := json.Unmarshal(payload, &fullRecord); err != nil { return downstream.UnaryResult{}, fmt.Errorf("decode success response: %w", err) } payloadBytes, err := transcoder.OpenEnrollmentResponseToPayload(&lobbymodel.OpenEnrollmentResponse{ GameID: fullRecord.GameID, Status: fullRecord.Status, }) if err != nil { return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err) } return downstream.UnaryResult{ ResultCode: resultCodeOK, PayloadBytes: payloadBytes, }, nil } return projectErrorResponse(statusCode, payload) } func (c *HTTPClient) doRequest(ctx context.Context, method, targetURL, userID string, requestBody any) ([]byte, int, error) { if c == nil || c.httpClient == nil { return nil, 0, errors.New("nil client") } var bodyReader io.Reader if requestBody != nil { body, err := json.Marshal(requestBody) if err != nil { return nil, 0, fmt.Errorf("marshal request body: %w", err) } bodyReader = bytes.NewReader(body) } request, err := http.NewRequestWithContext(ctx, method, targetURL, bodyReader) if err != nil { return nil, 0, fmt.Errorf("build request: %w", err) } if requestBody != nil { request.Header.Set("Content-Type", "application/json") } request.Header.Set(headerCallingUserID, userID) response, err := c.httpClient.Do(request) if err != nil { return nil, 0, err } defer response.Body.Close() payload, err := io.ReadAll(response.Body) if err != nil { return nil, 0, fmt.Errorf("read response body: %w", err) } return payload, response.StatusCode, nil } func projectErrorResponse(statusCode int, payload []byte) (downstream.UnaryResult, error) { switch { case statusCode == http.StatusServiceUnavailable: return downstream.UnaryResult{}, downstream.ErrDownstreamUnavailable case statusCode >= 400 && statusCode <= 599: errorResponse, err := decodeLobbyError(statusCode, payload) if err != nil { return downstream.UnaryResult{}, fmt.Errorf("decode error response: %w", err) } payloadBytes, err := transcoder.LobbyErrorResponseToPayload(errorResponse) if err != nil { return downstream.UnaryResult{}, fmt.Errorf("encode error response payload: %w", err) } return downstream.UnaryResult{ ResultCode: errorResponse.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 if err := decodeStrictJSONPayload(payload, &response); err != nil { return nil, err } response.Error.Code = normalizeErrorCode(statusCode, response.Error.Code) response.Error.Message = normalizeErrorMessage(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 normalizeErrorCode(statusCode int, code string) string { trimmed := strings.TrimSpace(code) if trimmed != "" { return trimmed } switch statusCode { case http.StatusBadRequest: return defaultErrorCodeBadRequest case http.StatusForbidden: return defaultErrorCodeForbidden case http.StatusNotFound: return defaultErrorCodeNotFound case http.StatusConflict: return defaultErrorCodeConflict default: return defaultErrorCodeInternalError } } func normalizeErrorMessage(code, message string) string { trimmed := strings.TrimSpace(message) if trimmed != "" { return trimmed } if stable, ok := stableErrorMessages[code]; ok { return stable } return stableErrorMessages[defaultErrorCodeInternalError] } func decodeStrictJSONPayload(payload []byte, target any) error { decoder := json.NewDecoder(bytes.NewReader(payload)) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { return err } if err := decoder.Decode(&struct{}{}); err != io.EOF { if err == nil { return errors.New("unexpected trailing JSON input") } return err } return nil } var _ downstream.Client = (*HTTPClient)(nil)