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

195 lines
6.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package bootstrap
// Path: /internal/platform/bootstrap
// File: loader.go
//
// Purpose
// Centralized application initializer (the “application kernel”).
// Constructs and wires the core runtime graph used by the system:
// configuration, database, schema bootstrap, session manager (SCS), router (Gin),
// CSRF wrapper (nosurf), and the HTTP server.
//
// Responsibilities (as implemented here)
// 1) Load strongly-typed configuration from disk (config.Load).
// 2) Open MySQL with pool tuning and DSN options (parseTime, utf8mb4, UTC).
// 3) Ensure initial schema on an empty DB (databasePlatform.EnsureInitialSchema).
// 4) Register gob types needed by sessions (map[string]string, []string, time.Time).
// 5) Create an SCS session manager via platform/session.New.
// 6) Build a Gin engine, attach global middleware, static mounts, and error handlers.
// 7) Inject *App into Gin context (c.Set("app", app)) for handler access.
// 8) Wrap Gin with SCS LoadAndSave, then wrap that with CSRF (nosurf).
// 9) Construct http.Server with Handler and ReadHeaderTimeout.
//
// HTTP stack order (matches code)
// Gin Router → SCS LoadAndSave → CSRF Wrapper → http.Server
//
// Design guarantees
// - Single source of truth via the App struct (Config, DB, SessionManager, Router, Handler, Server).
// - Stable middleware order: SCS wraps Gin before CSRF.
// - Gin handlers can access *App via c.MustGet("app").
// - Error surfaces are unified via custom NoRoute/NoMethod/Recovery handlers.
// - Extensible: add infra (cache/mailer/metrics) here.
//
// Operational details observed in this file
// - MySQL DSN uses: parseTime=true, charset=utf8mb4, loc=UTC.
// - Pool sizing and conn lifetime are read from config if set.
// - Schema application is idempotent and runs on startup.
// - Static files are served at /static and /favicon.ico.
// - Logging uses gin.Logger(). Recovery uses gin.CustomRecovery with weberr.Recovery.
// - ReadHeaderTimeout is set to 10s (currently hard-coded).
//
// Notes & TODOs (code-accurate)
// - Theres a second DB ping: openMySQL() pings, and Load() pings again.
// This is harmless but redundant; consider deleting one for clarity.
// - gin.Recovery() is commented out; we use CustomRecovery instead (intentional).
// - Consider moving ReadHeaderTimeout to config to match the rest of server tuning.
//
// Change log
// [2025-10-28] Document EnsureInitialSchema bootstrap, explicit gob registrations,
// clarified middleware/handler order, and server timeout behavior.
// [2025-10-24] Migrated to SCS-first wrapping and explicit App wiring.
package bootstrap
import (
"context"
"database/sql"
"encoding/gob"
"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) {
// Load configuration
cfg, err := config.Load(configPath)
if err != nil {
return nil, fmt.Errorf("load config: %w", err)
}
// Open DB
db, err := openMySQL(cfg)
if err != nil {
return nil, err
}
// Ensure initial schema (idempotent; safe on restarts)
if err := databasePlatform.EnsureInitialSchema(db); err != nil {
return nil, fmt.Errorf("ensure schema: %w", err)
}
// Register gob types used in session values
gob.Register(map[string]string{})
gob.Register([]string{})
gob.Register(time.Time{})
// Create SCS session manager
sessions := session.New(cfg)
// Build Gin router and global middleware
router := gin.New()
router.Use(gin.Logger())
router.Static("/static", "./web/static")
router.StaticFile("/favicon.ico", "./web/static/favicon.ico")
// Assemble App prior to injecting into context
app := &App{
Config: cfg,
DB: db,
SessionManager: sessions,
Router: router,
}
// Inject *App into Gin context for handler access
router.Use(func(c *gin.Context) {
c.Set("app", app)
c.Next()
})
// Error handling surfaces
router.NoRoute(weberr.NoRoute(app.SessionManager))
router.NoMethod(weberr.NoMethod(app.SessionManager))
router.Use(gin.CustomRecovery(weberr.Recovery(app.SessionManager)))
// Wrap: Gin → SCS → CSRF (nosurf)
handler := sessions.LoadAndSave(router)
handler = csrf.Wrap(handler, cfg)
// 9) Build HTTP server
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
// Credentials are used as-is; escaping handled by DSN rules for mysql driver.
escapedUser := dbCfg.Username
escapedPass := dbCfg.Password
// DSN opts: parseTime=true, utf8mb4, UTC location
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)
}
// Pool tuning from config (optional)
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)
}
}
// Connectivity check with timeout
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
}