Documentation
¶
Overview ¶
Package synthra synthesizes configuration for Go applications from many sources into one coherent runtime state.
The name follows σύνθεσις (synthesis): to put together, to compose into a whole. Modern systems are configured in layers: files, environment variables, defaults, flags, secret stores, and remote providers. Each layer is incomplete alone; Synthra merges them in order (later overrides earlier), validates, binds to structs, and exposes the result through Synthra. **From many sources, one state.**
Key casing ¶
Synthra keeps the casing your config sources use. If your file says `apiVersion`, the loaded map will have `apiVersion` too. This applies to all sources (YAML, JSON, TOML, Consul, embedded files, inline content, and any custom source you write). Only the matching is case-insensitive: you can read the same key as `apiVersion` or `APIVERSION` and get the same value.
The one exception is environment variables. Environment variables are uppercase by convention (`APP_API_VERSION`), so the env source lowercases them to produce a nested map. A WithEnv("APP_") source always contributes lowercase keys like `apiversion`. When env meets another source that already has the same key in a different casing, the case-insensitive merge keeps the first source's casing and overrides only the value. So if your YAML says `apiVersion: v1` and `APP_APIVERSION=v2` is set, the final map has `apiVersion: v2`.
When two non-env sources use different casings for the same key, the first source wins for the name and the last source wins for the value. So if `base.yaml` has `ApiVersion: v1` and `override.yaml` has `apiVersion: v2`, the final map looks like `ApiVersion: v2`. The typo in the base file is preserved.
To avoid that, register a JSON Schema. Before validation runs, Synthra renames any case-different keys in the data to match the schema. So `ApiVersion: v2` becomes `apiVersion: v2` if your schema says `"properties": {"apiVersion": ...}`.
cfg.Get("apiVersion") == cfg.Get("apiversion") // both work
// base.yaml -> ApiVersion: v1
// override.yaml -> apiVersion: v2
// result: ApiVersion: v2 (first writer's casing wins)
// Same files, with a schema declaring apiVersion:
// result: apiVersion: v2 (schema is the authority)
// config.yaml -> apiVersion: v1
// APP_APIVERSION=v2
// result: apiVersion: v2 (YAML casing wins, env overrides value)
Keys without a schema declaration keep whatever casing the first source provided. The env source always produces lowercase keys. `patternProperties` and `additionalProperties` dynamic keys are not renamed by the schema. Keys inside list elements are only renamed when the schema declares an `items` object for that list.
The package uses the same functional options pattern as other Gopherly packages: options apply to an internal config struct, and the constructor validates and builds the public Synthra from it. The returned Synthra is the runtime object used for Load, Get, and Dump.
Key Features ¶
- Multiple configuration sources (files, io/fs.FS, environment variables, Consul)
- Automatic format detection and decoding (JSON, YAML, TOML)
- Pipeline processing: schema steps, transforms, and validators are executed in registration order, enabling multi-phase workflows
- JSON Schema defaults: "default" values declared in the schema are automatically applied to missing keys, including patternProperties
- Dynamic schema selection (WithJSONSchemaFunc) for version-based or content-based schema routing at Load time
- POSIX-style variable substitution (WithEnvSubst) and arbitrary transforms (WithTransform) can be interleaved with schema steps
- Composable variable resolvers (FromMap, FromEnv, FromEnvFile) for maps, OS env, and .env files; prefix stripping via Resolver.Prefix and first-wins fallback chains via Resolver.Or
- Struct binding with automatic type conversion
- Validation using JSON Schema or custom validators
- Case-insensitive key access with dot notation
- Thread-safe configuration loading and access
- Configuration dumping to files or custom destinations
Quick Start ¶
Create a configuration instance with sources. Options are applied in order; any validation errors are reported when the config is built (by New or MustNew). Options must not be nil; passing a nil option results in a validation error.
cfg := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithEnv("APP_"),
)
Load the configuration:
if err := cfg.Load(context.Background()); err != nil {
log.Fatal(err)
}
Access configuration values (strict reads return an error if the key is missing or the value cannot be coerced; use *Or methods for defaults):
port, err := cfg.Int("server.port")
if err != nil {
log.Fatal(err)
}
host := cfg.StringOr("server.host", "localhost")
debug, err := cfg.Bool("debug")
if err != nil {
log.Fatal(err)
}
Configuration Sources ¶
The package supports multiple configuration sources that can be combined:
Files with automatic format detection:
synthra.WithFile("config.yaml") // Detects YAML
synthra.WithFile("config.json") // Detects JSON
synthra.WithFile("config.toml") // Detects TOML
Files with explicit format:
synthra.WithFileAs("config", codec.YAML)
Virtual files inside an io/fs.FS (tests, embed.FS, etc.):
synthra.WithFileFS(fsys, "config.yaml") synthra.WithFileFSAs(fsys, "config", codec.YAML)
Environment variables with prefix:
synthra.WithEnv("APP_") // Loads APP_SERVER_PORT as server.port
Consul key-value store (CONSUL_HTTP_ADDR required; construction fails if unset):
synthra.WithConsul("production/service.yaml")
Conditional Consul (e.g. for local dev without Consul):
synthra.WithIf(os.Getenv("CONSUL_HTTP_ADDR") != "",
synthra.WithConsul("production/service.yaml"),
)
Raw content:
yamlData := []byte("port: 8080")
synthra.WithContent(yamlData, codec.YAML)
Struct Binding ¶
Bind configuration to a struct for type-safe access:
type AppConfig struct {
Port int `synthra:"port"`
Host string `synthra:"host"`
Timeout time.Duration `synthra:"timeout"`
Debug bool `synthra:"debug" default:"false"`
}
var appConfig AppConfig
cfg := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithBinding(&appConfig),
)
if err := cfg.Load(context.Background()); err != nil {
log.Fatal(err)
}
// Access typed fields directly
fmt.Printf("Server: %s:%d\n", appConfig.Host, appConfig.Port)
Validation ¶
Validate configuration using struct methods:
type Config struct {
Port int `synthra:"port"`
}
func (c *Config) Validate() error {
if c.Port < 1 || c.Port > 65535 {
return fmt.Errorf("port must be between 1 and 65535")
}
return nil
}
Validate using JSON Schema (also applies "default" values automatically):
schema := []byte(`{
"type": "object",
"properties": {
"port": {"type": "integer", "minimum": 1, "maximum": 65535, "default": 8080}
},
"required": ["port"]
}`)
cfg := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithJSONSchema(schema), // validates AND fills in "default" values
)
// If config.yaml omits "port", Load sets it to 8080 before validating.
Pipeline ¶
After all sources are merged, Synthra executes pipeline steps in the order they were registered. Steps are added by:
- WithJSONSchema: validates against a static schema and applies its declared default values.
- WithJSONSchemaFunc: same as WithJSONSchema, but the schema bytes are returned by a callback that receives the current values map. Use this when the schema depends on a value inside the config (e.g. an apiVersion field).
- WithTransform: arbitrary map mutation step.
- WithEnvSubst: convenience transform that expands ${VAR} placeholders using a Resolver. Compose multiple sources with Resolver.Or (first match wins; see "Resolver vs Source precedence" below).
- WithEnvSubstFunc: same as WithEnvSubst, but the Resolver is built by a callback at Load time. Use this when the resolver depends on a value already loaded from a source (e.g. a .env file path in the config file).
- WithValidator: read-only check that may return an error.
Because steps run in registration order, you can interleave them freely. A common pattern is two-phase validation: validate partial data before substitution, substitute, then validate the final form.
Example: dynamic schema selection based on apiVersion:
cfg := synthra.MustNew(
synthra.WithFile("manifest.yaml"),
synthra.WithJSONSchemaFunc(func(v *synthra.Values) ([]byte, error) {
version, err := v.String("apiVersion")
if err != nil || version == "" {
return nil, errors.New("apiVersion is required")
}
return schemaRegistry.Get(version)
}),
)
Example: two-phase validation (validate before and after substitution):
cfg := synthra.MustNew(
synthra.WithFile("manifest.yaml"),
// Step 1: validate the raw "environments" block before substitution.
synthra.WithJSONSchemaFunc(environmentsSchema),
// Step 2: expand ${VAR} placeholders.
synthra.WithEnvSubst(synthra.FromEnv()),
// Step 3: validate the fully-substituted manifest.
synthra.WithJSONSchemaFunc(manifestSchema),
)
Multiple WithJSONSchema and WithJSONSchemaFunc calls are fully supported and each adds an independent schema step at the point it was registered. There is no mutual-exclusivity restriction.
Resolver vs Source precedence ¶
Synthra uses two different precedence rules, one for each kind of operation:
Sources (tree merge): later wins. When you call WithFile, WithEnv, or WithSource multiple times, each call layers on top of the previous one. The last source to provide a key wins. This matches every major config library (viper, koanf, dynaconf, Figment .merge).
Resolvers (per-key lookup): first wins. Resolver.Or tries the receiver before each fallback, returning as soon as one reports found=true. This matches stdlib context.Value, where the innermost (highest-priority) context shadows outer ones, and other per-key lookup chains (Spring PropertySource, os/exec.LookPath).
These rules are different because the operations are different: overlaying a full tree is not the same as looking up one key in a chain of stores. Each rule is named for its operation so you do not need to remember which library uses which: Sources layer in registration order (last wins), Resolvers fall through in call order (first wins via Or).
WithEnv and WithEnvSubst solve different problems and work well together:
- WithEnv is a source. It reads environment variables and adds them to the config map. For example, APP_SERVER_PORT=8080 becomes server.port.
- WithEnvSubst is a transform. It expands ${VAR} placeholders that are already present in string values loaded from files or other sources.
Example: three-layer priority with Or (highest priority first):
envFile, err := synthra.FromEnvFile(".env")
if err != nil {
log.Fatal(err)
}
cfg := synthra.MustNew(
synthra.WithFile("deployah.yaml"),
synthra.WithEnvSubst(
synthra.FromEnv().Prefix("DPY_VAR_"). // highest: prefixed OS env
Or(envFile). // middle: .env file
Or(synthra.FromMap(manifestVars)), // lowest: static defaults
),
)
// config.yaml: port: ${PORT:-3000}
// If DPY_VAR_PORT=9090 is set in the environment, port becomes "9090".
Example: custom transform to normalize values before schema validation:
cfg := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithTransform(func(v *synthra.Values) error {
if level := v.StringOr("logLevel", ""); level != "" {
return v.Set("logLevel", strings.ToLower(level))
}
return nil
}),
synthra.WithJSONSchema(schema), // validates the normalized values
)
Validate using custom functions:
cfg := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithValidator(func(v *synthra.Values) error {
if port := v.IntOr("port", 0); port < 1 {
return fmt.Errorf("invalid port: %d", port)
}
return nil
}),
)
Error paths for pipeline failures follow the pattern "step[N]:kind" where N is the zero-based step index and kind is "schema", "transform", or "validator".
Pipeline callbacks and Values ¶
Synthra's pipeline callbacks receive a *Values wrapper instead of a raw map. The wrapper gives you safe, case-insensitive, typed access to the data flowing through the pipeline.
Direct map access is case-sensitive at the Go language level. If your YAML says `apiVersion` but the merged map ends up storing it under a slightly different casing, `values["apiVersion"]` returns nil. The *Values wrapper fixes that. All its methods are case-insensitive.
Reading:
v.Get("metadata.name") // any, case-insensitive
v.Has("server.tls.enabled") // bool
v.String("apiVersion") // (string, error)
v.IntOr("server.port", 8080) // int with default
Writing:
v.Set("metadata.region", "eu-west-1") // creates intermediate maps
v.Delete("debug.experimental")
Walking the tree:
v.Walk(func(path string, val any) (any, bool) {
if s, ok := val.(string); ok && strings.HasPrefix(s, "${") {
return strings.TrimPrefix(s, "${"), true
}
return val, false
})
When you must hand the underlying map to code that expects a plain `map[string]any`, call `v.Raw()`. Mutations on the returned map are visible through the same *Values.
WithTransform, WithValidator, WithEnvSubstFunc, and WithJSONSchemaFunc run at the map stage, before binding, on the merged *Values.
OnBound is a binding-scoped option that goes inside WithBinding. It runs at the binding stage: after the bound struct is decoded and defaults applied, but before its `Validate()` method (if it implements Validator). The type parameter is inferred from the closure, and the compiler enforces that it matches the binding target.
Example combining both stages:
synthra.WithFile("config.yaml"),
synthra.WithTransform(func(v *synthra.Values) error {
if v.StringOr("env", "dev") == "prod" {
return v.Set("logging.level", "warn")
}
return nil
}),
synthra.WithBinding(&app,
synthra.OnBound(func(a *App) error {
a.Logging.Level = strings.ToLower(a.Logging.Level)
return nil
}),
),
Because OnBound is a sub-option of WithBinding, Go infers the same T for both. If the closure type does not match the binding target, you get a compile error, not a runtime panic:
var server Server
synthra.WithBinding(&server,
synthra.OnBound(func(a *App) error { ... }), // compile error
)
*Values is not safe for concurrent use. Each Load creates its own; do not share one across goroutines. Only one WithBinding per Synthra instance is supported.
Accessing Configuration Values ¶
Type-specific methods return (value, error). Missing keys and failed coercions are errors; use errors.Is with ErrKeyNotFound or ErrNilConfig as needed. Methods on a nil *Synthra return ErrNilConfig.
// Basic types (strict)
port, err := cfg.Int("server.port")
if err != nil {
return err
}
host, err := cfg.String("server.host")
if err != nil {
return err
}
debug, err := cfg.Bool("debug")
if err != nil {
return err
}
rate, err := cfg.Float64("rate")
if err != nil {
return err
}
// Optional keys with defaults (no error when missing)
host := cfg.StringOr("server.host", "localhost")
port := cfg.IntOr("server.port", 8080)
// Collections (strict)
tags, err := cfg.StringSlice("tags")
if err != nil {
return err
}
ports, err := cfg.IntSlice("ports")
if err != nil {
return err
}
metadata, err := cfg.StringMap("metadata")
if err != nil {
return err
}
// Time-related (strict)
timeout, err := cfg.Duration("timeout")
if err != nil {
return err
}
startTime, err := cfg.Time("start_time")
if err != nil {
return err
}
Generic Get for typed reads (same missing-key errors; primitive coercion matches GetOr for unsupported kinds):
port, err := synthra.Get[int](cfg, "server.port")
if err != nil {
log.Fatalf("port configuration required: %v", err)
}
Configuration Dumping ¶
Save the current configuration to a file:
cfg := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithFileDumper("output.yaml"),
)
cfg.Load(context.Background())
cfg.Dump(context.Background()) // Writes to output.yaml
Thread Safety ¶
Synthra is safe for concurrent use by multiple goroutines. Configuration loading and reading are protected by internal locks. Multiple goroutines can safely call Load() and access configuration values simultaneously.
Escape hatches ¶
For debugging or custom serialization, *Synthra.Values returns a shallow copy of the merged top-level map. Nested maps, slices, and pointers are not deep-copied; do not mutate nested values. Treat the snapshot as read-only.
Error Handling ¶
Construction failures, load/dump failures, and accessor type-conversion failures are returned as *ConfigError, shaped like os.PathError: Op names the entrypoint (OpNew, OpLoad, OpDump, or OpGet); Path is a diagnostic locator whose meaning depends on Op; Err is the cause for errors.Unwrap, errors.Is, and errors.As.
Use errors.As to inspect the structured error and switch on Op:
if err := cfg.Load(ctx); err != nil {
var ce *synthra.ConfigError
if errors.As(err, &ce) {
switch ce.Op {
case synthra.OpLoad:
log.Error("load failed", "path", ce.Path, "err", ce.Err)
}
}
return err
}
Use errors.Is for fixed outcomes such as a missing key, nil receiver, or nil context:
_, err := cfg.Int("server.port")
if errors.Is(err, synthra.ErrKeyNotFound) {
return useDefaultPort()
}
New may return errors.Join of multiple *ConfigError values. A single errors.As finds the first in the tree; to log every construction error, iterate using the errors.Join unwrap slice (see errors.Join).
Examples ¶
See the examples directory for complete working examples demonstrating various configuration patterns and use cases including:
- examples/basic: YAML file and struct binding
- examples/webapp: YAML defaults, env overrides, binding, and Validate
- examples/testing: synthratest helpers and source.NewMap
- examples/schema: WithJSONSchema defaults and patternProperties
- examples/casing: case-insensitive merge and schema as casing authority
- examples/hooks: WithTransform, WithValidator, and OnBound[T] in one pipeline
- examples/codecs: WithFileAs (JSON, TOML) and WithFileDumperAs (YAML dump)
- examples/envsubst-layered: three-layer Resolver.Or precedence
- examples/multi-schema: two-phase validation with WithJSONSchemaFunc
- examples/consul: optional Consul source with WithIf
For more details, see the package documentation at https://pkg.go.dev/gopherly.dev/synthra
Example ¶
Example demonstrates basic configuration usage.
package main
import (
"context"
"fmt"
"log"
"gopherly.dev/synthra"
"gopherly.dev/synthra/codec"
)
func exampleString(cfg *synthra.Synthra, key string) string {
v, err := cfg.String(key)
if err != nil {
log.Fatal(err)
}
return v
}
func exampleInt(cfg *synthra.Synthra, key string) int {
v, err := cfg.Int(key)
if err != nil {
log.Fatal(err)
}
return v
}
func main() {
// Create config with YAML content
yamlContent := []byte(`
server:
host: localhost
port: 8080
database:
name: mydb
`)
cfg, err := synthra.New(
synthra.WithContent(yamlContent, codec.YAML),
)
if err != nil {
log.Fatal(err)
}
// Load configuration
if err = cfg.Load(context.Background()); err != nil {
log.Fatal(err)
}
// Access values
fmt.Println(exampleString(cfg, "server.host"))
fmt.Println(exampleInt(cfg, "server.port"))
fmt.Println(exampleString(cfg, "database.name"))
}
Output: localhost 8080 mydb
Example (EnvironmentVariables) ¶
Example_environmentVariables demonstrates loading configuration from environment variables.
package main
import (
"context"
"fmt"
"log"
"gopherly.dev/synthra"
)
func main() {
// In real usage, set environment variables like:
// export APP_SERVER_HOST=localhost
// export APP_SERVER_PORT=8080
cfg, err := synthra.New(
synthra.WithEnv("APP_"),
)
if err != nil {
log.Fatal(err)
}
if err = cfg.Load(context.Background()); err != nil {
log.Fatal(err)
}
// Access environment variables without the prefix
// e.g., APP_SERVER_HOST becomes server.host
fmt.Println("Environment variables loaded")
}
Output: Environment variables loaded
Example (MultipleSources) ¶
Example_multipleSources demonstrates merging multiple configuration sources.
package main
import (
"context"
"fmt"
"log"
"gopherly.dev/synthra"
"gopherly.dev/synthra/codec"
)
func exampleString(cfg *synthra.Synthra, key string) string {
v, err := cfg.String(key)
if err != nil {
log.Fatal(err)
}
return v
}
func exampleInt(cfg *synthra.Synthra, key string) int {
v, err := cfg.Int(key)
if err != nil {
log.Fatal(err)
}
return v
}
func main() {
// Base configuration
baseConfig := []byte(`
server:
host: localhost
port: 8080
`)
// Override configuration
overrideConfig := []byte(`
server:
port: 9090
`)
cfg, err := synthra.New(
synthra.WithContent(baseConfig, codec.YAML),
synthra.WithContent(overrideConfig, codec.YAML),
)
if err != nil {
log.Fatal(err)
}
if err = cfg.Load(context.Background()); err != nil {
log.Fatal(err)
}
// Later sources override earlier ones
fmt.Println(exampleString(cfg, "server.host"))
fmt.Println(exampleInt(cfg, "server.port"))
}
Output: localhost 9090
Index ¶
- Constants
- Variables
- func Get[T any](c *Synthra, key string) (T, error)
- func GetOr[T any](c *Synthra, key string, defaultVal T) T
- type BindingOption
- type ConfigError
- type Dumper
- type Option
- func WithBinding[T any](target *T, opts ...BindingOption[T]) Option
- func WithConsul(path string) Option
- func WithConsulAs(path string, decoder codec.Decoder) Option
- func WithContent(data []byte, decoder codec.Decoder) Option
- func WithDumper(d Dumper) Option
- func WithEnv(prefix string) Option
- func WithEnvSubst(r Resolver) Option
- func WithEnvSubstFunc(fn func(*Values) (Resolver, error)) Option
- func WithFile(path string) Option
- func WithFileAs(path string, decoder codec.Decoder) Option
- func WithFileDumper(path string) Option
- func WithFileDumperAs(path string, encoder codec.Encoder) Option
- func WithFileFS(fsys fs.FS, path string) Option
- func WithFileFSAs(fsys fs.FS, path string, decoder codec.Decoder) Option
- func WithIf(condition bool, opts ...Option) Option
- func WithJSONSchema(schema []byte) Option
- func WithJSONSchemaFunc(selector func(*Values) ([]byte, error)) Option
- func WithSource(loader Source) Option
- func WithTag(tagName string) Option
- func WithTransform(fn func(*Values) error) Option
- func WithValidator(fn func(*Values) error) Option
- type Resolver
- type Source
- type Synthra
- func (c *Synthra) Bool(key string) (bool, error)
- func (c *Synthra) BoolOr(key string, defaultVal bool) bool
- func (c *Synthra) Dump(ctx context.Context) error
- func (c *Synthra) Duration(key string) (time.Duration, error)
- func (c *Synthra) DurationOr(key string, defaultVal time.Duration) time.Duration
- func (c *Synthra) Float64(key string) (float64, error)
- func (c *Synthra) Float64Or(key string, defaultVal float64) float64
- func (c *Synthra) Get(key string) any
- func (c *Synthra) Int(key string) (int, error)
- func (c *Synthra) Int64(key string) (int64, error)
- func (c *Synthra) Int64Or(key string, defaultVal int64) int64
- func (c *Synthra) IntOr(key string, defaultVal int) int
- func (c *Synthra) IntSlice(key string) ([]int, error)
- func (c *Synthra) IntSliceOr(key string, defaultVal []int) []int
- func (c *Synthra) Load(ctx context.Context) error
- func (c *Synthra) String(key string) (string, error)
- func (c *Synthra) StringMap(key string) (map[string]any, error)
- func (c *Synthra) StringMapOr(key string, defaultVal map[string]any) map[string]any
- func (c *Synthra) StringOr(key, defaultVal string) string
- func (c *Synthra) StringSlice(key string) ([]string, error)
- func (c *Synthra) StringSliceOr(key string, defaultVal []string) []string
- func (c *Synthra) Time(key string) (time.Time, error)
- func (c *Synthra) TimeOr(key string, defaultVal time.Time) time.Time
- func (c *Synthra) Values() *map[string]any
- type Validator
- type Values
- func (v *Values) Bool(path string) (bool, error)
- func (v *Values) BoolOr(path string, def bool) bool
- func (v *Values) Delete(path string) bool
- func (v *Values) Duration(path string) (time.Duration, error)
- func (v *Values) DurationOr(path string, def time.Duration) time.Duration
- func (v *Values) Float64(path string) (float64, error)
- func (v *Values) Float64Or(path string, def float64) float64
- func (v *Values) Get(path string) any
- func (v *Values) Has(path string) bool
- func (v *Values) Int(path string) (int, error)
- func (v *Values) Int64(path string) (int64, error)
- func (v *Values) Int64Or(path string, def int64) int64
- func (v *Values) IntOr(path string, def int) int
- func (v *Values) IntSlice(path string) ([]int, error)
- func (v *Values) Keys() []string
- func (v *Values) Raw() map[string]any
- func (v *Values) Set(path string, value any) error
- func (v *Values) String(path string) (string, error)
- func (v *Values) StringMap(path string) (map[string]string, error)
- func (v *Values) StringOr(path, def string) string
- func (v *Values) StringSlice(path string) ([]string, error)
- func (v *Values) Time(path string) (time.Time, error)
- func (v *Values) Walk(fn func(path string, value any) (any, bool))
Examples ¶
Constants ¶
const ( OpNew = "new" OpLoad = "load" OpDump = "dump" OpGet = "get" )
Op values identify which Synthra entrypoint produced a ConfigError. They follow the lowercase convention used by os.PathError.Op, net.OpError.Op, and net/url.Error.Op.
Variables ¶
var ErrKeyNotFound = errors.New("synthra: key not found")
ErrKeyNotFound is returned when a configuration key is missing or cannot be resolved for strict accessors. Errors may wrap this value; use errors.Is to detect it.
var ErrNilConfig = errors.New("synthra: nil Synthra")
ErrNilConfig is returned when a typed accessor or Get is used on a nil *Synthra.
var ErrNilContext = errors.New("synthra: nil context")
ErrNilContext is returned when Synthra.Load or Synthra.Dump is called with a nil context.Context.
Functions ¶
func Get ¶
Get returns the value associated with the given key as type T. If c is nil, it returns ErrNilConfig. If the key is missing or empty, or the value cannot be converted to T, it returns an error.
Example:
port, err := synthra.Get[int](cfg, "server.port")
if err != nil {
return fmt.Errorf("server.port: %w", err)
}
timeout, err := synthra.Get[time.Duration](cfg, "timeout")
if err != nil {
return err
}
func GetOr ¶
GetOr returns the value associated with the given key as type T. If the key is not found or cannot be converted to type T, it returns the provided default value. The type T is inferred from the default value.
Example:
port := synthra.GetOr(cfg, "server.port", 8080) // type inferred as int host := synthra.GetOr(cfg, "server.host", "localhost") // type inferred as string timeout := synthra.GetOr(cfg, "timeout", 30*time.Second) // type inferred as time.Duration
Types ¶
type BindingOption ¶ added in v0.9.0
type BindingOption[T any] interface { // contains filtered or unexported methods }
BindingOption is the constraint type for options that may appear inside WithBinding. The T parameter ties each option to the bound target type so the compiler enforces alignment between the target and any hook.
T appears in the [bindingConfig] parameter of applyToBinding so that two BindingOption instantiations with different T are distinct interfaces at compile time, not just at the type-checker. Without that, a method set independent of T would let BindingOption[App] satisfy BindingOption[Server] silently and the type-mismatch check would only happen at Load time.
func OnBound ¶ added in v0.9.0
func OnBound[T any](fn func(*T) error) BindingOption[T]
OnBound registers a function that runs against the bound struct after decode and applyDefaults, but before [Validator.Validate].
Use OnBound for type-safe normalization — lowercasing log levels, computing derived fields, applying region-based defaults. For map-level work before binding, use WithTransform.
The function must not be nil. Multiple OnBound hooks run in registration order; the first error stops the pipeline.
type ConfigError ¶
ConfigError is the structured error returned by Synthra for construction, load, dump, and type conversion failures at accessors.
Its shape follows os.PathError: Op names the operation, Path locates the failure in a way that depends on Op (see package docs), and Err is the underlying cause for errors.Unwrap, errors.Is, and errors.As.
Path is diagnostic text only; its format is not a stable API contract. Callers should branch on Op and use errors.Is on Err for specific reasons, not parse Path or ConfigError.Error output.
func NewConfigError ¶
func NewConfigError(op, path string, err error) *ConfigError
NewConfigError returns a *ConfigError. Op should be one of OpNew, OpLoad, OpDump, or OpGet. Path is the polymorphic locator described on ConfigError; use "" when none applies (for example nil-context errors).
func (*ConfigError) Error ¶
func (e *ConfigError) Error() string
Error implements [error]. The format is pinned for tests:
synthra: <Op>[ <Path>]: <Err>
When Path is empty, the space before Path is omitted.
type Dumper ¶
type Dumper interface {
// Dump writes the configuration values to a destination.
// The values map should not be modified by implementations.
Dump(ctx context.Context, values *map[string]any) error
}
Dumper defines the interface for configuration dumpers. Implementations write configuration data to various destinations such as files or remote services.
Dump must be safe to call concurrently.
type Option ¶
type Option func(cfg *config)
Option is a functional option that can be used to configure an Synthra instance. Options apply to an internal config struct; the constructor validates and builds the public Synthra from it. Options must not be nil; passing nil results in a validation error at construction.
func WithBinding ¶
func WithBinding[T any](target *T, opts ...BindingOption[T]) Option
WithBinding registers target as the destination for struct decoding and accepts binding-scoped options. T is inferred from target; every option passed must share the same T, which the compiler enforces.
Example:
type Config struct {
Server struct {
Host string `synthra:"host"`
Port int `synthra:"port"`
} `synthra:"server"`
}
var appCfg Config
cfg := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithBinding(&appCfg,
synthra.OnBound(func(c *Config) error {
c.Server.Host = strings.ToLower(c.Server.Host)
return nil
}),
),
)
fmt.Println(appCfg.Server.Port) // populated from config
Example ¶
ExampleWithBinding demonstrates binding configuration to a struct.
package main
import (
"context"
"fmt"
"log"
"gopherly.dev/synthra"
"gopherly.dev/synthra/codec"
)
func main() {
type ServerConfig struct {
Host string `synthra:"host"`
Port int `synthra:"port"`
}
type AppConfig struct {
Server ServerConfig `synthra:"server"`
}
yamlContent := []byte(`
server:
host: localhost
port: 8080
`)
var appConfig AppConfig
cfg, err := synthra.New(
synthra.WithContent(yamlContent, codec.YAML),
synthra.WithBinding(&appConfig),
)
if err != nil {
log.Fatal(err)
}
if err = cfg.Load(context.Background()); err != nil {
log.Fatal(err)
}
fmt.Printf("%s:%d\n", appConfig.Server.Host, appConfig.Server.Port)
}
Output: localhost:8080
func WithConsul ¶
WithConsul returns an Option that configures the Synthra instance to load configuration data from a Consul server. The format is automatically detected from the path extension. For custom formats, use WithConsulAs instead.
CONSUL_HTTP_ADDR is required. If it is not set, New/MustNew returns a validation error at construction. For conditional Consul (e.g., development without Consul), wrap this option with WithIf.
Paths support environment variable expansion using ${VAR} or $VAR syntax. Example: "${APP_ENV}/service.yaml" expands to "production/service.yaml" when APP_ENV=production
Required environment variables:
- CONSUL_HTTP_ADDR: The address of the Consul server (e.g., "http://localhost:8500")
- CONSUL_HTTP_TOKEN: The access token for authentication with Consul (optional)
Example:
cfg := synthra.MustNew(
synthra.WithConsul("production/service.yaml"), // Fails at construction if CONSUL_HTTP_ADDR is unset
)
func WithConsulAs ¶
WithConsulAs returns an Option that configures the Synthra instance to load configuration data from a Consul server with explicit decoder. Use this when you need to override the format detection.
CONSUL_HTTP_ADDR is required. If it is not set, New/MustNew returns a validation error at construction. For conditional Consul (e.g., development without Consul), wrap this option with WithIf.
Paths support environment variable expansion using ${VAR} or $VAR syntax. Example: "${APP_ENV}/service" expands to "production/service" when APP_ENV=production
Required environment variables:
- CONSUL_HTTP_ADDR: The address of the Consul server (e.g., "http://localhost:8500")
- CONSUL_HTTP_TOKEN: The access token for authentication with Consul (optional)
Example:
cfg := synthra.MustNew(
synthra.WithConsulAs("production/service", codec.JSON),
)
func WithContent ¶
WithContent returns an Option that configures the Synthra instance to load configuration data from a byte slice. The decoder parameter specifies how to decode the data (e.g., codec.YAML, codec.JSON).
Example:
yamlContent := []byte("server:\n port: 8080")
cfg := synthra.MustNew(
synthra.WithContent(yamlContent, codec.YAML),
)
Example ¶
ExampleWithContent demonstrates loading configuration from byte content.
package main
import (
"context"
"fmt"
"log"
"gopherly.dev/synthra"
"gopherly.dev/synthra/codec"
)
func exampleString(cfg *synthra.Synthra, key string) string {
v, err := cfg.String(key)
if err != nil {
log.Fatal(err)
}
return v
}
func main() {
jsonContent := []byte(`{
"app": {
"name": "MyApp",
"version": "1.0.0"
}
}`)
cfg, err := synthra.New(
synthra.WithContent(jsonContent, codec.JSON),
)
if err != nil {
log.Fatal(err)
}
if err = cfg.Load(context.Background()); err != nil {
log.Fatal(err)
}
fmt.Println(exampleString(cfg, "app.name"))
fmt.Println(exampleString(cfg, "app.version"))
}
Output: MyApp 1.0.0
func WithDumper ¶
WithDumper adds a custom Dumper to the configuration dumper. Use it to plug in dumpers not covered by the built-in options (e.g. a database, remote API, or custom file format). The dumper must not be nil.
Example:
cfg := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithDumper(myCustomDumper),
)
func WithEnv ¶
WithEnv returns an Option that configures the Synthra instance to load configuration data from environment variables. The prefix parameter specifies the prefix for the environment variables to be loaded. Environment variables are converted to lowercase and underscores create nested structures.
Example:
cfg := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithEnv("APP_"), // Loads APP_SERVER_PORT as server.port
)
func WithEnvSubst ¶ added in v0.6.0
WithEnvSubst registers a transform that expands POSIX-style ${VAR} placeholders in all string values of the merged configuration map.
The resolver argument must not be nil. To consult multiple sources, compose them with Resolver.Or — the first Resolver that reports found wins (highest priority first):
synthra.FromEnv().Prefix("APP_").Or(envFile).Or(synthra.FromMap(defaults))
Supported syntax includes ${VAR}, ${VAR:-default}, ${VAR:=default}, ${VAR^^}, ${VAR#pattern}, and more. The full set is documented at https://pkg.go.dev/github.com/fluxcd/pkg/envsubst.
This is different from WithEnv. WithEnv is a source: it reads environment variables and adds them as configuration keys. For example, APP_SERVER_PORT=8080 becomes server.port in the config map. WithEnvSubst is a transform: it expands ${VAR} placeholders that appear inside string values already loaded from other sources. Both can be used together without overlap.
Example — OS environment only:
cfg := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithEnvSubst(synthra.FromEnv()),
)
Example — expand placeholders using a static map:
cfg := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithEnvSubst(synthra.FromMap(map[string]string{
"ENV": "production",
"PORT": "8080",
})),
)
// If config.yaml contains: host: "app-${ENV}.example.com"
// After Load: cfg.Get("host") => "app-production.example.com"
Example — three-layer priority with Or (highest priority first):
envFile, err := synthra.FromEnvFile(".env")
if err != nil {
log.Fatal(err)
}
cfg := synthra.MustNew(
synthra.WithFile("deployah.yaml"),
synthra.WithEnvSubst(
synthra.FromEnv().Prefix("DPY_VAR_"). // highest: prefixed OS env
Or(envFile). // middle: .env file
Or(synthra.FromMap(manifestVars)), // lowest: static defaults
),
)
// config.yaml: port: ${PORT:-3000}
// If DPY_VAR_PORT=9090 is set, port becomes "9090".
// If DPY_VAR_PORT is not set but PORT is in the .env file, that wins.
// If neither is set, the ${VAR:-default} fallback gives "3000".
func WithEnvSubstFunc ¶ added in v0.8.0
WithEnvSubstFunc expands ${VAR} placeholders using a Resolver that is determined dynamically at Load time. The callback receives the current *Values and returns a Resolver (or an error that stops the pipeline).
This follows the same pattern as WithJSONSchemaFunc: the Func suffix means "the input to this step is determined at Load time from the current values." Use this when the resolver depends on values that are only known after sources are merged — for example, a .env file path that is itself stored in the config file.
The function must not be nil.
Example — .env file path specified in config:
cfg := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithEnvSubstFunc(func(v *synthra.Values) (synthra.Resolver, error) {
envPath := v.StringOr("envfile", "")
if envPath == "" {
return synthra.FromEnv(), nil
}
return synthra.FromEnvFile(envPath)
}),
)
Example — Vault resolver with setup that may fail:
cfg := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithEnvSubstFunc(func(_ *synthra.Values) (synthra.Resolver, error) {
client, err := vault.NewClient(vault.DefaultConfig())
if err != nil {
return nil, fmt.Errorf("vault client: %w", err)
}
return func(name string) (string, bool) {
secret, err := client.Read("secret/data/" + name)
if err != nil || secret == nil {
return "", false
}
v, ok := secret.Data["value"].(string)
return v, ok
}, nil
}),
)
func WithFile ¶
WithFile returns an Option that configures the Synthra instance to load configuration data from a file. The format is automatically detected from the file extension (.yaml, .yml, .json, .toml). For files without extensions or custom formats, use WithFileAs instead.
Paths support environment variable expansion using ${VAR} or $VAR syntax. Example: "${CONFIG_DIR}/app.yaml" expands to "/etc/myapp/app.yaml" when CONFIG_DIR=/etc/myapp
Example:
cfg := synthra.MustNew(
synthra.WithFile("config.yaml"), // Automatically detects YAML
synthra.WithFile("override.json"), // Automatically detects JSON
)
Example ¶
ExampleWithFile demonstrates loading configuration from a file.
package main
import (
"context"
"fmt"
"log"
"gopherly.dev/synthra"
"gopherly.dev/synthra/codec"
)
func exampleString(cfg *synthra.Synthra, key string) string {
v, err := cfg.String(key)
if err != nil {
log.Fatal(err)
}
return v
}
func main() {
// Create a temporary config file (in real code, use an actual file path)
cfg, err := synthra.New(
synthra.WithContent([]byte(`{"name": "example"}`), codec.JSON),
)
if err != nil {
log.Fatal(err)
}
if err = cfg.Load(context.Background()); err != nil {
log.Fatal(err)
}
fmt.Println(exampleString(cfg, "name"))
}
Output: example
func WithFileAs ¶
WithFileAs returns an Option that configures the Synthra instance to load configuration data from a file with explicit decoder. Use this when the file doesn't have an extension or when you need to override the format detection.
Paths support environment variable expansion using ${VAR} or $VAR syntax. Example: "${CONFIG_DIR}/app" expands to "/etc/myapp/app" when CONFIG_DIR=/etc/myapp
Example:
cfg := synthra.MustNew(
synthra.WithFileAs("config", codec.YAML), // No extension, specify YAML
synthra.WithFileAs("config.dat", codec.JSON), // Wrong extension, specify JSON
)
func WithFileDumper ¶
WithFileDumper returns an Option that configures the Synthra instance to dump configuration data to a file. The format is automatically detected from the file extension (.yaml, .yml, .json, .toml). For files without extensions or custom formats, use WithFileDumperAs instead.
Paths support environment variable expansion using ${VAR} or $VAR syntax. Example: "${LOG_DIR}/config.yaml" expands to "/var/log/config.yaml" when LOG_DIR=/var/log
Example:
cfg := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithFileDumper("output.yaml"), // Auto-detects YAML
)
func WithFileDumperAs ¶
WithFileDumperAs returns an Option that configures the Synthra instance to dump configuration data to a file with explicit encoder. Use this when the file doesn't have an extension or when you need to override the format detection.
Paths support environment variable expansion using ${VAR} or $VAR syntax. Example: "${OUTPUT_DIR}/config" expands to "/tmp/config" when OUTPUT_DIR=/tmp
Example:
cfg := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithFileDumperAs("output", codec.YAML), // No extension, specify YAML
)
func WithFileFS ¶
WithFileFS returns an Option that loads configuration from path inside fsys. The format is detected from path's file extension, like WithFile. Paths support environment variable expansion using ${VAR} or $VAR syntax.
If fsys is nil, New returns a validation error at construction.
Example (tests with testing/fstest.MapFS):
fsys := fstest.MapFS{"app.yaml": &fstest.MapFile{Data: []byte("port: 8080\n")}}
cfg := synthra.MustNew(synthra.WithFileFS(fsys, "app.yaml"))
func WithFileFSAs ¶
WithFileFSAs returns an Option that loads configuration from path inside fsys using an explicit decoder. It combines WithFileFS (embedded filesystem) with WithFileAs (explicit decoder) for files that have no extension or need a format override.
Paths support environment variable expansion using ${VAR} or $VAR syntax. If fsys is nil, New returns a validation error.
Example:
//go:embed configs
var configFS embed.FS
cfg := synthra.MustNew(
synthra.WithFileFSAs(configFS, "configs/app", codec.YAML),
)
func WithIf ¶
WithIf returns an Option that applies the provided options only when condition is true. When condition is false, this option is a no-op.
Example:
cfg := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithIf(os.Getenv("CONSUL_HTTP_ADDR") != "",
synthra.WithConsul("production/service.yaml"),
),
)
func WithJSONSchema ¶
WithJSONSchema adds a static JSON Schema as a pipeline step. The schema is used to apply default values and validate the configuration at the point in the pipeline where this option was registered.
Synthra supports JSON Schema drafts 4, 6, 7, 2019-09, and 2020-12.
Automatic defaults ¶
Synthra extracts every "default" value declared in the schema and applies it to any key that is missing from the current values map. Defaults are applied before validation, so the schema validator always sees a fully populated map.
Defaults are applied recursively at every level:
- "properties" — fills missing fixed-name keys in an object
- "patternProperties" — fills missing keys inside every existing map entry whose name matches the regular-expression pattern
- "items" — fills missing keys inside each element of an array
User-provided values are never overridden; only absent keys are filled.
Validation ¶
After defaults are applied the values are validated against the schema. If validation fails, Load returns a *ConfigError with Op OpLoad and Path "step[N]:schema" where N is the zero-based index of this step in the registered pipeline.
Multiple WithJSONSchema calls are allowed and each adds an independent pipeline step that runs at the point it was registered.
Example:
schema := []byte(`{
"type": "object",
"required": ["service"],
"properties": {
"service": {"type": "string"},
"port": {"type": "integer", "default": 8080},
"log_level": {"type": "string", "default": "info",
"enum": ["debug","info","warn","error"]}
}
}`)
cfg := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithJSONSchema(schema),
)
Example ¶
ExampleWithJSONSchema validates the merged configuration against a JSON Schema. Load fails fast if required keys are missing or values have the wrong type.
package main
import (
"context"
"fmt"
"log"
"gopherly.dev/synthra"
"gopherly.dev/synthra/codec"
)
func exampleString(cfg *synthra.Synthra, key string) string {
v, err := cfg.String(key)
if err != nil {
log.Fatal(err)
}
return v
}
func exampleInt(cfg *synthra.Synthra, key string) int {
v, err := cfg.Int(key)
if err != nil {
log.Fatal(err)
}
return v
}
func main() {
schema := []byte(`{
"type": "object",
"required": ["service", "port"],
"properties": {
"service": {"type": "string", "minLength": 1},
"port": {"type": "integer", "minimum": 1, "maximum": 65535}
}
}`)
cfg := synthra.MustNew(
synthra.WithContent([]byte("service: api\nport: 8080\n"), codec.YAML),
synthra.WithJSONSchema(schema),
)
if err := cfg.Load(context.Background()); err != nil {
log.Fatal(err)
}
fmt.Printf("service=%s port=%d\n", exampleString(cfg, "service"), exampleInt(cfg, "port"))
}
Output: service=api port=8080
func WithJSONSchemaFunc ¶ added in v0.8.0
WithJSONSchemaFunc registers a dynamic schema resolver as a pipeline step. The selector is called during Synthra.Load with the current *Values and returns the JSON Schema bytes to use at that point in the pipeline. This enables the schema to be chosen based on a value already present in the config — for example an apiVersion field — without requiring a two-pass read.
The bytes returned by the selector are compiled and the schema's "default" values are extracted and applied before validation runs, exactly as with WithJSONSchema.
Multiple WithJSONSchemaFunc calls are allowed; each adds an independent schema step that runs at the point it was registered. This is the mechanism for two-phase validation: register a pre-transform schema step to validate partial data, then register a post-transform schema step to validate the final form.
If the selector returns an error, or the returned bytes fail to compile, or validation fails, Load returns a *ConfigError with Op OpLoad and Path "step[N]:schema" where N is the zero-based index of this step.
The selector function must not be nil.
Example — select schema version from the config's own apiVersion key:
cfg := synthra.MustNew(
synthra.WithFile("deployah.yaml"),
synthra.WithJSONSchemaFunc(func(v *synthra.Values) ([]byte, error) {
version := v.StringOr("apiVersion", "")
if version == "" {
return nil, errors.New("apiVersion is required")
}
return schema.GetManifestSchema(version)
}),
)
Example — two-phase validation (validate before and after substitution):
cfg := synthra.MustNew(
synthra.WithFile("manifest.yaml"),
synthra.WithJSONSchemaFunc(environmentsSchema), // validate raw environments
synthra.WithEnvSubst(synthra.FromEnv()), // substitute variables
synthra.WithJSONSchemaFunc(manifestSchema), // validate substituted manifest
)
func WithSource ¶
WithSource adds a custom Source to the configuration loader. Use it to plug in sources not covered by the built-in options (e.g. a database, remote API, or custom file format). The source must not be nil.
Example:
cfg := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithSource(myCustomSource),
)
func WithTag ¶
WithTag sets a custom struct tag name for binding (default: "synthra"). Use it when the default tag clashes with another convention or you want a shorter key (for example "cfg" or "config").
Example:
type AppConfig struct {
Port int `cfg:"port"` // Using custom tag
}
cfg := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithBinding(&appConfig),
synthra.WithTag("cfg"),
)
func WithTransform ¶ added in v0.2.0
WithTransform registers a function that transforms the configuration values as a pipeline step. The transform runs at the point in the pipeline where it was registered, after any preceding steps have completed.
The function receives the current *Values and mutates it in place. Returning an error aborts Load with a *ConfigError whose Path identifies the failing step by its index and kind ("step[0]:transform", "step[1]:transform", ...).
Multiple transforms are applied in registration order.
Example — normalize log level to lowercase, then validate with a schema:
cfg := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithTransform(func(v *synthra.Values) error {
if level, err := v.String("log_level"); err == nil {
return v.Set("log_level", strings.ToLower(level))
}
return nil
}),
synthra.WithJSONSchema(schema),
)
func WithValidator ¶
WithValidator adds a custom validation function as a pipeline step. It runs a read-only check against the current *Values at the point in the pipeline where it was registered. Multiple validators run in registration order; the first error stops the pipeline.
Panics inside the validator are recovered and reported as errors. The function must not be nil.
If the validator returns an error or panics, Load returns a *ConfigError with Op OpLoad and Path "step[N]:validator" where N is the zero-based index of this step.
Example:
cfg, err := synthra.New(
synthra.WithFile("config.yaml"),
synthra.WithValidator(func(v *synthra.Values) error {
port := v.IntOr("port", 0)
if port < 1 || port > 65535 {
return fmt.Errorf("port %d out of range", port)
}
return nil
}),
)
Example ¶
ExampleWithValidator demonstrates using a custom validator.
package main
import (
"context"
"fmt"
"log"
"gopherly.dev/synthra"
"gopherly.dev/synthra/codec"
)
func main() {
yamlContent := []byte(`name: myapp`)
cfg, err := synthra.New(
synthra.WithContent(yamlContent, codec.YAML),
synthra.WithValidator(func(v *synthra.Values) error {
// Custom validation logic
if !v.Has("name") {
return fmt.Errorf("name is required")
}
return nil
}),
)
if err != nil {
log.Fatal(err)
}
if err = cfg.Load(context.Background()); err != nil {
log.Fatal(err)
}
fmt.Println("Validation passed")
}
Output: Validation passed
type Resolver ¶ added in v0.8.0
Resolver looks up a variable by name for ${VAR} expansion. It follows the Go "comma ok" idiom, matching os.LookupEnv: the second return value reports whether the variable was found. A nil Resolver always returns ("", false). A Resolver must be safe for concurrent use.
To consult multiple sources, compose them with Resolver.Or: the first Resolver that reports found wins.
func FromEnv ¶ added in v0.8.0
func FromEnv() Resolver
FromEnv returns a Resolver that reads from the live OS environment using os.LookupEnv. Changes between Synthra.Load calls are visible.
Example:
cfg := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithEnvSubst(synthra.FromEnv()),
)
func FromEnvFile ¶ added in v0.8.0
FromEnvFile reads a .env file eagerly and returns a map-backed Resolver. The file is read and parsed at call time; if the file does not exist or cannot be parsed, an error is returned.
Parsing is handled by github.com/hashicorp/go-envparse which supports quoted values, comment lines, the export prefix, inline comments, and escape sequences.
Supported syntax:
- KEY=VALUE (simple assignment)
- KEY="VALUE" or KEY='VALUE' (quoted values, preserves whitespace)
- # comment lines (ignored)
- export KEY=VALUE (export prefix stripped)
- Empty lines (ignored)
Example:
envFile, err := synthra.FromEnvFile(".env")
if err != nil {
log.Fatal(err)
}
cfg := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithEnvSubst(synthra.FromEnv().Or(envFile)),
)
Example — layered with .env.local override (first match wins via Or):
base, err := synthra.FromEnvFile(".env")
if err != nil {
log.Fatal(err)
}
local, err := synthra.FromEnvFile(".env.local")
if err != nil && !os.IsNotExist(err) {
log.Fatal(err)
}
cfg := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithEnvSubst(synthra.FromEnv().Or(local).Or(base)),
)
func FromMap ¶ added in v0.8.0
FromMap returns a Resolver that looks up variables from a static map. The map is read as-is; keys are case-sensitive. Passing a nil map returns a Resolver that never finds any variable.
Example:
r := synthra.FromMap(map[string]string{
"ENV": "production",
"PORT": "8080",
})
func (Resolver) Or ¶ added in v0.9.0
Or returns a Resolver that tries the receiver first, then each fallback in order, stopping at the first one that reports found. This is the standard Go lookup-chain pattern: the same semantics as context.Value, where the innermost (highest-priority) context shadows outer ones.
Precedence rule: the receiver has highest priority; fallbacks are consulted in the order they are listed. The first Resolver to return found=true wins.
Empty string counts as found. A resolver that returns ("", true) stops the Or chain — no further fallback resolver is consulted. This matches context.Value behavior: a deliberately-set empty value shadows later contexts.
Note: the envsubst library processes ${VAR:-default} according to POSIX, which fires when the variable is unset OR empty. So even if Or stops the chain with ("", true), a template that uses ${VAR:-fallback} will still expand to "fallback". Use bare ${VAR} or ${VAR-fallback} (single dash, unset only) if you need to distinguish unset from explicitly empty.
Nil fallbacks in the list are silently skipped, which makes it safe to pass a conditionally-built resolver without a guard:
r.Or(maybeNilResolver, synthra.FromEnv())
A nil receiver is treated as a Resolver that never finds anything; fallbacks are still consulted in order.
Or() with no arguments returns the receiver unchanged.
Example — three-layer priority (OS env prefix > .env file > static defaults):
envFile, err := synthra.FromEnvFile(".env")
if err != nil {
log.Fatal(err)
}
cfg := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithEnvSubst(
synthra.FromEnv().Prefix("APP_"). // highest: prefixed OS env
Or(envFile). // middle: .env file
Or(synthra.FromMap(defaults)), // lowest: static defaults
),
)
func (Resolver) Prefix ¶ added in v0.8.0
Prefix returns a Resolver that prepends prefix to every lookup name before delegating to the receiver. Prefix("APP_") resolves "PORT" by looking up "APP_PORT" in the underlying resolver. This works with any Resolver — FromEnv, FromEnvFile, or FromMap.
If prefix is empty, the receiver is returned unchanged.
Example:
cfg := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithEnvSubst(synthra.FromEnv().Prefix("APP_")),
)
Example — prefix applied to a .env file resolver, with OS env as fallback:
envFile, err := synthra.FromEnvFile(".env")
if err != nil {
log.Fatal(err)
}
cfg := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithEnvSubst(synthra.FromEnv().Prefix("APP_").Or(envFile.Prefix("APP_"))),
)
type Source ¶
type Source interface {
// Load loads configuration data from the source.
// It returns a map containing the configuration key-value pairs.
// Keys are normalized to lowercase for case-insensitive access.
Load(ctx context.Context) (map[string]any, error)
}
Source defines the interface for configuration sources. Implementations load configuration data from various locations such as files, environment variables, or remote services.
Load must be safe to call concurrently.
type Synthra ¶
type Synthra struct {
// contains filtered or unexported fields
}
Synthra manages configuration data loaded from multiple sources. It provides thread-safe access to configuration values and supports binding to structs, validation, and dumping to files.
Synthra is the runtime object returned by New/MustNew; use it for Load, Get, and Dump. Synthra is safe for concurrent use by multiple goroutines.
func MustNew ¶
MustNew is like New but panics if validation fails. Use it in main() or package-level initialization where a panic is acceptable. For explicit error handling, use New instead.
Example:
cfg := synthra.MustNew(
synthra.WithFile("config.yaml"),
synthra.WithEnvPrefix("APP"),
synthra.WithBinding(&appCfg),
)
fmt.Println(cfg.Get("server.port"))
Example ¶
ExampleMustNew demonstrates creating a configuration instance with panic on error.
package main
import (
"context"
"fmt"
"log"
"gopherly.dev/synthra"
)
func main() {
cfg := synthra.MustNew()
if err := cfg.Load(context.Background()); err != nil {
log.Fatal(err)
}
fmt.Println("Synthra created successfully")
}
Output: Synthra created successfully
func New ¶
New creates a new Synthra instance with the provided options. Options are applied in order to an internal config. Validation errors are collected and reported after all options are applied, so callers never receive a partially-initialized instance. A nil option is treated as a validation error.
Use MustNew in main() or initialization code where a panic on error is acceptable.
Example:
cfg, err := synthra.New(
synthra.WithFile("config.yaml"),
synthra.WithEnvPrefix("APP"),
synthra.WithBinding(&appCfg),
)
if err != nil {
log.Fatal(err)
}
fmt.Println(cfg.Get("server.port"))
Example ¶
ExampleNew demonstrates creating a new configuration instance.
package main
import (
"context"
"fmt"
"log"
"gopherly.dev/synthra"
)
func main() {
cfg, err := synthra.New()
if err != nil {
log.Fatal(err)
}
if err = cfg.Load(context.Background()); err != nil {
log.Fatal(err)
}
fmt.Println("Synthra created successfully")
}
Output: Synthra created successfully
func (*Synthra) Bool ¶
Bool returns the value at key as a bool. It returns an error if c is nil, the key is missing, or the value cannot be converted.
Example:
debug, err := cfg.Bool("debug")
if err != nil {
return err
}
Example ¶
ExampleSynthra_Bool demonstrates retrieving boolean values.
package main
import (
"context"
"fmt"
"log"
"gopherly.dev/synthra"
"gopherly.dev/synthra/codec"
)
func exampleBool(cfg *synthra.Synthra, key string) bool {
v, err := cfg.Bool(key)
if err != nil {
log.Fatal(err)
}
return v
}
func main() {
jsonContent := []byte(`{"debug": true, "verbose": false}`)
cfg, err := synthra.New(
synthra.WithContent(jsonContent, codec.JSON),
)
if err != nil {
log.Fatal(err)
}
if err = cfg.Load(context.Background()); err != nil {
log.Fatal(err)
}
fmt.Println(exampleBool(cfg, "debug"))
fmt.Println(exampleBool(cfg, "verbose"))
}
Output: true false
func (*Synthra) BoolOr ¶
BoolOr returns the value associated with the given key as a boolean, or the default value if not found.
Example:
debug := cfg.BoolOr("debug", false)
func (*Synthra) Dump ¶
Dump writes the current configuration values to the registered dumpers.
Errors:
- Returns *ConfigError with OpDump if ctx is nil (ErrNilContext)
- Returns *ConfigError with OpDump if any dumper fails to write the configuration
Example ¶
ExampleSynthra_Dump demonstrates writing configuration to registered dumpers.
package main
import (
"context"
"fmt"
"log"
"gopherly.dev/synthra"
"gopherly.dev/synthra/codec"
"gopherly.dev/synthra/synthratest"
)
func main() {
// Create a mock dumper for demonstration
dumper := &synthratest.Dumper{}
cfg := synthra.MustNew(
synthra.WithContent([]byte(`{"service": "api", "version": "1.0"}`), codec.JSON),
synthra.WithDumper(dumper),
)
if err := cfg.Load(context.Background()); err != nil {
log.Fatal(err)
}
if err := cfg.Dump(context.Background()); err != nil {
log.Fatal(err)
}
fmt.Println("Configuration dumped successfully")
}
Output: Configuration dumped successfully
func (*Synthra) Duration ¶
Duration returns the value at key as a time.Duration. It returns an error if c is nil, the key is missing, or the value cannot be converted.
Example:
timeout, err := cfg.Duration("timeout")
if err != nil {
return err
}
func (*Synthra) DurationOr ¶
DurationOr returns the value associated with the given key as a time.Duration, or the default value if not found.
Example:
timeout := cfg.DurationOr("timeout", 30*time.Second)
func (*Synthra) Float64 ¶
Float64 returns the value at key as a float64. It returns an error if c is nil, the key is missing, or the value cannot be converted.
Example:
rate, err := cfg.Float64("rate")
if err != nil {
return err
}
func (*Synthra) Float64Or ¶
Float64Or returns the value associated with the given key as a float64, or the default value if not found.
Example:
rate := cfg.Float64Or("rate", 0.5)
func (*Synthra) Get ¶
Get returns the value associated with the given key as an any type. If the key is not found, it returns nil.
Example ¶
ExampleSynthra_Get demonstrates retrieving configuration values.
package main
import (
"context"
"fmt"
"log"
"gopherly.dev/synthra"
"gopherly.dev/synthra/codec"
)
func main() {
yamlContent := []byte(`
settings:
enabled: true
count: 42
`)
cfg, err := synthra.New(
synthra.WithContent(yamlContent, codec.YAML),
)
if err != nil {
log.Fatal(err)
}
if err = cfg.Load(context.Background()); err != nil {
log.Fatal(err)
}
fmt.Println(cfg.Get("settings.enabled"))
fmt.Println(cfg.Get("settings.count"))
}
Output: true 42
func (*Synthra) Int ¶
Int returns the value at key as an int. It returns an error if c is nil, the key is missing, or the value cannot be converted.
Example:
port, err := cfg.Int("server.port")
if err != nil {
return err
}
Example ¶
ExampleSynthra_Int demonstrates retrieving integer values.
package main
import (
"context"
"fmt"
"log"
"gopherly.dev/synthra"
"gopherly.dev/synthra/codec"
)
func exampleInt(cfg *synthra.Synthra, key string) int {
v, err := cfg.Int(key)
if err != nil {
log.Fatal(err)
}
return v
}
func main() {
jsonContent := []byte(`{"port": 8080, "workers": 4}`)
cfg, err := synthra.New(
synthra.WithContent(jsonContent, codec.JSON),
)
if err != nil {
log.Fatal(err)
}
if err = cfg.Load(context.Background()); err != nil {
log.Fatal(err)
}
fmt.Println(exampleInt(cfg, "port"))
fmt.Println(exampleInt(cfg, "workers"))
}
Output: 8080 4
func (*Synthra) Int64 ¶
Int64 returns the value at key as an int64. It returns an error if c is nil, the key is missing, or the value cannot be converted.
Example:
maxSize, err := cfg.Int64("max_size")
if err != nil {
return err
}
func (*Synthra) Int64Or ¶
Int64Or returns the value associated with the given key as an int64, or the default value if not found.
Example:
maxSize := cfg.Int64Or("max_size", 1024)
func (*Synthra) IntOr ¶
IntOr returns the value associated with the given key as an int, or the default value if not found.
Example:
port := cfg.IntOr("server.port", 8080)
func (*Synthra) IntSlice ¶
IntSlice returns the value at key as a []int. It returns an error if c is nil, the key is missing, or the value cannot be converted.
Example:
ports, err := cfg.IntSlice("ports")
if err != nil {
return err
}
func (*Synthra) IntSliceOr ¶
IntSliceOr returns the value associated with the given key as a slice of integers, or the default value if not found.
Example:
ports := cfg.IntSliceOr("ports", []int{8080, 8081})
func (*Synthra) Load ¶
Load loads configuration data from the registered sources and merges it into the internal values map. The method executes all registered pipeline steps in registration order before atomically updating the internal state. Load is safe to call concurrently.
The pipeline runs in this order:
- Load and merge all sources (later sources override earlier ones).
- Execute each registered step (WithJSONSchema, WithJSONSchemaFunc, WithTransform, WithEnvSubst, WithValidator) in the order they were registered.
- Decode into the bound struct (WithBinding), apply struct-tag defaults, and call the struct's Validate method if it implements Validator.
Errors:
- Returns *ConfigError with OpLoad if ctx is nil (ErrNilContext)
- Returns *ConfigError with OpLoad if any source fails to load or merge
- Returns *ConfigError with OpLoad and Path "step[N]:schema" if a schema step's selector, compilation, or validation fails
- Returns *ConfigError with OpLoad and Path "step[N]:transform" if a transform step returns an error
- Returns *ConfigError with OpLoad and Path "step[N]:validator" if a validator step returns an error or panics
- Returns *ConfigError with OpLoad if binding or struct validation fails
Example ¶
ExampleSynthra_Load demonstrates loading configuration.
package main
import (
"context"
"fmt"
"log"
"gopherly.dev/synthra"
"gopherly.dev/synthra/codec"
)
func exampleString(cfg *synthra.Synthra, key string) string {
v, err := cfg.String(key)
if err != nil {
log.Fatal(err)
}
return v
}
func exampleInt(cfg *synthra.Synthra, key string) int {
v, err := cfg.Int(key)
if err != nil {
log.Fatal(err)
}
return v
}
func main() {
cfg := synthra.MustNew(
synthra.WithContent([]byte(`{"app": "example", "port": 8080}`), codec.JSON),
)
if err := cfg.Load(context.Background()); err != nil {
log.Fatal(err)
}
app := exampleString(cfg, "app")
port := exampleInt(cfg, "port")
fmt.Printf("App: %s, Port: %d\n", app, port)
}
Output: App: example, Port: 8080
func (*Synthra) String ¶
String returns the value at key as a string. It returns an error if c is nil, the key is missing, or the value cannot be converted.
Example:
host, err := cfg.String("server.host")
if err != nil {
return err
}
Example ¶
ExampleSynthra_String demonstrates retrieving string values.
package main
import (
"context"
"fmt"
"log"
"gopherly.dev/synthra"
"gopherly.dev/synthra/codec"
)
func exampleString(cfg *synthra.Synthra, key string) string {
v, err := cfg.String(key)
if err != nil {
log.Fatal(err)
}
return v
}
func main() {
jsonContent := []byte(`{"name": "MyApp", "env": "production"}`)
cfg, err := synthra.New(
synthra.WithContent(jsonContent, codec.JSON),
)
if err != nil {
log.Fatal(err)
}
if err = cfg.Load(context.Background()); err != nil {
log.Fatal(err)
}
fmt.Println(exampleString(cfg, "name"))
fmt.Println(exampleString(cfg, "env"))
}
Output: MyApp production
func (*Synthra) StringMap ¶
StringMap returns the value at key as a map[string]any. It returns an error if c is nil, the key is missing, or the value cannot be converted.
Example:
metadata, err := cfg.StringMap("metadata")
if err != nil {
return err
}
Example ¶
ExampleSynthra_StringMap demonstrates retrieving string maps.
package main
import (
"context"
"fmt"
"log"
"gopherly.dev/synthra"
"gopherly.dev/synthra/codec"
)
func exampleStringMap(cfg *synthra.Synthra, key string) map[string]any {
v, err := cfg.StringMap(key)
if err != nil {
log.Fatal(err)
}
return v
}
func main() {
yamlContent := []byte(`
metadata:
author: John Doe
version: 1.0.0
`)
cfg, err := synthra.New(
synthra.WithContent(yamlContent, codec.YAML),
)
if err != nil {
log.Fatal(err)
}
if err = cfg.Load(context.Background()); err != nil {
log.Fatal(err)
}
metadata := exampleStringMap(cfg, "metadata")
fmt.Println(metadata["author"])
fmt.Println(metadata["version"])
}
Output: John Doe 1.0.0
func (*Synthra) StringMapOr ¶
StringMapOr returns the value associated with the given key as a map[string]any, or the default value if not found.
Example:
metadata := cfg.StringMapOr("metadata", map[string]any{"version": "1.0"})
func (*Synthra) StringOr ¶
StringOr returns the value associated with the given key as a string, or the default value if not found.
Example:
host := cfg.StringOr("server.host", "localhost")
func (*Synthra) StringSlice ¶
StringSlice returns the value at key as a []string. It returns an error if c is nil, the key is missing, or the value cannot be converted.
Example:
tags, err := cfg.StringSlice("tags")
if err != nil {
return err
}
Example ¶
ExampleSynthra_StringSlice demonstrates retrieving string slices.
package main
import (
"context"
"fmt"
"log"
"gopherly.dev/synthra"
"gopherly.dev/synthra/codec"
)
func exampleStringSlice(cfg *synthra.Synthra, key string) []string {
v, err := cfg.StringSlice(key)
if err != nil {
log.Fatal(err)
}
return v
}
func main() {
yamlContent := []byte(`
tags:
- web
- api
- backend
`)
cfg, err := synthra.New(
synthra.WithContent(yamlContent, codec.YAML),
)
if err != nil {
log.Fatal(err)
}
if err = cfg.Load(context.Background()); err != nil {
log.Fatal(err)
}
tags := exampleStringSlice(cfg, "tags")
fmt.Printf("%v\n", tags)
}
Output: [web api backend]
func (*Synthra) StringSliceOr ¶
StringSliceOr returns the value associated with the given key as a slice of strings, or the default value if not found.
Example:
tags := cfg.StringSliceOr("tags", []string{"default"})
func (*Synthra) Time ¶
Time returns the value at key as a time.Time. It returns an error if c is nil, the key is missing, or the value cannot be converted.
Example:
startTime, err := cfg.Time("start_time")
if err != nil {
return err
}
func (*Synthra) TimeOr ¶
TimeOr returns the value associated with the given key as a time.Time, or the default value if not found.
Example:
startTime := cfg.TimeOr("start_time", time.Now())
func (*Synthra) Values ¶
Values returns a pointer to a shallow copy of the loaded configuration map. The copy is taken while holding a read lock; nested maps, slices, and pointers inside values are not deep-copied, so mutating nested data still affects the same objects held by this Synthra. If Load has not run yet, it returns a pointer to a new empty map.
type Validator ¶
type Validator interface {
Validate() error
}
Validator is an interface for structs that can validate their own configuration. The validation package uses the same contract (validation.Validator); a type implementing either satisfies both.
type Values ¶ added in v0.9.0
type Values struct {
// contains filtered or unexported fields
}
Values wraps the merged configuration map for pipeline callbacks. Every read and write is case-insensitive at each path segment. Dot notation addresses nested maps.
Values is not safe for concurrent use. Each Load call creates a fresh instance for the duration of the pipeline.
func (*Values) BoolOr ¶ added in v0.9.0
BoolOr returns the value at path as a bool, or def if not found.
func (*Values) Delete ¶ added in v0.9.0
Delete removes the value at path. Reports whether something was removed.
func (*Values) Duration ¶ added in v0.9.0
Duration returns the value at path as a time.Duration.
func (*Values) DurationOr ¶ added in v0.9.0
DurationOr returns the value at path as a time.Duration, or def if not found.
func (*Values) Float64Or ¶ added in v0.9.0
Float64Or returns the value at path as a float64, or def if not found.
func (*Values) Get ¶ added in v0.9.0
Get returns the raw value at path or nil if no key matches. Lookup is case-insensitive at each dot-separated segment. A top-level key that literally contains a dot is checked first before falling back to dot-notation traversal.
func (*Values) Int64Or ¶ added in v0.9.0
Int64Or returns the value at path as an int64, or def if not found.
func (*Values) IntOr ¶ added in v0.9.0
IntOr returns the value at path as an int, or def if not found.
func (*Values) Raw ¶ added in v0.9.0
Raw returns the underlying map. Direct map access is case-sensitive at the Go level; use Raw only when you must hand the data to code that expects a plain map[string]any. Mutations on the returned map remain visible through this *Values.
func (*Values) Set ¶ added in v0.9.0
Set assigns value at path. Intermediate maps are created as needed. Returns an error if a non-final segment along the path exists but is not a map.
func (*Values) StringMap ¶ added in v0.9.0
StringMap returns the value at path as a map[string]string.
func (*Values) StringOr ¶ added in v0.9.0
StringOr returns the value at path as a string, or def if not found.
func (*Values) StringSlice ¶ added in v0.9.0
StringSlice returns the value at path as a []string.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package codec provides encoding and decoding functionality for configuration data.
|
Package codec provides encoding and decoding functionality for configuration data. |
|
Package dumper provides configuration dumper implementations.
|
Package dumper provides configuration dumper implementations. |
|
examples
|
|
|
basic
command
Package main shows YAML file loading and struct binding with Synthra.
|
Package main shows YAML file loading and struct binding with Synthra. |
|
casing
command
Package main demonstrates Synthra's case-preserving, case-insensitive merge and shows how a JSON Schema acts as the canonical authority for key casing.
|
Package main demonstrates Synthra's case-preserving, case-insensitive merge and shows how a JSON Schema acts as the canonical authority for key casing. |
|
codecs
command
Package main shows codec usage end-to-end: JSON + TOML as input sources, merged YAML written out via WithFileDumperAs.
|
Package main shows codec usage end-to-end: JSON + TOML as input sources, merged YAML written out via WithFileDumperAs. |
|
consul
command
Package main loads local YAML, optionally merges Consul KV, then env.
|
Package main loads local YAML, optionally merges Consul KV, then env. |
|
envsubst-layered
command
Package main demonstrates three-layer resolver composition with Resolver.Or.
|
Package main demonstrates three-layer resolver composition with Resolver.Or. |
|
hooks
command
Package main demonstrates all three hook points in one pipeline.
|
Package main demonstrates all three hook points in one pipeline. |
|
multi-schema
command
Package main demonstrates two-phase validation using WithJSONSchemaFunc.
|
Package main demonstrates two-phase validation using WithJSONSchemaFunc. |
|
schema
command
Package main demonstrates JSON Schema defaults and WithEnvSubst.
|
Package main demonstrates JSON Schema defaults and WithEnvSubst. |
|
testing
command
Package main exists so `go run .` works; see README and *_test.go.
|
Package main exists so `go run .` works; see README and *_test.go. |
|
webapp
command
Package main demonstrates layered configuration (YAML defaults plus environment overrides), struct binding, and validation with Synthra.
|
Package main demonstrates layered configuration (YAML defaults plus environment overrides), struct binding, and validation with Synthra. |
|
Package source provides configuration source implementations.
|
Package source provides configuration source implementations. |
|
Package synthratest provides test helpers for packages that import gopherly.dev/synthra.
|
Package synthratest provides test helpers for packages that import gopherly.dev/synthra. |