zerobounce-sdk 2.1.8 → 3.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +172 -19
- data/README.md +477 -410
- data/lib/zerobounce/api_key.rb +21 -0
- data/lib/zerobounce/api_urls.rb +9 -0
- data/lib/zerobounce/base_request.rb +260 -8
- data/lib/zerobounce/client.rb +30 -0
- data/lib/zerobounce/configuration.rb +24 -3
- data/lib/zerobounce/error.rb +20 -0
- data/lib/zerobounce/get_file_helper.rb +6 -8
- data/lib/zerobounce/priority3.rb +351 -0
- data/lib/zerobounce/request.rb +84 -42
- data/lib/zerobounce/response_error.rb +117 -0
- data/lib/zerobounce/validate_sub_status.rb +1 -0
- data/lib/zerobounce/version.rb +1 -1
- data/lib/zerobounce/warmup.rb +471 -0
- data/lib/zerobounce.rb +112 -26
- metadata +46 -41
- data/.dockerignore +0 -17
- data/.env.example +0 -3
- data/.github/auto_assign.yml +0 -5
- data/.github/dependabot.yml +0 -35
- data/.github/workflows/auto_assign_ci.yaml +0 -21
- data/.github/workflows/codeql.yml +0 -105
- data/.github/workflows/publish.yml +0 -131
- data/.github/workflows/sdk_ci.yml +0 -29
- data/.gitignore +0 -70
- data/.rspec +0 -2
- data/.ruby-version +0 -1
- data/CODE_OF_CONDUCT.md +0 -69
- data/CONTRIBUTING.md +0 -112
- data/Dockerfile +0 -17
- data/Gemfile +0 -13
- data/Gemfile.lock +0 -135
- data/LICENSE.txt +0 -21
- data/Rakefile +0 -26
- data/SECURITY.md +0 -21
- data/bin/bundle +0 -105
- data/bin/console +0 -7
- data/bin/setup +0 -6
- data/data/zerobounce-ai-scoring.csv +0 -31
- data/data/zerobounce-batch-validation.csv +0 -31
- data/documentation.md +0 -530
- data/files/scoring.csv +0 -1
- data/files/validation.csv +0 -1
- data/files/zerobounce-ai-scoring.csv +0 -31
- data/files/zerobounce-batch-validation.csv +0 -31
- data/lib/zerobounce/mock_request.rb +0 -86
- data/zerobounce.gemspec +0 -49
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zerobounce
|
|
4
|
+
# Checks an API key before it is sent.
|
|
5
|
+
module ApiKey
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# The key, unchanged, or an ArgumentError when it has surrounding
|
|
9
|
+
# whitespace or control characters (a key read from a file with its
|
|
10
|
+
# newline). Such a key would fail at the API, and the HTTP library's own
|
|
11
|
+
# error could quote it unredacted.
|
|
12
|
+
def check!(api_key)
|
|
13
|
+
key = api_key.to_s
|
|
14
|
+
if key != key.strip || key.match?(/[\u0000-\u001f\u007f]/)
|
|
15
|
+
raise ArgumentError, 'api_key has surrounding whitespace or control characters'
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
api_key
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
data/lib/zerobounce/api_urls.rb
CHANGED
|
@@ -14,5 +14,14 @@ module Zerobounce
|
|
|
14
14
|
|
|
15
15
|
# Bulk API URLs
|
|
16
16
|
BULK_DEFAULT_URL = 'https://bulkapi.zerobounce.net/v2/'
|
|
17
|
+
|
|
18
|
+
# The S3 and SFTP/FTP bulk jobs live on the bulkapi host WITHOUT the /v2
|
|
19
|
+
# path. See https://www.zerobounce.net/docs/api-integrations.
|
|
20
|
+
BULK_ROOT_URL = 'https://bulkapi.zerobounce.net/'
|
|
21
|
+
|
|
22
|
+
# The list evaluator and the Inbox Placement Tester have their own base
|
|
23
|
+
# URLs, not paths built from the bulk one.
|
|
24
|
+
LIST_EVALUATOR_URL = 'https://bulkapi.zerobounce.net/v2/listevaluator/'
|
|
25
|
+
INBOX_PLACEMENT_URL = 'https://bulkapi.zerobounce.net/api/ipt'
|
|
17
26
|
end
|
|
18
27
|
end
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
|
+
require 'zerobounce/api_urls'
|
|
5
|
+
require 'zerobounce/response_error'
|
|
6
|
+
require 'zerobounce/api_key'
|
|
7
|
+
require 'zerobounce/error'
|
|
4
8
|
|
|
5
9
|
# https://github.com/rest-client/rest-client
|
|
6
10
|
require 'rest-client'
|
|
@@ -41,36 +45,120 @@ module Zerobounce
|
|
|
41
45
|
canonical
|
|
42
46
|
end
|
|
43
47
|
|
|
48
|
+
def self.__require_https__(url)
|
|
49
|
+
unless url.to_s.downcase.start_with?('https://')
|
|
50
|
+
raise ArgumentError, 'API URL must be an https:// URL'
|
|
51
|
+
end
|
|
52
|
+
url
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Removes the API key from error text. The key the request actually used
|
|
56
|
+
# (a Zerobounce::Client's or with_api_key's, which is thread-local) is
|
|
57
|
+
# redacted as well as the global one, in its raw and URL-encoded forms.
|
|
58
|
+
def self.__redact_api_key__(message)
|
|
59
|
+
text = message.to_s
|
|
60
|
+
[current_api_key, Zerobounce.config.apikey].uniq.each do |key|
|
|
61
|
+
next if key.nil? || key.to_s.strip.empty?
|
|
62
|
+
|
|
63
|
+
[key.to_s, URI.encode_www_form_component(key.to_s)].uniq.each do |form|
|
|
64
|
+
text = text.gsub(form, '***')
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
text
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.current_api_key
|
|
71
|
+
key = Thread.current[:zerobounce_api_key]
|
|
72
|
+
key.nil? ? Zerobounce.config.apikey : key
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The key for this request, or an error before anything is sent. A key with
|
|
76
|
+
# surrounding whitespace or control characters (one read from a file with
|
|
77
|
+
# its newline) would go out as it is and fail, and the HTTP library could
|
|
78
|
+
# quote it unredacted.
|
|
79
|
+
def self.__checked_api_key__
|
|
80
|
+
api_key = current_api_key
|
|
81
|
+
raise ('API key must be assigned') if api_key.nil? || api_key.to_s.strip.empty?
|
|
82
|
+
|
|
83
|
+
ApiKey.check!(api_key)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The message for an HTTP error status.
|
|
87
|
+
def self.__http_error_message__(error)
|
|
88
|
+
code = error.http_code.to_i
|
|
89
|
+
# max_redirects is 0, so a 3xx is never followed; say so rather than
|
|
90
|
+
# reporting its (usually empty) body as an "Empty response".
|
|
91
|
+
if code.between?(300, 399)
|
|
92
|
+
return "ZeroBounce answered with a redirect (HTTP #{code}), which is not followed"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
ResponseError.message_from(error.response)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.__execute_request__(**kwargs)
|
|
99
|
+
kwargs[:max_redirects] = 0
|
|
100
|
+
user_agent = Zerobounce.config.headers && Zerobounce.config.headers[:user_agent]
|
|
101
|
+
kwargs[:headers] = (kwargs[:headers] || {}).merge(user_agent: user_agent) if user_agent
|
|
102
|
+
RestClient::Request.execute(**kwargs)
|
|
103
|
+
rescue RestClient::ExceptionWithResponse => e
|
|
104
|
+
# Re-raised as the same RestClient class (RestClient::NotFound and so
|
|
105
|
+
# on), so `rescue RestClient::...` and #http_code keep working as they
|
|
106
|
+
# did up to 2.1.8. Only the message changes: the API's short error text,
|
|
107
|
+
# with the key redacted.
|
|
108
|
+
e.message = __redact_api_key__(__http_error_message__(e))
|
|
109
|
+
raise e
|
|
110
|
+
rescue RestClient::Exception => e
|
|
111
|
+
e.message = __redact_api_key__(e.message)
|
|
112
|
+
raise e
|
|
113
|
+
rescue StandardError => e
|
|
114
|
+
# Same class and backtrace, redacted message. cause: nil, because the
|
|
115
|
+
# original (unredacted) error would otherwise ride along as #cause and
|
|
116
|
+
# be printed by #full_message.
|
|
117
|
+
raise e.exception(__redact_api_key__(e.message)), cause: nil
|
|
118
|
+
end
|
|
119
|
+
|
|
44
120
|
def self._get(root, path, params, content_type='application/json')
|
|
45
121
|
|
|
46
122
|
# puts path
|
|
47
123
|
# puts Zerobounce.config.apikey
|
|
48
124
|
|
|
49
|
-
|
|
125
|
+
api_key = __checked_api_key__
|
|
50
126
|
|
|
51
|
-
params[:api_key] =
|
|
127
|
+
params[:api_key] = api_key
|
|
52
128
|
url = "#{Zerobounce::BaseRequest.__root_without_trailing_slashes__(root)}/#{path}"
|
|
129
|
+
Zerobounce::BaseRequest.__require_https__(url)
|
|
53
130
|
|
|
54
|
-
response =
|
|
131
|
+
response = __execute_request__(method: :get, url: url, timeout: 120, headers: { params: params })
|
|
55
132
|
return response
|
|
56
133
|
end
|
|
57
134
|
|
|
58
135
|
def self._post(root, path, params, content_type='application/json', filepath=nil)
|
|
59
136
|
|
|
60
|
-
|
|
137
|
+
api_key = __checked_api_key__
|
|
61
138
|
|
|
62
|
-
params[:api_key] =
|
|
139
|
+
params[:api_key] = api_key
|
|
63
140
|
url = "#{Zerobounce::BaseRequest.__root_without_trailing_slashes__(root)}/#{path}"
|
|
141
|
+
Zerobounce::BaseRequest.__require_https__(url)
|
|
64
142
|
response = nil
|
|
65
143
|
|
|
66
144
|
if filepath or content_type == 'multipart/form-data'
|
|
145
|
+
params.each do |key, value|
|
|
146
|
+
params[key] = value ? 'true' : 'false' if value == true || value == false
|
|
147
|
+
end
|
|
67
148
|
params[:file] = File.new(Zerobounce::BaseRequest.__safe_file_path__(filepath), 'rb')
|
|
68
149
|
params[:multipart] = true
|
|
69
|
-
response =
|
|
150
|
+
response = __execute_request__(method: :post, url: url, payload: params, timeout: 120)
|
|
70
151
|
|
|
71
152
|
elsif content_type == 'application/json'
|
|
72
|
-
response =
|
|
73
|
-
|
|
153
|
+
response = __execute_request__(
|
|
154
|
+
method: :post,
|
|
155
|
+
url: url,
|
|
156
|
+
payload: params.to_json,
|
|
157
|
+
timeout: 120,
|
|
158
|
+
headers: { content_type: :json, accept: :json }
|
|
159
|
+
)
|
|
160
|
+
elsif content_type == 'application/x-www-form-urlencoded'
|
|
161
|
+
response = __execute_request__(method: :post, url: url, payload: params, timeout: 120)
|
|
74
162
|
else
|
|
75
163
|
# this shouldn't happen
|
|
76
164
|
raise Error.new('Unknown content type specified in request.'\
|
|
@@ -79,5 +167,169 @@ module Zerobounce
|
|
|
79
167
|
return response
|
|
80
168
|
end
|
|
81
169
|
|
|
170
|
+
# Sends a request with an explicit method and an optional form or JSON body.
|
|
171
|
+
#
|
|
172
|
+
# The endpoints ported from the Python SDK need shapes _get and _post cannot
|
|
173
|
+
# express: a form-encoded POST on an arbitrary base URL, and the Inbox
|
|
174
|
+
# Placement Tester's PUT and DELETE with the API key on the query string.
|
|
175
|
+
# +bearer+ sends the key in an Authorization header instead of a query
|
|
176
|
+
# parameter, which the Inbox Placement Tester requires and which keeps the
|
|
177
|
+
# key out of request logs.
|
|
178
|
+
def self.__execute_with_method__(method, url, params, form: nil, json: nil, bearer: false)
|
|
179
|
+
api_key = __checked_api_key__
|
|
180
|
+
|
|
181
|
+
params = (params || {}).dup
|
|
182
|
+
extra = {}
|
|
183
|
+
if bearer
|
|
184
|
+
extra[:authorization] = "Bearer #{api_key}"
|
|
185
|
+
else
|
|
186
|
+
params = params.merge(api_key: api_key)
|
|
187
|
+
end
|
|
188
|
+
Zerobounce::BaseRequest.__require_https__(url)
|
|
189
|
+
|
|
190
|
+
options = { method: method, url: url, timeout: 120 }
|
|
191
|
+
if form
|
|
192
|
+
options[:payload] = form
|
|
193
|
+
options[:headers] = extra.merge(params: params, content_type: 'application/x-www-form-urlencoded')
|
|
194
|
+
elsif json
|
|
195
|
+
options[:payload] = json.to_json
|
|
196
|
+
options[:headers] = extra.merge(params: params, content_type: :json, accept: :json)
|
|
197
|
+
else
|
|
198
|
+
options[:headers] = extra.merge(params: params)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
__execute_request__(**options)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# A GET or form-encoded POST against an arbitrary base URL, for the list
|
|
205
|
+
# evaluator, which has its own host.
|
|
206
|
+
def self.root_request(method, root, path, params, form: nil)
|
|
207
|
+
url = "#{__root_without_trailing_slashes__(root)}/#{path.to_s.sub(%r{\A/}, '')}"
|
|
208
|
+
__parse_json_response__(__execute_with_method__(method, url, params, form: form))
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# One Inbox Placement Tester call.
|
|
212
|
+
def self.ipt_request(method, path, params = {}, json: nil)
|
|
213
|
+
root = Zerobounce.configuration.inbox_placement_root_url
|
|
214
|
+
url = "#{__root_without_trailing_slashes__(root)}/#{path.to_s.sub(%r{\A/}, '')}"
|
|
215
|
+
# LIVE: the Inbox Placement Tester authenticates with a Bearer header.
|
|
216
|
+
# Sent as an api_key parameter the request is rejected as unauthorized.
|
|
217
|
+
response = __execute_with_method__(method, url, params, json: json, bearer: true)
|
|
218
|
+
return nil if response.body.to_s.strip.empty?
|
|
219
|
+
|
|
220
|
+
__parse_json_response__(response)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Whether a Content-Type names a JSON body.
|
|
224
|
+
#
|
|
225
|
+
# The API types most responses 'application/json' but some
|
|
226
|
+
# 'application/json; charset=utf-8'. Compared with ==, the second kind
|
|
227
|
+
# skipped JSON parsing entirely, so an {"error": ...} payload came back as
|
|
228
|
+
# a raw String and read as a success. Only the media type decides; the
|
|
229
|
+
# parameters after the semicolon are ignored.
|
|
230
|
+
def self.__json_content_type__(content_type)
|
|
231
|
+
media_type = content_type.to_s.split(';', 2).first.to_s.strip.downcase
|
|
232
|
+
media_type == 'application/json' || media_type.end_with?('+json')
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# The root for the S3 and SFTP/FTP jobs: the bulkapi host WITHOUT /v2.
|
|
236
|
+
#
|
|
237
|
+
# +bulk_root_url+ (ZEROBOUNCE_BULK_ROOT_URL) wins when it is set, so these
|
|
238
|
+
# endpoints can be pointed at a mock server on their own. Left unset it is
|
|
239
|
+
# derived from +bulk_api_root_url+ with the /v2 stripped, so overriding
|
|
240
|
+
# that one alone still moves these endpoints with it.
|
|
241
|
+
def self.bulk_api_root_without_v2
|
|
242
|
+
configured = Zerobounce.configuration.bulk_root_url.to_s.strip
|
|
243
|
+
return __root_without_trailing_slashes__(configured) unless configured.empty?
|
|
244
|
+
|
|
245
|
+
derived = Zerobounce.configuration.bulk_api_root_url.to_s.sub(%r{/v2/?$}, '')
|
|
246
|
+
derived = ApiUrls::BULK_ROOT_URL if derived.strip.empty?
|
|
247
|
+
__root_without_trailing_slashes__(derived)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Whether a "success" field means failure. The API sends it as a JSON
|
|
251
|
+
# false, "false" or "False" depending on the endpoint.
|
|
252
|
+
def self.__success_means_failure__(value)
|
|
253
|
+
return !value if value == true || value == false
|
|
254
|
+
|
|
255
|
+
value.is_a?(String) && value.strip.casecmp('false').zero?
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# LIVE: {"success": false} on an HTTP 200 is a failure, not a result. The
|
|
259
|
+
# API answers an unknown bulk file id that way. Applied to the main API
|
|
260
|
+
# and the bulk API alike.
|
|
261
|
+
def self.__raise_if_success_false__(response, parsed)
|
|
262
|
+
return unless parsed.is_a?(Hash) && parsed.key?('success') && __success_means_failure__(parsed['success'])
|
|
263
|
+
|
|
264
|
+
raise ApiResponseError.new(
|
|
265
|
+
ResponseError.message_from_parts(response.code, response.headers[:content_type].to_s, response.body.to_s),
|
|
266
|
+
response.code
|
|
267
|
+
)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# A value that reads as an error: a non-blank String.
|
|
271
|
+
def self.__error_text__(value)
|
|
272
|
+
value.is_a?(String) && !value.strip.empty?
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# An "error" value that means failure: anything but nil or a blank String.
|
|
276
|
+
def self.__error_value__(value)
|
|
277
|
+
!value.nil? && !(value.is_a?(String) && value.strip.empty?)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# The keys a message-only body is made of.
|
|
281
|
+
MESSAGE_KEYS = %w[error message error_message Message Error detail].freeze
|
|
282
|
+
|
|
283
|
+
# A body made only of message keys (no "success"), for the calls whose
|
|
284
|
+
# result is never a bare message.
|
|
285
|
+
def self.__raise_if_message_only__(response, parsed)
|
|
286
|
+
return if parsed.empty? || parsed.key?('success')
|
|
287
|
+
return unless parsed.keys.all? { |key| MESSAGE_KEYS.include?(key) }
|
|
288
|
+
|
|
289
|
+
text = MESSAGE_KEYS.map { |key| parsed[key] }.find { |value| __error_text__(value) }
|
|
290
|
+
raise ApiResponseError.new(ResponseError.strip_prefix(text), response.code) if text
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# A dead or invalid key on an HTTP 200: no "success" field and a non-empty
|
|
294
|
+
# "Error" (LIVE: the filters spell it so) or "error_message".
|
|
295
|
+
def self.__raise_if_bare_error__(response, parsed)
|
|
296
|
+
return if parsed.key?('success')
|
|
297
|
+
|
|
298
|
+
%w[Error error_message].each do |key|
|
|
299
|
+
next unless __error_text__(parsed[key])
|
|
300
|
+
|
|
301
|
+
raise ApiResponseError.new(ResponseError.strip_prefix(parsed[key]), response.code)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def self.__parse_json_response__(response)
|
|
306
|
+
body = response.body.to_s
|
|
307
|
+
content_type = (response.headers[:content_type] || '').to_s
|
|
308
|
+
code = response.code
|
|
309
|
+
if ResponseError.html?(body, content_type) || body.strip.empty?
|
|
310
|
+
raise ApiResponseError.new(ResponseError.message_from_parts(code, content_type, body), code)
|
|
311
|
+
end
|
|
312
|
+
begin
|
|
313
|
+
parsed = JSON.parse(body)
|
|
314
|
+
rescue JSON::ParserError
|
|
315
|
+
raise ApiResponseError.new('ZeroBounce returned a response that is not valid JSON.', code)
|
|
316
|
+
end
|
|
317
|
+
return parsed unless parsed.is_a?(Hash)
|
|
318
|
+
|
|
319
|
+
__raise_if_success_false__(response, parsed)
|
|
320
|
+
|
|
321
|
+
raise ApiResponseError.new(ResponseError.strip_prefix(parsed['error']), code) if __error_value__(parsed['error'])
|
|
322
|
+
# The Inbox Placement Tester is a Django REST framework app and reports
|
|
323
|
+
# its errors under "detail" rather than "error"/"message".
|
|
324
|
+
if __error_value__(parsed['detail'])
|
|
325
|
+
raise ApiResponseError.new(ResponseError.strip_prefix(parsed['detail']), code)
|
|
326
|
+
end
|
|
327
|
+
__raise_if_bare_error__(response, parsed)
|
|
328
|
+
if parsed.key?('errors') && parsed['errors'].is_a?(Array) && !parsed['errors'].empty?
|
|
329
|
+
raise ApiResponseError.new(ResponseError.strip_prefix(parsed['errors'][0]['error']), code)
|
|
330
|
+
end
|
|
331
|
+
parsed
|
|
332
|
+
end
|
|
333
|
+
|
|
82
334
|
end
|
|
83
335
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'zerobounce/api_key'
|
|
4
|
+
|
|
5
|
+
module Zerobounce
|
|
6
|
+
# One API key. Package methods share Zerobounce.config; a Client does not.
|
|
7
|
+
class Client
|
|
8
|
+
def initialize(apikey)
|
|
9
|
+
ApiKey.check!(apikey) unless apikey.nil? || apikey.to_s.strip.empty?
|
|
10
|
+
@apikey = apikey
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Keeps the API key out of logs and consoles.
|
|
14
|
+
def inspect
|
|
15
|
+
"#<#{self.class.name} apikey=#{@apikey.nil? ? 'nil' : '[FILTERED]'}>"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def validate(email, ip_address = nil, **options)
|
|
19
|
+
Zerobounce.with_api_key(@apikey) { Zerobounce.validate(email, ip_address, **options) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def credits
|
|
23
|
+
Zerobounce.with_api_key(@apikey) { Zerobounce.credits }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def validate_batch(emails, ip_addresses = [], **options)
|
|
27
|
+
Zerobounce.with_api_key(@apikey) { Zerobounce.validate_batch(emails, ip_addresses, **options) }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'rest-client'
|
|
4
|
-
require 'dotenv'
|
|
5
4
|
require_relative 'api_urls'
|
|
6
5
|
|
|
7
6
|
module Zerobounce
|
|
@@ -24,19 +23,41 @@ module Zerobounce
|
|
|
24
23
|
# @attr [String] bulk_api_root_url
|
|
25
24
|
# The Zerobounce bulk API root URL.
|
|
26
25
|
#
|
|
26
|
+
# @attr [String, nil] bulk_root_url
|
|
27
|
+
# The root for the S3 and SFTP/FTP bulk jobs: the bulkapi host without the
|
|
28
|
+
# /v2 path. Defaults to nil, which derives it from {#bulk_api_root_url}.
|
|
29
|
+
#
|
|
27
30
|
# @attr [Array<Symbol>] valid_statues
|
|
28
31
|
# The statuses that are considered valid by {Response#valid?}.
|
|
29
32
|
class Configuration
|
|
30
|
-
attr_accessor :headers, :apikey, :api_root_url, :bulk_api_root_url, :
|
|
33
|
+
attr_accessor :headers, :apikey, :api_root_url, :bulk_api_root_url, :bulk_root_url,
|
|
34
|
+
:list_evaluator_root_url, :inbox_placement_root_url, :valid_statuses, :mock
|
|
31
35
|
|
|
32
36
|
def initialize(mock = false)
|
|
33
|
-
File.file?('.env') ? Dotenv.load('.env') : Dotenv.load
|
|
34
37
|
self.apikey = ENV['ZEROBOUNCE_API_KEY']
|
|
35
38
|
self.api_root_url = ENV['ZEROBOUNCE_API_URL'] || ApiUrls::DEFAULT_URL
|
|
36
39
|
self.bulk_api_root_url = ENV['ZEROBOUNCE_BULK_API_URL'] || ApiUrls::BULK_DEFAULT_URL
|
|
40
|
+
# The S3/SFTP root is the only one that can be derived from another base
|
|
41
|
+
# URL, so it is left unset unless it is asked for: BaseRequest then takes
|
|
42
|
+
# bulk_api_root_url with the /v2 stripped. Set it - in code or through
|
|
43
|
+
# ZEROBOUNCE_BULK_ROOT_URL - to point those jobs somewhere on their own.
|
|
44
|
+
self.bulk_root_url = ENV['ZEROBOUNCE_BULK_ROOT_URL']
|
|
45
|
+
# These two are separate base URLs rather than paths built from
|
|
46
|
+
# bulk_api_root_url, so overriding that alone leaves them on the live API.
|
|
47
|
+
self.list_evaluator_root_url = ENV['ZEROBOUNCE_LIST_EVALUATOR_URL'] || ApiUrls::LIST_EVALUATOR_URL
|
|
48
|
+
self.inbox_placement_root_url = ENV['ZEROBOUNCE_INBOX_PLACEMENT_URL'] || ApiUrls::INBOX_PLACEMENT_URL
|
|
37
49
|
self.valid_statuses = %i[valid catch_all accept_all]
|
|
38
50
|
self.headers = { user_agent: "ZerobounceRubyGem/#{Zerobounce::VERSION}" }
|
|
39
51
|
self.mock = mock
|
|
40
52
|
end
|
|
53
|
+
|
|
54
|
+
# Keep the API key out of logs, consoles and error pages that call #inspect.
|
|
55
|
+
def inspect
|
|
56
|
+
vars = instance_variables.map do |var|
|
|
57
|
+
value = var == :@apikey && apikey ? '[FILTERED]' : instance_variable_get(var).inspect
|
|
58
|
+
"#{var}=#{value}"
|
|
59
|
+
end
|
|
60
|
+
"#<#{self.class.name} #{vars.join(', ')}>"
|
|
61
|
+
end
|
|
41
62
|
end
|
|
42
63
|
end
|
data/lib/zerobounce/error.rb
CHANGED
|
@@ -54,6 +54,26 @@ module Zerobounce
|
|
|
54
54
|
end
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
# A failed API response.
|
|
58
|
+
#
|
|
59
|
+
# Carries the HTTP status so callers can tell a 403 (Cloudflare) from a
|
|
60
|
+
# 400/404 (API) or a 429 (the Warmup rate limit). The Warmup client raises
|
|
61
|
+
# it. The validation client raises the RestClient exception for an HTTP
|
|
62
|
+
# error status (status in #http_code), as it did up to 2.1.8, and this
|
|
63
|
+
# class, with the 2xx status (usually 200), for an error reported on a
|
|
64
|
+
# successful HTTP response; both carry the same short message. It is a
|
|
65
|
+
# RuntimeError, which those errors were before, so `rescue RuntimeError`
|
|
66
|
+
# still catches them.
|
|
67
|
+
class ApiResponseError < RuntimeError
|
|
68
|
+
# @return [Integer] the HTTP status of the failed response, or 0
|
|
69
|
+
attr_reader :status_code
|
|
70
|
+
|
|
71
|
+
def initialize(message, status_code = 0)
|
|
72
|
+
super(message)
|
|
73
|
+
@status_code = status_code.to_i
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
57
77
|
# Server returned a 500 error.
|
|
58
78
|
#
|
|
59
79
|
# @author Aaron Frase
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'stringio'
|
|
5
|
+
require 'zerobounce/error'
|
|
6
|
+
require 'zerobounce/response_error'
|
|
5
7
|
|
|
6
8
|
module Zerobounce
|
|
7
9
|
# Bulk getfile response handling (v2): HTTP errors, JSON error bodies (including HTTP 200).
|
|
@@ -69,17 +71,13 @@ module Zerobounce
|
|
|
69
71
|
body_str = response.body.to_s
|
|
70
72
|
ct = (response.headers[:content_type] || response.headers['Content-Type'] || '').to_s
|
|
71
73
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
else
|
|
76
|
-
body_str.empty? ? "HTTP #{code}" : body_str
|
|
77
|
-
end
|
|
78
|
-
raise msg
|
|
74
|
+
# An empty body is no result file: an error, as in the other SDKs.
|
|
75
|
+
if code > 299 || body_str.strip.empty? || ResponseError.html?(body_str, ct)
|
|
76
|
+
raise ApiResponseError.new(ResponseError.message_from_parts(code, ct, body_str), code)
|
|
79
77
|
end
|
|
80
78
|
|
|
81
79
|
if should_treat_as_error?(body_str, ct)
|
|
82
|
-
raise format_error_message(body_str)
|
|
80
|
+
raise ApiResponseError.new(format_error_message(body_str), code)
|
|
83
81
|
end
|
|
84
82
|
|
|
85
83
|
io = StringIO.new(response.body)
|