feat: runtime manager
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"galaxy/gateway/internal/authn"
|
||||
"galaxy/gateway/internal/config"
|
||||
"galaxy/gateway/internal/downstream"
|
||||
"galaxy/gateway/internal/downstream/lobbyservice"
|
||||
"galaxy/gateway/internal/downstream/userservice"
|
||||
"galaxy/gateway/internal/events"
|
||||
"galaxy/gateway/internal/grpcapi"
|
||||
@@ -207,8 +209,22 @@ func newAuthenticatedGRPCDependencies(ctx context.Context, cfg config.Config, lo
|
||||
)
|
||||
}
|
||||
|
||||
lobbyRoutes, closeLobbyServiceRoutes, err := lobbyservice.NewRoutes(cfg.LobbyService.BaseURL)
|
||||
if err != nil {
|
||||
return grpcapi.ServerDependencies{}, nil, nil, errors.Join(
|
||||
fmt.Errorf("build authenticated grpc dependencies: lobby service routes: %w", err),
|
||||
closeUserServiceRoutes(),
|
||||
closeRedisClient(),
|
||||
)
|
||||
}
|
||||
|
||||
allRoutes := make(map[string]downstream.Client, len(userRoutes)+len(lobbyRoutes))
|
||||
maps.Copy(allRoutes, userRoutes)
|
||||
maps.Copy(allRoutes, lobbyRoutes)
|
||||
|
||||
cleanup := func() error {
|
||||
return errors.Join(
|
||||
closeLobbyServiceRoutes(),
|
||||
closeUserServiceRoutes(),
|
||||
closeRedisClient(),
|
||||
)
|
||||
@@ -216,7 +232,7 @@ func newAuthenticatedGRPCDependencies(ctx context.Context, cfg config.Config, lo
|
||||
|
||||
return grpcapi.ServerDependencies{
|
||||
Service: grpcapi.NewFanOutPushStreamService(pushHub, responseSigner, nil, logger),
|
||||
Router: downstream.NewStaticRouter(userRoutes),
|
||||
Router: downstream.NewStaticRouter(allRoutes),
|
||||
ResponseSigner: responseSigner,
|
||||
SessionCache: sessionCache,
|
||||
ReplayStore: replayStore,
|
||||
|
||||
@@ -54,6 +54,11 @@ const (
|
||||
// gateway self-service delegation.
|
||||
userServiceBaseURLEnvVar = "GATEWAY_USER_SERVICE_BASE_URL"
|
||||
|
||||
// lobbyServiceBaseURLEnvVar names the environment variable that configures
|
||||
// the optional Game Lobby public HTTP base URL used by authenticated
|
||||
// gateway platform-command delegation.
|
||||
lobbyServiceBaseURLEnvVar = "GATEWAY_LOBBY_SERVICE_BASE_URL"
|
||||
|
||||
// adminHTTPAddrEnvVar names the environment variable that configures the
|
||||
// private admin HTTP listener address. When it is empty, the admin listener
|
||||
// remains disabled.
|
||||
@@ -475,6 +480,15 @@ type UserServiceConfig struct {
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
// LobbyServiceConfig describes the optional authenticated platform-command
|
||||
// upstream used by the gateway runtime.
|
||||
type LobbyServiceConfig struct {
|
||||
// BaseURL is the absolute base URL of the Game Lobby public HTTP API.
|
||||
// When BaseURL is empty, the gateway keeps using its built-in unavailable
|
||||
// downstream adapter for the reserved `lobby.*` routes.
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
// AdminHTTPConfig describes the private operational HTTP listener used for
|
||||
// metrics exposure. The listener remains disabled when Addr is empty.
|
||||
type AdminHTTPConfig struct {
|
||||
@@ -597,6 +611,10 @@ type Config struct {
|
||||
// delegation to User Service.
|
||||
UserService UserServiceConfig
|
||||
|
||||
// LobbyService configures the optional authenticated platform-command
|
||||
// delegation to Game Lobby.
|
||||
LobbyService LobbyServiceConfig
|
||||
|
||||
// AdminHTTP configures the optional private admin listener used for metrics
|
||||
// exposure.
|
||||
AdminHTTP AdminHTTPConfig
|
||||
@@ -788,6 +806,13 @@ func DefaultUserServiceConfig() UserServiceConfig {
|
||||
return UserServiceConfig{}
|
||||
}
|
||||
|
||||
// DefaultLobbyServiceConfig returns the default authenticated platform-command
|
||||
// upstream settings. The zero value keeps the built-in unavailable adapter
|
||||
// active for reserved `lobby.*` routes.
|
||||
func DefaultLobbyServiceConfig() LobbyServiceConfig {
|
||||
return LobbyServiceConfig{}
|
||||
}
|
||||
|
||||
// LoadFromEnv loads Config from the process environment, applies defaults for
|
||||
// omitted settings, and validates the resulting values.
|
||||
func LoadFromEnv() (Config, error) {
|
||||
@@ -797,6 +822,7 @@ func LoadFromEnv() (Config, error) {
|
||||
PublicHTTP: DefaultPublicHTTPConfig(),
|
||||
AuthService: DefaultAuthServiceConfig(),
|
||||
UserService: DefaultUserServiceConfig(),
|
||||
LobbyService: DefaultLobbyServiceConfig(),
|
||||
AdminHTTP: DefaultAdminHTTPConfig(),
|
||||
AuthenticatedGRPC: DefaultAuthenticatedGRPCConfig(),
|
||||
Redis: redisconn.DefaultConfig(),
|
||||
@@ -860,6 +886,11 @@ func LoadFromEnv() (Config, error) {
|
||||
cfg.UserService.BaseURL = rawUserServiceBaseURL
|
||||
}
|
||||
|
||||
rawLobbyServiceBaseURL, ok := os.LookupEnv(lobbyServiceBaseURLEnvVar)
|
||||
if ok {
|
||||
cfg.LobbyService.BaseURL = rawLobbyServiceBaseURL
|
||||
}
|
||||
|
||||
rawAdminHTTPAddr, ok := os.LookupEnv(adminHTTPAddrEnvVar)
|
||||
if ok {
|
||||
cfg.AdminHTTP.Addr = rawAdminHTTPAddr
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
// Package lobbyservice implements the authenticated Gateway -> Game Lobby
|
||||
// downstream adapter. It forwards verified authenticated commands as
|
||||
// trusted-internal HTTP requests against Game Lobby's public REST surface,
|
||||
// transporting the calling user identity through the `X-User-Id` header.
|
||||
package lobbyservice
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"galaxy/gateway/internal/downstream"
|
||||
lobbymodel "galaxy/model/lobby"
|
||||
"galaxy/transcoder"
|
||||
)
|
||||
|
||||
const (
|
||||
myGamesListPath = "/api/v1/lobby/my/games"
|
||||
openEnrollmentPathFormat = "/api/v1/lobby/games/%s/open-enrollment"
|
||||
|
||||
resultCodeOK = "ok"
|
||||
defaultErrorCodeBadRequest = "invalid_request"
|
||||
defaultErrorCodeNotFound = "subject_not_found"
|
||||
defaultErrorCodeForbidden = "forbidden"
|
||||
defaultErrorCodeConflict = "conflict"
|
||||
defaultErrorCodeInternalError = "internal_error"
|
||||
|
||||
headerCallingUserID = "X-User-Id"
|
||||
)
|
||||
|
||||
var stableErrorMessages = map[string]string{
|
||||
defaultErrorCodeBadRequest: "request is invalid",
|
||||
defaultErrorCodeNotFound: "subject not found",
|
||||
defaultErrorCodeForbidden: "operation is forbidden for the calling user",
|
||||
defaultErrorCodeConflict: "request conflicts with current state",
|
||||
defaultErrorCodeInternalError: "internal server error",
|
||||
}
|
||||
|
||||
// HTTPClient implements downstream.Client against the trusted Game Lobby
|
||||
// public REST API while preserving FlatBuffers at the external authenticated
|
||||
// gateway boundary.
|
||||
type HTTPClient struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewHTTPClient constructs one Game Lobby downstream client backed by the
|
||||
// public REST API at baseURL.
|
||||
func NewHTTPClient(baseURL string) (*HTTPClient, error) {
|
||||
transport, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
return nil, errors.New("new lobby service HTTP client: default transport is not *http.Transport")
|
||||
}
|
||||
|
||||
return newHTTPClient(baseURL, &http.Client{
|
||||
Transport: transport.Clone(),
|
||||
})
|
||||
}
|
||||
|
||||
func newHTTPClient(baseURL string, httpClient *http.Client) (*HTTPClient, error) {
|
||||
if httpClient == nil {
|
||||
return nil, errors.New("new lobby service HTTP client: http client must not be nil")
|
||||
}
|
||||
|
||||
trimmedBaseURL := strings.TrimSpace(baseURL)
|
||||
if trimmedBaseURL == "" {
|
||||
return nil, errors.New("new lobby service HTTP client: base URL must not be empty")
|
||||
}
|
||||
|
||||
parsedBaseURL, err := url.Parse(strings.TrimRight(trimmedBaseURL, "/"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new lobby service HTTP client: parse base URL: %w", err)
|
||||
}
|
||||
if parsedBaseURL.Scheme == "" || parsedBaseURL.Host == "" {
|
||||
return nil, errors.New("new lobby service HTTP client: base URL must be absolute")
|
||||
}
|
||||
|
||||
return &HTTPClient{
|
||||
baseURL: parsedBaseURL.String(),
|
||||
httpClient: httpClient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close releases idle HTTP connections owned by the client transport.
|
||||
func (c *HTTPClient) 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
|
||||
}
|
||||
|
||||
// ExecuteCommand routes one authenticated gateway command to the matching
|
||||
// trusted Game Lobby public REST route.
|
||||
func (c *HTTPClient) ExecuteCommand(ctx context.Context, command downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
|
||||
if c == nil || c.httpClient == nil {
|
||||
return downstream.UnaryResult{}, errors.New("execute lobby service command: nil client")
|
||||
}
|
||||
if ctx == nil {
|
||||
return downstream.UnaryResult{}, errors.New("execute lobby service command: nil context")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return downstream.UnaryResult{}, err
|
||||
}
|
||||
if strings.TrimSpace(command.UserID) == "" {
|
||||
return downstream.UnaryResult{}, errors.New("execute lobby service 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("execute lobby service command %q: %w", command.MessageType, err)
|
||||
}
|
||||
return c.executeMyGamesList(ctx, command.UserID)
|
||||
case lobbymodel.MessageTypeOpenEnrollment:
|
||||
request, err := transcoder.PayloadToOpenEnrollmentRequest(command.PayloadBytes)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute lobby service command %q: %w", command.MessageType, err)
|
||||
}
|
||||
return c.executeOpenEnrollment(ctx, command.UserID, request)
|
||||
default:
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute lobby service command: unsupported message type %q", command.MessageType)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *HTTPClient) executeMyGamesList(ctx context.Context, userID string) (downstream.UnaryResult, error) {
|
||||
payload, statusCode, err := c.doRequest(ctx, http.MethodGet, c.baseURL+myGamesListPath, userID, nil)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute my games list: %w", err)
|
||||
}
|
||||
|
||||
if statusCode == http.StatusOK {
|
||||
var response lobbymodel.MyGamesListResponse
|
||||
if err := decodeStrictJSONPayload(payload, &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: resultCodeOK,
|
||||
PayloadBytes: payloadBytes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return projectErrorResponse(statusCode, payload)
|
||||
}
|
||||
|
||||
func (c *HTTPClient) executeOpenEnrollment(ctx context.Context, userID string, request *lobbymodel.OpenEnrollmentRequest) (downstream.UnaryResult, error) {
|
||||
if request == nil || strings.TrimSpace(request.GameID) == "" {
|
||||
return downstream.UnaryResult{}, errors.New("execute open enrollment: game_id must not be empty")
|
||||
}
|
||||
|
||||
target := c.baseURL + fmt.Sprintf(openEnrollmentPathFormat, url.PathEscape(request.GameID))
|
||||
payload, statusCode, err := c.doRequest(ctx, http.MethodPost, target, userID, struct{}{})
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute open enrollment: %w", err)
|
||||
}
|
||||
|
||||
if statusCode == http.StatusOK {
|
||||
// Lobby's open-enrollment endpoint returns the full game record;
|
||||
// the gateway boundary projects the minimal status pair.
|
||||
var fullRecord struct {
|
||||
GameID string `json:"game_id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &fullRecord); err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("decode success response: %w", err)
|
||||
}
|
||||
payloadBytes, err := transcoder.OpenEnrollmentResponseToPayload(&lobbymodel.OpenEnrollmentResponse{
|
||||
GameID: fullRecord.GameID,
|
||||
Status: fullRecord.Status,
|
||||
})
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err)
|
||||
}
|
||||
return downstream.UnaryResult{
|
||||
ResultCode: resultCodeOK,
|
||||
PayloadBytes: payloadBytes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return projectErrorResponse(statusCode, payload)
|
||||
}
|
||||
|
||||
func (c *HTTPClient) doRequest(ctx context.Context, method, targetURL, userID string, requestBody any) ([]byte, int, error) {
|
||||
if c == nil || c.httpClient == nil {
|
||||
return nil, 0, errors.New("nil client")
|
||||
}
|
||||
|
||||
var bodyReader io.Reader
|
||||
if requestBody != nil {
|
||||
body, err := json.Marshal(requestBody)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("marshal request body: %w", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(body)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, method, targetURL, bodyReader)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
if requestBody != nil {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
request.Header.Set(headerCallingUserID, userID)
|
||||
|
||||
response, err := c.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
payload, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("read response body: %w", err)
|
||||
}
|
||||
|
||||
return payload, response.StatusCode, nil
|
||||
}
|
||||
|
||||
func projectErrorResponse(statusCode int, payload []byte) (downstream.UnaryResult, error) {
|
||||
switch {
|
||||
case statusCode == http.StatusServiceUnavailable:
|
||||
return downstream.UnaryResult{}, downstream.ErrDownstreamUnavailable
|
||||
case statusCode >= 400 && statusCode <= 599:
|
||||
errorResponse, err := decodeLobbyError(statusCode, payload)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("decode error response: %w", err)
|
||||
}
|
||||
payloadBytes, err := transcoder.LobbyErrorResponseToPayload(errorResponse)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("encode error response payload: %w", err)
|
||||
}
|
||||
return downstream.UnaryResult{
|
||||
ResultCode: errorResponse.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
|
||||
if err := decodeStrictJSONPayload(payload, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response.Error.Code = normalizeErrorCode(statusCode, response.Error.Code)
|
||||
response.Error.Message = normalizeErrorMessage(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 normalizeErrorCode(statusCode int, code string) string {
|
||||
trimmed := strings.TrimSpace(code)
|
||||
if trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
switch statusCode {
|
||||
case http.StatusBadRequest:
|
||||
return defaultErrorCodeBadRequest
|
||||
case http.StatusForbidden:
|
||||
return defaultErrorCodeForbidden
|
||||
case http.StatusNotFound:
|
||||
return defaultErrorCodeNotFound
|
||||
case http.StatusConflict:
|
||||
return defaultErrorCodeConflict
|
||||
default:
|
||||
return defaultErrorCodeInternalError
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeErrorMessage(code, message string) string {
|
||||
trimmed := strings.TrimSpace(message)
|
||||
if trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
if stable, ok := stableErrorMessages[code]; ok {
|
||||
return stable
|
||||
}
|
||||
|
||||
return stableErrorMessages[defaultErrorCodeInternalError]
|
||||
}
|
||||
|
||||
func decodeStrictJSONPayload(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
|
||||
}
|
||||
|
||||
var _ downstream.Client = (*HTTPClient)(nil)
|
||||
@@ -0,0 +1,212 @@
|
||||
package lobbyservice_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/gateway/internal/downstream"
|
||||
"galaxy/gateway/internal/downstream/lobbyservice"
|
||||
lobbymodel "galaxy/model/lobby"
|
||||
"galaxy/transcoder"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestExecuteMyGamesListSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expectedResponse := lobbymodel.MyGamesListResponse{
|
||||
Items: []lobbymodel.GameSummary{
|
||||
{
|
||||
GameID: "game-1",
|
||||
GameName: "Nebula Clash",
|
||||
GameType: "private",
|
||||
Status: "draft",
|
||||
OwnerUserID: "user-1",
|
||||
MinPlayers: 2,
|
||||
MaxPlayers: 8,
|
||||
EnrollmentEndsAt: time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC),
|
||||
CreatedAt: time.Date(2026, 4, 28, 9, 0, 0, 0, time.UTC),
|
||||
UpdatedAt: time.Date(2026, 4, 28, 9, 5, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
assert.Equal(t, "/api/v1/lobby/my/games", r.URL.Path)
|
||||
assert.Equal(t, "user-1", r.Header.Get("X-User-Id"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
require.NoError(t, json.NewEncoder(w).Encode(expectedResponse))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
client, err := lobbyservice.NewHTTPClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
|
||||
requestBytes, err := transcoder.MyGamesListRequestToPayload(&lobbymodel.MyGamesListRequest{})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
|
||||
MessageType: lobbymodel.MessageTypeMyGamesList,
|
||||
UserID: "user-1",
|
||||
PayloadBytes: requestBytes,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "ok", result.ResultCode)
|
||||
|
||||
decoded, err := transcoder.PayloadToMyGamesListResponse(result.PayloadBytes)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, decoded.Items, 1)
|
||||
assert.Equal(t, expectedResponse.Items[0].GameID, decoded.Items[0].GameID)
|
||||
assert.Equal(t, expectedResponse.Items[0].OwnerUserID, decoded.Items[0].OwnerUserID)
|
||||
assert.Equal(t, expectedResponse.Items[0].MinPlayers, decoded.Items[0].MinPlayers)
|
||||
}
|
||||
|
||||
func TestExecuteOpenEnrollmentSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "/api/v1/lobby/games/game-77/open-enrollment", r.URL.Path)
|
||||
assert.Equal(t, "owner-1", r.Header.Get("X-User-Id"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{
|
||||
"game_id": "game-77",
|
||||
"status": "enrollment_open",
|
||||
}))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
client, err := lobbyservice.NewHTTPClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
|
||||
requestBytes, err := transcoder.OpenEnrollmentRequestToPayload(&lobbymodel.OpenEnrollmentRequest{GameID: "game-77"})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
|
||||
MessageType: lobbymodel.MessageTypeOpenEnrollment,
|
||||
UserID: "owner-1",
|
||||
PayloadBytes: requestBytes,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "ok", result.ResultCode)
|
||||
|
||||
decoded, err := transcoder.PayloadToOpenEnrollmentResponse(result.PayloadBytes)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "game-77", decoded.GameID)
|
||||
assert.Equal(t, "enrollment_open", decoded.Status)
|
||||
}
|
||||
|
||||
func TestExecuteOpenEnrollmentForbiddenProjectsErrorEnvelope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{
|
||||
"error": map[string]string{
|
||||
"code": "forbidden",
|
||||
"message": "only the game owner may open enrollment",
|
||||
},
|
||||
}))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
client, err := lobbyservice.NewHTTPClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
|
||||
requestBytes, err := transcoder.OpenEnrollmentRequestToPayload(&lobbymodel.OpenEnrollmentRequest{GameID: "game-77"})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
|
||||
MessageType: lobbymodel.MessageTypeOpenEnrollment,
|
||||
UserID: "non-owner",
|
||||
PayloadBytes: requestBytes,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "forbidden", result.ResultCode)
|
||||
|
||||
decoded, err := transcoder.PayloadToLobbyErrorResponse(result.PayloadBytes)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "forbidden", decoded.Error.Code)
|
||||
assert.NotEmpty(t, decoded.Error.Message)
|
||||
}
|
||||
|
||||
func TestExecuteCommandUnavailableProjectsErrUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
client, err := lobbyservice.NewHTTPClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
|
||||
requestBytes, err := transcoder.MyGamesListRequestToPayload(&lobbymodel.MyGamesListRequest{})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
|
||||
MessageType: lobbymodel.MessageTypeMyGamesList,
|
||||
UserID: "user-1",
|
||||
PayloadBytes: requestBytes,
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, downstream.ErrDownstreamUnavailable))
|
||||
}
|
||||
|
||||
func TestExecuteCommandRejectsEmptyUserID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, err := lobbyservice.NewHTTPClient("http://127.0.0.1:1")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
|
||||
requestBytes, err := transcoder.MyGamesListRequestToPayload(&lobbymodel.MyGamesListRequest{})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
|
||||
MessageType: lobbymodel.MessageTypeMyGamesList,
|
||||
UserID: "",
|
||||
PayloadBytes: requestBytes,
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, strings.Contains(err.Error(), "user_id"), "error must mention user_id; got %q", err.Error())
|
||||
}
|
||||
|
||||
func TestNewRoutesReservesUnavailableClientWhenBaseURLEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
routes, closeFn, err := lobbyservice.NewRoutes("")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, closeFn()) })
|
||||
|
||||
require.Contains(t, routes, lobbymodel.MessageTypeMyGamesList)
|
||||
require.Contains(t, routes, lobbymodel.MessageTypeOpenEnrollment)
|
||||
|
||||
requestBytes, err := transcoder.MyGamesListRequestToPayload(&lobbymodel.MyGamesListRequest{})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = routes[lobbymodel.MessageTypeMyGamesList].ExecuteCommand(
|
||||
context.Background(),
|
||||
downstream.AuthenticatedCommand{
|
||||
MessageType: lobbymodel.MessageTypeMyGamesList,
|
||||
UserID: "user-1",
|
||||
PayloadBytes: requestBytes,
|
||||
},
|
||||
)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, downstream.ErrDownstreamUnavailable))
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package lobbyservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"galaxy/gateway/internal/downstream"
|
||||
lobbymodel "galaxy/model/lobby"
|
||||
)
|
||||
|
||||
var noOpClose = func() error { return nil }
|
||||
|
||||
// NewRoutes returns the reserved authenticated gateway routes owned by
|
||||
// the Gateway -> Game Lobby boundary.
|
||||
//
|
||||
// When baseURL is empty, the returned routes still reserve the stable
|
||||
// `lobby.*` message types but resolve them to a dependency-unavailable
|
||||
// client so callers receive the transport-level unavailable outcome
|
||||
// instead of a route-miss error.
|
||||
func NewRoutes(baseURL string) (map[string]downstream.Client, func() error, error) {
|
||||
client := downstream.Client(unavailableClient{})
|
||||
closeFn := noOpClose
|
||||
|
||||
if baseURL != "" {
|
||||
httpClient, err := NewHTTPClient(baseURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
client = httpClient
|
||||
closeFn = httpClient.Close
|
||||
}
|
||||
|
||||
return map[string]downstream.Client{
|
||||
lobbymodel.MessageTypeMyGamesList: client,
|
||||
lobbymodel.MessageTypeOpenEnrollment: client,
|
||||
}, closeFn, nil
|
||||
}
|
||||
|
||||
type unavailableClient struct{}
|
||||
|
||||
func (unavailableClient) ExecuteCommand(context.Context, downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
|
||||
return downstream.UnaryResult{}, downstream.ErrDownstreamUnavailable
|
||||
}
|
||||
|
||||
var _ downstream.Client = unavailableClient{}
|
||||
Reference in New Issue
Block a user