Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
1.0.1 (03/07/2026) - Add YARD doc comments to all public classes and methods. Expand README with polygon, error handling, and client usage examples. Handle malformed JSON responses (raise `HttpError` instead of `JSON::ParserError`). Handle missing time fields in alert data (return `nil` instead of raising `TypeError`).

***

1.0.0 (03/07/2026) - Major rewrite: migrate from defunct `alerts.weather.gov` XML feed to `api.weather.gov/alerts` JSON API. Drop all runtime dependencies (`httpclient`, `nokogiri`) in favor of Ruby stdlib (`net/http`, `json`). Require Ruby >= 3.1. Add `area` option for state-based filtering. Replace Travis CI with GitHub Actions. Add RuboCop. **Breaking:** remove `url` option (use `area` instead), remove `Polygon#image_url`, remove `strict` option.

***
Expand Down
45 changes: 42 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ alert.effective_at
alert.expires_at
alert.published_at
alert.area
alert.polygon
alert.polygon # Gull::Polygon or nil
alert.geocode.fips6
alert.geocode.ugc
alert.urgency
Expand All @@ -51,8 +51,47 @@ To get alerts for a single state or territory, pass the area option:
alerts = Gull::Alert.fetch(area: 'OK')
```

##Notes, Caveats
This library fetches active alerts from the [NWS API](https://api.weather.gov) (`api.weather.gov/alerts/active`), which returns GeoJSON. No authentication is required but the API does require a `User-Agent` header (set automatically by the gem).
### Polygons

Alerts with geographic boundaries include a `Polygon` object:

```ruby
alert.polygon.coordinates
# => [[34.57, -97.56], [34.77, -97.38], ...]

alert.polygon.to_s
# => "34.57,-97.56 34.77,-97.38 ..."

