package config import ( "fmt" "path/filepath" "strings" "sync" "github.com/go-playground/validator/v10" "github.com/spf13/viper" ) // Manager is the top-level configuration schema and viper instance. type Manager[T struct{}] struct { // PreLoad is an optional function that is called before loading the // configuration file. PreLoad func(*Manager[T]) error // PostLoad is an optional function that is called after loading the // configuration file. PostLoad func(*Manager[T]) error // R is a raw access to the configuration schema. WARNING: This does not // perform ANY validation or unmarshalling, so it should only be used if you // have already manually called the Load method and are sure that no errors // occured. R *T // loaded indicates whether the configuration has been loaded. loaded bool // mu is a mutex to ensure thread-safe access to the configuration. mu sync.RWMutex viper *viper.Viper } // NewManager creates a new configuration manager for schema T. Fields in T are // responsible for including any necessary `mapstructure` and `validate` tags // to ensure proper unmarshalling and validation of the configuration schema. func NewManager[T struct{}](configName, configType, configPath string) *Manager[T] { m := &Manager[T]{ viper: viper.New(), } m.viper.SetConfigName(configName) m.viper.SetConfigType(configType) m.viper.AddConfigPath(configPath) return m } // WithPrefix sets the environment variable prefix for the viper instance. Must // be set in order to use environment variables for configuration. func (m *Manager[T]) WithPrefix(prefix string) *Manager[T] { m.viper.SetEnvPrefix(prefix) m.viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) m.viper.AutomaticEnv() return m } // C returns the configuration schema. If the configuration has not been // loaded, C will load the configuration file before returning the schema. // Any errors encountered during loading will result in a panic. func (m *Manager[T]) C() *T { // First, try to acquire a read lock m.mu.RLock() if m.loaded { m.mu.RUnlock() return m.R // Already loaded, return the cached instance } m.mu.RUnlock() // Need to load the configuration, so acquire a write lock m.mu.Lock() defer m.mu.Unlock() // Double-check if loaded after acquiring the write lock if m.loaded { return m.R // Already loaded, return the cached instance } // Proceed to load the configuration if err := m.loadUnsafe(); err != nil { panic(fmt.Errorf("failed to load configuration: %w", err)) } return m.R // Return the loaded configuration } // ConfigPath returns the absolute path to the configuration file that was used. // If absolute path cannot be determined, the relative path is returned. func (m *Manager[T]) ConfigPath() string { if m.viper.ConfigFileUsed() == "" { return "" } absPath, err := filepath.Abs(m.viper.ConfigFileUsed()) if err != nil { return m.viper.ConfigFileUsed() } return absPath } // Viper returns the viper instance used by the configuration manager. func (m *Manager[T]) Viper() *viper.Viper { return m.viper } // Load reads and validates the configuration file, returning an error if any. func (m *Manager[T]) Load() error { // Make sure we have lock before checking if loaded m.mu.Lock() defer m.mu.Unlock() if m.loaded { return nil // Already loaded, no need to load again } return m.loadUnsafe() } // loadUnsafe performs the actual loading without acquiring locks. Caller MUST // hold the write lock. func (m *Manager[T]) loadUnsafe() error { // Initialize R to a new instance of T m.R = new(T) // Run the pre-load function if it is set if m.PreLoad != nil { if err := m.PreLoad(m); err != nil { return fmt.Errorf("pre-load function failed: %w", err) } } // Load the configuration file using viper if err := m.viper.ReadInConfig(); err != nil { return fmt.Errorf("viper failed to read config file: %w", err) } if err := m.viper.Unmarshal(m.R); err != nil { return fmt.Errorf("viper failed to unmarshal config file: %w", err) } // Validate the configuration schema using the validator package validate := validator.New(validator.WithRequiredStructEnabled()) if err := validate.Struct(m.R); err != nil { return fmt.Errorf("validator failed to validate config file: %w", err) } // Run the post-load function if it is set if m.PostLoad != nil { if err := m.PostLoad(m); err != nil { return fmt.Errorf("post-load function failed: %w", err) } } m.loaded = true return nil }