Stage 1: backend foundation (Postgres, sessions, accounts, OTel)
- internal/postgres: pgx-over-database/sql pool (otelsql), embedded goose
migrations into schema 'backend', committed go-jet code + cmd/jetgen tool.
- internal/account: durable accounts + unified telegram/email identities
(UUIDv7 keys), find-or-create provisioning with unique-conflict handling.
- internal/session: opaque 256-bit tokens stored as a SHA-256 hash, revoke-only
(no TTL); write-through cache gating /readyz; store + service.
- internal/telemetry: OTel tracer/meter providers (none/stdout) + request-timing
middleware; internal/config gains Postgres + OTel env loading.
- internal/server: /api/v1 {public,user,internal,admin} skeleton + X-User-ID
middleware; /readyz checks DB ping + cache; main wires
telemetry -> db+migrate -> warm cache -> server.
- Tests: unit + integration (build tag 'integration', testcontainers
postgres:17) for migrations, accounts, sessions, readyz; new integration.yaml.
- Docs: ARCHITECTURE, TESTING, PLAN refinements, root + backend READMEs.
Session/account REST handlers deferred to Stage 6 (gateway); OTLP + dashboards
to Stage 11.
This commit is contained in:
@@ -1,53 +1,146 @@
|
||||
// Package server wires the backend's HTTP listener: the gin engine, its route
|
||||
// groups and the start/stop lifecycle. At this stage it serves only the
|
||||
// infrastructure probes; the domain route groups described in PLAN.md
|
||||
// (/api/v1/public, /user, /internal, /admin) are added by later stages.
|
||||
// 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/telemetry"
|
||||
)
|
||||
|
||||
// shutdownTimeout bounds how long Run waits for in-flight requests to finish
|
||||
// during a graceful shutdown.
|
||||
const shutdownTimeout = 10 * time.Second
|
||||
|
||||
// Server owns the gin engine and the underlying HTTP server.
|
||||
type Server struct {
|
||||
log *zap.Logger
|
||||
http *http.Server
|
||||
// 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
|
||||
}
|
||||
|
||||
// New returns a Server that will listen on addr. The logger receives lifecycle
|
||||
// and request diagnostics.
|
||||
func New(addr string, log *zap.Logger) *Server {
|
||||
// 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
|
||||
|
||||
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())
|
||||
registerProbes(engine)
|
||||
engine.Use(telemetry.Middleware(log))
|
||||
|
||||
return &Server{
|
||||
log: log,
|
||||
http: &http.Server{Addr: addr, Handler: engine},
|
||||
s := &Server{
|
||||
log: log,
|
||||
db: deps.DB,
|
||||
pingTimeout: pingTimeout,
|
||||
sessionsReady: deps.SessionsReady,
|
||||
http: &http.Server{Addr: addr, Handler: engine},
|
||||
}
|
||||
s.registerProbes(engine)
|
||||
s.registerAPIGroups(engine)
|
||||
return s
|
||||
}
|
||||
|
||||
// registerProbes installs the unauthenticated infrastructure probes: /healthz
|
||||
// reports process liveness and /readyz reports readiness to serve traffic.
|
||||
// Until later stages add real dependencies (Postgres, warmed caches),
|
||||
// readiness mirrors liveness.
|
||||
func registerProbes(engine *gin.Engine) {
|
||||
ok := func(c *gin.Context) { c.String(http.StatusOK, "ok") }
|
||||
engine.GET("/healthz", ok)
|
||||
engine.GET("/readyz", ok)
|
||||
// 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 }
|
||||
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user