// Package lobbyclient provides the trusted-internal Lobby REST client // Game Master uses to fetch membership lists for the in-process // authorization cache and to resolve the human-readable `game_name` // consumed by notification intents. // // Two endpoints are mounted today: // // - `GET /api/v1/internal/games/{game_id}/memberships` — pagination is // handled internally so callers always receive every membership of // the game; // - `GET /api/v1/internal/games/{game_id}` — single read used by the // turn-generation orchestrator to resolve `game_name` per // notification. package lobbyclient import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "galaxy/gamemaster/internal/ports" ) const ( membershipsPathTemplate = "/api/v1/internal/games/%s/memberships" gameRecordPathTemplate = "/api/v1/internal/games/%s" // pageSize is the per-call page size; matches the Lobby spec // maximum (200) so we walk fewer pages on large rosters. pageSize = 200 // maxPages caps the page walk to defend against an upstream that // keeps returning a `next_page_token` indefinitely. 64 pages of // 200 items each cover 12_800 memberships per game — orders of // magnitude beyond any realistic Galaxy roster. maxPages = 64 ) // Config configures one HTTP-backed Lobby internal client. type Config struct { // BaseURL stores the absolute base URL of the Lobby internal HTTP // listener (e.g. `http://lobby:8095`). BaseURL string // RequestTimeout bounds one outbound page request. The total // wall-clock for `GetMemberships` is at most // `RequestTimeout * `, capped indirectly by the per-page // limit and `maxPages`. RequestTimeout time.Duration } // Client resolves Lobby memberships through the trusted internal HTTP // API. type Client struct { baseURL string requestTimeout time.Duration httpClient *http.Client closeIdleConnections func() } type membershipListEnvelope struct { Items []membershipRecordEnvelope `json:"items"` NextPageToken string `json:"next_page_token"` } type membershipRecordEnvelope struct { MembershipID string `json:"membership_id"` GameID string `json:"game_id"` UserID string `json:"user_id"` RaceName string `json:"race_name"` Status string `json:"status"` JoinedAt int64 `json:"joined_at"` RemovedAt *int64 `json:"removed_at,omitempty"` } // gameRecordEnvelope captures the fields GM consumes from Lobby's // `GameRecord` schema. Lobby may carry additional fields; the JSON // decoder ignores them. type gameRecordEnvelope struct { GameID string `json:"game_id"` GameName string `json:"game_name"` Status string `json:"status"` } type errorEnvelope struct { Error *errorBody `json:"error"` } type errorBody struct { Code string `json:"code"` Message string `json:"message"` } // NewClient constructs a Lobby internal client with otelhttp-wrapped // transport cloned from `http.DefaultTransport`. Call `Close` to // release idle connections at shutdown. func NewClient(cfg Config) (*Client, error) { transport, ok := http.DefaultTransport.(*http.Transport) if !ok { return nil, errors.New("new lobby client: default transport is not *http.Transport") } cloned := transport.Clone() return newClient(cfg, &http.Client{Transport: otelhttp.NewTransport(cloned)}, cloned.CloseIdleConnections) } func newClient(cfg Config, httpClient *http.Client, closeIdleConnections func()) (*Client, error) { switch { case strings.TrimSpace(cfg.BaseURL) == "": return nil, errors.New("new lobby client: base url must not be empty") case cfg.RequestTimeout <= 0: return nil, errors.New("new lobby client: request timeout must be positive") case httpClient == nil: return nil, errors.New("new lobby client: http client must not be nil") } parsed, err := url.Parse(strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")) if err != nil { return nil, fmt.Errorf("new lobby client: parse base url: %w", err) } if parsed.Scheme == "" || parsed.Host == "" { return nil, errors.New("new lobby client: base url must be absolute") } return &Client{ baseURL: parsed.String(), requestTimeout: cfg.RequestTimeout, httpClient: httpClient, closeIdleConnections: closeIdleConnections, }, nil } // Close releases idle HTTP connections owned by the underlying // transport. Safe to call multiple times. func (client *Client) Close() error { if client == nil || client.closeIdleConnections == nil { return nil } client.closeIdleConnections() return nil } // GetMemberships returns every membership of gameID, walking the // pagination chain transparently. Transport faults, non-2xx responses, // malformed payloads, and pagination overflow all surface as // `ports.ErrLobbyUnavailable` so callers can branch with `errors.Is`. func (client *Client) GetMemberships(ctx context.Context, gameID string) ([]ports.Membership, error) { if client == nil || client.httpClient == nil { return nil, errors.New("lobby get memberships: nil client") } if ctx == nil { return nil, errors.New("lobby get memberships: nil context") } if err := ctx.Err(); err != nil { return nil, err } if strings.TrimSpace(gameID) == "" { return nil, errors.New("lobby get memberships: game id must not be empty") } var memberships []ports.Membership pathPrefix := fmt.Sprintf(membershipsPathTemplate, url.PathEscape(gameID)) pageToken := "" for range maxPages { payload, statusCode, err := client.doRequest(ctx, http.MethodGet, buildPagedQuery(pathPrefix, pageToken)) if err != nil { return nil, fmt.Errorf("%w: %w", ports.ErrLobbyUnavailable, err) } if statusCode != http.StatusOK { errorCode := decodeErrorCode(payload) if errorCode != "" { return nil, fmt.Errorf("%w: unexpected status %d (error_code=%s)", ports.ErrLobbyUnavailable, statusCode, errorCode) } return nil, fmt.Errorf("%w: unexpected status %d", ports.ErrLobbyUnavailable, statusCode) } var envelope membershipListEnvelope if err := decodeJSONPayload(payload, &envelope); err != nil { return nil, fmt.Errorf("%w: decode response: %w", ports.ErrLobbyUnavailable, err) } for index, item := range envelope.Items { converted, err := toMembership(item) if err != nil { return nil, fmt.Errorf("%w: items[%d]: %w", ports.ErrLobbyUnavailable, index, err) } memberships = append(memberships, converted) } if strings.TrimSpace(envelope.NextPageToken) == "" { return memberships, nil } pageToken = envelope.NextPageToken } return nil, fmt.Errorf("%w: pagination overflow after %d pages", ports.ErrLobbyUnavailable, maxPages) } // GetGameSummary returns the narrow projection of Lobby's GameRecord // (game id, game name, lifecycle status) for gameID. Transport faults, // non-2xx responses, malformed payloads, and missing required fields // surface as `ports.ErrLobbyUnavailable` so callers can branch with // `errors.Is`. func (client *Client) GetGameSummary(ctx context.Context, gameID string) (ports.GameSummary, error) { if client == nil || client.httpClient == nil { return ports.GameSummary{}, errors.New("lobby get game summary: nil client") } if ctx == nil { return ports.GameSummary{}, errors.New("lobby get game summary: nil context") } if err := ctx.Err(); err != nil { return ports.GameSummary{}, err } if strings.TrimSpace(gameID) == "" { return ports.GameSummary{}, errors.New("lobby get game summary: game id must not be empty") } requestPath := fmt.Sprintf(gameRecordPathTemplate, url.PathEscape(gameID)) payload, statusCode, err := client.doRequest(ctx, http.MethodGet, requestPath) if err != nil { return ports.GameSummary{}, fmt.Errorf("%w: %w", ports.ErrLobbyUnavailable, err) } if statusCode != http.StatusOK { errorCode := decodeErrorCode(payload) if errorCode != "" { return ports.GameSummary{}, fmt.Errorf( "%w: unexpected status %d (error_code=%s)", ports.ErrLobbyUnavailable, statusCode, errorCode, ) } return ports.GameSummary{}, fmt.Errorf( "%w: unexpected status %d", ports.ErrLobbyUnavailable, statusCode, ) } var envelope gameRecordEnvelope if err := decodeJSONPayload(payload, &envelope); err != nil { return ports.GameSummary{}, fmt.Errorf("%w: decode response: %w", ports.ErrLobbyUnavailable, err) } if strings.TrimSpace(envelope.GameID) == "" { return ports.GameSummary{}, fmt.Errorf("%w: missing game_id", ports.ErrLobbyUnavailable) } if strings.TrimSpace(envelope.GameName) == "" { return ports.GameSummary{}, fmt.Errorf("%w: missing game_name", ports.ErrLobbyUnavailable) } if strings.TrimSpace(envelope.Status) == "" { return ports.GameSummary{}, fmt.Errorf("%w: missing status", ports.ErrLobbyUnavailable) } return ports.GameSummary{ GameID: envelope.GameID, GameName: envelope.GameName, Status: envelope.Status, }, nil } func buildPagedQuery(path, pageToken string) string { params := url.Values{} params.Set("page_size", strconv.Itoa(pageSize)) if pageToken != "" { params.Set("page_token", pageToken) } return path + "?" + params.Encode() } func toMembership(record membershipRecordEnvelope) (ports.Membership, error) { if strings.TrimSpace(record.UserID) == "" { return ports.Membership{}, errors.New("missing user_id") } if strings.TrimSpace(record.RaceName) == "" { return ports.Membership{}, errors.New("missing race_name") } if strings.TrimSpace(record.Status) == "" { return ports.Membership{}, errors.New("missing status") } membership := ports.Membership{ UserID: record.UserID, RaceName: record.RaceName, Status: record.Status, JoinedAt: time.UnixMilli(record.JoinedAt).UTC(), } if record.RemovedAt != nil { removedAt := time.UnixMilli(*record.RemovedAt).UTC() membership.RemovedAt = &removedAt } return membership, nil } func (client *Client) doRequest(ctx context.Context, method, requestPath string) ([]byte, int, error) { attemptCtx, cancel := context.WithTimeout(ctx, client.requestTimeout) defer cancel() req, err := http.NewRequestWithContext(attemptCtx, method, client.baseURL+requestPath, nil) if err != nil { return nil, 0, fmt.Errorf("build request: %w", err) } req.Header.Set("Accept", "application/json") resp, err := client.httpClient.Do(req) if err != nil { return nil, 0, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, 0, fmt.Errorf("read response body: %w", err) } return body, resp.StatusCode, nil } func decodeJSONPayload(payload []byte, target any) error { decoder := json.NewDecoder(bytes.NewReader(payload)) 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 } func decodeErrorCode(payload []byte) string { if len(payload) == 0 { return "" } var envelope errorEnvelope if err := json.Unmarshal(payload, &envelope); err != nil { return "" } if envelope.Error == nil { return "" } return envelope.Error.Code } // Compile-time assertion: Client implements ports.LobbyClient. var _ ports.LobbyClient = (*Client)(nil)