ruby_http_client 3.3.0 → 3.5.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -6,10 +6,54 @@ module SendGrid
6
6
 
7
7
  # Holds the response from an API call.
8
8
  class Response
9
+ # Provide useful functionality around API rate limiting.
10
+ class Ratelimit
11
+ attr_reader :limit, :remaining, :reset
12
+
13
+ # * *Args* :
14
+ # - +limit+ -> The total number of requests allowed within a rate limit window
15
+ # - +remaining+ -> The number of requests that have been processed within this current rate limit window
16
+ # - +reset+ -> The time (in seconds since Unix Epoch) when the rate limit will reset
17
+ def initialize(limit, remaining, reset)
18
+ @limit = limit.to_i
19
+ @remaining = remaining.to_i
20
+ @reset = Time.at reset.to_i
21
+ end
22
+
23
+ def exceeded?
24
+ remaining <= 0
25
+ end
26
+
27
+ # * *Returns* :
28
+ # - The number of requests that have been used out of this
29
+ # rate limit window
30
+ def used
31
+ limit - remaining
32
+ end
33
+
34
+ # Sleep until the reset time arrives. If given a block, it will
35
+ # be called after sleeping is finished.
36
+ #
37
+ # * *Returns* :
38
+ # - The amount of time (in seconds) that the rate limit slept
39
+ # for.
40
+ def wait!
41
+ now = Time.now.utc.to_i
42
+ duration = (reset.to_i - now) + 1
43
+
44
+ sleep duration if duration >= 0
45
+
46
+ yield if block_given?
47
+
48
+ duration
49
+ end
50
+ end
51
+
9
52
  # * *Args* :
10
53
  # - +response+ -> A NET::HTTP response object
11
54
  #
12
55
  attr_reader :status_code, :body, :headers
56
+
13
57
  def initialize(response)
14
58
  @status_code = response.code
15
59
  @body = response.body
@@ -21,6 +65,20 @@ module SendGrid
21
65
  def parsed_body
22
66
  @parsed_body ||= JSON.parse(@body, symbolize_names: true)
23
67
  end
68
+
69
+ def ratelimit
70
+ return @ratelimit unless @ratelimit.nil?
71
+
72
+ limit = headers['X-RateLimit-Limit']
73
+ remaining = headers['X-RateLimit-Remaining']
74
+ reset = headers['X-RateLimit-Reset']
75
+
76
+ # Guard against possibility that one (or probably, all) of the
77
+ # needed headers were not returned.
78
+ @ratelimit = Ratelimit.new(limit, remaining, reset) if limit && remaining && reset
79
+
80
+ @ratelimit
81
+ end
24
82
  end
25
83
 
26
84
  # A simple REST client.
@@ -35,15 +93,19 @@ module SendGrid
35
93
  # Or just pass the version as part of the URL
36
94
  # (e.g. client._("/v3"))
37
95
  # - +url_path+ -> A list of the url path segments
96
+ # - +proxy_options+ -> A hash of proxy settings.
97
+ # (e.g. { host: '127.0.0.1', port: 8080 })
38
98
  #
39
- def initialize(host: nil, request_headers: nil, version: nil, url_path: nil)
99
+ def initialize(host: nil, request_headers: nil, version: nil, url_path: nil, http_options: {}, proxy_options: {}) # rubocop:disable Metrics/ParameterLists
40
100
  @host = host
41
101
  @request_headers = request_headers || {}
42
102
  @version = version
43
103
  @url_path = url_path || []
44
- @methods = %w(delete get patch post put)
104
+ @methods = %w[delete get patch post put]
45
105
  @query_params = nil
46
106
  @request_body = nil
107
+ @http_options = http_options
108
+ @proxy_options = proxy_options
47
109
  end
48
110
 
49
111
  # Update the headers for the request
@@ -137,21 +199,10 @@ module SendGrid
137
199
  #
138
200
  def build_request(name, args)
139
201
  build_args(args) if args
