195 lines
6.1 KiB
Go
195 lines
6.1 KiB
Go
// 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)
|
||
// - There’s 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
|
||
}
|