feat: backend service
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
package backendclient
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config describes the backend endpoint and gateway client identity used
|
||||
// to construct a Client. All fields are required when the gateway is
|
||||
// expected to talk to a real backend; the empty value yields an
|
||||
// always-unavailable client.
|
||||
type Config struct {
|
||||
// HTTPBaseURL is the absolute base URL of the backend HTTP listener
|
||||
// (`/api/v1/{public,user,internal}/*`). Required.
|
||||
HTTPBaseURL string
|
||||
|
||||
// GRPCPushURL is the dial target of the backend `Push.SubscribePush`
|
||||
// listener (`host:port`). Required.
|
||||
GRPCPushURL string
|
||||
|
||||
// GatewayClientID is the durable identifier this gateway instance
|
||||
// presents to backend in `GatewaySubscribeRequest.gateway_client_id`.
|
||||
// Required.
|
||||
GatewayClientID string
|
||||
|
||||
// HTTPTimeout bounds individual REST calls. Must be positive.
|
||||
HTTPTimeout time.Duration
|
||||
|
||||
// PushReconnectBaseBackoff is the starting delay between reconnect
|
||||
// attempts of `Push.SubscribePush`. Must be positive.
|
||||
PushReconnectBaseBackoff time.Duration
|
||||
|
||||
// PushReconnectMaxBackoff is the upper bound for exponential
|
||||
// reconnect delays. Must be greater than or equal to
|
||||
// PushReconnectBaseBackoff.
|
||||
PushReconnectMaxBackoff time.Duration
|
||||
}
|
||||
|
||||
// Validate reports a formatted error when cfg is missing required
|
||||
// values. The empty value is invalid; callers that intentionally omit
|
||||
// the backend may bypass this check by skipping NewClient entirely.
|
||||
func (cfg Config) Validate() error {
|
||||
trimmed := strings.TrimSpace(cfg.HTTPBaseURL)
|
||||
if trimmed == "" {
|
||||
return errors.New("backendclient: HTTPBaseURL must not be empty")
|
||||
}
|
||||
parsed, err := url.Parse(strings.TrimRight(trimmed, "/"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("backendclient: parse HTTPBaseURL: %w", err)
|
||||
}
|
||||
if parsed.Scheme == "" || parsed.Host == "" {
|
||||
return errors.New("backendclient: HTTPBaseURL must be absolute")
|
||||
}
|
||||
if strings.TrimSpace(cfg.GRPCPushURL) == "" {
|
||||
return errors.New("backendclient: GRPCPushURL must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(cfg.GatewayClientID) == "" {
|
||||
return errors.New("backendclient: GatewayClientID must not be empty")
|
||||
}
|
||||
if cfg.HTTPTimeout <= 0 {
|
||||
return errors.New("backendclient: HTTPTimeout must be positive")
|
||||
}
|
||||
if cfg.PushReconnectBaseBackoff <= 0 {
|
||||
return errors.New("backendclient: PushReconnectBaseBackoff must be positive")
|
||||
}
|
||||
if cfg.PushReconnectMaxBackoff < cfg.PushReconnectBaseBackoff {
|
||||
return errors.New("backendclient: PushReconnectMaxBackoff must be >= PushReconnectBaseBackoff")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Client aggregates the REST and gRPC adapters that talk to backend.
|
||||
// One value is shared across the gateway process; all methods are safe
|
||||
// for concurrent use.
|
||||
type Client struct {
|
||||
rest *RESTClient
|
||||
push *PushClient
|
||||
}
|
||||
|
||||
// NewClient constructs a Client that targets the configured backend.
|
||||
// REST adapter is always built. The gRPC push adapter is built lazily
|
||||
// when StartPush is called so unit tests can construct a Client with a
|
||||
// stubbed push transport.
|
||||
func NewClient(cfg Config) (*Client, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rest, err := NewRESTClient(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
push, err := NewPushClient(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Client{rest: rest, push: push}, nil
|
||||
}
|
||||
|
||||
// REST returns the REST adapter. The returned value is nil when the
|
||||
// Client was constructed without a backend; callers must guard.
|
||||
func (c *Client) REST() *RESTClient {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
return c.rest
|
||||
}
|
||||
|
||||
// Push returns the gRPC push adapter. The returned value is nil when
|
||||
// the Client was constructed without a backend.
|
||||
func (c *Client) Push() *PushClient {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
return c.push
|
||||
}
|
||||
|
||||
// Close releases idle HTTP connections and closes the gRPC push
|
||||
// connection. Safe to call multiple times.
|
||||
func (c *Client) Close() error {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
var firstErr error
|
||||
if c.rest != nil {
|
||||
if err := c.rest.Close(); err != nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
if c.push != nil {
|
||||
if err := c.push.Close(); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Package backendclient is the gateway-side adapter to the consolidated
|
||||
// `backend` service. It bundles every gateway → backend conversation:
|
||||
//
|
||||
// - public REST (`/api/v1/public/auth/*`) used by the public auth
|
||||
// surface,
|
||||
// - internal REST (`/api/v1/internal/sessions/*`,
|
||||
// `/api/v1/internal/users/*/account-internal`) used by the
|
||||
// authenticated request pipeline,
|
||||
// - authenticated user REST (`/api/v1/user/*`) used by the gRPC
|
||||
// downstream router after envelope verification,
|
||||
// - gRPC `Push.SubscribePush` used to receive `client_event` and
|
||||
// `session_invalidation` frames from backend.
|
||||
//
|
||||
// One env-driven Config describes the backend endpoint and the gateway
|
||||
// client identity. A single Client value is wired by `cmd/gateway` and
|
||||
// shared by all consumers (rest API public auth handler, gRPC session
|
||||
// cache, downstream user/lobby routes, and the push subscriber).
|
||||
package backendclient
|
||||
@@ -0,0 +1,197 @@
|
||||
package backendclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"galaxy/gateway/internal/downstream"
|
||||
lobbymodel "galaxy/model/lobby"
|
||||
"galaxy/transcoder"
|
||||
)
|
||||
|
||||
const (
|
||||
lobbyResultCodeOK = "ok"
|
||||
defaultLobbyErrorCodeInvalid = "invalid_request"
|
||||
defaultLobbyErrorCodeNoSubj = "subject_not_found"
|
||||
defaultLobbyErrorCodeForbid = "forbidden"
|
||||
defaultLobbyErrorCodeConfl = "conflict"
|
||||
defaultLobbyErrorCodeIntErr = "internal_error"
|
||||
)
|
||||
|
||||
var stableLobbyErrorMessages = map[string]string{
|
||||
defaultLobbyErrorCodeInvalid: "request is invalid",
|
||||
defaultLobbyErrorCodeNoSubj: "subject not found",
|
||||
defaultLobbyErrorCodeForbid: "operation is forbidden for the calling user",
|
||||
defaultLobbyErrorCodeConfl: "request conflicts with current state",
|
||||
defaultLobbyErrorCodeIntErr: "internal server error",
|
||||
}
|
||||
|
||||
// ExecuteLobbyCommand routes one authenticated lobby command into
|
||||
// backend's `/api/v1/user/lobby/*` endpoints.
|
||||
func (c *RESTClient) ExecuteLobbyCommand(ctx context.Context, command downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
|
||||
if c == nil || c.httpClient == nil {
|
||||
return downstream.UnaryResult{}, errors.New("backendclient: execute lobby command: nil client")
|
||||
}
|
||||
if ctx == nil {
|
||||
return downstream.UnaryResult{}, errors.New("backendclient: execute lobby command: nil context")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return downstream.UnaryResult{}, err
|
||||
}
|
||||
if strings.TrimSpace(command.UserID) == "" {
|
||||
return downstream.UnaryResult{}, errors.New("backendclient: execute lobby 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("backendclient: execute lobby command %q: %w", command.MessageType, err)
|
||||
}
|
||||
return c.executeLobbyMyGames(ctx, command.UserID)
|
||||
case lobbymodel.MessageTypeOpenEnrollment:
|
||||
req, err := transcoder.PayloadToOpenEnrollmentRequest(command.PayloadBytes)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command %q: %w", command.MessageType, err)
|
||||
}
|
||||
return c.executeLobbyOpenEnrollment(ctx, command.UserID, req)
|
||||
default:
|
||||
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command: unsupported message type %q", command.MessageType)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *RESTClient) executeLobbyMyGames(ctx context.Context, userID string) (downstream.UnaryResult, error) {
|
||||
body, status, err := c.do(ctx, http.MethodGet, c.baseURL+"/api/v1/user/lobby/my/games", userID, nil)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute lobby.my.games.list: %w", err)
|
||||
}
|
||||
if status == http.StatusOK {
|
||||
var response lobbymodel.MyGamesListResponse
|
||||
if err := decodeStrictJSON(body, &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: lobbyResultCodeOK,
|
||||
PayloadBytes: payloadBytes,
|
||||
}, nil
|
||||
}
|
||||
return projectLobbyErrorResponse(status, body)
|
||||
}
|
||||
|
||||
func (c *RESTClient) executeLobbyOpenEnrollment(ctx context.Context, userID string, req *lobbymodel.OpenEnrollmentRequest) (downstream.UnaryResult, error) {
|
||||
if req == nil || strings.TrimSpace(req.GameID) == "" {
|
||||
return downstream.UnaryResult{}, errors.New("execute lobby.game.open-enrollment: game_id must not be empty")
|
||||
}
|
||||
target := c.baseURL + "/api/v1/user/lobby/games/" + url.PathEscape(req.GameID) + "/open-enrollment"
|
||||
body, status, err := c.do(ctx, http.MethodPost, target, userID, struct{}{})
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute lobby.game.open-enrollment: %w", err)
|
||||
}
|
||||
if status == http.StatusOK {
|
||||
// Backend returns the full LobbyGameDetail; gateway projects the
|
||||
// minimal {game_id, status} pair onto the existing wire shape.
|
||||
var detail struct {
|
||||
GameID string `json:"game_id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.NewDecoder(bytes.NewReader(body)).Decode(&detail); err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("decode success response: %w", err)
|
||||
}
|
||||
payloadBytes, err := transcoder.OpenEnrollmentResponseToPayload(&lobbymodel.OpenEnrollmentResponse{
|
||||
GameID: detail.GameID,
|
||||
Status: detail.Status,
|
||||
})
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err)
|
||||
}
|
||||
return downstream.UnaryResult{
|
||||
ResultCode: lobbyResultCodeOK,
|
||||
PayloadBytes: payloadBytes,
|
||||
}, nil
|
||||
}
|
||||
return projectLobbyErrorResponse(status, body)
|
||||
}
|
||||
|
||||
func projectLobbyErrorResponse(statusCode int, payload []byte) (downstream.UnaryResult, error) {
|
||||
switch {
|
||||
case statusCode == http.StatusServiceUnavailable:
|
||||
return downstream.UnaryResult{}, downstream.ErrDownstreamUnavailable
|
||||
case statusCode >= 400 && statusCode <= 599:
|
||||
errResp, err := decodeLobbyError(statusCode, payload)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("decode error response: %w", err)
|
||||
}
|
||||
payloadBytes, err := transcoder.LobbyErrorResponseToPayload(errResp)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("encode error response payload: %w", err)
|
||||
}
|
||||
return downstream.UnaryResult{
|
||||
ResultCode: errResp.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
|
||||
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return nil, errors.New("unexpected trailing JSON input")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
response.Error.Code = normalizeLobbyErrorCode(statusCode, response.Error.Code)
|
||||
response.Error.Message = normalizeLobbyErrorMessage(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 normalizeLobbyErrorCode(statusCode int, code string) string {
|
||||
if trimmed := strings.TrimSpace(code); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
switch statusCode {
|
||||
case http.StatusBadRequest:
|
||||
return defaultLobbyErrorCodeInvalid
|
||||
case http.StatusForbidden:
|
||||
return defaultLobbyErrorCodeForbid
|
||||
case http.StatusNotFound:
|
||||
return defaultLobbyErrorCodeNoSubj
|
||||
case http.StatusConflict:
|
||||
return defaultLobbyErrorCodeConfl
|
||||
default:
|
||||
return defaultLobbyErrorCodeIntErr
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeLobbyErrorMessage(code, message string) string {
|
||||
if trimmed := strings.TrimSpace(message); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
if stable, ok := stableLobbyErrorMessages[code]; ok {
|
||||
return stable
|
||||
}
|
||||
return stableLobbyErrorMessages[defaultLobbyErrorCodeIntErr]
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package backendclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SendEmailCodeInput is the public REST and adapter payload used to
|
||||
// request a login code for a single e-mail address.
|
||||
type SendEmailCodeInput struct {
|
||||
Email string `json:"email"`
|
||||
PreferredLanguage string `json:"-"`
|
||||
}
|
||||
|
||||
// SendEmailCodeResult is the public REST and adapter payload returned
|
||||
// after backend creates a login challenge.
|
||||
type SendEmailCodeResult struct {
|
||||
ChallengeID string `json:"challenge_id"`
|
||||
}
|
||||
|
||||
// ConfirmEmailCodeInput is the public REST and adapter payload used to
|
||||
// complete a previously issued login challenge.
|
||||
type ConfirmEmailCodeInput struct {
|
||||
ChallengeID string `json:"challenge_id"`
|
||||
Code string `json:"code"`
|
||||
ClientPublicKey string `json:"client_public_key"`
|
||||
TimeZone string `json:"time_zone"`
|
||||
}
|
||||
|
||||
// ConfirmEmailCodeResult is the public REST and adapter payload
|
||||
// returned after backend creates a device session.
|
||||
type ConfirmEmailCodeResult struct {
|
||||
DeviceSessionID string `json:"device_session_id"`
|
||||
}
|
||||
|
||||
// AuthError lets a public REST handler project a stable error envelope
|
||||
// without re-deriving backend semantics. StatusCode is the HTTP status
|
||||
// the gateway should return; Code and Message form the JSON envelope.
|
||||
type AuthError struct {
|
||||
StatusCode int
|
||||
Code string
|
||||
Message string
|
||||
}
|
||||
|
||||
// Error returns a readable representation of the projected auth error.
|
||||
func (e *AuthError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("backendclient auth error: status=%d code=%s message=%s", e.StatusCode, e.Code, e.Message)
|
||||
}
|
||||
|
||||
// SendEmailCode delegates the public send-email-code route to backend.
|
||||
func (c *RESTClient) SendEmailCode(ctx context.Context, input SendEmailCodeInput) (SendEmailCodeResult, error) {
|
||||
if strings.TrimSpace(input.Email) == "" {
|
||||
return SendEmailCodeResult{}, errors.New("backendclient: send email code: email must not be empty")
|
||||
}
|
||||
body, status, err := c.doWithHeaders(ctx, http.MethodPost, c.baseURL+"/api/v1/public/auth/send-email-code", "", input, map[string]string{
|
||||
"Accept-Language": resolvePreferredLanguage(input.PreferredLanguage),
|
||||
})
|
||||
if err != nil {
|
||||
return SendEmailCodeResult{}, fmt.Errorf("backendclient: send email code: %w", err)
|
||||
}
|
||||
switch {
|
||||
case status == http.StatusOK:
|
||||
var result SendEmailCodeResult
|
||||
if err := decodeStrictJSON(body, &result); err != nil {
|
||||
return SendEmailCodeResult{}, fmt.Errorf("backendclient: send email code: decode success response: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(result.ChallengeID) == "" {
|
||||
return SendEmailCodeResult{}, errors.New("backendclient: send email code: challenge_id must not be empty")
|
||||
}
|
||||
return result, nil
|
||||
case status >= 400 && status <= 599:
|
||||
authErr, decodeErr := decodeAuthError(status, body)
|
||||
if decodeErr != nil {
|
||||
return SendEmailCodeResult{}, fmt.Errorf("backendclient: send email code: %w", decodeErr)
|
||||
}
|
||||
return SendEmailCodeResult{}, authErr
|
||||
default:
|
||||
return SendEmailCodeResult{}, fmt.Errorf("backendclient: send email code: unexpected HTTP status %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
// ConfirmEmailCode delegates the public confirm-email-code route to
|
||||
// backend.
|
||||
func (c *RESTClient) ConfirmEmailCode(ctx context.Context, input ConfirmEmailCodeInput) (ConfirmEmailCodeResult, error) {
|
||||
if strings.TrimSpace(input.ChallengeID) == "" {
|
||||
return ConfirmEmailCodeResult{}, errors.New("backendclient: confirm email code: challenge_id must not be empty")
|
||||
}
|
||||
body, status, err := c.doWithHeaders(ctx, http.MethodPost, c.baseURL+"/api/v1/public/auth/confirm-email-code", "", input, nil)
|
||||
if err != nil {
|
||||
return ConfirmEmailCodeResult{}, fmt.Errorf("backendclient: confirm email code: %w", err)
|
||||
}
|
||||
switch {
|
||||
case status == http.StatusOK:
|
||||
var result ConfirmEmailCodeResult
|
||||
if err := decodeStrictJSON(body, &result); err != nil {
|
||||
return ConfirmEmailCodeResult{}, fmt.Errorf("backendclient: confirm email code: decode success response: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(result.DeviceSessionID) == "" {
|
||||
return ConfirmEmailCodeResult{}, errors.New("backendclient: confirm email code: device_session_id must not be empty")
|
||||
}
|
||||
return result, nil
|
||||
case status >= 400 && status <= 599:
|
||||
authErr, decodeErr := decodeAuthError(status, body)
|
||||
if decodeErr != nil {
|
||||
return ConfirmEmailCodeResult{}, fmt.Errorf("backendclient: confirm email code: %w", decodeErr)
|
||||
}
|
||||
return ConfirmEmailCodeResult{}, authErr
|
||||
default:
|
||||
return ConfirmEmailCodeResult{}, fmt.Errorf("backendclient: confirm email code: unexpected HTTP status %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
// resolvePreferredLanguage returns a non-empty Accept-Language value or
|
||||
// the empty string when input is unset; downstream HTTP request helpers
|
||||
// drop the header on empty values.
|
||||
func resolvePreferredLanguage(preferred string) string {
|
||||
return strings.TrimSpace(preferred)
|
||||
}
|
||||
|
||||
type authErrorEnvelope struct {
|
||||
Error *authErrorBody `json:"error"`
|
||||
}
|
||||
|
||||
type authErrorBody struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func decodeAuthError(statusCode int, payload []byte) (*AuthError, error) {
|
||||
var envelope authErrorEnvelope
|
||||
if err := decodeStrictJSON(payload, &envelope); err != nil {
|
||||
return nil, fmt.Errorf("decode error response: %w", err)
|
||||
}
|
||||
if envelope.Error == nil {
|
||||
return nil, errors.New("decode error response: missing error object")
|
||||
}
|
||||
return &AuthError{
|
||||
StatusCode: statusCode,
|
||||
Code: envelope.Error.Code,
|
||||
Message: envelope.Error.Message,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
// PushClient — gateway-side gRPC consumer of `Push.SubscribePush`.
|
||||
//
|
||||
// One PushClient is wired for the gateway lifecycle. Run keeps the
|
||||
// subscription open, reconnects on every transport error with
|
||||
// exponential backoff (capped at PushReconnectMaxBackoff), and forwards
|
||||
// every received PushEvent to the configured EventHandler. The cursor
|
||||
// of the last successfully handled event is remembered in process
|
||||
// memory only (see `backend/README.md` and `backend/docs/` D2). On reconnect
|
||||
// it is replayed back to backend so any events still in the freshness-
|
||||
// window ring are received exactly once.
|
||||
package backendclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand/v2"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
pushv1 "galaxy/backend/proto/push/v1"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// EventHandler receives every PushEvent successfully drained from the
|
||||
// backend stream. Implementations must be concurrency-safe and must not
|
||||
// block; PushClient owns the calling goroutine and waits for Handle to
|
||||
// return before reading the next event.
|
||||
type EventHandler interface {
|
||||
Handle(context.Context, *pushv1.PushEvent)
|
||||
}
|
||||
|
||||
// EventHandlerFunc adapts a plain function to the EventHandler
|
||||
// contract.
|
||||
type EventHandlerFunc func(context.Context, *pushv1.PushEvent)
|
||||
|
||||
// Handle implements EventHandler.
|
||||
func (f EventHandlerFunc) Handle(ctx context.Context, ev *pushv1.PushEvent) { f(ctx, ev) }
|
||||
|
||||
// PushClient is the gRPC adapter that owns the long-lived
|
||||
// SubscribePush stream.
|
||||
type PushClient struct {
|
||||
cfg Config
|
||||
dialOpts []grpc.DialOption
|
||||
clock func() time.Time
|
||||
sleep func(context.Context, time.Duration) error
|
||||
logger *zap.Logger
|
||||
handler EventHandler
|
||||
|
||||
mu sync.Mutex
|
||||
cursor string
|
||||
|
||||
connMu sync.Mutex
|
||||
conn *grpc.ClientConn
|
||||
}
|
||||
|
||||
// NewPushClient constructs a PushClient. The default dial uses
|
||||
// transport credentials INSECURE; deployments behind TLS must wrap the
|
||||
// returned client with an alternative DialOption set via
|
||||
// WithDialOptions before calling Run.
|
||||
func NewPushClient(cfg Config) (*PushClient, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PushClient{
|
||||
cfg: cfg,
|
||||
dialOpts: []grpc.DialOption{
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
|
||||
},
|
||||
clock: time.Now,
|
||||
sleep: defaultSleep,
|
||||
logger: zap.NewNop(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// WithDialOptions overrides the default dial options used when opening
|
||||
// the gRPC connection. Tests typically pass `grpc.WithContextDialer` so
|
||||
// `grpc.NewClient` connects to a `bufconn` listener.
|
||||
func (c *PushClient) WithDialOptions(opts ...grpc.DialOption) *PushClient {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
c.dialOpts = append([]grpc.DialOption(nil), opts...)
|
||||
return c
|
||||
}
|
||||
|
||||
// WithLogger replaces the structured logger.
|
||||
func (c *PushClient) WithLogger(logger *zap.Logger) *PushClient {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
c.logger = logger.Named("push_client")
|
||||
return c
|
||||
}
|
||||
|
||||
// WithHandler installs the EventHandler. Run returns an error if no
|
||||
// handler has been installed.
|
||||
func (c *PushClient) WithHandler(handler EventHandler) *PushClient {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
c.handler = handler
|
||||
return c
|
||||
}
|
||||
|
||||
// Cursor returns the cursor of the last event delivered to the handler.
|
||||
// Useful for tests and operator inspection. Returns the empty string
|
||||
// before any event has been processed.
|
||||
func (c *PushClient) Cursor() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.cursor
|
||||
}
|
||||
|
||||
// Run opens the SubscribePush stream and forwards events until ctx is
|
||||
// cancelled. Network errors are retried with exponential backoff up to
|
||||
// PushReconnectMaxBackoff; ctx cancellation is the only terminal exit.
|
||||
func (c *PushClient) Run(ctx context.Context) error {
|
||||
if c == nil {
|
||||
return errors.New("backendclient.PushClient.Run: nil client")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("backendclient.PushClient.Run: nil context")
|
||||
}
|
||||
if c.handler == nil {
|
||||
return errors.New("backendclient.PushClient.Run: handler is required")
|
||||
}
|
||||
|
||||
conn, err := grpc.NewClient(c.cfg.GRPCPushURL, c.dialOpts...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backendclient.PushClient.Run: dial backend push: %w", err)
|
||||
}
|
||||
c.connMu.Lock()
|
||||
c.conn = conn
|
||||
c.connMu.Unlock()
|
||||
defer func() {
|
||||
c.connMu.Lock()
|
||||
_ = c.conn.Close()
|
||||
c.conn = nil
|
||||
c.connMu.Unlock()
|
||||
}()
|
||||
|
||||
pushAPI := pushv1.NewPushClient(conn)
|
||||
backoff := c.cfg.PushReconnectBaseBackoff
|
||||
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err := c.runOnce(ctx, pushAPI)
|
||||
switch {
|
||||
case err == nil, errors.Is(err, context.Canceled):
|
||||
return ctx.Err()
|
||||
case status.Code(err) == codes.Aborted:
|
||||
c.logger.Info("backend replaced push subscription; reconnecting")
|
||||
case errors.Is(err, io.EOF):
|
||||
c.logger.Info("backend push stream closed; reconnecting")
|
||||
default:
|
||||
c.logger.Warn("backend push stream error; reconnecting",
|
||||
zap.Error(err),
|
||||
zap.Duration("backoff", backoff),
|
||||
)
|
||||
}
|
||||
|
||||
if err := c.sleep(ctx, jitter(backoff)); err != nil {
|
||||
return err
|
||||
}
|
||||
backoff = nextBackoff(backoff, c.cfg.PushReconnectMaxBackoff)
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown is a no-op kept for `app.Component` compatibility. The
|
||||
// SubscribePush call exits when its parent context is cancelled.
|
||||
func (c *PushClient) Shutdown(_ context.Context) error { return nil }
|
||||
|
||||
// Close closes the underlying gRPC connection if it is open. Idempotent.
|
||||
func (c *PushClient) Close() error {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
c.connMu.Lock()
|
||||
defer c.connMu.Unlock()
|
||||
if c.conn == nil {
|
||||
return nil
|
||||
}
|
||||
err := c.conn.Close()
|
||||
c.conn = nil
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *PushClient) runOnce(ctx context.Context, pushAPI pushv1.PushClient) error {
|
||||
stream, err := pushAPI.SubscribePush(ctx, &pushv1.GatewaySubscribeRequest{
|
||||
GatewayClientId: c.cfg.GatewayClientID,
|
||||
Cursor: c.Cursor(),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("subscribe push: %w", err)
|
||||
}
|
||||
|
||||
for {
|
||||
ev, err := stream.Recv()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.handler.Handle(ctx, ev)
|
||||
if cursor := ev.GetCursor(); cursor != "" {
|
||||
c.setCursor(cursor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *PushClient) setCursor(cursor string) {
|
||||
c.mu.Lock()
|
||||
c.cursor = cursor
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func nextBackoff(current, max time.Duration) time.Duration {
|
||||
doubled := current * 2
|
||||
if doubled > max {
|
||||
return max
|
||||
}
|
||||
if doubled <= 0 {
|
||||
return max
|
||||
}
|
||||
return doubled
|
||||
}
|
||||
|
||||
// jitter returns d with ±20% multiplicative noise so multiple gateway
|
||||
// instances do not retry in lockstep after a backend restart.
|
||||
func jitter(d time.Duration) time.Duration {
|
||||
if d <= 0 {
|
||||
return d
|
||||
}
|
||||
noise := 1 + (rand.Float64()-0.5)*0.4
|
||||
return time.Duration(float64(d) * noise)
|
||||
}
|
||||
|
||||
func defaultSleep(ctx context.Context, d time.Duration) error {
|
||||
if d <= 0 {
|
||||
return nil
|
||||
}
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package backendclient_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
backendpush "galaxy/backend/push"
|
||||
pushv1 "galaxy/backend/proto/push/v1"
|
||||
"galaxy/gateway/internal/backendclient"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/test/bufconn"
|
||||
)
|
||||
|
||||
// bufconnPushService starts an in-process backend push.Service backed by
|
||||
// a *grpc.Server on a bufconn listener and returns the dial option that
|
||||
// gateway PushClient should use to connect to it.
|
||||
type bufconnPushService struct {
|
||||
Service *backendpush.Service
|
||||
dial func(context.Context, string) (net.Conn, error)
|
||||
stop func()
|
||||
}
|
||||
|
||||
func newBufconnPushService(t *testing.T) *bufconnPushService {
|
||||
t.Helper()
|
||||
|
||||
service, err := backendpush.NewService(backendpush.ServiceConfig{
|
||||
FreshnessWindow: time.Minute,
|
||||
RingCapacity: 16,
|
||||
PerConnBuffer: 8,
|
||||
}, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
listener := bufconn.Listen(1 << 16)
|
||||
server := grpc.NewServer()
|
||||
pushv1.RegisterPushServer(server, service)
|
||||
|
||||
go func() {
|
||||
_ = server.Serve(listener)
|
||||
}()
|
||||
|
||||
stop := func() {
|
||||
service.Close()
|
||||
server.Stop()
|
||||
_ = listener.Close()
|
||||
}
|
||||
t.Cleanup(stop)
|
||||
|
||||
return &bufconnPushService{
|
||||
Service: service,
|
||||
dial: func(_ context.Context, _ string) (net.Conn, error) { return listener.Dial() },
|
||||
stop: stop,
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushClientDeliversClientEventsAndAdvancesCursor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := newBufconnPushService(t)
|
||||
|
||||
type received struct {
|
||||
event *pushv1.PushEvent
|
||||
cursor string
|
||||
}
|
||||
out := make(chan received, 4)
|
||||
|
||||
cfg := backendclient.Config{
|
||||
HTTPBaseURL: "http://example.invalid",
|
||||
GRPCPushURL: "passthrough://bufconn",
|
||||
GatewayClientID: "gw-1",
|
||||
HTTPTimeout: time.Second,
|
||||
PushReconnectBaseBackoff: 10 * time.Millisecond,
|
||||
PushReconnectMaxBackoff: 100 * time.Millisecond,
|
||||
}
|
||||
client, err := backendclient.NewPushClient(cfg)
|
||||
require.NoError(t, err)
|
||||
client.WithDialOptions(
|
||||
grpc.WithContextDialer(svc.dial),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
client.WithHandler(backendclient.EventHandlerFunc(func(_ context.Context, ev *pushv1.PushEvent) {
|
||||
out <- received{event: ev, cursor: ev.GetCursor()}
|
||||
}))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var (
|
||||
runErr error
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
runErr = client.Run(ctx)
|
||||
}()
|
||||
|
||||
// Wait for backend service to register the subscription.
|
||||
require.Eventually(t, func() bool { return svc.Service.SubscriberCount() == 1 }, time.Second, 10*time.Millisecond)
|
||||
|
||||
userID := uuid.New()
|
||||
require.NoError(t, svc.Service.PublishClientEvent(context.Background(), userID, nil, "lobby.invite.received", map[string]any{"x": 1.0}, "evt-1", "req-1", "trace-1"))
|
||||
|
||||
select {
|
||||
case got := <-out:
|
||||
ce := got.event.GetClientEvent()
|
||||
require.NotNil(t, ce)
|
||||
assert.Equal(t, userID.String(), ce.GetUserId())
|
||||
assert.Equal(t, "lobby.invite.received", ce.GetKind())
|
||||
assert.Equal(t, "evt-1", ce.GetEventId())
|
||||
assert.Equal(t, "req-1", ce.GetRequestId())
|
||||
assert.Equal(t, "trace-1", ce.GetTraceId())
|
||||
assert.NotEmpty(t, got.cursor)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for client event")
|
||||
}
|
||||
|
||||
require.Eventually(t, func() bool { return client.Cursor() != "" }, time.Second, 10*time.Millisecond)
|
||||
|
||||
cancel()
|
||||
wg.Wait()
|
||||
if runErr != nil && runErr != context.Canceled {
|
||||
t.Fatalf("unexpected run error: %v", runErr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package backendclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/gateway/internal/session"
|
||||
)
|
||||
|
||||
// HeaderUserID is the trusted gateway → backend identity header.
|
||||
const HeaderUserID = "X-User-Id"
|
||||
|
||||
// errSessionNotFound is the public error returned by LookupSession when
|
||||
// backend reports HTTP 404 for a device session id. It wraps
|
||||
// session.ErrNotFound so callers can keep using the existing typed
|
||||
// equality check at the gateway hot path.
|
||||
func errSessionNotFound() error {
|
||||
return fmt.Errorf("backendclient: lookup session: %w", session.ErrNotFound)
|
||||
}
|
||||
|
||||
// RESTClient owns the gateway's HTTP conversation with backend.
|
||||
//
|
||||
// All methods are safe for concurrent use.
|
||||
type RESTClient struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewRESTClient constructs a RESTClient targeting the backend HTTP
|
||||
// listener configured in cfg.
|
||||
func NewRESTClient(cfg Config) (*RESTClient, error) {
|
||||
transport, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
return nil, errors.New("backendclient: default HTTP transport is not *http.Transport")
|
||||
}
|
||||
parsed, err := url.Parse(strings.TrimRight(strings.TrimSpace(cfg.HTTPBaseURL), "/"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("backendclient: parse HTTPBaseURL: %w", err)
|
||||
}
|
||||
if parsed.Scheme == "" || parsed.Host == "" {
|
||||
return nil, errors.New("backendclient: HTTPBaseURL must be absolute")
|
||||
}
|
||||
return &RESTClient{
|
||||
baseURL: parsed.String(),
|
||||
httpClient: &http.Client{
|
||||
Transport: transport.Clone(),
|
||||
Timeout: cfg.HTTPTimeout,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close releases idle HTTP connections owned by the client transport.
|
||||
func (c *RESTClient) 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
|
||||
}
|
||||
|
||||
// LookupSession resolves deviceSessionID against
|
||||
// `GET /api/v1/internal/sessions/{device_session_id}`.
|
||||
// Returns session.ErrNotFound (wrapped) when backend reports 404.
|
||||
func (c *RESTClient) LookupSession(ctx context.Context, deviceSessionID string) (session.Record, error) {
|
||||
if c == nil || c.httpClient == nil {
|
||||
return session.Record{}, errors.New("backendclient: nil REST client")
|
||||
}
|
||||
if strings.TrimSpace(deviceSessionID) == "" {
|
||||
return session.Record{}, errors.New("backendclient: lookup session: device_session_id must not be empty")
|
||||
}
|
||||
|
||||
target := c.baseURL + "/api/v1/internal/sessions/" + url.PathEscape(deviceSessionID)
|
||||
body, status, err := c.do(ctx, http.MethodGet, target, "", nil)
|
||||
if err != nil {
|
||||
return session.Record{}, fmt.Errorf("backendclient: lookup session: %w", err)
|
||||
}
|
||||
|
||||
switch {
|
||||
case status == http.StatusOK:
|
||||
return decodeDeviceSession(deviceSessionID, body)
|
||||
case status == http.StatusNotFound:
|
||||
return session.Record{}, errSessionNotFound()
|
||||
default:
|
||||
return session.Record{}, fmt.Errorf("backendclient: lookup session: unexpected HTTP status %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
// RevokeSession asks backend to revoke a single device session by id.
|
||||
func (c *RESTClient) RevokeSession(ctx context.Context, deviceSessionID string) error {
|
||||
if strings.TrimSpace(deviceSessionID) == "" {
|
||||
return errors.New("backendclient: revoke session: device_session_id must not be empty")
|
||||
}
|
||||
target := c.baseURL + "/api/v1/internal/sessions/" + url.PathEscape(deviceSessionID) + "/revoke"
|
||||
_, status, err := c.do(ctx, http.MethodPost, target, "", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backendclient: revoke session: %w", err)
|
||||
}
|
||||
if status == http.StatusOK || status == http.StatusNoContent {
|
||||
return nil
|
||||
}
|
||||
if status == http.StatusNotFound {
|
||||
return errSessionNotFound()
|
||||
}
|
||||
return fmt.Errorf("backendclient: revoke session: unexpected HTTP status %d", status)
|
||||
}
|
||||
|
||||
// RevokeAllSessionsForUser asks backend to revoke every active device
|
||||
// session belonging to userID.
|
||||
func (c *RESTClient) RevokeAllSessionsForUser(ctx context.Context, userID string) error {
|
||||
if strings.TrimSpace(userID) == "" {
|
||||
return errors.New("backendclient: revoke-all sessions: user_id must not be empty")
|
||||
}
|
||||
target := c.baseURL + "/api/v1/internal/sessions/users/" + url.PathEscape(userID) + "/revoke-all"
|
||||
_, status, err := c.do(ctx, http.MethodPost, target, "", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backendclient: revoke-all sessions: %w", err)
|
||||
}
|
||||
if status == http.StatusOK || status == http.StatusNoContent {
|
||||
return nil
|
||||
}
|
||||
if status == http.StatusNotFound {
|
||||
return errSessionNotFound()
|
||||
}
|
||||
return fmt.Errorf("backendclient: revoke-all sessions: unexpected HTTP status %d", status)
|
||||
}
|
||||
|
||||
// do executes a JSON request and reads the response body. userID, when
|
||||
// non-empty, is sent as the X-User-Id header (required for `/api/v1/user/*`).
|
||||
func (c *RESTClient) do(ctx context.Context, method, target, userID string, body any) ([]byte, int, error) {
|
||||
return c.doWithHeaders(ctx, method, target, userID, body, nil)
|
||||
}
|
||||
|
||||
// doWithHeaders is the shared transport entry point. extraHeaders are
|
||||
// applied verbatim after Content-Type/X-User-Id; an empty value drops
|
||||
// the header so callers can pass optional language tags etc.
|
||||
func (c *RESTClient) doWithHeaders(ctx context.Context, method, target, userID string, body any, extraHeaders map[string]string) ([]byte, int, error) {
|
||||
if c == nil || c.httpClient == nil {
|
||||
return nil, 0, errors.New("nil REST client")
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil, 0, errors.New("nil context")
|
||||
}
|
||||
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("marshal request body: %w", err)
|
||||
}
|
||||
reader = bytes.NewReader(buf)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, target, reader)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if userID != "" {
|
||||
req.Header.Set(HeaderUserID, userID)
|
||||
}
|
||||
for key, value := range extraHeaders {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
continue
|
||||
}
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
payload, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, fmt.Errorf("read response body: %w", err)
|
||||
}
|
||||
return payload, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// deviceSessionWire mirrors backend openapi `DeviceSession`.
|
||||
type deviceSessionWire struct {
|
||||
DeviceSessionID string `json:"device_session_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Status string `json:"status"`
|
||||
ClientPublicKey string `json:"client_public_key,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
|
||||
}
|
||||
|
||||
func decodeDeviceSession(expectedDeviceSessionID string, payload []byte) (session.Record, error) {
|
||||
var wire deviceSessionWire
|
||||
if err := decodeStrictJSON(payload, &wire); err != nil {
|
||||
return session.Record{}, fmt.Errorf("decode device session: %w", err)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(wire.DeviceSessionID) == "" {
|
||||
return session.Record{}, errors.New("decode device session: device_session_id must not be empty")
|
||||
}
|
||||
if wire.DeviceSessionID != expectedDeviceSessionID {
|
||||
return session.Record{}, fmt.Errorf("decode device session: device_session_id %q does not match requested %q", wire.DeviceSessionID, expectedDeviceSessionID)
|
||||
}
|
||||
if strings.TrimSpace(wire.UserID) == "" {
|
||||
return session.Record{}, errors.New("decode device session: user_id must not be empty")
|
||||
}
|
||||
|
||||
status := session.Status(strings.TrimSpace(wire.Status))
|
||||
if !status.IsKnown() {
|
||||
return session.Record{}, fmt.Errorf("decode device session: status %q is unsupported", wire.Status)
|
||||
}
|
||||
if status == session.StatusActive && strings.TrimSpace(wire.ClientPublicKey) == "" {
|
||||
return session.Record{}, errors.New("decode device session: active record missing client_public_key")
|
||||
}
|
||||
|
||||
record := session.Record{
|
||||
DeviceSessionID: wire.DeviceSessionID,
|
||||
UserID: wire.UserID,
|
||||
ClientPublicKey: wire.ClientPublicKey,
|
||||
Status: status,
|
||||
}
|
||||
if wire.RevokedAt != nil {
|
||||
ms := wire.RevokedAt.UnixMilli()
|
||||
record.RevokedAtMS = &ms
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func decodeStrictJSON(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
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package backendclient_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/gateway/internal/backendclient"
|
||||
"galaxy/gateway/internal/session"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newRESTClient(t *testing.T, server *httptest.Server) *backendclient.RESTClient {
|
||||
t.Helper()
|
||||
cfg := backendclient.Config{
|
||||
HTTPBaseURL: server.URL,
|
||||
GRPCPushURL: "passthrough://test",
|
||||
GatewayClientID: "test-gateway",
|
||||
HTTPTimeout: time.Second,
|
||||
PushReconnectBaseBackoff: 10 * time.Millisecond,
|
||||
PushReconnectMaxBackoff: 100 * time.Millisecond,
|
||||
}
|
||||
client, err := backendclient.NewRESTClient(cfg)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
return client
|
||||
}
|
||||
|
||||
func TestRESTClientLookupSessionReturnsActiveRecord(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, http.MethodGet, r.Method)
|
||||
require.Equal(t, "/api/v1/internal/sessions/device-1", r.URL.Path)
|
||||
writeJSON(t, w, http.StatusOK, map[string]any{
|
||||
"device_session_id": "device-1",
|
||||
"user_id": "user-1",
|
||||
"status": "active",
|
||||
"client_public_key": "pk-1",
|
||||
"created_at": "2026-04-01T00:00:00Z",
|
||||
})
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
client := newRESTClient(t, server)
|
||||
rec, err := client.LookupSession(context.Background(), "device-1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, session.Record{
|
||||
DeviceSessionID: "device-1",
|
||||
UserID: "user-1",
|
||||
ClientPublicKey: "pk-1",
|
||||
Status: session.StatusActive,
|
||||
}, rec)
|
||||
}
|
||||
|
||||
func TestRESTClientLookupSessionReturnsRevokedRecord(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(t, w, http.StatusOK, map[string]any{
|
||||
"device_session_id": "device-2",
|
||||
"user_id": "user-2",
|
||||
"status": "revoked",
|
||||
"client_public_key": "pk-2",
|
||||
"created_at": "2026-04-01T00:00:00Z",
|
||||
"revoked_at": "2026-04-01T00:01:00Z",
|
||||
})
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
client := newRESTClient(t, server)
|
||||
rec, err := client.LookupSession(context.Background(), "device-2")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, session.StatusRevoked, rec.Status)
|
||||
require.NotNil(t, rec.RevokedAtMS)
|
||||
assert.Equal(t, time.Date(2026, 4, 1, 0, 1, 0, 0, time.UTC).UnixMilli(), *rec.RevokedAtMS)
|
||||
}
|
||||
|
||||
func TestRESTClientLookupSessionMapsNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(t, w, http.StatusNotFound, map[string]any{"error": map[string]any{"code": "subject_not_found", "message": "missing"}})
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
client := newRESTClient(t, server)
|
||||
_, err := client.LookupSession(context.Background(), "missing")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, session.ErrNotFound))
|
||||
}
|
||||
|
||||
func TestRESTClientLookupSessionRejectsMismatchedID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(t, w, http.StatusOK, map[string]any{
|
||||
"device_session_id": "other",
|
||||
"user_id": "user-1",
|
||||
"status": "active",
|
||||
"client_public_key": "pk-1",
|
||||
"created_at": "2026-04-01T00:00:00Z",
|
||||
})
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
client := newRESTClient(t, server)
|
||||
_, err := client.LookupSession(context.Background(), "device-1")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "does not match requested")
|
||||
}
|
||||
|
||||
func TestRESTClientSendEmailCodeForwardsAcceptLanguage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, http.MethodPost, r.Method)
|
||||
require.Equal(t, "/api/v1/public/auth/send-email-code", r.URL.Path)
|
||||
require.Equal(t, "ru-RU", r.Header.Get("Accept-Language"))
|
||||
writeJSON(t, w, http.StatusOK, map[string]any{"challenge_id": "challenge-1"})
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
client := newRESTClient(t, server)
|
||||
out, err := client.SendEmailCode(context.Background(), backendclient.SendEmailCodeInput{
|
||||
Email: "user@example.com",
|
||||
PreferredLanguage: "ru-RU",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "challenge-1", out.ChallengeID)
|
||||
}
|
||||
|
||||
func TestRESTClientSendEmailCodeProjectsAuthError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(t, w, http.StatusBadRequest, map[string]any{
|
||||
"error": map[string]any{"code": "invalid_request", "message": "bad email"},
|
||||
})
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
client := newRESTClient(t, server)
|
||||
_, err := client.SendEmailCode(context.Background(), backendclient.SendEmailCodeInput{Email: "user@example.com"})
|
||||
require.Error(t, err)
|
||||
var authErr *backendclient.AuthError
|
||||
require.ErrorAs(t, err, &authErr)
|
||||
assert.Equal(t, http.StatusBadRequest, authErr.StatusCode)
|
||||
assert.Equal(t, "invalid_request", authErr.Code)
|
||||
assert.Equal(t, "bad email", authErr.Message)
|
||||
}
|
||||
|
||||
func TestRESTClientConfirmEmailCodeReturnsDeviceSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/api/v1/public/auth/confirm-email-code", r.URL.Path)
|
||||
|
||||
var body backendclient.ConfirmEmailCodeInput
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
|
||||
assert.Equal(t, "challenge-1", body.ChallengeID)
|
||||
writeJSON(t, w, http.StatusOK, map[string]any{"device_session_id": "device-1"})
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
client := newRESTClient(t, server)
|
||||
out, err := client.ConfirmEmailCode(context.Background(), backendclient.ConfirmEmailCodeInput{
|
||||
ChallengeID: "challenge-1",
|
||||
Code: "12345",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "device-1", out.DeviceSessionID)
|
||||
}
|
||||
|
||||
func writeJSON(t *testing.T, w http.ResponseWriter, status int, body any) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
require.NoError(t, json.NewEncoder(w).Encode(body))
|
||||
}
|
||||
|
||||
// guard ensures package keeps testify dependency.
|
||||
var _ = strings.TrimSpace
|
||||
@@ -0,0 +1,67 @@
|
||||
package backendclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"galaxy/gateway/internal/downstream"
|
||||
lobbymodel "galaxy/model/lobby"
|
||||
usermodel "galaxy/model/user"
|
||||
)
|
||||
|
||||
// UserRoutes returns the authenticated `user.*` downstream routes
|
||||
// served by backend. When client is nil every route resolves to a
|
||||
// dependency-unavailable client so the static router still recognises
|
||||
// the message types.
|
||||
func UserRoutes(client *RESTClient) map[string]downstream.Client {
|
||||
target := downstream.Client(unavailableClient{})
|
||||
if client != nil {
|
||||
target = userCommandClient{rest: client}
|
||||
}
|
||||
return map[string]downstream.Client{
|
||||
usermodel.MessageTypeGetMyAccount: target,
|
||||
usermodel.MessageTypeUpdateMyProfile: target,
|
||||
usermodel.MessageTypeUpdateMySettings: target,
|
||||
}
|
||||
}
|
||||
|
||||
// LobbyRoutes returns the authenticated `lobby.*` downstream routes
|
||||
// served by backend. When client is nil every route resolves to a
|
||||
// dependency-unavailable client.
|
||||
func LobbyRoutes(client *RESTClient) map[string]downstream.Client {
|
||||
target := downstream.Client(unavailableClient{})
|
||||
if client != nil {
|
||||
target = lobbyCommandClient{rest: client}
|
||||
}
|
||||
return map[string]downstream.Client{
|
||||
lobbymodel.MessageTypeMyGamesList: target,
|
||||
lobbymodel.MessageTypeOpenEnrollment: target,
|
||||
}
|
||||
}
|
||||
|
||||
type unavailableClient struct{}
|
||||
|
||||
func (unavailableClient) ExecuteCommand(context.Context, downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
|
||||
return downstream.UnaryResult{}, downstream.ErrDownstreamUnavailable
|
||||
}
|
||||
|
||||
type userCommandClient struct {
|
||||
rest *RESTClient
|
||||
}
|
||||
|
||||
func (c userCommandClient) ExecuteCommand(ctx context.Context, command downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
|
||||
return c.rest.ExecuteUserCommand(ctx, command)
|
||||
}
|
||||
|
||||
type lobbyCommandClient struct {
|
||||
rest *RESTClient
|
||||
}
|
||||
|
||||
func (c lobbyCommandClient) ExecuteCommand(ctx context.Context, command downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
|
||||
return c.rest.ExecuteLobbyCommand(ctx, command)
|
||||
}
|
||||
|
||||
var (
|
||||
_ downstream.Client = unavailableClient{}
|
||||
_ downstream.Client = userCommandClient{}
|
||||
_ downstream.Client = lobbyCommandClient{}
|
||||
)
|
||||
@@ -0,0 +1,166 @@
|
||||
package backendclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"galaxy/gateway/internal/downstream"
|
||||
usermodel "galaxy/model/user"
|
||||
"galaxy/transcoder"
|
||||
)
|
||||
|
||||
const (
|
||||
userCommandResultCodeOK = "ok"
|
||||
defaultUserErrorCode = "internal_error"
|
||||
)
|
||||
|
||||
var stableUserErrorMessages = map[string]string{
|
||||
"invalid_request": "request is invalid",
|
||||
"subject_not_found": "subject not found",
|
||||
"conflict": "request conflicts with current state",
|
||||
defaultUserErrorCode: "internal server error",
|
||||
}
|
||||
|
||||
// ExecuteUserCommand routes one authenticated user-surface command into
|
||||
// backend's `/api/v1/user/*` endpoints. The function is registered for
|
||||
// the message types listed in `galaxy/model/user`.
|
||||
func (c *RESTClient) ExecuteUserCommand(ctx context.Context, command downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
|
||||
if c == nil || c.httpClient == nil {
|
||||
return downstream.UnaryResult{}, errors.New("backendclient: execute user command: nil client")
|
||||
}
|
||||
if ctx == nil {
|
||||
return downstream.UnaryResult{}, errors.New("backendclient: execute user command: nil context")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return downstream.UnaryResult{}, err
|
||||
}
|
||||
if strings.TrimSpace(command.UserID) == "" {
|
||||
return downstream.UnaryResult{}, errors.New("backendclient: execute user command: user_id must not be empty")
|
||||
}
|
||||
|
||||
switch command.MessageType {
|
||||
case usermodel.MessageTypeGetMyAccount:
|
||||
if _, err := transcoder.PayloadToGetMyAccountRequest(command.PayloadBytes); err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute user command %q: %w", command.MessageType, err)
|
||||
}
|
||||
return c.executeUserAccountGet(ctx, command.UserID)
|
||||
case usermodel.MessageTypeUpdateMyProfile:
|
||||
req, err := transcoder.PayloadToUpdateMyProfileRequest(command.PayloadBytes)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute user command %q: %w", command.MessageType, err)
|
||||
}
|
||||
return c.executeUserAccountUpdateProfile(ctx, command.UserID, req)
|
||||
case usermodel.MessageTypeUpdateMySettings:
|
||||
req, err := transcoder.PayloadToUpdateMySettingsRequest(command.PayloadBytes)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute user command %q: %w", command.MessageType, err)
|
||||
}
|
||||
return c.executeUserAccountUpdateSettings(ctx, command.UserID, req)
|
||||
default:
|
||||
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute user command: unsupported message type %q", command.MessageType)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *RESTClient) executeUserAccountGet(ctx context.Context, userID string) (downstream.UnaryResult, error) {
|
||||
body, status, err := c.do(ctx, http.MethodGet, c.baseURL+"/api/v1/user/account", userID, nil)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute user.account.get: %w", err)
|
||||
}
|
||||
return projectUserResponse(status, body)
|
||||
}
|
||||
|
||||
func (c *RESTClient) executeUserAccountUpdateProfile(ctx context.Context, userID string, req *usermodel.UpdateMyProfileRequest) (downstream.UnaryResult, error) {
|
||||
body, status, err := c.do(ctx, http.MethodPatch, c.baseURL+"/api/v1/user/account/profile", userID, req)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute user.profile.update: %w", err)
|
||||
}
|
||||
return projectUserResponse(status, body)
|
||||
}
|
||||
|
||||
func (c *RESTClient) executeUserAccountUpdateSettings(ctx context.Context, userID string, req *usermodel.UpdateMySettingsRequest) (downstream.UnaryResult, error) {
|
||||
body, status, err := c.do(ctx, http.MethodPatch, c.baseURL+"/api/v1/user/account/settings", userID, req)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute user.settings.update: %w", err)
|
||||
}
|
||||
return projectUserResponse(status, body)
|
||||
}
|
||||
|
||||
func projectUserResponse(statusCode int, payload []byte) (downstream.UnaryResult, error) {
|
||||
switch {
|
||||
case statusCode == http.StatusOK:
|
||||
var response usermodel.AccountResponse
|
||||
if err := decodeStrictJSON(payload, &response); err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("decode success response: %w", err)
|
||||
}
|
||||
payloadBytes, err := transcoder.AccountResponseToPayload(&response)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err)
|
||||
}
|
||||
return downstream.UnaryResult{
|
||||
ResultCode: userCommandResultCodeOK,
|
||||
PayloadBytes: payloadBytes,
|
||||
}, nil
|
||||
case statusCode == http.StatusServiceUnavailable:
|
||||
return downstream.UnaryResult{}, downstream.ErrDownstreamUnavailable
|
||||
case statusCode >= 400 && statusCode <= 599:
|
||||
errResp, err := decodeUserError(statusCode, payload)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("decode error response: %w", err)
|
||||
}
|
||||
payloadBytes, err := transcoder.ErrorResponseToPayload(errResp)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("encode error response payload: %w", err)
|
||||
}
|
||||
return downstream.UnaryResult{
|
||||
ResultCode: errResp.Error.Code,
|
||||
PayloadBytes: payloadBytes,
|
||||
}, nil
|
||||
default:
|
||||
return downstream.UnaryResult{}, fmt.Errorf("unexpected HTTP status %d", statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeUserError(statusCode int, payload []byte) (*usermodel.ErrorResponse, error) {
|
||||
var response usermodel.ErrorResponse
|
||||
if err := decodeStrictJSON(payload, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.Error.Code = normalizeUserErrorCode(statusCode, response.Error.Code)
|
||||
response.Error.Message = normalizeUserErrorMessage(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 normalizeUserErrorCode(statusCode int, code string) string {
|
||||
if trimmed := strings.TrimSpace(code); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
switch statusCode {
|
||||
case http.StatusBadRequest:
|
||||
return "invalid_request"
|
||||
case http.StatusNotFound:
|
||||
return "subject_not_found"
|
||||
case http.StatusConflict:
|
||||
return "conflict"
|
||||
default:
|
||||
return defaultUserErrorCode
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeUserErrorMessage(code, message string) string {
|
||||
if trimmed := strings.TrimSpace(message); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
if stable, ok := stableUserErrorMessages[code]; ok {
|
||||
return stable
|
||||
}
|
||||
return stableUserErrorMessages[defaultUserErrorCode]
|
||||
}
|
||||
Reference in New Issue
Block a user