package api import ( "context" "database/sql" "expvar" "fmt" "log/slog" "net/http" "os" "runtime" "sync" "time" "gardomatic.kleiax.de/internal/mailer" "gardomatic.kleiax.de/internal/storage" "gardomatic.kleiax.de/internal/storage/postgres" "gardomatic.kleiax.de/internal/vcs" "github.com/alexedwards/scs/postgresstore" "github.com/alexedwards/scs/v2" _ "github.com/lib/pq" ) var ( version = vcs.Version() ) // Config contains API server, database, session, mail, and security settings. type Config struct { Host string Port int Env string WebBaseURL string DemoAccountEmail string DB DatabaseConfig Limiter LimiterConfig Session SessionConfig Mail mailer.Config Cors CORSConfig } // DatabaseConfig controls the PostgreSQL connection pool. type DatabaseConfig struct { Dsn string MaxOpenConns int MaxIdleConns int MaxIdleTime time.Duration } // LimiterConfig controls per-client HTTP rate limiting. type LimiterConfig struct { Enabled bool Rps float64 Burst int } // SessionConfig controls the session cookie and server-side session lifetime. type SessionConfig struct { Lifetime time.Duration IdleTimeout time.Duration CookieName string CookieSecure bool } // CORSConfig lists origins allowed to make cross-origin API requests. type CORSConfig struct { TrustedOrigins []string } type application struct { config Config logger *slog.Logger models storage.Models mailer *mailer.Mailer sessions *scs.SessionManager wg sync.WaitGroup // db is retained by the application so Run can close the connection pool. db *sql.DB } // New initializes the API application and its database-backed dependencies. func New(cfg Config) *application { var handler slog.Handler = slog.NewTextHandler(os.Stdout, nil) if cfg.Env == "production" { handler = slog.NewJSONHandler(os.Stdout, nil) } logger := slog.New(handler) db, err := openDB(cfg) if err != nil { logger.Error(err.Error()) os.Exit(1) } logger.Info("database connection pool established") expvar.NewString("version").Set(version) expvar.Publish("goroutines", expvar.Func(func() any { return runtime.NumGoroutine() })) expvar.Publish("database", expvar.Func(func() any { return db.Stats() })) expvar.Publish("timestamp", expvar.Func(func() any { return time.Now().Unix() })) mailer, err := mailer.New(cfg.Mail) if err != nil { logger.Error(err.Error()) os.Exit(1) } sessions := scs.New() sessions.Store = postgresstore.New(db) sessions.Lifetime = cfg.Session.Lifetime sessions.IdleTimeout = cfg.Session.IdleTimeout sessions.HashTokenInStore = true sessions.Cookie.Name = cfg.Session.CookieName sessions.Cookie.HttpOnly = true sessions.Cookie.SameSite = http.SameSiteLaxMode sessions.Cookie.Secure = cfg.Session.CookieSecure app := &application{ config: cfg, logger: logger, models: postgres.New(db), mailer: mailer, sessions: sessions, db: db, } sessions.ErrorFunc = app.serverErrorResponse return app } // Run serves API requests until shutdown and then releases application resources. func (app *application) Run() { defer app.db.Close() err := app.serve() if err != nil { app.logger.Error(err.Error()) os.Exit(1) } } // Version returns the version embedded in the API binary. func (app *application) Version() string { return fmt.Sprintf("Version:\t%s\n", version) } func openDB(cfg Config) (*sql.DB, error) { db, err := sql.Open("postgres", cfg.DB.Dsn) if err != nil { return nil, err } db.SetMaxOpenConns(cfg.DB.MaxOpenConns) db.SetMaxIdleConns(cfg.DB.MaxIdleConns) db.SetConnMaxIdleTime(cfg.DB.MaxIdleTime) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() err = db.PingContext(ctx) if err != nil { db.Close() return nil, err } return db, nil }