ruby_http_client 3.3.0 → 3.5.5

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.
@@ -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,22 @@
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.5'
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 'simplecov', '~> 0.18.5'
20
22
  end
Binary file
Binary file
@@ -0,0 +1,7 @@
1
+ if RUBY_VERSION.equal?('2.7')
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,152 @@ 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_gh_actions_exists
360
+ assert(File.file?('./.github/workflows/test-and-deploy.yml'))
361
+ end
362
+
363
+ def test_changelog_exists
364
+ assert(File.file?('./CHANGELOG.md'))
365
+ end
366
+
367
+ def test_code_of_conduct_exists
368
+ assert(File.file?('./CODE_OF_CONDUCT.md'))
369
+ end
370
+
371
+ def test_contributing_exists
372
+ assert(File.file?('./CONTRIBUTING.md'))
373
+ end
374
+
375
+ def test_issue_template_exists
376
+ assert(File.file?('./ISSUE_TEMPLATE.md'))
377
+ end
378
+
379
+ def test_license_exists
380
+ assert(File.file?('./LICENSE'))
381
+ end
382
+
383
+ def test_pull_request_template_exists
384
+ assert(File.file?('./PULL_REQUEST_TEMPLATE.md'))
385
+ end
386
+
387
+ def test_readme_exists
388
+ assert(File.file?('./README.md'))
389
+ end
390
+
391
+ def test_troubleshooting_exists
392
+ assert(File.file?('./TROUBLESHOOTING.md'))
393
+ end
394
+
395
+ def test_use_cases_exists
396
+ assert(File.file?('use_cases/README.md'))
397
+ end
398
+
399
+ def test_license_date_is_updated
400
+ license_end_year = IO.read('LICENSE').match(/Copyright \(C\) (\d{4}), Twilio SendGrid/)[1].to_i
401
+ current_year = Time.new.year
402
+ assert_equal(current_year, license_end_year)
173
403
  end
174
404
  end
Binary file
@@ -0,0 +1,3 @@
1
+ This directory provides examples for specific use cases. Please [open an issue](https://github.com/sendgrid/ruby-http-client/issues) or make a pull request for any use cases you would like us to document here. Thank you!
2
+
3
+ # Table of Contents