gull 0.4.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +5 -5
  2. data/.github/workflows/ci.yml +23 -0
  3. data/.gitignore +5 -21
  4. data/.rubocop.yml +34 -1
  5. data/CHANGELOG.md +9 -1
  6. data/Gemfile +2 -0
  7. data/Guardfile +2 -0
  8. data/LICENSE.txt +1 -1
  9. data/README.md +41 -25
  10. data/Rakefile +2 -0
  11. data/gull.gemspec +19 -11
  12. data/lib/gull/alert.rb +79 -45
  13. data/lib/gull/client.rb +51 -36
  14. data/lib/gull/error.rb +5 -0
  15. data/lib/gull/geocode.rb +5 -0
  16. data/lib/gull/polygon.rb +11 -27
  17. data/lib/gull/version.rb +3 -1
  18. data/lib/gull.rb +10 -3
  19. data/spec/alert_spec.rb +130 -72
  20. data/spec/client_spec.rb +46 -63
  21. data/spec/error_spec.rb +4 -2
  22. data/spec/fixtures/alerts.json +4881 -0
  23. data/spec/fixtures/empty.json +4 -0
  24. data/spec/fixtures/features/blizzard_warning.json +93 -0
  25. data/spec/fixtures/features/empty_geocode.json +34 -0
  26. data/spec/fixtures/features/flood_advisory.json +156 -0
  27. data/spec/fixtures/features/flood_warning.json +108 -0
  28. data/spec/fixtures/features/missing_times.json +36 -0
  29. data/spec/fixtures/features/multipolygon.json +69 -0
  30. data/spec/fixtures/features/null_geometry.json +145 -0
  31. data/spec/fixtures/features/polygon_no_vtec.json +165 -0
  32. data/spec/fixtures/features/polygon_with_vtec.json +128 -0
  33. data/spec/fixtures/missing_event.json +21 -0
  34. data/spec/polygon_spec.rb +22 -34
  35. data/spec/spec_helper.rb +5 -88
  36. metadata +42 -42
  37. data/.hound.yml +0 -3
  38. data/.ruby-version +0 -1
  39. data/.travis.yml +0 -7
  40. data/spec/fixtures/alerts.xml +0 -118
  41. data/spec/fixtures/bad.xml +0 -1
  42. data/spec/fixtures/empty.xml +0 -30
  43. data/spec/fixtures/missing_cap.xml +0 -46
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 499056dd7d0dfaf61fd26893aac65a8f8b2f802f
4
- data.tar.gz: 989cda9df71c6d5fc5d31c3ae9c4ff6a26e7ff7b
2
+ SHA256:
3
+ metadata.gz: 7924e8188cee18f8b12e1a420d6ea68d1e0951c1c7c7e53f9d87b11b528d23f0
4
+ data.tar.gz: 517bb2c4a28d250207ddd22c51173b2be3392e0f1059b89110cc2af11a48af65
5
5
  SHA512:
6
- metadata.gz: 7b86f770036bbae32dd13ad68d793fc62043bbf199d3e980cdb92455e26c0498a952d1212a5af69ec26403377da32edce66ee2efdfa68d4e5249a6dc23a5ed35
7
- data.tar.gz: ffe0a676c31958d1c51c25a50a1be6d13046ba88689f734ec4a82112062c4d87e4c5cc6ef6abb52751689e3879a14e15d5d0f4d7fdd532e125eade99e47ff140
6
+ metadata.gz: 79015c143ade595b2d021986b48c0e7dbe78c83f2f42b787e9b7027914263631b3a6581a7491c607cef8037240465e056d8200f4eb9770016a9e88c83b022b71
7
+ data.tar.gz: 61d48682d8434559a7b14347765b3537cfc32c78f7c23e5ff137dcfe7d5571e3e98bbd8a8c79be198959351e3bd8913da7e0c02f82ad8310bf7a33ad7784d974
@@ -0,0 +1,23 @@
1
+ name: CI
2
+ on:
3
+ push:
4
+ branches: [master]
5
+ pull_request:
6
+ branches: [master]
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ strategy:
11
+ matrix:
12
+ ruby-version: ['3.1', '3.2', '3.3', '3.4']
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - name: Set up Ruby ${{ matrix.ruby-version }}
16
+ uses: ruby/setup-ruby@v1
17
+ with:
18
+ ruby-version: ${{ matrix.ruby-version }}
19
+ bundler-cache: true
20
+ - name: Run tests
21
+ run: bundle exec rspec
22
+ - name: Run linter
23
+ run: bundle exec rubocop
data/.gitignore CHANGED
@@ -1,22 +1,6 @@
1
- *.gem
2
- *.rbc
3
- .bundle
4
- .config
5
- .yardoc
1
+ /.bundle
2
+ /coverage
3
+ /pkg
4
+ /tmp
6
5
  Gemfile.lock