alert.polygon.to_wkt
# => "POLYGON((-97.56 34.57, -97.38 34.77, ...))"
```

### Error Handling

```ruby
begin
alerts = Gull::Alert.fetch
rescue Gull::TimeoutError => e
# request timed out
rescue Gull::HttpError => e
# non-success response or connection failure
e.original # wrapped exception, if any
end
```

### Advanced: Client

For direct access to unparseable features, use `Client`:

```ruby
client = Gull::Client.new(area: 'OK')
alerts = client.fetch
client.errors # features that could not be parsed
```

## Notes, Caveats

This library fetches active alerts from the [NWS API](https://api.weather.gov) (`api.weather.gov/alerts/active`), which returns GeoJSON. No authentication is required but the API does require a `User-Agent` header (set automatically by the gem). See the [NWS API docs](https://www.weather.gov/documentation/services-web-api) for more details.

The NWS will often cancel or update alerts before their expiration time. The API only returns currently active alerts.

Expand Down
33 changes: 29 additions & 4 deletions lib/gull/alert.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,26 @@
# frozen_string_literal: true

module Gull
# Represents a single NWS weather alert (warning, watch, or
# advisory). Use +Alert.fetch+ to retrieve active alerts from the
# NWS API.
class Alert
# @!attribute id [rw] NWS alert identifier
# @!attribute title [rw] Alert headline
# @!attribute summary [rw] Full alert description text
# @!attribute link [rw] Canonical URL for this alert
# @!attribute alert_type [rw] Event type (e.g. "Tornado Warning")
# @!attribute polygon [rw] Alert area as a Polygon, or nil
# @!attribute area [rw] Human-readable area description
# @!attribute effective_at [rw] Time the alert takes effect
# @!attribute expires_at [rw] Time the alert expires
# @!attribute updated_at [rw] Onset time, or sent time if absent
# @!attribute published_at [rw] Time the alert was sent
# @!attribute urgency [rw] Urgency level as a Symbol
# @!attribute severity [rw] Severity level as a Symbol
# @!attribute certainty [rw] Certainty level as a Symbol
# @!attribute geocode [rw] Geocode with UGC and FIPS codes
# @!attribute vtec [rw] VTEC string, or nil
attr_accessor :id, :title, :summary, :link, :alert_type, :polygon,
:area, :effective_at, :expires_at, :updated_at,
:published_at, :urgency, :severity, :certainty,
Expand All @@ -11,11 +30,13 @@ def initialize
self.geocode = Geocode.new
end

# Fetches active alerts from the NWS API.
def self.fetch(options = {})
client = Client.new(options)
client.fetch
end

# Populates this alert from a GeoJSON feature hash.
def parse(feature)
props = feature['properties']
parse_core_attributes(feature, props)
Expand All @@ -38,10 +59,14 @@ def parse_core_attributes(feature, props)
end

def parse_times(props)
self.effective_at = Time.parse(props['effective'])
self.expires_at = Time.parse(props['expires'])
self.published_at = Time.parse(props['sent'])
self.updated_at = Time.parse(props['onset'] || props['sent'])
self.effective_at = parse_time(props['effective'])
self.expires_at = parse_time(props['expires'])
self.published_at = parse_time(props['sent'])
self.updated_at = parse_time(props['onset'] || props['sent'])
end

def parse_time(value)
value ? Time.parse(value) : nil
end

def parse_categories(props)
Expand Down
7 changes: 7 additions & 0 deletions lib/gull/client.rb
Original file line number Diff line number Diff line change
@@ -1,21 +1,28 @@
# frozen_string_literal: true

module Gull
# Low-level HTTP client for the NWS alerts API. Handles
# fetching, parsing, and error wrapping. Most callers should
# use +Alert.fetch+ instead.
class Client
URL = 'https://api.weather.gov/alerts/active'
USER_AGENT = "gull/#{VERSION} (Ruby #{RUBY_VERSION})".freeze

# Features that could not be parsed are collected here.
attr_accessor :errors

def initialize(options = {})
@options = options
end

# Fetches active alerts and returns an Array of Alert objects.
def fetch
self.errors = []
json = response
data = JSON.parse(json)
process(data['features'] || [])
rescue JSON::ParserError
raise HttpError, 'Unexpected response from NWS API'
end

private
Expand Down
3 changes: 3 additions & 0 deletions lib/gull/error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
require 'English'

module Gull
# Raised when the NWS API returns a non-success response or the
# connection fails. Wraps the original exception, if any.
class HttpError < StandardError
attr_reader :original

Expand All @@ -12,6 +14,7 @@ def initialize(message, original = $ERROR_INFO)
end
end

# Raised when the NWS API request times out.
class TimeoutError < HttpError
end
end
3 changes: 3 additions & 0 deletions lib/gull/geocode.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
# frozen_string_literal: true

module Gull
# Holds UGC zone codes and FIPS county codes for an alert area.
class Geocode
# @!attribute fips6 [rw] Space-separated FIPS 6 codes
# @!attribute ugc [rw] Space-separated UGC zone codes
attr_accessor :fips6, :ugc
end
end
7 changes: 7 additions & 0 deletions lib/gull/polygon.rb
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
# frozen_string_literal: true

module Gull
# Represents the geographic boundary of an alert as a series of
# lat/lon coordinate pairs. Coordinates are stored in [lat, lon]
# order.
class Polygon
attr_accessor :coordinates

# Accepts GeoJSON coordinates ([lon, lat]) and stores them as
# [lat, lon].
def initialize(coords)
self.coordinates = coords.map { |point| [point[1], point[0]] }
end

# Returns coordinates as "lat,lon lat,lon ..." string.
def to_s
coordinates.map { |pair| pair.join(',') }.join(' ')
end

# Returns coordinates in Well-Known Text format.
def to_wkt
pairs = coordinates.map { |pair| "#{pair.last} #{pair.first}" }
.join(', ')
Expand Down
2 changes: 1 addition & 1 deletion lib/gull/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Gull
VERSION = '1.0.0'
VERSION = '1.0.1'
end
12 changes: 12 additions & 0 deletions spec/alert_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,18 @@ def stub_alerts(json)
expect(alerts.size).to eq(0)
end

it 'should handle missing onset time' do
feature = load_feature('missing_times.json')
stub_alerts(wrap_features(feature))

alert = Gull::Alert.fetch.first

expect(alert.effective_at).to be_nil
expect(alert.expires_at).to eq Time.parse('2026-03-07T08:15:00-06:00')
expect(alert.updated_at).to eq Time.parse('2026-03-07T07:48:00-06:00')
expect(alert.published_at).to eq Time.parse('2026-03-07T07:48:00-06:00')
end

it 'should handle missing event' do
json = File.read 'spec/fixtures/missing_event.json'
stub_alerts(json)
Expand Down
12 changes: 12 additions & 0 deletions spec/client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,18 @@
end
end

it 'should raise error on malformed JSON response' do
stub_request(:get, 'https://api.weather.gov/alerts/active')
.to_return(status: 200, body: '<html>Bad Gateway</html>',
headers: {})

client = Gull::Client.new
expect { client.fetch }
.to raise_error(Gull::HttpError, /Unexpected response/) do |e|
expect(e.original).to be_a(JSON::ParserError)
end
end

it 'should filter by area' do
json = File.read 'spec/fixtures/alerts.json'

Expand Down
36 changes: 36 additions & 0 deletions spec/fixtures/features/missing_times.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"id": "https://api.weather.gov/alerts/urn:oid:2.49.0.1.840.0.missing-times-test",
"type": "Feature",
"geometry": null,
"properties": {
"@id": "https://api.weather.gov/alerts/urn:oid:2.49.0.1.840.0.missing-times-test",
"@type": "wx:Alert",
"id": "urn:oid:2.49.0.1.840.0.missing-times-test",
"areaDesc": "Test County",
"geocode": {
"SAME": ["005001"],
"UGC": ["ARZ001"]
},
"affectedZones": [],
"references": [],
"sent": "2026-03-07T07:48:00-06:00",
"effective": null,
"onset": null,
"expires": "2026-03-07T08:15:00-06:00",
"ends": null,
"status": "Actual",
"messageType": "Alert",
"category": "Met",
"severity": "Moderate",
"certainty": "Likely",
"urgency": "Expected",
"event": "Special Weather Statement",
"sender": "w-nws.webmaster@noaa.gov",
"senderName": "NWS Test",
"headline": "Test alert with missing onset",
"description": "Test description.",
"instruction": null,
"response": "Monitor",
"parameters": {}
}
}