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:
8
event/doc.go
Normal file
8
event/doc.go
Normal file
@@ -0,0 +1,8 @@
|
||||
// Package event defines the domain-agnostic event envelope used by feedkit-style daemons.
|
||||
//
|
||||
// The core idea:
|
||||
// - feedkit infrastructure moves "events" around without knowing anything about the domain.
|
||||
// - domain-specific code (weatherfeeder, newsfeeder, etc.) provides a concrete Payload.
|
||||
//
|
||||
// This package should NOT import any domain packages (weather model types, etc.).
|
||||
package event
|
||||
101
event/event.go
Normal file
101
event/event.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrInvalidEvent is a sentinel error so callers can do:
|
||||
//
|
||||
// if errors.Is(err, event.ErrInvalidEvent) { ... }
|
||||
var ErrInvalidEvent = errors.New("invalid event")
|
||||
|
||||
// ValidationError reports one or more problems with an Event.
|
||||
//
|
||||
// We keep this structured so you get ALL issues in one pass rather than fixing
|
||||
// them one-by-one.
|
||||
type ValidationError struct {
|
||||
Problems []string
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
if e == nil || len(e.Problems) == 0 {
|
||||
return "invalid event"
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("invalid event:\n")
|
||||
for _, p := range e.Problems {
|
||||
b.WriteString(" - ")
|
||||
b.WriteString(p)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
// Is lets errors.Is(err, ErrInvalidEvent) work.
|
||||
func (e *ValidationError) Is(target error) bool {
|
||||
return target == ErrInvalidEvent
|
||||
}
|
||||
|
||||
// Event is the domain-agnostic envelope moved through scheduler → pipeline → dispatcher → sinks.
|
||||
//
|
||||
// Payload is intentionally "any":
|
||||
// - domain code can set it to a struct (recommended), a map, or some other JSON-marshable type.
|
||||
// - feedkit infrastructure never type-asserts Payload.
|
||||
//
|
||||
// If you plan to persist/emit events as JSON, ensure Payload is JSON-marshable.
|
||||
type Event struct {
|
||||
// ID should be stable for dedupe/storage purposes.
|
||||
// Often: "<provider>:<source>:<upstream-id-or-timestamp>".
|
||||
ID string `json:"id"`
|
||||
|
||||
// Kind is used for routing/policy.
|
||||
Kind Kind `json:"kind"`
|
||||
|
||||
// Source is the configured source name (e.g. "OpenMeteoObservation", "NWSAlertsSTL").
|
||||
Source string `json:"source"`
|
||||
|
||||
// EmittedAt is when *your daemon* emitted this event (typically time.Now().UTC()).
|
||||
EmittedAt time.Time `json:"emitted_at"`
|
||||
|
||||
// EffectiveAt is optional: the timestamp the payload is "about" (e.g. observation time).
|
||||
EffectiveAt *time.Time `json:"effective_at,omitempty"`
|
||||
|
||||
// Schema is optional but strongly recommended once multiple domains exist.
|
||||
// Examples:
|
||||
// "weather.observation.v1"
|
||||
// "news.article.v1"
|
||||
// It helps sinks and downstream consumers interpret Payload.
|
||||
Schema string `json:"schema,omitempty"`
|
||||
|
||||
// Payload is domain-defined and must be non-nil.
|
||||
Payload any `json:"payload"`
|
||||
}
|
||||
|
||||
// Validate enforces basic invariants that infrastructure depends on.
|
||||
// Domain-specific validation belongs in domain code (or domain processors).
|
||||
func (e Event) Validate() error {
|
||||
var problems []string
|
||||
|
||||
if strings.TrimSpace(e.ID) == "" {
|
||||
problems = append(problems, "ID is required")
|
||||
}
|
||||
if strings.TrimSpace(string(e.Kind)) == "" {
|
||||
problems = append(problems, "Kind is required")
|
||||
}
|
||||
if strings.TrimSpace(e.Source) == "" {
|
||||
problems = append(problems, "Source is required")
|
||||
}
|
||||
if e.EmittedAt.IsZero() {
|
||||
problems = append(problems, "EmittedAt must be set (non-zero)")
|
||||
}
|
||||
if e.Payload == nil {
|
||||
problems = append(problems, "Payload must be non-nil")
|
||||
}
|
||||
|
||||
if len(problems) > 0 {
|
||||
return &ValidationError{Problems: problems}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
30
event/kind.go
Normal file
30
event/kind.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Kind identifies the "type/category" of an event for routing and policy decisions.
|
||||
//
|
||||
// Kind is intentionally open-ended (stringly-typed), because different daemons will
|
||||
// have different kinds:
|
||||
//
|
||||
// weatherfeeder: "observation", "forecast", "alert"
|
||||
// newsfeeder: "article", "breaking", ...
|
||||
// stockfeeder: "quote", "bar", "news", ...
|
||||
//
|
||||
// Conventions (recommended, not required):
|
||||
// - lowercase
|
||||
// - words separated by underscores if needed
|
||||
type Kind string
|
||||
|
||||
// ParseKind normalizes and validates a kind string.
|
||||
// It lowercases and trims whitespace, and rejects empty values.
|
||||
func ParseKind(s string) (Kind, error) {
|
||||
k := strings.ToLower(strings.TrimSpace(s))
|
||||
if k == "" {
|
||||
return "", fmt.Errorf("kind cannot be empty")
|
||||
}
|
||||
return Kind(k), nil
|
||||
}
|
||||
Reference in New Issue
Block a user