package config import ( "fmt" "strconv" "strings" "time" "gopkg.in/yaml.v3" ) // Config is the top-level YAML configuration used by feedkit-style daemons. // // IMPORTANT: This package is intentionally domain-agnostic. // - We validate *shape* and internal consistency (required fields, uniqueness, etc.). // - We do NOT enforce domain rules (e.g., "observation|forecast|alert"). // Domain modules (weatherfeeder/newsfeeder/...) can add their own validation layer. type Config struct { Sources []SourceConfig `yaml:"sources"` Sinks []SinkConfig `yaml:"sinks"` Routes []RouteConfig `yaml:"routes"` } // SourceConfig describes one polling job. // // This is intentionally generic: // - driver-specific knobs belong in Params. // - "kind" is allowed (useful for safety checks / routing), but feedkit does not // restrict the allowed values. type SourceConfig struct { Name string `yaml:"name"` Driver string `yaml:"driver"` // e.g. "openmeteo_observation", "rss_feed", etc. Every Duration `yaml:"every"` // "15m", "1m", etc. // Kind is optional and domain-defined. If set, it should be a non-empty string. // Domains commonly use it to enforce "this source should only emit kind X". Kind string `yaml:"kind"` // Params are driver-specific settings (URL, headers, station IDs, API keys, etc.). // The driver implementation is responsible for reading/validating these. Params map[string]any `yaml:"params"` } // SinkConfig describes one output sink adapter. type SinkConfig struct { Name string `yaml:"name"` Driver string `yaml:"driver"` // "stdout", "file", "postgres", "rabbitmq", ... Params map[string]any `yaml:"params"` // sink-specific settings } // RouteConfig describes a routing rule: which sink receives which kinds. type RouteConfig struct { Sink string `yaml:"sink"` // sink name // Kinds is domain-defined. feedkit only enforces that each entry is non-empty. // // If Kinds is omitted or empty, the route matches ALL kinds. // This is useful when you want explicit per-sink routing rules even when a // particular sink should receive everything. Kinds []string `yaml:"kinds"` } // Duration is a YAML-friendly duration wrapper. // // Supported YAML formats: // // every: 15m # string duration (recommended) // every: 30s // every: 1h // // Also supported for convenience (interpreted as minutes): // // every: 15 # integer minutes // every: "15" # string minutes type Duration struct { time.Duration } func (d *Duration) UnmarshalYAML(value *yaml.Node) error { // We expect a scalar (string or int). if value.Kind != yaml.ScalarNode { return fmt.Errorf("duration must be a scalar (e.g. 15m), got %v", value.Kind) } // Case 1: YAML integer -> interpret as minutes. if value.Tag == "!!int" { n, err := strconv.Atoi(value.Value) if err != nil { return fmt.Errorf("invalid integer duration %q: %w", value.Value, err) } if n <= 0 { return fmt.Errorf("duration must be > 0, got %d", n) } d.Duration = time.Duration(n) * time.Minute return nil } // Case 2: YAML string. if value.Tag == "!!str" { s := strings.TrimSpace(value.Value) if s == "" { return fmt.Errorf("duration cannot be empty") } // If it’s all digits, interpret as minutes (e.g. "15"). if isAllDigits(s) { n, err := strconv.Atoi(s) if err != nil { return fmt.Errorf("invalid numeric duration %q: %w", s, err) } if n <= 0 { return fmt.Errorf("duration must be > 0, got %d", n) } d.Duration = time.Duration(n) * time.Minute return nil } // Otherwise parse as Go duration string (15m, 30s, 1h, etc.). parsed, err := time.ParseDuration(s) if err != nil { return fmt.Errorf("invalid duration %q (expected e.g. 15m, 30s, 1h): %w", s, err) } if parsed <= 0 { return fmt.Errorf("duration must be > 0, got %s", parsed) } d.Duration = parsed return nil } // Anything else: reject. return fmt.Errorf("duration must be a string like 15m or an integer minutes, got tag %s", value.Tag) }