7
- InstalledFiles
8
- _yardoc
9
- coverage
10
- doc/
11
- lib/bundler/man
12
- pkg
13
- rdoc
14
- spec/reports
15
- test/tmp
16
- test/version_tmp
17
- tmp
18
- *.bundle
19
- *.so
20
- *.o
21
- *.a
22
- mkmf.log
6
+ .ruby-version
data/.rubocop.yml CHANGED
@@ -1,5 +1,38 @@
1
1
  AllCops:
2
+ TargetRubyVersion: 3.1
3
+ NewCops: enable
4
+ SuggestExtensions: false
2
5
  Exclude:
3
6
  - 'spec/spec_helper.rb'
7
+ - 'gull.gemspec'
8
+ - 'vendor/**/*'
9
+ Metrics/BlockLength:
10
+ Exclude:
11
+ - 'spec/**/*'
12
+ Metrics/MethodLength:
13
+ Max: 20
14
+ Exclude:
15
+ - 'spec/**/*'
16
+ Metrics/AbcSize:
17
+ Max: 35
18
+ Exclude:
19
+ - 'spec/**/*'
20
+ Metrics/ClassLength:
21
+ Enabled: false
22
+ Metrics/CyclomaticComplexity:
23
+ Enabled: false
24
+ Metrics/PerceivedComplexity:
25
+ Enabled: false
26
+ Layout/LineLength:
27
+ Max: 80
28
+ Exclude:
29
+ - 'spec/**/*'
4
30
  Style/StringLiterals:
5
- EnforcedStyle: single_quotes
31
+ EnforcedStyle: single_quotes
32
+ Style/StringConcatenation:
33
+ Exclude:
34
+ - 'spec/**/*'
35
+ Style/Documentation:
36
+ Enabled: false
37
+ Naming/PredicateMethod:
38
+ Enabled: false
data/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ 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`).
2
+
3
+ ***
4
+
5
+ 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.
6
+
7
+ ***
8
+
1
9
  0.4.0 (07/26/2016) - Merged pull request #2 from [schrockwell](https://github.com/schrockwell), which adds `to_wkt` to `Polygon` which formats the polygon points as Well-Known Text (WKT). Removed `centroid` method, use new `to_wkt` method with an external geospatial library instead.
2
10
 
3
11
  ***
@@ -43,7 +51,7 @@
43
51
  ***
44
52
 
45
53
  0.2.0 (10/02/2014) - Introduced Polygon type.
46
-
54
+
47
55
  ***
48
56
 
49
57
  0.1.1 (10/01/2014) - Refactored and simplified alert processing.
data/Gemfile CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  source 'https://rubygems.org'
2
4
 
3
5
  # Specify your gem's dependencies in gull.gemspec
data/Guardfile CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  guard :rspec, cmd: 'bundle exec rspec' do
2
4
  watch(%r{^spec/.+_spec\.rb$})
3
5
  watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" }
data/LICENSE.txt CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2014 Seth Deckard
1
+ Copyright (c) 2014-2026 Seth Deckard
2
2
 
3
3
  MIT License
4
4
 
data/README.md CHANGED
@@ -1,12 +1,8 @@
1
1
  [![Gem Version](https://badge.fury.io/rb/gull.svg)](http://badge.fury.io/rb/gull)
2
- [![Build Status](https://travis-ci.org/sethdeckard/gull.svg?branch=master)](https://travis-ci.org/sethdeckard/gull)
3
- [![Coverage Status](https://coveralls.io/repos/sethdeckard/gull/badge.svg?branch=master)](https://coveralls.io/r/sethdeckard/gull)
4
- [![Code Climate](https://codeclimate.com/github/sethdeckard/gull/badges/gpa.svg)](https://codeclimate.com/github/sethdeckard/gull)
5
- [![Dependency Status](https://gemnasium.com/sethdeckard/gull.svg)](https://gemnasium.com/sethdeckard/gull)
6
- [![security](https://hakiri.io/github/sethdeckard/gull/master.svg)](https://hakiri.io/github/sethdeckard/gull/master)
2
+ [![CI](https://github.com/sethdeckard/gull/actions/workflows/ci.yml/badge.svg)](https://github.com/sethdeckard/gull/actions/workflows/ci.yml)
7
3
  # Gull
8
4
 
9
- Ruby client for parsing NOAA/NWS alerts, warnings, and watches. The name comes from the type of bird featured on the NOAA logo. Please read the Notes/Caveats section for limitations.
5
+ Ruby client for parsing NOAA/NWS alerts, warnings, and watches. The name comes from the type of bird featured on the NOAA logo. Zero runtime dependencies -- uses only Ruby stdlib (`net/http`, `json`).
10
6
 
11
7
  ## Installation
12
8
 
@@ -40,7 +36,7 @@ alert.effective_at
40
36
  alert.expires_at
41
37
  alert.published_at
42
38
  alert.area
43
- alert.polygon
39
+ alert.polygon # Gull::Polygon or nil
44
40
  alert.geocode.fips6
45
41
  alert.geocode.ugc
46
42
  alert.urgency
@@ -49,41 +45,61 @@ alert.certainty
49
45
  alert.vtec
50
46
  ```
