Files
feedkit/config/config.go
Eric Rakestraw 09bc65e947 feedkit: ergonomics pass (shared logger, route compiler, param helpers)
- Add logging.Logf as the canonical printf-style logger type used across feedkit.
  - Update scheduler and dispatch to alias their Logger types to logging.Logf.
  - Eliminates type-mismatch friction when wiring one log function through the system.

- Add dispatch.CompileRoutes(*config.Config) ([]dispatch.Route, error)
  - Compiles config routes into dispatch routes with event.ParseKind normalization.
  - If routes: is omitted, defaults to “all sinks receive all kinds”.

- Expand config param helpers for both SourceConfig and SinkConfig
  - Add ParamBool/ParamInt/ParamDuration/ParamStringSlice (+ Default variants).
  - Supports common YAML-decoded types (bool/int/float/string, []any, etc.)
  - Keeps driver code cleaner and reduces repeated type assertions.

- Fix Postgres sink validation error prefix ("postgres sink", not "rabbitmq sink").
2026-01-13 14:40:29 -06:00

131 lines
3.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
// Whether a given daemon "recognizes" a kind is domain-specific validation.
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 its 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)
}