Stage 1: backend foundation (Postgres, sessions, accounts, OTel)
Tests · Go / test (push) Successful in 11s
Tests · Integration / integration (push) Successful in 8s

- 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:
Ilia Denisov
2026-06-02 13:52:26 +02:00
parent da079b2bc6
commit eeaad62b10
45 changed files with 3461 additions and 92 deletions
+41
View File
@@ -0,0 +1,41 @@
package server
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// headerUserID is the identity header the gateway injects after resolving a
// session to an internal account.
const headerUserID = "X-User-ID"
// contextKey is an unexported type for request-context keys set by this package.
type contextKey string
const userIDContextKey contextKey = "scrabble.user_id"
// RequireUserID returns middleware that requires a valid X-User-ID header and
// stores the parsed account id in the request context. Requests without a
// parseable UUID are rejected with 401. The backend treats X-User-ID as the
// sole identity input and never derives identity from the request body.
func RequireUserID() gin.HandlerFunc {
return func(c *gin.Context) {
id, err := uuid.Parse(c.GetHeader(headerUserID))
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing or invalid X-User-ID"})
return
}
c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), userIDContextKey, id))
c.Next()
}
}
// UserIDFromContext returns the authenticated account id stored by
// RequireUserID, and whether it was present.
func UserIDFromContext(ctx context.Context) (uuid.UUID, bool) {
id, ok := ctx.Value(userIDContextKey).(uuid.UUID)
return id, ok
}
@@ -0,0 +1,60 @@
package server
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// TestRequireUserID checks that the middleware accepts a valid X-User-ID,
// exposes it through the request context, and rejects missing or malformed
// headers with 401.
func TestRequireUserID(t *testing.T) {
gin.SetMode(gin.TestMode)
var seen uuid.UUID
var ok bool
r := gin.New()
r.Use(RequireUserID())
r.GET("/x", func(c *gin.Context) {
seen, ok = UserIDFromContext(c.Request.Context())
c.String(http.StatusOK, "ok")
})
t.Run("valid", func(t *testing.T) {
seen, ok = uuid.Nil, false
id := uuid.New()
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.Header.Set("X-User-ID", id.String())
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if !ok || seen != id {
t.Fatalf("context id = %s (ok=%v), want %s", seen, ok, id)
}
})
t.Run("missing", func(t *testing.T) {
rec := httptest.NewRecorder()
r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/x", nil))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
})
t.Run("malformed", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.Header.Set("X-User-ID", "not-a-uuid")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
})
}
+114 -21
View File
@@ -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.
+46 -10
View File
@@ -8,15 +8,51 @@ import (
"go.uber.org/zap/zaptest"
)
// TestProbes verifies that the infrastructure probes answer 200 OK.
func TestProbes(t *testing.T) {
srv := New(":0", zaptest.NewLogger(t))
for _, path := range []string{"/healthz", "/readyz"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
srv.http.Handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s: status = %d, want %d", path, rec.Code, http.StatusOK)
}
// get serves a GET request against the server's handler and returns the recorder.
func get(srv *Server, path string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
return rec
}
// TestHealthz verifies that /healthz answers 200 OK.
func TestHealthz(t *testing.T) {
srv := New(":0", Deps{Logger: zaptest.NewLogger(t)})
if rec := get(srv, "/healthz"); rec.Code != http.StatusOK {
t.Fatalf("/healthz status = %d, want %d", rec.Code, http.StatusOK)
}
}
// TestReadyzReadyWithoutDeps verifies that, with no database and no session
// readiness gate wired, /readyz answers 200 OK.
func TestReadyzReadyWithoutDeps(t *testing.T) {
srv := New(":0", Deps{Logger: zaptest.NewLogger(t)})
if rec := get(srv, "/readyz"); rec.Code != http.StatusOK {
t.Fatalf("/readyz status = %d, want %d", rec.Code, http.StatusOK)
}
}
// TestReadyzNotReadyWhenSessionsCold verifies that /readyz answers 503 while the
// session cache reports not-ready.
func TestReadyzNotReadyWhenSessionsCold(t *testing.T) {
srv := New(":0", Deps{
Logger: zaptest.NewLogger(t),
SessionsReady: func() bool { return false },
})
if rec := get(srv, "/readyz"); rec.Code != http.StatusServiceUnavailable {
t.Fatalf("/readyz status = %d, want %d", rec.Code, http.StatusServiceUnavailable)
}
}
// TestReadyzReadyWhenSessionsWarm verifies that /readyz answers 200 once the
// session cache reports ready (and no database is wired).
func TestReadyzReadyWhenSessionsWarm(t *testing.T) {
srv := New(":0", Deps{
Logger: zaptest.NewLogger(t),
SessionsReady: func() bool { return true },
})
if rec := get(srv, "/readyz"); rec.Code != http.StatusOK {
t.Fatalf("/readyz status = %d, want %d", rec.Code, http.StatusOK)
}
}