package config import ( "bytes" "context" "encoding/json" "errors" "io" "os" "path/filepath" "reflect" "strings" "sync" "time" apierrors "git.yuntuo.hk/k2safe/go-omni/errors" "git.yuntuo.hk/k2safe/go-omni/logx" "gopkg.in/yaml.v3" ) // DecodeFile merges a bounded JSON/YAML document into defaults. It uses JSON // field names for both formats, rejects unknown fields and accepts durations // such as "3s". Application-specific validation remains with the caller. func DecodeFile[T any](path string, defaults T) (T, error) { result, err := decodeFile(path, defaults) if err != nil { var zero T return zero, &DecodeError{cause: err} } return result, nil } // DecodeError keeps input values out of public diagnostics while preserving // errors.Is/As for explicitly handled underlying I/O and syntax errors. type DecodeError struct{ cause error } func (e *DecodeError) Error() string { return "config: cannot decode file; check its path, format, field names and value types" } func (e *DecodeError) Unwrap() error { return e.cause } func decodeFile[T any](path string, defaults T) (T, error) { var zero T f, err := os.Open(path) if err != nil { return zero, err } defer f.Close() data, err := io.ReadAll(io.LimitReader(f, (1<<20)+1)) if err != nil { return zero, err } if len(data) > 1<<20 { return zero, errors.New("config: file exceeds 1 MiB") } var value any switch strings.ToLower(filepath.Ext(path)) { case ".yaml", ".yml": dec := yaml.NewDecoder(bytes.NewReader(data)) if err := dec.Decode(&value); err != nil { return zero, err } var extra any if err := dec.Decode(&extra); err != io.EOF { return zero, errors.New("config: one YAML document required") } case ".json": if err := apierrors.DecodeCatalogJSON(data, &value); err != nil { return zero, err } default: return zero, errors.New("config: expected .json, .yaml or .yml") } if _, ok := value.(map[string]any); !ok { return zero, errors.New("config: root must be an object") } value, err = durations(value, reflect.TypeOf(defaults)) if err != nil { return zero, err } encoded, err := json.Marshal(value) if err != nil { return zero, err } // Clone configuration without serializing borrowed json:"-" capabilities. var result T original, err := cloneConfiguration(reflect.ValueOf(&defaults).Elem(), 0) if err != nil { return zero, err } reflect.ValueOf(&result).Elem().Set(original) dec := json.NewDecoder(bytes.NewReader(encoded)) dec.DisallowUnknownFields() if err = dec.Decode(&result); err != nil { return zero, err } return result, nil } func durations(v any, t reflect.Type) (any, error) { if t == nil { return v, nil } for t.Kind() == reflect.Pointer { t = t.Elem() } if t == reflect.TypeOf(time.Duration(0)) { if s, ok := v.(string); ok { d, err := time.ParseDuration(s) return int64(d), err } return v, nil } if t.Kind() == reflect.Struct { if m, ok := v.(map[string]any); ok { for n := 0; n < t.NumField(); n++ { field := t.Field(n) name := strings.Split(field.Tag.Get("json"), ",")[0] if name == "-" { continue } if name == "" { name = field.Name } if item, exists := m[name]; exists { converted, err := durations(item, field.Type) if err != nil { return nil, err } m[name] = converted } } } } if t.Kind() == reflect.Map && t.Key().Kind() == reflect.String { if values, ok := v.(map[string]any); ok { for key, value := range values { converted, err := durations(value, t.Elem()) if err != nil { return nil, err } values[key] = converted } } } if t.Kind() == reflect.Slice { if items, ok := v.([]any); ok { for n, item := range items { converted, err := durations(item, t.Elem()) if err != nil { return nil, err } items[n] = converted } } } return v, nil } func LoadFile(path, prefix string) (Config, error) { cfg, err := DecodeFile(path, Default()) if err != nil { return Config{}, err } return ApplyEnv(cfg, prefix) } // Reloader only accepts log.level changes. Static changes and invalid files // leave the current snapshot and logger untouched. Watch does not own a goroutine. type Reloader struct { mu sync.Mutex path, prefix string current Config logger *logx.Logger } func NewReloader(path, prefix string, logger *logx.Logger) (*Reloader, error) { cfg, err := LoadFile(path, prefix) if err != nil { return nil, err } if err = logger.SetLevel(cfg.Log.Level); err != nil { return nil, err } return &Reloader{path: path, prefix: prefix, current: cfg, logger: logger}, nil } func (r *Reloader) Reload() error { r.mu.Lock() defer r.mu.Unlock() next, err := LoadFile(r.path, r.prefix) if err != nil { return err } comparable := next comparable.Log.Level = r.current.Log.Level if !reflect.DeepEqual(comparable, r.current) { return errors.New("config: static fields changed; restart required") } if err = r.logger.SetLevel(next.Log.Level); err != nil { return err } r.current = next return nil } func (r *Reloader) Snapshot() Config { r.mu.Lock() defer r.mu.Unlock() cfg := r.current cfg.Response = r.current.Response.Clone() cfg.Errors = r.current.Errors.Clone() cfg.TrustedProxies = append([]string(nil), cfg.TrustedProxies...) cfg.Log.RedactKeys = append([]string(nil), cfg.Log.RedactKeys...) return cfg } func (r *Reloader) Watch(ctx context.Context, interval time.Duration, onError func(error)) error { if ctx == nil || interval < 100*time.Millisecond { return errors.New("config: context and interval >=100ms required") } ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ctx.Done(): return ctx.Err() case <-ticker.C: if err := r.Reload(); err != nil && onError != nil { onError(err) } } } }