zerobounce-sdk 2.1.7 → 2.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +146 -19
  3. data/README.md +472 -410
  4. data/lib/zerobounce/api_urls.rb +9 -0
  5. data/lib/zerobounce/base_request.rb +208 -8
  6. data/lib/zerobounce/client.rb +22 -0
  7. data/lib/zerobounce/configuration.rb +24 -3
  8. data/lib/zerobounce/error.rb +18 -0
  9. data/lib/zerobounce/get_file_helper.rb +3 -7
  10. data/lib/zerobounce/mock_request.rb +31 -12
  11. data/lib/zerobounce/priority3.rb +331 -0
  12. data/lib/zerobounce/request.rb +77 -38
  13. data/lib/zerobounce/response_error.rb +117 -0
  14. data/lib/zerobounce/validate_sub_status.rb +1 -0
  15. data/lib/zerobounce/version.rb +1 -1
  16. data/lib/zerobounce/warmup.rb +467 -0
  17. data/lib/zerobounce.rb +101 -14
  18. metadata +8 -33
  19. data/.dockerignore +0 -17
  20. data/.env.example +0 -3
  21. data/.github/auto_assign.yml +0 -5
  22. data/.github/dependabot.yml +0 -35
  23. data/.github/workflows/auto_assign_ci.yaml +0 -21
  24. data/.github/workflows/codeql.yml +0 -105
  25. data/.github/workflows/publish.yml +0 -131
  26. data/.github/workflows/sdk_ci.yml +0 -29
  27. data/.gitignore +0 -70
  28. data/.rspec +0 -2
  29. data/.ruby-version +0 -1
  30. data/CODE_OF_CONDUCT.md +0 -69
  31. data/CONTRIBUTING.md +0 -112
  32. data/Dockerfile +0 -17
  33. data/Gemfile +0 -13
  34. data/Gemfile.lock +0 -135
  35. data/Rakefile +0 -26
  36. data/SECURITY.md +0 -21
  37. data/bin/bundle +0 -105
  38. data/bin/console +0 -7
  39. data/bin/setup +0 -6
  40. data/data/zerobounce-ai-scoring.csv +0 -31
  41. data/data/zerobounce-batch-validation.csv +0 -31
  42. data/documentation.md +0 -530
  43. data/files/scoring.csv +0 -1
  44. data/files/validation.csv +0 -1
  45. data/files/zerobounce-ai-scoring.csv +0 -31
  46. data/files/zerobounce-batch-validation.csv +0 -31
  47. data/zerobounce.gemspec +0 -49
@@ -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,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'zerobounce/api_urls'
5
+ require 'zerobounce/response_error'
4
6
 
5
7
  # https://github.com/rest-client/rest-client
6
8
  require 'rest-client'
@@ -41,36 +43,111 @@ module Zerobounce
41
43
  canonical
42
44
  end
43
45
 
