From bb79232e3e62750f26bf4893430c81f3fdb1e561 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 15 Jun 2026 12:44:57 +0000 Subject: [PATCH] Simplify Weather API source fetching --- internal/adapters/weatherapi/client.go | 176 ++++++++++++-------- internal/adapters/weatherapi/client_test.go | 18 ++ 2 files changed, 127 insertions(+), 67 deletions(-) diff --git a/internal/adapters/weatherapi/client.go b/internal/adapters/weatherapi/client.go index d59f630..70261f1 100644 --- a/internal/adapters/weatherapi/client.go +++ b/internal/adapters/weatherapi/client.go @@ -130,18 +130,32 @@ type bundleBuilder struct { fetchedAt time.Time } +type sourceRequest struct { + name string + endpoint string + query queryOptions + missingMessage string + required bool + decodeLabel string +} + +type fetchedSource struct { + raw json.RawMessage + source weatherdata.Source +} + func (b *bundleBuilder) fetchObservation(ctx context.Context) error { - raw, source, err := b.client.fetch(ctx, "observations", "/observations", queryOptions{precision: true}) - if err != nil { + var observation weatherdata.Observation + fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{ + name: "observations", + endpoint: "/observations", + query: queryOptions{precision: true}, + missingMessage: "observation data is missing", + }, &observation) + if err != nil || !ok { return err } - if raw == nil { - return b.handleMissing(&source, "observation data is missing", false) - } - var observation weatherdata.Observation - if err := decodeSource(raw, &observation); err != nil { - return b.handleMalformed(&source, err, false) - } + source := fetched.source source.IssuedAt = &observation.Timestamp b.bundle.Observation = &observation b.addSource(source) @@ -149,34 +163,36 @@ func (b *bundleBuilder) fetchObservation(ctx context.Context) error { } func (b *bundleBuilder) fetchCurrent(ctx context.Context) error { - raw, source, err := b.client.fetch(ctx, "current", "/conditions/current", queryOptions{precision: true}) - if err != nil { + var current weatherdata.Current + fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{ + name: "current", + endpoint: "/conditions/current", + query: queryOptions{precision: true}, + missingMessage: "current conditions data is missing", + }, ¤t) + if err != nil || !ok { return err } - if raw == nil { - return b.handleMissing(&source, "current conditions data is missing", false) - } - var current weatherdata.Current - if err := decodeSource(raw, ¤t); err != nil { - return b.handleMalformed(&source, err, false) - } + source := fetched.source b.bundle.Current = ¤t b.addSource(source) return nil } func (b *bundleBuilder) fetchHourly(ctx context.Context) error { - raw, source, err := b.client.fetch(ctx, "hourly", "/forecast/hourly", queryOptions{precision: true, timezone: true}) - if err != nil { + var hourly weatherdata.ForecastRun + fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{ + name: "hourly", + endpoint: "/forecast/hourly", + query: queryOptions{precision: true, timezone: true}, + missingMessage: "hourly forecast data is missing", + required: true, + decodeLabel: "hourly forecast", + }, &hourly) + if err != nil || !ok { return err } - if raw == nil { - return b.handleMissing(&source, "hourly forecast data is missing", true) - } - var hourly weatherdata.ForecastRun - if err := decodeSource(raw, &hourly); err != nil { - return fmt.Errorf("decode hourly forecast from %s: %w", source.Endpoint, err) - } + source := fetched.source if len(hourly.Periods) == 0 { return fmt.Errorf("hourly forecast from %s contains no periods", source.Endpoint) } @@ -188,17 +204,17 @@ func (b *bundleBuilder) fetchHourly(ctx context.Context) error { } func (b *bundleBuilder) fetchNarrative(ctx context.Context) error { - raw, source, err := b.client.fetch(ctx, "narrative", "/forecast/narrative", queryOptions{precision: true, timezone: true}) - if err != nil { + var narrative weatherdata.ForecastRun + fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{ + name: "narrative", + endpoint: "/forecast/narrative", + query: queryOptions{precision: true, timezone: true}, + missingMessage: "narrative forecast data is missing", + }, &narrative) + if err != nil || !ok { return err } - if raw == nil { - return b.handleMissing(&source, "narrative forecast data is missing", false) - } - var narrative weatherdata.ForecastRun - if err := decodeSource(raw, &narrative); err != nil { - return b.handleMalformed(&source, err, false) - } + source := fetched.source source.IssuedAt = &narrative.IssuedAt source.UpdatedAt = narrative.UpdatedAt b.bundle.Narrative = &narrative @@ -221,7 +237,7 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error { } var alerts weatherdata.AlertRun if err := decodeSource(raw, &alerts); err != nil { - return b.handleMalformed(&source, err, false) + return b.handleMalformed(&source, err, sourceRequest{name: "alerts"}) } alerts.Raw = append(json.RawMessage(nil), raw...) if alerts.AsOf != nil { @@ -233,17 +249,17 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error { } func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error { - raw, source, err := b.client.fetch(ctx, "discussion", "/discussion", queryOptions{timezone: true}) - if err != nil { + var discussion weatherdata.Discussion + fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{ + name: "discussion", + endpoint: "/discussion", + query: queryOptions{timezone: true}, + missingMessage: "forecast discussion data is missing", + }, &discussion) + if err != nil || !ok { return err } - if raw == nil { - return b.handleMissing(&source, "forecast discussion data is missing", false) - } - var discussion weatherdata.Discussion - if err := decodeSource(raw, &discussion); err != nil { - return b.handleMalformed(&source, err, false) - } + source := fetched.source source.IssuedAt = &discussion.IssuedAt source.UpdatedAt = discussion.UpdatedAt b.bundle.Discussion = &discussion @@ -252,17 +268,17 @@ func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error { } func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error { - raw, source, err := b.client.fetch(ctx, "weather_story", "/weatherstories/latest", queryOptions{omitUnits: true}) - if err != nil { + var story weatherdata.WeatherStory + fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{ + name: "weather_story", + endpoint: "/weatherstories/latest", + query: queryOptions{omitUnits: true}, + missingMessage: "NWS weather story data is missing", + }, &story) + if err != nil || !ok { return err } - if raw == nil { - return b.handleMissing(&source, "NWS weather story data is missing", false) - } - var story weatherdata.WeatherStory - if err := decodeSource(raw, &story); err != nil { - return b.handleMalformed(&source, err, false) - } + source := fetched.source if !story.StartTime.IsZero() { source.IssuedAt = &story.StartTime } @@ -273,17 +289,17 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error { } func (b *bundleBuilder) fetchSPCConvectiveOutlooks(ctx context.Context) error { - raw, source, err := b.client.fetch(ctx, sourceSPCConvectiveOutlooks, convectiveOutlooksEndpoint, queryOptions{timezone: true, omitUnits: true}) - if err != nil { + var run weatherdata.ConvectiveOutlookRun + fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{ + name: sourceSPCConvectiveOutlooks, + endpoint: convectiveOutlooksEndpoint, + query: queryOptions{timezone: true, omitUnits: true}, + missingMessage: "SPC convective outlook data is missing", + }, &run) + if err != nil || !ok { return err } - if raw == nil { - return b.handleMissing(&source, "SPC convective outlook data is missing", false) - } - var run weatherdata.ConvectiveOutlookRun - if err := decodeSource(raw, &run); err != nil { - return b.handleMalformed(&source, err, false) - } + source := fetched.source if run.IssuedAt != nil { source.IssuedAt = run.IssuedAt } else { @@ -295,6 +311,28 @@ func (b *bundleBuilder) fetchSPCConvectiveOutlooks(ctx context.Context) error { return nil } +func (b *bundleBuilder) fetchDecodedSource(ctx context.Context, request sourceRequest, target any) (fetchedSource, bool, error) { + fetched, ok, err := b.fetchSource(ctx, request) + if err != nil || !ok { + return fetchedSource{}, false, err + } + if err := decodeSource(fetched.raw, target); err != nil { + return fetchedSource{}, false, b.handleMalformed(&fetched.source, err, request) + } + return fetched, true, nil +} + +func (b *bundleBuilder) fetchSource(ctx context.Context, request sourceRequest) (fetchedSource, bool, error) { + raw, source, err := b.client.fetch(ctx, request.name, request.endpoint, request.query) + if err != nil { + return fetchedSource{}, false, err + } + if raw == nil { + return fetchedSource{}, false, b.handleMissing(&source, request.missingMessage, request.required) + } + return fetchedSource{raw: raw, source: source}, true, nil +} + func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string, required bool) error { source.Missing = true if required { @@ -303,9 +341,13 @@ func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string return b.applyMissingPolicy(source, "missing_source", message) } -func (b *bundleBuilder) handleMalformed(source *weatherdata.Source, err error, required bool) error { - if required { - return fmt.Errorf("decode %s from %s: %w", source.Name, source.Endpoint, err) +func (b *bundleBuilder) handleMalformed(source *weatherdata.Source, err error, request sourceRequest) error { + if request.required { + label := request.name + if request.decodeLabel != "" { + label = request.decodeLabel + } + return fmt.Errorf("decode %s from %s: %w", label, source.Endpoint, err) } source.Missing = true return b.applyMissingPolicy(source, "malformed_source", fmt.Sprintf("malformed %s data: %v", source.Name, err)) diff --git a/internal/adapters/weatherapi/client_test.go b/internal/adapters/weatherapi/client_test.go index bd8a28a..a809cbe 100644 --- a/internal/adapters/weatherapi/client_test.go +++ b/internal/adapters/weatherapi/client_test.go @@ -70,6 +70,24 @@ func TestFetchBundleFromFixtures(t *testing.T) { if len(bundle.Warnings) != 0 { t.Fatalf("Warnings length = %d, want no warnings", len(bundle.Warnings)) } + wantPaths := []string{ + "/observations", + "/conditions/current", + "/forecast/hourly", + "/forecast/narrative", + "/alerts/active", + "/discussion", + "/weatherstories/latest", + convectiveOutlooksEndpoint, + } + if len(requested) != len(wantPaths) { + t.Fatalf("requested paths = %v, want %d source endpoints", requested, len(wantPaths)) + } + for _, want := range wantPaths { + if !containsPath(requested, want) { + t.Fatalf("requested paths = %v, want %s", requested, want) + } + } if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") { t.Fatalf("requested paths = %v, want full hourly endpoint only", requested) }