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:
2026-06-29 18:00:31 -07:00
parent 444b2d4cf4
commit f2b6c07ff7
9 changed files with 421 additions and 230 deletions
+5
View File
@@ -0,0 +1,5 @@
# Test coverage
coverage.out
coverage.html
*.test
+61 -1
View File
@@ -1,3 +1,63 @@
# config # 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.
-205
View File
@@ -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
View File
@@ -6,47 +6,56 @@ import (
"path/filepath" "path/filepath"
) )
// RootDir checks the current working directory and its parent directories for // RootDir walks from the working directory upward, looking for searchName as a
// a given filename and returns the absolute path to the directory. If the file // regular file. depth limits how many parent directories are checked (0 = CWD only).
// is not found within the specified depth or any other error occurs, it will func RootDir(searchName string, depth ...int) (string, error) {
// 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
cwd, err := os.Getwd() cwd, err := os.Getwd()
if err != nil { if err != nil {
panic(err) return "", err
} }
// Apply default depth if not provided
depthVal := 0 depthVal := 0
if len(depth) > 0 { if len(depth) > 0 {
depthVal = depth[0] depthVal = depth[0]
} }
// Walk directories up to the specified depth to find the file path, err := walkRootDir(searchName, cwd, depthVal)
path := walkRootDir(searchName, cwd, depthVal) if err != nil {
return "", err
}
if path == "" { 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 return filepath.Abs(path)
abs, err := filepath.Abs(path)
if err != nil || abs == "" {
panic(fmt.Errorf("RootDir failed to get absolute path: %v", err))
}
return abs
} }
// walkRootDir recursively checks directories up to the specified reverseDepth. // MustRootDir is like [RootDir] but panics on error.
func walkRootDir(searchName, path string, reverseDepth int) string { func MustRootDir(searchName string, depth ...int) string {
if _, err := os.Stat(filepath.Join(path, searchName)); err == nil { dir, err := RootDir(searchName, depth...)
return path 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 { 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
} }
+20
View File
@@ -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
+3
View File
@@ -5,9 +5,11 @@ go 1.24.0
require ( require (
github.com/go-playground/validator/v10 v10.26.0 github.com/go-playground/validator/v10 v10.26.0
github.com/spf13/viper v1.20.1 github.com/spf13/viper v1.20.1
github.com/stretchr/testify v1.10.0
) )
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/go-playground/locales v0.14.1 // 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/leodido/go-urn v1.4.0 // indirect
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // 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/rogpeppe/go-internal v1.14.1 // indirect
github.com/sagikazarmark/locafero v0.9.0 // indirect github.com/sagikazarmark/locafero v0.9.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect
+17
View File
@@ -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 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 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 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 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 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 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= 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 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 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 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 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 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 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 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 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 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 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 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 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 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 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= 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 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 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 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 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 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 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 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 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 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+191
View File
@@ -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
}
+91
View File
@@ -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)
})
}