generatorlabs 2.0.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.
@@ -0,0 +1,297 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # This file is part of the Generator Labs Ruby SDK package.
5
+ #
6
+ # (c) Generator Labs <support@generatorlabs.com>
7
+ #
8
+ # For the full copyright and license information, please view the LICENSE
9
+ # file that was distributed with this source code.
10
+ #
11
+
12
+ module GeneratorLabs
13
+ # RBL monitoring API namespace
14
+ class RBL
15
+ def initialize(handler)
16
+ @handler = handler
17
+ end
18
+
19
+ # Access host management operations
20
+ # @return [RBLHosts]
21
+ def hosts
22
+ @hosts ||= RBLHosts.new(@handler)
23
+ end
24
+
25
+ # Access profile management operations
26
+ # @return [RBLProfiles]
27
+ def profiles
28
+ @profiles ||= RBLProfiles.new(@handler)
29
+ end
30
+
31
+ # Access source management operations
32
+ # @return [RBLSources]
33
+ def sources
34
+ @sources ||= RBLSources.new(@handler)
35
+ end
36
+
37
+ # Access manual RBL check operations
38
+ # @return [RBLCheck]
39
+ def check
40
+ @check ||= RBLCheck.new(@handler)
41
+ end
42
+
43
+ # Get current RBL listings for monitored hosts
44
+ # @return [Hash] Listing information
45
+ def listings
46
+ @handler.get('rbl/listings')
47
+ end
48
+ end
49
+
50
+ # Manual RBL check operations
51
+ class RBLCheck
52
+ def initialize(handler)
53
+ @handler = handler
54
+ end
55
+
56
+ # Start a manual RBL check
57
+ # @param params [Hash] Check parameters (host, callback, details)
58
+ # @return [Hash] Check result with check ID
59
+ def start(params)
60
+ @handler.post('rbl/check/start', params)
61
+ end
62
+
63
+ # Get the status of a manual RBL check
64
+ # @param id [String] Check ID
65
+ # @param params [Hash] Optional parameters (details)
66
+ # @return [Hash] Check status
67
+ def status(id, params = {})
68
+ @handler.get("rbl/check/status/#{id}", params)
69
+ end
70
+ end
71
+
72
+ # RBL host management
73
+ class RBLHosts
74
+ def initialize(handler)
75
+ @handler = handler
76
+ end
77
+
78
+ # Get hosts (all, by ID, or by array of IDs)
79
+ # @param ids [nil, String, Integer, Hash, Array] Optional ID(s) or parameters
80
+ # @return [Hash] Host data
81
+ def get(*ids)
82
+ return @handler.get('rbl/hosts') if ids.empty?
83
+
84
+ # Check if first argument is a hash (parameters)
85
+ return @handler.get('rbl/hosts', ids.first) if ids.first.is_a?(Hash)
86
+
87
+ return @handler.get("rbl/hosts/#{ids.first}") if ids.length == 1
88
+
89
+ @handler.get('rbl/hosts', { ids: ids.join(',') })
90
+ end
91
+
92
+ # Get all hosts with automatic pagination
93
+ # @param params [Hash] Optional parameters (e.g., page_size)
94
+ # @return [Array] All hosts across all pages
95
+ def get_all(params = {})
96
+ all_items = []
97
+ page = 1
98
+ params = params.dup
99
+
100
+ loop do
101
+ params[:page] = page
102
+ response = get(params)
103
+ hosts = response['hosts'] || []
104
+
105
+ all_items.concat(hosts)
106
+
107
+ break unless page < (response['total_pages'] || 1) && !hosts.empty?
108
+
109
+ page += 1
110
+ end
111
+
112
+ all_items
113
+ end
114
+
115
+ # Create a new monitored host
116
+ # @param params [Hash] Host parameters
117
+ # @return [Hash] Created host data
118
+ def create(params)
119
+ @handler.post('rbl/hosts', params)
120
+ end
121
+
122
+ # Update a monitored host
123
+ # @param id [String, Integer] Host ID
124
+ # @param params [Hash] Updated parameters
125
+ # @return [Hash] Updated host data
126
+ def update(id, params)
127
+ @handler.put("rbl/hosts/#{id}", params)
128
+ end
129
+
130
+ # Delete a monitored host
131
+ # @param id [String, Integer] Host ID
132
+ # @return [Hash] Deletion confirmation
133
+ def delete(id)
134
+ @handler.delete("rbl/hosts/#{id}")
135
+ end
136
+
137
+ # Pause monitoring for a host
138
+ # @param id [String, Integer] Host ID
139
+ # @return [Hash] Response
140
+ def pause(id)
141
+ @handler.post("rbl/hosts/#{id}/pause")
142
+ end
143
+
144
+ # Resume monitoring for a host
145
+ # @param id [String, Integer] Host ID
146
+ # @return [Hash] Response
147
+ def resume(id)
148
+ @handler.post("rbl/hosts/#{id}/resume")
149
+ end
150
+ end
151
+
152
+ # RBL profile management
153
+ class RBLProfiles
154
+ def initialize(handler)
155
+ @handler = handler
156
+ end
157
+
158
+ # Get profiles (all, by ID, or by array of IDs)
159
+ # @param ids [nil, String, Integer, Hash, Array] Optional ID(s) or parameters
160
+ # @return [Hash] Profile data
161
+ def get(*ids)
162
+ return @handler.get('rbl/profiles') if ids.empty?
163
+
164
+ # Check if first argument is a hash (parameters)
165
+ return @handler.get('rbl/profiles', ids.first) if ids.first.is_a?(Hash)
166
+
167
+ return @handler.get("rbl/profiles/#{ids.first}") if ids.length == 1
168
+
169
+ @handler.get('rbl/profiles', { ids: ids.join(',') })
170
+ end
171
+
172
+ # Get all profiles with automatic pagination
173
+ # @param params [Hash] Optional parameters (e.g., page_size)
174
+ # @return [Array] All profiles across all pages
175
+ def get_all(params = {})
176
+ all_items = []
177
+ page = 1
178
+ params = params.dup
179
+
180
+ loop do
181
+ params[:page] = page
182
+ response = get(params)
183
+ profiles = response['profiles'] || []
184
+
185
+ all_items.concat(profiles)
186
+
187
+ break unless page < (response['total_pages'] || 1) && !profiles.empty?
188
+
189
+ page += 1
190
+ end
191
+
192
+ all_items
193
+ end
194
+
195
+ # Create a new monitoring profile
196
+ # @param params [Hash] Profile parameters
197
+ # @return [Hash] Created profile data
198
+ def create(params)
199
+ @handler.post('rbl/profiles', params)
200
+ end
201
+
202
+ # Update a monitoring profile
203
+ # @param id [String, Integer] Profile ID
204
+ # @param params [Hash] Updated parameters
205
+ # @return [Hash] Updated profile data
206
+ def update(id, params)
207
+ @handler.put("rbl/profiles/#{id}", params)
208
+ end
209
+
210
+ # Delete a monitoring profile
211
+ # @param id [String, Integer] Profile ID
212
+ # @return [Hash] Deletion confirmation
213
+ def delete(id)
214
+ @handler.delete("rbl/profiles/#{id}")
215
+ end
216
+ end
217
+
218
+ # RBL source management
219
+ class RBLSources
220
+ def initialize(handler)
221
+ @handler = handler
222
+ end
223
+
224
+ # Get sources (all, by ID, or by array of IDs)
225
+ # @param ids [nil, String, Integer, Hash, Array] Optional ID(s) or parameters
226
+ # @return [Hash] Source data
227
+ def get(*ids)
228
+ return @handler.get('rbl/sources') if ids.empty?
229
+
230
+ # Check if first argument is a hash (parameters)
231
+ return @handler.get('rbl/sources', ids.first) if ids.first.is_a?(Hash)
232
+
233
+ return @handler.get("rbl/sources/#{ids.first}") if ids.length == 1
234
+
235
+ @handler.get('rbl/sources', { ids: ids.join(',') })
236
+ end
237
+
238
+ # Get all sources with automatic pagination
239
+ # @param params [Hash] Optional parameters (e.g., page_size)
240
+ # @return [Array] All sources across all pages
241
+ def get_all(params = {})
242
+ all_items = []
243
+ page = 1
244
+ params = params.dup
245
+
246
+ loop do
247
+ params[:page] = page
248
+ response = get(params)
249
+ sources = response['sources'] || []
250
+
251
+ all_items.concat(sources)
252
+
253
+ break unless page < (response['total_pages'] || 1) && !sources.empty?
254
+
255
+ page += 1
256
+ end
257
+
258
+ all_items
259
+ end
260
+
261
+ # Create a new RBL source
262
+ # @param params [Hash] Source parameters
263
+ # @return [Hash] Created source data
264
+ def create(params)
265
+ @handler.post('rbl/sources', params)
266
+ end
267
+
268
+ # Update an RBL source
269
+ # @param id [String, Integer] Source ID
270
+ # @param params [Hash] Updated parameters
271
+ # @return [Hash] Updated source data
272
+ def update(id, params)
273
+ @handler.put("rbl/sources/#{id}", params)
274
+ end
275
+
276
+ # Delete an RBL source
277
+ # @param id [String, Integer] Source ID
278
+ # @return [Hash] Deletion confirmation
279
+ def delete(id)
280
+ @handler.delete("rbl/sources/#{id}")
281
+ end
282
+
283
+ # Pause an RBL source
284
+ # @param id [String, Integer] Source ID
285
+ # @return [Hash] Response
286
+ def pause(id)
287
+ @handler.post("rbl/sources/#{id}/pause")
288
+ end
289
+
290
+ # Resume an RBL source
291
+ # @param id [String, Integer] Source ID
292
+ # @return [Hash] Response
293
+ def resume(id)
294
+ @handler.post("rbl/sources/#{id}/resume")
295
+ end
296
+ end
297
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # This file is part of the Generator Labs Ruby SDK package.
5
+ #
6
+ # (c) Generator Labs <support@generatorlabs.com>
7
+ #
8
+ # For the full copyright and license information, please view the LICENSE
9
+ # file that was distributed with this source code.
10
+ #
11
+
12
+ require 'faraday'
13
+ require 'faraday/retry'
14
+ require 'json'
15
+
16
+ module GeneratorLabs
17
+ # Handles HTTP requests to the Generator Labs API.
18
+ #
19
+ # This class manages HTTP client configuration, authentication, retry logic,
20
+ # and error handling for all API requests. It automatically retries failed
21
+ # requests using exponential backoff on connection errors, 5xx server errors,
22
+ # and 429 rate limit errors.
23
+ class RequestHandler
24
+ # Initialize request handler with authentication and retry logic.
25
+ #
26
+ # This creates a Faraday connection with:
27
+ # - Automatic retries on connection errors, 5xx errors, and 429 rate limits
28
+ # - Exponential backoff based on config.retry_backoff
29
+ # - Configurable timeouts from config.timeout and config.connect_timeout
30
+ # - HTTP Basic Authentication using account_sid and auth_token
31
+ #
32
+ # @param account_sid [String] Account SID for authentication
33
+ # @param auth_token [String] Auth token for authentication
34
+ # @param config [Config] Configuration object with timeouts and retry settings
35
+ def initialize(account_sid, auth_token, config)
36
+ @account_sid = account_sid
37
+ @auth_token = auth_token
38
+ @config = config
39
+
40
+ # Create Faraday connection with retry middleware
41
+ @connection = Faraday.new(url: config.base_url) do |faraday_config|
42
+ faraday_config.request :url_encoded
43
+ faraday_config.request :authorization, :basic, account_sid, auth_token
44
+ # Retry with exponential backoff; faraday-retry respects Retry-After
45
+ # headers automatically on rate limit (429) responses
46
+ faraday_config.request :retry,
47
+ max: config.max_retries,
48
+ interval: 1.0,
49
+ backoff_factor: config.retry_backoff,
50
+ retry_statuses: [429, 500, 502, 503, 504],
51
+ methods: %i[get post put delete]
52
+
53
+ faraday_config.adapter Faraday.default_adapter
54
+ faraday_config.options.timeout = config.timeout
55
+ faraday_config.options.open_timeout = config.connect_timeout
56
+ end
57
+ end
58
+
59
+ # Make a GET request to the API.
60
+ #
61
+ # Parameters are sent as query string parameters. The request includes
62
+ # automatic retry logic for failures.
63
+ #
64
+ # @param path [String] API endpoint path (e.g., 'rbl/hosts')
65
+ # @param params [Hash, nil] Query parameters (e.g., {status: 'active'})
66
+ # @return [Response] Response wrapper with parsed data and rate limit info
67
+ # @raise [Error] if request fails after all retries
68
+ #
69
+ # @example
70
+ # handler.get('rbl/hosts', { status: 'active' })
71
+ def get(path, params = nil)
72
+ make_request(:get, path, params)
73
+ end
74
+
75
+ # Make a POST request to the API.
76
+ #
77
+ # Parameters are sent as application/x-www-form-urlencoded data.
78
+ # The request includes automatic retry logic for failures.
79
+ #
80
+ # @param path [String] API endpoint path (e.g., 'rbl/hosts')
81
+ # @param params [Hash, nil] Request parameters (e.g., {name: 'My Host', host: '1.2.3.4'})
82
+ # @return [Response] Response wrapper with parsed data and rate limit info
83
+ # @raise [Error] if request fails after all retries
84
+ #
85
+ # @example
86
+ # handler.post('rbl/hosts', { name: 'My Host', host: '1.2.3.4', type: 'rbl' })
87
+ def post(path, params = nil)
88
+ make_request(:post, path, params)
89
+ end
90
+
91
+ # Make a PUT request to the API.
92
+ #
93
+ # Parameters are sent as application/x-www-form-urlencoded data.
94
+ # The request includes automatic retry logic for failures.
95
+ #
96
+ # @param path [String] API endpoint path (e.g., 'rbl/hosts/HTxxxxxxxx')
97
+ # @param params [Hash, nil] Request parameters (e.g., {name: 'Updated Name'})
98
+ # @return [Response] Response wrapper with parsed data and rate limit info
99
+ # @raise [Error] if request fails after all retries
100
+ #
101
+ # @example
102
+ # handler.put('rbl/hosts/HT1a2b3c4d5e6f7890abcdef1234567890', { name: 'Updated Name' })
103
+ def put(path, params = nil)
104
+ make_request(:put, path, params)
105
+ end
106
+
107
+ # Make a DELETE request to the API.
108
+ #
109
+ # No parameters are sent with DELETE requests. The request includes
110
+ # automatic retry logic for failures.
111
+ #
112
+ # @param path [String] API endpoint path (e.g., 'rbl/hosts/HTxxxxxxxx')
113
+ # @return [Response] Response wrapper with parsed data and rate limit info
114
+ # @raise [Error] if request fails after all retries
115
+ #
116
+ # @example
117
+ # handler.delete('rbl/hosts/HT1a2b3c4d5e6f7890abcdef1234567890')
118
+ def delete(path)
119
+ make_request(:delete, path, nil)
120
+ end
121
+
122
+ private
123
+
124
+ # Make HTTP request to API with automatic retries.
125
+ #
126
+ # This method handles:
127
+ # - Building the request URL with .json extension
128
+ # - Adding query parameters for GET requests
129
+ # - Adding form data for POST/PUT/DELETE requests
130
+ # - Setting authentication headers
131
+ # - Executing the request with retry logic
132
+ # - Parsing JSON responses
133
+ # - Checking for API errors in the response
134
+ #
135
+ # Errors are raised if:
136
+ # - All retry attempts fail
137
+ # - The response body cannot be parsed
138
+ # - The API returns success=false in the response
139
+ # - The HTTP status code is >= 400
140
+ #
141
+ # @param method [Symbol] HTTP method (:get, :post, :put, :delete)
142
+ # @param path [String] API endpoint path
143
+ # @param params [Hash, nil] Request parameters
144
+ # @return [Response] Response wrapper with parsed data and rate limit info
145
+ # @raise [Error] if request fails or response is invalid
146
+ def make_request(method, path, params)
147
+ url = "#{path}.json"
148
+ params = convert_params(params)
149
+
150
+ response = @connection.send(method) do |req|
151
+ req.url url
152
+ req.headers['User-Agent'] = "GeneratorLabs-Ruby/#{VERSION}"
153
+ req.headers['Accept'] = 'application/json'
154
+ apply_params(req, method, params)
155
+ end
156
+
157
+ data = parse_response(response)
158
+
159
+ #
160
+ # parse rate limit headers
161
+ #
162
+ rate_limit_info = nil
163
+ if response.headers['ratelimit-limit']
164
+ rate_limit_info = RateLimitInfo.new(
165
+ limit: response.headers['ratelimit-limit'],
166
+ remaining: response.headers['ratelimit-remaining'].to_i,
167
+ reset: response.headers['ratelimit-reset'].to_i
168
+ )
169
+ end
170
+
171
+ Response.new(data, rate_limit_info)
172
+ rescue Faraday::Error => e
173
+ raise Error, "API request failed: #{e.message}"
174
+ end
175
+
176
+ # Convert array values to comma-separated strings for form encoding.
177
+ def convert_params(params)
178
+ return unless params
179
+
180
+ params.transform_values { |v| v.is_a?(Array) ? v.join(',') : v }
181
+ end
182
+
183
+ # Apply parameters to request based on HTTP method.
184
+ def apply_params(req, method, params)
185
+ return unless params
186
+
187
+ if method == :get
188
+ req.params = params
189
+ else
190
+ req.body = params
191
+ end
192
+ end
193
+
194
+ # Parse JSON response and check for errors.
195
+ def parse_response(response)
196
+ data = JSON.parse(response.body)
197
+
198
+ if data.is_a?(Hash) && data['success'] == false
199
+ error_msg = data.dig('error', 'message') || data['message'] || 'Unknown error'
200
+ raise Error, "API error: #{error_msg}"
201
+ end
202
+
203
+ if response.status >= 400
204
+ error_msg = data.dig('error', 'message') || data['message'] || "HTTP #{response.status} error"
205
+ raise Error, "API error: #{error_msg}"
206
+ end
207
+
208
+ data
209
+ rescue JSON::ParserError => e
210
+ raise Error, "Failed to parse JSON response: #{e.message}"
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # This file is part of the Generator Labs Ruby SDK package.
5
+ #
6
+ # (c) Generator Labs <support@generatorlabs.com>
7
+ #
8
+ # For the full copyright and license information, please view the LICENSE
9
+ # file that was distributed with this source code.
10
+ #
11
+
12
+ module GeneratorLabs
13
+ # API response wrapper providing Hash-like access to response data and rate limit info.
14
+ #
15
+ # Supports bracket notation (response['key']) for backward compatibility
16
+ # with raw Hash returns, while also exposing rate_limit_info.
17
+ class Response
18
+ # @return [RateLimitInfo, nil] Rate limit information from response headers
19
+ attr_reader :rate_limit_info
20
+
21
+ # @param data [Hash] Parsed JSON response body
22
+ # @param rate_limit_info [RateLimitInfo, nil] Rate limit information
23
+ def initialize(data, rate_limit_info = nil)
24
+ @data = data
25
+ @rate_limit_info = rate_limit_info
26
+ end
27
+
28
+ # Access response data by key (Hash-like access).
29
+ # @param key [String] The key to look up
30
+ # @return [Object] The value
31
+ def [](key)
32
+ @data[key]
33
+ end
34
+
35
+ # Dig into nested response data.
36
+ # @param keys [Array] Keys to dig through
37
+ # @return [Object] The nested value
38
+ def dig(*keys)
39
+ @data.dig(*keys)
40
+ end
41
+
42
+ # Check if a key exists in the response data.
43
+ # @param key [String] The key to check
44
+ # @return [Boolean]
45
+ def key?(key)
46
+ @data.key?(key)
47
+ end
48
+
49
+ # Convert to a plain Hash.
50
+ # @return [Hash]
51
+ def to_h
52
+ @data
53
+ end
54
+
55
+ # Check if the response data is a Hash (for type compatibility).
56
+ # @param klass [Class] The class to check
57
+ # @return [Boolean]
58
+ def is_a?(klass)
59
+ klass == Hash ? true : super
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # This file is part of the Generator Labs Ruby SDK package.
5
+ #
6
+ # (c) Generator Labs <support@generatorlabs.com>
7
+ #
8
+ # For the full copyright and license information, please view the LICENSE
9
+ # file that was distributed with this source code.
10
+ #
11
+
12
+ module GeneratorLabs
13
+ # Current version of the SDK
14
+ VERSION = '2.0.0'
15
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # This file is part of the Generator Labs Ruby SDK package.
5
+ #
6
+ # (c) Generator Labs <support@generatorlabs.com>
7
+ #
8
+ # For the full copyright and license information, please view the LICENSE
9
+ # file that was distributed with this source code.
10
+ #
11
+
12
+ require 'openssl'
13
+ require 'json'
14
+
15
+ module GeneratorLabs
16
+ # Webhook signature verification utility.
17
+ #
18
+ # Verifies that incoming webhook requests were sent by Generator Labs
19
+ # using HMAC-SHA256 signatures.
20
+ #
21
+ # @example
22
+ # payload = GeneratorLabs::Webhook.verify(body, header, signing_secret)
23
+ class Webhook
24
+ # Default tolerance in seconds for timestamp validation (5 minutes).
25
+ DEFAULT_TOLERANCE = 300
26
+
27
+ # Verify a webhook signature and return the decoded payload.
28
+ #
29
+ # @param body [String] The raw request body string
30
+ # @param header [String] The X-Webhook-Signature header value
31
+ # @param secret [String] Your webhook's signing secret
32
+ # @param tolerance [Integer] Maximum age in seconds (0 to disable, default: 300)
33
+ # @return [Hash] The decoded JSON payload
34
+ # @raise [Error] if verification fails
35
+ def self.verify(body, header, secret, tolerance = DEFAULT_TOLERANCE)
36
+ raise Error, 'Missing X-Webhook-Signature header.' if header.nil? || header.empty?
37
+
38
+ # Parse the header: t=timestamp,v1=signature
39
+ parts = {}
40
+ header.split(',').each do |part|
41
+ key, value = part.split('=', 2)
42
+ parts[key] = value
43
+ end
44
+
45
+ raise Error, 'Invalid X-Webhook-Signature header format.' unless parts.key?('t') && parts.key?('v1')
46
+
47
+ # Check timestamp tolerance
48
+ if tolerance.positive? && (Time.now.to_i - parts['t'].to_i).abs > tolerance
49
+ raise Error, 'Webhook timestamp is outside the tolerance window.'
50
+ end
51
+
52
+ # Compute and compare the signature
53
+ expected = OpenSSL::HMAC.hexdigest('sha256', secret, "#{parts['t']}.#{body}")
54
+
55
+ raise Error, 'Webhook signature verification failed.' unless secure_compare(expected, parts['v1'])
56
+
57
+ # Decode and return the payload
58
+ JSON.parse(body)
59
+ end
60
+
61
+ # Constant-time string comparison.
62
+ def self.secure_compare(left, right)
63
+ return false unless left.bytesize == right.bytesize
64
+
65
+ OpenSSL.fixed_length_secure_compare(left, right)
66
+ end
67
+
68
+ private_class_method :secure_compare
69
+ end
70
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # This file is part of the Generator Labs Ruby SDK package.
5
+ #
6
+ # (c) Generator Labs <support@generatorlabs.com>
7
+ #
8
+ # For the full copyright and license information, please view the LICENSE
9
+ # file that was distributed with this source code.
10
+ #
11
+
12
+ require_relative 'generatorlabs/version'
13
+ require_relative 'generatorlabs/config'
14
+ require_relative 'generatorlabs/rate_limit_info'
15
+ require_relative 'generatorlabs/response'
16
+ require_relative 'generatorlabs/client'
17
+ require_relative 'generatorlabs/request_handler'
18
+ require_relative 'generatorlabs/webhook'
19
+ require_relative 'generatorlabs/rbl'
20
+ require_relative 'generatorlabs/contact'
21
+ require_relative 'generatorlabs/cert'
22
+
23
+ # Generator Labs API SDK
24
+ module GeneratorLabs
25
+ class Error < StandardError; end
26
+ end