Files
config/config.go
Elijah Duffy 6addf74925 improve manager options workflow
- Break out ManagerOpts type
- Add default configuration values system
- Add WithOpts method
- Rename WithPrefix to WithEnvOverride
2025-06-12 17:50:15 -07:00

206 lines
5.7 KiB
Go

package config
import (
"fmt"
"path/filepath"
"strings"
"sync"
"github.com/go-playground/validator/v10"
"github.com/spf13/viper"
)
// ManagerOpts defines options for the configuration manager.
type ManagerOpts[T any] 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
// EnvPrefix is an optional environment variable prefix that will be used
// to override configuration values from environment variables. If set,
// viper will automatically read environment variables with this prefix.
EnvPrefix string
// Defaults is a map of default values for the configuration.
Defaults map[string]any
}
// Manager is the top-level configuration schema and viper instance. Always use
// the C() method to access the loaded configuration. Always use NewManager to
// create a new configuration manager. The type parameter T is the configuration
// schema that will be used to unmarshal the configuration file.
type Manager[T any] 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 any](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
}
// WithOpts sets additional options for the Manager.
func (m *Manager[T]) WithOpts(opts *ManagerOpts[T]) *Manager[T] {
if opts == nil {
return m
}
if opts.PreLoad != nil {
m.PreLoad = opts.PreLoad
}
if opts.PostLoad != nil {
m.PostLoad = opts.PostLoad
}
if opts.Defaults != nil {
for key, value := range opts.Defaults {
m.viper.SetDefault(key, value)
}
}
if opts.EnvPrefix != "" {
m.WithEnvOverride(opts.EnvPrefix)
}
return m
}
// WithEnvOverride sets the environment variable prefix for the viper instance
// and configures viper to automatically read environment variables.
func (m *Manager[T]) WithEnvOverride(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
}