51
47
 
52
- To get alerts for a single state, territory, or marine zone just pass an optional URL
48
+ To get alerts for a single state or territory, pass the area option:
53
49
 
54
50
  ```ruby
55
- oklahoma_url = 'http://alerts.weather.gov/cap/ok.php?x=1'
56
- alerts = Gull::Alert.fetch(url: oklahoma_url)
51
+ alerts = Gull::Alert.fetch(area: 'OK')
57
52
  ```
58
53
 
59
- You can also generate a map (a really long URL pointing to a map) of the polygon if alert has one (requires Google Static Maps API Key)
54
+ ### Polygons
55
+
56
+ Alerts with geographic boundaries include a `Polygon` object:
60
57
 
61
58
  ```ruby
62
- alert.polygon.image_url 'YOUR_GOOGLE_API_KEY'
59
+ alert.polygon.coordinates
60
+ # => [[34.57, -97.56], [34.77, -97.38], ...]
61
+
62
+ alert.polygon.to_s
63
+ # => "34.57,-97.56 34.77,-97.38 ..."
63
64
 
64
- => "http://maps.googleapis.com/maps/api/staticmap?size=640x640&maptype=roadmap&path=color:0xff0000|weight:3|fillcolor:0xff000060|38.73,-94.22|38.75,-94.16|38.57,-93.94|38.4,-93.84|38.4,-93.91|38.73,-94.22&key=YOUR_GOOGLE_API_KEY"
65
+ alert.polygon.to_wkt
66
+ # => "POLYGON((-97.56 34.57, -97.38 34.77, ...))"
65
67
  ```
66
68
 
67
- Options can be passed for map to override defaults
69
+ ### Error Handling
70
+
71
+ ```ruby
72
+ begin
73
+ alerts = Gull::Alert.fetch
74
+ rescue Gull::TimeoutError => e
75
+ # request timed out
76
+ rescue Gull::HttpError => e
77
+ # non-success response or connection failure
78
+ e.original # wrapped exception, if any
79
+ end
80
+ ```
81
+
82
+ ### Advanced: Client
83
+
84
+ For direct access to unparseable features, use `Client`:
68
85
 
69
86
  ```ruby
70
- options = { width: 600, height: 300, color: '0xfbf000', weight: 4,
71
- fillcolor: '0xfbf00070', maptype: 'hybrid' }
72
- alert.polygon.image_url 'YOUR_GOOGLE_API_KEY', options
87
+ client = Gull::Client.new(area: 'OK')
88
+ alerts = client.fetch
89
+ client.errors # features that could not be parsed
73
90
  ```
74
91
 
