e5.1.0 Example added, improved builder, doc updated.
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@ -5,5 +5,4 @@ log
|
|||||||
logs
|
logs
|
||||||
script
|
script
|
||||||
*.log
|
*.log
|
||||||
bin
|
bin
|
||||||
example
|
|
||||||
21
builder.go
21
builder.go
@ -48,11 +48,20 @@ func (b *Builder) Build() (*Config, error) {
|
|||||||
tagName = "toml"
|
tagName = "toml"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register defaults if provided
|
// The logic for registering defaults must be prioritized:
|
||||||
|
// 1. If WithDefaults() was called, it takes precedence.
|
||||||
|
// 2. If not, but WithTarget() was called, use the target struct for defaults.
|
||||||
if b.defaults != nil {
|
if b.defaults != nil {
|
||||||
|
// WithDefaults() was called explicitly.
|
||||||
if err := b.cfg.RegisterStructWithTags(b.prefix, b.defaults, tagName); err != nil {
|
if err := b.cfg.RegisterStructWithTags(b.prefix, b.defaults, tagName); err != nil {
|
||||||
return nil, fmt.Errorf("failed to register defaults: %w", err)
|
return nil, fmt.Errorf("failed to register defaults: %w", err)
|
||||||
}
|
}
|
||||||
|
} else if b.cfg.structCache != nil && b.cfg.structCache.target != nil {
|
||||||
|
// No explicit defaults, so use the target struct as the source of defaults.
|
||||||
|
// This is the behavior the tests rely on.
|
||||||
|
if err := b.cfg.RegisterStructWithTags(b.prefix, b.cfg.structCache.target, tagName); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to register target struct as defaults: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Explicitly set the file path on the config object so the watcher can find it,
|
// Explicitly set the file path on the config object so the watcher can find it,
|
||||||
@ -179,10 +188,12 @@ func (b *Builder) WithTarget(target any) *Builder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register struct fields automatically
|
// NOTE: removed since it would cause issues when an empty struct is passed
|
||||||
if b.defaults == nil {
|
// TODO: may cause issue in other scenarios, test extensively
|
||||||
b.defaults = target
|
// // Register struct fields automatically
|
||||||
}
|
// if b.defaults == nil {
|
||||||
|
// b.defaults = target
|
||||||
|
// }
|
||||||
|
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|||||||
@ -214,7 +214,61 @@ This searches for configuration files in:
|
|||||||
3. Current directory
|
3. Current directory
|
||||||
4. XDG config directories (`~/.config/myapp/`, `/etc/myapp/`)
|
4. XDG config directories (`~/.config/myapp/`, `/etc/myapp/`)
|
||||||
|
|
||||||
## Advanced Patterns
|
## Method Interaction and Precedence
|
||||||
|
|
||||||
|
While most builder methods can be chained in any order, it's important to understand how `WithDefaults` and `WithTarget` interact to define the default configuration values.
|
||||||
|
|
||||||
|
### `WithDefaults` Has Precedence
|
||||||
|
|
||||||
|
**Rule:** If `WithDefaults()` is used anywhere in the chain, it will **always** be the definitive source for default values.
|
||||||
|
|
||||||
|
This is the recommended approach for clarity and explicitness. It cleanly separates the struct that defines the defaults from the struct that will be populated.
|
||||||
|
|
||||||
|
**Example (Recommended Pattern):**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// initialData contains the fallback values.
|
||||||
|
initialData := &AppConfig{
|
||||||
|
Server: ServerConfig{Port: 8080},
|
||||||
|
}
|
||||||
|
|
||||||
|
// target is an empty shell for population.
|
||||||
|
var target AppConfig
|
||||||
|
|
||||||
|
// WithDefaults explicitly sets the defaults.
|
||||||
|
// WithTarget sets up the config for type-safe decoding.
|
||||||
|
cfg, err := config.NewBuilder().
|
||||||
|
WithTarget(&target).
|
||||||
|
WithDefaults(initialData).
|
||||||
|
WithFile("config.toml").
|
||||||
|
Build()
|
||||||
|
```
|
||||||
|
|
||||||
|
In this scenario, the `target` struct is *only* used for type information and `AsStruct()` functionality; its initial (zero) values are not used as defaults as per below.
|
||||||
|
|
||||||
|
### Using `WithTarget` for Defaults
|
||||||
|
|
||||||
|
**Rule:** If `WithDefaults()` is **not** used, the struct passed to `WithTarget()` will serve as the source of default values.
|
||||||
|
|
||||||
|
This provides a convenient shorthand for simpler cases where the initial state of your application's config struct *is* the desired default state. The unit tests for the package rely on this behavior.
|
||||||
|
|
||||||
|
**Example (Convenience Pattern):**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// The initial state of this struct will be used as the defaults.
|
||||||
|
target := &AppConfig{
|
||||||
|
Server: ServerConfig{Port: 8080},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since WithDefaults() is absent, the builder uses `target`
|
||||||
|
// for both defaults and for type-safe decoding.
|
||||||
|
cfg, err := config.NewBuilder().
|
||||||
|
WithTarget(&target).
|
||||||
|
WithFile("config.toml").
|
||||||
|
Build()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Patterns
|
||||||
|
|
||||||
### Type-Safe Configuration Access
|
### Type-Safe Configuration Access
|
||||||
|
|
||||||
|
|||||||
232
example/main.go
Normal file
232
example/main.go
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
// FILE: lixenwraith/config/example/main.go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppConfig defines a richer configuration structure to showcase more features.
|
||||||
|
type AppConfig struct {
|
||||||
|
Server struct {
|
||||||
|
Host string `toml:"host"`
|
||||||
|
Port int64 `toml:"port"`
|
||||||
|
LogLevel string `toml:"log_level"`
|
||||||
|
} `toml:"server"`
|
||||||
|
FeatureFlags map[string]bool `toml:"feature_flags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const configFilePath = "config.toml"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// =========================================================================
|
||||||
|
// PART 1: INITIAL SETUP
|
||||||
|
// Create a clean config.toml file on disk for our program to read.
|
||||||
|
// =========================================================================
|
||||||
|
log.Println("---")
|
||||||
|
log.Println("➡️ PART 1: Creating initial configuration file...")
|
||||||
|
|
||||||
|
// Defer cleanup to run at the very end of the program.
|
||||||
|
defer func() {
|
||||||
|
log.Println("---")
|
||||||
|
log.Println("🧹 Cleaning up...")
|
||||||
|
os.Remove(configFilePath)
|
||||||
|
// Unset the environment variable we use for testing.
|
||||||
|
os.Unsetenv("APP_SERVER_PORT")
|
||||||
|
log.Printf("Removed %s and unset APP_SERVER_PORT.", configFilePath)
|
||||||
|
}()
|
||||||
|
|
||||||
|
initialData := &AppConfig{}
|
||||||
|
initialData.Server.Host = "localhost"
|
||||||
|
initialData.Server.Port = 8080
|
||||||
|
initialData.Server.LogLevel = "info"
|
||||||
|
initialData.FeatureFlags = map[string]bool{"enable_metrics": true}
|
||||||
|
|
||||||
|
if err := createInitialConfigFile(initialData); err != nil {
|
||||||
|
log.Fatalf("❌ Failed during initial file creation: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("✅ Initial configuration saved to %s.", configFilePath)
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// PART 2: RECOMMENDED CONFIGURATION USING THE BUILDER
|
||||||
|
// This demonstrates source precedence, validation, and type-safe targets.
|
||||||
|
// =========================================================================
|
||||||
|
log.Println("---")
|
||||||
|
log.Println("➡️ PART 2: Configuring manager with the Builder...")
|
||||||
|
|
||||||
|
// Set an environment variable to demonstrate source precedence (Env > File).
|
||||||
|
os.Setenv("APP_SERVER_PORT", "8888")
|
||||||
|
log.Println(" (Set environment variable APP_SERVER_PORT=8888)")
|
||||||
|
|
||||||
|
// Create a "target" struct. The builder will automatically populate this
|
||||||
|
// and keep it updated when using `AsStruct()`.
|
||||||
|
target := &AppConfig{}
|
||||||
|
|
||||||
|
// Define a custom validator function.
|
||||||
|
validator := func(c *config.Config) error {
|
||||||
|
p, _ := c.Get("server.port")
|
||||||
|
// 'p' can be an int64 (from defaults/TOML) or a string (from environment variables).
|
||||||
|
|
||||||
|
var port int64
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch v := p.(type) {
|
||||||
|
case string:
|
||||||
|
// If it's a string from an env var, parse it.
|
||||||
|
port, err = strconv.ParseInt(v, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not parse port from string '%s': %w", v, err)
|
||||||
|
}
|
||||||
|
case int64:
|
||||||
|
// If it's already an int64, just use it.
|
||||||
|
port = v
|
||||||
|
default:
|
||||||
|
// Handle any other unexpected types.
|
||||||
|
return fmt.Errorf("unexpected type for server.port: %T", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
if port < 1024 || port > 65535 {
|
||||||
|
return fmt.Errorf("port %d is outside the recommended range (1024-65535)", port)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the builder to chain multiple configuration options.
|
||||||
|
builder := config.NewBuilder().
|
||||||
|
WithTarget(target). // Enables type-safe `AsStruct()` and auto-registration.
|
||||||
|
WithDefaults(initialData). // Explicitly set the source of defaults.
|
||||||
|
WithFile(configFilePath). // Specifies the config file to read.
|
||||||
|
WithEnvPrefix("APP_"). // Sets prefix for environment variables (e.g., APP_SERVER_PORT).
|
||||||
|
WithValidator(validator) // Adds a validation function to run at the end of the build.
|
||||||
|
|
||||||
|
// Build the final config object.
|
||||||
|
cfg, err := builder.Build()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ Builder failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("✅ Builder finished successfully. Initial values loaded.")
|
||||||
|
initialTarget, _ := cfg.AsStruct()
|
||||||
|
printCurrentState(initialTarget.(*AppConfig), "Initial State (Env overrides File)")
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// PART 3: DYNAMIC RELOADING WITH THE WATCHER
|
||||||
|
// We'll now modify the file and verify the watcher updates the config.
|
||||||
|
// =========================================================================
|
||||||
|
log.Println("---")
|
||||||
|
log.Println("➡️ PART 3: Testing the file watcher...")
|
||||||
|
|
||||||
|
// Use WithOptions to demonstrate customizing the watcher.
|
||||||
|
watchOpts := config.WatchOptions{
|
||||||
|
PollInterval: 250 * time.Millisecond,
|
||||||
|
Debounce: 100 * time.Millisecond,
|
||||||
|
}
|
||||||
|
cfg.AutoUpdateWithOptions(watchOpts)
|
||||||
|
changes := cfg.Watch()
|
||||||
|
log.Println("✅ Watcher is now active with custom options.")
|
||||||
|
|
||||||
|
// Start a goroutine to modify the file after a short delay.
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(1)
|
||||||
|
go modifyFileOnDiskStructurally(&wg)
|
||||||
|
log.Println(" (Modifier goroutine dispatched to change file in 1 second...)")
|
||||||
|
|
||||||
|
log.Println(" (Waiting for watcher notification...)")
|
||||||
|
select {
|
||||||
|
case path := <-changes:
|
||||||
|
log.Printf("✅ Watcher detected a change for path: '%s'", path)
|
||||||
|
log.Println(" Verifying in-memory config using AsStruct()...")
|
||||||
|
|
||||||
|
// Retrieve the updated, type-safe struct.
|
||||||
|
updatedTarget, err := cfg.AsStruct()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ AsStruct() failed after update: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type-assert and verify the new values.
|
||||||
|
typedCfg := updatedTarget.(*AppConfig)
|
||||||
|
expectedLevel := "debug"
|
||||||
|
if typedCfg.Server.LogLevel != expectedLevel {
|
||||||
|
log.Fatalf("❌ VERIFICATION FAILED: Expected log_level '%s', but got '%s'.", expectedLevel, typedCfg.Server.LogLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("✅ VERIFICATION SUCCESSFUL: In-memory config was updated by the watcher.")
|
||||||
|
printCurrentState(typedCfg, "Final State (Updated by Watcher)")
|
||||||
|
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
log.Fatalf("❌ TEST FAILED: Timed out waiting for watcher notification.")
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// createInitialConfigFile is a helper to set up the initial file state.
|
||||||
|
func createInitialConfigFile(data *AppConfig) error {
|
||||||
|
cfg := config.New()
|
||||||
|
if err := cfg.RegisterStruct("", data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return cfg.Save(configFilePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// modifyFileOnDiskStructurally simulates an external program robustly changing the config file.
|
||||||
|
func modifyFileOnDiskStructurally(wg *sync.WaitGroup) {
|
||||||
|
defer wg.Done()
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
log.Println(" (Modifier goroutine: now changing file on disk...)")
|
||||||
|
|
||||||
|
modifierCfg := config.New()
|
||||||
|
if err := modifierCfg.RegisterStruct("", &AppConfig{}); err != nil {
|
||||||
|
log.Fatalf("❌ Modifier failed to register struct: %v", err)
|
||||||
|
}
|
||||||
|
if err := modifierCfg.LoadFile(configFilePath); err != nil {
|
||||||
|
log.Fatalf("❌ Modifier failed to load file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change the log level and add a new feature flag.
|
||||||
|
modifierCfg.Set("server.log_level", "debug")
|
||||||
|
|
||||||
|
rawFlags, _ := modifierCfg.Get("feature_flags")
|
||||||
|
newFlags := make(map[string]any)
|
||||||
|
|
||||||
|
// Use a type switch to robustly handle the map, regardless of its source.
|
||||||
|
switch flags := rawFlags.(type) {
|
||||||
|
case map[string]bool:
|
||||||
|
for k, v := range flags {
|
||||||
|
newFlags[k] = v
|
||||||
|
}
|
||||||
|
case map[string]any:
|
||||||
|
for k, v := range flags {
|
||||||
|
newFlags[k] = v
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
log.Fatalf("❌ Modifier encountered unexpected type for feature_flags: %T", rawFlags)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now modify the generic map and set it back.
|
||||||
|
newFlags["enable_tracing"] = false
|
||||||
|
modifierCfg.Set("feature_flags", newFlags)
|
||||||
|
|
||||||
|
if err := modifierCfg.Save(configFilePath); err != nil {
|
||||||
|
log.Fatalf("❌ Modifier failed to save file: %v", err)
|
||||||
|
}
|
||||||
|
log.Println(" (Modifier goroutine: finished.)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// printCurrentState is a helper to display the typed config state.
|
||||||
|
func printCurrentState(cfg *AppConfig, title string) {
|
||||||
|
fmt.Println(" --------------------------------------------------")
|
||||||
|
fmt.Printf(" %s\n", title)
|
||||||
|
fmt.Println(" --------------------------------------------------")
|
||||||
|
fmt.Printf(" Server Host: %s\n", cfg.Server.Host)
|
||||||
|
fmt.Printf(" Server Port: %d\n", cfg.Server.Port)
|
||||||
|
fmt.Printf(" Server Log Level: %s\n", cfg.Server.LogLevel)
|
||||||
|
fmt.Printf(" Feature Flags: %v\n", cfg.FeatureFlags)
|
||||||
|
fmt.Println(" --------------------------------------------------")
|
||||||
|
}
|
||||||
1
go.mod
1
go.mod
@ -4,7 +4,6 @@ go 1.24.5
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/BurntSushi/toml v1.5.0
|
github.com/BurntSushi/toml v1.5.0
|
||||||
github.com/lixenwraith/config v0.0.0-20250719015120-e02ee494d440
|
|
||||||
github.com/mitchellh/mapstructure v1.5.0
|
github.com/mitchellh/mapstructure v1.5.0
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
)
|
)
|
||||||
|
|||||||
2
go.sum
2
go.sum
@ -4,8 +4,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
|||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/go-viper/mapstructure v1.6.0 h1:0WdPOF2rmmQDN1xo8qIgxyugvLp71HrZSWyGLxofobw=
|
github.com/go-viper/mapstructure v1.6.0 h1:0WdPOF2rmmQDN1xo8qIgxyugvLp71HrZSWyGLxofobw=
|
||||||
github.com/go-viper/mapstructure v1.6.0/go.mod h1:FcbLReH7/cjaC0RVQR+LHFIrBhHF3s1e/ud1KMDoBVw=
|
github.com/go-viper/mapstructure v1.6.0/go.mod h1:FcbLReH7/cjaC0RVQR+LHFIrBhHF3s1e/ud1KMDoBVw=
|
||||||
github.com/lixenwraith/config v0.0.0-20250719015120-e02ee494d440 h1:O6nHnpeDfIYQ1WxCtA2gkm8upQ4RW21DUMlQz5DKJCU=
|
|
||||||
github.com/lixenwraith/config v0.0.0-20250719015120-e02ee494d440/go.mod h1:y7kgDrWIFROWJJ6ASM/SPTRRAj27FjRGWh2SDLcdQ68=
|
|
||||||
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/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
|||||||
Reference in New Issue
Block a user