feat: gamemaster
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
// 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 * <pages>`, 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)
|
||||
Reference in New Issue
Block a user