Files
go-omni/config/project.go

156 lines
3.9 KiB
Go

package config
import (
"encoding"
"errors"
"math"
"reflect"
"strconv"
"strings"
"time"
)
// LoadProject clones defaults, resolves only explicit file references, binds the
// provided fields and validates. Empty path uses only defaults. No env tags,
// prefixes, aliases or implicit environment overlays are applied.
func LoadProject[T any](path string, defaults T, loaders ...*Loader) (T, error) {
var zero T
loader, err := selectLoader(loaders)
if err != nil {
return zero, err
}
if err = rejectEnvironmentTags(reflect.TypeOf(defaults), map[reflect.Type]bool{}); err != nil {
return zero, err
}
doc := Document{Value: map[string]any{}}
if path != "" {
doc, err = loader.read(path)
if err != nil {
return zero, err
}
}
cfg, err := bindDocument(doc, defaults)
if err != nil {
return zero, locateError(err, path, doc)
}
if valid, ok := any(&cfg).(interface{ Validate() error }); ok {
if err = valid.Validate(); err != nil {
return zero, locateError(err, path, doc)
}
} else if valid, ok := any(cfg).(interface{ Validate() error }); ok {
if err = valid.Validate(); err != nil {
return zero, locateError(err, path, doc)
}
}
return cfg, nil
}
func locateError(err error, path string, doc Document) *Error {
d := validationError(err)
d.File = path
if d.Line == 0 {
p := doc.Positions[d.Field]
d.Line = p.Line
d.Column = p.Column
}
for field := d.Field; field != ""; {
if names := doc.environments[field]; len(names) > 0 {
d.Env = strings.Join(names, ", ")
break
}
i := strings.LastIndexAny(field, ".[")
if i < 0 {
break
}
field = field[:i]
}
return d
}
// Reject removed declarations rather than silently ignoring deployment rules.
func rejectEnvironmentTags(t reflect.Type, seen map[reflect.Type]bool) error {
if t == nil || seen[t] {
return nil
}
seen[t] = true
switch t.Kind() {
case reflect.Pointer, reflect.Slice, reflect.Array, reflect.Map:
return rejectEnvironmentTags(t.Elem(), seen)
case reflect.Struct:
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
if f.PkgPath != "" {
continue
}
_, env := f.Tag.Lookup("env")
_, def := f.Tag.Lookup("envDefault")
if env || def {
return &Error{Field: f.Name, Reason: "env/envDefault tags were removed; use Default() and explicit file references; see CONFIG.md"}
}
if f.Tag.Get("json") != "-" {
if err := rejectEnvironmentTags(f.Type, seen); err != nil {
return err
}
}
}
}
return nil
}
var textType = reflect.TypeOf((*encoding.TextUnmarshaler)(nil)).Elem()
var durationType = reflect.TypeOf(time.Duration(0))
func parseTextValue(v reflect.Value, raw string) (err error) {
defer func() {
if recover() != nil {
err = errors.New("invalid custom value")
}
}()
if v.Kind() == reflect.Pointer {
v.Set(reflect.New(v.Type().Elem()))
return parseTextValue(v.Elem(), raw)
}
if v.CanAddr() && v.Addr().Type().Implements(textType) {
return v.Addr().Interface().(encoding.TextUnmarshaler).UnmarshalText([]byte(raw))
}
if v.Type() == durationType {
d, err := time.ParseDuration(raw)
if err == nil {
v.SetInt(int64(d))
}
return err
}
switch v.Kind() {
case reflect.String:
v.SetString(raw)
return nil
case reflect.Bool:
x, err := strconv.ParseBool(raw)
if err == nil {
v.SetBool(x)
}
return err
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
x, err := strconv.ParseInt(raw, 10, v.Type().Bits())
if err == nil {
v.SetInt(x)
}
return err
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
x, err := strconv.ParseUint(raw, 10, v.Type().Bits())
if err == nil {
v.SetUint(x)
}
return err
case reflect.Float32, reflect.Float64:
x, err := strconv.ParseFloat(raw, v.Type().Bits())
if err == nil && (math.IsNaN(x) || math.IsInf(x, 0)) {
return errors.New("nonfinite value")
}
if err == nil {
v.SetFloat(x)
}
return err
}
return errors.New("expected scalar text")
}