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) { var zero T loader, err := selectLoader(loaders) if err != nil { return zero, err } cfg, err := projectOverlay(defaults, options, true) if err != nil { return zero, err } var doc Document 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) if err != nil { return zero, err } 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 cfg, nil } func locateError(err error, path string, doc Document, origins map[string]string) *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 } 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 } if v.Kind() == reflect.Pointer { if v.IsNil() { return } 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) } 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) { 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() }