140
- uri = build_url(query_params: @query_params)
141
- @http = add_ssl(Net::HTTP.new(uri.host, uri.port))
142
- net_http = Kernel.const_get('Net::HTTP::' + name.to_s.capitalize)
143
- @request = build_request_headers(net_http.new(uri.request_uri))
144
- if (@request_body &&
145
- (!@request_headers.has_key?('Content-Type') ||
146
- @request_headers['Content-Type'] == 'application/json')
147
- )
148
- @request.body = @request_body.to_json
149
- @request['Content-Type'] = 'application/json'
150
- elsif !@request_body and (name.to_s == "post")
151
- @request['Content-Type'] = ''
152
- else
153
- @request.body = @request_body
154
- end
202
+ # build the request & http object
203
+ build_http_request(name)
204
+ # set the content type & request body
205
+ update_content_type(name)
155
206
  make_request(@http, @request)
156
207
  end
157
208
 
@@ -169,6 +220,18 @@ module SendGrid
169
220
  Response.new(response)
170
221
  end
171
222
 
223
+ # Build HTTP request object
224
+ #
225
+ # * *Returns* :
226
+ # - Request object
227
+ def build_http(host, port)
228
+ params = [host, port]
229
+ params += @proxy_options.values_at(:host, :port, :user, :pass) unless @proxy_options.empty?
230
+ http = add_ssl(Net::HTTP.new(*params))
231
+ http = add_http_options(http) unless @http_options.empty?
232
+ http
233
+ end
234
+
172
235
  # Allow for https calls
173
236
  #
174
237
  # * *Args* :
@@ -184,6 +247,20 @@ module SendGrid
184
247
  http
185
248
  end
186
249
 
250
+ # Add others http options to http object
251
+ #
252
+ # * *Args* :
253
+ # - +http+ -> HTTP::NET object
254
+ # * *Returns* :
255
+ # - HTTP::NET object
256
+ #
257
+ def add_http_options(http)
258
+ @http_options.each do |attribute, value|
259
+ http.send("#{attribute}=", value)
260
+ end
261
+ http
262
+ end
263
+
187
264
  # Add variable values to the url.
188
265
  # (e.g. /your/api/{variable_value}/call)
189
266
  # Another example: if you have a ruby reserved word, such as true,
@@ -195,20 +272,22 @@ module SendGrid
195
272
  # - Client object
196
273
  #
197
274
  def _(name = nil)
198
- url_path = name ? @url_path.push(name) : @url_path
199
- @url_path = []
275
+ url_path = name ? @url_path + [name] : @url_path
200
276
  Client.new(host: @host, request_headers: @request_headers,
201
- version: @version, url_path: url_path)
277
+ version: @version, url_path: url_path,
278
+ http_options: @http_options)
202
279
  end
203
280
 
204
281
  # Dynamically add segments to the url, then call a method.
205
282
  # (e.g. client.name.name.get())
206
283
  #
207
284
  # * *Args* :
208
- # - The args are autmoatically passed in
285
+ # - The args are automatically passed in
209
286
  # * *Returns* :
210
287
  # - Client object or Response object
211
288
  #
289
+ # rubocop:disable Style/MethodMissingSuper
290
+ # rubocop:disable Style/MissingRespondToMissing
212
291
  def method_missing(name, *args, &_block)
213
292
  # Capture the version
214
293
  if name.to_s == 'version'
@@ -217,8 +296,42 @@ module SendGrid
217
296
  end
218
297
  # We have reached the end of the method chain, make the API call
219
298
  return build_request(name, args) if @methods.include?(name.to_s)
299
+
220
300
  # Add a segment to the URL
221
301
  _(name)
222
302
  end
