Updates to the nws forecast source and normalizer to separate code specific to hourly forecasts and prepare for upcoming feature addition of daily and narrative forecasts
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
This commit is contained in:
@@ -19,8 +19,8 @@ func RegisterBuiltins(r *fksource.Registry) {
|
||||
r.RegisterPoll("nws_alerts", func(cfg config.SourceConfig) (fksource.PollSource, error) {
|
||||
return nws.NewAlertsSource(cfg)
|
||||
})
|
||||
r.RegisterPoll("nws_forecast", func(cfg config.SourceConfig) (fksource.PollSource, error) {
|
||||
return nws.NewForecastSource(cfg)
|
||||
r.RegisterPoll("nws_forecast_hourly", func(cfg config.SourceConfig) (fksource.PollSource, error) {
|
||||
return nws.NewHourlyForecastSource(cfg)
|
||||
})
|
||||
|
||||
// Open-Meteo drivers
|
||||
|
||||
47
internal/sources/builtins_test.go
Normal file
47
internal/sources/builtins_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package sources
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/ejr/feedkit/config"
|
||||
fksource "gitea.maximumdirect.net/ejr/feedkit/sources"
|
||||
)
|
||||
|
||||
func TestRegisterBuiltinsRegistersNWSHourlyForecastDriver(t *testing.T) {
|
||||
reg := fksource.NewRegistry()
|
||||
RegisterBuiltins(reg)
|
||||
|
||||
in, err := reg.BuildInput(sourceConfigForDriver("nws_forecast_hourly"))
|
||||
if err != nil {
|
||||
t.Fatalf("BuildInput(nws_forecast_hourly) error = %v", err)
|
||||
}
|
||||
if _, ok := in.(fksource.PollSource); !ok {
|
||||
t.Fatalf("BuildInput(nws_forecast_hourly) type = %T, want PollSource", in)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterBuiltinsDoesNotRegisterLegacyNWSForecastDriver(t *testing.T) {
|
||||
reg := fksource.NewRegistry()
|
||||
RegisterBuiltins(reg)
|
||||
|
||||
_, err := reg.BuildInput(sourceConfigForDriver("nws_forecast"))
|
||||
if err == nil {
|
||||
t.Fatalf("BuildInput(nws_forecast) expected unknown driver error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `unknown source driver: "nws_forecast"`) {
|
||||
t.Fatalf("error = %q, want unknown source driver for nws_forecast", err)
|
||||
}
|
||||
}
|
||||
|
||||
func sourceConfigForDriver(driver string) config.SourceConfig {
|
||||
return config.SourceConfig{
|
||||
Name: "test-source",
|
||||
Driver: driver,
|
||||
Mode: config.SourceModePoll,
|
||||
Params: map[string]any{
|
||||
"url": "https://example.invalid",
|
||||
"user_agent": "test-agent",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// FILE: internal/sources/nws/forecast.go
|
||||
// FILE: internal/sources/nws/forecast_hourly.go
|
||||
package nws
|
||||
|
||||
import (
|
||||
@@ -14,19 +14,19 @@ import (
|
||||
"gitea.maximumdirect.net/ejr/weatherfeeder/standards"
|
||||
)
|
||||
|
||||
// ForecastSource polls an NWS forecast endpoint (narrative or hourly) and emits a RAW forecast Event.
|
||||
// HourlyForecastSource polls an NWS hourly forecast endpoint and emits a RAW forecast Event.
|
||||
//
|
||||
// It intentionally emits the *entire* upstream payload as json.RawMessage and only decodes
|
||||
// minimal metadata for Event.EffectiveAt and Event.ID.
|
||||
//
|
||||
// Output schema (current implementation):
|
||||
// - standards.SchemaRawNWSHourlyForecastV1
|
||||
type ForecastSource struct {
|
||||
type HourlyForecastSource struct {
|
||||
http *common.HTTPSource
|
||||
}
|
||||
|
||||
func NewForecastSource(cfg config.SourceConfig) (*ForecastSource, error) {
|
||||
const driver = "nws_forecast"
|
||||
func NewHourlyForecastSource(cfg config.SourceConfig) (*HourlyForecastSource, error) {
|
||||
const driver = "nws_forecast_hourly"
|
||||
|
||||
// NWS forecast endpoints are GeoJSON (and sometimes also advertise json-ld/json).
|
||||
hs, err := common.NewHTTPSource(driver, cfg, "application/geo+json, application/json")
|
||||
@@ -34,15 +34,15 @@ func NewForecastSource(cfg config.SourceConfig) (*ForecastSource, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ForecastSource{http: hs}, nil
|
||||
return &HourlyForecastSource{http: hs}, nil
|
||||
}
|
||||
|
||||
func (s *ForecastSource) Name() string { return s.http.Name }
|
||||
func (s *HourlyForecastSource) Name() string { return s.http.Name }
|
||||
|
||||
// Kind is used for routing/policy.
|
||||
func (s *ForecastSource) Kind() event.Kind { return event.Kind("forecast") }
|
||||
func (s *HourlyForecastSource) Kind() event.Kind { return event.Kind("forecast") }
|
||||
|
||||
func (s *ForecastSource) Poll(ctx context.Context) ([]event.Event, error) {
|
||||
func (s *HourlyForecastSource) Poll(ctx context.Context) ([]event.Event, error) {
|
||||
raw, meta, err := s.fetchRaw(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -80,7 +80,7 @@ func (s *ForecastSource) Poll(ctx context.Context) ([]event.Event, error) {
|
||||
|
||||
// ---- RAW fetch + minimal metadata decode ----
|
||||
|
||||
type forecastMeta struct {
|
||||
type hourlyForecastMeta struct {
|
||||
// Present for GeoJSON Feature responses, but often stable (endpoint URL).
|
||||
ID string `json:"id"`
|
||||
|
||||
@@ -94,16 +94,16 @@ type forecastMeta struct {
|
||||
ParsedUpdateTime time.Time `json:"-"`
|
||||
}
|
||||
|
||||
func (s *ForecastSource) fetchRaw(ctx context.Context) (json.RawMessage, forecastMeta, error) {
|
||||
func (s *HourlyForecastSource) fetchRaw(ctx context.Context) (json.RawMessage, hourlyForecastMeta, error) {
|
||||
raw, err := s.http.FetchJSON(ctx)
|
||||
if err != nil {
|
||||
return nil, forecastMeta{}, err
|
||||
return nil, hourlyForecastMeta{}, err
|
||||
}
|
||||
|
||||
var meta forecastMeta
|
||||
var meta hourlyForecastMeta
|
||||
if err := json.Unmarshal(raw, &meta); err != nil {
|
||||
// If metadata decode fails, still return raw; Poll will fall back to Source:EmittedAt.
|
||||
return raw, forecastMeta{}, nil
|
||||
return raw, hourlyForecastMeta{}, nil
|
||||
}
|
||||
|
||||
// generatedAt (preferred)
|
||||
Reference in New Issue
Block a user