first commit
This commit is contained in:
329
go-api/internal/config/config.go
Normal file
329
go-api/internal/config/config.go
Normal file
@@ -0,0 +1,329 @@
|
||||
// Package config loads and validates the backend's runtime configuration.
|
||||
//
|
||||
// Configuration comes from the process environment. A .env file in the
|
||||
// repository root is read first as a convenience for local development, and
|
||||
// never overrides a variable that is already set — so an explicit
|
||||
// `DATABASE_PASSWORD=… go run ./cmd/api` always wins over the file.
|
||||
//
|
||||
// Nothing here has a credential baked in. Load fails loudly rather than
|
||||
// falling back to a default host, database or user, because a silent default
|
||||
// is how a development process ends up pointed at the wrong database.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config is the whole of the Phase 1 configuration surface.
|
||||
type Config struct {
|
||||
AppEnv string
|
||||
Log LogConfig
|
||||
HTTP HTTPConfig
|
||||
DB DBConfig
|
||||
Seed SeedConfig
|
||||
}
|
||||
|
||||
// SeedConfig locates the demo fixture. The file is generated from the frontend
|
||||
// repository, so it lives beside the migrations rather than inside the Go
|
||||
// module: regenerating it must not require rebuilding the binary.
|
||||
type SeedConfig struct {
|
||||
FixturePath string
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
Level string
|
||||
}
|
||||
|
||||
type HTTPConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
IdleTimeout time.Duration
|
||||
ShutdownTimeout time.Duration
|
||||
|
||||
// CORSOrigins is the exact set of browser origins allowed to call the API.
|
||||
//
|
||||
// It exists for one reason: in local development the Vite dev server is an
|
||||
// origin of its own (http://localhost:5173) and the API is another
|
||||
// (http://127.0.0.1:8080), so every fetch from the frontend is
|
||||
// cross-origin. Empty means CORS is off and the API answers only
|
||||
// same-origin callers, which is the correct posture everywhere the
|
||||
// frontend is served from the same host as the API.
|
||||
//
|
||||
// Origins are matched exactly and echoed back one at a time. There is no
|
||||
// wildcard and no pattern: "*" would let any page on the internet read
|
||||
// this API, and once authentication exists that becomes a real hole rather
|
||||
// than a theoretical one.
|
||||
CORSOrigins []string
|
||||
}
|
||||
|
||||
type DBConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
Name string
|
||||
User string
|
||||
Password string
|
||||
Schema string
|
||||
SSLMode string
|
||||
MaxOpenConns int32
|
||||
MinIdleConns int32
|
||||
ConnMaxLifetime time.Duration
|
||||
ConnectTimeout time.Duration
|
||||
StatementTimeout time.Duration
|
||||
}
|
||||
|
||||
// DSN builds a libpq-style connection URL.
|
||||
//
|
||||
// Every component is URL-escaped: the local database is called "Krow-force",
|
||||
// which is both mixed-case and hyphenated, and a password may contain anything
|
||||
// at all. Escaping is not optional here.
|
||||
func (d DBConfig) DSN() string {
|
||||
u := &url.URL{
|
||||
Scheme: "postgres",
|
||||
User: url.UserPassword(d.User, d.Password),
|
||||
Host: fmt.Sprintf("%s:%d", d.Host, d.Port),
|
||||
Path: "/" + d.Name,
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("sslmode", d.SSLMode)
|
||||
// Pin the schema on every connection so no query can accidentally resolve
|
||||
// against a different one, and so nothing reaches for a system schema.
|
||||
q.Set("search_path", d.Schema)
|
||||
q.Set("connect_timeout", strconv.Itoa(int(d.ConnectTimeout.Seconds())))
|
||||
q.Set("statement_timeout", strconv.Itoa(int(d.StatementTimeout.Milliseconds())))
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// Redacted returns the DSN with the password replaced, for logs.
|
||||
func (d DBConfig) Redacted() string {
|
||||
u, err := url.Parse(d.DSN())
|
||||
if err != nil {
|
||||
return "postgres://<unparseable>"
|
||||
}
|
||||
if _, hasPassword := u.User.Password(); hasPassword {
|
||||
u.User = url.UserPassword(u.User.Username(), "xxxxx")
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// Load reads the environment, applies defaults and validates the result.
|
||||
func Load() (*Config, error) {
|
||||
loadDotEnv(".env")
|
||||
|
||||
var missing []string
|
||||
required := func(key string) string {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
missing = append(missing, key)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
AppEnv: withDefault("APP_ENV", "development"),
|
||||
Log: LogConfig{Level: withDefault("LOG_LEVEL", "info")},
|
||||
HTTP: HTTPConfig{
|
||||
Host: withDefault("HTTP_HOST", "127.0.0.1"),
|
||||
Port: intDefault("HTTP_PORT", 8080),
|
||||
ReadTimeout: durationDefault("HTTP_READ_TIMEOUT", 15*time.Second),
|
||||
WriteTimeout: durationDefault("HTTP_WRITE_TIMEOUT", 30*time.Second),
|
||||
IdleTimeout: durationDefault("HTTP_IDLE_TIMEOUT", 60*time.Second),
|
||||
ShutdownTimeout: durationDefault("HTTP_SHUTDOWN_TIMEOUT", 10*time.Second),
|
||||
CORSOrigins: corsOrigins(withDefault("APP_ENV", "development")),
|
||||
},
|
||||
Seed: SeedConfig{
|
||||
FixturePath: withDefault("SEED_FIXTURE_PATH", "./seed/fixtures/seed.json"),
|
||||
},
|
||||
DB: DBConfig{
|
||||
Host: required("DATABASE_HOST"),
|
||||
Port: intDefault("DATABASE_PORT", 5432),
|
||||
Name: required("DATABASE_NAME"),
|
||||
User: required("DATABASE_USER"),
|
||||
Password: os.Getenv("DATABASE_PASSWORD"), // may legitimately be empty (trust/peer auth)
|
||||
Schema: withDefault("DATABASE_SCHEMA", "public"),
|
||||
SSLMode: withDefault("DATABASE_SSLMODE", "disable"),
|
||||
MaxOpenConns: int32(intDefault("DATABASE_MAX_OPEN_CONNS", 25)),
|
||||
MinIdleConns: int32(intDefault("DATABASE_MIN_IDLE_CONNS", 2)),
|
||||
ConnMaxLifetime: durationDefault("DATABASE_CONN_MAX_LIFETIME", 30*time.Minute),
|
||||
ConnectTimeout: durationDefault("DATABASE_CONNECT_TIMEOUT", 5*time.Second),
|
||||
StatementTimeout: durationDefault("DATABASE_STATEMENT_TIMEOUT", 10*time.Second),
|
||||
},
|
||||
}
|
||||
|
||||
if len(missing) > 0 {
|
||||
return nil, fmt.Errorf("missing required environment variables: %s "+
|
||||
"(copy .env.example to .env and fill them in)", strings.Join(missing, ", "))
|
||||
}
|
||||
if err := cfg.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) validate() error {
|
||||
switch c.AppEnv {
|
||||
case "development", "staging", "production":
|
||||
default:
|
||||
return fmt.Errorf("APP_ENV must be development, staging or production, got %q", c.AppEnv)
|
||||
}
|
||||
if c.HTTP.Port < 1 || c.HTTP.Port > 65535 {
|
||||
return fmt.Errorf("HTTP_PORT out of range: %d", c.HTTP.Port)
|
||||
}
|
||||
if c.DB.Port < 1 || c.DB.Port > 65535 {
|
||||
return fmt.Errorf("DATABASE_PORT out of range: %d", c.DB.Port)
|
||||
}
|
||||
// The application owns exactly one schema and it is never a system schema.
|
||||
switch c.DB.Schema {
|
||||
case "pg_catalog", "pg_toast", "information_schema":
|
||||
return fmt.Errorf("DATABASE_SCHEMA must not be a PostgreSQL system schema, got %q", c.DB.Schema)
|
||||
}
|
||||
if strings.HasPrefix(c.DB.Schema, "pg_") {
|
||||
return fmt.Errorf("DATABASE_SCHEMA must not start with \"pg_\", got %q", c.DB.Schema)
|
||||
}
|
||||
if c.DB.MinIdleConns > c.DB.MaxOpenConns {
|
||||
return fmt.Errorf("DATABASE_MIN_IDLE_CONNS (%d) exceeds DATABASE_MAX_OPEN_CONNS (%d)",
|
||||
c.DB.MinIdleConns, c.DB.MaxOpenConns)
|
||||
}
|
||||
if c.AppEnv == "production" && c.DB.SSLMode == "disable" {
|
||||
return fmt.Errorf("DATABASE_SSLMODE=disable is not allowed when APP_ENV=production")
|
||||
}
|
||||
for _, origin := range c.HTTP.CORSOrigins {
|
||||
// "*" is rejected rather than quietly honoured. The middleware echoes a
|
||||
// single matched origin, so a wildcard could only ever be a
|
||||
// misunderstanding of what this setting does.
|
||||
if origin == "*" {
|
||||
return fmt.Errorf("HTTP_CORS_ORIGINS must list explicit origins; \"*\" is not accepted")
|
||||
}
|
||||
if !strings.HasPrefix(origin, "http://") && !strings.HasPrefix(origin, "https://") {
|
||||
return fmt.Errorf("HTTP_CORS_ORIGINS entry %q must be a full origin including the scheme", origin)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// devCORSOrigins are the origins the Vite dev server can occupy. Vite binds
|
||||
// localhost by default and 127.0.0.1 when asked, and a browser treats those two
|
||||
// as different origins, so both are listed. 4173 is `vite preview`.
|
||||
var devCORSOrigins = []string{
|
||||
"http://localhost:5173", "http://127.0.0.1:5173",
|
||||
"http://localhost:4173", "http://127.0.0.1:4173",
|
||||
}
|
||||
|
||||
// corsOrigins reads HTTP_CORS_ORIGINS, a comma-separated allowlist.
|
||||
//
|
||||
// The development default is the Vite dev server, because that is the whole
|
||||
// point of the setting in Phase 2D. Outside development the default is empty:
|
||||
// a staging or production deployment that genuinely serves its frontend from
|
||||
// another origin has to say so explicitly, rather than inheriting a list of
|
||||
// localhost origins nobody reviewed.
|
||||
func corsOrigins(appEnv string) []string {
|
||||
raw, set := os.LookupEnv("HTTP_CORS_ORIGINS")
|
||||
if !set {
|
||||
if appEnv == "development" {
|
||||
return devCORSOrigins
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
// A trailing slash makes the string unequal to the Origin header the
|
||||
// browser actually sends, which fails in a way that looks like a
|
||||
// server bug rather than a typo.
|
||||
if o := strings.TrimRight(strings.TrimSpace(part), "/"); o != "" {
|
||||
out = append(out, o)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func withDefault(key, fallback string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func intDefault(key string, fallback int) int {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func durationDefault(key string, fallback time.Duration) time.Duration {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// loadDotEnv reads KEY=VALUE lines, walking up from the working directory so
|
||||
// `go run ./cmd/api` finds the repository-root .env. Existing environment
|
||||
// variables always win. Absence of the file is not an error.
|
||||
func loadDotEnv(name string) {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
path := dir + string(os.PathSeparator) + name
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
applyDotEnv(string(data))
|
||||
return
|
||||
}
|
||||
parent := parentDir(dir)
|
||||
if parent == dir {
|
||||
return
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
func parentDir(dir string) string {
|
||||
i := strings.LastIndex(dir, string(os.PathSeparator))
|
||||
if i <= 0 {
|
||||
return dir
|
||||
}
|
||||
return dir[:i]
|
||||
}
|
||||
|
||||
func applyDotEnv(content string) {
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(strings.TrimPrefix(key, "export "))
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) >= 2 {
|
||||
if (value[0] == '"' && value[len(value)-1] == '"') ||
|
||||
(value[0] == '\'' && value[len(value)-1] == '\'') {
|
||||
value = value[1 : len(value)-1]
|
||||
}
|
||||
}
|
||||
if _, present := os.LookupEnv(key); !present {
|
||||
_ = os.Setenv(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user