feat: load explicit configuration references and remove implicit env overlays
This commit is contained in:
@@ -1,318 +1,105 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"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)
|
||||
// Aliases maps canonical absolute environment names to ordered fallback names.
|
||||
// A present canonical value wins, including an explicit empty value.
|
||||
Aliases map[string][]string
|
||||
}
|
||||
|
||||
// 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, loaders ...*Loader) (T, error) {
|
||||
// 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
|
||||
}
|
||||
cfg, err := projectOverlay(defaults, options, true)
|
||||
if err != nil {
|
||||
if err = rejectEnvironmentTags(reflect.TypeOf(defaults), map[reflect.Type]bool{}); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
var doc Document
|
||||
doc := Document{Value: map[string]any{}}
|
||||
if path != "" {
|
||||
doc, err = loader.read(path)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
cfg, err = bindDocument(doc, cfg)
|
||||
if err != nil {
|
||||
return zero, locateError(err, path, doc, nil)
|
||||
}
|
||||
} else {
|
||||
value, e := cloneConfiguration(reflect.ValueOf(&cfg).Elem(), 0)
|
||||
if e != nil {
|
||||
return zero, &Error{Reason: "cannot clone defaults", cause: e}
|
||||
}
|
||||
cfg = value.Interface().(T)
|
||||
}
|
||||
origins := map[string]string{}
|
||||
cfg, err = projectOverlay(cfg, options, false, origins)
|
||||
cfg, err := bindDocument(doc, defaults)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
return zero, locateError(err, path, doc)
|
||||
}
|
||||
normalizeProject(reflect.ValueOf(&cfg).Elem(), "", origins, 0)
|
||||
if valid, ok := any(&cfg).(interface{ Validate() error }); ok {
|
||||
if err = valid.Validate(); err != nil {
|
||||
return zero, locateError(err, path, doc, origins)
|
||||
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, origins map[string]string) *Error {
|
||||
func locateError(err error, path string, doc Document) *Error {
|
||||
d := validationError(err)
|
||||
field := d.Field
|
||||
for field != "" && origins[field] == "" {
|
||||
index := strings.LastIndexAny(field, ".[")
|
||||
if index < 0 {
|
||||
field = ""
|
||||
} else {
|
||||
field = field[:index]
|
||||
}
|
||||
}
|
||||
if env := origins[field]; env != "" {
|
||||
d.Env = env
|
||||
d.File = ""
|
||||
d.Line = 0
|
||||
d.Column = 0
|
||||
return d
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// Framework-owned normalization stays inside the common pipeline. Explicit
|
||||
// environment stream selection clears a file profile unless a file override
|
||||
// was also provided; project code never needs to repeat this rule.
|
||||
func normalizeProject(v reflect.Value, path string, origins map[string]string, depth int) {
|
||||
if depth > 32 {
|
||||
return
|
||||
// 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
|
||||
}
|
||||
if v.Kind() == reflect.Pointer {
|
||||
if v.IsNil() {
|
||||
return
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
v = v.Elem()
|
||||
}
|
||||
if v.Kind() != reflect.Struct {
|
||||
return
|
||||
}
|
||||
if v.Type() == reflect.TypeOf(Config{}) && v.CanAddr() {
|
||||
c := v.Addr().Interface().(*Config)
|
||||
if origins[joinField(path, "log.output")] != "" && c.Log.Output != "" && (origins[joinField(path, "log.file")] == "" || c.Log.File == "") {
|
||||
c.Log.File = ""
|
||||
c.Log.Rotation = logx.RotationConfig{}
|
||||
}
|
||||
return
|
||||
}
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
f := v.Type().Field(i)
|
||||
if f.PkgPath != "" {
|
||||
continue
|
||||
}
|
||||
name := strings.Split(f.Tag.Get("json"), ",")[0]
|
||||
if name == "-" {
|
||||
continue
|
||||
}
|
||||
if name == "" {
|
||||
name = f.Name
|
||||
}
|
||||
normalizeProject(v.Field(i), joinField(path, name), origins, depth+1)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
return nil
|
||||
}
|
||||
|
||||
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, sourceMaps ...map[string]string) (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, string, int) error
|
||||
walk = func(value reflect.Value, prefix, path 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
|
||||
}
|
||||
fieldPath := joinField(path, jsonName)
|
||||
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+"_", fieldPath, 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+"_", fieldPath, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
if !dst.IsNil() || !child.Elem().IsZero() {
|
||||
dst.Set(child)
|
||||
}
|
||||
if required && !defaults && dst.IsNil() {
|
||||
return &Error{Env: key, Field: fieldPath, Reason: "is required"}
|
||||
}
|
||||
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
|
||||
var raw string
|
||||
var present bool
|
||||
if !defaults {
|
||||
raw, present = options.Lookup(key)
|
||||
}
|
||||
selectedKey := key
|
||||
if !present && !defaults {
|
||||
for _, alias := range options.Aliases[key] {
|
||||
if raw, present = options.Lookup(alias); present {
|
||||
selectedKey = alias
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if defaults {
|
||||
raw, present = field.Tag.Lookup("envDefault")
|
||||
present = present && dst.IsZero()
|
||||
}
|
||||
if present && raw == "" && !defaults {
|
||||
if empty == "reject" {
|
||||
return &Error{Env: selectedKey, Field: fieldPath, Reason: "must not be empty"}
|
||||
}
|
||||
if empty == "ignore" {
|
||||
present = false
|
||||
}
|
||||
}
|
||||
if present {
|
||||
parsed := reflect.New(dst.Type()).Elem()
|
||||
if err := parseEnvValue(parsed, raw); err != nil {
|
||||
origin := selectedKey
|
||||
if defaults {
|
||||
origin = ""
|
||||
}
|
||||
return &Error{Env: origin, Field: fieldPath, Reason: "expected a valid " + dst.Type().String(), cause: err}
|
||||
}
|
||||
dst.Set(parsed)
|
||||
if !defaults && len(sourceMaps) > 0 {
|
||||
sourceMaps[0][fieldPath] = selectedKey
|
||||
}
|
||||
}
|
||||
if required && !defaults && emptyRequired(dst) {
|
||||
return &Error{Env: key, Field: fieldPath, Reason: "is required"}
|
||||
}
|
||||
}
|
||||
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) {
|
||||
func parseTextValue(v reflect.Value, raw string) (err error) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
err = errors.New("invalid custom value")
|
||||
@@ -320,7 +107,7 @@ func parseEnvValue(v reflect.Value, raw string) (err error) {
|
||||
}()
|
||||
if v.Kind() == reflect.Pointer {
|
||||
v.Set(reflect.New(v.Type().Elem()))
|
||||
return parseEnvValue(v.Elem(), raw)
|
||||
return parseTextValue(v.Elem(), raw)
|
||||
}
|
||||
if v.CanAddr() && v.Addr().Type().Implements(textType) {
|
||||
return v.Addr().Interface().(encoding.TextUnmarshaler).UnmarshalText([]byte(raw))
|
||||
@@ -363,37 +150,6 @@ func parseEnvValue(v reflect.Value, raw string) (err error) {
|
||||
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()
|
||||
return errors.New("expected scalar text")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user