2026-01-13 10:40:01 -06:00
|
|
|
|
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"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 19:19:19 -05:00
|
|
|
|
// SourceMode selects how a source receives upstream input.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Empty mode means "auto": feedkit infers mode from the registered driver type.
|
|
|
|
|
|
type SourceMode string
|
|
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
|
SourceModeAuto SourceMode = ""
|
|
|
|
|
|
SourceModePoll SourceMode = "poll"
|
|
|
|
|
|
SourceModeStream SourceMode = "stream"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// Normalize lowercases and trims the mode.
|
|
|
|
|
|
func (m SourceMode) Normalize() SourceMode {
|
|
|
|
|
|
switch strings.ToLower(strings.TrimSpace(string(m))) {
|
|
|
|
|
|
case "":
|
|
|
|
|
|
return SourceModeAuto
|
|
|
|
|
|
case string(SourceModePoll):
|
|
|
|
|
|
return SourceModePoll
|
|
|
|
|
|
case string(SourceModeStream):
|
|
|
|
|
|
return SourceModeStream
|
|
|
|
|
|
default:
|
|
|
|
|
|
return SourceMode(strings.ToLower(strings.TrimSpace(string(m))))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SourceConfig describes one input source.
|
2026-01-13 10:40:01 -06:00
|
|
|
|
//
|
|
|
|
|
|
// This is intentionally generic:
|
|
|
|
|
|
// - driver-specific knobs belong in Params.
|
2026-03-15 19:19:19 -05:00
|
|
|
|
// - mode controls polling vs streaming behavior.
|
|
|
|
|
|
// - expected emitted kinds are optional and domain-defined.
|
2026-01-13 10:40:01 -06:00
|
|
|
|
type SourceConfig struct {
|
|
|
|
|
|
Name string `yaml:"name"`
|
|
|
|
|
|
Driver string `yaml:"driver"` // e.g. "openmeteo_observation", "rss_feed", etc.
|
|
|
|
|
|
|
2026-03-15 19:19:19 -05:00
|
|
|
|
// Mode is optional:
|
|
|
|
|
|
// - "poll": Every must be set (>0)
|
|
|
|
|
|
// - "stream": Every must be omitted/zero
|
|
|
|
|
|
// - empty: infer from driver registration type (poll vs stream)
|
|
|
|
|
|
Mode SourceMode `yaml:"mode"`
|
|
|
|
|
|
|
|
|
|
|
|
// Every is the poll cadence for poll-mode sources ("15m", "1m", etc.).
|
|
|
|
|
|
Every Duration `yaml:"every"`
|
|
|
|
|
|
|
|
|
|
|
|
// Kinds is optional and domain-defined.
|
|
|
|
|
|
// If set, it describes the expected emitted event kinds for this source.
|
|
|
|
|
|
Kinds []string `yaml:"kinds"`
|
2026-01-13 10:40:01 -06:00
|
|
|
|
|
|
|
|
|
|
// 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"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 19:19:19 -05:00
|
|
|
|
// ExpectedKinds returns normalized expected kinds from config.
|
|
|
|
|
|
func (cfg SourceConfig) ExpectedKinds() []string {
|
2026-03-28 13:52:48 -05:00
|
|
|
|
out := make([]string, 0, len(cfg.Kinds))
|
|
|
|
|
|
for _, k := range cfg.Kinds {
|
|
|
|
|
|
k = strings.TrimSpace(k)
|
|
|
|
|
|
if k == "" {
|
|
|
|
|
|
continue
|
2026-03-15 19:19:19 -05:00
|
|
|
|
}
|
2026-03-28 13:52:48 -05:00
|
|
|
|
out = append(out, k)
|
2026-03-15 19:19:19 -05:00
|
|
|
|
}
|
2026-03-28 13:52:48 -05:00
|
|
|
|
if len(out) == 0 {
|
|
|
|
|
|
return nil
|
2026-03-15 19:19:19 -05:00
|
|
|
|
}
|
2026-03-28 13:52:48 -05:00
|
|
|
|
return out
|
2026-03-15 19:19:19 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-13 10:40:01 -06:00
|
|
|
|
// SinkConfig describes one output sink adapter.
|
|
|
|
|
|
type SinkConfig struct {
|
|
|
|
|
|
Name string `yaml:"name"`
|
2026-03-28 13:02:37 -05:00
|
|
|
|
Driver string `yaml:"driver"` // "stdout", "nats", "postgres", ...
|
2026-01-13 10:40:01 -06:00
|
|
|
|
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.
|
2026-01-15 18:26:45 -06:00
|
|
|
|
//
|
|
|
|
|
|
// 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.
|
2026-01-13 10:40:01 -06:00
|
|
|
|
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)
|
|
|
|
|
|
}
|