303
+
304
+ private
305
+
306
+ def build_http_request(http_method)
307
+ uri = build_url(query_params: @query_params)
308
+ net_http = Kernel.const_get('Net::HTTP::' + http_method.to_s.capitalize)
309
+
310
+ @http = build_http(uri.host, uri.port)
311
+ @request = build_request_headers(net_http.new(uri.request_uri))
312
+ end
313
+
314
+ def update_content_type(http_method)
315
+ if @request_body && content_type_json?
316
+ # If body is a hash or array, encode it; else leave it alone
317
+ @request.body = if [Hash, Array].include?(@request_body.class)
318
+ @request_body.to_json
319
+ else
320
+ @request_body
321
+ end
322
+ @request['Content-Type'] = 'application/json'
323
+ elsif !@request_body && http_method.to_s == 'post'
324
+ @request['Content-Type'] = ''
325
+ else
326
+ @request.body = @request_body
327
+ end
328
+ end
329
+
330
+ def content_type_json?
331
+ !@request_headers.key?('Content-Type') ||
332
+ @request_headers['Content-Type'] == 'application/json'
333
+ end
334
+ # rubocop:enable Style/MethodMissingSuper
335
+ # rubocop:enable Style/MissingRespondToMissing
223
336
  end
224
337
  end
@@ -1,20 +1,23 @@
1
- # coding: utf-8
2
- lib = File.expand_path('../lib', __FILE__)
1
+ lib = File.expand_path('lib', __dir__)
3
2
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
3
 
5
4
  Gem::Specification.new do |spec|
6
- spec.name = 'ruby_http_client'
7
- spec.version = '3.3.0'
8
- spec.authors = ['Elmer Thomas']
9
- spec.email = 'dx@sendgrid.com'
10
- spec.summary = 'A simple REST client'
5
+ spec.name = 'ruby_http_client'
6
+ spec.version = '3.5.2'
7
+ spec.authors = ['Elmer Thomas']
8
+ spec.email = 'help@twilio.com'
9
+ spec.summary = 'A simple REST client'
11
10
  spec.description = 'Quickly and easily access any REST or REST-like API.'
12
- spec.homepage = 'http://github.com/sendgrid/ruby-http-client'
13
- spec.license = 'MIT'
14
- spec.files = `git ls-files -z`.split("\x0")
15
- spec.executables = spec.files.grep(/^bin/) { |f| File.basename(f) }
16
- spec.test_files = spec.files.grep(/^(test|spec|features)/)
11
+ spec.homepage = 'http://github.com/sendgrid/ruby-http-client'
12
+ spec.license = 'MIT'
13
+ spec.files = `git ls-files -z`.split("\x0")
14
+ spec.executables = spec.files.grep(/^bin/) { |f| File.basename(f) }
15
+ spec.test_files = spec.files.grep(/^(test|spec|features)/)
17
16
  spec.require_paths = ['lib']
18
17
 
19
- spec.add_development_dependency 'rake', '~> 0'
18
+ spec.add_development_dependency 'codecov'
19
+ spec.add_development_dependency 'minitest'
20
+ spec.add_development_dependency 'rake'
21
+ spec.add_development_dependency 'rubocop', '~> 0.88.0'
22
+ spec.add_development_dependency 'simplecov', '~> 0.18.5'
20
23
  end
@@ -0,0 +1,7 @@
1
+ if ENV['CI'] == 'true'
2
+ require 'simplecov'
3
+ SimpleCov.start
4
+
5
+ require 'codecov'
6
+ SimpleCov.formatter = SimpleCov::Formatter::Codecov
7
+ end
@@ -1,3 +1,4 @@
1
+ require './test/test_helper'
1
2
  require 'ruby_http_client'
2
3
  require 'minitest/autorun'
3
4
 
@@ -11,6 +12,26 @@ class MockResponse
11
12
  end
12
13
  end
13
14
 
15
+ class MockHttpResponse
16
+ attr_reader :code, :body, :headers
17
+
18
+ def initialize(code, body, headers)
19
+ @code = code
20
+ @body = body
21
+ @headers = headers
22
+ end
23
+
24
+ alias to_hash headers
25
+ end
26
+
27
+ class MockResponseWithRequestBody < MockResponse
28
+ attr_reader :request_body
29
+
30
+ def initialize(response)
31
+ @request_body = response['request_body']
32
+ end
33
+ end
34
+
14
35
  class MockRequest < SendGrid::Client
