Suppress superseded alerts in the /alerts/active endpoint
All checks were successful
ci/woodpecker/manual/build-image Pipeline was successful
All checks were successful
ci/woodpecker/manual/build-image Pipeline was successful
This commit is contained in:
@@ -194,7 +194,8 @@ GET /conditions/current?format=json&precision=0
|
|||||||
GET /alerts/active
|
GET /alerts/active
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns the latest stored alert run filtered to alerts active at request time.
|
Returns the latest stored alert run filtered to alerts active at request time,
|
||||||
|
omitting older alerts superseded by newer alert references in the same run.
|
||||||
|
|
||||||
Query parameters: `format`, `units`.
|
Query parameters: `format`, `units`.
|
||||||
|
|
||||||
@@ -216,6 +217,9 @@ at or before request time, and the alert end boundary is absent or after request
|
|||||||
time. The end boundary prefers `ends`; if `ends` is absent, `expires` is used as
|
time. The end boundary prefers `ends`; if `ends` is absent, `expires` is used as
|
||||||
a fallback for older rows or providers that do not supply an alert-period end.
|
a fallback for older rows or providers that do not supply an alert-period end.
|
||||||
`onset` is presented when available but is not used as the active boundary.
|
`onset` is presented when available but is not used as the active boundary.
|
||||||
|
After active-time filtering, alerts referenced by another alert in the same run
|
||||||
|
are omitted as superseded. References from update and cancel messages are both
|
||||||
|
honored, even when the referencing alert is not itself returned.
|
||||||
|
|
||||||
Alert fields include `id`, `event`, `headline`, `severity`, `urgency`,
|
Alert fields include `id`, `event`, `headline`, `severity`, `urgency`,
|
||||||
`certainty`, `status`, `messageType`, `category`, `response`, `description`,
|
`certainty`, `status`, `messageType`, `category`, `response`, `description`,
|
||||||
|
|||||||
@@ -136,7 +136,10 @@ behavior deterministic.
|
|||||||
the application service with `alertNow().UTC()` so active alert filtering uses
|
the application service with `alertNow().UTC()` so active alert filtering uses
|
||||||
the request-time instant while remaining deterministic in endpoint tests. The
|
the request-time instant while remaining deterministic in endpoint tests. The
|
||||||
application service prefers alert `ends` over `expires` when deciding whether an
|
application service prefers alert `ends` over `expires` when deciding whether an
|
||||||
alert has ended.
|
alert has ended. The application service also suppresses alerts referenced by
|
||||||
|
another alert in the same latest run. This supersession rule uses alert
|
||||||
|
references from update and cancel messages, even when the referencing message is
|
||||||
|
not returned by `/alerts/active`.
|
||||||
|
|
||||||
## Outlook Filters
|
## Outlook Filters
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const nwsAlertURLPrefix = "https://api.weather.gov/alerts/"
|
||||||
|
|
||||||
// Repository defines outbound data access used by weatherapi use cases.
|
// Repository defines outbound data access used by weatherapi use cases.
|
||||||
type Repository interface {
|
type Repository interface {
|
||||||
LatestObservation(ctx context.Context) (*model.WeatherObservation, error)
|
LatestObservation(ctx context.Context) (*model.WeatherObservation, error)
|
||||||
@@ -77,9 +79,10 @@ func (s *Service) LatestActiveAlertRun(ctx context.Context, activeAt time.Time)
|
|||||||
}
|
}
|
||||||
|
|
||||||
out := cloneAlertRun(run)
|
out := cloneAlertRun(run)
|
||||||
|
supersededIDs := collectSupersededAlertIDs(out.Alerts)
|
||||||
alerts := out.Alerts[:0]
|
alerts := out.Alerts[:0]
|
||||||
for _, alert := range out.Alerts {
|
for _, alert := range out.Alerts {
|
||||||
if isActiveAlert(alert, activeAt) {
|
if isActiveAlert(alert, activeAt) && !isSupersededAlert(alert, supersededIDs) {
|
||||||
alerts = append(alerts, alert)
|
alerts = append(alerts, alert)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,6 +184,41 @@ func isActiveAlert(alert model.WeatherAlert, activeAt time.Time) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func collectSupersededAlertIDs(alerts []model.WeatherAlert) map[string]struct{} {
|
||||||
|
supersededIDs := make(map[string]struct{})
|
||||||
|
for _, alert := range alerts {
|
||||||
|
for _, ref := range alert.References {
|
||||||
|
id := normalizeAlertID(referenceAlertID(ref))
|
||||||
|
if id != "" {
|
||||||
|
supersededIDs[id] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return supersededIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSupersededAlert(alert model.WeatherAlert, supersededIDs map[string]struct{}) bool {
|
||||||
|
id := normalizeAlertID(alert.ID)
|
||||||
|
if id == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok := supersededIDs[id]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func referenceAlertID(ref model.AlertReference) string {
|
||||||
|
if strings.TrimSpace(ref.Identifier) != "" {
|
||||||
|
return ref.Identifier
|
||||||
|
}
|
||||||
|
return ref.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAlertID(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
value = strings.TrimPrefix(value, nwsAlertURLPrefix)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
func cloneOutlookRun(run *model.WeatherOutlookRun) *model.WeatherOutlookRun {
|
func cloneOutlookRun(run *model.WeatherOutlookRun) *model.WeatherOutlookRun {
|
||||||
out := *run
|
out := *run
|
||||||
out.Latitude = copyFloat64(run.Latitude)
|
out.Latitude = copyFloat64(run.Latitude)
|
||||||
|
|||||||
@@ -272,6 +272,60 @@ func TestServiceLatestActiveAlertRunUsesEndsBeforeExpires(t *testing.T) {
|
|||||||
assertAlertIDs(t, run, []string{"ends-after-active-expires-before", "expires-fallback"})
|
assertAlertIDs(t, run, []string{"ends-after-active-expires-before", "expires-fallback"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServiceLatestActiveAlertRunSuppressesReferencedOriginal(t *testing.T) {
|
||||||
|
activeAt := testTime(12)
|
||||||
|
original := testAlert("https://api.weather.gov/alerts/urn:oid:original", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
|
||||||
|
update := testAlert("https://api.weather.gov/alerts/urn:oid:update", "Update", testTimePtr(11), testTimePtr(11), nil, testTimePtr(13), testTimePtr(13))
|
||||||
|
update.References = []model.AlertReference{{Identifier: "urn:oid:original"}}
|
||||||
|
unrelated := testAlert("https://api.weather.gov/alerts/urn:oid:unrelated", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
|
||||||
|
repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{original, update, unrelated})}
|
||||||
|
svc := NewService(repo)
|
||||||
|
|
||||||
|
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
assertAlertIDs(t, run, []string{
|
||||||
|
"https://api.weather.gov/alerts/urn:oid:update",
|
||||||
|
"https://api.weather.gov/alerts/urn:oid:unrelated",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceLatestActiveAlertRunCancelSuppressesReferencedOriginal(t *testing.T) {
|
||||||
|
activeAt := testTime(12)
|
||||||
|
original := testAlert("https://api.weather.gov/alerts/urn:oid:original", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
|
||||||
|
cancel := testAlert("https://api.weather.gov/alerts/urn:oid:cancel", "Cancel", testTimePtr(11), testTimePtr(11), nil, testTimePtr(13), testTimePtr(13))
|
||||||
|
cancel.References = []model.AlertReference{{Identifier: "urn:oid:original"}}
|
||||||
|
repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{original, cancel})}
|
||||||
|
svc := NewService(repo)
|
||||||
|
|
||||||
|
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
assertAlertIDs(t, run, []string{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceLatestActiveAlertRunReferenceIdentifierPrecedenceAndIDFallback(t *testing.T) {
|
||||||
|
activeAt := testTime(12)
|
||||||
|
fromID := testAlert("urn:oid:from-id", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
|
||||||
|
fromIdentifier := testAlert("urn:oid:from-identifier", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
|
||||||
|
idFallback := testAlert("urn:oid:id-fallback", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
|
||||||
|
update := testAlert("urn:oid:update", "Update", testTimePtr(11), testTimePtr(11), nil, testTimePtr(13), testTimePtr(13))
|
||||||
|
update.References = []model.AlertReference{
|
||||||
|
{ID: "urn:oid:from-id", Identifier: "urn:oid:from-identifier"},
|
||||||
|
{ID: "urn:oid:id-fallback"},
|
||||||
|
}
|
||||||
|
repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{fromID, fromIdentifier, idFallback, update})}
|
||||||
|
svc := NewService(repo)
|
||||||
|
|
||||||
|
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
assertAlertIDs(t, run, []string{"urn:oid:from-id", "urn:oid:update"})
|
||||||
|
}
|
||||||
|
|
||||||
func TestServiceDelegatesLatestConvectiveOutlookRun(t *testing.T) {
|
func TestServiceDelegatesLatestConvectiveOutlookRun(t *testing.T) {
|
||||||
repo := &fakeRepository{outlookRun: testOutlookRun()}
|
repo := &fakeRepository{outlookRun: testOutlookRun()}
|
||||||
svc := NewService(repo)
|
svc := NewService(repo)
|
||||||
|
|||||||
Reference in New Issue
Block a user