184 lines
6.0 KiB
Go
184 lines
6.0 KiB
Go
// 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)
|