// Command api runs the HTTP server. Run it from backend/ so the godotenv // autoload below finds backend/.env. package main import ( "context" "errors" "fmt" "log/slog" "net/http" "os" "os/signal" "syscall" "time" // Loads a .env file next to the working directory, if present. _ "github.com/joho/godotenv/autoload" "git.k6n.net/mats/fullstack-template/internal/api" "git.k6n.net/mats/fullstack-template/internal/auth" "git.k6n.net/mats/fullstack-template/internal/config" "git.k6n.net/mats/fullstack-template/internal/database" "git.k6n.net/mats/fullstack-template/internal/items" ) func main() { if err := run(); err != nil { slog.Error("server exited with error", "err", err) os.Exit(1) } slog.Info("server stopped") } func run() error { cfg, err := config.Load() if err != nil { return err } logger := newLogger(cfg) slog.SetDefault(logger) db, err := database.Open(cfg) if err != nil { return err } defer db.Close() pingCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := db.PingContext(pingCtx); err != nil { return fmt.Errorf("connect to database: %w (check DATABASE_URL; for postgres, is `make db-up` running?)", err) } svc := items.NewService(items.NewBunRepository(db)) // OIDC is on when a client id is configured; without one the API runs // standalone (no /api/auth routes, /api/v1 unprotected) — see README. authDeps, err := configureAuth(logger, cfg) if err != nil { return err } handler := api.New(api.Deps{ Logger: logger, Items: svc, DB: db, AllowedOrigins: cfg.AllowedOrigins, Auth: authDeps, }) srv := &http.Server{ Addr: cfg.Addr, Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, } errCh := make(chan error, 1) go func() { logger.Info("api listening", "addr", cfg.Addr) errCh <- srv.ListenAndServe() }() ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() select { case err := <-errCh: if errors.Is(err, http.ErrServerClosed) { return nil } return err case <-ctx.Done(): logger.Info("shutting down") shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := srv.Shutdown(shutdownCtx); err != nil { return fmt.Errorf("graceful shutdown: %w", err) } return nil } } // configureAuth builds the OIDC collaborators, or nil in standalone mode. func configureAuth(logger *slog.Logger, cfg config.Config) (*api.AuthDeps, error) { if cfg.OIDCClientID == "" { logger.Warn("OIDC_CLIENT_ID not set — running standalone: /api/auth is disabled and /api/v1 is unprotected") return nil, nil } if cfg.AuthSessionSecretEphemeral { logger.Warn("AUTH_SESSION_SECRET not set — generated an ephemeral one; every restart signs everyone out") } // Discovery failure at boot is fatal: a server that cannot verify // identities must not come up half-working. client, err := auth.New(context.Background(), auth.Config{ Issuer: cfg.OIDCIssuer, ClientID: cfg.OIDCClientID, ClientSecret: cfg.OIDCClientSecret, Scopes: []string{"openid", "profile", "email"}, }) if err != nil { return nil, fmt.Errorf("oidc discovery against %s: %w", cfg.OIDCIssuer, err) } logger.Info("oidc auth enabled", "issuer", cfg.OIDCIssuer, "client_id", cfg.OIDCClientID) return &api.AuthDeps{ Client: client, Signer: auth.NewSigner(cfg.AuthSessionSecret), SessionTTL: cfg.AuthSessionTTL, }, nil } func newLogger(cfg config.Config) *slog.Logger { opts := &slog.HandlerOptions{Level: cfg.LogLevel} if cfg.LogFormat == "text" { return slog.New(slog.NewTextHandler(os.Stdout, opts)) } return slog.New(slog.NewJSONHandler(os.Stdout, opts)) }