api calls

This commit is contained in:
2026-09-06 19:14:59 +02:00
parent 13627a369a
commit 2dc9a30644
9 changed files with 1096 additions and 31 deletions
+184
View File
@@ -0,0 +1,184 @@
// Package config loads runtime settings from the environment.
//
// Environment variables rather than a config file, for two reasons: the DSN
// differs per machine and you are syncing the repo across two, and a file with
// credentials in it is one `git add .` away from being public. The bot uses
// config.json because its token is the only secret; the backend holds database
// credentials and key material.
package config
import (
"errors"
"fmt"
"log/slog"
"os"
"strconv"
"strings"
"time"
)
type Config struct {
// Addr is the listen address. Bind to localhost in production and put a
// reverse proxy in front for TLS.
Addr string
// DSN for Postgres. Required.
DSN string
// MaxConns caps the pgx pool. Postgres forks a backend per connection, so
// this is a real resource, not a client-side nicety.
MaxConns int32
// RedisAddr enables the shared rate limiter. Empty falls back to an
// in-process limiter, which is correct for a single instance and wrong for
// several - each would keep its own counters.
RedisAddr string
RedisDB int
// EndUserHeader carries the caller's identifier for whoever triggered the
// request, for per-user rate limiting. Empty disables it.
EndUserHeader string
LogLevel slog.Level
LogJSON bool
ReadHeaderTimeout time.Duration
ShutdownTimeout time.Duration
}
func Load() (Config, error) {
c := Config{
Addr: env("HAMMY_ADDR", "127.0.0.1:8080"),
DSN: os.Getenv("HAMMY_DSN"),
RedisAddr: os.Getenv("HAMMY_REDIS_ADDR"),
EndUserHeader: env("HAMMY_ENDUSER_HEADER", "X-Hammy-User"),
ReadHeaderTimeout: 10 * time.Second,
ShutdownTimeout: 20 * time.Second,
LogJSON: boolEnv("HAMMY_LOG_JSON", false),
}
if c.DSN == "" {
return Config{}, errors.New("config: HAMMY_DSN is not set")
}
n, err := intEnv("HAMMY_MAX_CONNS", 10)
if err != nil {
return Config{}, err
}
c.MaxConns = int32(n)
if c.RedisDB, err = intEnv("HAMMY_REDIS_DB", 0); err != nil {
return Config{}, err
}
if c.LogLevel, err = levelEnv("HAMMY_LOG_LEVEL", slog.LevelInfo); err != nil {
return Config{}, err
}
if c.ReadHeaderTimeout, err = durEnv("HAMMY_READ_HEADER_TIMEOUT", c.ReadHeaderTimeout); err != nil {
return Config{}, err
}
if c.ShutdownTimeout, err = durEnv("HAMMY_SHUTDOWN_TIMEOUT", c.ShutdownTimeout); err != nil {
return Config{}, err
}
return c, nil
}
// Redacted is safe to log at startup. The DSN carries a password.
func (c Config) Redacted() string {
redis := c.RedisAddr
if redis == "" {
redis = "(in-process limiter)"
}
return fmt.Sprintf("addr=%s db=%s redis=%s max_conns=%d level=%s",
c.Addr, redactDSN(c.DSN), redis, c.MaxConns, c.LogLevel)
}
// redactDSN keeps the shape of the connection string and drops the password.
func redactDSN(dsn string) string {
at := strings.LastIndex(dsn, "@")
if at < 0 {
return "(set)"
}
scheme := strings.Index(dsn, "://")
if scheme < 0 {
return "(set)"
}
creds := dsn[scheme+3 : at]
user := creds
if colon := strings.Index(creds, ":"); colon >= 0 {
user = creds[:colon]
}
return dsn[:scheme+3] + user + ":***" + dsn[at:]
}
func env(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func intEnv(key string, def int) (int, error) {
v := os.Getenv(key)
if v == "" {
return def, nil
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, fmt.Errorf("config: %s: %w", key, err)
}
return n, nil
}
func boolEnv(key string, def bool) bool {
v := os.Getenv(key)
if v == "" {
return def
}
b, err := strconv.ParseBool(v)
if err != nil {
return def
}
return b
}
func durEnv(key string, def time.Duration) (time.Duration, error) {
v := os.Getenv(key)
if v == "" {
return def, nil
}
d, err := time.ParseDuration(v)
if err != nil {
return 0, fmt.Errorf("config: %s: %w", key, err)
}
return d, nil
}
func levelEnv(key string, def slog.Level) (slog.Level, error) {
v := os.Getenv(key)
if v == "" {
return def, nil
}
var l slog.Level
if err := l.UnmarshalText([]byte(v)); err != nil {
return 0, fmt.Errorf("config: %s: %w", key, err)
}
return l, nil
}
+103
View File
@@ -0,0 +1,103 @@
package config
import (
"log/slog"
"strings"
"testing"
"time"
)
func TestLoadRequiresDSN(t *testing.T) {
t.Setenv("HAMMY_DSN", "")
if _, err := Load(); err == nil {
t.Fatal("expected an error when HAMMY_DSN is unset")
}
}
func TestLoadDefaults(t *testing.T) {
t.Setenv("HAMMY_DSN", "postgres://u:p@localhost/hammy")
c, err := Load()
if err != nil {
t.Fatal(err)
}
if c.Addr != "127.0.0.1:8080" {
t.Errorf("Addr %q, want localhost by default - binding 0.0.0.0 should be deliberate", c.Addr)
}
if c.MaxConns != 10 {
t.Errorf("MaxConns %d, want 10", c.MaxConns)
}
if c.LogLevel != slog.LevelInfo {
t.Errorf("LogLevel %v, want info", c.LogLevel)
}
if c.RedisAddr != "" {
t.Errorf("RedisAddr %q, want empty", c.RedisAddr)
}
}
func TestLoadOverrides(t *testing.T) {
t.Setenv("HAMMY_DSN", "postgres://u:p@localhost/hammy")
t.Setenv("HAMMY_ADDR", ":9000")
t.Setenv("HAMMY_MAX_CONNS", "25")
t.Setenv("HAMMY_LOG_LEVEL", "debug")
t.Setenv("HAMMY_SHUTDOWN_TIMEOUT", "45s")
t.Setenv("HAMMY_REDIS_ADDR", "localhost:6379")
c, err := Load()
if err != nil {
t.Fatal(err)
}
if c.Addr != ":9000" || c.MaxConns != 25 || c.LogLevel != slog.LevelDebug {
t.Errorf("overrides not applied: %+v", c)
}
if c.ShutdownTimeout != 45*time.Second {
t.Errorf("ShutdownTimeout %v, want 45s", c.ShutdownTimeout)
}
}
func TestLoadRejectsBadValues(t *testing.T) {
t.Setenv("HAMMY_DSN", "postgres://u:p@localhost/hammy")
for _, c := range []struct{ key, val string }{
{"HAMMY_MAX_CONNS", "lots"},
{"HAMMY_LOG_LEVEL", "verbose"},
{"HAMMY_SHUTDOWN_TIMEOUT", "soon"},
} {
t.Run(c.key, func(t *testing.T) {
t.Setenv(c.key, c.val)
if _, err := Load(); err == nil {
t.Errorf("%s=%q accepted", c.key, c.val)
}
})
}
}
// A DSN in a startup log is a password in a log.
func TestRedactedHidesPassword(t *testing.T) {
cases := []string{
"postgres://hammy_app:[email protected]:5432/hammy",
"postgresql://hammy_app:p%40ss@localhost/hammy?sslmode=verify-full",
}
for _, dsn := range cases {
c := Config{DSN: dsn, Addr: ":8080", MaxConns: 10}
out := c.Redacted()
if strings.Contains(out, "hunter2") || strings.Contains(out, "p%40ss") {
t.Errorf("password leaked: %s", out)
}
if !strings.Contains(out, "hammy_app") {
t.Errorf("username should survive for diagnosis: %s", out)
}
}
// A DSN with no credentials must not panic or produce nonsense.
c := Config{DSN: "postgres:///hammy?host=/var/run/postgresql"}
if out := c.Redacted(); strings.Contains(out, "***") && !strings.Contains(out, "(set)") {
t.Logf("socket DSN rendered as: %s", out)
}
}