Add convective outlook HTTP routes

This commit is contained in:
2026-06-11 15:38:19 +00:00
parent 63c8f33a2a
commit d6734d5ffc
6 changed files with 419 additions and 0 deletions

View File

@@ -11,6 +11,7 @@ func Definitions(svc Service) []endpoint.Definition {
conditionsDefinition(svc),
}
defs = append(defs, weatherStoriesDefinitions(svc)...)
defs = append(defs, outlookDefinitions(svc)...)
defs = append(defs, discussionDefinitions(svc)...)
defs = append(defs, forecastDefinitions(svc)...)
return defs

View File

@@ -30,6 +30,8 @@ type fakeService struct {
weatherStoryRun *model.WeatherStoryRun
weatherStory *model.WeatherStory
alerts *model.WeatherAlertRun
outlookRun *model.WeatherOutlookRun
outlookFilters []app.OutlookFilter
conditions *app.CurrentConditions
err error
}
@@ -62,6 +64,11 @@ func (s *fakeService) LatestAlertRun(context.Context) (*model.WeatherAlertRun, e
return s.alerts, s.err
}
func (s *fakeService) LatestConvectiveOutlook(_ context.Context, filter app.OutlookFilter) (*model.WeatherOutlookRun, error) {
s.outlookFilters = append(s.outlookFilters, filter)
return s.outlookRun, s.err
}
func (s *fakeService) CurrentConditions(context.Context) (*app.CurrentConditions, error) {
return s.conditions, s.err
}
@@ -1272,6 +1279,208 @@ func TestAlertsRejectPrecisionQueryParameter(t *testing.T) {
}
}
func TestOutlookRoutesRegistered(t *testing.T) {
defs := Definitions(&fakeService{})
for _, path := range []string{
"/outlooks/convective",
"/outlooks/convective/active",
"/outlooks/convective/location",
} {
def := definitionForPath(t, defs, path)
if len(def.Methods) != 1 || def.Methods[0] != http.MethodGet {
t.Fatalf("%s: expected GET definition, got %+v", path, def.Methods)
}
}
}
func TestOutlookRoutesJSONSuccess(t *testing.T) {
setOutlookNowForTest(t, time.Date(2026, 6, 11, 15, 0, 0, 0, time.UTC))
for _, path := range []string{
"/outlooks/convective",
"/outlooks/convective/active",
"/outlooks/convective/location",
} {
t.Run(path, func(t *testing.T) {
h := newHandler(t, &fakeService{outlookRun: testOutlookRun()}, path)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var payload struct {
Data struct {
LocationID string `json:"locationId"`
Outlooks []struct {
ID string `json:"id"`
} `json:"outlooks"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode outlook payload: %v", err)
}
if payload.Data.LocationID != "stl" {
t.Fatalf("expected locationId stl, got %q", payload.Data.LocationID)
}
if len(payload.Data.Outlooks) != 1 || payload.Data.Outlooks[0].ID != "cat-1" {
t.Fatalf("unexpected outlooks payload: %+v", payload.Data.Outlooks)
}
})
}
}
func TestOutlookNoDataReturnsNullEnvelopeData(t *testing.T) {
h := newHandler(t, &fakeService{}, "/outlooks/convective")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/outlooks/convective", 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 TestOutlookFilteredNoMatchReturnsEmptyOutlooks(t *testing.T) {
run := testOutlookRun()
run.Outlooks = []model.WeatherOutlook{}
h := newHandler(t, &fakeService{outlookRun: run}, "/outlooks/convective")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/outlooks/convective?day=3", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var payload struct {
Data struct {
LocationID string `json:"locationId"`
Outlooks []model.WeatherOutlook `json:"outlooks"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode outlook payload: %v", err)
}
if payload.Data.LocationID != "stl" {
t.Fatalf("expected metadata to remain populated, got %+v", payload.Data)
}
if payload.Data.Outlooks == nil || len(payload.Data.Outlooks) != 0 {
t.Fatalf("expected empty outlooks slice, got %+v", payload.Data.Outlooks)
}
}
func TestOutlookQueryParamsConstructFilter(t *testing.T) {
svc := &fakeService{outlookRun: testOutlookRun()}
h := newHandler(t, svc, "/outlooks/convective")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/outlooks/convective?day=2&outlookType=Tornado&containsLocation=true&tz=CDT&units=US", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if len(svc.outlookFilters) != 1 {
t.Fatalf("expected one filter, got %d", len(svc.outlookFilters))
}
filter := svc.outlookFilters[0]
if filter.Day == nil || *filter.Day != 2 {
t.Fatalf("expected day filter 2, got %+v", filter.Day)
}
if filter.OutlookType != "tornado" {
t.Fatalf("expected outlookType tornado, got %q", filter.OutlookType)
}
if filter.ContainsLocation == nil || !*filter.ContainsLocation {
t.Fatalf("expected containsLocation true, got %+v", filter.ContainsLocation)
}
if filter.ActiveAt != nil {
t.Fatalf("expected no active filter, got %v", filter.ActiveAt)
}
}
func TestOutlookActiveAndLocationFiltersUseNow(t *testing.T) {
now := time.Date(2026, 6, 11, 15, 30, 0, 0, time.FixedZone("CDT", -5*3600))
setOutlookNowForTest(t, now)
activeSvc := &fakeService{outlookRun: testOutlookRun()}
activeHandler := newHandler(t, activeSvc, "/outlooks/convective/active")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/outlooks/convective/active?day=1", nil)
activeHandler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected active 200, got %d", w.Code)
}
activeFilter := activeSvc.outlookFilters[0]
if activeFilter.ActiveAt == nil || !activeFilter.ActiveAt.Equal(now.UTC()) {
t.Fatalf("expected activeAt %s, got %v", now.UTC(), activeFilter.ActiveAt)
}
if activeFilter.ContainsLocation != nil {
t.Fatalf("expected active route not to force containsLocation, got %+v", activeFilter.ContainsLocation)
}
locationSvc := &fakeService{outlookRun: testOutlookRun()}
locationHandler := newHandler(t, locationSvc, "/outlooks/convective/location")
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/outlooks/convective/location?outlookType=hail", nil)
locationHandler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected location 200, got %d", w.Code)
}
locationFilter := locationSvc.outlookFilters[0]
if locationFilter.ActiveAt == nil || !locationFilter.ActiveAt.Equal(now.UTC()) {
t.Fatalf("expected location activeAt %s, got %v", now.UTC(), locationFilter.ActiveAt)
}
if locationFilter.ContainsLocation == nil || !*locationFilter.ContainsLocation {
t.Fatalf("expected location route to force containsLocation true, got %+v", locationFilter.ContainsLocation)
}
if locationFilter.OutlookType != "hail" {
t.Fatalf("expected outlookType hail, got %q", locationFilter.OutlookType)
}
}
func TestOutlookInvalidQueryParamsReturnBadRequest(t *testing.T) {
for _, rawURL := range []string{
"/outlooks/convective?precision=1",
"/outlooks/convective?bogus=1",
"/outlooks/convective?day=0",
"/outlooks/convective?day=4",
"/outlooks/convective?day=two",
"/outlooks/convective?outlookType=snow",
"/outlooks/convective?containsLocation=maybe",
"/outlooks/convective?tz=not-a-timezone",
"/outlooks/convective?tz=CDT&TZ=EST",
"/outlooks/convective/location?containsLocation=true",
} {
t.Run(rawURL, func(t *testing.T) {
h := newHandler(t, &fakeService{outlookRun: testOutlookRun()}, strings.Split(rawURL, "?")[0])
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, rawURL, nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
})
}
}
func TestDiscussionNoDataReturnsNullEnvelopeData(t *testing.T) {
h := newHandler(t, &fakeService{}, "/discussion")
@@ -1965,6 +2174,7 @@ func testRenderers(t *testing.T) *render.Registry {
"discussion_long_term.txt.tmpl": "Forecast Discussion Long Term",
"forecast_hourly.txt.tmpl": "Forecast text",
"forecast_narrative.txt.tmpl": "Narrative Forecast",
"outlooks_convective.txt.tmpl": "Convective Outlook",
"weatherstories.txt.tmpl": "Weather Stories",
"weatherstories_latest.txt.tmpl": "Latest Weather Story",
"alerts_active.txt.tmpl": "Alerts text",
@@ -1994,6 +2204,38 @@ func wmoCodePtr(v model.WMOCode) *model.WMOCode {
return &out
}
func setOutlookNowForTest(t *testing.T, now time.Time) {
t.Helper()
original := outlookNow
outlookNow = func() time.Time { return now }
t.Cleanup(func() { outlookNow = original })
}
func testOutlookRun() *model.WeatherOutlookRun {
issuedAt := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
return &model.WeatherOutlookRun{
LocationID: "stl",
LocationName: "St. Louis",
AsOf: issuedAt,
IssuedAt: &issuedAt,
Outlooks: []model.WeatherOutlook{{
ID: "cat-1",
Provider: "spc",
Product: "convective",
Day: 1,
OutlookType: "categorical",
Label: "SLGT",
LabelText: "Slight Risk",
ValidFrom: issuedAt,
ValidTo: issuedAt.Add(6 * time.Hour),
IssuedAt: issuedAt,
ExpiresAt: issuedAt.Add(6 * time.Hour),
ContainsLocation: true,
Geometry: []byte(`{"type":"Point","coordinates":[-90.2,38.6]}`),
}},
}
}
func sampleWeatherStoryRun() *model.WeatherStoryRun {
return &model.WeatherStoryRun{
OfficeID: "LSX",

View File

@@ -0,0 +1,63 @@
// outlooks_endpoint.go defines convective outlook endpoint behavior.
// Layer: adapters/inbound/httpapi outlook routes.
package httpapi
import (
"context"
"net/http"
"time"
"gitea.maximumdirect.net/ejr/feedapi/endpoint"
"gitea.maximumdirect.net/ejr/feedapi/render"
"gitea.maximumdirect.net/ejr/feedapi/response"
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
)
type outlookFilterMode int
const (
outlookFilterUser outlookFilterMode = iota
outlookFilterActive
outlookFilterLocation
)
var outlookNow = time.Now
func outlookDefinitions(svc Service) []endpoint.Definition {
return []endpoint.Definition{
outlookDefinition("/outlooks/convective", outlookFilterUser, bindOutlookQuery, svc),
outlookDefinition("/outlooks/convective/active", outlookFilterActive, bindOutlookQuery, svc),
outlookDefinition("/outlooks/convective/location", outlookFilterLocation, bindOutlookLocationQuery, svc),
}
}
func outlookDefinition(
path string,
mode outlookFilterMode,
binder func(*http.Request) (outlookQueryRequest, error),
svc Service,
) endpoint.Definition {
return endpoint.GET(
path,
binder,
func(ctx context.Context, req outlookQueryRequest) (any, error) {
filter := req.Filter
if mode == outlookFilterActive || mode == outlookFilterLocation {
activeAt := outlookNow().UTC()
filter.ActiveAt = &activeAt
}
if mode == outlookFilterLocation {
containsLocation := true
filter.ContainsLocation = &containsLocation
}
run, err := svc.LatestConvectiveOutlook(ctx, filter)
if err != nil {
return nil, err
}
return response.Envelope{Data: presenter.OutlookRunPayload(run, req.Units, req.Timezone)}, nil
},
endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
endpoint.WithTemplate("outlooks_convective.txt.tmpl"),
)
}

View File

@@ -0,0 +1,16 @@
// outlook.go presents convective outlook payloads.
// Layer: adapters/inbound/httpapi/presenter outlook payload mapping.
package presenter
import (
"time"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func OutlookRunPayload(run *model.WeatherOutlookRun, _ Units, _ *time.Location) any {
if run == nil {
return nil
}
return run
}

View File

@@ -8,7 +8,9 @@ import (
"time"
"gitea.maximumdirect.net/ejr/feedapi/bind"
apierrors "gitea.maximumdirect.net/ejr/feedapi/errors"
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
)
type queryRequest struct {
@@ -26,6 +28,12 @@ type timezoneQueryRequest struct {
Timezone *time.Location
}
type outlookQueryRequest struct {
Units presenter.Units
Timezone *time.Location
Filter app.OutlookFilter
}
func bindQuery(r *http.Request) (queryRequest, error) {
normalizeCommonQueryValue(r, "units")
normalizeCommonQueryValue(r, "format")
@@ -135,3 +143,91 @@ func bindPrecisionQueryInternal(r *http.Request, allowTimezone bool) (precisionQ
Timezone: tz,
}, nil
}
func bindOutlookQuery(r *http.Request) (outlookQueryRequest, error) {
return bindOutlookQueryInternal(r, true)
}
func bindOutlookLocationQuery(r *http.Request) (outlookQueryRequest, error) {
return bindOutlookQueryInternal(r, false)
}
func bindOutlookQueryInternal(r *http.Request, allowContainsLocation bool) (outlookQueryRequest, error) {
normalizeCommonQueryValue(r, "units")
normalizeCommonQueryValue(r, "format")
normalizeCommonQueryValue(r, "outlookType")
allowedExtra := []string{"tz", "TZ", "day", "outlookType"}
if allowContainsLocation {
allowedExtra = append(allowedExtra, "containsLocation")
}
common, err := bind.CommonQueryParams(r, bind.QueryPolicy{
AllowUnits: true,
AllowFormat: true,
DefaultUnits: string(presenter.UnitsMetric),
RejectUnknown: true,
}, allowedExtra...)
if err != nil {
return outlookQueryRequest{}, err
}
units := presenter.Units(strings.ToLower(strings.TrimSpace(common.Units)))
if units == "" {
units = presenter.UnitsMetric
}
tz, err := parseTimezoneQuery(r)
if err != nil {
return outlookQueryRequest{}, err
}
filter, err := bindOutlookFilter(r, allowContainsLocation)
if err != nil {
return outlookQueryRequest{}, err
}
return outlookQueryRequest{
Units: units,
Timezone: tz,
Filter: filter,
}, nil
}
func bindOutlookFilter(r *http.Request, allowContainsLocation bool) (app.OutlookFilter, error) {
var filter app.OutlookFilter
if strings.TrimSpace(r.URL.Query().Get("day")) != "" {
day, err := bind.OptionalInt(r, "day", 0)
if err != nil {
return app.OutlookFilter{}, err
}
if day < 1 || day > 3 {
return app.OutlookFilter{}, apierrors.InvalidParameter("day must be one of [1, 2, 3]")
}
filter.Day = &day
}
outlookType := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("outlookType")))
if outlookType != "" {
switch outlookType {
case "categorical", "tornado", "hail", "wind":
filter.OutlookType = outlookType
default:
return app.OutlookFilter{}, apierrors.InvalidParameter("outlookType must be one of [categorical, tornado, hail, wind]")
}
}
if strings.TrimSpace(r.URL.Query().Get("containsLocation")) != "" {
if !allowContainsLocation {
return app.OutlookFilter{}, apierrors.InvalidParameter("containsLocation is not allowed on this endpoint")
}
containsLocation, err := bind.OptionalBool(r, "containsLocation", false)
if err != nil {
return app.OutlookFilter{}, err
}
filter.ContainsLocation = &containsLocation
}
return filter, nil
}

View File

@@ -18,5 +18,6 @@ type Service interface {
LatestWeatherStoryRun(ctx context.Context) (*model.WeatherStoryRun, error)
LatestWeatherStory(ctx context.Context) (*model.WeatherStory, error)
LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error)
LatestConvectiveOutlook(ctx context.Context, filter app.OutlookFilter) (*model.WeatherOutlookRun, error)
CurrentConditions(ctx context.Context) (*app.CurrentConditions, error)
}