Moved broadly useful helper functions upstream into feedkit
This commit is contained in:
145
sources/helpers.go
Normal file
145
sources/helpers.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package sources
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/ejr/feedkit/config"
|
||||
"gitea.maximumdirect.net/ejr/feedkit/event"
|
||||
)
|
||||
|
||||
// DefaultEventID applies feedkit's default Event.ID policy:
|
||||
//
|
||||
// - If upstream provides an ID, use it (trimmed).
|
||||
// - Otherwise, ID is "<Source>:<EffectiveAt>" when available.
|
||||
// - If EffectiveAt is unavailable, fall back to "<Source>:<EmittedAt>".
|
||||
//
|
||||
// Timestamps are encoded as RFC3339Nano in UTC.
|
||||
func DefaultEventID(upstreamID, sourceName string, effectiveAt *time.Time, emittedAt time.Time) string {
|
||||
if id := strings.TrimSpace(upstreamID); id != "" {
|
||||
return id
|
||||
}
|
||||
|
||||
src := strings.TrimSpace(sourceName)
|
||||
if src == "" {
|
||||
src = "UNKNOWN_SOURCE"
|
||||
}
|
||||
|
||||
if effectiveAt != nil && !effectiveAt.IsZero() {
|
||||
return fmt.Sprintf("%s:%s", src, effectiveAt.UTC().Format(time.RFC3339Nano))
|
||||
}
|
||||
|
||||
t := emittedAt.UTC()
|
||||
if t.IsZero() {
|
||||
t = time.Now().UTC()
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s:%s", src, t.Format(time.RFC3339Nano))
|
||||
}
|
||||
|
||||
// SingleEvent constructs, validates, and returns a slice containing exactly one event.
|
||||
func SingleEvent(
|
||||
kind event.Kind,
|
||||
sourceName string,
|
||||
schema string,
|
||||
id string,
|
||||
emittedAt time.Time,
|
||||
effectiveAt *time.Time,
|
||||
payload any,
|
||||
) ([]event.Event, error) {
|
||||
if emittedAt.IsZero() {
|
||||
emittedAt = time.Now().UTC()
|
||||
} else {
|
||||
emittedAt = emittedAt.UTC()
|
||||
}
|
||||
|
||||
e := event.Event{
|
||||
ID: id,
|
||||
Kind: kind,
|
||||
Source: sourceName,
|
||||
EmittedAt: emittedAt,
|
||||
EffectiveAt: effectiveAt,
|
||||
Schema: schema,
|
||||
Payload: payload,
|
||||
}
|
||||
|
||||
if err := e.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []event.Event{e}, nil
|
||||
}
|
||||
|
||||
// ValidateExpectedKinds checks that configured source expected kinds are a subset
|
||||
// of the kinds advertised by the built source, when the source exposes kind
|
||||
// metadata. If the source does not advertise kinds, the check is skipped.
|
||||
func ValidateExpectedKinds(cfg config.SourceConfig, in Input) error {
|
||||
expectedKinds, err := parseExpectedKinds(cfg.ExpectedKinds())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(expectedKinds) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
advertisedKinds := advertisedSourceKinds(in)
|
||||
if len(advertisedKinds) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for kind := range expectedKinds {
|
||||
if !advertisedKinds[kind] {
|
||||
return fmt.Errorf(
|
||||
"configured expected kind %q not advertised by source (configured=%v advertised=%v)",
|
||||
kind,
|
||||
sortedKinds(expectedKinds),
|
||||
sortedKinds(advertisedKinds),
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseExpectedKinds(raw []string) (map[event.Kind]bool, error) {
|
||||
kinds := map[event.Kind]bool{}
|
||||
for i, k := range raw {
|
||||
kind, err := event.ParseKind(k)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid expected kind at index %d (%q): %w", i, k, err)
|
||||
}
|
||||
kinds[kind] = true
|
||||
}
|
||||
return kinds, nil
|
||||
}
|
||||
|
||||
func advertisedSourceKinds(in Input) map[event.Kind]bool {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
kinds := map[event.Kind]bool{}
|
||||
if ks, ok := in.(KindsSource); ok {
|
||||
for _, kind := range ks.Kinds() {
|
||||
kinds[kind] = true
|
||||
}
|
||||
return kinds
|
||||
}
|
||||
|
||||
if ks, ok := in.(KindSource); ok {
|
||||
kinds[ks.Kind()] = true
|
||||
return kinds
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func sortedKinds(kindSet map[event.Kind]bool) []string {
|
||||
out := make([]string, 0, len(kindSet))
|
||||
for kind := range kindSet {
|
||||
out = append(out, string(kind))
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
131
sources/helpers_test.go
Normal file
131
sources/helpers_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package sources
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/ejr/feedkit/config"
|
||||
"gitea.maximumdirect.net/ejr/feedkit/event"
|
||||
)
|
||||
|
||||
type testInput struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (s testInput) Name() string { return s.name }
|
||||
|
||||
type testKindSource struct {
|
||||
testInput
|
||||
kind event.Kind
|
||||
}
|
||||
|
||||
func (s testKindSource) Kind() event.Kind { return s.kind }
|
||||
|
||||
type testKindsSource struct {
|
||||
testInput
|
||||
kinds []event.Kind
|
||||
}
|
||||
|
||||
func (s testKindsSource) Kinds() []event.Kind { return s.kinds }
|
||||
|
||||
func TestValidateExpectedKindsLegacyKindFallback(t *testing.T) {
|
||||
cfg := config.SourceConfig{Kind: "observation"}
|
||||
in := testKindSource{
|
||||
testInput: testInput{name: "test"},
|
||||
kind: event.Kind("observation"),
|
||||
}
|
||||
|
||||
if err := ValidateExpectedKinds(cfg, in); err != nil {
|
||||
t.Fatalf("ValidateExpectedKinds() unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateExpectedKindsSubsetAllowed(t *testing.T) {
|
||||
cfg := config.SourceConfig{Kinds: []string{"observation"}}
|
||||
in := testKindsSource{
|
||||
testInput: testInput{name: "test"},
|
||||
kinds: []event.Kind{"observation", "forecast"},
|
||||
}
|
||||
|
||||
if err := ValidateExpectedKinds(cfg, in); err != nil {
|
||||
t.Fatalf("ValidateExpectedKinds() unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateExpectedKindsMismatchFails(t *testing.T) {
|
||||
cfg := config.SourceConfig{Kinds: []string{"alert"}}
|
||||
in := testKindsSource{
|
||||
testInput: testInput{name: "test"},
|
||||
kinds: []event.Kind{"observation", "forecast"},
|
||||
}
|
||||
|
||||
err := ValidateExpectedKinds(cfg, in)
|
||||
if err == nil {
|
||||
t.Fatalf("ValidateExpectedKinds() expected mismatch error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "configured expected kind") {
|
||||
t.Fatalf("ValidateExpectedKinds() error %q does not include expected message", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateExpectedKindsNoMetadataSkipsCheck(t *testing.T) {
|
||||
cfg := config.SourceConfig{Kinds: []string{"alert"}}
|
||||
in := testInput{name: "test"}
|
||||
|
||||
if err := ValidateExpectedKinds(cfg, in); err != nil {
|
||||
t.Fatalf("ValidateExpectedKinds() unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultEventIDUsesUpstreamID(t *testing.T) {
|
||||
emittedAt := time.Date(2026, 3, 28, 15, 4, 5, 123, time.UTC)
|
||||
got := DefaultEventID(" upstream-id ", "source", nil, emittedAt)
|
||||
if got != "upstream-id" {
|
||||
t.Fatalf("DefaultEventID() = %q, want upstream-id", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultEventIDPrefersEffectiveAt(t *testing.T) {
|
||||
effectiveAt := time.Date(2026, 3, 28, 16, 4, 5, 987654321, time.FixedZone("x", -6*3600))
|
||||
emittedAt := time.Date(2026, 3, 28, 15, 4, 5, 123, time.UTC)
|
||||
|
||||
got := DefaultEventID("", "source", &effectiveAt, emittedAt)
|
||||
want := "source:" + effectiveAt.UTC().Format(time.RFC3339Nano)
|
||||
if got != want {
|
||||
t.Fatalf("DefaultEventID() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultEventIDFallsBackToEmittedAt(t *testing.T) {
|
||||
emittedAt := time.Date(2026, 3, 28, 15, 4, 5, 123456789, time.FixedZone("y", 3*3600))
|
||||
got := DefaultEventID("", "source", nil, emittedAt)
|
||||
want := "source:" + emittedAt.UTC().Format(time.RFC3339Nano)
|
||||
if got != want {
|
||||
t.Fatalf("DefaultEventID() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleEventBuildsValidatedSlice(t *testing.T) {
|
||||
effectiveAt := time.Date(2026, 3, 28, 16, 0, 0, 0, time.UTC)
|
||||
emittedAt := time.Date(2026, 3, 28, 15, 0, 0, 0, time.FixedZone("z", -5*3600))
|
||||
|
||||
got, err := SingleEvent(
|
||||
event.Kind("observation"),
|
||||
"source-a",
|
||||
"raw.example.v1",
|
||||
"evt-1",
|
||||
emittedAt,
|
||||
&effectiveAt,
|
||||
map[string]any{"ok": true},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("SingleEvent() unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("SingleEvent() len = %d, want 1", len(got))
|
||||
}
|
||||
if got[0].EmittedAt != emittedAt.UTC() {
|
||||
t.Fatalf("SingleEvent() emittedAt = %s, want %s", got[0].EmittedAt, emittedAt.UTC())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user