// 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) }