Add convective outlook HTTP routes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
63
internal/adapters/inbound/httpapi/outlooks_endpoint.go
Normal file
63
internal/adapters/inbound/httpapi/outlooks_endpoint.go
Normal 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"),
|
||||
)
|
||||
}
|
||||
16
internal/adapters/inbound/httpapi/presenter/outlook.go
Normal file
16
internal/adapters/inbound/httpapi/presenter/outlook.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user