conf/conf.go
juancwu c4ebd80669 Add reflective struct-tag config loader
Implements conf.Load to populate tagged structs from a chain of Sources
(env, .env, YAML/JSON/TOML, custom). Supports default values, slice
separators, nested structs, pointer fields, and a Validator hook.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 20:35:51 +00:00

110 lines
2.5 KiB
Go

// Package conf loads configuration values from one or more Sources into a
// tagged Go struct.
//
// Field tags:
//
// env:"KEY" key looked up in each Source (required to bind a field)
// default:"v" raw value used when no Source returns the key
// sep:"," separator for slice fields (default ",")
// env:"-" skip the field
//
// Sources are tried in order; the first one returning a value wins.
// If the destination type implements Validator, Validate is called after the
// fields are populated.
package conf
import (
"reflect"
"git.juancwu.dev/juancwu/errx"
)
// Validator is implemented by config types that want a post-load hook.
type Validator interface {
Validate() error
}
// Load populates dst from sources. dst must be a non-nil pointer to a struct.
func Load(dst any, sources ...Source) error {
const op = "conf.Load"
if dst == nil {
return errx.New(op, "dst is nil")
}
v := reflect.ValueOf(dst)
if v.Kind() != reflect.Pointer || v.IsNil() {
return errx.New(op, "dst must be a non-nil pointer to a struct")
}
v = v.Elem()
if v.Kind() != reflect.Struct {
return errx.New(op, "dst must point to a struct")
}
if err := walk(v, sources); err != nil {
return errx.Wrap(op, err)
}
if val, ok := dst.(Validator); ok {
if err := val.Validate(); err != nil {
return errx.Wrap(op, err)
}
}
return nil
}
func walk(v reflect.Value, sources []Source) error {
const op = "conf.walk"
t := v.Type()
for i := 0; i < v.NumField(); i++ {
sf := t.Field(i)
if !sf.IsExported() {
continue
}
fv := v.Field(i)
// Recurse into nested structs (and pointers to structs) that have no env tag.
key := sf.Tag.Get("env")
if key == "" {
switch {
case fv.Kind() == reflect.Struct:
if err := walk(fv, sources); err != nil {
return err
}
case fv.Kind() == reflect.Pointer && fv.Type().Elem().Kind() == reflect.Struct:
if fv.IsNil() {
fv.Set(reflect.New(fv.Type().Elem()))
}
if err := walk(fv.Elem(), sources); err != nil {
return err
}
}
continue
}
if key == "-" {
continue
}
raw, ok := lookup(sources, key)
if !ok {
raw, ok = sf.Tag.Lookup("default")
if !ok {
continue
}
}
if err := assignString(fv, raw, sf.Tag.Get("sep")); err != nil {
return errx.Wrapf(op, err, "field %s (%s)", sf.Name, key)
}
}
return nil
}
func lookup(sources []Source, key string) (string, bool) {
for _, s := range sources {
if v, ok := s.Lookup(key); ok {
return v, true
}
}
return "", false
}