15
36
  def make_request(_http, _request)
16
37
  response = {}
@@ -21,6 +42,17 @@ class MockRequest < SendGrid::Client
21
42
  end
22
43
  end
23
44
 
45
+ class MockRequestWithRequestBody < SendGrid::Client
46
+ def make_request(_http, request)
47
+ response = {}
48
+ response['code'] = 200
49
+ response['body'] = { 'message' => 'success' }
50
+ response['headers'] = { 'headers' => 'test' }
51
+ response['request_body'] = request.body
52
+ MockResponseWithRequestBody.new(response)
53
+ end
54
+ end
55
+
24
56
  class TestClient < Minitest::Test
25
57
  def setup
26
58
  @headers = JSON.parse('
@@ -31,9 +63,14 @@ class TestClient < Minitest::Test
31
63
  ')
32
64
  @host = 'http://localhost:4010'
33
65
  @version = 'v3'
66
+ @http_options = { open_timeout: 60, read_timeout: 60 }
34
67
  @client = MockRequest.new(host: @host,
35
68
  request_headers: @headers,
36
69
  version: @version)
70
+ @client_with_options = MockRequest.new(host: @host,
71
+ request_headers: @headers,
72
+ version: @version,
73
+ http_options: @http_options)
37
74
  end
38
75
 
39
76
  def test_init
@@ -61,7 +98,7 @@ class TestClient < Minitest::Test
61
98
 
62
99
  def test_build_query_params
63
100
  url = ''
64
- query_params = { 'limit' => 100, 'offset' => 0, 'categories' => ['category1', 'category2'] }
101
+ query_params = { 'limit' => 100, 'offset' => 0, 'categories' => %w[category1 category2] }
65
102
  url = @client.build_query_params(url, query_params)
66
103
  assert_equal('?limit=100&offset=0&categories=category1&categories=category2', url)
67
104
  end
@@ -91,8 +128,8 @@ class TestClient < Minitest::Test
91
128
  args = nil
92
129
  response = @client.build_request(name, args)
93
130
  assert_equal(200, response.status_code)
94
- assert_equal({'message' => 'success'}, response.body)
95
- assert_equal({'headers' => 'test'}, response.headers)
131
+ assert_equal({ 'message' => 'success' }, response.body)
132
+ assert_equal({ 'headers' => 'test' }, response.headers)
96
133
  end
97
134
 
98
135
  def test_build_request_post_empty_content_type
@@ -103,7 +140,7 @@ class TestClient < Minitest::Test
103
140
  request_headers: headers,
104
141
  version: 'v3'
105
142
  )
106
- args = [{'request_body' => {"hogekey" => "hogevalue"}}]
143
+ args = [{ 'request_body' => { 'hogekey' => 'hogevalue' } }]
107
144
  client.build_request('post', args)
108
145
  assert_equal('application/json', client.request['Content-Type'])
109
146
  assert_equal('{"hogekey":"hogevalue"}', client.request.body)
@@ -143,15 +180,71 @@ class TestClient < Minitest::Test
143
180
  }
144
181
  client = MockRequest.new(
145
182
  host: 'https://localhost',
146
- request_headers: headers,
183
+ request_headers: headers
147
184
  )
148
185
  name = 'post'
149
- args = [{'request_body' => 'hogebody'}]
186
+ args = [{ 'request_body' => 'hogebody' }]
150
187
  client.build_request(name, args)
151
188
  assert_equal('multipart/form-data; boundary=xYzZY', client.request['Content-Type'])
152
189
  assert_equal('hogebody', client.request.body)
153
190
  end
154
191
 
