feat: establish Go service and database foundation
This commit is contained in:
66
internal/config/config.go
Normal file
66
internal/config/config.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
HTTPAddr string
|
||||
DatabaseURL string
|
||||
RedisAddr string
|
||||
RedisPassword string
|
||||
ImportPath string
|
||||
DemoUsername string
|
||||
DemoPassword string
|
||||
SessionSecret string
|
||||
FrontendOrigin string
|
||||
CookieSecure bool
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
HTTPAddr: value("HTTP_ADDR", "127.0.0.1:8080"),
|
||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||
RedisAddr: value("REDIS_ADDR", "localhost:6379"),
|
||||
RedisPassword: os.Getenv("REDIS_PASSWORD"),
|
||||
ImportPath: value("IMPORT_PATH", "data/incoming/challenge_data.csv.gz"),
|
||||
DemoUsername: value("DEMO_USERNAME", "merchant-0001"),
|
||||
DemoPassword: os.Getenv("DEMO_PASSWORD"),
|
||||
SessionSecret: os.Getenv("SESSION_SECRET"),
|
||||
FrontendOrigin: value("FRONTEND_ORIGIN", "http://localhost:5173"),
|
||||
}
|
||||
|
||||
var err error
|
||||
cfg.CookieSecure, err = strconv.ParseBool(value("COOKIE_SECURE", "false"))
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("parsing cookie secure: %w", err)
|
||||
}
|
||||
if cfg.DatabaseURL == "" {
|
||||
return Config{}, errors.New("config: DATABASE_URL is required")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c Config) ValidateAPI() error {
|
||||
if len(c.SessionSecret) < 32 {
|
||||
return errors.New("config: SESSION_SECRET must contain at least 32 characters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Config) ValidateSeed() error {
|
||||
if len(c.DemoPassword) < 12 {
|
||||
return errors.New("config: DEMO_PASSWORD must contain at least 12 characters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func value(name, fallback string) string {
|
||||
if value := os.Getenv(name); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
18
internal/config/config_test.go
Normal file
18
internal/config/config_test.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLoadRequiresDatabaseURL(t *testing.T) {
|
||||
t.Setenv("DATABASE_URL", "")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("Load() error = nil, want required database url error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAPIRequiresLongSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := Config{SessionSecret: "short"}
|
||||
if err := cfg.ValidateAPI(); err == nil {
|
||||
t.Fatal("ValidateAPI() error = nil, want secret length error")
|
||||
}
|
||||
}
|
||||
30
internal/database/database.go
Normal file
30
internal/database/database.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func Open(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
|
||||
poolConfig, err := pgxpool.ParseConfig(databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing database configuration: %w", err)
|
||||
}
|
||||
poolConfig.MaxConns = 12
|
||||
poolConfig.MinConns = 1
|
||||
poolConfig.MaxConnLifetime = 30 * time.Minute
|
||||
poolConfig.MaxConnIdleTime = 5 * time.Minute
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening database: %w", err)
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("pinging database: %w", err)
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
23
internal/database/migrate.go
Normal file
23
internal/database/migrate.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Migrate applies the idempotent local MVP schema. Production migration tooling
|
||||
// can execute the same reviewed SQL from migrations/.
|
||||
func Migrate(ctx context.Context, pool *pgxpool.Pool, migration string) error {
|
||||
for statement := range strings.SplitSeq(migration, "-- statement-break") {
|
||||
if strings.TrimSpace(statement) == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := pool.Exec(ctx, statement); err != nil {
|
||||
return fmt.Errorf("applying migration: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user