diff --git a/internal/gtfs/realtime.go b/internal/gtfs/realtime.go index 3413bc23..bbf6d929 100644 --- a/internal/gtfs/realtime.go +++ b/internal/gtfs/realtime.go @@ -201,6 +201,36 @@ func (manager *Manager) GetAlertsForStop(stopID string) []gtfs.Alert { return out } +// GetAllAlerts returns all deduplicated realtime alerts across all feeds. +// Deduplication is by alert ID, preserving first-seen order by sorted feed ID. +func (manager *Manager) GetAllAlerts() []gtfs.Alert { + manager.realTimeMutex.RLock() + defer manager.realTimeMutex.RUnlock() + + feedIDs := make([]string, 0, len(manager.feedAlerts)) + for feedID := range manager.feedAlerts { + feedIDs = append(feedIDs, feedID) + } + sort.Strings(feedIDs) + + seen := make(map[string]struct{}) + alerts := make([]gtfs.Alert, 0) + for _, feedID := range feedIDs { + for _, alert := range manager.feedAlerts[feedID] { + if alert.ID == "" { + continue + } + if _, exists := seen[alert.ID]; exists { + continue + } + seen[alert.ID] = struct{}{} + alerts = append(alerts, alert) + } + } + + return alerts +} + // Fetches GTFS-RT data from a URL with per-feed headers. func loadRealtimeData(ctx context.Context, source string, headers map[string]string) (*gtfs.Realtime, error) { req, err := http.NewRequestWithContext(ctx, "GET", source, nil) diff --git a/internal/restapi/routes.go b/internal/restapi/routes.go index b70c89e7..74e4649b 100644 --- a/internal/restapi/routes.go +++ b/internal/restapi/routes.go @@ -96,6 +96,7 @@ func (api *RestAPI) SetRoutes(mux *http.ServeMux) { // Real-time simple ID endpoints (no ETag) mux.Handle("GET /api/where/vehicles-for-agency/{id}", CacheControlMiddleware(models.CacheDurationShort, rateLimitAndValidateAPIKey(api, api.vehiclesForAgencyHandler))) + mux.Handle("GET /api/where/situation/{id}", CacheControlMiddleware(models.CacheDurationShort, rateLimitAndValidateAPIKey(api, api.situationHandler))) // --- Routes with combined ID validation (agency_id_code format) --- mux.Handle("GET /api/where/trip/{id}", CacheControlMiddleware(models.CacheDurationLong, rateLimitAndValidateAPIKey(api, etagStatic(api, api.tripHandler)))) diff --git a/internal/restapi/situation_handler.go b/internal/restapi/situation_handler.go new file mode 100644 index 00000000..fdad212b --- /dev/null +++ b/internal/restapi/situation_handler.go @@ -0,0 +1,42 @@ +package restapi + +import ( + "fmt" + "net/http" + + "github.com/OneBusAway/go-gtfs" + "maglev.onebusaway.org/internal/models" +) + +// situationHandler serves a single GTFS-RT service alert (OneBusAway "Situation") +// by its alert id. +func (api *RestAPI) situationHandler(w http.ResponseWriter, r *http.Request) { + situationID, ok := api.extractAndValidateID(w, r) + if !ok { + return + } + + var alert gtfs.Alert + found := false + for _, candidate := range api.GtfsManager.GetAllAlerts() { + if candidate.ID == situationID { + alert = candidate + found = true + break + } + } + if !found { + api.sendNotFound(w, r) + return + } + + situations := api.BuildSituationReferences([]gtfs.Alert{alert}) + if len(situations) == 0 { + api.serverErrorResponse(w, r, fmt.Errorf("unexpected empty situation build for id %q", situationID)) + return + } + + references := models.NewEmptyReferences() + response := models.NewEntryResponse(situations[0], *references, api.Clock) + api.sendResponse(w, r, response) +} diff --git a/internal/restapi/situation_handler_test.go b/internal/restapi/situation_handler_test.go new file mode 100644 index 00000000..2499934b --- /dev/null +++ b/internal/restapi/situation_handler_test.go @@ -0,0 +1,76 @@ +package restapi + +import ( + "net/http" + "testing" + + "github.com/OneBusAway/go-gtfs" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSituationHandlerRequiresValidAPIKey(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + + resp, model := serveApiAndRetrieveEndpoint(t, api, "/api/where/situation/test-alert.json?key=invalid") + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + assert.Equal(t, http.StatusUnauthorized, model.Code) + assert.Equal(t, "permission denied", model.Text) +} + +func TestSituationHandlerNotFound(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + + resp, model := serveApiAndRetrieveEndpoint(t, api, "/api/where/situation/nonexistent-alert.json?key=TEST") + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + assert.Equal(t, http.StatusNotFound, model.Code) + assert.Equal(t, "resource not found", model.Text) +} + +func TestSituationHandlerWithSituation(t *testing.T) { + api := createTestApi(t) + defer api.Shutdown() + + alert := gtfs.Alert{ + ID: "test-alert-123", + Header: []gtfs.AlertText{ + {Text: "Service disruption", Language: "en"}, + }, + Description: []gtfs.AlertText{ + {Text: "Detour in effect", Language: "en"}, + }, + } + api.GtfsManager.AddTestAlert(alert) + + resp, model := serveApiAndRetrieveEndpoint(t, api, "/api/where/situation/test-alert-123.json?key=TEST") + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, http.StatusOK, model.Code) + assert.Equal(t, "OK", model.Text) + assert.Equal(t, 2, model.Version) + + data, ok := model.Data.(map[string]interface{}) + require.True(t, ok, "response should include data object") + + entry, ok := data["entry"].(map[string]interface{}) + require.True(t, ok, "response should include data.entry object") + assert.Equal(t, "test-alert-123", entry["id"]) + assert.Equal(t, "UNKNOWN_CAUSE", entry["reason"]) + assert.Equal(t, "noImpact", entry["severity"]) + + references, ok := data["references"].(map[string]interface{}) + require.True(t, ok, "response should include data.references object") + + agencies, ok := references["agencies"].([]interface{}) + require.True(t, ok) + assert.Len(t, agencies, 0) + + routes, ok := references["routes"].([]interface{}) + require.True(t, ok) + assert.Len(t, routes, 0) + + stops, ok := references["stops"].([]interface{}) + require.True(t, ok) + assert.Len(t, stops, 0) +}