75
- ##Notes, Caveats
76
- This library provides a simplified/flattened model of the [Common Alerting Protocol](http://docs.oasis-open.org/emergency/cap/v1.2/CAP-v1.2-os.html) based only on the elements NWS utilizes in their [public Atom feeds](http://alerts.weather.gov/). If you need a complete CAP parser I suggest looking at [RCAP](https://github.com/farrel/RCAP).
92
+ ## Notes, Caveats
77
93
 
78
- The NWS will often cancel or update alerts before their expiration time. The public Atom feeds only provide current active alerts and do not include these separate update and cancellation CAP messages.
94
+ 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.
79
95
 
80
- The public Atom feeds have not always been reliable in terms of uptime and are often delayed by 2-3 minutes. If you are considering using this (or another gem/library that accesses the public Atom feeds) for mission critical purposes then you should explore other options.
96
+ The NWS will often cancel or update alerts before their expiration time. The API only returns currently active alerts.
81
97
 
82
98
  ### Urgency
83
99
 
84
- | Symbol | Definition
100
+ | Symbol | Definition
85
101
  | :------------- |:-------------
86
- | :immediate | Responsive action should betaken immediately
102
+ | :immediate | Responsive action should be taken immediately
87
103
  | :expected | Responsive action should be taken soon (within next hour)
88
104
  | :future | Responsive action should be taken in the near future
89
105
  | :past | Responsive action is no longer required
@@ -91,7 +107,7 @@ The public Atom feeds have not always been reliable in terms of uptime and are o
91
107
 
92
108
  ### Severity
93
109
 
94
- | Symbol | Definition
110
+ | Symbol | Definition
95
111
  | :------------- |:-------------
96
112
  | :extreme | Extraordinary threat to life or property
97
113
  | :severe | Significant threat to life or property
@@ -101,7 +117,7 @@ The public Atom feeds have not always been reliable in terms of uptime and are o
101
117
 
102
118
  ### Certainty
103
119
 
104
- | Symbol | Definition
120
+ | Symbol | Definition
105
121
  | :------------- |:-------------
106
122
  | :very_likely | Highly likely (p > ~ 85%) or certain
107
123
  | :likely | Likely (p > ~50%)
data/Rakefile CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'bundler/gem_tasks'
2
4
  require 'rspec/core/rake_task'
3
5
 
data/gull.gemspec CHANGED
@@ -1,5 +1,6 @@
1
- # coding: utf-8
2
- lib = File.expand_path('../lib', __FILE__)
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
3
4
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
5
  require 'gull/version'
5
6
 
@@ -9,22 +10,29 @@ Gem::Specification.new do |spec|
9
10
  spec.authors = ['Seth Deckard']
10
11
  spec.email = ['seth@deckard.me']
11
12
  spec.summary = 'Client for parsing NOAA/NWS alerts, warnings, and watches.'
12
- spec.description = 'Client for parsing NOAA/NWS alerts, warnings, and watches.'
13
+ spec.description = 'Fetches and parses NOAA/NWS alerts, warnings, and watches from api.weather.gov. Zero runtime dependencies.'
13
14
  spec.homepage = 'https://github.com/sethdeckard/gull'
14
15
  spec.license = 'MIT'
16
+ spec.required_ruby_version = '>= 3.1'
17
+
18
+ spec.metadata = {
19
+ 'source_code_uri' => 'https://github.com/sethdeckard/gull',
20
+ 'changelog_uri' => 'https://github.com/sethdeckard/gull/blob/master/CHANGELOG.md',
21
+ 'bug_tracker_uri' => 'https://github.com/sethdeckard/gull/issues'
22
+ }
15
23
 
16
- spec.files = `git ls-files -z`.split("\x0")
24
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
25
+ f.match(%r{^(AGENTS|CLAUDE)\.md$})
26
+ end
17
27
  spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
- spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
28
  spec.require_paths = ['lib']
20
29
 
21
- spec.add_runtime_dependency 'httpclient'
22
- spec.add_runtime_dependency 'nokogiri', '>= 1.6.8'
23
-
24
30
  spec.add_development_dependency 'bundler'
31
+ spec.add_development_dependency 'guard-rspec'
25
32
  spec.add_development_dependency 'rake'
26
- spec.add_development_dependency 'rspec', '>=3.0'
27
- spec.add_development_dependency 'coveralls'
28
- spec.add_development_dependency 'guard'
33
+ spec.add_development_dependency 'rspec', '>= 3.0'
34
+ spec.add_development_dependency 'rubocop'
35
+ spec.add_development_dependency 'rubocop-rake'
36
+ spec.add_development_dependency 'simplecov'
29
37
  spec.add_development_dependency 'webmock'
30
38
  end
data/lib/gull/alert.rb CHANGED
@@ -1,80 +1,114 @@
1
- require 'httpclient'
2
- require 'nokogiri'
1
+ # frozen_string_literal: true
3
2
 
4
3
  module Gull
5
- # Gull represents an NWS/NOAA alert and provides the ability to fetch
6
- # them from the public web service
4
+ # Represents a single NWS weather alert (warning, watch, or
5
+ # advisory). Use +Alert.fetch+ to retrieve active alerts from the
6
+ # NWS API.
7
7
  class Alert
8
- attr_accessor :id, :title, :summary, :link, :alert_type, :polygon, :area,
9
- :effective_at, :expires_at, :updated_at, :published_at,
10
- :urgency, :severity, :certainty, :geocode, :vtec
8
+ # @!attribute id [rw] NWS alert identifier
9
+ # @!attribute title [rw] Alert headline
10
+ # @!attribute summary [rw] Full alert description text
11
+ # @!attribute link [rw] Canonical URL for this alert
12
+ # @!attribute alert_type [rw] Event type (e.g. "Tornado Warning")
13
+ # @!attribute polygon [rw] Alert area as a Polygon, or nil
14
+ # @!attribute area [rw] Human-readable area description
15
+ # @!attribute effective_at [rw] Time the alert takes effect
16
+ # @!attribute expires_at [rw] Time the alert expires
17
+ # @!attribute updated_at [rw] Onset time, or sent time if absent
18
+ # @!attribute published_at [rw] Time the alert was sent
19
+ # @!attribute urgency [rw] Urgency level as a Symbol
20
+ # @!attribute severity [rw] Severity level as a Symbol
21
+ # @!attribute certainty [rw] Certainty level as a Symbol
22
+ # @!attribute geocode [rw] Geocode with UGC and FIPS codes
23
+ # @!attribute vtec [rw] VTEC string, or nil
24
+ attr_accessor :id, :title, :summary, :link, :alert_type, :polygon,
25
+ :area, :effective_at, :expires_at, :updated_at,
26
+ :published_at, :urgency, :severity, :certainty,
27
+ :geocode, :vtec
11
28
 
12
29
  def initialize
13
30
  self.geocode = Geocode.new
14
31
  end
15
32
 
33
+ # Fetches active alerts from the NWS API.
16
34
  def self.fetch(options = {})
17
- client = Client.new options
35
+ client = Client.new(options)
18
36
  client.fetch
19
37
  end
20
38
 
21
- def parse(element)
22
- parse_core_attributes element
23
- parse_times element
24
- parse_categories element
25
-
26
- parse_polygon element.xpath('cap:polygon').inner_text
27
- parse_geocode element.xpath('cap:geocode')
28
- parse_vtec element.xpath('cap:parameter')
39
+ # Populates this alert from a GeoJSON feature hash.
40
+ def parse(feature)
41
+ props = feature['properties']
42
+ parse_core_attributes(feature, props)
43
+ parse_times(props)
44
+ parse_categories(props)
45
+ parse_polygon(feature)
46
+ parse_geocode(props)
47
+ parse_vtec(props)
29
48
  end
30
49
 
31
50
  private
32
51
 
33
- def parse_core_attributes(element)
34
- self.id = element.css('id').inner_text
35
- self.title = element.css('title').inner_text
36
- self.summary = element.css('summary').inner_text
37
- self.link = parse_link element
38
- self.alert_type = element.xpath('cap:event').inner_text
39
- self.area = element.xpath('cap:areaDesc').inner_text
52
+ def parse_core_attributes(feature, props)
53
+ self.id = props['id']
54
+ self.title = props['headline']
55
+ self.summary = props['description']
56
+ self.link = props['@id'] || feature['id']
57
+ self.alert_type = props['event']
58
+ self.area = props['areaDesc']
40
59
  end
41
60
 
42
- def parse_link(element)
43
- link = element.css('link').first
44
- link.attributes['href'].value unless link.nil?
61
+ def parse_times(props)
62
+ self.effective_at = parse_time(props['effective'])
63
+ self.expires_at = parse_time(props['expires'])
64
+ self.published_at = parse_time(props['sent'])
65
+ self.updated_at = parse_time(props['onset'] || props['sent'])
45
66
  end
46
67
 
47
- def parse_times(element)
48
- self.updated_at = Time.parse(element.css('updated').inner_text)
49
- self.published_at = Time.parse(element.css('published').inner_text)
50
- self.effective_at = Time.parse(element.xpath('cap:effective').inner_text)
51
- self.expires_at = Time.parse(element.xpath('cap:expires').inner_text)
68
+ def parse_time(value)
69
+ value ? Time.parse(value) : nil
52
70
  end
53
71
 
54
- def parse_categories(element)
55
- self.urgency = code_to_symbol element.xpath('cap:urgency').inner_text
56
- self.severity = code_to_symbol element.xpath('cap:severity').inner_text
57
- self.certainty = code_to_symbol element.xpath('cap:certainty').inner_text
72
+ def parse_categories(props)
73
+ self.urgency = code_to_symbol(props['urgency'])
74
+ self.severity = code_to_symbol(props['severity'])
75
+ self.certainty = code_to_symbol(props['certainty'])
58
76
  end
59
77
 
60
- def parse_polygon(text)
61
- return if text.empty?
62
- self.polygon = Polygon.new text
78
+ def parse_polygon(feature)
79
+ geometry = feature['geometry']
80
+ return if geometry.nil?
81
+
82
+ coords = geometry['coordinates']
83
+ return if coords.nil? || coords.empty?
84
+
85
+ ring = coords.first
86
+ ring = ring.first if geometry['type'] == 'MultiPolygon'
87
+ self.polygon = Polygon.new(ring)
63
88
  end
64
89
 
65
- def parse_geocode(element)
66
- return if element.children.css('value').first.nil?
90
+ def parse_geocode(props)
91
+ geocode_data = props['geocode']
92
+ return if geocode_data.nil?
67
93
 
68
- geocode.fips6 = element.children.css('value').first.inner_text
69
- geocode.ugc = element.children.css('value').last.inner_text
94
+ ugc = geocode_data['UGC']
95
+ geocode.ugc = ugc&.join(' ')
96
+
97
+ fips = geocode_data['SAME']
98
+ geocode.fips6 = fips&.join(' ')
70
99
  end
71
100
 
72
- def parse_vtec(element)
73
- value = element.children.css('value').inner_text
74
- self.vtec = value.empty? ? nil : value
101
+ def parse_vtec(props)
102
+ params = props['parameters']
103
+ return if params.nil?
104
+
105
+ vtec_values = params['VTEC']
106
+ self.vtec = vtec_values&.first
75
107
  end
76
108
 
77
109
  def code_to_symbol(code)
110
+ return :unknown if code.nil?
111
+
78
112
  code.tr(' ', '_').downcase.to_sym
79
113
  end
80
114
  end
data/lib/gull/client.rb CHANGED
@@ -1,65 +1,80 @@
1
- require 'httpclient'
2
- require 'nokogiri'
1
+ # frozen_string_literal: true
3
2
 
4
3
  module Gull
5
- # Client exposes methods and options for fetching alerts from the NWS/NOAA
6
- # web service
4
+ # Low-level HTTP client for the NWS alerts API. Handles
5
+ # fetching, parsing, and error wrapping. Most callers should
6
+ # use +Alert.fetch+ instead.
7
7
  class Client
8
+ URL = 'https://api.weather.gov/alerts/active'
9
+ USER_AGENT = "gull/#{VERSION} (Ruby #{RUBY_VERSION})".freeze
10
+
11
+ # Features that could not be parsed are collected here.
8
12
  attr_accessor :errors
9
13
 
10
14
  def initialize(options = {})
11
- @options = {
12
- url: 'http://alerts.weather.gov/cap/us.php?x=1',
13
- strict: false
14
- }.merge options
15
+ @options = options
15
16
  end
16
17
 
18
+ # Fetches active alerts and returns an Array of Alert objects.
17
19
  def fetch
18
20
  self.errors = []
19
- content = response
20
- document = Nokogiri::XML content do |config|
21
- config.strict if @options[:strict]
22
- end
23
- process document.xpath('//xmlns:feed/xmlns:entry', namespaces)
21
+ json = response
22
+ data = JSON.parse(json)
23
+ process(data['features'] || [])
24
+ rescue JSON::ParserError
25
+ raise HttpError, 'Unexpected response from NWS API'
24
26
  end
25
27
 
26
28
  private
27
29
 
28
30
  def response
29
- client = HTTPClient.new
30
- begin
31
- return client.get_content @options[:url]
32
- rescue HTTPClient::TimeoutError
33
- raise TimeoutError, 'Timeout while connecting to NWS web service'
34
- rescue HTTPClient::KeepAliveDisconnected, HTTPClient::BadResponseError,
35
- SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET
36
- raise HttpError, 'Could not connect to NWS web service'
31
+ uri = build_uri
32
+ http = Net::HTTP.new(uri.host, uri.port)
33
+ http.use_ssl = uri.scheme == 'https'
34
+ request = Net::HTTP::Get.new(uri)
35
+ request['User-Agent'] = USER_AGENT
36
+ request['Accept'] = 'application/geo+json'
37
+ result = http.request(request)
38
+ unless result.is_a?(Net::HTTPSuccess)
39
+ raise HttpError, "NWS API returned #{result.code}"
37
40
  end
41
+
42
+ result.body
43
+ rescue Net::OpenTimeout, Net::ReadTimeout
44
+ raise TimeoutError,
45
+ 'Timeout while connecting to NWS web service'
46
+ rescue SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET
47
+ raise HttpError,
48
+ 'Could not connect to NWS web service'
38
49
  end
39
50
 
40
- def process(entries)
41
- alerts = []
42
- entries.each do |entry|
43
- alert = create_instance entry
44
- alerts.push alert unless alert.nil?
45
- errors.push entry if alert.nil?
51
+ def build_uri
52
+ uri = URI(URL)
53
+ if @options[:area]
54
+ params = URI.decode_www_form(uri.query || '')
55
+ params << ['area', @options[:area]]
56
+ uri.query = URI.encode_www_form(params)
46
57
  end
58
+ uri
59
+ end
47
60
 
61
+ def process(features)
62
+ alerts = []
63
+ features.each do |feature|
64
+ alert = create_instance(feature)
65
+ alerts.push(alert) unless alert.nil?
66
+ errors.push(feature) if alert.nil?
67
+ end
48
68
  alerts
49
69
  end
50
70
 
51
- def create_instance(entry)
52
- return if entry.xpath('cap:event').empty?
71
+ def create_instance(feature)
72
+ properties = feature['properties']
73
+ return if properties.nil? || properties['event'].nil?
53
74
 
54
75
  alert = Alert.new
55
- alert.parse entry
76
+ alert.parse(feature)
56
77
  alert
57
78
  end
58
-
59
- def namespaces
60
- { 'xmlns' => 'http://www.w3.org/2005/Atom',
61
- 'cap' => 'urn:oasis:names:tc:emergency:cap:1.1',
62
- 'ha' => 'http://www.alerting.net/namespace/index_1.0' }
63
- end
64
79
  end
65
80
  end
data/lib/gull/error.rb CHANGED
@@ -1,6 +1,10 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'English'
2
4
 
3
5
  module Gull
6
+ # Raised when the NWS API returns a non-success response or the
7
+ # connection fails. Wraps the original exception, if any.
4
8
  class HttpError < StandardError
5
9
  attr_reader :original
6
10
 
@@ -10,6 +14,7 @@ module Gull
10
14
  end
11
15
  end
12
16
 
17
+ # Raised when the NWS API request times out.
13
18
  class TimeoutError < HttpError
14
19
  end
15
20
  end
data/lib/gull/geocode.rb CHANGED
@@ -1,5 +1,10 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Gull
4
+ # Holds UGC zone codes and FIPS county codes for an alert area.
2
5
  class Geocode
6
+ # @!attribute fips6 [rw] Space-separated FIPS 6 codes
7
+ # @!attribute ugc [rw] Space-separated UGC zone codes
3
8
  attr_accessor :fips6, :ugc
4
9
  end
5
10
  end