my_john_deere_api 2.3.4 → 2.5.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +111 -1
  3. data/lib/my_john_deere_api.rb +1 -0
  4. data/lib/my_john_deere_api/client.rb +13 -3
  5. data/lib/my_john_deere_api/net_http_retry.rb +4 -0
  6. data/lib/my_john_deere_api/net_http_retry/decorator.rb +62 -0
  7. data/lib/my_john_deere_api/net_http_retry/invalid_response_error.rb +23 -0
  8. data/lib/my_john_deere_api/net_http_retry/max_retries_exceeded_error.rb +20 -0
  9. data/lib/my_john_deere_api/version.rb +1 -1
  10. data/test/lib/my_john_deere_api/client_test.rb +20 -3
  11. data/test/lib/my_john_deere_api/net_http_retry/decorator_test.rb +208 -0
  12. data/test/lib/my_john_deere_api/net_http_retry/invalid_response_error_test.rb +19 -0
  13. data/test/lib/my_john_deere_api/net_http_retry/max_retries_exceeded_error_test.rb +13 -0
  14. data/test/support/helper.rb +1 -0
  15. data/test/support/vcr/accessor/delete_failed.yml +327 -0
  16. data/test/support/vcr/accessor/delete_invalid.yml +39 -0
  17. data/test/support/vcr/accessor/delete_max_failed.yml +615 -0
  18. data/test/support/vcr/accessor/delete_retry.yml +191 -0
  19. data/test/support/vcr/accessor/delete_retry_too_soon.yml +191 -0
  20. data/test/support/vcr/accessor/get_failed.yml +390 -0
  21. data/test/support/vcr/accessor/get_invalid.yml +46 -0
  22. data/test/support/vcr/accessor/get_max_failed.yml +734 -0
  23. data/test/support/vcr/accessor/get_retry.yml +226 -0
  24. data/test/support/vcr/accessor/get_retry_too_soon.yml +226 -0
  25. data/test/support/vcr/accessor/post_failed.yml +417 -0
  26. data/test/support/vcr/accessor/post_invalid.yml +49 -0
  27. data/test/support/vcr/accessor/post_max_failed.yml +785 -0
  28. data/test/support/vcr/accessor/post_retry.yml +241 -0
  29. data/test/support/vcr/accessor/post_retry_too_soon.yml +241 -0
  30. data/test/support/vcr/accessor/put_failed.yml +372 -0
  31. data/test/support/vcr/accessor/put_invalid.yml +44 -0
  32. data/test/support/vcr/accessor/put_max_failed.yml +700 -0
  33. data/test/support/vcr/accessor/put_retry.yml +216 -0
  34. data/test/support/vcr/accessor/put_retry_too_soon.yml +216 -0
  35. metadata +30 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0b6ea750328ecdcb653d61bbadf19ab4ab23ef2fb712963bda1351736ffedb37
4
- data.tar.gz: '059eab2d2bbb7d8d716bde8f61f5ce935c46039dfbe6c82b933041e4f90fd072'
3
+ metadata.gz: ba69ef151796a688f69eba0f6a396d277baa893aa9e589922f2f2eac7b4e3a6a
4
+ data.tar.gz: e0539d88ae84a348113be06e9f81299b64a5c74598428e9d2a33ec00b0b49584
5
5
  SHA512:
