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:
@@ -5,6 +5,11 @@ package config
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"scrabble/backend/internal/postgres"
|
||||
"scrabble/backend/internal/telemetry"
|
||||
)
|
||||
|
||||
// Config holds the backend's runtime configuration.
|
||||
@@ -13,6 +18,10 @@ type Config struct {
|
||||
HTTPAddr string
|
||||
// LogLevel is the zap log level: "debug", "info", "warn" or "error".
|
||||
LogLevel string
|
||||
// Postgres configures the primary database pool.
|
||||
Postgres postgres.Config
|
||||
// Telemetry configures the OpenTelemetry providers.
|
||||
Telemetry telemetry.Config
|
||||
}
|
||||
|
||||
// Defaults applied when the corresponding environment variable is unset.
|
||||
@@ -21,12 +30,35 @@ const (
|
||||
defaultLogLevel = "info"
|
||||
)
|
||||
|
||||
// Load reads the configuration from the environment, applies defaults for
|
||||
// unset variables, and validates the result.
|
||||
// Load reads the configuration from the environment, applies defaults for unset
|
||||
// variables, and validates the result.
|
||||
func Load() (Config, error) {
|
||||
pg := postgres.DefaultConfig()
|
||||
pg.DSN = os.Getenv("BACKEND_POSTGRES_DSN")
|
||||
var err error
|
||||
if pg.MaxOpenConns, err = envInt("BACKEND_POSTGRES_MAX_OPEN_CONNS", pg.MaxOpenConns); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if pg.MaxIdleConns, err = envInt("BACKEND_POSTGRES_MAX_IDLE_CONNS", pg.MaxIdleConns); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if pg.ConnMaxLifetime, err = envDuration("BACKEND_POSTGRES_CONN_MAX_LIFETIME", pg.ConnMaxLifetime); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if pg.OperationTimeout, err = envDuration("BACKEND_POSTGRES_OPERATION_TIMEOUT", pg.OperationTimeout); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
tel := telemetry.DefaultConfig()
|
||||
tel.ServiceName = envOr("BACKEND_SERVICE_NAME", tel.ServiceName)
|
||||
tel.TracesExporter = envOr("BACKEND_OTEL_TRACES_EXPORTER", tel.TracesExporter)
|
||||
tel.MetricsExporter = envOr("BACKEND_OTEL_METRICS_EXPORTER", tel.MetricsExporter)
|
||||
|
||||
c := Config{
|
||||
HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr),
|
||||
LogLevel: envOr("BACKEND_LOG_LEVEL", defaultLogLevel),
|
||||
HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr),
|
||||
LogLevel: envOr("BACKEND_LOG_LEVEL", defaultLogLevel),
|
||||
Postgres: pg,
|
||||
Telemetry: tel,
|
||||
}
|
||||
if err := c.validate(); err != nil {
|
||||
return Config{}, err
|
||||
@@ -44,6 +76,12 @@ func (c Config) validate() error {
|
||||
if c.HTTPAddr == "" {
|
||||
return fmt.Errorf("config: BACKEND_HTTP_ADDR must not be empty")
|
||||
}
|
||||
if err := c.Postgres.Validate(); err != nil {
|
||||
return fmt.Errorf("config: %w (set BACKEND_POSTGRES_DSN)", err)
|
||||
}
|
||||
if err := c.Telemetry.Validate(); err != nil {
|
||||
return fmt.Errorf("config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -55,3 +93,31 @@ func envOr(key, fallback string) string {
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// envInt parses the environment variable named key as an int, returning
|
||||
// fallback when it is unset and an error when it is set but malformed.
|
||||
func envInt(key string, fallback int) (int, error) {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("config: %s: %w", key, err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// envDuration parses the environment variable named key as a Go duration,
|
||||
// returning fallback when it is unset and an error when it is set but malformed.
|
||||
func envDuration(key string, fallback time.Duration) (time.Duration, error) {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("config: %s: %w", key, err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
// TestLoadDefaults verifies that Load applies defaults when the environment is
|
||||
// empty.
|
||||
"scrabble/backend/internal/postgres"
|
||||
"scrabble/backend/internal/telemetry"
|
||||
)
|
||||
|
||||
// testDSN is a syntactically valid DSN used to satisfy the required-DSN check.
|
||||
const testDSN = "postgres://u:p@localhost:5432/db?search_path=backend&sslmode=disable"
|
||||
|
||||
// TestLoadDefaults verifies that Load applies defaults when only the required
|
||||
// DSN is set.
|
||||
func TestLoadDefaults(t *testing.T) {
|
||||
t.Setenv("BACKEND_HTTP_ADDR", "")
|
||||
t.Setenv("BACKEND_LOG_LEVEL", "")
|
||||
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
|
||||
|
||||
c, err := Load()
|
||||
if err != nil {
|
||||
@@ -18,12 +28,29 @@ func TestLoadDefaults(t *testing.T) {
|
||||
if c.LogLevel != defaultLogLevel {
|
||||
t.Errorf("LogLevel = %q, want %q", c.LogLevel, defaultLogLevel)
|
||||
}
|
||||
if c.Postgres.DSN != testDSN {
|
||||
t.Errorf("Postgres.DSN = %q, want %q", c.Postgres.DSN, testDSN)
|
||||
}
|
||||
if c.Postgres.MaxOpenConns != postgres.DefaultMaxOpenConns {
|
||||
t.Errorf("Postgres.MaxOpenConns = %d, want %d", c.Postgres.MaxOpenConns, postgres.DefaultMaxOpenConns)
|
||||
}
|
||||
if c.Telemetry.ServiceName != telemetry.DefaultServiceName {
|
||||
t.Errorf("Telemetry.ServiceName = %q, want %q", c.Telemetry.ServiceName, telemetry.DefaultServiceName)
|
||||
}
|
||||
if c.Telemetry.TracesExporter != telemetry.ExporterNone {
|
||||
t.Errorf("Telemetry.TracesExporter = %q, want %q", c.Telemetry.TracesExporter, telemetry.ExporterNone)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadOverrides verifies that environment variables override the defaults.
|
||||
func TestLoadOverrides(t *testing.T) {
|
||||
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
|
||||
t.Setenv("BACKEND_HTTP_ADDR", "127.0.0.1:9090")
|
||||
t.Setenv("BACKEND_LOG_LEVEL", "debug")
|
||||
t.Setenv("BACKEND_POSTGRES_MAX_OPEN_CONNS", "7")
|
||||
t.Setenv("BACKEND_POSTGRES_OPERATION_TIMEOUT", "3s")
|
||||
t.Setenv("BACKEND_SERVICE_NAME", "scrabble-test")
|
||||
t.Setenv("BACKEND_OTEL_TRACES_EXPORTER", "stdout")
|
||||
|
||||
c, err := Load()
|
||||
if err != nil {
|
||||
@@ -33,14 +60,63 @@ func TestLoadOverrides(t *testing.T) {
|
||||
t.Errorf("HTTPAddr = %q, want %q", c.HTTPAddr, "127.0.0.1:9090")
|
||||
}
|
||||
if c.LogLevel != "debug" {
|
||||
t.Errorf("LogLevel = %q, want %q", c.LogLevel, "debug")
|
||||
t.Errorf("LogLevel = %q", c.LogLevel)
|
||||
}
|
||||
if c.Postgres.MaxOpenConns != 7 {
|
||||
t.Errorf("Postgres.MaxOpenConns = %d, want 7", c.Postgres.MaxOpenConns)
|
||||
}
|
||||
if c.Postgres.OperationTimeout != 3*time.Second {
|
||||
t.Errorf("Postgres.OperationTimeout = %s, want 3s", c.Postgres.OperationTimeout)
|
||||
}
|
||||
if c.Telemetry.ServiceName != "scrabble-test" {
|
||||
t.Errorf("Telemetry.ServiceName = %q", c.Telemetry.ServiceName)
|
||||
}
|
||||
if c.Telemetry.TracesExporter != telemetry.ExporterStdout {
|
||||
t.Errorf("Telemetry.TracesExporter = %q, want %q", c.Telemetry.TracesExporter, telemetry.ExporterStdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadRejectsMissingDSN verifies that an empty DSN fails validation.
|
||||
func TestLoadRejectsMissingDSN(t *testing.T) {
|
||||
t.Setenv("BACKEND_POSTGRES_DSN", "")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("Load: expected an error for a missing DSN, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadRejectsInvalidLevel verifies that an unknown log level is rejected.
|
||||
func TestLoadRejectsInvalidLevel(t *testing.T) {
|
||||
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
|
||||
t.Setenv("BACKEND_LOG_LEVEL", "verbose")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("Load: expected an error for an invalid log level, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadRejectsMalformedInt verifies that a non-numeric pool size is rejected.
|
||||
func TestLoadRejectsMalformedInt(t *testing.T) {
|
||||
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
|
||||
t.Setenv("BACKEND_POSTGRES_MAX_OPEN_CONNS", "lots")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("Load: expected an error for a malformed int, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadRejectsMalformedDuration verifies that a malformed duration is rejected.
|
||||
func TestLoadRejectsMalformedDuration(t *testing.T) {
|
||||
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
|
||||
t.Setenv("BACKEND_POSTGRES_OPERATION_TIMEOUT", "soon")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("Load: expected an error for a malformed duration, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadRejectsUnsupportedExporter verifies that an exporter outside the MVP
|
||||
// set is rejected.
|
||||
func TestLoadRejectsUnsupportedExporter(t *testing.T) {
|
||||
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
|
||||
t.Setenv("BACKEND_OTEL_TRACES_EXPORTER", "otlp")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("Load: expected an error for an unsupported exporter, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user