Files
website/internal/platform/bootstrap/loader.go

161 lines
4.1 KiB
Go

// Package bootstrap
// Path /internal/platform/bootstrap
// File: loader.go
//
// Purpose:
// Centralized application initializer (the “application kernel”).
// This constructs and wires together the core runtime graph used by the
// entire system: configuration, database, session manager (SCS), router (Gin),
// CSRF wrapper (nosurf), and the HTTP server.
//
// Responsibilities:
// 1) Load strongly-typed configuration.
// 2) Initialize long-lived infrastructure (DB, sessions).
// 3) Build the Gin router and mount global middleware and routes.
// 4) Wrap the router with SCS (LoadAndSave) and CSRF in the correct order.
// 5) Construct the http.Server and expose the assembled components via App.
//
// HTTP stack order (important):
// Gin Router → SCS LoadAndSave → CSRF Wrapper → http.Server
//
// Design guarantees:
// - Single source of truth via the App struct.
// - Stable middleware order (SCS must wrap Gin before CSRF).
// - Gin handlers can access *App via c.MustGet("app").
// - Extensible: add infra (cache/mailer/metrics) here.
//
// Change log:
// [2025-10-24] Migrated to SCS-first wrapping and explicit App wiring.
package bootstrap
import (
"context"
"database/sql"
"fmt"
"net/http"
"time"
weberr "synlotto-website/internal/http/error"
databasePlatform "synlotto-website/internal/platform/database"
"synlotto-website/internal/platform/config"
"synlotto-website/internal/platform/csrf"
"synlotto-website/internal/platform/session"
"github.com/alexedwards/scs/v2"
"github.com/gin-gonic/gin"
_ "github.com/go-sql-driver/mysql"
)
type App struct {
Config config.Config
DB *sql.DB
SessionManager *scs.SessionManager
Router *gin.Engine
Handler http.Handler
Server *http.Server
}
func Load(configPath string) (*App, error) {
cfg, err := config.Load(configPath)
if err != nil {
return nil, fmt.Errorf("load config: %w", err)
}
db, err := openMySQL(cfg)
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
return nil, fmt.Errorf("mysql ping: %w", err)
}
if err := databasePlatform.EnsureInitialSchema(db); err != nil {
return nil, fmt.Errorf("ensure schema: %w", err)
}
sessions := session.New(cfg)
router := gin.New()
router.Use(gin.Logger(), gin.Recovery())
router.Static("/static", "./web/static")
router.StaticFile("/favicon.ico", "./web/static/favicon.ico")
app := &App{
Config: cfg,
DB: db,
SessionManager: sessions,
Router: router,
}
router.Use(func(c *gin.Context) {
c.Set("app", app)
c.Next()
})
router.NoRoute(weberr.NoRoute(app.SessionManager))
router.NoMethod(weberr.NoMethod(app.SessionManager))
router.Use(gin.CustomRecovery(weberr.Recovery(app.SessionManager)))
handler := sessions.LoadAndSave(router)
handler = csrf.Wrap(handler, cfg)
addr := fmt.Sprintf("%s:%d", cfg.HttpServer.Address, cfg.HttpServer.Port)
srv := &http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: 10 * time.Second, // ToDo: consider moving to config
}
app.Handler = handler
app.Server = srv
return app, nil
}
func openMySQL(cfg config.Config) (*sql.DB, error) {
dbCfg := cfg.Database
escapedUser := dbCfg.Username
escapedPass := dbCfg.Password
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&charset=utf8mb4,utf8&loc=UTC",
escapedUser,
escapedPass,
dbCfg.Server,
dbCfg.Port,
dbCfg.DatabaseNamed,
)
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, fmt.Errorf("mysql open: %w", err)
}
if dbCfg.MaxOpenConnections > 0 {
db.SetMaxOpenConns(dbCfg.MaxOpenConnections)
}
if dbCfg.MaxIdleConnections > 0 {
db.SetMaxIdleConns(dbCfg.MaxIdleConnections)
}
if dbCfg.ConnectionMaxLifetime != "" {
if d, err := time.ParseDuration(dbCfg.ConnectionMaxLifetime); err == nil {
db.SetConnMaxLifetime(d)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return nil, fmt.Errorf("mysql ping: %w", err)
}
return db, nil
}