feedkit: split the former maximumdirect.net/weatherd project in two.

feedkit now contains a reusable core, while weatherfeeder is a concrete implementation that includes weather-specific functions.
This commit is contained in:
2026-01-13 10:40:01 -06:00
parent 977b81e647
commit 0cc2862170
26 changed files with 1553 additions and 0 deletions

55
sinks/builtins.go Normal file
View File

@@ -0,0 +1,55 @@
package sinks
import (
"fmt"
"strings"
"gitea.maximumdirect.net/ejr/feedkit/config"
)
// RegisterBuiltins registers sink drivers included in this binary.
//
// In feedkit, these are "infrastructure primitives" — they are not domain-specific.
// Individual daemons can choose to call this (or register their own custom sinks).
func RegisterBuiltins(r *Registry) {
// Stdout sink: great for debugging, piping to jq, etc.
r.Register("stdout", func(cfg config.SinkConfig) (Sink, error) {
return NewStdoutSink(cfg.Name), nil
})
// File sink: writes/archives events somewhere on disk.
r.Register("file", func(cfg config.SinkConfig) (Sink, error) {
return NewFileSinkFromConfig(cfg)
})
// Postgres sink: persists events durably.
r.Register("postgres", func(cfg config.SinkConfig) (Sink, error) {
return NewPostgresSinkFromConfig(cfg)
})
// RabbitMQ sink: publishes events to a broker for downstream consumers.
r.Register("rabbitmq", func(cfg config.SinkConfig) (Sink, error) {
return NewRabbitMQSinkFromConfig(cfg)
})
}
// ---- helpers for validating sink params ----
//
// These helpers live in sinks (not config) on purpose:
// - config is domain-agnostic and should not embed driver-specific validation helpers.
// - sinks are adapters; validating their own params here keeps the logic near the driver.
func requireStringParam(cfg config.SinkConfig, key string) (string, error) {
v, ok := cfg.Params[key]
if !ok {
return "", fmt.Errorf("sink %q: params.%s is required", cfg.Name, key)
}
s, ok := v.(string)
if !ok {
return "", fmt.Errorf("sink %q: params.%s must be a string", cfg.Name, key)
}
if strings.TrimSpace(s) == "" {
return "", fmt.Errorf("sink %q: params.%s cannot be empty", cfg.Name, key)
}
return s, nil
}

30
sinks/file.go Normal file
View File

@@ -0,0 +1,30 @@
package sinks
import (
"context"
"fmt"
"gitea.maximumdirect.net/ejr/feedkit/config"
"gitea.maximumdirect.net/ejr/feedkit/event"
)
type FileSink struct {
name string
path string
}
func NewFileSinkFromConfig(cfg config.SinkConfig) (Sink, error) {
path, err := requireStringParam(cfg, "path")
if err != nil {
return nil, err
}
return &FileSink{name: cfg.Name, path: path}, nil
}
func (s *FileSink) Name() string { return s.name }
func (s *FileSink) Consume(ctx context.Context, e event.Event) error {
_ = ctx
_ = e
return fmt.Errorf("file sink: TODO implement (path=%s)", s.path)
}

37
sinks/postgres.go Normal file
View File

@@ -0,0 +1,37 @@
package sinks
import (
"context"
"fmt"
"gitea.maximumdirect.net/ejr/feedkit/config"
"gitea.maximumdirect.net/ejr/feedkit/event"
)
type PostgresSink struct {
name string
dsn string
}
func NewPostgresSinkFromConfig(cfg config.SinkConfig) (Sink, error) {
dsn, err := requireStringParam(cfg, "dsn")
if err != nil {
return nil, err
}
return &PostgresSink{name: cfg.Name, dsn: dsn}, nil
}
func (p *PostgresSink) Name() string { return p.name }
func (p *PostgresSink) Consume(ctx context.Context, e event.Event) error {
_ = ctx
// Boundary validation: if something upstream violated invariants,
// surface it loudly rather than printing partial nonsense.
if err := e.Validate(); err != nil {
return fmt.Errorf("rabbitmq sink: invalid event: %w", err)
}
// TODO implement Postgres transaction
return nil
}

42
sinks/rabbitmq.go Normal file
View File

@@ -0,0 +1,42 @@
package sinks
import (
"context"
"fmt"
"gitea.maximumdirect.net/ejr/feedkit/config"
"gitea.maximumdirect.net/ejr/feedkit/event"
)
type RabbitMQSink struct {
name string
url string
exchange string
}
func NewRabbitMQSinkFromConfig(cfg config.SinkConfig) (Sink, error) {
url, err := requireStringParam(cfg, "url")
if err != nil {
return nil, err
}
ex, err := requireStringParam(cfg, "exchange")
if err != nil {
return nil, err
}
return &RabbitMQSink{name: cfg.Name, url: url, exchange: ex}, nil
}
func (r *RabbitMQSink) Name() string { return r.name }
func (r *RabbitMQSink) Consume(ctx context.Context, e event.Event) error {
_ = ctx
// Boundary validation: if something upstream violated invariants,
// surface it loudly rather than printing partial nonsense.
if err := e.Validate(); err != nil {
return fmt.Errorf("rabbitmq sink: invalid event: %w", err)
}
// TODO implement RabbitMQ publishing
return nil
}

33
sinks/registry.go Normal file
View File

@@ -0,0 +1,33 @@
package sinks
import (
"fmt"
"gitea.maximumdirect.net/ejr/feedkit/config"
)
// Factory constructs a sink instance from config.
//
// This is the mechanism that lets concrete daemons wire in whatever sinks they
// want without main.go being full of switch statements.
type Factory func(cfg config.SinkConfig) (Sink, error)
type Registry struct {
byDriver map[string]Factory
}
func NewRegistry() *Registry {
return &Registry{byDriver: map[string]Factory{}}
}
func (r *Registry) Register(driver string, f Factory) {
r.byDriver[driver] = f
}
func (r *Registry) Build(cfg config.SinkConfig) (Sink, error) {
f, ok := r.byDriver[cfg.Driver]
if !ok {
return nil, fmt.Errorf("unknown sink driver: %q", cfg.Driver)
}
return f(cfg)
}

16
sinks/sink.go Normal file
View File

@@ -0,0 +1,16 @@
package sinks
import (
"context"
"gitea.maximumdirect.net/ejr/feedkit/event"
)
// Sink is an adapter that consumes a stream of domain-agnostic events.
//
// Sinks MUST respect ctx.Done() whenever they do I/O or blocking work.
// (Fanout timeouts only help if the sink cooperates with context cancellation.)
type Sink interface {
Name() string
Consume(ctx context.Context, e event.Event) error
}

36
sinks/stdout.go Normal file
View File

@@ -0,0 +1,36 @@
package sinks
import (
"context"
"encoding/json"
"fmt"
"gitea.maximumdirect.net/ejr/feedkit/event"
)
type StdoutSink struct{ name string }
func NewStdoutSink(name string) *StdoutSink {
return &StdoutSink{name: name}
}
func (s *StdoutSink) Name() string { return s.name }
func (s *StdoutSink) Consume(ctx context.Context, e event.Event) error {
_ = ctx
// Boundary validation: if something upstream violated invariants,
// surface it loudly rather than printing partial nonsense.
if err := e.Validate(); err != nil {
return fmt.Errorf("stdout sink: invalid event: %w", err)
}
// Generic default: one JSON line per event.
// This makes stdout useful across all domains and easy to pipe into jq / logs.
b, err := json.Marshal(e)
if err != nil {
return fmt.Errorf("stdout sink: marshal event: %w", err)
}
fmt.Println(string(b))
return nil
}