6
- metadata.gz: d9e2285aa182a610b45b757707a26edad1e2ae342ce7ba62609c6a486f247fe3beaaad9e7df39a2ce2077b099b1a31edd53b1732fcdcde1d5b7253da648a7bdb
7
- data.tar.gz: a9fad3831aa447433ee0969eb839d8e21245a29e955b1602ba560b75a989c8737c10c1489474d3982a7f71398407adfb9900968a240fd3cd677b05990e9dc3de
6
+ metadata.gz: 9a40c8720b6e44da21bb5c2d0247055cf8b4f7d04e5b6d9e5d6db03778af368e64b5d2ba375aa02aa671aebdbf3d64d87c8090beb958bb7dc83ef5b1da52196e
7
+ data.tar.gz: 77bac482b0518e59b534d1916b9155c2a59e75308cc71e93354c9a3d071fa10c35362ee836f3eb456b15f612b73d503f06c735d7af942d220415a39c6943c606
data/README.md CHANGED
@@ -24,6 +24,7 @@ without having to code your own oAuth process, API requests, and pagination.
24
24
  * [Organizations](#organizations)
25
25
  * [Assets](#assets)
26
26
  * [Asset Locations](#asset-locations)
27
+ * [Fields](#fields)
27
28
  * [Direct API Requests](#direct-api-requests)
28
29
  * [GET](#get)
29
30
  * [POST](#post)
@@ -346,7 +347,7 @@ organization.links
346
347
  # => {
347
348
  # 'self' => 'https://sandboxapi.deere.com/platform/organizations/1234',
348
349
  # 'machines' => 'https://sandboxapi.deere.com/platform/organizations/1234/machines',
349
- # 'wdtCapableMachines' => 'ttps://sandboxapi.deere.com/platform/organizations/1234/machines?capability=wdt'
350
+ # 'wdtCapableMachines' => 'https://sandboxapi.deere.com/platform/organizations/1234/machines?capability=wdt'
350
351
  # }
351
352
 
352
353
  organization.assets
@@ -558,6 +559,115 @@ Note that locations are called "Asset Locations" in John Deere, but we call the
558
559
  `asset.locations`, for brevity.
559
560
 
560
561
 
562
+ ### [Fields](https://developer.deere.com/#!documentation&doc=myjohndeere%2FfieldsADS.htm)
563
+
564
+ Handles an organization's fields. Field collections support the following methods:
565
+
566
+ * all
567
+ * count
568
+ * first
569
+ * find(field\_id)
570
+
571
+ An individual field supports the following methods and associations:
572
+
573
+ * id
574
+ * name
575
+ * archived?
576
+ * links
577
+ * flags (collection of this field's flags)
578
+
579
+ The `count` method only requires loading the first page of results, so it's a relatively cheap call. On the other hand,
580
+ `all` forces the entire collection to be loaded from John Deere's API, so use with caution. Fields can be
581
+ created via the API, but there is no `create` method on this collection yet.
582
+
583
+ ```ruby
584
+ organization.fields
585
+ # => collection of fields under this organization
586
+
587
+ organization.fields.count
588
+ # => 15
589
+
590
+ organization.fields.first
591
+ # => an individual field object
592
+
593
+ field = organization.fields.find(1234)
594
+ # => an individual field object, fetched by ID
595
+
596
+ field.name
597
+ # => 'Smith Field'
598
+
599
+ field.archived?
600
+ # => false
601
+
602
+ field.links
603
+ # => a hash of API urls related to this field
604
+
605
+ field.flags
606
+ # => collection of flags belonging to this field
607
+ ```
608
+
609
+
610
+ ### [Flags](https://developer.deere.com/#!documentation&doc=.%2Fmyjohndeere%2Fflags.htm)
611
+
612
+ Handles a field's flags. Flag collections support the following methods. Note, John Deere does not provide an endpoint to retrieve a specific flag by id:
613
+
614
+ * all
615
+ * count
616
+ * first
617
+
618
+ An individual flag supports the following methods and associations:
619
+
620
+ * id
621
+ * notes
622
+ * geometry
623
+ * archived?
624
+ * proximity\_alert\_enabled?
625
+ * links
626
+
627
+ The `count` method only requires loading the first page of results, so it's a relatively cheap call. On the other hand,
628
+ `all` forces the entire collection to be loaded from John Deere's API, so use with caution. Flags can be
629
+ created via the API, but there is no `create` method on this collection yet.
630
+
631
+ ```ruby
632
+ field.flags
633
+ # => collection of flags under this field
634
+
635
+ field.flags.count
636
+ # => 15
637
+
638
+ flag = field.flags.first
639
+ # => an individual flag object
640
+
641
+ flag.notes
642
+ # => 'A big rock on the left after entering the field'
643
+
644
+ flag.geometry
645
+ # => a GeoJSON formatted hash, for example:
646
+ # {
647
+ # "type"=>"Feature",
648
+ # "geometry"=>{
649
+ # "geometries"=>[
650
+ # {
651
+ # "coordinates"=>[-95.123456, 40.123456],
652
+ # "type"=>"Point"
653
+ # }
654
+ # ],
655
+ # "type"=>"GeometryCollection"
656
+ # }
657
+ # }
658
+
659
+
660
+ field.archived?
661
+ # => false
662
+
663
+ field.proximity_alert_enabled?
664
+ # => true
665
+
666
+ field.links
667
+ # => a hash of API urls related to this flag
668
+ ```
669
+
670
+
561
671
  ## Direct API Requests
562
672
 
563
673
  While the goal of the client is to eliminate the need to make/interpret calls to the John Deere API, it's important
@@ -13,4 +13,5 @@ module MyJohnDeereApi
13
13
  autoload :Validators, 'my_john_deere_api/validators'
14
14
 
15
15
  require 'my_john_deere_api/errors'
16
+ require 'my_john_deere_api/net_http_retry'
16
17
  end
@@ -4,10 +4,11 @@ module MyJohnDeereApi
4
4
  include Helpers::CaseConversion
5
5
 
6
6
  attr_accessor :contribution_definition_id
7
- attr_reader :api_key, :api_secret, :access_token, :access_secret
7
+ attr_reader :api_key, :api_secret, :access_token, :access_secret, :http_retry_options
8
8
 
9
9
  DEFAULTS = {
10
- environment: :live
10
+ environment: :live,
11
+ http_retry: {}
11
12
  }
12
13
 
13
14
  ##
@@ -37,6 +38,7 @@ module MyJohnDeereApi
37
38
 
38
39
  self.environment = options[:environment]
39
40
  @contribution_definition_id = options[:contribution_definition_id]
41
+ @http_retry_options = options[:http_retry]
40
42
  end
41
43
 
42
44
  ##
@@ -45,7 +47,15 @@ module MyJohnDeereApi
45
47
 
46
48
  def accessor
47
49
  return @accessor if defined?(@accessor)
48
- @accessor = OAuth::AccessToken.new(consumer.user_get, access_token, access_secret)
50
+
51
+ @accessor = NetHttpRetry::Decorator.new(
52
+ OAuth::AccessToken.new(
53
+ consumer.user_get,
54
+ access_token,
55
+ access_secret
56
+ ),
57
+ http_retry_options
58
+ )
49
59
  end
50
60
 
51
61
  ##
@@ -0,0 +1,4 @@
1
+ require 'my_john_deere_api/net_http_retry/decorator'
2
+
3
+ require 'my_john_deere_api/net_http_retry/invalid_response_error'
4
+ require 'my_john_deere_api/net_http_retry/max_retries_exceeded_error'
@@ -0,0 +1,62 @@
1
+ module MyJohnDeereApi
2
+ module NetHttpRetry
3
+ class Decorator
4
+ attr_reader :object, :request_methods, :retry_delay_exponent, :max_retries, :retry_codes, :valid_codes
5
+
6
+ DEFAULTS = {
7
+ request_methods: [:get, :post, :put, :delete],
8
+ retry_delay_exponent: 2,
9
+ max_retries: 12,
10
+ retry_codes: ['429', '503'],
11
+ valid_codes: ['200', '201', '204'],
12
+ }
13
+
14
+ def initialize(object, options={})
15
+ @object = object
16
+
17
+ # defaults that can be used as-is
18
+ [:request_methods, :retry_delay_exponent, :max_retries].each do |option|
19
+ instance_variable_set(:"@#{option}", options[option] || DEFAULTS[option])
20
+ end
21
+
22
+ # defaults that require casting as string arrays
23
+ [:retry_codes, :valid_codes].each do |option|
24
+ instance_variable_set(:"@#{option}", (options[option] || DEFAULTS[option]).map(&:to_s))
25
+ end
26
+ end
27
+
28
+ def request(method_name, *args)
29
+ retries = 0
30
+ result = object.send(method_name, *args)
31
+
32
+ while retry_codes.include?(result.code)
33
+ if retries >= max_retries
34
+ raise MaxRetriesExceededError.new(method_name, "#{result.code} #{result.message}")
35
+ end
36
+
37
+ delay = [result['retry-after'].to_i, retry_delay_exponent ** retries].max
38
+ sleep(delay)
39
+
40
+ result = object.send(method_name, *args)
41
+ retries += 1
42
+ end
43
+
44
+ unless valid_codes.include?(result.code)
45
+ raise InvalidResponseError.new(result)
46
+ end
47
+
48
+ result
49
+ end
50
+
51
+ private
52
+
53
+ def method_missing(method_name, *args, &block)
54
+ if request_methods.include?(method_name)
55
+ request(method_name, *args)
56
+ else
57
+ object.send(method_name, *args, &block)
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,23 @@
1
+ module MyJohnDeereApi
2
+ module NetHttpRetry
3
+ ##
4
+ # This error is used when a single request has exceeded
5
+ # the number of retries allowed by NetHttpRetry::Decorator::MAX_RETRIES.
6
+
7
+ class InvalidResponseError < StandardError
8
+
9
+ ##
10
+ # argument is a string which describes the attempted request
11
+
12
+ def initialize(response)
13
+ message = {
14
+ code: response.code,
15
+ message: response.message,
16
+ body: response.body,
17
+ }.to_json
18
+
19
+ super(message)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,20 @@
1
+ module MyJohnDeereApi
2
+ module NetHttpRetry
3
+ ##
4
+ # This error is used when a single request has exceeded
5
+ # the number of retries allowed by NetHttpRetry::Decorator::MAX_RETRIES.
6
+
7
+ class MaxRetriesExceededError < StandardError
8
+
9
+ ##
10
+ # argument is a string which describes the attempted request
11
+
12
+ def initialize(request_method, response_message)
13
+ message = "Max retries exceeded for #{request_method.to_s.upcase} " +
14
+ "request: #{response_message}"
15
+
16
+ super(message)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -1,3 +1,3 @@
1
1
  module MyJohnDeereApi
2
- VERSION='2.3.4'
2
+ VERSION='2.5.0'
3
3
  end
@@ -6,7 +6,7 @@ describe 'MyJohnDeereApi::Client' do
6
6
  assert_equal 'thisIsATest', client.send(:camelize, :this_is_a_test)
7
7
  end
8
8
 
9
- describe '#initialize(api_key, api_secret)' do
9
+ describe '#initialize(api_key, api_secret, options={})' do
10
10
  it 'sets the api key/secret' do
11
11
  client = JD::Client.new(api_key, api_secret)
12
12
 
@@ -35,6 +35,23 @@ describe 'MyJohnDeereApi::Client' do
35
35
  client = JD::Client.new(api_key, api_secret, contribution_definition_id: contribution_definition_id)
36
36
  assert_equal contribution_definition_id, client.contribution_definition_id
37
37
  end
38
+
39
+ it 'accepts a list of parameters for NetHttpRetry' do
40
+ custom_retries = JD::NetHttpRetry::Decorator::DEFAULTS[:max_retries] + 10
41
+
42
+ VCR.use_cassette('catalog') do
43
+ new_client = JD::Client.new(
44
+ api_key,
45
+ api_secret,
46
+ contribution_definition_id: contribution_definition_id,
47
+ environment: :sandbox,
48
+ access: [access_token, access_secret],
49
+ http_retry: {max_retries: custom_retries}
50
+ )
51
+
52
+ assert_equal custom_retries, new_client.accessor.max_retries
53
+ end
54
+ end
38
55
  end
39
56
 
40
57
  describe '#contribution_definition_id' do
@@ -130,7 +147,7 @@ describe 'MyJohnDeereApi::Client' do
130
147
 
131
148
  it 'sends the request' do
132
149
  response = VCR.use_cassette('put_asset') { client.put("/assets/#{asset_id}", attributes) }
133
- puts "HASH!! #{response.inspect}" if response.is_a?(Hash)
150
+
134
151
  assert_equal '204', response.code
135
152
  assert_equal 'No Content', response.message
136
153
  end
@@ -199,7 +216,7 @@ describe 'MyJohnDeereApi::Client' do
199
216
 
200
217
  describe '#accessor' do
201
218
  it 'returns an object that can make user-specific requests' do
202
- assert_kind_of OAuth::AccessToken, accessor
219
+ assert_kind_of JD::NetHttpRetry::Decorator, accessor
203
220
  assert_kind_of OAuth::Consumer, accessor.consumer
204
221
  assert_equal access_token, accessor.token
205
222
  assert_equal access_secret, accessor.secret
@@ -0,0 +1,208 @@
1
+ require 'support/helper'
2
+
3
+ describe 'JD::NetHttpRetry::Decorator' do
4
+ REQUESTS = {
5
+ get: '/',
6
+ post: '/organizations/000000/assets',
7
+ put: '/assets/00000000-0000-0000-0000-000000000000',
8
+ delete: '/assets/00000000-0000-0000-0000-000000000000'
9
+ }
10
+
11
+ REQUEST_METHODS = REQUESTS.keys
12
+
13
+ let(:klass) { JD::NetHttpRetry::Decorator }
14
+ let(:object) { klass.new(mock, options) }
15
+
16
+ let(:options) do
17
+ {
18
+ request_methods: request_methods,
19
+ retry_delay_exponent: retry_delay_exponent,
20
+ max_retries: max_retries,
21
+ retry_codes: retry_codes,
22
+ valid_codes: valid_codes
23
+ }
24
+ end
25
+
26
+ let(:request_methods) { nil }
27
+ let(:retry_delay_exponent) { nil }
28
+ let(:max_retries) { nil }
29
+ let(:retry_codes) { nil }
30
+ let(:valid_codes) { nil }
31
+
32
+ let(:retry_values) { [13, 17, 19, 23] }
33
+ let(:exponential_retries) { (0..klass::DEFAULTS[:max_retries]-1).map{|i| 2 ** i} }
34
+
35
+ it 'wraps a "net-http"-responsive object' do
36
+ assert_kind_of OAuth::AccessToken, accessor.object
37
+ end
38
+
39
+ describe '#initialize' do
40
+ describe 'when request methods are specified' do
41
+ let(:request_methods) { [:banana, :fana, :fofana] }
42
+
43
+ it 'uses the supplied values' do
44
+ assert_equal request_methods, object.request_methods
45
+ end
46
+ end
47
+
48
+ describe 'when request methods are not specified' do
49
+ it 'uses the default values' do
50
+ assert_equal klass::DEFAULTS[:request_methods], object.request_methods
51
+ end
52
+ end
53
+
54
+ describe 'when retry_delay_exponent is specified' do
55
+ let(:retry_delay_exponent) { 42 }
56
+
57
+ it 'uses the supplied value' do
58
+ assert_equal retry_delay_exponent, object.retry_delay_exponent
59
+ end
60
+ end
61
+
62
+ describe 'when retry_delay_exponent is not specified' do
63
+ it 'uses the default value' do
64
+ assert_equal klass::DEFAULTS[:retry_delay_exponent], object.retry_delay_exponent
65
+ end
66
+ end
67
+
68
+ describe 'when max_retries is specified' do
69
+ let(:max_retries) { 42 }
70
+
71
+ it 'uses the supplied value' do
72
+ assert_equal max_retries, object.max_retries
73
+ end
74
+ end
75
+
76
+ describe 'when max_retries is not specified' do
77
+ it 'uses the default value' do
78
+ assert_equal klass::DEFAULTS[:max_retries], object.max_retries
79
+ end
80
+ end
81
+
82
+ describe 'when retry_codes are specified' do
83
+ let(:retry_codes) { ['200', '201'] }
84
+
85
+ it 'uses the supplied values' do
86
+ assert_equal retry_codes, object.retry_codes
87
+ end
88
+ end
89
+
90
+ describe 'when retry_codes are specified as integers' do
91
+ let(:retry_codes) { [200, 201] }
92
+
93
+ it 'uses the stringified versions of the supplied values' do
94
+ assert_equal retry_codes.map(&:to_s), object.retry_codes
95
+ end
96
+ end
97
+
98
+ describe 'when retry_codes are not specified' do
99
+ it 'uses the default values' do
100
+ assert_equal klass::DEFAULTS[:retry_codes], object.retry_codes
101
+ end
102
+ end
103
+
104
+ describe 'when valid_codes are specified' do
105
+ let(:valid_codes) { ['123', '234'] }
106
+
107
+ it 'uses the supplied values' do
108
+ assert_equal valid_codes, object.valid_codes
109
+ end
110
+ end
111
+
112
+ describe 'when valid_codes are specified as integers' do
113
+ let(:valid_codes) { [123, 234] }
114
+
115
+ it 'uses the stringified versions of the supplied values' do
116
+ assert_equal valid_codes.map(&:to_s), object.valid_codes
117
+ end
118
+ end
119
+
120
+ describe 'when valid_codes are not specified' do
121
+ it 'uses the default values' do
122
+ assert_kind_of Array, klass::DEFAULTS[:valid_codes]
123
+ refute klass::DEFAULTS[:valid_codes].empty?
124
+
125
+ assert_equal klass::DEFAULTS[:valid_codes], object.valid_codes
126
+ end
127
+ end
128
+ end
129
+
130
+ describe "honors Retry-After headers" do
131
+ REQUEST_METHODS.each do |request_method|
132
+ it "in #{request_method.to_s.upcase} requests" do
133
+ retry_values.each do |retry_seconds|
134
+ accessor.expects(:sleep).with(retry_seconds)
135
+ end
136
+
137
+ VCR.use_cassette("accessor/#{request_method}_retry") do
138
+ accessor.send(request_method, REQUESTS[request_method])
139
+ end
140
+ end
141
+ end
142
+ end
143
+
144
+ describe 'employs exponential wait times for automatic retries' do
145
+ REQUEST_METHODS.each do |request_method|
146
+ it "in #{request_method.to_s.upcase} requests" do
147
+ exponential_retries[0,8].each do |retry_seconds|
148
+ accessor.expects(:sleep).with(retry_seconds)
149
+ end
150
+
151
+ VCR.use_cassette("accessor/#{request_method}_failed") do
152
+ accessor.send(request_method, REQUESTS[request_method])
153
+ end
154
+ end
155
+ end
156
+ end
157
+
158
+ describe 'when Retry-After is shorter than exponential wait time' do
159
+ REQUEST_METHODS.each do |request_method|
160
+ it "chooses longer exponential time in #{request_method.to_s.upcase} requests" do
161
+ exponential_retries[0,4].each do |retry_seconds|
162
+ accessor.expects(:sleep).with(retry_seconds)
163
+ end
164
+
165
+ VCR.use_cassette("accessor/#{request_method}_retry_too_soon") do
166
+ accessor.send(request_method, REQUESTS[request_method])
167
+ end
168
+ end
169
+ end
170
+ end
171
+
172
+ describe 'when max retries have been reached' do
173
+ REQUEST_METHODS.each do |request_method|
174
+ it "returns an error for #{request_method.to_s.upcase} requests" do
175
+ exponential_retries.each do |retry_seconds|
176
+ accessor.expects(:sleep).with(retry_seconds)
177
+ end
178
+
179
+ exception = assert_raises(JD::NetHttpRetry::MaxRetriesExceededError) do
180
+ VCR.use_cassette("accessor/#{request_method}_max_failed") do
181
+ accessor.send(request_method, REQUESTS[request_method])
182
+ end
183
+ end
184
+
185
+ expected_error = "Max retries exceeded for #{request_method.to_s.upcase} request: 429 Too Many Requests"
186
+ assert_equal expected_error, exception.message
187
+ end
188
+ end
189
+ end
190
+
191
+ describe 'when an invalid response code is returned' do
192
+ REQUEST_METHODS.each do |request_method|
193
+ it "returns an error for #{request_method.to_s.upcase} requests" do
194
+ exception = assert_raises(JD::NetHttpRetry::InvalidResponseError) do
195
+ VCR.use_cassette("accessor/#{request_method}_invalid") do
196
+ accessor.send(request_method, REQUESTS[request_method])
197
+ end
198
+ end
199
+
200
+ exception_json = JSON.parse(exception.message)
201
+
202
+ assert_equal '500', exception_json['code']
203
+ assert_equal 'Internal Error', exception_json['message']
204
+ assert_equal 'You Have Died of Dysentery', exception_json['body']
205
+ end
206
+ end
207
+ end
208
+ end