diff --git a/CHANGELOG.md b/CHANGELOG.md index 3aa6a62..f9cb5ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. *** diff --git a/README.md b/README.md index 7c85de2..eb30f97 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/lib/gull/alert.rb b/lib/gull/alert.rb index 03eb7db..9fa28de 100644 --- a/lib/gull/alert.rb +++ b/lib/gull/alert.rb @@ -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, @@ -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) @@ -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) diff --git a/lib/gull/client.rb b/lib/gull/client.rb index 05da280..5e75be5 100644 --- a/lib/gull/client.rb +++ b/lib/gull/client.rb @@ -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 diff --git a/lib/gull/error.rb b/lib/gull/error.rb index 55daf7a..1e0b3cd 100644 --- a/lib/gull/error.rb +++ b/lib/gull/error.rb @@ -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 @@ -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 diff --git a/lib/gull/geocode.rb b/lib/gull/geocode.rb index c2c99d4..01b3ac9 100644 --- a/lib/gull/geocode.rb +++ b/lib/gull/geocode.rb @@ -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 diff --git a/lib/gull/polygon.rb b/lib/gull/polygon.rb index a9c0f31..e8073ad 100644 --- a/lib/gull/polygon.rb +++ b/lib/gull/polygon.rb @@ -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(', ') diff --git a/lib/gull/version.rb b/lib/gull/version.rb index 45388c4..4ca2468 100644 --- a/lib/gull/version.rb +++ b/lib/gull/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Gull - VERSION = '1.0.0' + VERSION = '1.0.1' end diff --git a/spec/alert_spec.rb b/spec/alert_spec.rb index f17ef89..5861a25 100644 --- a/spec/alert_spec.rb +++ b/spec/alert_spec.rb @@ -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) diff --git a/spec/client_spec.rb b/spec/client_spec.rb index f116be6..1abc00b 100644 --- a/spec/client_spec.rb +++ b/spec/client_spec.rb @@ -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: 'Bad Gateway', + 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' diff --git a/spec/fixtures/features/missing_times.json b/spec/fixtures/features/missing_times.json new file mode 100644 index 0000000..991de83 --- /dev/null +++ b/spec/fixtures/features/missing_times.json @@ -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": {} + } +}