package traffic import ( "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" ) // Event is a normalized traffic incident from a feed. type Event struct { Raw json.RawMessage `json:"raw"` Type string `json:"type"` // feed-native event type, e.g. "Road Closed" Desc string `json:"desc"` // short description City string `json:"city"` Lat float64 `json:"lat"` Lon float64 `json:"lon"` } const fiftyOneNYBase = "https://511ny.org/api" // FetchEvents pulls all current incidents from the 511NY REST API. // The service is ASP.NET OData v3, so the JSON payload is {"d":[...]}; // we accept that plus plain arrays for forward compatibility. // The raw response body is returned so callers can archive/inspect it — // field names are decoded leniently because we could not inspect a live // response before the first keyed call. func FetchEvents(key string) ([]Event, []byte, error) { client := &http.Client{Timeout: 30 * time.Second} u := fiftyOneNYBase + "/GetEvents?" + url.Values{"key": {key}, "format": {"json"}}.Encode() resp, err := client.Get(u) if err != nil { return nil, nil, fmt.Errorf("511ny: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) if err != nil { return nil, nil, fmt.Errorf("511ny read: %w", err) } if resp.StatusCode != 200 { return nil, body, fmt.Errorf("511ny: status %d: %s", resp.StatusCode, truncate(string(body), 200)) } var events []Event var err2 error if events, err2 = decodeEvents(body); err2 != nil { return nil, body, fmt.Errorf("511ny decode: %w", err2) } return events, body, nil } // decodeEvents accepts {"d":[...]} (OData v3), a plain array, or // {"results":[...]}. func decodeEvents(body []byte) ([]Event, error) { var pick func(v any) []json.RawMessage switch { case strings.HasPrefix(strings.TrimSpace(string(body)), "{"): var obj map[string]json.RawMessage if err := json.Unmarshal(body, &obj); err != nil { return nil, err } for _, k := range []string{"d", "results", "value"} { if raw, ok := obj[k]; ok { var arr []json.RawMessage if err := json.Unmarshal(raw, &arr); err == nil { pick = func(any) []json.RawMessage { return arr } break } } } if pick == nil { return nil, fmt.Errorf("no event array in object (keys: %s)", keys(obj)) } default: var arr []json.RawMessage if err := json.Unmarshal(body, &arr); err != nil { return nil, err } pick = func(any) []json.RawMessage { return arr } } var events []Event for _, raw := range pick(nil) { events = append(events, decodeEvent(raw)) } return events, nil } func keys(m map[string]json.RawMessage) string { var ks []string for k := range m { ks = append(ks, k) } return strings.Join(ks, ",") } // decodeEvent extracts location + type leniently. 511NY field names vary // between vintages; we try the documented shapes: // // {"EventType": "...", "Location": {"Latitude": .., "Longitude": ..}, ...} // {"type": "...", "lat": .., "lon": .., ...} func decodeEvent(raw json.RawMessage) Event { e := Event{Raw: raw} var m map[string]any if err := json.Unmarshal(raw, &m); err != nil { return e } e.Type = firstString(m, "EventType", "eventType", "type", "Event", "category") e.Desc = firstString(m, "Description", "description", "EventDescription", "summary") e.City = firstString(m, "City", "city", "LocationDescription") // location: nested object first, then flat fields if loc, ok := m["Location"].(map[string]any); ok { e.Lat = firstFloat(loc, "Latitude", "latitude", "lat") e.Lon = firstFloat(loc, "Longitude", "longitude", "lon", "lng") } if !e.Valid() { e.Lat = firstFloat(m, "Latitude", "latitude", "lat") e.Lon = firstFloat(m, "Longitude", "longitude", "lon", "lng") } return e } // Valid reports whether the event carries a usable location. func (e Event) Valid() bool { return e.Lat != 0 && e.Lon != 0 } func firstString(m map[string]any, keys ...string) string { for _, k := range keys { if v, ok := m[k].(string); ok && v != "" { return v } } return "" } func firstFloat(m map[string]any, keys ...string) float64 { for _, k := range keys { if v, ok := m[k].(float64); ok && v != 0 { return v } } return 0 }