feat(feedkit): add optional normalization hook and document external API
Introduce an optional normalization stage for feedkit pipelines via the new normalize package. This adds: - normalize.Normalizer interface with flexible Match() semantics - normalize.Registry for ordered normalizer selection (first match wins) - normalize.Processor adapter implementing pipeline.Processor - Pass-through behavior when no normalizer matches (normalization is optional) - Func helper for ergonomic normalizer definitions Update root doc.go to fully document the normalization model, its role in the pipeline, recommended conventions (Schema-based matching, raw vs normalized events), and concrete wiring examples. The documentation now serves as a complete external-facing API specification for downstream daemons such as weatherfeeder. This change preserves feedkit’s non-framework philosophy while enabling a clean separation between data collection and domain normalization.
This commit is contained in:
140
normalize/registry.go
Normal file
140
normalize/registry.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package normalize
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"gitea.maximumdirect.net/ejr/feedkit/event"
|
||||
)
|
||||
|
||||
// Registry holds a set of Normalizers and selects one for a given event.
|
||||
//
|
||||
// Selection rule (simple + predictable):
|
||||
// - iterate in registration order
|
||||
// - the FIRST Normalizer whose Match(e) returns true is used
|
||||
//
|
||||
// If none match, the event passes through unchanged.
|
||||
//
|
||||
// Why "first match wins"?
|
||||
// Normalization is usually a single mapping step from a raw schema/version into
|
||||
// a normalized schema/version. If you want multiple transformation steps,
|
||||
// model them as multiple pipeline processors (which feedkit already supports).
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
ns []Normalizer
|
||||
}
|
||||
|
||||
// Register adds a normalizer to the registry.
|
||||
//
|
||||
// Register panics if n is nil; this is a programmer error and should fail fast.
|
||||
func (r *Registry) Register(n Normalizer) {
|
||||
if n == nil {
|
||||
panic("normalize.Registry.Register: normalizer cannot be nil")
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.ns = append(r.ns, n)
|
||||
}
|
||||
|
||||
// Normalize finds the first matching Normalizer and applies it.
|
||||
//
|
||||
// If no normalizer matches, it returns the input event unchanged.
|
||||
//
|
||||
// If a normalizer returns (nil, nil), the event is dropped.
|
||||
func (r *Registry) Normalize(ctx context.Context, in event.Event) (*event.Event, error) {
|
||||
if r == nil {
|
||||
// Nil registry is a valid "feature off" state.
|
||||
out := in
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
r.mu.RLock()
|
||||
ns := append([]Normalizer(nil), r.ns...) // copy for safe iteration outside lock
|
||||
r.mu.RUnlock()
|
||||
|
||||
for _, n := range ns {
|
||||
if n == nil {
|
||||
// Shouldn't happen (Register panics), but guard anyway.
|
||||
continue
|
||||
}
|
||||
if !n.Match(in) {
|
||||
continue
|
||||
}
|
||||
|
||||
out, err := n.Normalize(ctx, in)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("normalize: normalizer failed: %w", err)
|
||||
}
|
||||
// out may be nil to signal "drop".
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// No match: pass through unchanged.
|
||||
out := in
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// Processor adapts a Registry into a pipeline Processor.
|
||||
//
|
||||
// It implements:
|
||||
//
|
||||
// Process(ctx context.Context, in event.Event) (*event.Event, error)
|
||||
//
|
||||
// which matches feedkit/pipeline.Processor.
|
||||
//
|
||||
// Optionality:
|
||||
// - If Registry is nil, Processor becomes a no-op pass-through.
|
||||
// - If Registry has no matching normalizer for an event, that event passes through unchanged.
|
||||
type Processor struct {
|
||||
Registry *Registry
|
||||
|
||||
// If true, events that do not match any normalizer cause an error.
|
||||
// Default is false (pass-through), which is the behavior you asked for.
|
||||
RequireMatch bool
|
||||
}
|
||||
|
||||
// Process implements the pipeline.Processor interface.
|
||||
func (p Processor) Process(ctx context.Context, in event.Event) (*event.Event, error) {
|
||||
// "Feature off": no registry means no normalization.
|
||||
if p.Registry == nil {
|
||||
out := in
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
out, err := p.Registry.Normalize(ctx, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if out == nil {
|
||||
// Dropped by normalization policy.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if p.RequireMatch {
|
||||
// Detect "no-op pass-through due to no match" by checking whether a match existed.
|
||||
// We do this with a cheap second pass to avoid changing Normalize()'s signature.
|
||||
// (This is rare to enable; correctness/clarity > micro-optimization.)
|
||||
if !p.Registry.hasMatch(in) {
|
||||
return nil, fmt.Errorf("normalize: no normalizer matched event (id=%s kind=%s source=%s schema=%q)",
|
||||
in.ID, in.Kind, in.Source, in.Schema)
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Registry) hasMatch(in event.Event) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
for _, n := range r.ns {
|
||||
if n != nil && n.Match(in) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user