feat: backend service

This commit is contained in:
Ilia Denisov
2026-05-06 10:14:55 +03:00
committed by GitHub
parent 3e2622757e
commit f446c6a2ac
1486 changed files with 49720 additions and 266401 deletions
+138
View File
@@ -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
}
+18
View File
@@ -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)
}
}
+256
View File
@@ -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
}
+190
View File
@@ -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
+67
View File
@@ -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]
}