192
+ def test_json_body_encode_hash
193
+ headers = {
194
+ 'Content-Type' => 'application/json'
195
+ }
196
+ client = MockRequestWithRequestBody.new(
197
+ host: 'https://localhost',
198
+ request_headers: headers
199
+ )
200
+ name = 'post'
201
+ args = [{ 'request_body' => { 'this_is' => 'json' } }]
202
+ response = client.build_request(name, args)
203
+ assert_equal('{"this_is":"json"}', response.request_body)
204
+ end
205
+
206
+ def test_json_body_encode_array
207
+ headers = {
208
+ 'Content-Type' => 'application/json'
209
+ }
210
+ client = MockRequestWithRequestBody.new(
211
+ host: 'https://localhost',
212
+ request_headers: headers
213
+ )
214
+ name = 'post'
215
+ args = [{ 'request_body' => [{ 'this_is' => 'json' }] }]
216
+ response = client.build_request(name, args)
217
+ assert_equal('[{"this_is":"json"}]', response.request_body)
218
+ end
219
+
220
+ def test_json_body_do_not_reencode
221
+ headers = {
222
+ 'Content-Type' => 'application/json'
223
+ }
224
+ client = MockRequestWithRequestBody.new(
225
+ host: 'https://localhost',
226
+ request_headers: headers
227
+ )
228
+ name = 'post'
229
+ args = [{ 'request_body' => '{"this_is":"json"}' }]
230
+ response = client.build_request(name, args)
231
+ assert_equal('{"this_is":"json"}', response.request_body)
232
+ end
233
+
234
+ def test_json_body_do_not_reencode_simplejson
235
+ headers = {
236
+ 'Content-Type' => 'application/json'
237
+ }
238
+ client = MockRequestWithRequestBody.new(
239
+ host: 'https://localhost',
240
+ request_headers: headers
241
+ )
242
+ name = 'post'
243
+ args = [{ 'request_body' => 'true' }]
244
+ response = client.build_request(name, args)
245
+ assert_equal('true', response.request_body)
246
+ end
247
+
155
248
  def add_ssl
156
249
  uri = URI.parse('https://localhost:4010')
157
250
  http = Net::HTTP.new(uri.host, uri.port)
@@ -160,15 +253,156 @@ class TestClient < Minitest::Test
160
253
  assert_equal(http.verify_mode, OpenSSL::SSL::VERIFY_PEER)
161
254
  end
162
255
 
256
+ def test_add_http_options
257
+ uri = URI.parse('https://localhost:4010')
258
+ http = Net::HTTP.new(uri.host, uri.port)
259
+ http = @client_with_options.add_http_options(http)
260
+ assert_equal(http.open_timeout, 60)
261
+ assert_equal(http.read_timeout, 60)
262
+ end
263
+
163
264
  def test__
164
265
  url1 = @client._('test')
165
266
  assert_equal(['test'], url1.url_path)
166
267
  end
167
268
 
269
+ def test_ratelimit_core
270
+ expiry = Time.now.to_i + 1
271
+ rl = SendGrid::Response::Ratelimit.new(500, 100, expiry)
272
+ rl2 = SendGrid::Response::Ratelimit.new(500, 0, expiry)
273
+
274
+ refute rl.exceeded?
275
+ assert rl2.exceeded?
276
+
277
+ assert_equal(rl.used, 400)
278
+ assert_equal(rl2.used, 500)
279
+ end
280
+
281
+ def test_response_ratelimit_parsing
282
+ headers = {
283
+ 'X-RateLimit-Limit' => '500',
284
+ 'X-RateLimit-Remaining' => '300',
285
+ 'X-RateLimit-Reset' => Time.now.to_i.to_s
286
+ }
287
+
288
+ body = ''
289
+ code = 204
290
+ http_response = MockHttpResponse.new(code, body, headers)
291
+ response = SendGrid::Response.new(http_response)
292
+
293
+ refute_nil response.ratelimit
294
+ refute response.ratelimit.exceeded?
295
+ end
296
+
168
297
  def test_method_missing
169
298
  response = @client.get
170
299
  assert_equal(200, response.status_code)
