- Adopt an opinionated Event.ID policy across sources: - use upstream-provided ID when available - otherwise derive a stable ID from Source:EffectiveAt (RFC3339Nano, UTC) - fall back to Source:EmittedAt when EffectiveAt is unavailable - Add common/id helper to centralize ID selection logic and keep sources consistent - Simplify common event construction by collapsing SingleRawEventAt/SingleRawEvent into a single explicit SingleRawEvent helper (emittedAt passed in) - Update NWS/Open-Meteo/OpenWeather observation sources to: - compute EffectiveAt first - generate IDs via the shared helper - build envelopes via the unified SingleRawEvent helper - Improve determinism and dedupe-friendliness without changing schemas or payloads
95 lines
2.3 KiB
Go
95 lines
2.3 KiB
Go
// FILE: ./internal/sources/nws/observation.go
|
||
package nws
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"strings"
|
||
"time"
|
||
|
||
"gitea.maximumdirect.net/ejr/feedkit/config"
|
||
"gitea.maximumdirect.net/ejr/feedkit/event"
|
||
"gitea.maximumdirect.net/ejr/weatherfeeder/internal/sources/common"
|
||
"gitea.maximumdirect.net/ejr/weatherfeeder/internal/standards"
|
||
)
|
||
|
||
// ObservationSource polls an NWS station observation endpoint and emits a RAW observation Event.
|
||
type ObservationSource struct {
|
||
http *common.HTTPSource
|
||
}
|
||
|
||
func NewObservationSource(cfg config.SourceConfig) (*ObservationSource, error) {
|
||
const driver = "nws_observation"
|
||
|
||
hs, err := common.NewHTTPSource(driver, cfg, "application/geo+json, application/json")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return &ObservationSource{http: hs}, nil
|
||
}
|
||
|
||
func (s *ObservationSource) Name() string { return s.http.Name }
|
||
|
||
func (s *ObservationSource) Kind() event.Kind { return event.Kind("observation") }
|
||
|
||
func (s *ObservationSource) Poll(ctx context.Context) ([]event.Event, error) {
|
||
raw, meta, err := s.fetchRaw(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// EffectiveAt is optional; for observations it’s naturally the observation timestamp.
|
||
var effectiveAt *time.Time
|
||
if !meta.ParsedTimestamp.IsZero() {
|
||
t := meta.ParsedTimestamp.UTC()
|
||
effectiveAt = &t
|
||
}
|
||
|
||
emittedAt := time.Now().UTC()
|
||
eventID := common.ChooseEventID(meta.ID, s.http.Name, effectiveAt, emittedAt)
|
||
|
||
return common.SingleRawEvent(
|
||
s.Kind(),
|
||
s.http.Name,
|
||
standards.SchemaRawNWSObservationV1,
|
||
eventID,
|
||
emittedAt,
|
||
effectiveAt,
|
||
raw,
|
||
)
|
||
}
|
||
|
||
// ---- RAW fetch + minimal metadata decode ----
|
||
|
||
type observationMeta struct {
|
||
ID string `json:"id"`
|
||
Properties struct {
|
||
Timestamp string `json:"timestamp"`
|
||
} `json:"properties"`
|
||
|
||
ParsedTimestamp time.Time `json:"-"`
|
||
}
|
||
|
||
func (s *ObservationSource) fetchRaw(ctx context.Context) (json.RawMessage, observationMeta, error) {
|
||
raw, err := s.http.FetchJSON(ctx)
|
||
if err != nil {
|
||
return nil, observationMeta{}, err
|
||
}
|
||
|
||
var meta observationMeta
|
||
if err := json.Unmarshal(raw, &meta); err != nil {
|
||
// If metadata decode fails, still return raw; envelope will fall back to Source:EffectiveAt.
|
||
return raw, observationMeta{}, nil
|
||
}
|
||
|
||
tsStr := strings.TrimSpace(meta.Properties.Timestamp)
|
||
if tsStr != "" {
|
||
if t, err := time.Parse(time.RFC3339, tsStr); err == nil {
|
||
meta.ParsedTimestamp = t
|
||
}
|
||
}
|
||
|
||
return raw, meta, nil
|
||
}
|