nws 0.2.0

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.
data/lib/nws/alert.rb ADDED
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NWS
4
+ # Represents a weather alert from the NWS
5
+ class Alert
6
+ # @return [String, nil] Alert identifier
7
+ attr_reader :id
8
+
9
+ # @return [String, nil] Description of affected areas
10
+ attr_reader :area_desc
11
+
12
+ # @return [Time, nil] When the alert was sent
13
+ attr_reader :sent
14
+
15
+ # @return [Time, nil] When the alert becomes effective
16
+ attr_reader :effective
17
+
18
+ # @return [Time, nil] When the alert conditions begin
19
+ attr_reader :onset
20
+
21
+ # @return [Time, nil] When the alert expires
22
+ attr_reader :expires
23
+
24
+ # @return [Time, nil] When the alert conditions end
25
+ attr_reader :ends
26
+
27
+ # @return [String, nil] Alert status (e.g., "Actual", "Test")
28
+ attr_reader :status
29
+
30
+ # @return [String, nil] Message type (e.g., "Alert", "Update", "Cancel")
31
+ attr_reader :message_type
32
+
33
+ # @return [String, nil] Severity level (e.g., "Extreme", "Severe", "Moderate", "Minor")
34
+ attr_reader :severity
35
+
36
+ # @return [String, nil] Certainty level (e.g., "Observed", "Likely", "Possible")
37
+ attr_reader :certainty
38
+
39
+ # @return [String, nil] Urgency level (e.g., "Immediate", "Expected", "Future")
40
+ attr_reader :urgency
41
+
42
+ # @return [String, nil] Event type (e.g., "Tornado Warning", "Winter Storm Watch")
43
+ attr_reader :event
44
+
45
+ # @return [String, nil] Name of the sending organization
46
+ attr_reader :sender_name
47
+
48
+ # @return [String, nil] Alert headline
49
+ attr_reader :headline
50
+
51
+ # @return [String, nil] Detailed description of the alert
52
+ attr_reader :description
53
+
54
+ # @return [String, nil] Instructions for the public
55
+ attr_reader :instruction
56
+
57
+ # @return [String, nil] Recommended response type
58
+ attr_reader :response
59
+
60
+ # @return [Array<String>] URLs of affected forecast zones
61
+ attr_reader :affected_zones
62
+
63
+ # @return [Hash] Raw API response data
64
+ attr_reader :raw_data
65
+
66
+ # Initialize an Alert from API response data
67
+ #
68
+ # @param data [Hash] Parsed JSON response from the alerts API
69
+ def initialize(data)
70
+ @raw_data = data
71
+ parse_data(data)
72
+ end
73
+
74
+ # Fetch alerts from the NWS API
75
+ #
76
+ # @param state [String, nil] Two-letter state code to filter alerts
77
+ # @param point [Array<Float>, nil] Latitude and longitude pair [lat, lon]
78
+ # @param zone [String, nil] NWS zone identifier
79
+ # @param active [Boolean] Whether to fetch only active alerts
80
+ # @param client [Client] NWS API client instance
81
+ # @return [Array<Alert>] Array of Alert objects
82
+ def self.fetch(state: nil, point: nil, zone: nil, active: true, client: NWS.client)
83
+ params = {}
84
+ params[:status] = "actual" if active
85
+
86
+ path = if state
87
+ params[:area] = state.upcase
88
+ "/alerts/active"
89
+ elsif point
90
+ params[:point] = "#{point[0]},#{point[1]}"
91
+ "/alerts/active"
92
+ elsif zone
93
+ "/alerts/active/zone/#{zone}"
94
+ else
95
+ "/alerts/active"
96
+ end
97
+
98
+ data = client.get(path, params: params)
99
+ features = data["features"] || []
100
+ features.map { |f| new(f) }
101
+ end
102
+
103
+ # Fetch all available alert types
104
+ #
105
+ # @param client [Client] NWS API client instance
106
+ # @return [Array<String>] Array of alert event type names
107
+ def self.types(client: NWS.client)
108
+ data = client.get("/alerts/types")
109
+ data["eventTypes"] || []
110
+ end
111
+
112
+ # Check if the alert is currently active
113
+ #
114
+ # @return [Boolean] True if the alert has not yet expired
115
+ def active?
116
+ return false unless @expires
117
+ Time.now < @expires
118
+ end
119
+
120
+ # Check if this is a severe alert
121
+ #
122
+ # @return [Boolean] True if severity is "Extreme" or "Severe"
123
+ def severe?
124
+ %w[Extreme Severe].include?(@severity)
125
+ end
126
+
127
+ # Check if this is a warning
128
+ #
129
+ # @return [Boolean] True if the event name contains "warning"
130
+ def warning?
131
+ !!@event&.downcase&.include?("warning")
132
+ end
133
+
134
+ # Check if this is a watch
135
+ #
136
+ # @return [Boolean] True if the event name contains "watch"
137
+ def watch?
138
+ !!@event&.downcase&.include?("watch")
139
+ end
140
+
141
+ # Check if this is an advisory
142
+ #
143
+ # @return [Boolean] True if the event name contains "advisory"
144
+ def advisory?
145
+ !!@event&.downcase&.include?("advisory")
146
+ end
147
+
148
+ # Get a formatted string representation of the alert
149
+ #
150
+ # @return [String] Multi-line formatted alert summary
151
+ def to_s
152
+ lines = []
153
+ lines << "#{@event}"
154
+ lines << " Severity: #{@severity} | Urgency: #{@urgency} | Certainty: #{@certainty}"
155
+ lines << " Areas: #{@area_desc}"
156
+ lines << " Effective: #{@effective&.strftime('%Y-%m-%d %I:%M %p')}"
157
+ lines << " Expires: #{@expires&.strftime('%Y-%m-%d %I:%M %p')}"
158
+ lines << ""
159
+ lines << " #{@headline}"
160
+ lines.join("\n")
161
+ end
162
+
163
+ # Get a detailed formatted string representation of the alert
164
+ #
165
+ # @return [String] Multi-line detailed alert with description and instructions
166
+ def detailed
167
+ lines = []
168
+ lines << "#{@event}"
169
+ lines << "=" * @event.length
170
+ lines << ""
171
+ lines << "Severity: #{@severity}"
172
+ lines << "Urgency: #{@urgency}"
173
+ lines << "Certainty: #{@certainty}"
174
+ lines << ""
175
+ lines << "Areas Affected:"
176
+ lines << " #{@area_desc}"
177
+ lines << ""
178
+ lines << "Timing:"
179
+ lines << " Effective: #{@effective&.strftime('%Y-%m-%d %I:%M %p')}"
180
+ lines << " Expires: #{@expires&.strftime('%Y-%m-%d %I:%M %p')}"
181
+ lines << ""
182
+ lines << "Headline:"
183
+ lines << " #{@headline}"
184
+ lines << ""
185
+ lines << "Description:"
186
+ wrap_text(@description, 80).each { |line| lines << " #{line}" }
187
+ if @instruction
188
+ lines << ""
189
+ lines << "Instructions:"
190
+ wrap_text(@instruction, 80).each { |line| lines << " #{line}" }
191
+ end
192
+ lines.join("\n")
193
+ end
194
+
195
+ private
196
+
197
+ # Parse API response data into instance variables
198
+ #
199
+ # @param data [Hash] Parsed JSON response
200
+ # @return [void]
201
+ def parse_data(data)
202
+ props = data["properties"] || {}
203
+
204
+ @id = props["id"]
205
+ @area_desc = props["areaDesc"]
206
+ @sent = parse_time(props["sent"])
207
+ @effective = parse_time(props["effective"])
208
+ @onset = parse_time(props["onset"])
209
+ @expires = parse_time(props["expires"])
210
+ @ends = parse_time(props["ends"])
211
+ @status = props["status"]
212
+ @message_type = props["messageType"]
213
+ @severity = props["severity"]
214
+ @certainty = props["certainty"]
215
+ @urgency = props["urgency"]
216
+ @event = props["event"]
217
+ @sender_name = props["senderName"]
218
+ @headline = props["headline"]
219
+ @description = props["description"]
220
+ @instruction = props["instruction"]
221
+ @response = props["response"]
222
+ @affected_zones = props["affectedZones"] || []
223
+ end
224
+
225
+ # Parse an ISO8601 time string
226
+ #
227
+ # @param str [String, nil] Time string
228
+ # @return [Time, nil] Parsed time or nil
229
+ def parse_time(str)
230
+ return nil unless str
231
+ Time.parse(str)
232
+ rescue ArgumentError
233
+ nil
234
+ end
235
+
236
+ # Wrap text to a specified width
237
+ #
238
+ # @param text [String, nil] Text to wrap
239
+ # @param width [Integer] Maximum line width
240
+ # @return [Array<String>] Array of wrapped lines
241
+ def wrap_text(text, width)
242
+ return [] unless text
243
+ text.split("\n").flat_map do |paragraph|
244
+ if paragraph.strip.empty?
245
+ [""]
246
+ else
247
+ words = paragraph.split
248
+ lines = []
249
+ current_line = ""
250
+
251
+ words.each do |word|
252
+ if current_line.empty?
253
+ current_line = word
254
+ elsif (current_line.length + word.length + 1) <= width
255
+ current_line += " #{word}"
256
+ else
257
+ lines << current_line
258
+ current_line = word
259
+ end
260
+ end
261
+ lines << current_line unless current_line.empty?
262
+ lines
263
+ end
264
+ end
265
+ end
266
+ end
267
+ end
data/lib/nws/client.rb ADDED
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module NWS
8
+ # HTTP client for making requests to the NWS API
9
+ class Client
10
+ # Base URL for the NWS API
11
+ BASE_URL = "https://api.weather.gov"
12
+
13
+ # @return [String] User-Agent string sent with requests
14
+ attr_reader :user_agent
15
+
16
+ # Initialize a new NWS API client
17
+ #
18
+ # @param user_agent [String, nil] Custom User-Agent string. If nil, uses default.
19
+ def initialize(user_agent: nil)
20
+ @user_agent = user_agent || default_user_agent
21
+ end
22
+
23
+ # Make a GET request to the NWS API
24
+ #
25
+ # @param path [String] API path (e.g., "/points/38.8977,-77.0365")
26
+ # @param params [Hash] Query parameters
27
+ # @return [Hash] Parsed JSON response
28
+ # @raise [NotFoundError] if resource is not found (404)
29
+ # @raise [RateLimitError] if rate limit exceeded (429)
30
+ # @raise [ServerError] if server error (5xx)
31
+ # @raise [APIError] for other API errors
32
+ def get(path, params: {})
33
+ uri = build_uri(path, params)
34
+ request = Net::HTTP::Get.new(uri)
35
+ request["User-Agent"] = user_agent
36
+ request["Accept"] = "application/geo+json"
37
+
38
+ response = execute_request(uri, request)
39
+ handle_response(response)
40
+ end
41
+
42
+ # Make a GET request to a full URL
43
+ #
44
+ # @param url [String] Full URL to request
45
+ # @return [Hash] Parsed JSON response
46
+ # @raise [NotFoundError] if resource is not found (404)
47
+ # @raise [RateLimitError] if rate limit exceeded (429)
48
+ # @raise [ServerError] if server error (5xx)
49
+ # @raise [APIError] for other API errors
50
+ def get_url(url)
51
+ uri = URI.parse(url)
52
+ request = Net::HTTP::Get.new(uri)
53
+ request["User-Agent"] = user_agent
54
+ request["Accept"] = "application/geo+json"
55
+
56
+ response = execute_request(uri, request)
57
+ handle_response(response)
58
+ end
59
+
60
+ private
61
+
62
+ # Build a URI from path and params
63
+ #
64
+ # @param path [String] API path
65
+ # @param params [Hash] Query parameters
66
+ # @return [URI] Constructed URI
67
+ def build_uri(path, params)
68
+ uri = URI.parse("#{BASE_URL}#{path}")
69
+ uri.query = URI.encode_www_form(params) unless params.empty?
70
+ uri
71
+ end
72
+
73
+ # Execute an HTTP request
74
+ #
75
+ # @param uri [URI] Request URI
76
+ # @param request [Net::HTTP::Request] Request object
77
+ # @return [Net::HTTPResponse] Response object
78
+ def execute_request(uri, request)
79
+ http = Net::HTTP.new(uri.host, uri.port)
80
+ http.use_ssl = (uri.scheme == "https")
81
+ http.open_timeout = 10
82
+ http.read_timeout = 30
83
+ http.request(request)
84
+ end
85
+
86
+ # Handle API response, raising errors for non-success codes
87
+ #
88
+ # @param response [Net::HTTPResponse] Response object
89
+ # @return [Hash] Parsed JSON response
90
+ # @raise [NotFoundError, RateLimitError, ServerError, APIError] for error responses
91
+ def handle_response(response)
92
+ case response.code.to_i
93
+ when 200..299
94
+ JSON.parse(response.body)
95
+ when 404
96
+ raise NotFoundError.new("Resource not found", status_code: 404, response_body: response.body)
97
+ when 429
98
+ raise RateLimitError.new("Rate limit exceeded. Retry after a few seconds.", status_code: 429, response_body: response.body)
99
+ when 500..599
100
+ raise ServerError.new("Server error: #{response.code}", status_code: response.code.to_i, response_body: response.body)
101
+ else
102
+ raise APIError.new("API error: #{response.code}", status_code: response.code.to_i, response_body: response.body)
103
+ end
104
+ end
105
+
106
+ # Generate default User-Agent string
107
+ #
108
+ # @return [String] Default User-Agent
109
+ def default_user_agent
110
+ "(nws-ruby-gem, #{NWS::VERSION})"
111
+ end
112
+ end
113
+ end
data/lib/nws/errors.rb ADDED
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NWS
4
+ # Base error class for all NWS errors
5
+ class Error < StandardError; end
6
+
7
+ # Error raised when the NWS API returns an error response
8
+ class APIError < Error
9
+ # @return [Integer, nil] HTTP status code from the API response
10
+ attr_reader :status_code
11
+
12
+ # @return [String, nil] Raw response body from the API
13
+ attr_reader :response_body
14
+
15
+ # @param message [String] Error message
16
+ # @param status_code [Integer, nil] HTTP status code
17
+ # @param response_body [String, nil] Raw response body
18
+ def initialize(message, status_code: nil, response_body: nil)
19
+ @status_code = status_code
20
+ @response_body = response_body
21
+ super(message)
22
+ end
23
+ end
24
+
25
+ # Error raised when a requested resource is not found (404)
26
+ class NotFoundError < APIError; end
27
+
28
+ # Error raised when rate limit is exceeded (429)
29
+ class RateLimitError < APIError; end
30
+
31
+ # Error raised when the server returns a 5xx error
32
+ class ServerError < APIError; end
33
+
34
+ # Error raised when invalid coordinates are provided
35
+ class InvalidCoordinatesError < Error; end
36
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NWS
4
+ # Represents a 7-day weather forecast with 12-hour periods
5
+ class Forecast
6
+ # @return [Point, nil] The point this forecast is for
7
+ attr_reader :point
8
+
9
+ # @return [Time, nil] When the forecast was last updated
10
+ attr_reader :updated_at
11
+
12
+ # @return [Array<ForecastPeriod>] Forecast periods (typically 14, covering 7 days)
13
+ attr_reader :periods
14
+
15
+ # @return [Hash] Raw API response data
16
+ attr_reader :raw_data
17
+
18
+ # Initialize a Forecast from API response data
19
+ #
20
+ # @param data [Hash] Parsed JSON response from the forecast API
21
+ # @param point [Point, nil] The point this forecast is for
22
+ def initialize(data, point: nil)
23
+ @point = point
24
+ @raw_data = data
25
+ parse_data(data)
26
+ end
27
+
28
+ # Get today's forecast period
29
+ #
30
+ # @return [ForecastPeriod] First forecast period (typically today)
31
+ def today
32
+ @periods.first
33
+ end
34
+
35
+ # Get tonight's forecast period
36
+ #
37
+ # @return [ForecastPeriod, nil] Tonight's forecast period or nil if not found
38
+ def tonight
39
+ @periods.find { |p| p.name.downcase.include?("tonight") || p.name.downcase.include?("night") }
40
+ end
41
+
42
+ # Get a formatted string representation of the forecast
43
+ #
44
+ # @return [String] Multi-line formatted forecast
45
+ def to_s
46
+ lines = []
47
+ lines << "Forecast for #{point&.location_string || 'Unknown Location'}"
48
+ lines << "Updated: #{updated_at&.strftime('%Y-%m-%d %I:%M %p')}"
49
+ lines << ""
50
+
51
+ @periods.each do |period|
52
+ lines << "#{period.name}: #{period.temperature}°#{period.temperature_unit}"
53
+ lines << " #{period.short_forecast}"
54
+ lines << ""
55
+ end
56
+
57
+ lines.join("\n")
58
+ end
59
+
60
+ # Get a detailed formatted string representation of the forecast
61
+ #
62
+ # @return [String] Multi-line detailed forecast
63
+ def detailed
64
+ lines = []
65
+ lines << "Forecast for #{point&.location_string || 'Unknown Location'}"
66
+ lines << "Updated: #{updated_at&.strftime('%Y-%m-%d %I:%M %p')}"
67
+ lines << ""
68
+
69
+ @periods.each do |period|
70
+ lines << "#{period.name}:"
71
+ lines << " #{period.detailed_forecast}"
72
+ lines << ""
73
+ end
74
+
75
+ lines.join("\n")
76
+ end
77
+
78
+ private
79
+
80
+ # Parse API response data into instance variables
81
+ #
82
+ # @param data [Hash] Parsed JSON response
83
+ # @return [void]
84
+ def parse_data(data)
85
+ props = data["properties"] || {}
86
+ @updated_at = parse_time(props["updateTime"])
87
+
88
+ raw_periods = props["periods"] || []
89
+ @periods = raw_periods.map { |p| ForecastPeriod.new(p) }
90
+ end
91
+
92
+ # Parse an ISO8601 time string
93
+ #
94
+ # @param str [String, nil] Time string
95
+ # @return [Time, nil] Parsed time or nil
96
+ def parse_time(str)
97
+ return nil unless str
98
+ Time.parse(str)
99
+ rescue ArgumentError
100
+ nil
101
+ end
102
+ end
103
+
104
+ # Represents a single forecast period (typically 12 hours)
105
+ class ForecastPeriod
106
+ # @return [Integer] Period number
107
+ attr_reader :number
108
+
109
+ # @return [String] Period name (e.g., "Today", "Tonight", "Friday")
110
+ attr_reader :name
111
+
112
+ # @return [Time, nil] Start time of the period
113
+ attr_reader :start_time
114
+
115
+ # @return [Time, nil] End time of the period
116
+ attr_reader :end_time
117
+
118
+ # @return [Boolean] Whether this is a daytime period
119
+ attr_reader :is_daytime
120
+
121
+ # @return [Integer] Temperature value
122
+ attr_reader :temperature
123
+
124
+ # @return [String] Temperature unit (e.g., "F")
125
+ attr_reader :temperature_unit
126
+
127
+ # @return [String, nil] Temperature trend
128
+ attr_reader :temperature_trend
129
+
130
+ # @return [Integer, nil] Probability of precipitation (0-100)
131
+ attr_reader :probability_of_precipitation
132
+
133
+ # @return [String] Wind speed (e.g., "10 mph")
134
+ attr_reader :wind_speed
135
+
136
+ # @return [String] Wind direction (e.g., "NW")
137
+ attr_reader :wind_direction
138
+
139
+ # @return [String] Short forecast description
140
+ attr_reader :short_forecast
141
+
142
+ # @return [String] Detailed forecast description
143
+ attr_reader :detailed_forecast
144
+
145
+ # @return [String] URL to forecast icon
146
+ attr_reader :icon
147
+
148
+ # @return [Hash] Raw API response data
149
+ attr_reader :raw_data
150
+
151
+ # Initialize a ForecastPeriod from API response data
152
+ #
153
+ # @param data [Hash] Parsed JSON period data
154
+ def initialize(data)
155
+ @raw_data = data
156
+ parse_data(data)
157
+ end
158
+
159
+ # Check if this is a daytime period
160
+ #
161
+ # @return [Boolean] True if daytime period
162
+ def daytime?
163
+ @is_daytime
164
+ end
165
+
166
+ # Get a formatted string representation of the period
167
+ #
168
+ # @return [String] Formatted period string
169
+ def to_s
170
+ "#{@name}: #{@temperature}°#{@temperature_unit} - #{@short_forecast}"
171
+ end
172
+
173
+ private
174
+
175
+ # Parse API response data into instance variables
176
+ #
177
+ # @param data [Hash] Parsed JSON period data
178
+ # @return [void]
179
+ def parse_data(data)
180
+ @number = data["number"]
181
+ @name = data["name"]
182
+ @start_time = parse_time(data["startTime"])
183
+ @end_time = parse_time(data["endTime"])
184
+ @is_daytime = data["isDaytime"]
185
+ @temperature = data["temperature"]
186
+ @temperature_unit = data["temperatureUnit"]
187
+ @temperature_trend = data["temperatureTrend"]
188
+ @probability_of_precipitation = data.dig("probabilityOfPrecipitation", "value")
189
+ @wind_speed = data["windSpeed"]
190
+ @wind_direction = data["windDirection"]
191
+ @short_forecast = data["shortForecast"]
192
+ @detailed_forecast = data["detailedForecast"]
193
+ @icon = data["icon"]
194
+ end
195
+
196
+ # Parse an ISO8601 time string
197
+ #
198
+ # @param str [String, nil] Time string
199
+ # @return [Time, nil] Parsed time or nil
200
+ def parse_time(str)
201
+ return nil unless str
202
+ Time.parse(str)
203
+ rescue ArgumentError
204
+ nil
205
+ end
206
+ end
207
+ end