diff --git a/internal/platform/bootstrap/loader.go b/internal/platform/bootstrap/loader.go index 05fe724..68d5fa0 100644 --- a/internal/platform/bootstrap/loader.go +++ b/internal/platform/bootstrap/loader.go @@ -1,30 +1,51 @@ // Package bootstrap -// Path /internal/platform/bootstrap +// Path: /internal/platform/bootstrap // File: loader.go // -// Purpose: +// 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), +// 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: -// 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. +// 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 (important): +// HTTP stack order (matches code) // 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). +// 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. // -// Change log: +// 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 @@ -60,38 +81,37 @@ type App struct { } 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 } - - 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) - } - + // 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(), gin.Recovery()) 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, @@ -99,18 +119,22 @@ func Load(configPath string) (*App, error) { 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, @@ -127,9 +151,11 @@ func Load(configPath string) (*App, error) { 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, @@ -143,6 +169,7 @@ func openMySQL(cfg config.Config) (*sql.DB, error) { return nil, fmt.Errorf("mysql open: %w", err) } + // Pool tuning from config (optional) if dbCfg.MaxOpenConnections > 0 { db.SetMaxOpenConns(dbCfg.MaxOpenConnections) } @@ -155,6 +182,7 @@ func openMySQL(cfg config.Config) (*sql.DB, error) { } } + // Connectivity check with timeout ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() if err := db.PingContext(ctx); err != nil {