From f2b6c07ff73b4f2e976003fa2a5c70b3450b9fa5 Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Mon, 29 Jun 2026 18:00:31 -0700 Subject: [PATCH] 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 --- .gitignore | 5 ++ README.md | 62 ++++++++++++++- config.go | 205 ------------------------------------------------ directory.go | 57 ++++++++------ doc.go | 20 +++++ go.mod | 3 + go.sum | 17 ++++ manager.go | 191 ++++++++++++++++++++++++++++++++++++++++++++ manager_test.go | 91 +++++++++++++++++++++ 9 files changed, 421 insertions(+), 230 deletions(-) create mode 100644 .gitignore delete mode 100644 config.go create mode 100644 doc.go create mode 100644 manager.go create mode 100644 manager_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..db37594 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# Test coverage +coverage.out +coverage.html + +*.test diff --git a/README.md b/README.md index ff4446a..67d15bd 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,63 @@ # config -config is a tiny library to streamline configuration loading and validation using [viper](https://github.com/spf13/viper) and [validator](https://github.com/go-playground/validator). +Typed configuration loading with [viper](https://github.com/spf13/viper) and [validator](https://github.com/go-playground/validator). + +## Install + +```bash +go get gitea.auvem.com/go-toolkit/config +``` + +## Quick start + +```go +type Schema struct { + AppName string `mapstructure:"appName" validate:"required"` +} + +dir, err := config.RootDir(".env.yml", 1) +if err != nil { + log.Fatal(err) +} + +m := config.NewManager[Schema](".env", "yaml", dir).WithOpts(&config.ManagerOpts[Schema]{ + EnvPrefix: "MYAPP", + PostLoad: func(cfg *Schema) error { + // derived fields — validated again after PostLoad + return nil + }, +}) + +if err := m.Load(); err != nil { + log.Fatal(err) +} + +cfg := m.C() // or m.Config() +_ = cfg.AppName +``` + +## Load vs C + +- `Load()` returns errors explicitly — use in `main` during startup +- `C()` and `Config()` lazy-load and panic on failure — convenient after startup + +## PostLoad hooks + +PostLoad runs after the first validation pass. The schema is **re-validated** after PostLoad so derived fields cannot bypass constraints. + +## RootDir + +`RootDir(name, depth)` returns `(string, error)`. `MustRootDir` panics on failure (for package-level `var` initialization). + +## Breaking changes (V1) + +| Before | After | +|--------|-------| +| `PostLoad func(*Manager[T])` | `PostLoad func(*T)` | +| `PreLoad func(*Manager[T])` | `PreLoad func(*T)` | +| `Manager.R` field | use `C()` / hook `*T` parameter | +| `RootDir(...) string` (panic) | `RootDir(...) (string, error)`; use `MustRootDir` for panic | + +## Companion types + +Configuration structs in [dbx](https://gitea.auvem.com/go-toolkit/dbx) and [courier](https://gitea.auvem.com/go-toolkit/courier) include mapstructure/validate tags compatible with this package. diff --git a/config.go b/config.go deleted file mode 100644 index 6e28671..0000000 --- a/config.go +++ /dev/null @@ -1,205 +0,0 @@ -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 -} diff --git a/directory.go b/directory.go index e769902..837c5ea 100644 --- a/directory.go +++ b/directory.go @@ -6,47 +6,56 @@ import ( "path/filepath" ) -// RootDir checks the current working directory and its parent directories for -// a given filename and returns the absolute path to the directory. If the file -// is not found within the specified depth or any other error occurs, it will -// panic. If depth is zero or negative, RootDir checks the current directory only. -func RootDir(searchName string, depth ...int) string { - // Get the current working directory +// RootDir walks from the working directory upward, looking for searchName as a +// regular file. depth limits how many parent directories are checked (0 = CWD only). +func RootDir(searchName string, depth ...int) (string, error) { cwd, err := os.Getwd() if err != nil { - panic(err) + return "", err } - // Apply default depth if not provided depthVal := 0 if len(depth) > 0 { depthVal = depth[0] } - // Walk directories up to the specified depth to find the file - path := walkRootDir(searchName, cwd, depthVal) + path, err := walkRootDir(searchName, cwd, depthVal) + if err != nil { + return "", err + } if path == "" { - panic(fmt.Errorf("RootDir checked %d directories, no '%s' file found", depthVal+1, searchName)) + return "", fmt.Errorf("RootDir checked %d directories, no '%s' file found", depthVal+1, searchName) } - // Try to get the absolute path of the found directory - abs, err := filepath.Abs(path) - if err != nil || abs == "" { - panic(fmt.Errorf("RootDir failed to get absolute path: %v", err)) - } - - return abs + return filepath.Abs(path) } -// walkRootDir recursively checks directories up to the specified reverseDepth. -func walkRootDir(searchName, path string, reverseDepth int) string { - if _, err := os.Stat(filepath.Join(path, searchName)); err == nil { - return path +// MustRootDir is like [RootDir] but panics on error. +func MustRootDir(searchName string, depth ...int) string { + dir, err := RootDir(searchName, depth...) + if err != nil { + panic(err) + } + return dir +} + +func walkRootDir(searchName, path string, reverseDepth int) (string, error) { + candidate := filepath.Join(path, searchName) + info, err := os.Stat(candidate) + if err == nil { + if info.IsDir() { + return "", fmt.Errorf("RootDir found directory %q, expected a regular file", candidate) + } + return path, nil } if reverseDepth > 0 { - return walkRootDir(searchName, path+"/..", reverseDepth-1) + parent := filepath.Dir(path) + if parent == path { + return "", nil + } + return walkRootDir(searchName, parent, reverseDepth-1) } - return "" + return "", nil } diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..75a2ffd --- /dev/null +++ b/doc.go @@ -0,0 +1,20 @@ +// Package config loads and validates typed configuration files using viper and +// go-playground/validator. +// +// # Workflow +// +// Define a struct with mapstructure and validate tags, create a [Manager] with +// [NewManager], optionally configure hooks via [Manager.WithOpts], then call +// [Manager.Load] during startup. Use [Manager.C] or [Manager.Config] for quick +// access after load. +// +// # Environment overrides +// +// Set [ManagerOpts.EnvPrefix] or call [Manager.WithEnvOverride]. Nested keys use +// dots in YAML and underscores in env vars (for example MYAPP_DATABASE_USER). +// +// # Root directory discovery +// +// [RootDir] and [MustRootDir] locate a marker file by walking up from the working +// directory—useful when config lives near a repo root. +package config diff --git a/go.mod b/go.mod index 7cb4307..a6d64cc 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,11 @@ go 1.24.0 require ( github.com/go-playground/validator/v10 v10.26.0 github.com/spf13/viper v1.20.1 + github.com/stretchr/testify v1.10.0 ) require ( + github.com/davecgh/go-spew v1.1.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -17,6 +19,7 @@ require ( github.com/leodido/go-urn v1.4.0 // indirect github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/sagikazarmark/locafero v0.9.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect diff --git a/go.sum b/go.sum index 7415f34..d32e0bb 100644 --- a/go.sum +++ b/go.sum @@ -3,7 +3,9 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= +github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -15,22 +17,31 @@ github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAu github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k= +github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= +github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= +github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= @@ -40,11 +51,17 @@ github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/manager.go b/manager.go new file mode 100644 index 0000000..f3b482d --- /dev/null +++ b/manager.go @@ -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 +} diff --git a/manager_test.go b/manager_test.go new file mode 100644 index 0000000..d4daf5d --- /dev/null +++ b/manager_test.go @@ -0,0 +1,91 @@ +package config_test + +import ( + "os" + "path/filepath" + "testing" + + "gitea.auvem.com/go-toolkit/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type testSchema struct { + Name string `mapstructure:"name" validate:"required"` + Port int `mapstructure:"port" validate:"required,min=1"` + Extra string `mapstructure:"extra"` +} + +func writeConfig(t *testing.T, dir, content string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(dir, "app.yaml"), []byte(content), 0o644)) +} + +func TestLoadAndC(t *testing.T) { + dir := t.TempDir() + writeConfig(t, dir, "name: svc\nport: 8080\n") + + m := config.NewManager[testSchema]("app", "yaml", dir) + require.NoError(t, m.Load()) + assert.Equal(t, "svc", m.C().Name) + assert.Equal(t, 8080, m.Config().Port) +} + +func TestPostLoadRevalidates(t *testing.T) { + dir := t.TempDir() + writeConfig(t, dir, "name: svc\nport: 8080\nextra: ok\n") + + m := config.NewManager[testSchema]("app", "yaml", dir).WithOpts(&config.ManagerOpts[testSchema]{ + PostLoad: func(cfg *testSchema) error { + cfg.Name = "" + return nil + }, + }) + + err := m.Load() + assert.Error(t, err) + assert.Contains(t, err.Error(), "post-load") +} + +func TestPostLoadSuccess(t *testing.T) { + dir := t.TempDir() + writeConfig(t, dir, "name: svc\nport: 8080\n") + + m := config.NewManager[testSchema]("app", "yaml", dir).WithOpts(&config.ManagerOpts[testSchema]{ + PostLoad: func(cfg *testSchema) error { + cfg.Extra = "derived" + return nil + }, + }) + + require.NoError(t, m.Load()) + assert.Equal(t, "derived", m.C().Extra) +} + +func TestRootDir(t *testing.T) { + dir := t.TempDir() + marker := filepath.Join(dir, ".marker") + require.NoError(t, os.WriteFile(marker, []byte("x"), 0o644)) + + orig, err := os.Getwd() + require.NoError(t, err) + t.Cleanup(func() { _ = os.Chdir(orig) }) + + sub := filepath.Join(dir, "sub") + require.NoError(t, os.Mkdir(sub, 0o755)) + require.NoError(t, os.Chdir(sub)) + + got, err := config.RootDir(".marker", 1) + require.NoError(t, err) + absDir, err := filepath.EvalSymlinks(dir) + require.NoError(t, err) + gotEval, err := filepath.EvalSymlinks(got) + require.NoError(t, err) + assert.Equal(t, absDir, gotEval) +} + +func TestMustRootDirPanics(t *testing.T) { + assert.Panics(t, func() { + config.MustRootDir("definitely-missing-marker-xyz", 0) + }) +}