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:
55
sinks/builtins.go
Normal file
55
sinks/builtins.go
Normal 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
30
sinks/file.go
Normal 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
37
sinks/postgres.go
Normal 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
42
sinks/rabbitmq.go
Normal 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
33
sinks/registry.go
Normal 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
16
sinks/sink.go
Normal 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
36
sinks/stdout.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user