Implemented NWS weather stories support
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:
@@ -9,6 +9,7 @@ var builtins = []fknormalize.Normalizer{
|
||||
ObservationNormalizer{},
|
||||
ForecastNormalizer{},
|
||||
ForecastDiscussionNormalizer{},
|
||||
WeatherStoriesNormalizer{},
|
||||
AlertsNormalizer{},
|
||||
}
|
||||
|
||||
|
||||
@@ -262,6 +262,25 @@ type nwsAlertProperties struct {
|
||||
References json.RawMessage `json:"references"`
|
||||
}
|
||||
|
||||
// nwsWeatherStoriesResponse is a minimal representation of the NWS /weatherstories
|
||||
// payload needed for mapping into model.WeatherStoryRun.
|
||||
type nwsWeatherStoriesResponse struct {
|
||||
Stories []nwsWeatherStory `json:"stories"`
|
||||
}
|
||||
|
||||
type nwsWeatherStory struct {
|
||||
OfficeID string `json:"officeId"`
|
||||
StartTime string `json:"startTime"`
|
||||
EndTime string `json:"endTime"`
|
||||
UpdateTime string `json:"updateTime"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
AltText string `json:"altText"`
|
||||
Priority bool `json:"priority"`
|
||||
Order int `json:"order"`
|
||||
Download string `json:"download"`
|
||||
}
|
||||
|
||||
type nwsAlertReference struct {
|
||||
ID string `json:"id"`
|
||||
Identifier string `json:"identifier"`
|
||||
|
||||
107
internal/normalizers/nws/weatherstories.go
Normal file
107
internal/normalizers/nws/weatherstories.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package nws
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/ejr/feedkit/event"
|
||||
normcommon "gitea.maximumdirect.net/ejr/weatherfeeder/internal/normalizers/common"
|
||||
nwscommon "gitea.maximumdirect.net/ejr/weatherfeeder/internal/providers/nws"
|
||||
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||
"gitea.maximumdirect.net/ejr/weatherfeeder/standards"
|
||||
)
|
||||
|
||||
// WeatherStoriesNormalizer converts:
|
||||
//
|
||||
// standards.SchemaRawNWSWeatherStoriesV1 -> standards.SchemaWeatherStoryV1
|
||||
//
|
||||
// It maps the NWS /weatherstories JSON response into a canonical story snapshot.
|
||||
type WeatherStoriesNormalizer struct{}
|
||||
|
||||
func (WeatherStoriesNormalizer) Match(e event.Event) bool {
|
||||
return strings.TrimSpace(e.Schema) == standards.SchemaRawNWSWeatherStoriesV1
|
||||
}
|
||||
|
||||
func (WeatherStoriesNormalizer) Normalize(ctx context.Context, in event.Event) (*event.Event, error) {
|
||||
_ = ctx
|
||||
|
||||
fallbackAsOf := in.EmittedAt.UTC()
|
||||
if in.EffectiveAt != nil && !in.EffectiveAt.IsZero() {
|
||||
fallbackAsOf = in.EffectiveAt.UTC()
|
||||
}
|
||||
|
||||
return normcommon.NormalizeJSON(
|
||||
in,
|
||||
"nws weatherstories",
|
||||
standards.SchemaWeatherStoryV1,
|
||||
func(parsed nwsWeatherStoriesResponse) (model.WeatherStoryRun, time.Time, error) {
|
||||
return buildWeatherStories(parsed, fallbackAsOf)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func buildWeatherStories(parsed nwsWeatherStoriesResponse, fallbackAsOf time.Time) (model.WeatherStoryRun, time.Time, error) {
|
||||
stories := make([]model.WeatherStory, 0, len(parsed.Stories))
|
||||
|
||||
var officeID string
|
||||
var asOf time.Time
|
||||
for i, raw := range parsed.Stories {
|
||||
startTime, err := parseRequiredNWSTime(raw.StartTime, fmt.Sprintf("stories[%d].startTime", i))
|
||||
if err != nil {
|
||||
return model.WeatherStoryRun{}, time.Time{}, err
|
||||
}
|
||||
endTime, err := parseRequiredNWSTime(raw.EndTime, fmt.Sprintf("stories[%d].endTime", i))
|
||||
if err != nil {
|
||||
return model.WeatherStoryRun{}, time.Time{}, err
|
||||
}
|
||||
updatedAt, err := parseRequiredNWSTime(raw.UpdateTime, fmt.Sprintf("stories[%d].updateTime", i))
|
||||
if err != nil {
|
||||
return model.WeatherStoryRun{}, time.Time{}, err
|
||||
}
|
||||
|
||||
storyOfficeID := strings.TrimSpace(raw.OfficeID)
|
||||
if officeID == "" && storyOfficeID != "" {
|
||||
officeID = storyOfficeID
|
||||
}
|
||||
if asOf.IsZero() || updatedAt.After(asOf) {
|
||||
asOf = updatedAt
|
||||
}
|
||||
|
||||
stories = append(stories, model.WeatherStory{
|
||||
OfficeID: storyOfficeID,
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
UpdatedAt: updatedAt,
|
||||
Title: strings.TrimSpace(raw.Title),
|
||||
Description: strings.TrimSpace(raw.Description),
|
||||
AltText: strings.TrimSpace(raw.AltText),
|
||||
Priority: raw.Priority,
|
||||
Order: raw.Order,
|
||||
DownloadURL: strings.TrimSpace(raw.Download),
|
||||
})
|
||||
}
|
||||
|
||||
if asOf.IsZero() {
|
||||
asOf = fallbackAsOf.UTC()
|
||||
}
|
||||
|
||||
run := model.WeatherStoryRun{
|
||||
OfficeID: officeID,
|
||||
AsOf: asOf,
|
||||
Stories: stories,
|
||||
}
|
||||
return run, asOf, nil
|
||||
}
|
||||
|
||||
func parseRequiredNWSTime(raw, field string) (time.Time, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return time.Time{}, fmt.Errorf("%s is required", field)
|
||||
}
|
||||
t, err := nwscommon.ParseTime(raw)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("%s: %w", field, err)
|
||||
}
|
||||
return t.UTC(), nil
|
||||
}
|
||||
146
internal/normalizers/nws/weatherstories_test.go
Normal file
146
internal/normalizers/nws/weatherstories_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package nws
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/ejr/feedkit/event"
|
||||
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||
"gitea.maximumdirect.net/ejr/weatherfeeder/standards"
|
||||
)
|
||||
|
||||
func TestWeatherStoriesNormalizerProducesCanonicalSchemaAndMapsSample(t *testing.T) {
|
||||
out, err := (WeatherStoriesNormalizer{}).Normalize(nil, weatherStoriesRawEvent(weatherStoriesSamplePayload()))
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize() error = %v", err)
|
||||
}
|
||||
if out == nil {
|
||||
t.Fatalf("Normalize() returned nil output")
|
||||
}
|
||||
if out.Schema != standards.SchemaWeatherStoryV1 {
|
||||
t.Fatalf("Schema = %q, want %q", out.Schema, standards.SchemaWeatherStoryV1)
|
||||
}
|
||||
if out.Kind != event.Kind("weather_story") {
|
||||
t.Fatalf("Kind = %q, want weather_story", out.Kind)
|
||||
}
|
||||
|
||||
payload, ok := out.Payload.(model.WeatherStoryRun)
|
||||
if !ok {
|
||||
t.Fatalf("Payload type = %T, want model.WeatherStoryRun", out.Payload)
|
||||
}
|
||||
if payload.OfficeID != "LSX" {
|
||||
t.Fatalf("OfficeID = %q, want LSX", payload.OfficeID)
|
||||
}
|
||||
wantAsOf := time.Date(2026, 5, 30, 9, 0, 34, 0, time.UTC)
|
||||
if !payload.AsOf.Equal(wantAsOf) {
|
||||
t.Fatalf("AsOf = %s, want %s", payload.AsOf, wantAsOf)
|
||||
}
|
||||
if out.EffectiveAt == nil || !out.EffectiveAt.Equal(wantAsOf) {
|
||||
t.Fatalf("EffectiveAt = %v, want %s", out.EffectiveAt, wantAsOf)
|
||||
}
|
||||
if len(payload.Stories) != 1 {
|
||||
t.Fatalf("Stories len = %d, want 1", len(payload.Stories))
|
||||
}
|
||||
story := payload.Stories[0]
|
||||
if story.Title != "Several Chances for Rain Through Monday" {
|
||||
t.Fatalf("Title = %q", story.Title)
|
||||
}
|
||||
if story.Description != "A stagnant weather pattern." {
|
||||
t.Fatalf("Description = %q", story.Description)
|
||||
}
|
||||
if story.AltText != "This slide shows the forecast." {
|
||||
t.Fatalf("AltText = %q", story.AltText)
|
||||
}
|
||||
if story.Priority {
|
||||
t.Fatalf("Priority = true, want false")
|
||||
}
|
||||
if story.Order != 1 {
|
||||
t.Fatalf("Order = %d, want 1", story.Order)
|
||||
}
|
||||
if story.DownloadURL != "https://api.weather.gov/offices/LSX/weatherstories/download/3228e499-2aae-45a8-9ff9-1c060311026f" {
|
||||
t.Fatalf("DownloadURL = %q", story.DownloadURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeatherStoriesNormalizerEmptyStoriesUsesFallbackAsOf(t *testing.T) {
|
||||
effectiveAt := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
|
||||
in := weatherStoriesRawEvent(`{"stories":[]}`)
|
||||
in.EffectiveAt = &effectiveAt
|
||||
|
||||
out, err := (WeatherStoriesNormalizer{}).Normalize(nil, in)
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize() error = %v", err)
|
||||
}
|
||||
payload, ok := out.Payload.(model.WeatherStoryRun)
|
||||
if !ok {
|
||||
t.Fatalf("Payload type = %T, want model.WeatherStoryRun", out.Payload)
|
||||
}
|
||||
if !payload.AsOf.Equal(effectiveAt) {
|
||||
t.Fatalf("AsOf = %s, want fallback %s", payload.AsOf, effectiveAt)
|
||||
}
|
||||
if payload.Stories == nil {
|
||||
t.Fatalf("Stories = nil, want empty slice")
|
||||
}
|
||||
if len(payload.Stories) != 0 {
|
||||
t.Fatalf("Stories len = %d, want 0", len(payload.Stories))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeatherStoriesNormalizerRejectsInvalidRequiredStoryTime(t *testing.T) {
|
||||
_, err := (WeatherStoriesNormalizer{}).Normalize(nil, weatherStoriesRawEvent(`{
|
||||
"stories": [{
|
||||
"officeId": "LSX",
|
||||
"startTime": "bad",
|
||||
"endTime": "2026-05-31T11:00:00+00:00",
|
||||
"updateTime": "2026-05-30T09:00:34+00:00"
|
||||
}]
|
||||
}`))
|
||||
if err == nil {
|
||||
t.Fatalf("Normalize() error = nil, want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "stories[0].startTime") {
|
||||
t.Fatalf("error = %q, want field context", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeatherStoriesNormalizerMatch(t *testing.T) {
|
||||
n := WeatherStoriesNormalizer{}
|
||||
if !n.Match(event.Event{Schema: standards.SchemaRawNWSWeatherStoriesV1}) {
|
||||
t.Fatalf("Match(raw weatherstories) = false, want true")
|
||||
}
|
||||
if n.Match(event.Event{Schema: standards.SchemaRawNWSAlertsV1}) {
|
||||
t.Fatalf("Match(raw alerts) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func weatherStoriesRawEvent(payload string) event.Event {
|
||||
return event.Event{
|
||||
ID: "evt-weatherstories-1",
|
||||
Kind: event.Kind("weather_story"),
|
||||
Source: "nws-weatherstories-test",
|
||||
EmittedAt: time.Date(2026, 5, 30, 9, 5, 0, 0, time.UTC),
|
||||
Schema: standards.SchemaRawNWSWeatherStoriesV1,
|
||||
Payload: json.RawMessage(payload),
|
||||
}
|
||||
}
|
||||
|
||||
func weatherStoriesSamplePayload() string {
|
||||
return `{
|
||||
"stories": [
|
||||
{
|
||||
"officeId": " LSX ",
|
||||
"startTime": "2026-05-30T08:46:00+00:00",
|
||||
"endTime": "2026-05-31T11:00:00+00:00",
|
||||
"updateTime": "2026-05-30T09:00:34+00:00",
|
||||
"title": " Several Chances for Rain Through Monday ",
|
||||
"description": " A stagnant weather pattern. ",
|
||||
"altText": " This slide shows the forecast. ",
|
||||
"priority": false,
|
||||
"order": 1,
|
||||
"download": " https://api.weather.gov/offices/LSX/weatherstories/download/3228e499-2aae-45a8-9ff9-1c060311026f "
|
||||
}
|
||||
]
|
||||
}`
|
||||
}
|
||||
Reference in New Issue
Block a user