46
+ def self.__require_https__(url)
47
+ unless url.to_s.downcase.start_with?('https://')
48
+ raise ArgumentError, 'API URL must be an https:// URL'
49
+ end
50
+ url
51
+ end
52
+
53
+ # Removes the API key from error text. The key the request actually used
54
+ # (a Zerobounce::Client's or with_api_key's, which is thread-local) is
55
+ # redacted as well as the global one, in its raw and URL-encoded forms.
56
+ def self.__redact_api_key__(message)
57
+ text = message.to_s
58
+ [current_api_key, Zerobounce.config.apikey].uniq.each do |key|
59
+ next if key.nil? || key.to_s.strip.empty?
60
+
61
+ [key.to_s, URI.encode_www_form_component(key.to_s)].uniq.each do |form|
62
+ text = text.gsub(form, '***')
63
+ end
64
+ end
65
+ text
66
+ end
67
+
68
+ def self.current_api_key
69
+ key = Thread.current[:zerobounce_api_key]
70
+ key.nil? ? Zerobounce.config.apikey : key
71
+ end
72
+
73
+ # The message for an HTTP error status.
74
+ def self.__http_error_message__(error)
75
+ code = error.http_code.to_i
76
+ # max_redirects is 0, so a 3xx is never followed; say so rather than
77
+ # reporting its (usually empty) body as an "Empty response".
78
+ if code.between?(300, 399)
79
+ return "ZeroBounce answered with a redirect (HTTP #{code}), which is not followed"
80
+ end
81
+
82
+ ResponseError.message_from(error.response)
83
+ end
84
+
85
+ def self.__execute_request__(**kwargs)
86
+ kwargs[:max_redirects] = 0
87
+ user_agent = Zerobounce.config.headers && Zerobounce.config.headers[:user_agent]
88
+ kwargs[:headers] = (kwargs[:headers] || {}).merge(user_agent: user_agent) if user_agent
89
+ RestClient::Request.execute(**kwargs)
90
+ rescue RestClient::ExceptionWithResponse => e
91
+ # Re-raised as the same RestClient class (RestClient::NotFound and so
92
+ # on), so `rescue RestClient::...` and #http_code keep working as they
93
+ # did up to 2.1.8. Only the message changes: the API's short error text,
94
+ # with the key redacted.
95
+ e.message = __redact_api_key__(__http_error_message__(e))
96
+ raise e
97
+ rescue RestClient::Exception => e
98
+ e.message = __redact_api_key__(e.message)
99
+ raise e
100
+ rescue StandardError => e
101
+ # Same class and backtrace, redacted message. cause: nil, because the
102
+ # original (unredacted) error would otherwise ride along as #cause and
103
+ # be printed by #full_message.
104
+ raise e.exception(__redact_api_key__(e.message)), cause: nil
105
+ end
106
+
44
107
  def self._get(root, path, params, content_type='application/json')
45
108
 
46
109
  # puts path
47
110
  # puts Zerobounce.config.apikey
48
111
 
49
- raise ("API key must be assigned") if not Zerobounce.config.apikey
112
+ api_key = current_api_key
113
+ raise ('API key must be assigned') if api_key.nil? || api_key.to_s.strip.empty?
50
114
 
51
- params[:api_key] = Zerobounce.config.apikey
115
+ params[:api_key] = api_key
52
116
  url = "#{Zerobounce::BaseRequest.__root_without_trailing_slashes__(root)}/#{path}"
117
+ Zerobounce::BaseRequest.__require_https__(url)
53
118
 
54
- response = RestClient.get(url, {params: params})
119
+ response = __execute_request__(method: :get, url: url, timeout: 120, headers: { params: params })
55
120
  return response
56
121
  end
57
122
 
58
123
  def self._post(root, path, params, content_type='application/json', filepath=nil)
59
124
 
60
- raise ("API key must be assigned") if not Zerobounce.config.apikey
125
+ api_key = current_api_key
126
+ raise ('API key must be assigned') if api_key.nil? || api_key.to_s.strip.empty?
61
127
 
62
- params[:api_key] = Zerobounce.config.apikey
128
+ params[:api_key] = api_key
63
129
  url = "#{Zerobounce::BaseRequest.__root_without_trailing_slashes__(root)}/#{path}"
130
+ Zerobounce::BaseRequest.__require_https__(url)
64
131
  response = nil
65
132
 
66
133
  if filepath or content_type == 'multipart/form-data'
134
+ params.each do |key, value|
135
+ params[key] = value ? 'true' : 'false' if value == true || value == false
136
+ end
67
137
  params[:file] = File.new(Zerobounce::BaseRequest.__safe_file_path__(filepath), 'rb')
68
138
  params[:multipart] = true
69
- response = RestClient.post(url, params)
139
+ response = __execute_request__(method: :post, url: url, payload: params, timeout: 120)
70
140
 
71
141
  elsif content_type == 'application/json'
72
- response = RestClient.post(url, params.to_json, \
73
- content_type: :json, accept: :json)
142
+ response = __execute_request__(
143
+ method: :post,
144
+ url: url,
145
+ payload: params.to_json,
146
+ timeout: 120,
147
+ headers: { content_type: :json, accept: :json }
148
+ )
149
+ elsif content_type == 'application/x-www-form-urlencoded'
150
+ response = __execute_request__(method: :post, url: url, payload: params, timeout: 120)
74
151
  else
75
152
  # this shouldn't happen
