// Package server wires the backend's HTTP listener: the gin engine, its route // groups, the per-request telemetry middleware and the start/stop lifecycle. // // The /api/v1 route groups (public, user, internal, admin) are created here so // later stages attach their endpoints to a stable structure; the /user group // requires the X-User-ID identity header. The probes /healthz (liveness) and // /readyz (database + session-cache readiness) are unauthenticated. package server import ( "context" "database/sql" "errors" "net/http" "time" "github.com/gin-gonic/gin" "go.uber.org/zap" "scrabble/backend/internal/account" "scrabble/backend/internal/game" "scrabble/backend/internal/lobby" "scrabble/backend/internal/session" "scrabble/backend/internal/social" "scrabble/backend/internal/telemetry" ) // shutdownTimeout bounds how long Run waits for in-flight requests to finish // during a graceful shutdown. const shutdownTimeout = 10 * time.Second // defaultPingTimeout bounds the /readyz database ping when Deps.PingTimeout is // not set. const defaultPingTimeout = 5 * time.Second // Deps carries the runtime dependencies the HTTP layer needs. type Deps struct { // Logger receives lifecycle, request and readiness diagnostics. Logger *zap.Logger // DB backs the /readyz database ping. A nil DB skips the database check. DB *sql.DB // PingTimeout bounds the /readyz database ping. PingTimeout time.Duration // SessionsReady reports whether the session cache has been warmed. A nil // func skips the session-readiness check. SessionsReady func() bool // Sessions, Accounts and Games are the identity, account and game-domain // services the Stage 6 REST handlers route to. Sessions *session.Service Accounts *account.Store Games *game.Service // Social, Matchmaker, Invitations and Emails are the Stage 4 domain services // the Stage 6 REST handlers route to. Social *social.Service Matchmaker *lobby.Matchmaker Invitations *lobby.InvitationService Emails *account.EmailService } // Server owns the gin engine, the underlying HTTP server and the readiness // dependencies. type Server struct { log *zap.Logger http *http.Server db *sql.DB pingTimeout time.Duration sessionsReady func() bool sessions *session.Service accounts *account.Store games *game.Service social *social.Service matchmaker *lobby.Matchmaker invitations *lobby.InvitationService emails *account.EmailService public *gin.RouterGroup user *gin.RouterGroup internal *gin.RouterGroup admin *gin.RouterGroup } // New returns a Server that will listen on addr. It installs the recovery and // telemetry middleware, the infrastructure probes, and the /api/v1 route groups. func New(addr string, deps Deps) *Server { log := deps.Logger if log == nil { log = zap.NewNop() } pingTimeout := deps.PingTimeout if pingTimeout <= 0 { pingTimeout = defaultPingTimeout } gin.SetMode(gin.ReleaseMode) engine := gin.New() engine.Use(gin.Recovery()) engine.Use(telemetry.Middleware(log)) s := &Server{ log: log, db: deps.DB, pingTimeout: pingTimeout, sessionsReady: deps.SessionsReady, sessions: deps.Sessions, accounts: deps.Accounts, games: deps.Games, social: deps.Social, matchmaker: deps.Matchmaker, invitations: deps.Invitations, emails: deps.Emails, http: &http.Server{Addr: addr, Handler: engine}, } s.registerProbes(engine) s.registerAPIGroups(engine) s.registerRoutes() return s } // registerProbes installs the unauthenticated infrastructure probes: /healthz // reports process liveness and /readyz reports readiness to serve traffic // (database reachable and session cache warmed). func (s *Server) registerProbes(engine *gin.Engine) { engine.GET("/healthz", func(c *gin.Context) { c.String(http.StatusOK, "ok") }) engine.GET("/readyz", s.readyz) } // readyz reports 200 only when the database answers a bounded ping and the // session cache is warmed; otherwise 503. func (s *Server) readyz(c *gin.Context) { if s.db != nil { ctx, cancel := context.WithTimeout(c.Request.Context(), s.pingTimeout) defer cancel() if err := s.db.PingContext(ctx); err != nil { s.log.Warn("readiness: database ping failed", zap.Error(err)) c.String(http.StatusServiceUnavailable, "database unavailable") return } } if s.sessionsReady != nil && !s.sessionsReady() { c.String(http.StatusServiceUnavailable, "sessions not ready") return } c.String(http.StatusOK, "ok") } // registerAPIGroups wires the /api/v1 route groups. They are populated by the // stages that add their first endpoint; the /user group requires X-User-ID, // which the gateway injects after resolving a session. func (s *Server) registerAPIGroups(engine *gin.Engine) { v1 := engine.Group("/api/v1") s.public = v1.Group("/public") s.user = v1.Group("/user") s.user.Use(RequireUserID()) s.internal = v1.Group("/internal") s.admin = v1.Group("/admin") } // PublicGroup returns the unauthenticated public route group. func (s *Server) PublicGroup() *gin.RouterGroup { return s.public } // UserGroup returns the authenticated user route group (requires X-User-ID). func (s *Server) UserGroup() *gin.RouterGroup { return s.user } // InternalGroup returns the gateway-facing internal route group. func (s *Server) InternalGroup() *gin.RouterGroup { return s.internal } // AdminGroup returns the admin route group (authenticated at the gateway). func (s *Server) AdminGroup() *gin.RouterGroup { return s.admin } // Social returns the social domain service for the handlers added in Stage 6. func (s *Server) Social() *social.Service { return s.social } // Matchmaker returns the in-memory matchmaking pool for the Stage 6 handlers. func (s *Server) Matchmaker() *lobby.Matchmaker { return s.matchmaker } // Invitations returns the friend-game invitation service for the Stage 6 handlers. func (s *Server) Invitations() *lobby.InvitationService { return s.invitations } // Emails returns the email confirm-code service for the Stage 6 handlers. func (s *Server) Emails() *account.EmailService { return s.emails } // Handler returns the underlying HTTP handler. It lets tests drive the server // without binding a socket and lets later stages compose the backend behind // another listener. func (s *Server) Handler() http.Handler { return s.http.Handler } // Run starts the listener and blocks until ctx is cancelled, then shuts the // server down gracefully within shutdownTimeout. It returns the first error // that is not the expected http.ErrServerClosed. func (s *Server) Run(ctx context.Context) error { errc := make(chan error, 1) go func() { s.log.Info("http listener starting", zap.String("addr", s.http.Addr)) errc <- s.http.ListenAndServe() }() select { case err := <-errc: if errors.Is(err, http.ErrServerClosed) { return nil } return err case <-ctx.Done(): s.log.Info("http listener stopping") shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) defer cancel() return s.http.Shutdown(shutdownCtx) } }