feedkit now contains a reusable core, while weatherfeeder is a concrete implementation that includes weather-specific functions.
194 lines
5.1 KiB
Go
194 lines
5.1 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// Load reads the YAML config file from disk, decodes it into Config,
|
|
// and then validates it.
|
|
//
|
|
// Important behaviors:
|
|
// - Uses yaml.Decoder with KnownFields(true) to catch typos like "srouces:".
|
|
// - Returns a validation error that (usually) contains multiple issues at once.
|
|
func Load(path string) (*Config, error) {
|
|
if strings.TrimSpace(path) == "" {
|
|
path = "config.yml"
|
|
}
|
|
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("config.Load: read %q: %w", path, err)
|
|
}
|
|
|
|
var cfg Config
|
|
|
|
dec := yaml.NewDecoder(strings.NewReader(string(raw)))
|
|
dec.KnownFields(true) // strict mode for struct fields
|
|
|
|
if err := dec.Decode(&cfg); err != nil {
|
|
return nil, fmt.Errorf("config.Load: parse YAML %q: %w", path, err)
|
|
}
|
|
|
|
// Optional: ensure there isn't a second YAML document accidentally appended.
|
|
var extra any
|
|
if err := dec.Decode(&extra); err == nil {
|
|
return nil, fmt.Errorf("config.Load: %q contains multiple YAML documents; expected exactly one", path)
|
|
}
|
|
|
|
if err := cfg.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &cfg, nil
|
|
}
|
|
|
|
// Validate checks whether the config is internally consistent and safe to run.
|
|
//
|
|
// This is intentionally DOMAIN-AGNOSTIC validation:
|
|
// - required fields are present
|
|
// - names are unique
|
|
// - durations are > 0
|
|
// - routes reference defined sinks
|
|
//
|
|
// We DO NOT enforce domain-specific constraints like "allowed kinds" or
|
|
// "NWS requires a user-agent". Those belong in the domain module (weatherfeeder).
|
|
func (c *Config) Validate() error {
|
|
var m multiError
|
|
|
|
// ---------- sources ----------
|
|
if len(c.Sources) == 0 {
|
|
m.Add(fieldErr("sources", "must contain at least one source"))
|
|
} else {
|
|
seen := map[string]bool{}
|
|
for i, s := range c.Sources {
|
|
path := fmt.Sprintf("sources[%d]", i)
|
|
|
|
// Name
|
|
if strings.TrimSpace(s.Name) == "" {
|
|
m.Add(fieldErr(path+".name", "is required"))
|
|
} else {
|
|
if seen[s.Name] {
|
|
m.Add(fieldErr(path+".name", fmt.Sprintf("duplicate source name %q (source names must be unique)", s.Name)))
|
|
}
|
|
seen[s.Name] = true
|
|
}
|
|
|
|
// Driver
|
|
if strings.TrimSpace(s.Driver) == "" {
|
|
m.Add(fieldErr(path+".driver", "is required (e.g. openmeteo_observation, rss_feed, ...)"))
|
|
}
|
|
|
|
// Every
|
|
if s.Every.Duration <= 0 {
|
|
m.Add(fieldErr(path+".every", "must be a positive duration (e.g. 15m, 1m, 30s)"))
|
|
}
|
|
|
|
// Kind (optional but if present must be non-empty after trimming)
|
|
if s.Kind != "" && strings.TrimSpace(s.Kind) == "" {
|
|
m.Add(fieldErr(path+".kind", "cannot be blank (omit it entirely, or provide a non-empty string)"))
|
|
}
|
|
|
|
// Params can be nil; that's fine.
|
|
}
|
|
}
|
|
|
|
// ---------- sinks ----------
|
|
sinkNames := map[string]bool{}
|
|
if len(c.Sinks) == 0 {
|
|
m.Add(fieldErr("sinks", "must contain at least one sink"))
|
|
} else {
|
|
for i, s := range c.Sinks {
|
|
path := fmt.Sprintf("sinks[%d]", i)
|
|
|
|
if strings.TrimSpace(s.Name) == "" {
|
|
m.Add(fieldErr(path+".name", "is required"))
|
|
} else {
|
|
if sinkNames[s.Name] {
|
|
m.Add(fieldErr(path+".name", fmt.Sprintf("duplicate sink name %q (sink names must be unique)", s.Name)))
|
|
}
|
|
sinkNames[s.Name] = true
|
|
}
|
|
|
|
if strings.TrimSpace(s.Driver) == "" {
|
|
m.Add(fieldErr(path+".driver", "is required (stdout|file|postgres|rabbitmq|...)"))
|
|
}
|
|
|
|
// Params can be nil; that's fine.
|
|
}
|
|
}
|
|
|
|
// ---------- routes ----------
|
|
// Routes are optional. If provided, validate shape + references.
|
|
for i, r := range c.Routes {
|
|
path := fmt.Sprintf("routes[%d]", i)
|
|
|
|
if strings.TrimSpace(r.Sink) == "" {
|
|
m.Add(fieldErr(path+".sink", "is required"))
|
|
} else if !sinkNames[r.Sink] {
|
|
m.Add(fieldErr(path+".sink", fmt.Sprintf("references unknown sink %q (define it under sinks:)", r.Sink)))
|
|
}
|
|
|
|
if len(r.Kinds) == 0 {
|
|
// You could relax this later (e.g. empty == "all kinds"), but for now
|
|
// keeping it strict prevents accidental "route does nothing".
|
|
m.Add(fieldErr(path+".kinds", "must contain at least one kind"))
|
|
} else {
|
|
for j, k := range r.Kinds {
|
|
kpath := fmt.Sprintf("%s.kinds[%d]", path, j)
|
|
if strings.TrimSpace(k) == "" {
|
|
m.Add(fieldErr(kpath, "kind cannot be empty"))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return m.Err()
|
|
}
|
|
|
|
// ---- error helpers ----
|
|
|
|
// fieldErr produces consistent "path: message" errors.
|
|
func fieldErr(path, msg string) error {
|
|
return fmt.Errorf("%s: %s", path, msg)
|
|
}
|
|
|
|
// multiError collects many errors and returns them as one error.
|
|
// This makes config iteration much nicer: you fix several things per run.
|
|
type multiError struct {
|
|
errs []error
|
|
}
|
|
|
|
func (m *multiError) Add(err error) {
|
|
if err == nil {
|
|
return
|
|
}
|
|
m.errs = append(m.errs, err)
|
|
}
|
|
|
|
func (m *multiError) Err() error {
|
|
if len(m.errs) == 0 {
|
|
return nil
|
|
}
|
|
// Sort for stable output (useful in tests and when iterating).
|
|
sort.Slice(m.errs, func(i, j int) bool {
|
|
return m.errs[i].Error() < m.errs[j].Error()
|
|
})
|
|
return m
|
|
}
|
|
|
|
func (m *multiError) Error() string {
|
|
var b strings.Builder
|
|
b.WriteString("config validation failed:\n")
|
|
for _, e := range m.errs {
|
|
b.WriteString(" - ")
|
|
b.WriteString(e.Error())
|
|
b.WriteString("\n")
|
|
}
|
|
return strings.TrimRight(b.String(), "\n")
|
|
}
|