- 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").
131 lines
3.8 KiB
Go
131 lines
3.8 KiB
Go
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 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)
|
||
}
|