v0.2.0 toml-only, harden concurrency and env export, dep updates
Test / test (push) Canceled after 0s
Test / test (push) Canceled after 0s
This commit is contained in:
1 parent
3c35601179
commit
e08467bf38
42 files changed
+2533
-4274
No files matched your search
+37
-401
@@ -1,401 +1,37 @@
|
||||
# Access Patterns
|
||||
|
||||
This guide covers all methods for getting and setting configuration values, type conversions, and working with structured data.
|
||||
|
||||
**Always Register First**: Register paths before setting values
|
||||
**Use Type Assertions**: After struct registration, types are guaranteed
|
||||
|
||||
## Getting Values
|
||||
|
||||
### Basic Get
|
||||
|
||||
```go
|
||||
// Get returns (value, exists)
|
||||
value, exists := cfg.Get("server.port")
|
||||
if !exists {
|
||||
log.Fatal("server.port not configured")
|
||||
}
|
||||
|
||||
// Type assertion (safe after registration)
|
||||
port := value.(int64)
|
||||
```
|
||||
|
||||
### Type-Safe Access
|
||||
|
||||
When using struct registration, types are guaranteed:
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
Server struct {
|
||||
Port int64 `toml:"port"`
|
||||
Host string `toml:"host"`
|
||||
} `toml:"server"`
|
||||
}
|
||||
|
||||
cfg.RegisterStruct("", &Config{})
|
||||
|
||||
// After registration, type assertions are safe
|
||||
port, _ := cfg.Get("server.port")
|
||||
portNum := port.(int64) // Won't panic - type is enforced
|
||||
```
|
||||
|
||||
### Get from Specific Source
|
||||
|
||||
```go
|
||||
// Get value from specific source
|
||||
envPort, exists := cfg.GetSource(config.SourceEnv, "server.port")
|
||||
if exists {
|
||||
log.Printf("Port from environment: %v", envPort)
|
||||
}
|
||||
|
||||
// Check all sources
|
||||
sources := cfg.GetSources("server.port")
|
||||
for source, value := range sources {
|
||||
log.Printf("%s: %v", source, value)
|
||||
}
|
||||
```
|
||||
|
||||
### Struct Scanning
|
||||
|
||||
```go
|
||||
// Scan into struct
|
||||
var serverConfig struct {
|
||||
Host string `toml:"host"`
|
||||
Port int64 `toml:"port"`
|
||||
TLS struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
Cert string `toml:"cert"`
|
||||
} `toml:"tls"`
|
||||
}
|
||||
|
||||
if err := cfg.Scan(&serverConfig, "server"); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Use structured data
|
||||
log.Printf("Server: %s:%d", serverConfig.Host, serverConfig.Port)
|
||||
```
|
||||
|
||||
### Target Population
|
||||
|
||||
```go
|
||||
// Populate entire config struct
|
||||
var config AppConfig
|
||||
if err := cfg.Target(&config); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Or with builder pattern
|
||||
var config AppConfig
|
||||
cfg, _ := config.NewBuilder().
|
||||
WithTarget(&config).
|
||||
Build()
|
||||
|
||||
// Access directly
|
||||
fmt.Println(config.Server.Port)
|
||||
```
|
||||
|
||||
### GetTyped
|
||||
|
||||
Retrieves a single configuration value and decodes it to the specified type.
|
||||
|
||||
```go
|
||||
import "time"
|
||||
|
||||
// Returns an int, converting from string "9090" if necessary.
|
||||
port, err := config.GetTyped[int](cfg, "server.port")
|
||||
|
||||
// Returns a time.Duration, converting from string "5m30s".
|
||||
timeout, err := config.GetTyped[time.Duration](cfg, "server.timeout")
|
||||
```
|
||||
|
||||
### ScanTyped
|
||||
|
||||
A generic wrapper around `Scan` that allocates, populates, and returns a pointer to a struct of the specified type.
|
||||
|
||||
```go
|
||||
// Instead of:
|
||||
// var dbConf DBConfig
|
||||
// if err := cfg.Scan("database", &dbConf); err != nil { ... }
|
||||
|
||||
// You can write:
|
||||
dbConf, err := config.ScanTyped[DBConfig](cfg, "database")
|
||||
if err != nil {
|
||||
// ...
|
||||
}
|
||||
// dbConf is a *DBConfig```
|
||||
```
|
||||
|
||||
### Type-Aware Mode
|
||||
|
||||
```go
|
||||
var conf AppConfig
|
||||
|
||||
cfg, _ := config.NewBuilder().
|
||||
WithTarget(&conf).
|
||||
Build()
|
||||
|
||||
// Get updated struct anytime
|
||||
latest, err := cfg.AsStruct()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
appConfig := latest.(*AppConfig)
|
||||
```
|
||||
|
||||
## Setting Values
|
||||
|
||||
### Basic Set
|
||||
|
||||
```go
|
||||
// Set updates the highest priority source (default: CLI)
|
||||
if err := cfg.Set("server.port", int64(9090)); err != nil {
|
||||
log.Fatal(err) // Error if path not registered
|
||||
}
|
||||
```
|
||||
|
||||
### Set in Specific Source
|
||||
|
||||
```go
|
||||
// Set value in specific source
|
||||
cfg.SetSource(config.SourceEnv, "server.port", "8080")
|
||||
cfg.SetSource(config.SourceCLI, "debug", true)
|
||||
|
||||
// File source typically set via LoadFile, but can be manual
|
||||
cfg.SetSource(config.SourceFile, "feature.enabled", true)
|
||||
```
|
||||
|
||||
### Batch Updates
|
||||
|
||||
```go
|
||||
// Multiple updates
|
||||
updates := map[string]any{
|
||||
"server.port": int64(9090),
|
||||
"server.host": "0.0.0.0",
|
||||
"database.maxconns": int64(50),
|
||||
}
|
||||
|
||||
for path, value := range updates {
|
||||
if err := cfg.Set(path, value); err != nil {
|
||||
log.Printf("Failed to set %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Type Conversions
|
||||
|
||||
The package uses mapstructure for flexible type conversion:
|
||||
|
||||
```go
|
||||
// These all work for a string field
|
||||
cfg.Set("name", "value") // Direct string
|
||||
cfg.Set("name", 123) // Number → "123"
|
||||
cfg.Set("name", true) // Boolean → "true"
|
||||
|
||||
// For int64 fields
|
||||
cfg.Set("port", int64(8080)) // Direct
|
||||
cfg.Set("port", "8080") // String → int64
|
||||
cfg.Set("port", 8080.0) // Float → int64
|
||||
cfg.Set("port", int(8080)) // int → int64
|
||||
```
|
||||
|
||||
### Duration Handling
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
Timeout time.Duration `toml:"timeout"`
|
||||
}
|
||||
|
||||
// All these work
|
||||
cfg.Set("timeout", 30*time.Second) // Direct duration
|
||||
cfg.Set("timeout", "30s") // String parsing
|
||||
cfg.Set("timeout", "5m30s") // Complex duration
|
||||
```
|
||||
|
||||
### Network Types
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
IP net.IP `toml:"ip"`
|
||||
CIDR net.IPNet `toml:"cidr"`
|
||||
URL url.URL `toml:"url"`
|
||||
}
|
||||
|
||||
// Automatic parsing
|
||||
cfg.Set("ip", "192.168.1.1")
|
||||
cfg.Set("cidr", "10.0.0.0/8")
|
||||
cfg.Set("url", "https://example.com:8080/path")
|
||||
```
|
||||
|
||||
### Slice Handling
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
Tags []string `toml:"tags"`
|
||||
Ports []int `toml:"ports"`
|
||||
}
|
||||
|
||||
// Direct slice
|
||||
cfg.Set("tags", []string{"prod", "stable"})
|
||||
|
||||
// Comma-separated string (from env/CLI)
|
||||
cfg.Set("tags", "prod,stable,v2")
|
||||
|
||||
// Number arrays
|
||||
cfg.Set("ports", []int{8080, 8081, 8082})
|
||||
```
|
||||
|
||||
## Checking Configuration
|
||||
|
||||
### Path Registration
|
||||
|
||||
```go
|
||||
// Check if path is registered
|
||||
if _, exists := cfg.Get("server.port"); !exists {
|
||||
log.Fatal("server.port not registered")
|
||||
}
|
||||
|
||||
// Get all registered paths
|
||||
paths := cfg.GetRegisteredPaths("server.")
|
||||
for path := range paths {
|
||||
log.Printf("Registered: %s", path)
|
||||
}
|
||||
|
||||
// With default values
|
||||
defaults := cfg.GetRegisteredPathsWithDefaults("")
|
||||
for path, defaultVal := range defaults {
|
||||
log.Printf("%s = %v (default)", path, defaultVal)
|
||||
}
|
||||
```
|
||||
|
||||
### Validation
|
||||
|
||||
```go
|
||||
// Check required fields
|
||||
if err := cfg.Validate("api.key", "database.url"); err != nil {
|
||||
log.Fatal("Missing required config:", err)
|
||||
}
|
||||
|
||||
// Custom validation
|
||||
requiredPorts := []string{"server.port", "metrics.port"}
|
||||
for _, path := range requiredPorts {
|
||||
if val, exists := cfg.Get(path); exists {
|
||||
if port := val.(int64); port < 1024 {
|
||||
log.Fatalf("%s must be >= 1024", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Source Inspection
|
||||
|
||||
```go
|
||||
// Debug specific value
|
||||
path := "server.port"
|
||||
log.Printf("=== %s ===", path)
|
||||
log.Printf("Current: %v", cfg.Get(path))
|
||||
|
||||
sources := cfg.GetSources(path)
|
||||
for source, value := range sources {
|
||||
log.Printf(" %s: %v", source, value)
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
### Dynamic Configuration
|
||||
|
||||
```go
|
||||
// Change configuration at runtime
|
||||
func updatePort(cfg *config.Config, port int64) error {
|
||||
if port < 1 || port > 65535 {
|
||||
return fmt.Errorf("invalid port: %d", port)
|
||||
}
|
||||
return cfg.Set("server.port", port)
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Facade
|
||||
|
||||
```go
|
||||
type ConfigFacade struct {
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func (f *ConfigFacade) ServerPort() int64 {
|
||||
val, _ := f.cfg.Get("server.port")
|
||||
return val.(int64)
|
||||
}
|
||||
|
||||
func (f *ConfigFacade) SetServerPort(port int64) error {
|
||||
return f.cfg.Set("server.port", port)
|
||||
}
|
||||
|
||||
func (f *ConfigFacade) DatabaseURL() string {
|
||||
val, _ := f.cfg.Get("database.url")
|
||||
return val.(string)
|
||||
}
|
||||
```
|
||||
|
||||
### Default Fallbacks
|
||||
|
||||
```go
|
||||
// Helper for optional configuration
|
||||
func getOrDefault(cfg *config.Config, path string, defaultVal any) any {
|
||||
if val, exists := cfg.Get(path); exists {
|
||||
return val
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
// Usage
|
||||
timeout := getOrDefault(cfg, "timeout", 30*time.Second).(time.Duration)
|
||||
```
|
||||
|
||||
## Thread Safety
|
||||
|
||||
All access methods are thread-safe:
|
||||
|
||||
```go
|
||||
// Safe concurrent access
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Multiple readers
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
port, _ := cfg.Get("server.port")
|
||||
log.Printf("Port: %v", port)
|
||||
}()
|
||||
}
|
||||
|
||||
// Concurrent writes are safe too
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
cfg.Set("counter", atomic.AddInt64(&counter, 1))
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
### View All Configuration
|
||||
|
||||
```go
|
||||
// Debug output
|
||||
fmt.Println(cfg.Debug())
|
||||
|
||||
// Dump as TOML
|
||||
cfg.Dump() // Writes to stdout
|
||||
```
|
||||
|
||||
### Clone for Testing
|
||||
|
||||
```go
|
||||
// Create isolated copy for testing
|
||||
testCfg := cfg.Clone()
|
||||
testCfg.Set("server.port", int64(0)) // Random port for tests
|
||||
```
|
||||
# Access and conversion
|
||||
|
||||
| API | Behavior |
|
||||
| --- | --- |
|
||||
| `Get(path)` | Detached raw effective value and registration flag |
|
||||
| `GetSource(path, source)` | Detached source value; Default returns the registered default |
|
||||
| `GetSources(path)` | Detached map of explicit source overrides |
|
||||
| `GetTyped[T](cfg, path)` | Checked conversion to T |
|
||||
| `GetTypedWithDefault[T]` | Registers a default only when the path is absent; existing conversion errors are returned |
|
||||
| `Scan(&target, optionalPath)` | Transactional decoding of current values |
|
||||
| `ScanSource(source, &target, optionalPath)` | Decodes only the selected source |
|
||||
| `ScanTyped[T]` | Allocates and populates a typed destination |
|
||||
| `ScanMap(values, &target)` | Uses the same decoder without registration |
|
||||
| `AsStruct()` | Independent snapshot of the configured target type |
|
||||
|
||||
Raw environment/CLI values remain strings. TOML integer literals are int64.
|
||||
Defaults retain their declared types. Use typed access instead of assuming a raw
|
||||
source type. Matching uses `toml` tags, then case-sensitive Go field names. Tags
|
||||
with an empty name use the field name; `-` skips a field. Unknown input fields and
|
||||
unexported destination fields are ignored. Maps and slices replace existing
|
||||
containers; fixed arrays require exact lengths. Missing struct fields remain as
|
||||
provided by the caller. A present nil value clears its destination.
|
||||
|
||||
Conversions support signed/unsigned numbers, floats, booleans, strings, slices,
|
||||
arrays, string-keyed maps, structs and pointers. Durations, time.Time, net.IP,
|
||||
net.IPNet and url.URL are atomic configuration values, including pointer forms.
|
||||
They accept their native types or documented text representations. Comma-separated
|
||||
strings can populate typed numeric slices as well as string slices.
|
||||
|
||||
No truncating integer conversions or integer-to-float precision loss is allowed.
|
||||
Float narrowing may round within range. NaN/Inf and unsupported object graphs are
|
||||
rejected. All decoding failures leave the destination unchanged. There are no
|
||||
custom decode hooks or automatic text-unmarshal interfaces.
|
||||
|
||||
A Config owns accepted values. Modifying Get results, defaults after registration,
|
||||
clones or AsStruct snapshots cannot modify stored state. Do not mutate arguments
|
||||
concurrently while a call is reading them. Builder setup itself is not concurrent.
|
||||
+24
-169
@@ -1,174 +1,29 @@
|
||||
# Config Package Architecture
|
||||
# Architecture
|
||||
|
||||
## Overview
|
||||
Registered items hold a default, source overrides and an effective value selected
|
||||
by precedence. A Config RWMutex protects these maps and runtime options. Values
|
||||
are copied on admission and never mutated internally afterward; replacement removes
|
||||
or swaps references. Public reads return copies. Nested snapshots can therefore be
|
||||
assembled under a read lock and decoded after releasing it.
|
||||
|
||||
The `lixenwraith/config` package provides thread-safe configuration management with support for multiple sources, type-safe struct population, and live reconfiguration.
|
||||
The decoder handles the limited configuration object model: TOML tags, standard
|
||||
containers and atomic duration/time/network values. It delegates numeric conversion
|
||||
to lixenwraith/toml and uses explicit decimal parsing for environment/CLI strings.
|
||||
No mapstructure or external file parser is used in the runtime dependency graph.
|
||||
|
||||
## Logical Architecture
|
||||
Typed cache metadata uses a second mutex, always acquired after the Config read
|
||||
lock. Version changes invalidate its immutable decoded snapshot. Every AsStruct call
|
||||
returns a detached copy, so publication cannot race with existing readers. Build
|
||||
writes the supplied target once. Clone copies configuration state and options but
|
||||
does not copy active watchers or subscriptions.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ User API Layer │
|
||||
├────────────────┬────────────────┬────────────────────────┤
|
||||
│ Builder │ Type-Safe │ Dynamic Access │
|
||||
│ Pattern │ Struct API │ Key-Value API │
|
||||
├────────────────┴────────────────┴────────────────────────┤
|
||||
│ Core Config Engine │
|
||||
│ • Path Registration • Source Merging • Thread Safety │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ Source Loaders │
|
||||
│ File │ Environment │ CLI Arguments │ Defaults │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ Supporting Systems │
|
||||
│ Validation │ Type Decode │ File Watch │ Error Handling │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
File reads and parsing precede source publication. All candidate values are checked
|
||||
against the current schema, then the complete source is replaced under one lock.
|
||||
The watcher additionally verifies cancellation, timeout and generation at commit.
|
||||
One worker owns fingerprint/debounce state. Subscriber send/close operations share a
|
||||
mutex; stop does not wait for that worker while holding the Config mutex.
|
||||
|
||||
## Component Interactions
|
||||
|
||||
```
|
||||
Builder Flow:
|
||||
NewBuilder()
|
||||
↓
|
||||
Configure (WithTarget, WithFile, WithEnvPrefix)
|
||||
↓
|
||||
Build() → Register Paths → Load Sources → Merge Values
|
||||
↓
|
||||
Config Instance
|
||||
↓
|
||||
AsStruct() / Get() / Watch()
|
||||
|
||||
Value Resolution:
|
||||
Path Request
|
||||
↓
|
||||
Check Registration
|
||||
↓
|
||||
Search Sources (CLI → Env → File → Default)
|
||||
↓
|
||||
Type Conversion
|
||||
↓
|
||||
Return Value
|
||||
|
||||
Live Reload:
|
||||
File Change Detected
|
||||
↓
|
||||
Debounce Timer
|
||||
↓
|
||||
Reload File
|
||||
↓
|
||||
Merge with Sources
|
||||
↓
|
||||
Notify Watchers
|
||||
```
|
||||
|
||||
## File Organization
|
||||
|
||||
### Core (`config.go`)
|
||||
- **Config struct**: Thread-safe state management with atomic versioning
|
||||
- **Initialization**: `New()`, `NewWithOptions()`
|
||||
- **State Management**: `Get()`, `Set()`, `GetSource()`, `SetSource()`
|
||||
- **Precedence Control**: `SetPrecedence()`, `GetPrecedence()`, `computeValue()`
|
||||
- **Cache Management**: `AsStruct()`, `populateStruct()`, `invalidateCache()`
|
||||
- **Source Operations**: `Reset()`, `ResetSource()`, `GetSources()`
|
||||
|
||||
### Registration (`register.go`)
|
||||
- **Path Registration**: `Register()`, `RegisterWithEnv()`, `RegisterRequired()`, `Unregister()`
|
||||
- **Struct Registration**: `RegisterStruct()`, `RegisterStructWithTags()`, `registerFields()`
|
||||
- **Discovery**: `GetRegisteredPaths()`, `GetRegisteredPathsWithDefaults()`
|
||||
- **Decoding Bridge**: `Scan()`, `ScanSource()` - delegates to decode.go
|
||||
|
||||
### Builder Pattern (`builder.go`)
|
||||
- **Fluent API**: `NewBuilder()`, `Build()`, `MustBuild()`
|
||||
- **Configuration**: `WithTarget()`, `WithDefaults()`, `WithFile()`, `WithEnvPrefix()`
|
||||
- **Discovery Integration**: `WithFileDiscovery()`
|
||||
- **Validation**: `WithValidator()`, `WithTypedValidator()`
|
||||
- **Security**: `WithSecurityOptions()`
|
||||
|
||||
### Source Loading (`loader.go`)
|
||||
- **Multi-Source Loading**: `loadWithOptions()`
|
||||
- **Individual Sources**: `LoadFile()`, `LoadEnv()`, `LoadCLI()`
|
||||
- **Persistence**: `Save()`, `SaveSource()`, `atomicWriteFile()`
|
||||
- **Environment Mapping**: `DiscoverEnv()`, `ExportEnv()`, `defaultEnvTransform()`
|
||||
- **Parsing**: `parseArgs()`, `parseValue()`, `detectFileFormat()`
|
||||
- **Security Checks**: Path traversal, file ownership, size limits
|
||||
|
||||
### Type System (`decode.go`)
|
||||
- **Unified Decoding**: `unmarshal()` - single authoritative decoder
|
||||
- **Hook Composition**: `getDecodeHook()`, `customDecodeHook()`
|
||||
- **Type Converters**: `jsonNumberHookFunc()`, `stringToNetIPHookFunc()`, `stringToURLHookFunc()`
|
||||
- **Navigation**: `navigateToPath()`, `normalizeMap()`
|
||||
|
||||
### File Watching (`watch.go`)
|
||||
- **Auto-Reload**: `AutoUpdate()`, `AutoUpdateWithOptions()`, `StopAutoUpdate()`
|
||||
- **Subscriptions**: `Watch()`, `WatchWithOptions()`, `WatchFile()`
|
||||
- **Watcher State**: `watcher` struct with debouncing, polling, version tracking
|
||||
- **Change Detection**: `checkAndReload()`, `performReload()`, `notifyWatchers()`
|
||||
- **Resource Management**: Max watcher limits, graceful shutdown
|
||||
|
||||
### Convenience API (`utility.go`)
|
||||
- **Quick Setup**: `Quick()`, `QuickCustom()`, `MustQuick()`, `QuickTyped()`
|
||||
- **Flag Integration**: `GenerateFlags()`, `BindFlags()`
|
||||
- **Type-Safe Access**: `GetTyped()`, `GetTypedWithDefault()`, `ScanTyped()`
|
||||
- **Utilities**: `Validate()`, `Debug()`, `Dump()`, `Clone()`, `ScanMap()`
|
||||
|
||||
### File Discovery (`discovery.go`)
|
||||
- **Options**: `FileDiscoveryOptions` struct with search strategies
|
||||
- **XDG Compliance**: `getXDGConfigPaths()` for standard config locations
|
||||
- **Defaults**: `DefaultDiscoveryOptions()` with sensible patterns
|
||||
|
||||
### Validation Library (`validator.go`)
|
||||
- **Network**: `Port()`, `IPAddress()`, `IPv4Address()`, `IPv6Address()`
|
||||
- **Numeric**: `Positive()`, `NonNegative()`, `Range()`
|
||||
- **String**: `NonEmpty()`, `Pattern()`, `OneOf()`, `URLPath()`
|
||||
|
||||
### Error System (`error.go`)
|
||||
- **Categories**: Sentinel errors for `errors.Is()` checking
|
||||
- **Wrapping**: `wrapError()` maintains dual error chains
|
||||
|
||||
### Internal Utilities (`helper.go`)
|
||||
- **Map Operations**: `flattenMap()`, `setNestedValue()`
|
||||
- **Validation**: `isValidKeySegment()` for path validation
|
||||
|
||||
### Constants (`constant.go`)
|
||||
- **Shared Constants**: Defines shared constants for timing, formats, limits, file watcher and discovery.
|
||||
|
||||
## Data Flow Patterns
|
||||
|
||||
### Configuration Loading
|
||||
1. **Registration Phase**: Paths registered with types/defaults
|
||||
2. **Source Collection**: Each source populates its layer
|
||||
3. **Merge Phase**: Precedence determines final values
|
||||
4. **Population Phase**: Struct populated via reflection
|
||||
|
||||
### Thread Safety Model
|
||||
- **Read Operations**: Multiple concurrent readers via RLock
|
||||
- **Write Operations**: Exclusive access via Lock
|
||||
- **Atomic Updates**: Prepare → Lock → Swap → Unlock pattern
|
||||
- **Version Tracking**: Atomic counter for cache invalidation
|
||||
|
||||
### Error Propagation
|
||||
- **Wrapped Errors**: Category + specific error detail
|
||||
- **Early Return**: Builder accumulates first error
|
||||
- **Panic Mode**: MustBuild for fail-fast scenarios
|
||||
|
||||
## Extension Points
|
||||
|
||||
### Custom Types
|
||||
Implement decode hooks in `customDecodeHook()`:
|
||||
```go
|
||||
func(f reflect.Type, t reflect.Type, data any) (any, error)
|
||||
```
|
||||
|
||||
### Source Transformation
|
||||
Environment variable mapping via `WithEnvTransform()`:
|
||||
```go
|
||||
func(path string) string // Return env var name
|
||||
```
|
||||
|
||||
### Validation Layers
|
||||
- Pre-decode: `WithValidator(func(*Config) error)`
|
||||
- Post-decode: `WithTypedValidator(func(*YourType) error)`
|
||||
|
||||
### File Discovery
|
||||
Search strategy via `WithFileDiscovery()`:
|
||||
- CLI flag check → Env var → XDG paths → Current dir
|
||||
Save normalizes atomic application values to TOML strings, marshals before touching
|
||||
the target file, then writes/syncs/renames a temporary file. Comment text is captured
|
||||
by lexer tokens and emitted as a preamble. The codec owns syntax, while Config owns
|
||||
source precedence, snapshot isolation and live reload publication.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Config audit and TOML migration
|
||||
|
||||
This branch updates only lixenwraith/config. It uses Go 1.27.1 and the user's merged
|
||||
TOML main commit `b59d068ff6a48b86365a65e1121343f672f77a67`, pinned as
|
||||
`v0.0.0-20260928180512-b59d068ff6a4` because no version tag existed at integration.
|
||||
Logwisp adoption and other TOML consumers are separate follow-up work.
|
||||
|
||||
## Confirmed defects and fixes
|
||||
|
||||
| Finding | Result | Regression evidence |
|
||||
| --- | --- | --- |
|
||||
| Fractions/overflow could pass through weak numeric conversion | Checked TOML conversion and explicit destination-width parsing | `TestCheckedConversionsAndRollback`, `FuzzConfigNumericConversions` |
|
||||
| Options, input containers and returned values could alias caller state | Owned options/values and detached reads/clones | `TestOptionsAreOwned`, `TestOwnedValuesAndSnapshots` |
|
||||
| Updating the cached target changed old AsStruct results | Immutable cached result and a fresh snapshot per call | `TestOwnedValuesAndSnapshots` |
|
||||
| ExportEnv compared uncomparable slices/maps directly | Deep equality, with callbacks outside Config locks | `TestExportEnvContainerValues` |
|
||||
| Invalid sources could be published before decoding failed | Whole-source validation before mutation | `TestSourceReplacementIsTransactional` |
|
||||
| Removed env/CLI values survived subsequent loads | Successful loads replace their complete source | `TestSourceReplacementIsTransactional` |
|
||||
| A timed-out reload kept running and could publish after stop | Context, watcher identity, path and generation checked at commit | `TestReloadCannotPublishAfterStopTimeoutOrNewLoad` |
|
||||
| A timeout could suppress retries of unchanged content | Retry timeouts while deduplicating their notifications | `TestWatcherRetriesTimedOutUnchangedContent` |
|
||||
| Debounce did not cover errors from partial in-place writes | Errors and successful updates wait for a quiet interval | `TestWatcherDebouncesErrorsAndRecovers` |
|
||||
| Metadata-only polling missed some replacements/edits | Content fingerprints on every poll; deletion/recreation recovery | `TestWatcherSameMetadataReplacementAndRecreation` |
|
||||
| Source saving lost comments and widened permissions | Comment preamble, existing modes retained, new files 0600 | `TestSaveCommentsPermissionsAndFailure` |
|
||||
| Atomic types were inconsistently registered/serialized | Exact type matching for duration/time/IP/CIDR/URL and pointer forms | `TestAtomicValuesAndFileRoundTrip` |
|
||||
| A missing file could hide a joined invalid CLI/environment error | Missing-file fallback only when other sources succeed | `TestBuilderDoesNotHideInvalidSourceBehindMissingFile` |
|
||||
| Required registration was a no-op; overlapping/duplicate fields were ambiguous | Required paths validated at Build; staged registration rejects conflicts | `TestRegistrationAndRequiredContracts` |
|
||||
| Default source selection and narrow target schemas could be inconsistent | Default precedence honored; explicit defaults must match the typed target | `TestRegistrationAndRequiredContracts` |
|
||||
|
||||
Reproduction against original main `ccd280b`: fractional-to-integer conversion
|
||||
silently produced 1 from 1.5; modifying an input slice changed its registered
|
||||
default; an out-of-range CLI batch returned success; and requesting a refreshed
|
||||
AsStruct changed an earlier result under its reader. All four were reproduced with
|
||||
public-API probes; committed regressions cover the corrected behavior.
|
||||
|
||||
## Dependency and scope decision
|
||||
|
||||
Mapstructure is replaceable for the package's actual object model. The replacement
|
||||
supports TOML field names, ordinary containers, pointers and the existing standard
|
||||
atomic types. It does not reproduce mapstructure's broad weak coercions or add a
|
||||
custom hook API. Numeric correctness is shared with lixenwraith/toml.
|
||||
|
||||
`go list -deps ./...` reports only the standard library, lixenwraith/toml and this
|
||||
module. BurntSushi TOML, mapstructure and YAML are removed from runtime code.
|
||||
Testify remains test-only, so its transitive YAML dependency still appears in go.mod
|
||||
and go.sum. JSON/YAML files and tags are unsupported, with explicit rejection tests.
|
||||
|
||||
No global reflection cache or lock was introduced. Known container sizes are used
|
||||
for allocation. Stored values are immutable after admission. One watcher worker
|
||||
owns observation/debounce state, and subscription cleanup has no per-subscriber
|
||||
goroutine. File I/O and environment callbacks execute outside Config locks.
|
||||
|
||||
## Verification and limits
|
||||
|
||||
The full unit and race suites and go vet pass with Go 1.27.1. Numeric fuzzing ran
|
||||
for 10 seconds and passed 234,155 executions, covering full uint64 preservation,
|
||||
signed boundaries and narrowing. The 386 and Windows test binaries cross-compile
|
||||
locally. This host cannot execute 386 binaries; CI executes that suite and also
|
||||
runs race tests, vet, fuzzing and Windows compilation. Final checks are on the PR.
|
||||
|
||||
TOML's supported subset is unchanged. Native datetime syntax is not added; time.Time
|
||||
is represented by a quoted RFC3339Nano string. Full uint64 works in memory but values
|
||||
above MaxInt64 cannot be saved as TOML integers. Comment text survives as a preamble;
|
||||
locations and formatting do not. Unknown file keys are not saved. Depth is bounded
|
||||
at 128 for configuration graphs. Live application validators remain an application
|
||||
responsibility; Builder validators run only during initialization. Notifications
|
||||
are best effort, and file timeouts prevent late publication without forcibly
|
||||
interrupting kernel I/O. See the linked topic guides for migration details.
|
||||
+32
-373
@@ -1,373 +1,32 @@
|
||||
# Builder Pattern
|
||||
|
||||
The builder pattern provides fine-grained control over configuration initialization and loading behavior.
|
||||
|
||||
## Basic Builder Usage
|
||||
|
||||
```go
|
||||
cfg, err := config.NewBuilder().
|
||||
WithDefaults(defaultStruct).
|
||||
WithEnvPrefix("MYAPP_").
|
||||
WithFile("config.toml").
|
||||
Build()
|
||||
```
|
||||
|
||||
## Builder Methods
|
||||
|
||||
### WithDefaults
|
||||
|
||||
Register a struct containing default values:
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
Host string `toml:"host"`
|
||||
Port int `toml:"port"`
|
||||
}
|
||||
|
||||
defaults := &Config{
|
||||
Host: "localhost",
|
||||
Port: 8080,
|
||||
}
|
||||
|
||||
cfg, _ := config.NewBuilder().
|
||||
WithDefaults(defaults).
|
||||
Build()
|
||||
```
|
||||
|
||||
### WithTarget
|
||||
|
||||
Enable type-aware mode with automatic struct population:
|
||||
|
||||
```go
|
||||
var appConfig Config
|
||||
|
||||
cfg, _ := config.NewBuilder().
|
||||
WithTarget(&appConfig). // Registers struct and enables AsStruct()
|
||||
WithFile("config.toml").
|
||||
Build()
|
||||
|
||||
// Access populated struct
|
||||
populated, _ := cfg.AsStruct()
|
||||
config := populated.(*Config)
|
||||
```
|
||||
|
||||
### WithTagName
|
||||
|
||||
Use different struct tags for field mapping:
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
Server struct {
|
||||
Host string `json:"host"` // Using JSON tags
|
||||
Port int `json:"port"`
|
||||
} `json:"server"`
|
||||
}
|
||||
|
||||
cfg, _ := config.NewBuilder().
|
||||
WithDefaults(&Config{}).
|
||||
WithTagName("json"). // Use json tags instead of toml
|
||||
Build()
|
||||
```
|
||||
|
||||
Supported tag names: `toml` (default), `json`, `yaml`
|
||||
|
||||
### WithPrefix
|
||||
|
||||
Add a prefix to all registered paths:
|
||||
|
||||
```go
|
||||
cfg, _ := config.NewBuilder().
|
||||
WithDefaults(serverConfig).
|
||||
WithPrefix("server"). // All paths prefixed with "server."
|
||||
Build()
|
||||
|
||||
// Access as "server.host" instead of just "host"
|
||||
host, _ := cfg.Get("server.host")
|
||||
```
|
||||
|
||||
### WithEnvPrefix
|
||||
|
||||
Set environment variable prefix:
|
||||
|
||||
```go
|
||||
cfg, err := config.NewBuilder().
|
||||
WithEnvPrefix("MYAPP_").
|
||||
Build()
|
||||
|
||||
// Reads from MYAPP_SERVER_PORT for "server.port"
|
||||
```
|
||||
|
||||
### WithSources
|
||||
|
||||
Configure source precedence order:
|
||||
|
||||
```go
|
||||
// Environment variables take highest priority
|
||||
cfg, _ := config.NewBuilder().
|
||||
WithSources(
|
||||
config.SourceEnv,
|
||||
config.SourceFile,
|
||||
config.SourceCLI,
|
||||
config.SourceDefault,
|
||||
).
|
||||
Build()
|
||||
```
|
||||
|
||||
### WithEnvTransform
|
||||
|
||||
Custom environment variable name mapping:
|
||||
|
||||
```go
|
||||
cfg, _ := config.NewBuilder().
|
||||
WithEnvTransform(func(path string) string {
|
||||
// Custom mapping logic
|
||||
switch path {
|
||||
case "server.port":
|
||||
return "PORT" // Use $PORT instead of $MYAPP_SERVER_PORT
|
||||
case "database.url":
|
||||
return "DATABASE_URL"
|
||||
default:
|
||||
// Default transformation
|
||||
return "MYAPP_" + strings.ToUpper(
|
||||
strings.ReplaceAll(path, ".", "_"),
|
||||
)
|
||||
}
|
||||
}).
|
||||
Build()
|
||||
```
|
||||
|
||||
### WithEnvWhitelist
|
||||
|
||||
Limit which configuration paths check environment variables:
|
||||
|
||||
```go
|
||||
cfg, _ := config.NewBuilder().
|
||||
WithEnvWhitelist(
|
||||
"server.port",
|
||||
"database.url",
|
||||
"api.key",
|
||||
). // Only these paths read from env
|
||||
Build()
|
||||
```
|
||||
|
||||
### WithValidator
|
||||
|
||||
Add validation functions that run *before* the target struct is populated. These validators operate on the raw `*config.Config` object and are suitable for checking required paths or formats before type conversion.
|
||||
|
||||
```go
|
||||
// Validator runs on raw, pre-decoded values.
|
||||
cfg, _ := config.NewBuilder().
|
||||
WithDefaults(defaults).
|
||||
WithValidator(func(c *config.Config) error {
|
||||
// Validate port range
|
||||
port, _ := c.Get("server.port")
|
||||
if p := port.(int64); p < 1024 || p > 65535 {
|
||||
return fmt.Errorf("port must be between 1024-65535")
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
WithValidator(func(c *config.Config) error {
|
||||
// Validate required fields
|
||||
return c.Validate("api.key", "database.url")
|
||||
}).
|
||||
Build()
|
||||
```
|
||||
|
||||
For type-safe validation, see `WithTypedValidator`.
|
||||
|
||||
### WithTypedValidator
|
||||
|
||||
Add a type-safe validation function that runs *after* the configuration has been fully loaded and decoded into the target struct (set by `WithTarget`). This is the recommended approach for most validation logic.
|
||||
|
||||
The validation function must accept a single argument: a pointer to the same struct type that was passed to `WithTarget`.
|
||||
```go
|
||||
type AppConfig struct {
|
||||
Server struct {
|
||||
Port int64 `toml:"port"`
|
||||
} `toml:"server"`
|
||||
}
|
||||
|
||||
var target AppConfig
|
||||
|
||||
cfg, err := config.NewBuilder().
|
||||
WithTarget(&target).
|
||||
WithFile("config.toml").
|
||||
WithTypedValidator(func(conf *AppConfig) error {
|
||||
if conf.Server.Port < 1024 || conf.Server.Port > 65535 {
|
||||
return fmt.Errorf("port %d is outside the valid range", conf.Server.Port)
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Build()
|
||||
```
|
||||
|
||||
### WithFile
|
||||
|
||||
Set configuration file path:
|
||||
|
||||
```go
|
||||
cfg, _ := config.NewBuilder().
|
||||
WithFile("/etc/myapp/config.toml").
|
||||
Build()
|
||||
```
|
||||
|
||||
### WithArgs
|
||||
|
||||
Override command-line arguments (default is os.Args[1:]):
|
||||
|
||||
```go
|
||||
cfg, _ := config.NewBuilder().
|
||||
WithArgs([]string{"--debug", "--server.port=9090"}).
|
||||
Build()
|
||||
```
|
||||
|
||||
### WithFileDiscovery
|
||||
|
||||
Enable automatic configuration file discovery:
|
||||
|
||||
```go
|
||||
cfg, _ := config.NewBuilder().
|
||||
WithFileDiscovery(config.FileDiscoveryOptions{
|
||||
Name: "myapp",
|
||||
Extensions: []string{".toml", ".conf"},
|
||||
EnvVar: "MYAPP_CONFIG",
|
||||
CLIFlag: "--config",
|
||||
UseXDG: true,
|
||||
}).
|
||||
Build()
|
||||
```
|
||||
|
||||
This searches for configuration files in:
|
||||
1. Path specified by `--config` flag
|
||||
2. Path in `$MYAPP_CONFIG` environment variable
|
||||
3. Current directory
|
||||
4. XDG config directories (`~/.config/myapp/`, `/etc/myapp/`)
|
||||
|
||||
## 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
|
||||
|
||||
```go
|
||||
type AppConfig struct {
|
||||
Server ServerConfig `toml:"server"`
|
||||
DB DBConfig `toml:"database"`
|
||||
}
|
||||
|
||||
var conf AppConfig
|
||||
|
||||
cfg, _ := config.NewBuilder().
|
||||
WithTarget(&conf).
|
||||
WithFile("config.toml").
|
||||
Build()
|
||||
|
||||
// Direct struct access after building
|
||||
fmt.Printf("Port: %d\n", conf.Server.Port)
|
||||
|
||||
// Or get updated struct anytime
|
||||
latest, _ := cfg.AsStruct()
|
||||
appConf := latest.(*AppConfig)
|
||||
```
|
||||
|
||||
### Multi-Stage Validation
|
||||
|
||||
```go
|
||||
cfg, err := config.NewBuilder().
|
||||
WithDefaults(defaults).
|
||||
// Stage 1: Validate structure
|
||||
WithValidator(validateStructure).
|
||||
// Stage 2: Validate values
|
||||
WithValidator(validateRanges).
|
||||
// Stage 3: Validate relationships
|
||||
WithValidator(validateRelationships).
|
||||
Build()
|
||||
|
||||
func validateStructure(c *config.Config) error {
|
||||
required := []string{"server.host", "server.port", "database.url"}
|
||||
return c.Validate(required...)
|
||||
}
|
||||
|
||||
func validateRanges(c *config.Config) error {
|
||||
port, _ := c.Get("server.port")
|
||||
if p := port.(int64); p < 1 || p > 65535 {
|
||||
return fmt.Errorf("invalid port: %d", p)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRelationships(c *config.Config) error {
|
||||
// Validate that related values make sense together
|
||||
// e.g., if SSL is enabled, ensure cert paths are set
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
The builder accumulates errors and returns them on `Build()`:
|
||||
|
||||
```go
|
||||
cfg, err := config.NewBuilder().
|
||||
WithTarget(nil). // Error: nil target
|
||||
WithTagName("invalid"). // Error: unsupported tag
|
||||
Build()
|
||||
|
||||
if err != nil {
|
||||
// err contains first error encountered
|
||||
}
|
||||
```
|
||||
|
||||
For panic on error use `MustBuild()`
|
||||
# Builder
|
||||
|
||||
`NewBuilder` collects defaults, an optional typed target, file, environment and CLI
|
||||
settings. Build registers defaults, loads each configured source, checks required
|
||||
paths, runs Config validators and then typed validators. Missing files are nonfatal
|
||||
only when other loading and validation succeeds.
|
||||
|
||||
| Option | Purpose |
|
||||
| --- | --- |
|
||||
| `WithDefaults(value)` | Explicit struct defaults |
|
||||
| `WithTarget(&value)` | Target type and fallback defaults; filled once by Build |
|
||||
| `WithPrefix("section")` | Prefix registered paths and the typed snapshot section |
|
||||
| `WithFile(path)` | TOML file; missing path can be watched for later creation |
|
||||
| `WithFileDiscovery(options)` | Search CLI, environment and configured directories |
|
||||
| `WithEnvPrefix`, `WithEnvTransform`, `WithEnvWhitelist` | Environment mapping |
|
||||
| `WithArgs(args)` | Explicit CLI input; nil disables it |
|
||||
| `WithSources(...)` | Source precedence; first wins |
|
||||
| `WithSecurityOptions(options)` | File traversal, owner and size checks |
|
||||
| `WithValidator(fn)` | Build-time validation using Config accessors |
|
||||
| `WithTypedValidator(func(*T) error)` | Build-time validation of the target snapshot |
|
||||
|
||||
Only TOML tags and file format are supported. WithTagName accepts `toml`;
|
||||
WithFileFormat accepts `toml` or `auto`. JSON/YAML modes are removed.
|
||||
|
||||
Use a builder from one goroutine during initialization. Runtime updates go through
|
||||
Config methods. `AsStruct` returns independent snapshots and never mutates the
|
||||
original target after Build. Validators run during Build, not on every later Set
|
||||
or reload; later changes are always checked against registered value types.
|
||||
|
||||
Explicit defaults must use the same struct type as the target. `WithDefaults`
|
||||
takes precedence over the initial target values. A typed nil validator is ignored;
|
||||
signatures must match the target pointer and return error.
|
||||
+15
-184
@@ -1,188 +1,19 @@
|
||||
# Command Line Arguments
|
||||
# Command-line arguments
|
||||
|
||||
The config package supports command-line argument parsing with flexible formats and automatic type conversion.
|
||||
`LoadCLI` accepts `--server.port=9000`, `--server.port 9000`, and bare boolean flags
|
||||
such as `--debug`. Arguments after `--` are positional and are not parsed. Other
|
||||
positional arguments are ignored. There is no short-flag parser; use Go's flag package
|
||||
when that interface is needed. Paths must obey registration's dot-separated key syntax.
|
||||
|
||||
## Argument Formats
|
||||
Values remain strings and are validated against registered types before publication.
|
||||
Each successful call replaces the complete CLI source, including an empty argument
|
||||
list. Omitted flags lose their previous overrides. Unknown paths are recorded in
|
||||
`UnknownCLIKeys` in sorted order and are not registered. The returned list is a copy.
|
||||
|
||||
### Key-Value Pairs
|
||||
`GenerateFlags` creates a flag.FlagSet from defaults. `BindFlags` atomically replaces
|
||||
the CLI source with explicitly visited flags, rejects unregistered flags, and uses
|
||||
the same checked conversions. Repeated flags resolve to the last value. Invalid
|
||||
values leave the prior CLI source and unknown-key list intact.
|
||||
|
||||
```bash
|
||||
# Space-separated
|
||||
./myapp --server.port 8080 --database.url "postgres://localhost/db"
|
||||
|
||||
# Equals-separated
|
||||
./myapp --server.port=8080 --database.url=postgres://localhost/db
|
||||
|
||||
# Mixed formats
|
||||
./myapp --server.port 8080 --debug=true
|
||||
```
|
||||
|
||||
### Boolean Flags
|
||||
|
||||
```bash
|
||||
# Boolean flags don't require a value (assumed true)
|
||||
./myapp --debug --verbose
|
||||
|
||||
# Explicit boolean values
|
||||
./myapp --debug=true --verbose=false
|
||||
```
|
||||
|
||||
### Nested Paths
|
||||
|
||||
Use dot notation for nested configuration:
|
||||
|
||||
```bash
|
||||
./myapp --server.host=0.0.0.0 --server.port=9090 --server.tls.enabled=true
|
||||
```
|
||||
|
||||
## Type Conversion
|
||||
|
||||
Command-line values are automatically converted to match registered types:
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
Port int64 `toml:"port"`
|
||||
Timeout time.Duration `toml:"timeout"`
|
||||
Ratio float64 `toml:"ratio"`
|
||||
Enabled bool `toml:"enabled"`
|
||||
Tags []string `toml:"tags"`
|
||||
}
|
||||
|
||||
// All these are parsed correctly:
|
||||
// --port=8080 → int64(8080)
|
||||
// --timeout=30s → time.Duration(30 * time.Second)
|
||||
// --ratio=0.95 → float64(0.95)
|
||||
// --enabled=true → bool(true)
|
||||
// --tags=prod,stable → []string{"prod", "stable"}
|
||||
```
|
||||
|
||||
## Integration with flag Package
|
||||
|
||||
### Generate flag.FlagSet
|
||||
|
||||
```go
|
||||
// Generate flags from registered configuration
|
||||
fs := cfg.GenerateFlags()
|
||||
|
||||
// Parse command line
|
||||
if err := fs.Parse(os.Args[1:]); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Apply parsed flags to configuration
|
||||
if err := cfg.BindFlags(fs); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Flag Registration
|
||||
|
||||
```go
|
||||
fs := flag.NewFlagSet("myapp", flag.ContinueOnError)
|
||||
|
||||
// Add custom flags
|
||||
verbose := fs.Bool("v", false, "verbose output")
|
||||
configFile := fs.String("config", "config.toml", "config file path")
|
||||
|
||||
// Parse
|
||||
fs.Parse(os.Args[1:])
|
||||
|
||||
// Use custom flags
|
||||
if *verbose {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
}
|
||||
|
||||
// Load config with custom file path
|
||||
cfg, _ := config.NewBuilder().
|
||||
WithFile(*configFile).
|
||||
Build()
|
||||
|
||||
// Bind remaining flags
|
||||
cfg.BindFlags(fs)
|
||||
```
|
||||
|
||||
## Precedence and Overrides
|
||||
|
||||
Command-line arguments have the highest precedence by default:
|
||||
|
||||
```go
|
||||
// Default precedence: CLI > Env > File > Default
|
||||
cfg, _ := config.Quick(defaults, "APP_", "config.toml")
|
||||
|
||||
// Even if config.toml sets port=8080 and APP_PORT=9090,
|
||||
// --port=7070 will win
|
||||
```
|
||||
|
||||
Change precedence if needed:
|
||||
|
||||
```go
|
||||
cfg, _ := config.NewBuilder().
|
||||
WithSources(
|
||||
config.SourceEnv, // Env highest
|
||||
config.SourceCLI, // Then CLI
|
||||
config.SourceFile, // Then file
|
||||
config.SourceDefault, // Finally defaults
|
||||
).
|
||||
Build()
|
||||
```
|
||||
|
||||
## Argument Parsing Details
|
||||
|
||||
### Validation
|
||||
|
||||
- Paths must use valid identifiers (letters, numbers, underscore, dash)
|
||||
- No leading/trailing dots in paths
|
||||
- Empty segments not allowed (no `..` in paths)
|
||||
|
||||
### Special Cases
|
||||
|
||||
```bash
|
||||
# Double dash stops flag parsing
|
||||
./myapp --port=8080 -- --not-a-flag
|
||||
|
||||
# Single dash flags are ignored (not GNU-style)
|
||||
./myapp -p 8080 # Ignored, use --port
|
||||
|
||||
# Quoted values preserve spaces
|
||||
./myapp --message="Hello World" --name='John Doe'
|
||||
|
||||
# Escape quotes in values
|
||||
./myapp --json="{\"key\": \"value\"}"
|
||||
```
|
||||
|
||||
### Value Parsing Rules
|
||||
|
||||
1. **Booleans**: `true`, `false` (case-sensitive)
|
||||
2. **Numbers**: Standard decimal notation
|
||||
3. **Strings**: Quoted or unquoted (quotes removed if present)
|
||||
4. **Lists**: Comma-separated (when target type is slice)
|
||||
|
||||
## Override Arguments
|
||||
|
||||
```go
|
||||
// Parse custom arguments instead of os.Args
|
||||
customArgs := []string{"--debug", "--port=9090"}
|
||||
|
||||
cfg, _ := config.NewBuilder().
|
||||
WithArgs(customArgs).
|
||||
Build()
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
CLI parsing errors are returned from `Build()` or `LoadCLI()`:
|
||||
|
||||
```go
|
||||
cfg, err := config.NewBuilder().
|
||||
WithDefaults(&Config{}).
|
||||
Build()
|
||||
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, config.ErrCLIParse):
|
||||
log.Fatal("Invalid command line arguments:", err)
|
||||
default:
|
||||
log.Fatal("Configuration error:", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
The Builder reads os.Args by default. Use `WithArgs(nil)` to disable CLI loading for
|
||||
that build or supply an explicit argument slice for deterministic initialization.
|
||||
+18
-198
@@ -1,202 +1,22 @@
|
||||
# Environment Variables
|
||||
# Environment variables
|
||||
|
||||
The config package provides flexible environment variable support with automatic name transformation, custom mappings, and whitelist capabilities.
|
||||
`LoadEnv("APP_")` maps `server.port` to `APP_SERVER_PORT`. It replaces the complete
|
||||
environment source: variables removed since the previous load lose their overrides.
|
||||
Set `LoadOptions.EnvTransform` for custom names and `EnvWhitelist` to limit paths.
|
||||
Options are copied. Custom transforms run outside Config locks and may inspect Config.
|
||||
|
||||
## Basic Usage
|
||||
Environment values are stored as strings and validated against registered types
|
||||
before any part of the replacement is published. Numbers use decimal text; durations
|
||||
use Go duration syntax; timestamps use RFC3339Nano-compatible text. Slices and arrays
|
||||
use comma-separated elements without an escaping convention. Empty text gives an
|
||||
empty slice. Values larger than MaxValueSize are rejected.
|
||||
|
||||
Environment variables are automatically mapped from configuration paths:
|
||||
`RegisterWithEnv` and a struct field's `env` tag load an explicitly named variable
|
||||
during registration. Later `LoadEnv` calls use the configured transform/whitelist,
|
||||
so explicit registration-time mappings are not automatically repeated on reload.
|
||||
|
||||
```go
|
||||
cfg, _ := config.Quick(defaults, "MYAPP_", "config.toml")
|
||||
|
||||
// These environment variables are automatically loaded:
|
||||
// MYAPP_SERVER_PORT → server.port
|
||||
// MYAPP_DATABASE_URL → database.url
|
||||
// MYAPP_LOG_LEVEL → log.level
|
||||
// MYAPP_FEATURES_ENABLED → features.enabled
|
||||
```
|
||||
|
||||
## Name Transformation
|
||||
|
||||
### Default Transformation
|
||||
|
||||
By default, paths are transformed as follows:
|
||||
1. Dots (`.`) become underscores (`_`)
|
||||
2. Converted to uppercase
|
||||
3. Prefix is prepended
|
||||
|
||||
```go
|
||||
// Path transformations:
|
||||
// server.port → MYAPP_SERVER_PORT
|
||||
// database.url → MYAPP_DATABASE_URL
|
||||
// tls.cert.path → MYAPP_TLS_CERT_PATH
|
||||
// maxRetries → MYAPP_MAXRETRIES
|
||||
```
|
||||
|
||||
### Custom Transformation
|
||||
|
||||
Define custom environment variable mappings:
|
||||
|
||||
```go
|
||||
cfg, _ := config.NewBuilder().
|
||||
WithEnvTransform(func(path string) string {
|
||||
switch path {
|
||||
case "server.port":
|
||||
return "PORT" // Use $PORT directly
|
||||
case "database.url":
|
||||
return "DATABASE_URL"
|
||||
case "api.key":
|
||||
return "API_KEY"
|
||||
default:
|
||||
// Fallback to default transformation
|
||||
return "MYAPP_" + strings.ToUpper(
|
||||
strings.ReplaceAll(path, ".", "_"),
|
||||
)
|
||||
}
|
||||
}).
|
||||
Build()
|
||||
```
|
||||
|
||||
### No Transformation
|
||||
|
||||
Return empty string to skip environment lookup:
|
||||
|
||||
```go
|
||||
cfg, _ := config.NewBuilder().
|
||||
WithEnvTransform(func(path string) string {
|
||||
// Only allow specific env vars
|
||||
allowed := map[string]string{
|
||||
"port": "PORT",
|
||||
"database": "DATABASE_URL",
|
||||
}
|
||||
return allowed[path] // Empty string if not in map
|
||||
}).
|
||||
Build()
|
||||
```
|
||||
|
||||
## Explicit Environment Variable Mapping
|
||||
|
||||
Use the `env` struct tag for explicit mappings:
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
Port int `toml:"port" env:"PORT"`
|
||||
Database string `toml:"database" env:"DATABASE_URL"`
|
||||
APIKey string `toml:"api_key" env:"API_KEY"`
|
||||
}
|
||||
|
||||
// These use the explicit env tag names, ignoring prefix
|
||||
cfg, _ := config.NewBuilder().
|
||||
WithDefaults(&Config{}).
|
||||
WithEnvPrefix("MYAPP_"). // Not used for tagged fields
|
||||
Build()
|
||||
```
|
||||
|
||||
Or register with explicit environment variable:
|
||||
|
||||
```go
|
||||
cfg.RegisterWithEnv("server.port", 8080, "PORT")
|
||||
cfg.RegisterWithEnv("database.url", "localhost", "DATABASE_URL")
|
||||
```
|
||||
|
||||
## Using the `env` Tag
|
||||
|
||||
Structs can specify explicit environment variable names using the `env` tag:
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
Server struct {
|
||||
Port int `toml:"port" env:"PORT"` // Uses $PORT
|
||||
Host string `toml:"host" env:"SERVER_HOST"` // Uses $SERVER_HOST
|
||||
} `toml:"server"`
|
||||
}
|
||||
```
|
||||
|
||||
## Environment Variable Whitelist
|
||||
|
||||
Limit which paths can be set via environment:
|
||||
|
||||
```go
|
||||
cfg, _ := config.NewBuilder().
|
||||
WithEnvWhitelist(
|
||||
"server.port",
|
||||
"database.url",
|
||||
"api.key",
|
||||
"log.level",
|
||||
). // Only these paths read from environment
|
||||
Build()
|
||||
```
|
||||
|
||||
## Type Conversion
|
||||
|
||||
Environment variables (strings) are automatically converted to the registered type:
|
||||
|
||||
```bash
|
||||
# Booleans
|
||||
export MYAPP_DEBUG=true
|
||||
export MYAPP_VERBOSE=false
|
||||
|
||||
# Numbers
|
||||
export MYAPP_PORT=8080
|
||||
export MYAPP_TIMEOUT=30
|
||||
export MYAPP_RATIO=0.95
|
||||
|
||||
# Durations
|
||||
export MYAPP_TIMEOUT=30s
|
||||
export MYAPP_INTERVAL=5m
|
||||
|
||||
# Lists (comma-separated)
|
||||
export MYAPP_TAGS=prod,stable,v2
|
||||
```
|
||||
|
||||
## Manual Environment Loading
|
||||
|
||||
Load environment variables at any time:
|
||||
|
||||
```go
|
||||
cfg := config.New()
|
||||
cfg.RegisterStruct("", &Config{})
|
||||
|
||||
// Load with prefix
|
||||
if err := cfg.LoadEnv("MYAPP_"); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Or use existing options
|
||||
if err := cfg.LoadWithOptions("", nil, config.LoadOptions{
|
||||
EnvPrefix: "MYAPP_",
|
||||
Sources: []config.Source{config.SourceEnv},
|
||||
}); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
## Discovering Environment Variables
|
||||
|
||||
Find which environment variables are set:
|
||||
|
||||
```go
|
||||
// Discover all env vars matching registered paths
|
||||
discovered := cfg.DiscoverEnv("MYAPP_")
|
||||
|
||||
for path, envVar := range discovered {
|
||||
log.Printf("%s is set via %s", path, envVar)
|
||||
}
|
||||
```
|
||||
|
||||
## Precedence Examples
|
||||
|
||||
Default precedence: CLI > Env > File > Default
|
||||
|
||||
Custom precedence (Env > File > CLI > Default):
|
||||
|
||||
```go
|
||||
cfg, _ := config.NewBuilder().
|
||||
WithSources(
|
||||
config.SourceEnv,
|
||||
config.SourceFile,
|
||||
config.SourceCLI,
|
||||
config.SourceDefault,
|
||||
).
|
||||
Build()
|
||||
```
|
||||
`DiscoverEnv(prefix)` returns registered paths with present variable names.
|
||||
`ExportEnv(prefix)` returns formatted effective values that differ from defaults;
|
||||
it safely handles containers. It is diagnostic output, not a general lossless
|
||||
serialization format for maps, slices or strings containing delimiters. Use Save
|
||||
for round-trip persistence.
|
||||
+29
-299
@@ -1,304 +1,34 @@
|
||||
# Configuration Files
|
||||
# TOML files and saves
|
||||
|
||||
The config package supports TOML configuration files with automatic loading, discovery, and atomic saving.
|
||||
`LoadFile(path)` parses TOML regardless of the usual TOML/conf/config extension;
|
||||
JSON/YAML extensions are rejected. `SetFileFormat` and `WithFileFormat` accept
|
||||
`toml` and the compatibility alias `auto`; neither enables format detection.
|
||||
Only registered paths are applied. Registered map fields accept whole tables.
|
||||
Replacing a registered parent table with a scalar is an error. A missing key
|
||||
removes that source override, exposing the next source or registered default.
|
||||
|
||||
## TOML Format
|
||||
Loads parse and validate before publication. Syntax errors, incompatible values,
|
||||
overflow and read failures leave prior values, comments and the loaded path intact.
|
||||
Readers must be regular files. Security options check ownership of the opened file
|
||||
on Unix and enforce size limits both before and during reading. Ownership checks
|
||||
fail explicitly on unsupported platforms. PreventPathTraversal rejects relative
|
||||
paths escaping through `..`; it is not a symlink sandbox. Absolute paths remain
|
||||
allowed. A zero MaxFileSize means no configured byte limit.
|
||||
|
||||
TOML (Tom's Obvious, Minimal Language) is the supported configuration format:
|
||||
`Save(path)` writes effective values; `SaveSource(path, source)` writes one source.
|
||||
Default selects registered defaults. Both use a temporary file in the destination
|
||||
directory, sync it, and rename it over the destination. They preserve an existing
|
||||
file's permission bits and use 0600 for a new file. They do not promise portable
|
||||
power-loss durability of the directory entry or platform-independent replacement
|
||||
of open files. Encoding failures occur before the destination is changed.
|
||||
|
||||
```toml
|
||||
# Basic values
|
||||
host = "localhost"
|
||||
port = 8080
|
||||
debug = false
|
||||
Loaded comment text, including inline comments, is preserved as a preamble. Hashes
|
||||
inside strings are not comments. Formatting and comment positions are standardized,
|
||||
and unknown/unregistered keys are omitted. Saves do not merge concurrent edits made
|
||||
outside Config after the last successful load. Save does not switch the watched path.
|
||||
|
||||
# Nested sections
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
port = 9090
|
||||
timeout = "30s"
|
||||
|
||||
[database]
|
||||
url = "postgres://localhost/mydb"
|
||||
max_conns = 25
|
||||
timeout = "5s"
|
||||
|
||||
# Arrays
|
||||
[features]
|
||||
enabled = ["auth", "api", "metrics"]
|
||||
|
||||
# Inline tables
|
||||
tls = { enabled = true, cert = "/path/to/cert", key = "/path/to/key" }
|
||||
```
|
||||
|
||||
## Loading Configuration Files
|
||||
|
||||
### Basic Loading
|
||||
|
||||
```go
|
||||
cfg := config.New()
|
||||
cfg.RegisterStruct("", &Config{})
|
||||
|
||||
if err := cfg.LoadFile("config.toml"); err != nil {
|
||||
if errors.Is(err, config.ErrConfigNotFound) {
|
||||
log.Println("Config file not found, using defaults")
|
||||
} else {
|
||||
log.Fatal("Failed to load config:", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### With Builder
|
||||
|
||||
```go
|
||||
cfg, err := config.NewBuilder().
|
||||
WithDefaults(&Config{}).
|
||||
WithFile("/etc/myapp/config.toml").
|
||||
Build()
|
||||
```
|
||||
|
||||
### Multiple File Attempts
|
||||
|
||||
```go
|
||||
// Try multiple locations
|
||||
locations := []string{
|
||||
"./config.toml",
|
||||
"~/.config/myapp/config.toml",
|
||||
"/etc/myapp/config.toml",
|
||||
}
|
||||
|
||||
var cfg *config.Config
|
||||
var err error
|
||||
|
||||
for _, path := range locations {
|
||||
cfg, err = config.NewBuilder().
|
||||
WithDefaults(&Config{}).
|
||||
WithFile(path).
|
||||
Build()
|
||||
|
||||
if err == nil || !errors.Is(err, config.ErrConfigNotFound) {
|
||||
break
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Automatic File Discovery
|
||||
|
||||
Use file discovery to find configuration automatically:
|
||||
|
||||
```go
|
||||
cfg, _ := config.NewBuilder().
|
||||
WithDefaults(&Config{}).
|
||||
WithFileDiscovery(config.FileDiscoveryOptions{
|
||||
Name: "myapp",
|
||||
Extensions: []string{".toml", ".conf"},
|
||||
EnvVar: "MYAPP_CONFIG",
|
||||
CLIFlag: "--config",
|
||||
UseXDG: true,
|
||||
UseCurrentDir: true,
|
||||
Paths: []string{"/opt/myapp"},
|
||||
}).
|
||||
Build()
|
||||
```
|
||||
|
||||
Search order:
|
||||
1. CLI flag: `--config=/path/to/config.toml`
|
||||
2. Environment variable: `$MYAPP_CONFIG`
|
||||
3. Current directory: `./myapp.toml`, `./myapp.conf`
|
||||
4. XDG config: `~/.config/myapp/myapp.toml`
|
||||
5. System paths: `/etc/myapp/myapp.toml`
|
||||
6. Custom paths: `/opt/myapp/myapp.toml`
|
||||
|
||||
## Saving Configuration
|
||||
|
||||
### Save Current State
|
||||
|
||||
```go
|
||||
// Save all current values atomically
|
||||
if err := cfg.Save("config.toml"); err != nil {
|
||||
log.Fatal("Failed to save config:", err)
|
||||
}
|
||||
```
|
||||
|
||||
The save operation is atomic - it writes to a temporary file then renames it.
|
||||
|
||||
### Save Specific Source
|
||||
|
||||
```go
|
||||
// Save only values from environment variables
|
||||
if err := cfg.SaveSource("env-config.toml", config.SourceEnv); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Save only file-loaded values
|
||||
if err := cfg.SaveSource("file-only.toml", config.SourceFile); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
### Generate Default Configuration
|
||||
|
||||
```go
|
||||
// Create a default config file
|
||||
defaults := &Config{}
|
||||
// ... set default values ...
|
||||
|
||||
cfg, _ := config.NewBuilder().
|
||||
WithDefaults(defaults).
|
||||
Build()
|
||||
|
||||
// Save defaults as config template
|
||||
if err := cfg.SaveSource("config.toml.example", config.SourceDefault); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
## File Structure Mapping
|
||||
|
||||
TOML structure maps directly to dot-notation paths:
|
||||
|
||||
```toml
|
||||
# Maps to "debug"
|
||||
debug = true
|
||||
|
||||
[server]
|
||||
# Maps to "server.host"
|
||||
host = "localhost"
|
||||
# Maps to "server.port"
|
||||
port = 8080
|
||||
|
||||
[server.tls]
|
||||
# Maps to "server.tls.enabled"
|
||||
enabled = true
|
||||
# Maps to "server.tls.cert"
|
||||
cert = "/path/to/cert"
|
||||
|
||||
[[users]]
|
||||
# Array elements: "users.0.name", "users.0.role"
|
||||
name = "admin"
|
||||
role = "administrator"
|
||||
|
||||
[[users]]
|
||||
# Array elements: "users.1.name", "users.1.role"
|
||||
name = "user"
|
||||
role = "standard"
|
||||
```
|
||||
|
||||
## Type Handling
|
||||
|
||||
TOML types map to Go types:
|
||||
|
||||
```toml
|
||||
# Strings
|
||||
name = "myapp"
|
||||
multiline = """
|
||||
Line one
|
||||
Line two
|
||||
"""
|
||||
|
||||
# Numbers
|
||||
port = 8080 # int64
|
||||
timeout = 30 # int64
|
||||
ratio = 0.95 # float64
|
||||
max_size = 1_000_000 # int64 (underscores allowed)
|
||||
|
||||
# Booleans
|
||||
enabled = true
|
||||
debug = false
|
||||
|
||||
# Dates/Times (RFC 3339)
|
||||
created_at = 2024-01-15T09:30:00Z
|
||||
expires = 2024-12-31
|
||||
|
||||
# Arrays
|
||||
ports = [8080, 8081, 8082]
|
||||
tags = ["production", "stable"]
|
||||
|
||||
# Tables (objects)
|
||||
[database]
|
||||
host = "localhost"
|
||||
port = 5432
|
||||
|
||||
# Array of tables
|
||||
[[servers]]
|
||||
name = "web1"
|
||||
host = "10.0.0.1"
|
||||
|
||||
[[servers]]
|
||||
name = "web2"
|
||||
host = "10.0.0.2"
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
File loading can produce several error types:
|
||||
|
||||
```go
|
||||
err := cfg.LoadFile("config.toml")
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, config.ErrConfigNotFound):
|
||||
// File doesn't exist - often not fatal
|
||||
log.Println("No config file, using defaults")
|
||||
|
||||
case strings.Contains(err.Error(), "failed to parse TOML"):
|
||||
// TOML syntax error
|
||||
log.Fatal("Invalid TOML syntax:", err)
|
||||
|
||||
case strings.Contains(err.Error(), "failed to read"):
|
||||
// Permission or I/O error
|
||||
log.Fatal("Cannot read config file:", err)
|
||||
|
||||
default:
|
||||
log.Fatal("Config error:", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### File Permissions
|
||||
|
||||
```go
|
||||
// After saving, verify permissions
|
||||
info, err := os.Stat("config.toml")
|
||||
if err == nil {
|
||||
mode := info.Mode()
|
||||
if mode&0077 != 0 {
|
||||
log.Warn("Config file is world/group readable")
|
||||
// Fix permissions
|
||||
os.Chmod("config.toml", 0600)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Size Limits
|
||||
|
||||
Files and values have size limits:
|
||||
- Maximum file size: ~10MB (10 * MaxValueSize)
|
||||
- Maximum value size: 1MB
|
||||
|
||||
## Partial Loading
|
||||
|
||||
Load only specific sections:
|
||||
|
||||
```go
|
||||
var serverCfg ServerConfig
|
||||
if err := cfg.Scan("server", &serverCfg); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
var dbCfg DatabaseConfig
|
||||
if err := cfg.Scan("database", &dbCfg); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use Example Files**: Generate `.example` files with defaults
|
||||
2. **Check Permissions**: Ensure config files aren't world-readable
|
||||
3. **Validate After Load**: Add validators to check loaded values
|
||||
4. **Handle Missing Files**: Missing config files often aren't fatal
|
||||
5. **Use Atomic Saves**: The built-in Save method is atomic
|
||||
6. **Document Structure**: Comment your TOML files thoroughly
|
||||
Durations and atomic time/network values are saved as strings and decoded back to
|
||||
their registered types. time.Time uses RFC3339Nano; native TOML datetime syntax is
|
||||
unsupported. Integers in files have signed 64-bit range. Full uint64 values can be
|
||||
held in memory but cannot be saved above MaxInt64. Unsupported TOML syntax and
|
||||
numeric key restrictions follow the pinned [toml package](https://github.com/lixenwraith/toml).
|
||||
@@ -1,159 +0,0 @@
|
||||
# lixenwraith/config Quick Reference Guide
|
||||
|
||||
This guide details the `lixenwraith/config` package for thread-safe Go configuration.
|
||||
It supports multiple sources (files, environment, CLI), type-safe struct population, and live reconfiguration.
|
||||
|
||||
## Quick Start: Recommended Usage
|
||||
|
||||
The recommended pattern uses the **Builder** with a **target struct** providing compile-time type safety and eliminating the need for runtime type assertions
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/lixenwraith/config"
|
||||
)
|
||||
|
||||
// 1. Define your application's configuration struct
|
||||
type AppConfig struct {
|
||||
Server struct {
|
||||
Host string `toml:"host"`
|
||||
Port int `toml:"port"`
|
||||
} `toml:"server"`
|
||||
Debug bool `toml:"debug"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
// 2. Create a target instance with defaults
|
||||
// Method A: Direct initialization (cleaner for simple defaults)
|
||||
target := &AppConfig{}
|
||||
target.Server.Host = "localhost"
|
||||
target.Server.Port = 8080
|
||||
|
||||
// Method B: Use WithDefaults() for explicit separation (shown below)
|
||||
|
||||
// 3. Use the builder to configure and load all sources
|
||||
cfg, err := config.NewBuilder().
|
||||
WithTarget(target). // Enable type-safe mode and register struct fields
|
||||
// WithDefaults(&AppConfig{...}), // Optional: Override defaults from WithTarget
|
||||
WithFile("config.toml"). // Load from file (supports .toml, .json, .yaml)
|
||||
WithEnvPrefix("APP_"). // Load from environment (e.g., APP_SERVER_PORT)
|
||||
WithArgs(os.Args[1:]). // Load from command-line flags (e.g., --server.port=9090)
|
||||
Build() // Build the final config object
|
||||
if err != nil {
|
||||
log.Fatalf("Config build failed: %v", err)
|
||||
}
|
||||
|
||||
// 4. Access the fully populated, type-safe struct
|
||||
// The `target` variable is now populated with the final merged values
|
||||
fmt.Printf("Running on %s:%d\n", target.Server.Host, target.Server.Port)
|
||||
|
||||
// Or, retrieve the updated struct at any time (e.g., after a live reload)
|
||||
latest, _ := cfg.AsStruct()
|
||||
latestConfig := latest.(*AppConfig)
|
||||
fmt.Printf("Debug mode: %v\n", latestConfig.Debug)
|
||||
}
|
||||
```
|
||||
|
||||
## Supported Formats: TOML, JSON, YAML
|
||||
|
||||
The package supports multiple file formats. The format is auto-detected from the file extension (`.toml`, `.json`, `.yaml`, `.yml`) or file content
|
||||
|
||||
* To specify a format explicitly, use `Builder.WithFileFormat("json")`
|
||||
* The default struct tag for field mapping is `toml`, but can be changed with `Builder.WithTagName("json")`
|
||||
|
||||
## Builder Pattern
|
||||
|
||||
The `Builder` is the primary way to construct a `Config` instance
|
||||
|
||||
```go
|
||||
// NewBuilder creates a new configuration builder
|
||||
func NewBuilder() *Builder
|
||||
// Build finalizes configuration; returns the first of any accumulated errors
|
||||
func (b *Builder) Build() (*Config, error)
|
||||
// MustBuild is like Build but panics on fatal errors
|
||||
func (b *Builder) MustBuild() *Config
|
||||
```
|
||||
|
||||
### Builder Methods
|
||||
|
||||
* `WithTarget(target any)`: **(Recommended)** Registers fields from the target struct and allows access via `AsStruct()`
|
||||
* `WithDefaults(defaults any)`: Explicitly sets a struct containing default values, overriding any default values already in struct from `WithTarget`
|
||||
* `WithFile(path string)`: Sets the configuration file path
|
||||
* `WithFileDiscovery(opts FileDiscoveryOptions)`: Enables automatic config file discovery (searches CLI flags, env vars, XDG paths, and current directory)
|
||||
* `WithArgs(args []string)`: Sets the command-line arguments to parse (e.g., `--server.port=9090`)
|
||||
* `WithEnvPrefix(prefix string)`: Sets the global environment variable prefix (e.g., `MYAPP_`)
|
||||
* `WithSources(sources ...Source)`: Overrides the default source precedence order
|
||||
* `WithTypedValidator(fn any)`: **(Recommended for validation)** Adds a type-safe validation function with signature `func(c *YourConfigType) error` that runs *after* the target struct is populated
|
||||
* `WithValidator(fn ValidatorFunc)`: Adds a validation function that runs *before* type-safe population, operating on the raw `*Config` object
|
||||
* `WithTagName(tagName string)`: Sets the primary struct tag for field mapping (`"toml"`, `"json"`, `"yaml"`)
|
||||
* `WithPrefix(prefix string)`: Adds a prefix to all registered paths from a struct
|
||||
* `WithFileFormat(format string)`: Explicitly sets the file format (`"toml"`, `"json"`, `"yaml"`, `"auto"`)
|
||||
* `WithSecurityOptions(opts SecurityOptions)`: Sets security options for file loading (path traversal, file size limits)
|
||||
* `WithEnvTransform(fn EnvTransformFunc)`: Sets a custom environment variable mapping function
|
||||
* `WithEnvWhitelist(paths ...string)`: Limits environment variable loading to a specific set of paths
|
||||
|
||||
## Type-Safe Access & Population
|
||||
|
||||
These are the **preferred methods** for accessing configuration data
|
||||
|
||||
* `AsStruct() (any, error)`: After using `Builder.WithTarget()`, this method returns the populated, type-safe target struct - This is the primary way to access config after initialization or live reload
|
||||
* `Scan(basePath string, target any)`: Populates a struct with values from a specific config path (e.g., `cfg.Scan("server", &serverConf)`)
|
||||
* `ScanMap(configMap map[string]any, target any, tagName ...string)`: Decodes a map[string]any directly into a target struct, useful for plugins or custom config sources
|
||||
* `GetTyped[T](c *Config, path string) (T, error)`: Retrieves a single value and decodes it to type `T`, handling type conversion automatically
|
||||
* `ScanTyped[T](c *Config, basePath ...string) (*T, error)`: A generic wrapper around `Scan` that allocates, populates, and returns a pointer to a struct of type `T`
|
||||
|
||||
## Live Reconfiguration
|
||||
|
||||
Enable automatic reloading of configuration when the source file changes
|
||||
|
||||
* `AutoUpdate()`: Enables file watching and automatic reloading with default options
|
||||
* `AutoUpdateWithOptions(opts WatchOptions)`: Enables reloading with custom options (e.g., poll interval, debounce)
|
||||
* `StopAutoUpdate()`: Stops the file watcher
|
||||
* `Watch() <-chan string`: Returns a channel that receives the paths of changed values
|
||||
* `WatchFile(filePath string, formatHint ...string)`: Switches the watcher to a new file at runtime
|
||||
* `IsWatching() bool`: Returns `true` if the file watcher is active
|
||||
|
||||
The watch channel also receives special notifications: `"file_deleted"`, `"permissions_changed"`, `"reload_error:..."`
|
||||
|
||||
## Source Precedence
|
||||
|
||||
The default order of precedence (highest to lowest) is:
|
||||
1. **CLI**: Command-line arguments (`--server.port=9090`)
|
||||
2. **Env**: Environment variables (`MYAPP_SERVER_PORT=8888`)
|
||||
3. **File**: Configuration file (`config.toml`)
|
||||
4. **Default**: Values registered from a struct
|
||||
|
||||
This order can be changed via `Builder.WithSources()` or `Config.SetPrecedence()`
|
||||
|
||||
## Dynamic / Legacy Value Access
|
||||
|
||||
These methods are for dynamic key-value access that require type assertion and should be **avoided when a type-safe struct can be used**
|
||||
|
||||
* `Get(path string) (any, bool)`: Retrieves the final merged value. The `bool` indicates if the path was registered. Requires a type assertion, e.g., `port := val.(int64)`
|
||||
* `Set(path string, value any)`: Updates a value in the highest priority source. The path must be registered first
|
||||
* `GetSource(path string, source Source) (any, bool)`: Retrieves a value from a specific source layer
|
||||
* `SetSource(path string, source Source, value any)`: Sets a value for a specific source layer
|
||||
|
||||
## API Reference Summary
|
||||
|
||||
### Core Types
|
||||
|
||||
* `Config`: The primary thread-safe configuration manager
|
||||
* `Source`: A configuration source (`SourceCLI`, `SourceEnv`, `SourceFile`, `SourceDefault`)
|
||||
* `LoadOptions`: Options for loading configuration from multiple sources
|
||||
* `Builder`: Fluent API for constructing a `Config` instance
|
||||
|
||||
### Core Methods
|
||||
|
||||
* `New() *Config`: Creates a new `Config` instance.
|
||||
* `Register(path string, defaultValue any)`: Registers a path with a default value
|
||||
* `RegisterStruct(prefix string, structWithDefaults any)`: Recursively registers fields from a struct using `toml` tags
|
||||
* `Validate(required ...string)`: Checks that all specified required paths have been set from a non-default source
|
||||
* `Save(path string)`: Atomically saves the current merged configuration state to a file
|
||||
* `Clone() *Config`: Creates a deep copy of the configuration state
|
||||
* `Debug() string`: Returns a formatted string of all values for debugging
|
||||
+18
-203
@@ -1,215 +1,30 @@
|
||||
# Quick Start Guide
|
||||
# Quick start
|
||||
|
||||
This guide gets you up and running with the config package in minutes.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
The simplest way to use the config package is with the `Quick` function:
|
||||
Use Go 1.27.1 and TOML tags. Register defaults before loading sources:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"github.com/lixenwraith/config"
|
||||
)
|
||||
|
||||
// Define your configuration structure
|
||||
type Config struct {
|
||||
Server struct {
|
||||
Host string `toml:"host"`
|
||||
Port int `toml:"port"`
|
||||
} `toml:"server"`
|
||||
Database struct {
|
||||
URL string `toml:"url"`
|
||||
MaxConns int `toml:"max_conns"`
|
||||
} `toml:"database"`
|
||||
Debug bool `toml:"debug"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Create defaults
|
||||
defaults := &Config{}
|
||||
defaults.Server.Host = "localhost"
|
||||
defaults.Server.Port = 8080
|
||||
defaults.Database.URL = "postgres://localhost/mydb"
|
||||
defaults.Database.MaxConns = 10
|
||||
defaults.Debug = false
|
||||
|
||||
// Initialize configuration
|
||||
cfg, err := config.Quick(
|
||||
defaults, // Default values from struct
|
||||
"MYAPP_", // Environment variable prefix
|
||||
"config.toml", // Configuration file path
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Access values
|
||||
port, _ := cfg.Get("server.port")
|
||||
dbURL, _ := cfg.Get("database.url")
|
||||
|
||||
log.Printf("Server running on port %d", port.(int64))
|
||||
log.Printf("Database URL: %s", dbURL.(string))
|
||||
}
|
||||
cfg := config.New()
|
||||
if err := cfg.Register("server.port", uint16(8080)); err != nil { return err }
|
||||
if err := cfg.LoadFile("config.toml"); err != nil { return err }
|
||||
if err := cfg.LoadEnv("APP_"); err != nil { return err }
|
||||
if err := cfg.LoadCLI(os.Args[1:]); err != nil { return err }
|
||||
port, err := config.GetTyped[uint16](cfg, "server.port")
|
||||
```
|
||||
|
||||
## Configuration Sources
|
||||
|
||||
The package loads configuration from multiple sources in this default order (highest to lowest priority):
|
||||
|
||||
1. **Command-line arguments** - Override everything
|
||||
2. **Environment variables** - Override file and defaults
|
||||
3. **Configuration file** - Override defaults
|
||||
4. **Default values** - Base configuration
|
||||
|
||||
### Command-Line Arguments
|
||||
|
||||
```bash
|
||||
./myapp --server.port=9090 --debug
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
export MYAPP_SERVER_PORT=9090
|
||||
export MYAPP_DATABASE_URL="postgres://prod/mydb"
|
||||
export MYAPP_DEBUG=true
|
||||
```
|
||||
|
||||
### Configuration File (config.toml)
|
||||
`APP_SERVER_PORT=9000` and `--server.port=9001` address the same registered path.
|
||||
CLI has highest default priority. TOML values can be configured in a table:
|
||||
|
||||
```toml
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
port = 8080
|
||||
|
||||
[database]
|
||||
url = "postgres://localhost/mydb"
|
||||
max_conns = 25
|
||||
|
||||
debug = false
|
||||
```
|
||||
|
||||
## Type Safety
|
||||
For a typed application, use `NewBuilder().WithTarget(&settings)` as shown in the
|
||||
[README](../README.md). Build fills the supplied target once. Use `AsStruct` to
|
||||
obtain new snapshots after updates; retaining an old snapshot is safe.
|
||||
|
||||
The package uses struct tags to ensure type safety. When you register a struct, the types are enforced:
|
||||
|
||||
```go
|
||||
// This struct defines the expected types
|
||||
type Config struct {
|
||||
Port int64 `toml:"port"` // Must be a number
|
||||
Host string `toml:"host"` // Must be a string
|
||||
Debug bool `toml:"debug"` // Must be a boolean
|
||||
}
|
||||
|
||||
// Type assertions are safe after registration
|
||||
port, _ := cfg.Get("port")
|
||||
portNum := port.(int64) // Type matches registration
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The package validates types during loading:
|
||||
|
||||
```go
|
||||
cfg, err := config.Quick(defaults, "APP_", "config.toml")
|
||||
if err != nil {
|
||||
// Handle errors like:
|
||||
// - Invalid TOML syntax
|
||||
// - Type mismatches (e.g., string value for int field)
|
||||
// - File permissions issues
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
### Error Categories
|
||||
|
||||
The package uses structured error categories for better error handling. Check errors using `errors.Is()`:
|
||||
```go
|
||||
if err := cfg.LoadFile("config.toml"); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, config.ErrConfigNotFound):
|
||||
// Config file doesn't exist, use defaults
|
||||
case errors.Is(err, config.ErrFileAccess):
|
||||
// Permission denied or file access issues
|
||||
case errors.Is(err, config.ErrFileFormat):
|
||||
// Invalid TOML/JSON/YAML syntax
|
||||
case errors.Is(err, config.ErrTypeMismatch):
|
||||
// Value type doesn't match registered type
|
||||
case errors.Is(err, config.ErrValidation):
|
||||
// Validation failed
|
||||
default:
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [Errors](./error.go) for the complete list of error categories and description.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Required Fields
|
||||
|
||||
```go
|
||||
// Register required configuration
|
||||
cfg.RegisterRequired("api.key", "")
|
||||
cfg.RegisterRequired("database.url", "")
|
||||
|
||||
// Validate all required fields are set
|
||||
if err := cfg.Validate("api.key", "database.url"); err != nil {
|
||||
log.Fatal("Missing required configuration:", err)
|
||||
}
|
||||
```
|
||||
|
||||
### Type-Safe Validation
|
||||
|
||||
```go
|
||||
cfg, _ := config.NewBuilder().
|
||||
WithTarget(&Config{}).
|
||||
WithTypedValidator(func(c *Config) error {
|
||||
if c.Server.Port < 1024 {
|
||||
return fmt.Errorf("port must be >= 1024")
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Build()
|
||||
```
|
||||
|
||||
See [Validation](validator.md) for more validation options.
|
||||
|
||||
### Using Different Struct Tags
|
||||
|
||||
```go
|
||||
// Use JSON tags instead of default TOML
|
||||
type Config struct {
|
||||
Server struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
} `json:"server"`
|
||||
}
|
||||
|
||||
cfg, _ := config.NewBuilder().
|
||||
WithTarget(&Config{}).
|
||||
WithTagName("json").
|
||||
WithFile("config.json").
|
||||
Build()
|
||||
```
|
||||
|
||||
### Checking Value Sources
|
||||
|
||||
```go
|
||||
// See which source provided a value
|
||||
port, _ := cfg.Get("server.port")
|
||||
sources := cfg.GetSources("server.port")
|
||||
|
||||
for source, value := range sources {
|
||||
log.Printf("server.port from %s: %v", source, value)
|
||||
}
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Builder Pattern](builder.md) - Advanced configuration options
|
||||
- [Access Patterns](access.md) - All ways to get and set values
|
||||
A missing file returns `ErrConfigNotFound` along with a usable Config when all
|
||||
other initialization succeeds. Check that category explicitly if a file is
|
||||
optional. Invalid environment or CLI values remain fatal even when the file is
|
||||
missing. `MustBuild` and `MustQuick` ignore only a missing file and panic on other
|
||||
initialization errors. All source loads replace the previous values of that source.
|
||||
+30
-343
@@ -1,349 +1,36 @@
|
||||
# Live Reconfiguration
|
||||
# Live reconfiguration
|
||||
|
||||
The config package supports automatic configuration reloading when files change, enabling zero-downtime reconfiguration.
|
||||
`AutoUpdate` starts polling the loaded file. `Watch` starts it if necessary and
|
||||
subscribes to notifications. Option variants configure the polling interval
|
||||
(minimum 100ms), debounce, subscriber limit, reload timeout and permission checks.
|
||||
Options for an already-running watcher stay unchanged; stop/restart to change them.
|
||||
|
||||
## Basic File Watching
|
||||
One worker reads and fingerprints the regular file per poll, including same-size,
|
||||
same-timestamp edits and atomic replacements. Both successful and erroneous reads
|
||||
must settle for the debounce interval. Stable errors are reported once per observed
|
||||
state. Deletion preserves the last valid state, and recreation can reload normally.
|
||||
This trades one file read per poll for reliable detection of metadata-preserving edits.
|
||||
|
||||
### Enable Auto-Update
|
||||
Before publishing, the worker checks its context, current watcher identity, watched
|
||||
path and source generation under the Config lock. Type checks and allocation finish
|
||||
before the final timeout check. Expired, stopped or superseded operations cannot
|
||||
publish. ReloadTimeout limits publication eligibility; it cannot forcibly interrupt
|
||||
a kernel filesystem operation. Reads never hold the Config lock.
|
||||
Timed-out operations retry on later polls even if the content is unchanged; repeated
|
||||
timeouts for the same observation emit only one notification until recovery.
|
||||
|
||||
```go
|
||||
cfg, _ := config.NewBuilder().
|
||||
WithDefaults(&Config{}).
|
||||
WithFile("config.toml").
|
||||
Build()
|
||||
Events are changed registered paths, or `file_deleted`, `permissions_changed`,
|
||||
`reload_timeout`, `reload_error:<detail>`, and `precedence:<path>`. A path event means
|
||||
the effective value changed; a file value masked by CLI need not notify. Group/world
|
||||
permission changes are blocked when VerifyPermissions is enabled; correcting the
|
||||
permissions allows loading again. Default options enable that check.
|
||||
|
||||
// Enable automatic reloading
|
||||
cfg.AutoUpdate()
|
||||
Notifications are buffered (10 entries) and best effort; a slow subscriber may miss
|
||||
events and should read the latest full state. MaxWatchers bounds retained channels.
|
||||
StopAutoUpdate cancels publication and closes channels synchronously without waiting
|
||||
under the Config lock. There is no goroutine per subscriber or per timed-out reload.
|
||||
A new Watch call after stopping creates a new channel and worker.
|
||||
|
||||
// Your application continues running
|
||||
// Config reloads automatically when file changes
|
||||
|
||||
// Stop watching when done
|
||||
defer cfg.StopAutoUpdate()
|
||||
```
|
||||
|
||||
### Watch for Changes
|
||||
|
||||
```go
|
||||
// Get notified of configuration changes
|
||||
changes := cfg.Watch()
|
||||
|
||||
go func() {
|
||||
for path := range changes {
|
||||
log.Printf("Configuration changed: %s", path)
|
||||
|
||||
// React to specific changes
|
||||
switch path {
|
||||
case "server.port":
|
||||
// Restart server with new port
|
||||
restartServer()
|
||||
case "log.level":
|
||||
// Update log level
|
||||
updateLogLevel()
|
||||
}
|
||||
}
|
||||
}()
|
||||
```
|
||||
|
||||
## Watch Options
|
||||
|
||||
### Custom Watch Configuration
|
||||
|
||||
```go
|
||||
opts := config.WatchOptions{
|
||||
PollInterval: 500 * time.Millisecond, // Check every 500ms
|
||||
Debounce: 200 * time.Millisecond, // Wait 200ms after changes
|
||||
MaxWatchers: 50, // Limit concurrent watchers
|
||||
ReloadTimeout: 10 * time.Second, // Timeout for reload
|
||||
VerifyPermissions: true, // Security check
|
||||
}
|
||||
|
||||
cfg.AutoUpdateWithOptions(opts)
|
||||
```
|
||||
|
||||
### Watch Without Auto-Update
|
||||
|
||||
```go
|
||||
// Just watch, don't auto-reload
|
||||
changes := cfg.WatchWithOptions(config.WatchOptions{
|
||||
PollInterval: time.Second,
|
||||
})
|
||||
|
||||
// Manually reload when desired
|
||||
go func() {
|
||||
for range changes {
|
||||
if shouldReload() {
|
||||
cfg.LoadFile("config.toml")
|
||||
}
|
||||
}
|
||||
}()
|
||||
```
|
||||
|
||||
## Change Detection
|
||||
|
||||
### Value Changes
|
||||
|
||||
The watcher detects and notifies about:
|
||||
- New values added
|
||||
- Existing values modified
|
||||
- Values removed
|
||||
- Type changes
|
||||
|
||||
```go
|
||||
changes := cfg.Watch()
|
||||
|
||||
for path := range changes {
|
||||
newVal, exists := cfg.Get(path)
|
||||
if !exists {
|
||||
log.Printf("Removed: %s", path)
|
||||
continue
|
||||
}
|
||||
|
||||
sources := cfg.GetSources(path)
|
||||
fileVal, hasFile := sources[config.SourceFile]
|
||||
|
||||
log.Printf("Changed: %s = %v (from file: %v)",
|
||||
path, newVal, hasFile)
|
||||
}
|
||||
```
|
||||
|
||||
### Special Notifications
|
||||
|
||||
```go
|
||||
changes := cfg.Watch()
|
||||
|
||||
for notification := range changes {
|
||||
switch notification {
|
||||
case "file_deleted":
|
||||
log.Warn("Config file was deleted")
|
||||
|
||||
case "permissions_changed":
|
||||
log.Error("Config file permissions changed - potential security issue")
|
||||
|
||||
case "reload_timeout":
|
||||
log.Error("Config reload timed out")
|
||||
|
||||
default:
|
||||
if strings.HasPrefix(notification, "reload_error:") {
|
||||
log.Error("Reload error:", notification)
|
||||
} else {
|
||||
// Normal path change
|
||||
handleConfigChange(notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Debouncing
|
||||
|
||||
Rapid file changes are automatically debounced:
|
||||
|
||||
```go
|
||||
// Multiple rapid saves to config.toml
|
||||
// Only triggers one reload after debounce period
|
||||
|
||||
opts := config.WatchOptions{
|
||||
PollInterval: 100 * time.Millisecond,
|
||||
Debounce: 500 * time.Millisecond, // Wait 500ms
|
||||
}
|
||||
|
||||
cfg.AutoUpdateWithOptions(opts)
|
||||
```
|
||||
|
||||
## Permission Monitoring
|
||||
|
||||
```go
|
||||
opts := config.WatchOptions{
|
||||
VerifyPermissions: true, // Enabled by default
|
||||
}
|
||||
|
||||
cfg.AutoUpdateWithOptions(opts)
|
||||
|
||||
// Detects if file becomes world-writable
|
||||
changes := cfg.Watch()
|
||||
for change := range changes {
|
||||
if change == "permissions_changed" {
|
||||
// File permissions changed
|
||||
// Possible security breach
|
||||
alert("Config file permissions modified!")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern: Reconfiguration
|
||||
|
||||
```go
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
listener net.Listener
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func (s *Server) watchConfig() {
|
||||
changes := s.cfg.Watch()
|
||||
|
||||
for path := range changes {
|
||||
switch {
|
||||
case strings.HasPrefix(path, "server."):
|
||||
s.scheduleRestart()
|
||||
|
||||
case path == "log.level":
|
||||
s.updateLogLevel()
|
||||
|
||||
case strings.HasPrefix(path, "feature."):
|
||||
s.reloadFeatures()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) scheduleRestart() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Graceful restart logic
|
||||
log.Info("Scheduling server restart for config changes")
|
||||
// ... drain connections, restart listener ...
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern: Feature Flags
|
||||
|
||||
```go
|
||||
type FeatureFlags struct {
|
||||
cfg *config.Config
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func (ff *FeatureFlags) Watch() {
|
||||
changes := ff.cfg.Watch()
|
||||
|
||||
for path := range changes {
|
||||
if strings.HasPrefix(path, "features.") {
|
||||
feature := strings.TrimPrefix(path, "features.")
|
||||
enabled, _ := ff.cfg.Get(path)
|
||||
|
||||
log.Printf("Feature %s: %v", feature, enabled)
|
||||
ff.notifyFeatureChange(feature, enabled.(bool))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ff *FeatureFlags) IsEnabled(feature string) bool {
|
||||
ff.mu.RLock()
|
||||
defer ff.mu.RUnlock()
|
||||
|
||||
val, exists := ff.cfg.Get("features." + feature)
|
||||
return exists && val.(bool)
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern: Multi-Stage Reload
|
||||
|
||||
```go
|
||||
func watchConfigWithValidation(cfg *config.Config) {
|
||||
changes := cfg.Watch()
|
||||
|
||||
for range changes {
|
||||
// Stage 1: Snapshot current config
|
||||
backup := cfg.Clone()
|
||||
|
||||
// Stage 2: Validate new configuration
|
||||
if err := validateNewConfig(cfg); err != nil {
|
||||
log.Error("Invalid configuration:", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Stage 3: Apply changes
|
||||
if err := applyConfigChanges(cfg, backup); err != nil {
|
||||
log.Error("Failed to apply changes:", err)
|
||||
// Could restore from backup here
|
||||
continue
|
||||
}
|
||||
|
||||
log.Info("Configuration successfully reloaded")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Watch Status
|
||||
|
||||
```go
|
||||
// Check if watching is active
|
||||
if cfg.IsWatching() {
|
||||
log.Printf("Auto-update is enabled")
|
||||
log.Printf("Active watchers: %d", cfg.WatcherCount())
|
||||
}
|
||||
```
|
||||
|
||||
### Resource Management
|
||||
|
||||
```go
|
||||
// Limit watchers to prevent resource exhaustion
|
||||
opts := config.WatchOptions{
|
||||
MaxWatchers: 10, // Max 10 concurrent watch channels
|
||||
}
|
||||
|
||||
// Watchers beyond limit receive closed channels
|
||||
cfg.AutoUpdateWithOptions(opts)
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always Stop Watching**: Use `defer cfg.StopAutoUpdate()` to clean up
|
||||
2. **Handle All Notifications**: Check for special error notifications
|
||||
3. **Validate After Reload**: Ensure new config is valid before applying
|
||||
4. **Use Debouncing**: Prevent reload storms from rapid edits
|
||||
5. **Monitor Permissions**: Enable permission verification for security
|
||||
6. **Graceful Updates**: Plan how your app handles config changes
|
||||
7. **Log Changes**: Audit configuration modifications
|
||||
|
||||
## Limitations
|
||||
|
||||
- File watching uses polling (not inotify/kqueue)
|
||||
- No support for watching multiple files
|
||||
- Changes only detected for registered paths
|
||||
- Reloads entire file (no partial updates)
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Changes Not Detected
|
||||
|
||||
```go
|
||||
// Ensure path is registered before watching
|
||||
cfg.Register("new.value", "default")
|
||||
|
||||
// Now changes to new.value will be detected
|
||||
```
|
||||
|
||||
### Rapid Reloads
|
||||
|
||||
```go
|
||||
// Increase debounce to prevent rapid reloads
|
||||
opts := config.WatchOptions{
|
||||
Debounce: 2 * time.Second, // Wait 2s after changes stop
|
||||
}
|
||||
```
|
||||
|
||||
### Memory Leaks
|
||||
|
||||
```go
|
||||
// Always stop watching to prevent goroutine leaks
|
||||
watcher := cfg.Watch()
|
||||
|
||||
// Use context for cancellation
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case change := <-watcher:
|
||||
handleChange(change)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
```
|
||||
`WatchFile` loads a replacement path before replacing the watcher and preserves its
|
||||
options. Failure leaves the prior watcher and values active. Loading a different
|
||||
file directly stops the previous watcher; call Watch to watch the new path.
|
||||
+18
-170
@@ -1,175 +1,23 @@
|
||||
# Validation
|
||||
|
||||
The config package provides flexible validation through function injection and bundled validators for common scenarios.
|
||||
Registration defaults define conversion types. Set, SetSource and complete source
|
||||
loads reject incompatible values before publication. Overflow, fractional integer
|
||||
conversion, non-finite values and invalid textual duration/time/network values error.
|
||||
Untyped nil defaults leave the value type open within the supported value graph.
|
||||
|
||||
## Validation Stages
|
||||
`Validate(paths...)` requires a registered path to have a supplied value, even if it
|
||||
equals the default. `RegisterRequired` and `required:"true"` fields are collected by
|
||||
`Validate()` with no arguments and by Build. Defaults alone do not satisfy required
|
||||
inputs. Registration of a struct is staged, so a duplicate/invalid field or invalid
|
||||
explicit environment value cannot leave a partially registered schema.
|
||||
|
||||
The package supports two validation stages when using the Builder pattern:
|
||||
Build supports `WithValidator(func(*Config) error)` and a matching
|
||||
`WithTypedValidator(func(*T) error)`. They run at initialization. They are not rerun
|
||||
on runtime changes: apply application-level constraints before adopting a new
|
||||
snapshot when those constraints must hold after reconfiguration.
|
||||
|
||||
### Pre-Population Validation (`WithValidator`)
|
||||
|
||||
Runs on raw configuration values before struct population:
|
||||
|
||||
```go
|
||||
cfg, _ := config.NewBuilder().
|
||||
WithDefaults(defaults).
|
||||
WithValidator(func(c *config.Config) error {
|
||||
// Check required paths exist
|
||||
return c.Validate("api.key", "database.url")
|
||||
}).
|
||||
Build()
|
||||
```
|
||||
|
||||
### Post-Population Validation (`WithTypedValidator`)
|
||||
|
||||
Type-safe validation after struct is populated:
|
||||
|
||||
```go
|
||||
type AppConfig struct {
|
||||
Server struct {
|
||||
Port int64 `toml:"port"`
|
||||
} `toml:"server"`
|
||||
}
|
||||
|
||||
cfg, _ := config.NewBuilder().
|
||||
WithTarget(&AppConfig{}).
|
||||
WithTypedValidator(func(cfg *AppConfig) error {
|
||||
// No type assertion needed - cfg.Server.Port is int64
|
||||
if cfg.Server.Port < 1024 || cfg.Server.Port > 65535 {
|
||||
return fmt.Errorf("port %d outside valid range", cfg.Server.Port)
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Build()
|
||||
```
|
||||
|
||||
## Bundled Validators
|
||||
|
||||
The package includes common validation functions in the `config` package:
|
||||
|
||||
### Numeric Validators
|
||||
|
||||
```go
|
||||
// Port validates TCP/UDP port range (1-65535)
|
||||
config.Port(8080) // returns nil
|
||||
|
||||
// Positive validates positive numbers
|
||||
config.Positive(42) // returns nil
|
||||
config.Positive(-1) // returns error
|
||||
|
||||
// NonNegative validates non-negative numbers
|
||||
config.NonNegative(0) // returns nil
|
||||
config.NonNegative(-5) // returns error
|
||||
|
||||
// Range creates min/max validators
|
||||
portValidator := config.Range(1024, 65535)
|
||||
err := portValidator(8080) // returns nil
|
||||
```
|
||||
|
||||
### String Validators
|
||||
|
||||
```go
|
||||
// NonEmpty validates non-empty strings
|
||||
config.NonEmpty("hello") // returns nil
|
||||
config.NonEmpty("") // returns error
|
||||
|
||||
// Pattern creates regex validators
|
||||
emailPattern := config.Pattern(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$`)
|
||||
err := emailPattern("user@example.com") // returns nil
|
||||
|
||||
// URLPath validates URL path format
|
||||
config.URLPath("/api/v1") // returns nil
|
||||
config.URLPath("api/v1") // returns error (must start with /)
|
||||
```
|
||||
|
||||
### Network Validators
|
||||
|
||||
```go
|
||||
// IPAddress validates any IP address
|
||||
config.IPAddress("192.168.1.1") // returns nil
|
||||
config.IPAddress("::1") // returns nil
|
||||
|
||||
// IPv4Address validates IPv4 only
|
||||
config.IPv4Address("192.168.1.1") // returns nil
|
||||
config.IPv4Address("::1") // returns error
|
||||
|
||||
// IPv6Address validates IPv6 only
|
||||
config.IPv6Address("2001:db8::1") // returns nil
|
||||
config.IPv6Address("192.168.1.1") // returns error
|
||||
```
|
||||
|
||||
### Enum Validators
|
||||
|
||||
```go
|
||||
// OneOf creates allowed values validator
|
||||
logLevel := config.OneOf("debug", "info", "warn", "error")
|
||||
err := logLevel("info") // returns nil
|
||||
err = logLevel("trace") // returns error
|
||||
```
|
||||
|
||||
## Integration Example
|
||||
|
||||
```go
|
||||
type ServerConfig struct {
|
||||
Host string `toml:"host"`
|
||||
Port int64 `toml:"port"`
|
||||
Mode string `toml:"mode"`
|
||||
}
|
||||
|
||||
cfg, err := config.NewBuilder().
|
||||
WithTarget(&ServerConfig{}).
|
||||
WithFile("config.toml").
|
||||
WithTypedValidator(func(c *ServerConfig) error {
|
||||
// Use bundled validators
|
||||
if err := config.IPAddress(c.Host); err != nil {
|
||||
return fmt.Errorf("invalid host: %w", err)
|
||||
}
|
||||
|
||||
if err := config.Port(c.Port); err != nil {
|
||||
return fmt.Errorf("invalid port: %w", err)
|
||||
}
|
||||
|
||||
modeValidator := config.OneOf("development", "production")
|
||||
if err := modeValidator(c.Mode); err != nil {
|
||||
return fmt.Errorf("invalid mode: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}).
|
||||
Build()
|
||||
```
|
||||
|
||||
## Integration with go-playground/validator
|
||||
|
||||
The package works seamlessly with `go-playground/validator`:
|
||||
|
||||
```go
|
||||
import "github.com/go-playground/validator/v10"
|
||||
|
||||
type ServerConfig struct {
|
||||
Host string `toml:"host" validate:"required,hostname"`
|
||||
Port int `toml:"port" validate:"required,min=1024,max=65535"`
|
||||
|
||||
TLS struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
Cert string `toml:"cert" validate:"required_if=Enabled true"`
|
||||
Key string `toml:"key" validate:"required_if=Enabled true"`
|
||||
} `toml:"tls"`
|
||||
}
|
||||
|
||||
// Load configuration
|
||||
cfg, _ := config.NewBuilder().
|
||||
WithDefaults(&ServerConfig{}).
|
||||
Build()
|
||||
|
||||
// Get populated struct
|
||||
serverCfg, _ := cfg.AsStruct()
|
||||
|
||||
// Validate with go-playground/validator
|
||||
validate := validator.New()
|
||||
if err := validate.Struct(serverCfg); err != nil {
|
||||
return err // Validation errors
|
||||
}
|
||||
```
|
||||
|
||||
Tags don't conflict since each package uses different tag names (`toml`/`json`/`yaml` for config, `validate` for validator).
|
||||
Helpers include Port, Positive, NonNegative, IPAddress, IPv4Address, IPv6Address,
|
||||
URLPath, OneOf, Range, Pattern and NonEmpty. Pattern compiles its expression when the
|
||||
validator is created and panics on an invalid expression. Network validators allow
|
||||
the documented empty/unspecified defaults. Use errors.Is with ErrValidation,
|
||||
ErrTypeMismatch and ErrDecode to handle failures.
|
||||
Reference in new issue
Block a user