fix: validate config after PostLoad hooks
Re-run validator after PostLoad, return errors from RootDir, and add Config alias for C(). Hooks receive *T instead of *Manager. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+191
@@ -0,0 +1,191 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// Schema marks configuration struct types. Use a struct type for T in [NewManager].
|
||||
type Schema any
|
||||
|
||||
// ManagerOpts defines options for the configuration manager.
|
||||
type ManagerOpts[T Schema] struct {
|
||||
// PreLoad is called before reading the configuration file. The schema pointer
|
||||
// may be newly allocated and empty.
|
||||
PreLoad func(*T) error
|
||||
|
||||
// PostLoad is called after the file is unmarshalled and validated. Use it to
|
||||
// populate derived fields; validation runs again after PostLoad returns.
|
||||
PostLoad func(*T) error
|
||||
|
||||
// EnvPrefix enables environment variable overrides with this prefix.
|
||||
EnvPrefix string
|
||||
|
||||
// Defaults is a map of default values (dot-notation keys).
|
||||
Defaults map[string]any
|
||||
}
|
||||
|
||||
// Manager loads and validates a typed configuration schema via viper.
|
||||
// Prefer [Manager.Load] during startup and [Manager.C] for quick access after load.
|
||||
type Manager[T Schema] struct {
|
||||
preLoad func(*T) error
|
||||
postLoad func(*T) error
|
||||
|
||||
config *T
|
||||
loaded bool
|
||||
mu sync.RWMutex
|
||||
|
||||
viper *viper.Viper
|
||||
}
|
||||
|
||||
// NewManager creates a configuration manager for schema T. T must be a struct type.
|
||||
func NewManager[T Schema](configName, configType, configPath string) *Manager[T] {
|
||||
var zero T
|
||||
if reflect.TypeOf(zero).Kind() != reflect.Struct {
|
||||
panic(fmt.Sprintf("config: schema type %T must be a struct", zero))
|
||||
}
|
||||
|
||||
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 and enables AutomaticEnv.
|
||||
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 loaded configuration, loading the file first if needed.
|
||||
// Panics if loading or validation fails.
|
||||
func (m *Manager[T]) C() *T {
|
||||
m.mu.RLock()
|
||||
if m.loaded {
|
||||
cfg := m.config
|
||||
m.mu.RUnlock()
|
||||
return cfg
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.loaded {
|
||||
return m.config
|
||||
}
|
||||
|
||||
if err := m.loadUnsafe(); err != nil {
|
||||
panic(fmt.Errorf("failed to load configuration: %w", err))
|
||||
}
|
||||
|
||||
return m.config
|
||||
}
|
||||
|
||||
// Config is an alias for [Manager.C].
|
||||
func (m *Manager[T]) Config() *T {
|
||||
return m.C()
|
||||
}
|
||||
|
||||
// ConfigPath returns the absolute path to the configuration file that was used.
|
||||
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 underlying viper instance. Prefer [Manager.Load] and [Manager.C]
|
||||
// so validation and loaded state stay consistent.
|
||||
func (m *Manager[T]) Viper() *viper.Viper {
|
||||
return m.viper
|
||||
}
|
||||
|
||||
// Load reads and validates the configuration file.
|
||||
func (m *Manager[T]) Load() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.loaded {
|
||||
return nil
|
||||
}
|
||||
|
||||
return m.loadUnsafe()
|
||||
}
|
||||
|
||||
func (m *Manager[T]) loadUnsafe() error {
|
||||
m.config = new(T)
|
||||
|
||||
if m.preLoad != nil {
|
||||
if err := m.preLoad(m.config); err != nil {
|
||||
return fmt.Errorf("pre-load function failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.viper.ReadInConfig(); err != nil {
|
||||
return fmt.Errorf("viper failed to read config file: %w", err)
|
||||
}
|
||||
if err := m.viper.Unmarshal(m.config); err != nil {
|
||||
return fmt.Errorf("viper failed to unmarshal config file: %w", err)
|
||||
}
|
||||
|
||||
validate := validator.New(validator.WithRequiredStructEnabled())
|
||||
if err := validate.Struct(m.config); err != nil {
|
||||
return fmt.Errorf("validator failed to validate config file: %w", err)
|
||||
}
|
||||
|
||||
if m.postLoad != nil {
|
||||
if err := m.postLoad(m.config); err != nil {
|
||||
return fmt.Errorf("post-load function failed: %w", err)
|
||||
}
|
||||
if err := validate.Struct(m.config); err != nil {
|
||||
return fmt.Errorf("validator failed after post-load: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
m.loaded = true
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user