76
153
  raise Error.new('Unknown content type specified in request.'\
@@ -79,5 +156,128 @@ module Zerobounce
79
156
  return response
80
157
  end
81
158
 
159
+ # Sends a request with an explicit method and an optional form or JSON body.
160
+ #
161
+ # The endpoints ported from the Python SDK need shapes _get and _post cannot
162
+ # express: a form-encoded POST on an arbitrary base URL, and the Inbox
163
+ # Placement Tester's PUT and DELETE with the API key on the query string.
164
+ # +bearer+ sends the key in an Authorization header instead of a query
165
+ # parameter, which the Inbox Placement Tester requires and which keeps the
166
+ # key out of request logs.
167
+ def self.__execute_with_method__(method, url, params, form: nil, json: nil, bearer: false)
168
+ api_key = current_api_key
169
+ raise ('API key must be assigned') if api_key.nil? || api_key.to_s.strip.empty?
170
+
171
+ params = (params || {}).dup
172
+ extra = {}
173
+ if bearer
174
+ extra[:authorization] = "Bearer #{api_key}"
175
+ else
176
+ params = params.merge(api_key: api_key)
177
+ end
178
+ Zerobounce::BaseRequest.__require_https__(url)
179
+
180
+ options = { method: method, url: url, timeout: 120 }
181
+ if form
182
+ options[:payload] = form
183
+ options[:headers] = extra.merge(params: params, content_type: 'application/x-www-form-urlencoded')
184
+ elsif json
185
+ options[:payload] = json.to_json
186
+ options[:headers] = extra.merge(params: params, content_type: :json, accept: :json)
187
+ else
188
+ options[:headers] = extra.merge(params: params)
189
+ end
190
+
191
+ __execute_request__(**options)
192
+ end
193
+
194
+ # A GET or form-encoded POST against an arbitrary base URL, for the list
195
+ # evaluator, which has its own host.
196
+ def self.root_request(method, root, path, params, form: nil)
197
+ url = "#{__root_without_trailing_slashes__(root)}/#{path.to_s.sub(%r{\A/}, '')}"
198
+ __parse_json_response__(__execute_with_method__(method, url, params, form: form))
199
+ end
200
+
201
+ # One Inbox Placement Tester call.
202
+ def self.ipt_request(method, path, params = {}, json: nil)
203
+ root = Zerobounce.configuration.inbox_placement_root_url
204
+ url = "#{__root_without_trailing_slashes__(root)}/#{path.to_s.sub(%r{\A/}, '')}"
205
+ # LIVE: the Inbox Placement Tester authenticates with a Bearer header.
206
+ # Sent as an api_key parameter the request is rejected as unauthorized.
207
+ response = __execute_with_method__(method, url, params, json: json, bearer: true)
208
+ return nil if response.body.to_s.strip.empty?
209
+
210
+ __parse_json_response__(response)
211
+ end
212
+
213
+ # Whether a Content-Type names a JSON body.
214
+ #
215
+ # The API types most responses 'application/json' but some
216
+ # 'application/json; charset=utf-8'. Compared with ==, the second kind
217
+ # skipped JSON parsing entirely, so an {"error": ...} payload came back as
218
+ # a raw String and read as a success. Only the media type decides; the
219
+ # parameters after the semicolon are ignored.
220
+ def self.__json_content_type__(content_type)
221
+ media_type = content_type.to_s.split(';', 2).first.to_s.strip.downcase
222
+ media_type == 'application/json' || media_type.end_with?('+json')
223
+ end
224
+
225
+ # The root for the S3 and SFTP/FTP jobs: the bulkapi host WITHOUT /v2.
226
+ #
227
+ # +bulk_root_url+ (ZEROBOUNCE_BULK_ROOT_URL) wins when it is set, so these
228
+ # endpoints can be pointed at a mock server on their own. Left unset it is
229
+ # derived from +bulk_api_root_url+ with the /v2 stripped, so overriding
230
+ # that one alone still moves these endpoints with it.
231
+ def self.bulk_api_root_without_v2
232
+ configured = Zerobounce.configuration.bulk_root_url.to_s.strip
233
+ return __root_without_trailing_slashes__(configured) unless configured.empty?
234
+
235
+ derived = Zerobounce.configuration.bulk_api_root_url.to_s.sub(%r{/v2/?$}, '')
236
+ derived = ApiUrls::BULK_ROOT_URL if derived.strip.empty?
237
+ __root_without_trailing_slashes__(derived)
238
+ end
239
+
240
+ # Whether a "success" field means failure. The API sends it as a JSON
241
+ # false, "false" or "False" depending on the endpoint.
242
+ def self.__success_means_failure__(value)
243
+ return !value if value == true || value == false
244
+
245
+ value.is_a?(String) && value.strip.casecmp('false').zero?
246
+ end
247
+
248
+ # LIVE: {"success": false} on an HTTP 200 is a failure, not a result. The
249
+ # API answers an unknown bulk file id that way. Applied to the main API
250
+ # and the bulk API alike.
251
+ def self.__raise_if_success_false__(response, parsed)
252
+ return unless parsed.is_a?(Hash) && parsed.key?('success') && __success_means_failure__(parsed['success'])
253
+
254
+ raise ResponseError.message_from_parts(response.code, response.headers[:content_type].to_s, response.body.to_s)
255
+ end
256
+
257
+ def self.__parse_json_response__(response)
258
+ body = response.body.to_s
259
+ content_type = (response.headers[:content_type] || '').to_s
260
+ if ResponseError.html?(body, content_type) || body.strip.empty?
261
+ raise ResponseError.message_from_parts(response.code, content_type, body)
262
+ end
263
+ begin
264
+ parsed = JSON.parse(body)
265
+ rescue JSON::ParserError
266
+ raise 'ZeroBounce returned a response that is not valid JSON.'
267
+ end
268
+ return parsed unless parsed.is_a?(Hash)
269
+
270
+ __raise_if_success_false__(response, parsed)
271
+
272
+ raise ResponseError.strip_prefix(parsed['error']) if parsed.key?('error') && !parsed['error'].nil?
273
+ # The Inbox Placement Tester is a Django REST framework app and reports
274
+ # its errors under "detail" rather than "error"/"message".
275
+ raise ResponseError.strip_prefix(parsed['detail']) if parsed.key?('detail') && !parsed['detail'].nil?
276
+ if parsed.key?('errors') && parsed['errors'].is_a?(Array) && !parsed['errors'].empty?
277
+ raise ResponseError.strip_prefix(parsed['errors'][0]['error'])
278
+ end
279
+ parsed
280
+ end
281
+
82
282
  end
83
283
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zerobounce
4
+ # One API key. Package methods share Zerobounce.config; a Client does not.
5
+ class Client
6
+ def initialize(apikey)
7
+ @apikey = apikey
8
+ end
9
+
10
+ def validate(email, ip_address = nil, **options)
11
+ Zerobounce.with_api_key(@apikey) { Zerobounce.validate(email, ip_address, **options) }
12
+ end
13
+
14
+ def credits
15
+ Zerobounce.with_api_key(@apikey) { Zerobounce.credits }
16
+ end
17
+
18
+ def validate_batch(emails, ip_addresses = [], **options)
19
+ Zerobounce.with_api_key(@apikey) { Zerobounce.validate_batch(emails, ip_addresses, **options) }
20
+ end
21
+ end
22
+ 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, :valid_statuses, :mock
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
@@ -54,6 +54,24 @@ 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 a
63
+ # RuntimeError for an error reported with an HTTP 200; both carry the same
64
+ # short message.
65
+ class ApiResponseError < StandardError
66
+ # @return [Integer] the HTTP status of the failed response, or 0
67
+ attr_reader :status_code
68
+
69
+ def initialize(message, status_code = 0)
70
+ super(message)
71
+ @status_code = status_code.to_i
72
+ end
73
+ end
74
+
57
75
  # Server returned a 500 error.
58
76
  #
59
77
  # @author Aaron Frase
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
  require 'stringio'
5
+ require 'zerobounce/response_error'
5
6
 
6
7
  module Zerobounce
7
8
  # Bulk getfile response handling (v2): HTTP errors, JSON error bodies (including HTTP 200).
@@ -69,13 +70,8 @@ module Zerobounce
69
70
  body_str = response.body.to_s
70
71
  ct = (response.headers[:content_type] || response.headers['Content-Type'] || '').to_s
71
72
 
72
- if code > 299
73
- msg = if body_str.lstrip.start_with?('{')
74
- format_error_message(body_str)
75
- else
76
- body_str.empty? ? "HTTP #{code}" : body_str
77
- end
78
- raise msg
73
+ if code > 299 || ResponseError.html?(body_str, ct)
74
+ raise ResponseError.message_from_parts(code, ct, body_str)
79
75
  end
80
76
 
81
77
  if should_treat_as_error?(body_str, ct)
@@ -10,7 +10,7 @@ module Zerobounce
10
10
 
11
11
  def self.get(path, params, content_type='application/json')
12
12
  response = self._get(Zerobounce.configuration.api_root_url, path, params, content_type)
13
- if response.headers[:content_type] == 'application/json'
13
+ if __json_content_type__(response.headers[:content_type])
14
14
  response_body = response.body
15
15
  response_body_json = JSON.parse(response_body)
16
16
 
@@ -27,9 +27,10 @@ module Zerobounce
27
27
 
28
28
  def self.bulk_get(path, params, content_type='application/json')
29
29
  response = self._get(Zerobounce.configuration.bulk_api_root_url, path, params, content_type)
30
- if response.headers[:content_type] == 'application/json'
30
+ if __json_content_type__(response.headers[:content_type])
31
31
  response_body = response.body
32
32
  response_body_json = JSON.parse(response_body)
33
+ return response_body_json unless response_body_json.is_a?(Hash)
33
34
 
34
35
  raise (response_body_json['error']) if response_body_json.key?('error')
35
36
  raise (response_body_json['errors'][0]['error']) \
@@ -50,7 +51,7 @@ module Zerobounce
50
51
  def self.post(path, params, content_type='application/json', filepath=nil)
51
52
  response = self._post(Zerobounce.configuration.api_root_url, path, params, \
52
53
  content_type, filepath)
53
- if response.headers[:content_type] == 'application/json'
54
+ if __json_content_type__(response.headers[:content_type])
54
55
  response_body = response.body
55
56
  response_body_json = JSON.parse(response_body)
56
57
 
@@ -67,18 +68,36 @@ module Zerobounce
67
68
  def self.bulk_post(path, params, content_type='application/json', filepath=nil)
68
69
  response = self._post(Zerobounce.configuration.bulk_api_root_url, path, params, \
69
70
  content_type, filepath)
70
- if response.headers[:content_type] == 'application/json'
71
- response_body = response.body
72
- response_body_json = JSON.parse(response_body)
71
+ if __json_content_type__(response.headers[:content_type])
72
+ return __parse_json_response__(response)
73
+ end
74
+ response.body
75
+ end
73
76
 
74
- raise (response_body_json['error']) if response_body_json.key?('error')
75
- raise (response_body_json['errors'][0]['error']) \
76
- if response_body_json.key?('errors') and \
77
- response_body_json['errors'].length > 0
77
+ def self.bulk_root_get(path, params, content_type='application/json')
78
+ response = self._get(bulk_api_root_without_v2, path, params, content_type)
79
+ if __json_content_type__(response.headers[:content_type])
80
+ return __parse_json_response__(response)
81
+ end
82
+ response.body
83
+ end
78
84
 
79
- return response_body_json
85
+ def self.bulk_root_getfile(path, params)
86
+ response = self._get(bulk_api_root_without_v2, path, params, 'application/json')
87
+ GetFileHelper.process_getfile_response(response)
88
+ end
89
+
90
+ def self.bulk_root_post(path, params, content_type='application/json', filepath=nil)
91
+ response = self._post(bulk_api_root_without_v2, path, params, content_type, filepath)
92
+ if __json_content_type__(response.headers[:content_type])
93
+ return __parse_json_response__(response)
80
94
  end
81
- return response.body
95
+ response.body
96
+ end
97
+
98
+ def self.bulk_root_form_post(path, params)
99
+ response = self._post(bulk_api_root_without_v2, path, params, 'application/x-www-form-urlencoded')
100
+ __parse_json_response__(response)
82
101
  end
83
102
 
84
103
  end