Documentation
¶
Overview ¶
Package config provides configuration loading, merging, and access via the Containable interface backed by Viper.
Configurations can be loaded from multiple sources — local files, embedded assets, environment variables, and command-line flags — and merged with deterministic precedence. Factory functions include NewFilesContainer, LoadFilesContainer, NewReaderContainer, and NewContainerFromViper.
Type-safe accessors (GetString, GetInt, GetBool, GetDuration, GetTime, etc.) are exposed through Containable. For advanced use cases, [Containable.GetViper] provides direct access to the underlying Viper instance as an intentional power-user escape hatch.
Hot-reload is supported on file-backed containers via the Observable interface. The container owns an fsnotify watcher over every configured file; on a change it re-reads and re-merges all files into a candidate, validates the candidate against the schema (if any), and only on success swaps the live config and notifies registered observers. Unparseable or invalid reloads are rejected fail-closed, keeping the last-known-good values. The debounce window is configurable via WithReloadDebounce; call Container.Close to stop the watcher.
Index ¶
- Constants
- Variables
- func LoadEnv(fs afero.Fs, log logger.Logger)
- func ValidateStruct[T any](cfg Containable, opts ...SchemaOption) error
- type Containable
- func Load(paths []string, fs afero.Fs, allowEmptyConfig bool, opts ...ContainerOption) (Containable, error)
- func LoadEmbed(paths []string, assets fs.FS, opts ...ContainerOption) (Containable, error)
- func LoadFilesContainer(fs afero.Fs, opts ...ContainerOption) (Containable, error)
- func LoadFilesContainerWithSchema(fs afero.Fs, schema *Schema, opts ...ContainerOption) (Containable, error)
- type Container
- func (c *Container) AddObserver(o Observable)
- func (c *Container) AddObserverFunc(f func(Containable) error)
- func (c *Container) BindPFlag(key string, flag *pflag.Flag) error
- func (c *Container) Close() error
- func (c *Container) Dump(w io.Writer)
- func (c *Container) Get(key string) any
- func (c *Container) GetBool(key string) bool
- func (c *Container) GetDuration(key string) time.Duration
- func (c *Container) GetFloat(key string) float64
- func (c *Container) GetInt(key string) int
- func (c *Container) GetObservers() []Observable
- func (c *Container) GetString(key string) string
- func (c *Container) GetTime(key string) time.Time
- func (c *Container) GetViper() *viper.Viper
- func (c *Container) Has(key string) bool
- func (c *Container) IsSet(key string) bool
- func (c *Container) OnReloadError(f func(error))
- func (c *Container) Set(key string, value any)
- func (c *Container) SetSchema(schema *Schema)
- func (c *Container) Sub(key string) Containable
- func (c *Container) ToJSON() string
- func (c *Container) Validate(schema *Schema) *ValidationResult
- func (c *Container) WriteConfigAs(dest string) error
- type ContainerOption
- func WithConfigFiles(files ...string) ContainerOption
- func WithConfigFormat(format string) ContainerOption
- func WithConfigReaders(readers ...io.Reader) ContainerOption
- func WithEnvPrefix(prefix string) ContainerOption
- func WithLogger(l logger.Logger) ContainerOption
- func WithReloadDebounce(d time.Duration) ContainerOption
- func WithSchema(schema *Schema) ContainerOption
- type FieldSchema
- type Observable
- type Observer
- type Schema
- type SchemaOption
- type ValidationError
- type ValidationResult
Examples ¶
Constants ¶
const DefaultReloadDebounce = 250 * time.Millisecond
DefaultReloadDebounce is the default coalescing window applied to the burst of filesystem events a single save produces before a hot-reload is performed. The relatively generous default tolerates slow or networked filesystems.
Variables ¶
var ( ErrNoFilesFound = errors.Newf("no configuration files found please run init, or provide a config file using the --config flag") // ErrConfigFileNotFound is returned by LoadFilesContainer when the first // configured file does not exist. Callers can branch on this with // errors.Is to fall back to defaults (e.g. on first run). ErrConfigFileNotFound = errors.Newf("config file not found") )
Functions ¶
func LoadEnv ¶
LoadEnv loads environment variables from a .env file if it exists. A nil log is tolerated — it falls back to a no-op logger so callers that have not yet wired logging cannot trigger a nil-pointer panic.
func ValidateStruct ¶ added in v0.4.0
func ValidateStruct[T any](cfg Containable, opts ...SchemaOption) error
ValidateStruct validates cfg against the schema derived from T, returning a formatted error if any rule fails or nil if the configuration is valid.
It takes the Containable interface, so callers do not need to type-assert Props.Config down to the concrete *Container. It is the recommended way to validate a command or feature's config slice:
if err := config.ValidateStruct[MyConfig](props.Config); err != nil {
return err
}
Schema options such as WithStrictMode may be passed through.
Types ¶
type Containable ¶
type Containable interface {
Get(key string) any
GetBool(key string) bool
GetInt(key string) int
GetFloat(key string) float64
GetString(key string) string
GetTime(key string) time.Time
GetDuration(key string) time.Duration
// GetViper returns the underlying Viper instance for advanced operations
// not exposed by the Containable interface. This is an intentional escape
// hatch for power users who need Viper's full API (e.g., MergeConfig,
// BindPFlag, or direct access to config file watching).
//
// Prefer the typed accessor methods (GetString, GetInt, etc.) for standard
// configuration reads. Use GetViper only when the Containable interface
// does not cover your use case.
GetViper() *viper.Viper
// BindPFlag binds a single pflag to the given configuration key. Once
// bound, the flag's value participates in Viper's precedence resolution,
// sitting above environment variables and file config. Callers should only
// bind flags the user explicitly changed (flag.Changed) so that a flag left
// at its default does not clobber configured values.
BindPFlag(key string, flag *pflag.Flag) error
Has(key string) bool
IsSet(key string) bool
Set(key string, value any)
WriteConfigAs(dest string) error
Sub(key string) Containable
AddObserver(o Observable)
AddObserverFunc(f func(Containable) error)
// OnReloadError registers a callback invoked whenever a hot-reload is
// rejected (candidate build or schema validation failed) and the
// last-known-good config is retained. It is never called for a
// successful reload; observers handle that case.
OnReloadError(f func(error))
ToJSON() string
Dump(w io.Writer)
// Validate checks the container's current values against the provided schema.
// Returns a ValidationResult; callers should check result.Valid().
Validate(schema *Schema) *ValidationResult
}
Containable is the primary configuration interface. It provides typed accessors, observation for hot-reload, and schema validation.
func Load ¶
func Load(paths []string, fs afero.Fs, allowEmptyConfig bool, opts ...ContainerOption) (Containable, error)
Load reads configuration from the first available file in paths. Returns ErrNoFilesFound if no files exist and allowEmptyConfig is false.
func LoadEmbed ¶
func LoadEmbed(paths []string, assets fs.FS, opts ...ContainerOption) (Containable, error)
LoadEmbed reads configuration from embedded filesystem assets and merges them.
func LoadFilesContainer ¶
func LoadFilesContainer(fs afero.Fs, opts ...ContainerOption) (Containable, error)
LoadFilesContainer loads configuration from files and returns a Containable. Config files are specified via WithConfigFiles. It returns ErrConfigFileNotFound if the first file specified does not exist; it never returns a nil Containable with a nil error.
func LoadFilesContainerWithSchema ¶
func LoadFilesContainerWithSchema(fs afero.Fs, schema *Schema, opts ...ContainerOption) (Containable, error)
LoadFilesContainerWithSchema loads config files and validates against the schema. Returns an error wrapping all validation errors if the config is invalid. The schema can also be provided via WithSchema; if both are present, the option takes precedence.
type Container ¶
type Container struct {
ID string
// contains filtered or unexported fields
}
Container container for configuration.
Sub-containers returned by Container.Sub track two Viper instances:
- viper (the "structural view") points at the sub-tree and is used by Write/Dump/ToJSON/Validate operations so those continue to scope correctly to the sub-path.
- root + prefix together identify the original container; every Get/Set/Has/IsSet call is routed through root.viper with the key qualified by the accumulated prefix. This is what keeps Viper's AutomaticEnv + prefix-aware env binding alive across Sub() calls — `cfg.Sub("github").GetString("auth.value")` now honours `<TOOL_PREFIX>_GITHUB_AUTH_VALUE`, which a Viper-native Sub would silently drop.
func NewContainerFromViper ¶
NewContainerFromViper creates a new Container from an existing Viper instance. If l is nil, a no-op logger is used.
func NewFilesContainer ¶
func NewFilesContainer(fs afero.Fs, opts ...ContainerOption) *Container
NewFilesContainer initialises a configuration container to read files from the FS. Config files are specified via WithConfigFiles.
func NewReaderContainer ¶
func NewReaderContainer(fs afero.Fs, opts ...ContainerOption) *Container
NewReaderContainer initialises a configuration container to read config from io.Readers. Readers are specified via WithConfigReaders; format via WithConfigFormat.
Example ¶
package main
import (
"fmt"
"strings"
"github.com/spf13/afero"
"gitlab.com/phpboyscout/go-tool-base/pkg/config"
"gitlab.com/phpboyscout/go-tool-base/pkg/logger"
)
func main() {
l := logger.NewNoop()
yaml := `
log:
level: debug
server:
port: 8080
`
cfg := config.NewReaderContainer(afero.NewMemMapFs(), config.WithLogger(l), config.WithConfigFormat("yaml"), config.WithConfigReaders(strings.NewReader(yaml)))
fmt.Println("Level:", cfg.GetString("log.level"))
fmt.Println("Port:", cfg.GetInt("server.port"))
}
Output: Level: debug Port: 8080
func (*Container) AddObserver ¶
func (c *Container) AddObserver(o Observable)
AddObserver attach observer to trigger on config update.
func (*Container) AddObserverFunc ¶
func (c *Container) AddObserverFunc(f func(Containable) error)
AddObserverFunc attach function to trigger on config update.
func (*Container) BindPFlag ¶ added in v0.17.0
BindPFlag binds a single pflag to the given configuration key.
Binding is routed through the same Viper instance used for value resolution (the root container's Viper on sub-containers), and the key is qualified with the sub-container's prefix, so a bound flag is visible to the typed Get methods at Viper's documented precedence (flag > env > file).
Bind only flags the user explicitly changed (flag.Changed); binding a flag at its default value would clobber configured values — Viper's classic default-clobber footgun.
func (*Container) Close ¶ added in v0.17.0
Close stops the hot-reload watcher and releases its resources. It is safe to call on containers that are not watching and safe to call more than once.
func (*Container) GetDuration ¶
GetDuration get duration value from config.
func (*Container) GetObservers ¶
func (c *Container) GetObservers() []Observable
GetObservers retrieve all currently attached Observers.
func (*Container) GetViper ¶
GetViper retrieves the underlying Viper configuration. On sub-containers this returns the sub-tree structural view; use this only for bulk reads that do not need env binding (the full env-aware resolution pipeline is only available via the typed Get methods).
func (*Container) Has ¶
Has reports whether the given key is present in the loaded file configuration. It is backed by Viper's InConfig, which inspects only keys read from a config file — env vars picked up by AutomaticEnv, flag bindings, and Set overrides are not counted. Use IsSet for a presence check that spans file, env, and flag sources.
func (*Container) OnReloadError ¶ added in v0.17.0
OnReloadError registers a callback invoked whenever a hot-reload is rejected: the candidate failed to build (fail-closed partial-merge, parse error, or a missing primary file) or failed schema validation, so the live config was NOT swapped and the last-known-good config is retained. The callback is never invoked for a successful reload (observers are notified of that change instead). Callbacks run in registration order on the watcher goroutine, after the container has logged the error; a slow callback delays subsequent reloads, so expensive work should be offloaded.
func (*Container) SetSchema ¶
SetSchema attaches a validation schema to the container. When set, hot-reload will validate a candidate config before swapping it in; a candidate that fails validation is rejected and the last-known-good config is retained.
func (*Container) Sub ¶
func (c *Container) Sub(key string) Containable
Sub returns a view over a subtree of the parent configuration.
Unlike Viper's native Sub — which constructs a fresh Viper instance that drops the parent's AutomaticEnv + prefix settings — this Sub returns a view that delegates every Get/Set/Has/IsSet call back to the root container's Viper with a fully-qualified key path. That means env-aware resolution (including prefix-aware env vars like `<TOOL>_GITHUB_AUTH_VALUE`) continues to apply no matter how many Sub() layers a caller walks through.
The returned view's `viper` field is the Viper-native sub-tree used only by operations that legitimately need a scoped view: WriteConfigAs, Dump, ToJSON, and Validate.
Returns nil when key is not present anywhere in the config hierarchy, matching Viper's Sub semantics.
func (*Container) Validate ¶
func (c *Container) Validate(schema *Schema) *ValidationResult
Validate checks the current configuration against the provided schema. Returns a ValidationResult; callers should check result.Valid().
Example ¶
package main
import (
"fmt"
"strings"
"github.com/spf13/afero"
"gitlab.com/phpboyscout/go-tool-base/pkg/config"
"gitlab.com/phpboyscout/go-tool-base/pkg/logger"
)
func main() {
type AppConfig struct {
Log struct {
Level string `config:"log.level" enum:"debug,info,warn,error"`
}
}
l := logger.NewNoop()
cfg := config.NewReaderContainer(afero.NewMemMapFs(), config.WithLogger(l), config.WithConfigFormat("yaml"), config.WithConfigReaders(strings.NewReader("log:\n level: verbose\n")))
schema, _ := config.NewSchema(config.WithStructSchema(AppConfig{}))
result := cfg.Validate(schema)
if !result.Valid() {
fmt.Println(result.Error())
}
}
Output:
func (*Container) WriteConfigAs ¶
WriteConfigAs writes the current configuration to the given path.
type ContainerOption ¶
type ContainerOption func(*containerOptions)
ContainerOption configures optional behavior for config containers.
func WithConfigFiles ¶
func WithConfigFiles(files ...string) ContainerOption
WithConfigFiles specifies one or more config file paths to load. The first file is treated as the primary config; subsequent files are merged in order.
func WithConfigFormat ¶
func WithConfigFormat(format string) ContainerOption
WithConfigFormat sets the config format (e.g. "yaml", "json") for reader-based config loading.
func WithConfigReaders ¶
func WithConfigReaders(readers ...io.Reader) ContainerOption
WithConfigReaders provides one or more io.Readers as config sources. The first reader is the primary config; subsequent readers are merged. Requires WithConfigFormat to be set.
func WithEnvPrefix ¶
func WithEnvPrefix(prefix string) ContainerOption
WithEnvPrefix sets the environment variable prefix for automatic env binding. When set to "GTB", the config key "ai.provider" resolves from the environment variable "GTB_AI_PROVIDER". An empty string disables prefixing (the default, preserving backward compatibility).
func WithLogger ¶
func WithLogger(l logger.Logger) ContainerOption
WithLogger sets the logger for the config container. When not provided, a no-op logger is used.
func WithReloadDebounce ¶ added in v0.17.0
func WithReloadDebounce(d time.Duration) ContainerOption
WithReloadDebounce sets the coalescing window for hot-reload events. A single file save typically emits a burst of filesystem events (write, rename, chmod); the container waits for the burst to settle for the given duration before performing a reload. Values <= 0 fall back to DefaultReloadDebounce.
func WithSchema ¶
func WithSchema(schema *Schema) ContainerOption
WithSchema attaches a validation schema to the container.
type FieldSchema ¶
type FieldSchema struct {
// Type is the expected Go type: "string", "int", "float64", "bool", "duration".
Type string
// Required indicates the field must be present and non-zero.
Required bool
// Description is used in validation error messages.
Description string
// Default is the default value for documentation and error hints only.
// The validation layer does not inject defaults — use embedded assets for that.
Default any
// Enum restricts the field to a set of allowed values.
Enum []any
}
FieldSchema describes a single configuration field.
type Observable ¶
type Observable interface {
Run(Containable) error
}
Observable is the interface for config change observers. Implementations receive the updated config when a watched config file changes and return an error if they could not apply the new configuration. The returned error is logged by the container; it never blocks subsequent reloads.
type Observer ¶
type Observer struct {
// contains filtered or unexported fields
}
Observer is a simple Observable that wraps a handler function.
func (Observer) Run ¶
func (o Observer) Run(c Containable) error
Run invokes the observer's handler with the updated config.
type Schema ¶
type Schema struct {
// contains filtered or unexported fields
}
Schema defines the expected structure and constraints for configuration values.
func NewSchema ¶
func NewSchema(opts ...SchemaOption) (*Schema, error)
NewSchema creates a Schema from the provided options.
Example ¶
package main
import (
"fmt"
"gitlab.com/phpboyscout/go-tool-base/pkg/config"
)
func main() {
type AppConfig struct {
Server struct {
Port int `config:"server.port" default:"8080"`
Host string `config:"server.host"`
}
Log struct {
Level string `config:"log.level" enum:"debug,info,warn,error" default:"info"`
}
Github struct {
Token string `config:"github.token" validate:"required"`
}
}
schema, err := config.NewSchema(config.WithStructSchema(AppConfig{}))
if err != nil {
fmt.Println("Error:", err)
return
}
_ = schema // Use with container.Validate(schema)
}
Output:
func SchemaOf ¶ added in v0.4.0
func SchemaOf[T any](opts ...SchemaOption) (*Schema, error)
SchemaOf returns a Schema derived from the struct tags of T. For the option-free call the result is cached per type, so repeated calls for the same T do not re-reflect. When opts are supplied (for example WithStrictMode) the schema is built fresh and not cached, since options can change the result.
T must be a struct; see WithStructSchema for the supported tags.
func (*Schema) Fields ¶
func (s *Schema) Fields() map[string]FieldSchema
Fields returns the schema field definitions.
type SchemaOption ¶
type SchemaOption func(*schemaConfig)
SchemaOption configures schema construction.
func WithStrictMode ¶
func WithStrictMode() SchemaOption
WithStrictMode treats unknown keys as errors instead of warnings.
func WithStructSchema ¶
func WithStructSchema(v any) SchemaOption
WithStructSchema derives a schema from a tagged Go struct. Supported tags: `config:"key" validate:"required" enum:"a,b,c" default:"value"`.
type ValidationError ¶
type ValidationError struct {
// Key is the dot-separated config key.
Key string
// Message is a human-readable description of the failure.
Message string
// Hint is an actionable fix suggestion.
Hint string
}
ValidationError contains details about a single validation failure.
func (ValidationError) String ¶
func (e ValidationError) String() string
type ValidationResult ¶
type ValidationResult struct {
Errors []ValidationError
Warnings []ValidationError
}
ValidationResult holds the outcome of schema validation.
func (*ValidationResult) Error ¶
func (r *ValidationResult) Error() string
Error returns a formatted multi-line error string, or empty string if valid.
func (*ValidationResult) Valid ¶
func (r *ValidationResult) Valid() bool
Valid returns true if no errors were found. Warnings do not affect validity.