Load service settings from the environment
Go · Go · beginner · modification
Moves the server's environment parsing out of `main` into a small `config` package. `config.Load` takes the lookup function, so tests pass a map instead of touching the real environment. Bad values used to fall back to the defaults without a word (a `REQUEST_TIMEOUT=30` meant 15 seconds); now the server refuses to start and logs one message listing every bad variable with what it expects. `main` gets a `run` function so the deferred `db.Close()` runs on the way out. Table-driven tests cover the defaults, each bound and the combined error.
Go 1.22 HTTP service, standard library only (`net/http`, `log/slog`). Existing and unchanged: `store.Open(url)` connects to PostgreSQL and returns an error for a URL it cannot use, and its errors never include the URL or the password; `api.Routes(db, logger, api.Options)` builds the handlers and wraps request bodies in `http.MaxBytesReader` with `Options.MaxBodyBytes`; `Options.SignupsEnabled` turns `POST /signup` on. The service runs in containers whose environment is set by the deploy config, behind a load balancer that health-checks the port.
Requirements
- Move the environment parsing out of `main` into `config.Load(getenv func(string) string) (Config, error)`; production passes `os.Getenv`, tests pass a fake. Values are taken as they are, never trimmed, and a variable set to the empty string counts as unset. Integers are base 10 as `strconv` reads them: an optional sign and leading zeros are fine, spaces, `_` and exponents are not.
- The variables, each with its default when unset and the values it accepts (bounds inclusive): `PORT`, an integer from 1 to 65535, default 8080, served on all interfaces (`Addr` is `":"` plus the port); `DATABASE_URL`, required, its format is checked by `store.Open`, not here; `REQUEST_TIMEOUT`, a Go duration (`time.ParseDuration` syntax, such as `30s`) from 1s to 2m, default 15s; `MAX_BODY_BYTES`, an integer from 1024 to 10485760 (1 KiB to 10 MiB), default 1048576 (1 MiB); `SIGNUPS_ENABLED`, any value `strconv.ParseBool` accepts (`true`, `false`, `1`, `0`, `t`, `F`, ...), default false.
- A value outside these rules is an error, never silently replaced by the default as `main` does today. `Load` reports every problem at once: one error (`errors.Join`) with one line per bad variable, in the order listed above, each starting with the variable's name and quoting the rejected value. `DATABASE_URL` is never quoted (it holds a password); when it is missing its line is `DATABASE_URL: required`. On error the returned `Config` is the zero value and must not be used.
- On a configuration error the server logs it and exits with status 1 before it opens the database or listens. Every current deployment (checked by ops) either leaves a variable unset or sets a value that means the same under the old and the new parsing (`SIGNUPS_ENABLED` is only ever `true` or `false`), so the change alters no running service.
- Ship table-driven tests for the defaults, both sides of every bound, a malformed value per variable, and the combined message.
Files touched
- cmd/server/main.go
- internal/config/config.go
- internal/config/config_test.go
--- cmd/server/main.go
import (
+ "fmt"
"log/slog"
"net/http"
"os"
- "strconv"
"time"
"example.com/accounts/internal/api"
+ "example.com/accounts/internal/config"
"example.com/accounts/internal/store"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
-
- port := os.Getenv("PORT")
- if port == "" {
- port = "8080"
- }
- timeout := 15 * time.Second
- if d, err := time.ParseDuration(os.Getenv("REQUEST_TIMEOUT")); err == nil {
- timeout = d
- }
- maxBody := int64(1 << 20)
- if n, err := strconv.ParseInt(os.Getenv("MAX_BODY_BYTES"), 10, 64); err == nil {
- maxBody = n
- }
- signups := os.Getenv("SIGNUPS_ENABLED") == "true"