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:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user