feat: game lobby service

This commit is contained in:
Ilia Denisov
2026-04-25 23:20:55 +02:00
committed by GitHub
parent 32dc29359a
commit 48b0056b49
336 changed files with 57074 additions and 1418 deletions
+409
View File
@@ -0,0 +1,409 @@
// Package publichttp provides the public authenticated HTTP listener used by
// the runnable Game Lobby Service process. In the runnable
// skeleton it exposes only the platform liveness and readiness probes; later
// stages add player-facing routes.
package publichttp
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"strconv"
"sync"
"time"
"galaxy/lobby/internal/api/httpcommon"
"galaxy/lobby/internal/service/approveapplication"
"galaxy/lobby/internal/service/blockmember"
"galaxy/lobby/internal/service/cancelgame"
"galaxy/lobby/internal/service/createinvite"
"galaxy/lobby/internal/service/creategame"
"galaxy/lobby/internal/service/declineinvite"
"galaxy/lobby/internal/service/getgame"
"galaxy/lobby/internal/service/listgames"
"galaxy/lobby/internal/service/listmemberships"
"galaxy/lobby/internal/service/listmyapplications"
"galaxy/lobby/internal/service/listmygames"
"galaxy/lobby/internal/service/listmyinvites"
"galaxy/lobby/internal/service/listmyracenames"
"galaxy/lobby/internal/service/manualreadytostart"
"galaxy/lobby/internal/service/openenrollment"
"galaxy/lobby/internal/service/pausegame"
"galaxy/lobby/internal/service/redeeminvite"
"galaxy/lobby/internal/service/registerracename"
"galaxy/lobby/internal/service/rejectapplication"
"galaxy/lobby/internal/service/removemember"
"galaxy/lobby/internal/service/resumegame"
"galaxy/lobby/internal/service/retrystartgame"
"galaxy/lobby/internal/service/revokeinvite"
"galaxy/lobby/internal/service/startgame"
"galaxy/lobby/internal/service/submitapplication"
"galaxy/lobby/internal/service/updategame"
"galaxy/lobby/internal/telemetry"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/otel/attribute"
)
const jsonContentType = "application/json; charset=utf-8"
const (
// HealthzPath is the public liveness probe route.
HealthzPath = "/healthz"
// ReadyzPath is the public readiness probe route.
ReadyzPath = "/readyz"
)
// Config describes the public authenticated HTTP listener owned by
// Game Lobby Service.
type Config struct {
// Addr is the TCP listen address used by the public HTTP server.
Addr string
// ReadHeaderTimeout bounds how long the listener may spend reading request
// headers before the server rejects the connection.
ReadHeaderTimeout time.Duration
// ReadTimeout bounds how long the listener may spend reading one request.
ReadTimeout time.Duration
// IdleTimeout bounds how long the listener keeps an idle keep-alive
// connection open.
IdleTimeout time.Duration
}
// Validate reports whether cfg contains a usable public HTTP listener
// configuration.
func (cfg Config) Validate() error {
switch {
case cfg.Addr == "":
return errors.New("public HTTP addr must not be empty")
case cfg.ReadHeaderTimeout <= 0:
return errors.New("public HTTP read header timeout must be positive")
case cfg.ReadTimeout <= 0:
return errors.New("public HTTP read timeout must be positive")
case cfg.IdleTimeout <= 0:
return errors.New("public HTTP idle timeout must be positive")
default:
return nil
}
}
// Dependencies describes the collaborators used by the public HTTP transport
// layer.
type Dependencies struct {
// Logger writes structured listener lifecycle logs. When nil,
// slog.Default is used.
Logger *slog.Logger
// Telemetry records low-cardinality probe metrics and lifecycle events.
Telemetry *telemetry.Runtime
// CreateGame handles the `lobby.game.create` message type. A nil value
// makes the corresponding route return `internal_error`; tests that do
// not exercise the route may leave it nil.
CreateGame *creategame.Service
// UpdateGame handles the `lobby.game.update` message type.
UpdateGame *updategame.Service
// OpenEnrollment handles the `lobby.game.open_enrollment` message type.
OpenEnrollment *openenrollment.Service
// CancelGame handles the `lobby.game.cancel` message type.
CancelGame *cancelgame.Service
// ManualReadyToStart handles the `lobby.game.ready_to_start` message
// type — manual close of enrollment with cascading invite expiry.
ManualReadyToStart *manualreadytostart.Service
// StartGame handles the `lobby.game.start` message type.
StartGame *startgame.Service
// RetryStartGame handles the `lobby.game.retry_start` message type
//.
RetryStartGame *retrystartgame.Service
// PauseGame handles the `lobby.game.pause` message type.
PauseGame *pausegame.Service
// ResumeGame handles the `lobby.game.resume` message type
//.
ResumeGame *resumegame.Service
// SubmitApplication handles the `lobby.application.submit` message
// type. Wired on the public port only.
SubmitApplication *submitapplication.Service
// ApproveApplication handles the `lobby.application.approve` message
// type. Wired on the public port for OpenAPI parity; the public
// route always returns 403 because UserActor is not admin.
ApproveApplication *approveapplication.Service
// RejectApplication handles the `lobby.application.reject` message
// type. Same parity rule as ApproveApplication.
RejectApplication *rejectapplication.Service
// CreateInvite handles the `lobby.invite.create` message type.
CreateInvite *createinvite.Service
// RedeemInvite handles the `lobby.invite.redeem` message type.
RedeemInvite *redeeminvite.Service
// DeclineInvite handles the `lobby.invite.decline` message type.
DeclineInvite *declineinvite.Service
// RevokeInvite handles the `lobby.invite.revoke` message type.
RevokeInvite *revokeinvite.Service
// RemoveMember handles the `lobby.membership.remove` message type
//.
RemoveMember *removemember.Service
// BlockMember handles the `lobby.membership.block` message type
//.
BlockMember *blockmember.Service
// RegisterRaceName handles the `lobby.race_name.register` message
// type.
RegisterRaceName *registerracename.Service
// ListMyRaceNames handles the `lobby.race_names.list` message type
//. The service returns the acting user's three RND
// views in one response.
ListMyRaceNames *listmyracenames.Service
// GetGame handles the `lobby.game.get` message type.
GetGame *getgame.Service
// ListGames handles the `lobby.games.list` message type.
ListGames *listgames.Service
// ListMemberships handles the `lobby.memberships.list` message type
//.
ListMemberships *listmemberships.Service
// ListMyGames handles the `lobby.my_games.list` message type
//.
ListMyGames *listmygames.Service
// ListMyApplications handles the `lobby.my_applications.list`
// message type.
ListMyApplications *listmyapplications.Service
// ListMyInvites handles the `lobby.my_invites.list` message type
//.
ListMyInvites *listmyinvites.Service
}
// Server owns the public authenticated HTTP listener exposed by
// Game Lobby Service.
type Server struct {
cfg Config
handler http.Handler
logger *slog.Logger
metrics *telemetry.Runtime
stateMu sync.RWMutex
server *http.Server
listener net.Listener
}
// NewServer constructs one public authenticated HTTP server for cfg and deps.
func NewServer(cfg Config, deps Dependencies) (*Server, error) {
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("new public HTTP server: %w", err)
}
logger := deps.Logger
if logger == nil {
logger = slog.Default()
}
return &Server{
cfg: cfg,
handler: newHandler(deps, logger),
logger: logger.With("component", "public_http"),
metrics: deps.Telemetry,
}, nil
}
// Addr returns the currently bound listener address after Run is called. It
// returns an empty string if the server has not yet bound a listener.
func (server *Server) Addr() string {
server.stateMu.RLock()
defer server.stateMu.RUnlock()
if server.listener == nil {
return ""
}
return server.listener.Addr().String()
}
// Run binds the configured listener and serves the public HTTP surface until
// Shutdown closes the server.
func (server *Server) Run(ctx context.Context) error {
if ctx == nil {
return errors.New("run public HTTP server: nil context")
}
if err := ctx.Err(); err != nil {
return err
}
listener, err := net.Listen("tcp", server.cfg.Addr)
if err != nil {
return fmt.Errorf("run public HTTP server: listen on %q: %w", server.cfg.Addr, err)
}
httpServer := &http.Server{
Handler: server.handler,
ReadHeaderTimeout: server.cfg.ReadHeaderTimeout,
ReadTimeout: server.cfg.ReadTimeout,
IdleTimeout: server.cfg.IdleTimeout,
}
server.stateMu.Lock()
server.server = httpServer
server.listener = listener
server.stateMu.Unlock()
server.logger.Info("public HTTP server started", "addr", listener.Addr().String())
defer func() {
server.stateMu.Lock()
server.server = nil
server.listener = nil
server.stateMu.Unlock()
}()
err = httpServer.Serve(listener)
switch {
case err == nil:
return nil
case errors.Is(err, http.ErrServerClosed):
server.logger.Info("public HTTP server stopped")
return nil
default:
return fmt.Errorf("run public HTTP server: serve on %q: %w", server.cfg.Addr, err)
}
}
// Shutdown gracefully stops the public HTTP server within ctx.
func (server *Server) Shutdown(ctx context.Context) error {
if ctx == nil {
return errors.New("shutdown public HTTP server: nil context")
}
server.stateMu.RLock()
httpServer := server.server
server.stateMu.RUnlock()
if httpServer == nil {
return nil
}
if err := httpServer.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("shutdown public HTTP server: %w", err)
}
return nil
}
func newHandler(deps Dependencies, logger *slog.Logger) http.Handler {
if logger == nil {
logger = slog.Default()
}
mux := http.NewServeMux()
mux.HandleFunc("GET "+HealthzPath, handleHealthz)
mux.HandleFunc("GET "+ReadyzPath, handleReadyz)
registerGameRoutes(mux, deps, logger)
registerApplicationRoutes(mux, deps, logger)
registerInviteRoutes(mux, deps, logger)
registerReadyToStartRoutes(mux, deps, logger)
registerStartRoutes(mux, deps, logger)
registerPauseResumeRoutes(mux, deps, logger)
registerMembershipRoutes(mux, deps, logger)
registerRaceNameRoutes(mux, deps, logger)
registerMyListRoutes(mux, deps, logger)
metrics := deps.Telemetry
options := []otelhttp.Option{}
if metrics != nil {
options = append(options,
otelhttp.WithTracerProvider(metrics.TracerProvider()),
otelhttp.WithMeterProvider(metrics.MeterProvider()),
)
}
observable := otelhttp.NewHandler(withObservability(mux, metrics), "lobby.public_http", options...)
return httpcommon.RequestID(observable)
}
func withObservability(next http.Handler, metrics *telemetry.Runtime) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
startedAt := time.Now()
recorder := &statusRecorder{
ResponseWriter: writer,
statusCode: http.StatusOK,
}
next.ServeHTTP(recorder, request)
route := request.Pattern
switch recorder.statusCode {
case http.StatusMethodNotAllowed:
route = "method_not_allowed"
case http.StatusNotFound:
route = "not_found"
case 0:
route = "unmatched"
}
if route == "" {
route = "unmatched"
}
metrics.RecordPublicHTTPRequest(
request.Context(),
[]attribute.KeyValue{
attribute.String("route", route),
attribute.String("method", request.Method),
attribute.String("status_code", strconv.Itoa(recorder.statusCode)),
},
time.Since(startedAt),
)
})
}
func handleHealthz(writer http.ResponseWriter, _ *http.Request) {
writeStatusResponse(writer, http.StatusOK, "ok")
}
func handleReadyz(writer http.ResponseWriter, _ *http.Request) {
writeStatusResponse(writer, http.StatusOK, "ready")
}
func writeStatusResponse(writer http.ResponseWriter, statusCode int, status string) {
writer.Header().Set("Content-Type", jsonContentType)
writer.WriteHeader(statusCode)
_ = json.NewEncoder(writer).Encode(statusResponse{Status: status})
}
type statusResponse struct {
Status string `json:"status"`
}
type statusRecorder struct {
http.ResponseWriter
statusCode int
}
func (recorder *statusRecorder) WriteHeader(statusCode int) {
recorder.statusCode = statusCode
recorder.ResponseWriter.WriteHeader(statusCode)
}