291 lines
8.0 KiB
Go
291 lines
8.0 KiB
Go
package config
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
|
"math"
|
|
"os"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
)
|
|
|
|
// EnvOptions controls typed project configuration overlays. Explicit env tags
|
|
// are absolute names; otherwise JSON names become PREFIX_NESTED_FIELD names.
|
|
// Empty is allow (default), ignore or reject. No input values appear in errors.
|
|
type EnvOptions struct {
|
|
Prefix string
|
|
Empty string
|
|
Lookup func(string) (string, bool)
|
|
}
|
|
|
|
// ApplyDefaults applies envDefault tags to zero fields before file overlays.
|
|
func ApplyDefaults[T any](base T) (T, error) { return projectOverlay(base, EnvOptions{}, true) }
|
|
|
|
// LoadProject applies code/tag defaults, a strict optional file, environment,
|
|
// then the project's Validate method, before any logger or dependency is opened.
|
|
// envDefault is only applied to zero-valued defaults before reading the file.
|
|
func LoadProject[T any](path string, defaults T, options EnvOptions) (T, error) {
|
|
var zero T
|
|
cfg, err := projectOverlay(defaults, options, true)
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
if path != "" {
|
|
cfg, err = DecodeFile(path, cfg)
|
|
if err != nil {
|
|
return zero, errors.New("config: cannot decode file; check its path, format, field names and value types")
|
|
}
|
|
}
|
|
cfg, err = OverlayEnv(cfg, options)
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
if valid, ok := any(&cfg).(interface{ Validate() error }); ok {
|
|
if err = valid.Validate(); err != nil {
|
|
return zero, err
|
|
}
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// OverlayEnv overlays the entire project struct without mutating the input.
|
|
// Tags: env:"CUSTOM_NAME,required,secret,empty=reject"; env:"-" excludes a
|
|
// subtree. required means nonempty string/non-nil pointer or collection; false
|
|
// and zero are valid scalars. Use project Validate for semantic constraints.
|
|
// Slices/maps use JSON syntax, avoiding ambiguous comma and empty semantics.
|
|
func OverlayEnv[T any](base T, options EnvOptions) (T, error) {
|
|
return projectOverlay(base, options, false)
|
|
}
|
|
|
|
var textType = reflect.TypeOf((*encoding.TextUnmarshaler)(nil)).Elem()
|
|
var durationType = reflect.TypeOf(time.Duration(0))
|
|
|
|
func projectOverlay[T any](base T, options EnvOptions, defaults bool) (T, error) {
|
|
var zero T
|
|
if options.Lookup == nil {
|
|
options.Lookup = os.LookupEnv
|
|
}
|
|
if options.Empty == "" {
|
|
options.Empty = "allow"
|
|
}
|
|
if options.Empty != "allow" && options.Empty != "ignore" && options.Empty != "reject" {
|
|
return zero, errors.New("config: empty policy must be allow, ignore or reject")
|
|
}
|
|
v := reflect.ValueOf(&base).Elem()
|
|
if v.Kind() != reflect.Struct {
|
|
return zero, errors.New("config: project configuration must be a struct")
|
|
}
|
|
result := reflect.New(v.Type()).Elem()
|
|
result.Set(v)
|
|
seen := map[string]bool{}
|
|
var walk func(reflect.Value, string, int) error
|
|
walk = func(value reflect.Value, prefix string, depth int) error {
|
|
if depth > 32 {
|
|
return errors.New("config: nesting exceeds 32")
|
|
}
|
|
for i := 0; i < value.NumField(); i++ {
|
|
field := value.Type().Field(i)
|
|
dst := value.Field(i)
|
|
if !dst.CanSet() || field.PkgPath != "" {
|
|
continue
|
|
}
|
|
tag := strings.Split(field.Tag.Get("env"), ",")
|
|
if tag[0] == "-" {
|
|
continue
|
|
}
|
|
jsonName := strings.Split(field.Tag.Get("json"), ",")[0]
|
|
if jsonName == "-" && tag[0] == "" {
|
|
continue
|
|
}
|
|
if jsonName == "" {
|
|
jsonName = field.Name
|
|
}
|
|
key := prefix + envName(jsonName)
|
|
if tag[0] != "" {
|
|
key = tag[0]
|
|
}
|
|
empty, required := options.Empty, false
|
|
for _, opt := range tag[1:] {
|
|
switch opt {
|
|
case "required":
|
|
required = true
|
|
case "secret", "":
|
|
case "empty=allow":
|
|
empty = "allow"
|
|
case "empty=ignore":
|
|
empty = "ignore"
|
|
case "empty=reject":
|
|
empty = "reject"
|
|
default:
|
|
return fmt.Errorf("config: invalid env tag for %s", key)
|
|
}
|
|
}
|
|
if dst.Kind() == reflect.Struct && !dst.Addr().Type().Implements(textType) {
|
|
if err := walk(dst, key+"_", depth+1); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
if dst.Kind() == reflect.Pointer && dst.Type().Elem().Kind() == reflect.Struct && !dst.Type().Implements(textType) {
|
|
// Clone before traversing so a failed overlay never edits defaults.
|
|
child := reflect.New(dst.Type().Elem())
|
|
if !dst.IsNil() {
|
|
child.Elem().Set(dst.Elem())
|
|
}
|
|
if err := walk(child.Elem(), key+"_", depth+1); err != nil {
|
|
return err
|
|
}
|
|
if !dst.IsNil() || !child.Elem().IsZero() {
|
|
dst.Set(child)
|
|
}
|
|
if required && !defaults && dst.IsNil() {
|
|
return fmt.Errorf("config: %s is required", key)
|
|
}
|
|
continue
|
|
}
|
|
// Unexported runtime capabilities are skipped unless explicitly tagged.
|
|
if dst.Kind() == reflect.Func || dst.Kind() == reflect.Chan || dst.Kind() == reflect.Interface {
|
|
if tag[0] != "" {
|
|
return fmt.Errorf("config: %s has unsupported environment type", key)
|
|
}
|
|
continue
|
|
}
|
|
if seen[key] {
|
|
return fmt.Errorf("config: duplicate environment key %s", key)
|
|
}
|
|
seen[key] = true
|
|
raw, present := options.Lookup(key)
|
|
if defaults {
|
|
raw, present = field.Tag.Lookup("envDefault")
|
|
present = present && dst.IsZero()
|
|
}
|
|
if present && raw == "" && !defaults {
|
|
if empty == "reject" {
|
|
return fmt.Errorf("config: %s must not be empty", key)
|
|
}
|
|
if empty == "ignore" {
|
|
present = false
|
|
}
|
|
}
|
|
if present {
|
|
parsed := reflect.New(dst.Type()).Elem()
|
|
if err := parseEnvValue(parsed, raw); err != nil {
|
|
return fmt.Errorf("config: %s must be a valid %s", key, dst.Type())
|
|
}
|
|
dst.Set(parsed)
|
|
}
|
|
if required && !defaults && emptyRequired(dst) {
|
|
return fmt.Errorf("config: %s is required", key)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
if err := walk(result, options.Prefix, 0); err != nil {
|
|
return zero, err
|
|
}
|
|
return result.Interface().(T), nil
|
|
}
|
|
|
|
func emptyRequired(v reflect.Value) bool {
|
|
switch v.Kind() {
|
|
case reflect.String:
|
|
return v.Len() == 0
|
|
case reflect.Pointer, reflect.Map, reflect.Slice:
|
|
return v.IsNil()
|
|
}
|
|
return false
|
|
}
|
|
|
|
func parseEnvValue(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 parseEnvValue(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
|
|
case reflect.Slice, reflect.Map:
|
|
var value any
|
|
if err := apierrors.DecodeCatalogJSON([]byte(raw), &value); err != nil {
|
|
return err
|
|
}
|
|
value, err := durations(value, v.Type())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
encoded, err := json.Marshal(value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
decoder := json.NewDecoder(bytes.NewReader(encoded))
|
|
decoder.DisallowUnknownFields()
|
|
return decoder.Decode(v.Addr().Interface())
|
|
}
|
|
return errors.New("unsupported environment value")
|
|
}
|
|
|
|
func envName(s string) string {
|
|
r := []rune(s)
|
|
var out strings.Builder
|
|
for i, c := range r {
|
|
if i > 0 && unicode.IsUpper(c) && (unicode.IsLower(r[i-1]) || unicode.IsDigit(r[i-1]) || (i+1 < len(r) && unicode.IsLower(r[i+1]))) {
|
|
out.WriteByte('_')
|
|
}
|
|
if c == '-' {
|
|
c = '_'
|
|
}
|
|
out.WriteRune(unicode.ToUpper(c))
|
|
}
|
|
return out.String()
|
|
}
|