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
+244 -25
View File
@@ -1,37 +1,256 @@
// cmd/hammyd/main.go
package main
import (
"context"
"log"
"os"
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9"
"git.dcrubro.com/dcrubro/hammy-backend/internal/api"
"git.dcrubro.com/dcrubro/hammy-backend/internal/config"
"git.dcrubro.com/dcrubro/hammy-backend/internal/db"
"git.dcrubro.com/dcrubro/hammy-backend/internal/ratelimit"
)
// version is stamped at build time:
// go build -ldflags "-X main.version=$(git describe --tags --always --dirty)" ./cmd/hammyd
var version = "dev"
func main() {
ctx := context.Background()
// All real work happens in run() so that deferred cleanup actually runs.
// os.Exit skips defers, so calling it anywhere but here leaks the pool and
// drops in-flight requests.
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "hammyd: %v\n", err)
os.Exit(1)
}
}
dsn := os.Getenv("HAMMY_DSN")
if dsn == "" {
log.Fatal("HAMMY_DSN is not set!")
}
func run() error {
// NotifyContext cancels on SIGINT/SIGTERM, which is what gives the server
// a chance to finish in-flight requests instead of being killed mid-write.
ctx, stop := signal.NotifyContext(context.Background(),
os.Interrupt, syscall.SIGTERM)
defer stop()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
log.Fatalf("connect: %v", err)
}
defer pool.Close()
args := os.Args[1:]
if err := pool.Ping(ctx); err != nil {
log.Fatalf("ping: %v", err)
}
cmd := "serve"
if len(args) > 0 {
cmd, args = args[0], args[1:]
}
var n int
err = pool.QueryRow(ctx, "SELECT count(*) FROM core.users").Scan(&n)
if err != nil {
log.Fatalf("query: %v", err)
}
switch cmd {
case "serve":
return runServe(ctx, args)
case "keygen":
return runKeygen(ctx, args)
case "version":
fmt.Println(version)
log.Printf("connected, %d users", n)
}
return nil
case "help", "-h", "--help":
usage()
return nil
default:
usage()
return fmt.Errorf("unknown command %q", cmd)
}
}
func usage() {
fmt.Fprintf(os.Stderr, `hammyd %s
Usage:
hammyd serve Run the API server (default)
hammyd keygen -email … Issue an API key
hammyd version
Configuration comes from the environment:
HAMMY_DSN Postgres connection string (required)
HAMMY_ADDR Listen address (default 127.0.0.1:8080)
HAMMY_REDIS_ADDR Redis for shared rate limiting (optional)
HAMMY_MAX_CONNS Postgres pool size (default 10)
HAMMY_LOG_LEVEL debug, info, warn, error (default info)
HAMMY_LOG_JSON true for structured logs
HAMMY_ENDUSER_HEADER Header carrying the end-user id (default X-Hammy-User)
`, version)
}
func runServe(ctx context.Context, _ []string) error {
cfg, err := config.Load()
if err != nil {
return err
}
logger := newLogger(cfg)
slog.SetDefault(logger)
logger.Info("starting", "version", version, "config", cfg.Redacted())
// ---- Postgres ---------------------------------------------------------
poolCfg, err := pgxpool.ParseConfig(cfg.DSN)
if err != nil {
return fmt.Errorf("parsing DSN: %w", err)
}
// Postgres forks a backend process per connection, so the pool size is a
// real resource on the server, not just a client-side knob.
poolCfg.MaxConns = cfg.MaxConns
poolCfg.MaxConnLifetime = time.Hour
poolCfg.MaxConnIdleTime = 15 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, poolCfg)
if err != nil {
return fmt.Errorf("connecting to postgres: %w", err)
}
defer pool.Close()
// Fail at startup rather than on the first request. A backend that boots
// happily and 500s on everything is much harder to diagnose.
pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := pool.Ping(pingCtx); err != nil {
return fmt.Errorf("pinging postgres: %w", err)
}
logger.Info("postgres connected", "max_conns", cfg.MaxConns)
// ---- Redis, or not ----------------------------------------------------
var limiter ratelimit.Limiter
if cfg.RedisAddr == "" {
// A single self-hosted instance does not need Redis to have working
// rate limits. Several instances do: each process would keep its own
// counters, so N processes allow N times the limit.
logger.Warn("HAMMY_REDIS_ADDR is unset, using an in-process limiter. " +
"Correct for one instance, wrong for several.")
mem := ratelimit.NewMemory()
limiter = mem
go sweepPeriodically(ctx, mem, time.Minute, logger)
} else {
rdb := redis.NewClient(&redis.Options{Addr: cfg.RedisAddr, DB: cfg.RedisDB})
defer rdb.Close()
if err := rdb.Ping(ctx).Err(); err != nil {
return fmt.Errorf("connecting to redis: %w", err)
}
logger.Info("redis connected", "addr", cfg.RedisAddr)
limiter = ratelimit.NewRedis(ratelimit.GoRedis{Client: rdb})
}
// ---- HTTP -------------------------------------------------------------
queries := db.New(pool)
router := &api.Router{
Auth: &api.Authenticator{
Keys: api.PgKeyStore{Q: queries},
Limiter: limiter,
Logger: logger,
EndUserHeader: cfg.EndUserHeader,
},
Logger: logger,
Version: version,
Ready: func() error {
// Readiness checks dependencies; liveness deliberately does not.
c, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
return pool.Ping(c)
},
}
srv := &http.Server{
Addr: cfg.Addr,
Handler: router.Handler(),
// ReadHeaderTimeout is the one that matters: without it, a client can
// hold a connection open by dribbling headers forever (Slowloris).
ReadHeaderTimeout: cfg.ReadHeaderTimeout,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 2 * time.Minute,
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
}
errCh := make(chan error, 1)
go func() {
logger.Info("listening", "addr", cfg.Addr)
// ErrServerClosed is the normal result of Shutdown, not a failure.
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
select {
case err := <-errCh:
return fmt.Errorf("listening: %w", err)
case <-ctx.Done():
logger.Info("shutdown signal received, draining")
}
// A FRESH context: ctx is already cancelled, and passing it to Shutdown
// would abort in-flight requests immediately, which is the opposite of
// what a graceful shutdown is for.
shutdownCtx, cancelShutdown := context.WithTimeout(
context.Background(), cfg.ShutdownTimeout)
defer cancelShutdown()
if err := srv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown: %w", err)
}
logger.Info("stopped cleanly")
return nil
}
// sweepPeriodically drops expired windows from the in-process limiter. Without
// it the map grows for every key and end user ever seen.
func sweepPeriodically(ctx context.Context, m *ratelimit.Memory, every time.Duration, logger *slog.Logger) {
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if n := m.Sweep(); n > 0 {
logger.Debug("swept rate limit windows", "removed", n)
}
}
}
}
func newLogger(cfg config.Config) *slog.Logger {
opts := &slog.HandlerOptions{Level: cfg.LogLevel}
if cfg.LogJSON {
return slog.New(slog.NewJSONHandler(os.Stdout, opts))
}
return slog.New(slog.NewTextHandler(os.Stdout, opts))
}