feat: game lobby service
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
// Package internalhttp provides the trusted internal 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 Game Master registration and admin routes.
|
||||
package internalhttp
|
||||
|
||||
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/creategame"
|
||||
"galaxy/lobby/internal/service/getgame"
|
||||
"galaxy/lobby/internal/service/listgames"
|
||||
"galaxy/lobby/internal/service/listmemberships"
|
||||
"galaxy/lobby/internal/service/manualreadytostart"
|
||||
"galaxy/lobby/internal/service/openenrollment"
|
||||
"galaxy/lobby/internal/service/pausegame"
|
||||
"galaxy/lobby/internal/service/rejectapplication"
|
||||
"galaxy/lobby/internal/service/removemember"
|
||||
"galaxy/lobby/internal/service/resumegame"
|
||||
"galaxy/lobby/internal/service/retrystartgame"
|
||||
"galaxy/lobby/internal/service/startgame"
|
||||
"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 internal liveness probe route.
|
||||
HealthzPath = "/healthz"
|
||||
|
||||
// ReadyzPath is the internal readiness probe route.
|
||||
ReadyzPath = "/readyz"
|
||||
)
|
||||
|
||||
// Config describes the trusted internal HTTP listener owned by
|
||||
// Game Lobby Service.
|
||||
type Config struct {
|
||||
// Addr is the TCP listen address used by the internal 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 internal HTTP listener
|
||||
// configuration.
|
||||
func (cfg Config) Validate() error {
|
||||
switch {
|
||||
case cfg.Addr == "":
|
||||
return errors.New("internal HTTP addr must not be empty")
|
||||
case cfg.ReadHeaderTimeout <= 0:
|
||||
return errors.New("internal HTTP read header timeout must be positive")
|
||||
case cfg.ReadTimeout <= 0:
|
||||
return errors.New("internal HTTP read timeout must be positive")
|
||||
case cfg.IdleTimeout <= 0:
|
||||
return errors.New("internal HTTP idle timeout must be positive")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Dependencies describes the collaborators used by the internal 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 admin-initiated `lobby.game.create` calls routed
|
||||
// through Admin Service. 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 admin-initiated `lobby.game.update` calls.
|
||||
UpdateGame *updategame.Service
|
||||
|
||||
// OpenEnrollment handles admin-initiated `lobby.game.open_enrollment`
|
||||
// calls.
|
||||
OpenEnrollment *openenrollment.Service
|
||||
|
||||
// CancelGame handles admin-initiated `lobby.game.cancel` calls.
|
||||
CancelGame *cancelgame.Service
|
||||
|
||||
// ManualReadyToStart handles admin-initiated
|
||||
// `lobby.game.ready_to_start` calls.
|
||||
ManualReadyToStart *manualreadytostart.Service
|
||||
|
||||
// StartGame handles admin-initiated `lobby.game.start` calls
|
||||
//.
|
||||
StartGame *startgame.Service
|
||||
|
||||
// RetryStartGame handles admin-initiated `lobby.game.retry_start`
|
||||
// calls.
|
||||
RetryStartGame *retrystartgame.Service
|
||||
|
||||
// PauseGame handles admin-initiated `lobby.game.pause` calls
|
||||
//.
|
||||
PauseGame *pausegame.Service
|
||||
|
||||
// ResumeGame handles admin-initiated `lobby.game.resume` calls
|
||||
//.
|
||||
ResumeGame *resumegame.Service
|
||||
|
||||
// ApproveApplication handles admin-initiated
|
||||
// `lobby.application.approve` calls. Wired on the internal port for
|
||||
// Admin Service routing.
|
||||
ApproveApplication *approveapplication.Service
|
||||
|
||||
// RejectApplication handles admin-initiated
|
||||
// `lobby.application.reject` calls.
|
||||
RejectApplication *rejectapplication.Service
|
||||
|
||||
// RemoveMember handles admin-initiated `lobby.membership.remove`
|
||||
// calls.
|
||||
RemoveMember *removemember.Service
|
||||
|
||||
// BlockMember handles admin-initiated `lobby.membership.block`
|
||||
// calls.
|
||||
BlockMember *blockmember.Service
|
||||
|
||||
// GetGame handles `internalGetGame` and `adminGetGame` reads
|
||||
//. The handler always passes shared.NewAdminActor() so
|
||||
// the response is unrestricted by visibility rules.
|
||||
GetGame *getgame.Service
|
||||
|
||||
// ListGames handles `adminListGames`. The handler
|
||||
// always passes shared.NewAdminActor() so every status is included.
|
||||
ListGames *listgames.Service
|
||||
|
||||
// ListMemberships handles `internalListMemberships` and
|
||||
// `adminListMemberships` reads. The handler always
|
||||
// passes shared.NewAdminActor() so every membership is returned.
|
||||
ListMemberships *listmemberships.Service
|
||||
}
|
||||
|
||||
// Server owns the trusted internal 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 trusted internal 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 internal 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", "internal_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 internal HTTP surface
|
||||
// until Shutdown closes the server.
|
||||
func (server *Server) Run(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("run internal 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 internal 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("internal 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("internal HTTP server stopped")
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("run internal HTTP server: serve on %q: %w", server.cfg.Addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown gracefully stops the internal HTTP server within ctx.
|
||||
func (server *Server) Shutdown(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("shutdown internal 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 internal 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)
|
||||
registerReadyToStartRoutes(mux, deps, logger)
|
||||
registerStartRoutes(mux, deps, logger)
|
||||
registerPauseResumeRoutes(mux, deps, logger)
|
||||
registerMembershipRoutes(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.internal_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.RecordInternalHTTPRequest(
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user