171
- assert_equal({'message' => 'success'}, response.body)
172
- assert_equal({'headers' => 'test'}, response.headers)
300
+ assert_equal({ 'message' => 'success' }, response.body)
301
+ assert_equal({ 'headers' => 'test' }, response.headers)
302
+ end
303
+
304
+ def test_http_options
305
+ url1 = @client_with_options._('test')
306
+ assert_equal(@host, @client_with_options.host)
307
+ assert_equal(@headers, @client_with_options.request_headers)
308
+ assert_equal(['test'], url1.url_path)
309
+ end
310
+
311
+ def test_proxy_options
312
+ proxy_options = {
313
+ host: '127.0.0.1', port: 8080, user: 'anonymous', pass: 'secret'
314
+ }
315
+ client = MockRequest.new(
316
+ host: 'https://api.sendgrid.com',
317
+ request_headers: { 'Authorization' => 'Bearer xxx' },
318
+ proxy_options: proxy_options
319
+ ).version('v3').api_keys
320
+
321
+ assert(client.proxy_address, '127.0.0.1')
322
+ assert(client.proxy_pass, 'secret')
323
+ assert(client.proxy_port, 8080)
324
+ assert(client.proxy_user, 'anonymous')
325
+ end
326
+
327
+ def test_proxy_from_http_proxy_environment_variable
328
+ ENV['http_proxy'] = 'anonymous:secret@127.0.0.1:8080'
329
+
330
+ client = MockRequest.new(
331
+ host: 'https://api.sendgrid.com',
332
+ request_headers: { 'Authorization' => 'Bearer xxx' }
333
+ ).version('v3').api_keys
334
+
335
+ assert(client.proxy_address, '127.0.0.1')
336
+ assert(client.proxy_pass, 'secret')
337
+ assert(client.proxy_port, 8080)
338
+ assert(client.proxy_user, 'anonymous')
339
+ ensure
340
+ ENV.delete('http_proxy')
341
+ end
342
+
343
+ # def test_docker_exists
344
+ # assert(File.file?('./Dockerfile') || File.file?('./docker/Dockerfile'))
345
+ # end
346
+
347
+ # def test_docker_compose_exists
348
+ # assert(File.file?('./docker-compose.yml') || File.file?('./docker/docker-compose.yml'))
349
+ # end
350
+
351
+ def test_env_sample_exists
352
+ assert(File.file?('./.env_sample'))
353
+ end
354
+
355
+ def test_gitignore_exists
356
+ assert(File.file?('./.gitignore'))
357
+ end
358
+
359
+ def test_travis_exists
360
+ assert(File.file?('./.travis.yml'))
361
+ end
362
+
363
+ def test_codeclimate_exists
364
+ assert(File.file?('./.codeclimate.yml'))
365
+ end
366
+
367
+ def test_changelog_exists
368
+ assert(File.file?('./CHANGELOG.md'))
369
+ end
370
+
371
+ def test_code_of_conduct_exists
372
+ assert(File.file?('./CODE_OF_CONDUCT.md'))
373
+ end
374
+
375
+ def test_contributing_exists
376
+ assert(File.file?('./CONTRIBUTING.md'))
377
+ end
378
+
379
+ def test_issue_template_exists
380
+ assert(File.file?('./ISSUE_TEMPLATE.md'))
381
+ end
382
+
383
+ def test_license_exists
384
+ assert(File.file?('./LICENSE'))
385
+ end
386
+
387
+ def test_pull_request_template_exists
388
+ assert(File.file?('./PULL_REQUEST_TEMPLATE.md'))
389
+ end
390
+
391
+ def test_readme_exists
392
+ assert(File.file?('./README.md'))
393
+ end
394
+
395
+ def test_troubleshooting_exists
396
+ assert(File.file?('./TROUBLESHOOTING.md'))
397
+ end
398
+
399
+ def test_use_cases_exists
400
+ assert(File.file?('use_cases/README.md'))
401
+ end
402
+
403
+ def test_license_date_is_updated
404
+ license_end_year = IO.read('LICENSE').match(/Copyright \(C\) (\d{4}), Twilio SendGrid/)[1].to_i
405
+ current_year = Time.new.year
406
+ assert_equal(current_year, license_end_year)
173
407
  end
174
408
  end