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:
@@ -0,0 +1,5 @@
|
||||
# Test coverage
|
||||
coverage.out
|
||||
coverage.html
|
||||
|
||||
*.test
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+33
-24
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
+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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user