From 291a9178c856e0b0e4b5b2023d1da2c97b3b2b18 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 27 Mar 2026 22:39:17 -0500 Subject: [PATCH] Added new endpoints under /forecast/narrative --- README.md | 26 +- .../inbound/httpapi/endpoints_test.go | 231 +++++++++++++++++- .../inbound/httpapi/forecast_endpoint.go | 38 ++- .../inbound/httpapi/presenter/forecast.go | 4 +- internal/adapters/inbound/httpapi/service.go | 1 + .../outbound/postgres/forecast_queries.go | 18 +- .../outbound/postgres/forecast_read.go | 18 +- internal/app/service.go | 5 + internal/app/service_test.go | 18 ++ templates/forecast_narrative.txt.tmpl | 20 ++ 10 files changed, 360 insertions(+), 19 deletions(-) create mode 100644 templates/forecast_narrative.txt.tmpl diff --git a/README.md b/README.md index 284262f..491f3aa 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,27 @@ # weatherapi -A small HTTP API that serves a variety of weather-related endpoints. \ No newline at end of file +A small HTTP API that serves a variety of weather-related endpoints. + +## Endpoints + +- `GET /observations` +- `GET /conditions/current` +- `GET /alerts/active` +- `GET /forecast/hourly` +- `GET /forecast/hourly/today` +- `GET /forecast/hourly/tomorrow` +- `GET /forecast/narrative` +- `GET /forecast/narrative/today` +- `GET /forecast/narrative/tomorrow` + +## Query Parameters + +Shared weather query parameters: + +- `format` (`json`, `xml`, `text`) +- `units` (`metric`, `us`) + +Forecast endpoint query parameters: + +- `precision` (`0`-`2`) +- `tz` / `TZ` (IANA timezone, US abbreviation, or UTC offset) diff --git a/internal/adapters/inbound/httpapi/endpoints_test.go b/internal/adapters/inbound/httpapi/endpoints_test.go index e633e3d..31291db 100644 --- a/internal/adapters/inbound/httpapi/endpoints_test.go +++ b/internal/adapters/inbound/httpapi/endpoints_test.go @@ -23,11 +23,12 @@ import ( ) type fakeService struct { - observation *model.WeatherObservation - forecast *model.WeatherForecastRun - alerts *model.WeatherAlertRun - conditions *app.CurrentConditions - err error + observation *model.WeatherObservation + forecast *model.WeatherForecastRun + narrativeForecast *model.WeatherForecastRun + alerts *model.WeatherAlertRun + conditions *app.CurrentConditions + err error } func (s *fakeService) LatestObservation(context.Context) (*model.WeatherObservation, error) { @@ -38,6 +39,10 @@ func (s *fakeService) LatestHourlyForecast(context.Context) (*model.WeatherForec return s.forecast, s.err } +func (s *fakeService) LatestNarrativeForecast(context.Context) (*model.WeatherForecastRun, error) { + return s.narrativeForecast, s.err +} + func (s *fakeService) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) { return s.alerts, s.err } @@ -847,6 +852,221 @@ func TestForecastHourlyTodayNoMatchingPeriodsReturnsDataWithEmptyPeriods(t *test } } +func TestForecastNarrativeNoDataReturnsNullEnvelopeData(t *testing.T) { + h := newHandler(t, &fakeService{}, "/forecast/narrative") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/forecast/narrative", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var payload struct { + Data *json.RawMessage `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode envelope: %v", err) + } + if payload.Data != nil { + t.Fatalf("expected data null, got %s", string(*payload.Data)) + } +} + +func TestForecastNarrativePopulatedJSONEnvelope(t *testing.T) { + h := newHandler(t, &fakeService{ + narrativeForecast: &model.WeatherForecastRun{ + Product: model.ForecastProductNarrative, + IssuedAt: time.Now().UTC(), + Periods: []model.WeatherForecastPeriod{{ + StartTime: time.Now().UTC(), + EndTime: time.Now().UTC().Add(12 * time.Hour), + Name: "Tonight", + ConditionCode: model.WMOUnknown, + TextDescription: "Mostly clear overnight.", + }}, + }, + }, "/forecast/narrative") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/forecast/narrative", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var payload struct { + Data struct { + Product string `json:"product"` + Periods []struct { + Name string `json:"name"` + } `json:"periods"` + } `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode envelope: %v", err) + } + if payload.Data.Product != "narrative" { + t.Fatalf("expected product narrative, got %q", payload.Data.Product) + } + if len(payload.Data.Periods) != 1 || payload.Data.Periods[0].Name != "Tonight" { + t.Fatalf("unexpected narrative periods payload: %+v", payload.Data.Periods) + } +} + +func TestForecastNarrativeTextFormatUsesNarrativeTemplate(t *testing.T) { + h := newHandler(t, &fakeService{ + narrativeForecast: &model.WeatherForecastRun{ + Product: model.ForecastProductNarrative, + IssuedAt: time.Now().UTC(), + }, + }, "/forecast/narrative") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/forecast/narrative?format=TEXT", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for text request, got %d", w.Code) + } + if !strings.Contains(w.Header().Get("Content-Type"), "text/plain") { + t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type")) + } + if !strings.Contains(w.Body.String(), "Narrative Forecast") { + t.Fatalf("expected narrative text template body, got %q", w.Body.String()) + } +} + +func TestForecastNarrativeSupportsSameFlags(t *testing.T) { + h := newHandler(t, &fakeService{ + narrativeForecast: &model.WeatherForecastRun{ + Product: model.ForecastProductNarrative, + IssuedAt: time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC), + Periods: []model.WeatherForecastPeriod{{ + StartTime: time.Date(2026, 7, 10, 13, 0, 0, 0, time.UTC), + EndTime: time.Date(2026, 7, 11, 1, 0, 0, 0, time.UTC), + ConditionCode: 1, + TemperatureC: float64Ptr(20.123), + }}, + }, + }, "/forecast/narrative") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/forecast/narrative?format=XML&units=US&precision=2&tz=CDT", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") { + t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type")) + } +} + +func TestForecastNarrativeRejectUnknownQueryParameter(t *testing.T) { + h := newHandler(t, &fakeService{}, "/forecast/narrative") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/forecast/narrative?bogus=1", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestForecastNarrativeTimezoneValidation(t *testing.T) { + h := newHandler(t, &fakeService{ + narrativeForecast: &model.WeatherForecastRun{Product: model.ForecastProductNarrative, IssuedAt: time.Now().UTC()}, + }, "/forecast/narrative") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/forecast/narrative?tz=not-a-timezone", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } + + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/forecast/narrative?tz=CDT&TZ=EST", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestForecastNarrativeTodayTimezoneAffectsDaySlice(t *testing.T) { + setForecastNowForTest(t, time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)) + + h := newHandler(t, &fakeService{ + narrativeForecast: &model.WeatherForecastRun{ + Product: model.ForecastProductNarrative, + IssuedAt: time.Date(2026, 7, 10, 11, 0, 0, 0, time.UTC), + Periods: []model.WeatherForecastPeriod{ + {StartTime: time.Date(2026, 7, 10, 4, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 10, 5, 30, 0, 0, time.UTC), ConditionCode: model.WMOUnknown}, + {StartTime: time.Date(2026, 7, 11, 3, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 15, 30, 0, 0, time.UTC), ConditionCode: model.WMOUnknown}, + {StartTime: time.Date(2026, 7, 11, 5, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 17, 30, 0, 0, time.UTC), ConditionCode: model.WMOUnknown}, + }, + }, + }, "/forecast/narrative/today") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/forecast/narrative/today?tz=CDT", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + payload := decodeForecastTimePayload(t, w) + if len(payload.Data.Periods) != 1 { + t.Fatalf("expected 1 period, got %d", len(payload.Data.Periods)) + } + if !payload.Data.Periods[0].StartTime.UTC().Equal(time.Date(2026, 7, 11, 3, 30, 0, 0, time.UTC)) { + t.Fatalf("unexpected filtered period start: %s", payload.Data.Periods[0].StartTime.UTC().Format(time.RFC3339)) + } + assertOffsetSeconds(t, payload.Data.Periods[0].StartTime, -5*60*60) +} + +func TestForecastNarrativeTomorrowFiltersByStartDateUTCDefault(t *testing.T) { + setForecastNowForTest(t, time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)) + + h := newHandler(t, &fakeService{ + narrativeForecast: &model.WeatherForecastRun{ + Product: model.ForecastProductNarrative, + IssuedAt: time.Date(2026, 7, 10, 11, 0, 0, 0, time.UTC), + Periods: []model.WeatherForecastPeriod{ + {StartTime: time.Date(2026, 7, 10, 23, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 11, 0, 0, 0, time.UTC), ConditionCode: model.WMOUnknown}, + {StartTime: time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC), ConditionCode: model.WMOUnknown}, + {StartTime: time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 12, 0, 0, 0, 0, time.UTC), ConditionCode: model.WMOUnknown}, + }, + }, + }, "/forecast/narrative/tomorrow") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/forecast/narrative/tomorrow", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + payload := decodeForecastTimePayload(t, w) + if len(payload.Data.Periods) != 2 { + t.Fatalf("expected 2 periods, got %d", len(payload.Data.Periods)) + } + for _, p := range payload.Data.Periods { + y, m, d := p.StartTime.UTC().Date() + if y != 2026 || m != time.July || d != 11 { + t.Fatalf("expected start date 2026-07-11 UTC, got %s", p.StartTime.UTC().Format(time.RFC3339)) + } + } +} + func TestCurrentConditionsRejectTimezoneQueryParameter(t *testing.T) { h := newHandler(t, &fakeService{}, "/conditions/current") @@ -1073,6 +1293,7 @@ func testRenderers(t *testing.T) *render.Registry { for name, body := range map[string]string{ "observations.txt.tmpl": "Observation text", "forecast_hourly.txt.tmpl": "Forecast text", + "forecast_narrative.txt.tmpl": "Narrative Forecast", "alerts_active.txt.tmpl": "Alerts text", "conditions_current.txt.tmpl": "Conditions text", } { diff --git a/internal/adapters/inbound/httpapi/forecast_endpoint.go b/internal/adapters/inbound/httpapi/forecast_endpoint.go index 6981a0d..f8122c5 100644 --- a/internal/adapters/inbound/httpapi/forecast_endpoint.go +++ b/internal/adapters/inbound/httpapi/forecast_endpoint.go @@ -1,4 +1,4 @@ -// forecast_endpoint.go defines the /forecast/hourly endpoint behavior. +// forecast_endpoint.go defines forecast endpoint behavior. // Layer: adapters/inbound/httpapi forecast route. package httpapi @@ -24,19 +24,43 @@ const ( var forecastNow = time.Now func forecastDefinitions(svc Service) []endpoint.Definition { + out := make([]endpoint.Definition, 0, 6) + out = append(out, forecastDefinitionSet( + "/forecast/hourly", + "forecast_hourly.txt.tmpl", + svc.LatestHourlyForecast, + )...) + out = append(out, forecastDefinitionSet( + "/forecast/narrative", + "forecast_narrative.txt.tmpl", + svc.LatestNarrativeForecast, + )...) + return out +} + +func forecastDefinitionSet( + basePath string, + templateName string, + fetch func(context.Context) (*model.WeatherForecastRun, error), +) []endpoint.Definition { return []endpoint.Definition{ - forecastDefinition(svc, "/forecast/hourly", forecastDaySliceAll), - forecastDefinition(svc, "/forecast/hourly/today", forecastDaySliceToday), - forecastDefinition(svc, "/forecast/hourly/tomorrow", forecastDaySliceTomorrow), + forecastDefinition(basePath, forecastDaySliceAll, templateName, fetch), + forecastDefinition(basePath+"/today", forecastDaySliceToday, templateName, fetch), + forecastDefinition(basePath+"/tomorrow", forecastDaySliceTomorrow, templateName, fetch), } } -func forecastDefinition(svc Service, path string, daySlice forecastDaySlice) endpoint.Definition { +func forecastDefinition( + path string, + daySlice forecastDaySlice, + templateName string, + fetch func(context.Context) (*model.WeatherForecastRun, error), +) endpoint.Definition { return endpoint.GET( path, bindForecastPrecisionQuery, func(ctx context.Context, req precisionQueryRequest) (any, error) { - run, err := svc.LatestHourlyForecast(ctx) + run, err := fetch(ctx) if err != nil { return nil, err } @@ -48,7 +72,7 @@ func forecastDefinition(svc Service, path string, daySlice forecastDaySlice) end return response.Envelope{Data: presenter.ForecastPayload(run, req.Units, req.Precision, req.Timezone)}, nil }, endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText), - endpoint.WithTemplate("forecast_hourly.txt.tmpl"), + endpoint.WithTemplate(templateName), ) } diff --git a/internal/adapters/inbound/httpapi/presenter/forecast.go b/internal/adapters/inbound/httpapi/presenter/forecast.go index 3aaf31a..45d67f4 100644 --- a/internal/adapters/inbound/httpapi/presenter/forecast.go +++ b/internal/adapters/inbound/httpapi/presenter/forecast.go @@ -1,4 +1,4 @@ -// forecast.go presents hourly forecast payloads in metric and US shapes. +// forecast.go presents forecast payloads in metric and US shapes. // Layer: adapters/inbound/httpapi/presenter forecast payload mapping. package presenter @@ -8,7 +8,7 @@ import ( "gitea.maximumdirect.net/ejr/weatherfeeder/model" ) -// WeatherForecastRunUS is the US-customary response shape for hourly forecasts. +// WeatherForecastRunUS is the US-customary response shape for forecasts. type WeatherForecastRunUS struct { LocationID string `json:"locationId,omitempty" xml:"locationId,omitempty"` LocationName string `json:"locationName,omitempty" xml:"locationName,omitempty"` diff --git a/internal/adapters/inbound/httpapi/service.go b/internal/adapters/inbound/httpapi/service.go index 592b8f5..7379171 100644 --- a/internal/adapters/inbound/httpapi/service.go +++ b/internal/adapters/inbound/httpapi/service.go @@ -13,6 +13,7 @@ import ( type Service interface { LatestObservation(ctx context.Context) (*model.WeatherObservation, error) LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error) + LatestNarrativeForecast(ctx context.Context) (*model.WeatherForecastRun, error) LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error) CurrentConditions(ctx context.Context) (*app.CurrentConditions, error) } diff --git a/internal/adapters/outbound/postgres/forecast_queries.go b/internal/adapters/outbound/postgres/forecast_queries.go index 205de4a..c601e32 100644 --- a/internal/adapters/outbound/postgres/forecast_queries.go +++ b/internal/adapters/outbound/postgres/forecast_queries.go @@ -1,4 +1,4 @@ -// forecast_queries.go contains SQL text for hourly forecast reads. +// forecast_queries.go contains SQL text for forecast reads. // Layer: adapters/outbound/postgres forecast feature. package postgres @@ -17,6 +17,22 @@ SELECT FROM forecasts WHERE product = 'hourly' ORDER BY issued_at DESC, event_emitted_at DESC +LIMIT 1` + + queryLatestNarrativeForecast = ` +SELECT + event_id, + location_id, + location_name, + issued_at, + updated_at, + product, + latitude, + longitude, + elevation_meters +FROM forecasts +WHERE product = 'narrative' +ORDER BY issued_at DESC, event_emitted_at DESC LIMIT 1` queryForecastPeriods = ` diff --git a/internal/adapters/outbound/postgres/forecast_read.go b/internal/adapters/outbound/postgres/forecast_read.go index 6898b19..9c0ea4f 100644 --- a/internal/adapters/outbound/postgres/forecast_read.go +++ b/internal/adapters/outbound/postgres/forecast_read.go @@ -1,4 +1,4 @@ -// forecast_read.go executes hourly forecast and period queries. +// forecast_read.go executes forecast and period queries. // Layer: adapters/outbound/postgres forecast feature. package postgres @@ -12,12 +12,24 @@ import ( ) func (r *Repository) LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error) { + return r.loadLatestForecastRun(ctx, queryLatestHourlyForecast, "hourly") +} + +func (r *Repository) LatestNarrativeForecast(ctx context.Context) (*model.WeatherForecastRun, error) { + return r.loadLatestForecastRun(ctx, queryLatestNarrativeForecast, "narrative") +} + +func (r *Repository) loadLatestForecastRun( + ctx context.Context, + parentQuery string, + productLabel string, +) (*model.WeatherForecastRun, error) { if r == nil || r.db == nil { return nil, fmt.Errorf("postgres repository is not configured") } var row forecastParentRow - err := r.db.QueryRowContext(ctx, queryLatestHourlyForecast).Scan( + err := r.db.QueryRowContext(ctx, parentQuery).Scan( &row.EventID, &row.LocationID, &row.LocationName, @@ -32,7 +44,7 @@ func (r *Repository) LatestHourlyForecast(ctx context.Context) (*model.WeatherFo return nil, nil } if err != nil { - return nil, fmt.Errorf("query latest hourly forecast: %w", err) + return nil, fmt.Errorf("query latest %s forecast: %w", productLabel, err) } run := mapForecastParentRow(row) diff --git a/internal/app/service.go b/internal/app/service.go index d6ac485..53a488a 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -12,6 +12,7 @@ import ( type Repository interface { LatestObservation(ctx context.Context) (*model.WeatherObservation, error) LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error) + LatestNarrativeForecast(ctx context.Context) (*model.WeatherForecastRun, error) LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error) CurrentConditions(ctx context.Context, observationWindowMinutes int) (*CurrentConditions, error) } @@ -33,6 +34,10 @@ func (s *Service) LatestHourlyForecast(ctx context.Context) (*model.WeatherForec return s.repo.LatestHourlyForecast(ctx) } +func (s *Service) LatestNarrativeForecast(ctx context.Context) (*model.WeatherForecastRun, error) { + return s.repo.LatestNarrativeForecast(ctx) +} + func (s *Service) LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error) { return s.repo.LatestAlertRun(ctx) } diff --git a/internal/app/service_test.go b/internal/app/service_test.go index 5dd5705..c98da92 100644 --- a/internal/app/service_test.go +++ b/internal/app/service_test.go @@ -13,6 +13,7 @@ import ( type fakeRepository struct { observation *model.WeatherObservation forecast *model.WeatherForecastRun + narrative *model.WeatherForecastRun alerts *model.WeatherAlertRun conditions *CurrentConditions err error @@ -28,6 +29,10 @@ func (r *fakeRepository) LatestHourlyForecast(context.Context) (*model.WeatherFo return r.forecast, r.err } +func (r *fakeRepository) LatestNarrativeForecast(context.Context) (*model.WeatherForecastRun, error) { + return r.narrative, r.err +} + func (r *fakeRepository) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) { return r.alerts, r.err } @@ -63,6 +68,19 @@ func TestServiceDelegatesForecast(t *testing.T) { } } +func TestServiceDelegatesNarrativeForecast(t *testing.T) { + repo := &fakeRepository{narrative: &model.WeatherForecastRun{LocationID: "stl-narrative"}} + svc := NewService(repo) + + run, err := svc.LatestNarrativeForecast(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if run == nil || run.LocationID != "stl-narrative" { + t.Fatalf("unexpected forecast: %+v", run) + } +} + func TestServiceDelegatesAlerts(t *testing.T) { repo := &fakeRepository{alerts: &model.WeatherAlertRun{LocationID: "stl"}} svc := NewService(repo) diff --git a/templates/forecast_narrative.txt.tmpl b/templates/forecast_narrative.txt.tmpl new file mode 100644 index 0000000..75af1a2 --- /dev/null +++ b/templates/forecast_narrative.txt.tmpl @@ -0,0 +1,20 @@ +{{- if .Data -}} +Narrative Forecast +Location ID: {{if .Data.LocationID}}{{.Data.LocationID}}{{else}}n/a{{end}} +Location Name: {{if .Data.LocationName}}{{.Data.LocationName}}{{else}}n/a{{end}} +Issued At: {{.Data.IssuedAt}} +Periods: {{len .Data.Periods}} +{{- range $i, $period := .Data.Periods}} + +[{{$i}}] {{$period.StartTime}} -> {{$period.EndTime}} +{{- if $period.Name}} +Name: {{$period.Name}} +{{- end}} +Condition Code: {{$period.ConditionCode}} +{{- if $period.TextDescription}} +Summary: {{$period.TextDescription}} +{{- end}} +{{- end}} +{{- else -}} +No narrative forecast data available. +{{- end}}