feat: game lobby service
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
// Package userservice provides the HTTP adapter for the
|
||||
// ports.UserService eligibility port. It wraps the trusted
|
||||
// User Service internal endpoint
|
||||
// `GET /api/v1/internal/users/{user_id}/eligibility` and decodes the
|
||||
// response into the lobby-side ports.Eligibility shape.
|
||||
package userservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
)
|
||||
|
||||
// permanentBlockSanctionCode mirrors policy.SanctionCodePermanentBlock in
|
||||
// galaxy/user. The lobby adapter inspects the active_sanctions array for
|
||||
// this string to populate Eligibility.PermanentBlocked without taking a
|
||||
// build-time dependency on the user module.
|
||||
const permanentBlockSanctionCode = "permanent_block"
|
||||
|
||||
// maxRegisteredRaceNamesLimitCode mirrors
|
||||
// policy.LimitCodeMaxRegisteredRaceNames in galaxy/user. A snapshot value
|
||||
// of 0 denotes unlimited per the lifetime tariff.
|
||||
const maxRegisteredRaceNamesLimitCode = "max_registered_race_names"
|
||||
|
||||
// Client implements ports.UserService against the trusted internal HTTP
|
||||
// surface of User Service.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// Config groups the construction parameters of Client.
|
||||
type Config struct {
|
||||
// BaseURL is the absolute root URL of User Service (no trailing slash
|
||||
// required). The eligibility path is appended on every call.
|
||||
BaseURL string
|
||||
|
||||
// Timeout bounds one round trip including TLS handshake. It must be
|
||||
// positive.
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Validate reports whether cfg stores a usable Client configuration.
|
||||
func (cfg Config) Validate() error {
|
||||
switch {
|
||||
case strings.TrimSpace(cfg.BaseURL) == "":
|
||||
return errors.New("user service base url must not be empty")
|
||||
case cfg.Timeout <= 0:
|
||||
return errors.New("user service timeout must be positive")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// NewClient constructs a Client from cfg. Transport is wrapped with
|
||||
// otelhttp.NewTransport so traces propagate to User Service.
|
||||
func NewClient(cfg Config) (*Client, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("new user service client: %w", err)
|
||||
}
|
||||
httpClient := &http.Client{
|
||||
Timeout: cfg.Timeout,
|
||||
Transport: otelhttp.NewTransport(http.DefaultTransport),
|
||||
}
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
|
||||
httpClient: httpClient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// rawEligibility mirrors the lobby-relevant subset of
|
||||
// lobbyeligibility.GetUserEligibilityResult. Unknown JSON fields are
|
||||
// ignored intentionally so future user-side additions do not break the
|
||||
// lobby decoder; see the decision record for context.
|
||||
type rawEligibility struct {
|
||||
Exists bool `json:"exists"`
|
||||
Markers rawMarkers `json:"markers"`
|
||||
ActiveSanctions []rawSanction `json:"active_sanctions"`
|
||||
EffectiveLimits []rawLimit `json:"effective_limits"`
|
||||
}
|
||||
|
||||
type rawMarkers struct {
|
||||
CanLogin bool `json:"can_login"`
|
||||
CanCreatePrivateGame bool `json:"can_create_private_game"`
|
||||
CanManagePrivateGame bool `json:"can_manage_private_game"`
|
||||
CanJoinGame bool `json:"can_join_game"`
|
||||
CanUpdateProfile bool `json:"can_update_profile"`
|
||||
}
|
||||
|
||||
type rawSanction struct {
|
||||
SanctionCode string `json:"sanction_code"`
|
||||
}
|
||||
|
||||
type rawLimit struct {
|
||||
LimitCode string `json:"limit_code"`
|
||||
Value int `json:"value"`
|
||||
}
|
||||
|
||||
// GetEligibility issues GET /api/v1/internal/users/{user_id}/eligibility
|
||||
// and decodes the response into a ports.Eligibility value. HTTP 404 is
|
||||
// treated as a present-but-missing user (Exists=false). Transport errors,
|
||||
// timeouts, and unexpected statuses surface as ports.ErrUserServiceUnavailable.
|
||||
func (client *Client) GetEligibility(ctx context.Context, userID string) (ports.Eligibility, error) {
|
||||
if client == nil || client.httpClient == nil {
|
||||
return ports.Eligibility{}, errors.New("get eligibility: nil client")
|
||||
}
|
||||
if ctx == nil {
|
||||
return ports.Eligibility{}, errors.New("get eligibility: nil context")
|
||||
}
|
||||
trimmed := strings.TrimSpace(userID)
|
||||
if trimmed == "" {
|
||||
return ports.Eligibility{}, errors.New("get eligibility: user id must not be empty")
|
||||
}
|
||||
|
||||
endpoint := client.baseURL + "/api/v1/internal/users/" + url.PathEscape(trimmed) + "/eligibility"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return ports.Eligibility{}, fmt.Errorf("get eligibility: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := client.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return ports.Eligibility{}, fmt.Errorf("get eligibility: %w", errors.Join(ports.ErrUserServiceUnavailable, err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusNotFound:
|
||||
return ports.Eligibility{Exists: false}, nil
|
||||
case resp.StatusCode < 200 || resp.StatusCode >= 300:
|
||||
return ports.Eligibility{}, fmt.Errorf(
|
||||
"get eligibility: unexpected status %d: %w",
|
||||
resp.StatusCode, ports.ErrUserServiceUnavailable,
|
||||
)
|
||||
}
|
||||
|
||||
var raw rawEligibility
|
||||
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||||
return ports.Eligibility{}, fmt.Errorf("get eligibility: decode body: %w", err)
|
||||
}
|
||||
|
||||
return ports.Eligibility{
|
||||
Exists: raw.Exists,
|
||||
CanLogin: raw.Markers.CanLogin,
|
||||
CanCreatePrivateGame: raw.Markers.CanCreatePrivateGame,
|
||||
CanManagePrivateGame: raw.Markers.CanManagePrivateGame,
|
||||
CanJoinGame: raw.Markers.CanJoinGame,
|
||||
CanUpdateProfile: raw.Markers.CanUpdateProfile,
|
||||
PermanentBlocked: containsSanction(raw.ActiveSanctions, permanentBlockSanctionCode),
|
||||
MaxRegisteredRaceNames: lookupLimit(raw.EffectiveLimits, maxRegisteredRaceNamesLimitCode),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func containsSanction(records []rawSanction, code string) bool {
|
||||
for _, record := range records {
|
||||
if record.SanctionCode == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func lookupLimit(records []rawLimit, code string) int {
|
||||
for _, record := range records {
|
||||
if record.LimitCode == code {
|
||||
return record.Value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Compile-time interface assertion.
|
||||
var _ ports.UserService = (*Client)(nil)
|
||||
Reference in New Issue
Block a user