my_john_deere_api 2.5.0 → 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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/lib/my_john_deere_api.rb +1 -1
  3. data/lib/my_john_deere_api/authorize.rb +36 -27
  4. data/lib/my_john_deere_api/client.rb +38 -29
  5. data/lib/my_john_deere_api/consumer.rb +46 -35
  6. data/lib/my_john_deere_api/helpers/case_conversion.rb +5 -2
  7. data/lib/my_john_deere_api/helpers/uri_helpers.rb +1 -1
  8. data/lib/my_john_deere_api/model/contribution_definition.rb +1 -1
  9. data/lib/my_john_deere_api/net_http_retry/decorator.rb +10 -11
  10. data/lib/my_john_deere_api/net_http_retry/invalid_response_error.rb +2 -2
  11. data/lib/my_john_deere_api/request/collection/asset_locations.rb +1 -1
  12. data/lib/my_john_deere_api/request/collection/assets.rb +1 -1
  13. data/lib/my_john_deere_api/request/collection/base.rb +2 -14
  14. data/lib/my_john_deere_api/request/collection/contribution_definitions.rb +1 -1
  15. data/lib/my_john_deere_api/request/collection/contribution_products.rb +1 -1
  16. data/lib/my_john_deere_api/request/collection/fields.rb +1 -1
  17. data/lib/my_john_deere_api/request/collection/flags.rb +1 -1
  18. data/lib/my_john_deere_api/request/collection/organizations.rb +1 -1
  19. data/lib/my_john_deere_api/request/create/asset.rb +8 -11
  20. data/lib/my_john_deere_api/request/create/asset_location.rb +24 -25
  21. data/lib/my_john_deere_api/request/create/base.rb +2 -20
  22. data/lib/my_john_deere_api/request/individual/asset.rb +2 -2
  23. data/lib/my_john_deere_api/request/individual/base.rb +6 -24
  24. data/lib/my_john_deere_api/request/individual/contribution_definition.rb +1 -1
  25. data/lib/my_john_deere_api/request/individual/contribution_product.rb +1 -1
  26. data/lib/my_john_deere_api/request/individual/field.rb +1 -1
  27. data/lib/my_john_deere_api/request/individual/organization.rb +1 -1
  28. data/lib/my_john_deere_api/request/update/asset.rb +2 -2
  29. data/lib/my_john_deere_api/request/update/base.rb +1 -19
  30. data/lib/my_john_deere_api/version.rb +1 -1
  31. data/test/lib/my_john_deere_api/authorize_test.rb +37 -25
  32. data/test/lib/my_john_deere_api/client_test.rb +22 -56
  33. data/test/lib/my_john_deere_api/consumer_test.rb +16 -28
  34. data/test/lib/my_john_deere_api/helpers/uri_helpers_test.rb +0 -10
  35. data/test/lib/my_john_deere_api/model/asset_location_test.rb +0 -4
  36. data/test/lib/my_john_deere_api/model/asset_test.rb +9 -8
  37. data/test/lib/my_john_deere_api/model/base_test.rb +4 -8
  38. data/test/lib/my_john_deere_api/model/contribution_definition_test.rb +3 -7
  39. data/test/lib/my_john_deere_api/model/contribution_product_test.rb +4 -7
  40. data/test/lib/my_john_deere_api/model/field_test.rb +4 -6
  41. data/test/lib/my_john_deere_api/model/flag_test.rb +6 -7
  42. data/test/lib/my_john_deere_api/model/organization_test.rb +3 -5
  43. data/test/lib/my_john_deere_api/net_http_retry/decorator_test.rb +14 -14
  44. data/test/lib/my_john_deere_api/net_http_retry/invalid_response_error_test.rb +22 -2
  45. data/test/lib/my_john_deere_api/request/collection/asset_locations_test.rb +2 -2
  46. data/test/lib/my_john_deere_api/request/collection/assets_test.rb +2 -2
  47. data/test/lib/my_john_deere_api/request/collection/contribution_definitions_test.rb +2 -2
  48. data/test/lib/my_john_deere_api/request/collection/contribution_products_test.rb +2 -2
  49. data/test/lib/my_john_deere_api/request/collection/fields_test.rb +2 -2
  50. data/test/lib/my_john_deere_api/request/collection/flags_test.rb +2 -2
  51. data/test/lib/my_john_deere_api/request/collection/organizations_test.rb +2 -2
  52. data/test/lib/my_john_deere_api/request/create/asset_location_test.rb +3 -2
  53. data/test/lib/my_john_deere_api/request/create/asset_test.rb +5 -4
  54. data/test/lib/my_john_deere_api/request/create/base_test.rb +0 -14
  55. data/test/lib/my_john_deere_api/request/individual/asset_test.rb +2 -3
  56. data/test/lib/my_john_deere_api/request/individual/base_test.rb +0 -1
  57. data/test/lib/my_john_deere_api/request/individual/contribution_definition_test.rb +2 -3
  58. data/test/lib/my_john_deere_api/request/individual/contribution_product_test.rb +2 -3
  59. data/test/lib/my_john_deere_api/request/individual/field_test.rb +2 -3
  60. data/test/lib/my_john_deere_api/request/individual/organization_test.rb +2 -3
  61. data/test/lib/my_john_deere_api/request/update/asset_test.rb +5 -17
  62. data/test/lib/my_john_deere_api/request/update/base_test.rb +0 -14
  63. data/test/support/helper.rb +14 -5
  64. data/test/support/link_helpers.rb +14 -0
  65. data/test/support/response_helpers.rb +18 -0
  66. data/test/support/vcr/catalog.yml +44 -37
  67. data/test/support/vcr/get_access_token.yml +90 -17
  68. data/test/support/vcr/get_refresh_token.yml +159 -0
  69. data/test/support/vcr/get_request_url.yml +51 -0
  70. data/test/support/vcr_setup.rb +80 -19
  71. metadata +11 -8
  72. data/test/support/vcr/get_request_token.yml +0 -83
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ba69ef151796a688f69eba0f6a396d277baa893aa9e589922f2f2eac7b4e3a6a
4
- data.tar.gz: e0539d88ae84a348113be06e9f81299b64a5c74598428e9d2a33ec00b0b49584
3
+ metadata.gz: b36f05ba98236dbdddfcab7086fa13c6af8d673dc8c2d2c1f185abcbf0f1ceeb
4
+ data.tar.gz: d5bda4fae253a293fd58b44045eacf2b7eba8dd142560aa3813da3deb334bc7c
5
5
  SHA512:
6
- metadata.gz: 9a40c8720b6e44da21bb5c2d0247055cf8b4f7d04e5b6d9e5d6db03778af368e64b5d2ba375aa02aa671aebdbf3d64d87c8090beb958bb7dc83ef5b1da52196e
7
- data.tar.gz: 77bac482b0518e59b534d1916b9155c2a59e75308cc71e93354c9a3d071fa10c35362ee836f3eb456b15f612b73d503f06c735d7af942d220415a39c6943c606
6
+ metadata.gz: 454ac16787a9448ce1fb0d5db1bc4f2b39b9c0c729552fe9c0730093d681dc442fc33b21cdbb8aea79678a1a2e1fd1bdab2ff042b931251818435cbcbd7a54e9
7
+ data.tar.gz: b90f4aa488daa2e0c99c31ede5045769f6d7cd04fce875d92375b369318845737fc5dbf9d9e4824b048980261e6ccc1880e8b4803a01cd9f0be4299d826cd491
@@ -1,4 +1,4 @@
1
- require 'oauth'
1
+ require 'oauth2'
2
2
  require 'uri'
3
3
  require 'json'
4
4
 
@@ -2,10 +2,7 @@ module MyJohnDeereApi
2
2
  class Authorize
3
3
  include Helpers::EnvironmentHelper
4
4
 
5
- attr_reader :api_key, :api_secret,
6
- :request_token, :request_secret,
7
- :access_token, :access_secret,
8
- :environment, :options
5
+ attr_reader :api_key, :api_secret, :environment, :options, :token_hash
9
6
 
10
7
  DEFAULTS = {
11
8
  environment: :live
@@ -23,6 +20,9 @@ module MyJohnDeereApi
23
20
  @api_key = api_key
24
21
  @api_secret = api_secret
25
22
  self.environment = @options[:environment]
23
+
24
+ # This is only set upon verification
25
+ @token_hash = nil
26
26
  end
27
27
 
28
28
  ##
@@ -32,38 +32,47 @@ module MyJohnDeereApi
32
32
  def authorize_url
33
33
  return @authorize_url if defined?(@authorize_url)
34
34
 
35
- request_options = options.slice(:oauth_callback)
35
+ request_options = options.slice(:redirect_uri, :state, :scope)
36
+
37
+ if options.key?(:scopes)
38
+ options[:scopes] << 'offline_access' unless options[:scopes].include?('offline_access')
39
+ request_options[:scope] = options[:scopes].join(' ')
40
+ end
36
41
 
37
- requester = consumer.get_request_token(request_options)
38
- @request_token = requester.token
39
- @request_secret = requester.secret
42
+ # generate a default unique-ish "state" key if not provided
43
+ unless request_options.key?(:state)
44
+ request_options[:state] = (rand(8000) + 1000).to_s
45
+ end
40
46
 
41
- @authorize_url = requester.authorize_url(request_options)
47
+ @authorize_url = oauth_client.auth_code.authorize_url(request_options)
42
48
  end
43
49
 
44
50
  ##
45
- # API consumer that makes non-user-specific GET requests
51
+ # API client that makes authentication requests
46
52
 
47
- def consumer
48
- return @consumer if defined?(@consumer)
49
- @consumer = MyJohnDeereApi::Consumer.new(@api_key, @api_secret, environment: environment).app_get
53
+ def oauth_client
54
+ return @oauth_client if defined?(@oauth_client)
55
+ @oauth_client = MyJohnDeereApi::Consumer.new(@api_key, @api_secret, environment: environment).auth_client
50
56
  end
51
57
 
52
58
  ##
53
- # Turn a verification code into access tokens. If this is
54
- # run from a separate process than the one that created
55
- # the initial RequestToken, the request token/secret
56
- # can be passed in.
57
-
58
- def verify(code, token=nil, secret=nil)
59
- token ||= request_token
60
- secret ||= request_secret
61
-
62
- requester = OAuth::RequestToken.new(consumer, token, secret)
63
- access_object = requester.get_access_token(oauth_verifier: code)
64
- @access_token = access_object.token
65
- @access_secret = access_object.secret
66
- nil
59
+ # Turn a verification code into access token.
60
+
61
+ def verify(code)
62
+ token = oauth_client.auth_code.get_token(code, redirect_uri: options[:redirect_uri])
63
+
64
+ # normalize hash
65
+ @token_hash = JSON.parse(token.to_hash.to_json)
66
+ end
67
+
68
+ ##
69
+ # Use an old token hash to generate a new token hash.
70
+
71
+ def refresh_from_hash(old_token_hash)
72
+ old_token = OAuth2::AccessToken.from_hash(oauth_client, old_token_hash)
73
+ new_token = old_token.refresh!
74
+
75
+ new_token.to_hash
67
76
  end
68
77
  end
69
78
  end
@@ -4,7 +4,7 @@ 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, :http_retry_options
7
+ attr_reader :api_key, :api_secret, :token_hash, :http_retry_options
8
8
 
9
9
  DEFAULTS = {
10
10
  environment: :live,
@@ -13,18 +13,17 @@ module MyJohnDeereApi
13
13
 
14
14
  ##
15
15
  # Creates the client with everything it needs to perform API requests.
16
- # User-specific credentials are optional, but user-specific API
17
- # requests are only possible if they are supplied.
16
+ # User-specific token_hash is optional, but user-specific API
17
+ # requests are only possible if it is supplied.
18
18
  #
19
19
  # options:
20
20
  #
21
21
  # [:environment] :sandbox or :live
22
22
  #
23
23
  # [:contribution_definition_id] optional, but needed for some requests
24
- # like asset create/update.
24
+ # like asset create/update
25
25
  #
26
- # [:access] an array with two elements, the access_token
27
- # and the access_secret of the given user
26
+ # [:token_hash] a hash used to re-create the access token
28
27
 
29
28
  def initialize(api_key, api_secret, options = {})
30
29
  options = DEFAULTS.merge(options)
@@ -32,8 +31,8 @@ module MyJohnDeereApi
32
31
  @api_key = api_key
33
32
  @api_secret = api_secret
34
33
 
35
- if options.has_key?(:access) && options[:access].is_a?(Array)
36
- @access_token, @access_secret = options[:access]
34
+ if options.has_key?(:token_hash) && options[:token_hash].is_a?(Hash)
35
+ @token_hash = options[:token_hash]
37
36
  end
38
37
 
39
38
  self.environment = options[:environment]
@@ -49,22 +48,38 @@ module MyJohnDeereApi
49
48
  return @accessor if defined?(@accessor)
50
49
 
51
50
  @accessor = NetHttpRetry::Decorator.new(
52
- OAuth::AccessToken.new(
53
- consumer.user_get,
54
- access_token,
55
- access_secret
56
- ),
51
+ OAuth2::AccessToken.from_hash(oauth_client, token_hash),
57
52
  http_retry_options
58
53
  )
59
54
  end
60
55
 
56
+ ##
57
+ # Returns the URI for the Contribution Definiton ID, if provided
58
+
59
+ def contribution_definition_uri
60
+ return @contribution_definition_uri if defined?(@contribution_definition_uri)
61
+
62
+ @contribution_definition_uri =
63
+ if contribution_definition_id
64
+ "#{site}/contributionDefinitions/#{contribution_definition_id}"
65
+ else
66
+ nil
67
+ end
68
+ end
69
+
70
+ ##
71
+ # Returns the base url for requests
72
+
73
+ def site
74
+ return @site if defined?(@site)
75
+ @site = accessor.client.site
76
+ end
77
+
61
78
  ##
62
79
  # generic user-specific GET request method that returns JSON
63
80
 
64
81
  def get resource
65
- resource = resource.to_s
66
- resource = "/#{resource}" unless resource =~ /^\//
67
- response = accessor.get(resource, headers)
82
+ response = accessor.get(resource, headers: headers)
68
83
 
69
84
  JSON.parse(response.body)
70
85
  end
@@ -73,9 +88,7 @@ module MyJohnDeereApi
73
88
  # generic user-specific POST request method that returns JSON or response
74
89
 
75
90
  def post resource, body
76
- resource = resource.to_s
77
- resource = "/#{resource}" unless resource =~ /^\//
78
- response = accessor.post(resource, camelize(body).to_json, post_headers)
91
+ response = accessor.post(resource, body: camelize(body).to_json, headers: post_headers)
79
92
 
80
93
  if response.body && response.body.size > 0
81
94
  JSON.parse(response.body)
@@ -88,9 +101,7 @@ module MyJohnDeereApi
88
101
  # generic user-specific PUT request method that returns JSON or response
89
102
 
90
103
  def put resource, body
91
- resource = resource.to_s
92
- resource = "/#{resource}" unless resource =~ /^\//
93
- response = accessor.put(resource, camelize(body).to_json, post_headers)
104
+ response = accessor.put(resource, body: camelize(body).to_json, headers: post_headers)
94
105
 
95
106
  if response.body && response.body.size > 0
96
107
  JSON.parse(response.body)
@@ -103,9 +114,7 @@ module MyJohnDeereApi
103
114
  # generic user-specific DELETE request method that returns JSON or response
104
115
 
105
116
  def delete resource
106
- resource = resource.to_s
107
- resource = "/#{resource}" unless resource =~ /^\//
108
- response = accessor.delete(resource, headers)
117
+ response = accessor.delete(resource, headers: headers)
109
118
 
110
119
  if response.body && response.body.size > 0
111
120
  JSON.parse(response.body)
@@ -133,11 +142,11 @@ module MyJohnDeereApi
133
142
  private
134
143
 
135
144
  ##
136
- # Returns an oAuth consumer which can be used to build requests
145
+ # Returns an oAuth client which can be used to build requests
137
146
 
138
- def consumer
139
- return @consumer if defined?(@consumer)
140
- @consumer = MyJohnDeereApi::Consumer.new(@api_key, @api_secret, environment: environment)
147
+ def oauth_client
148
+ return @oauth_client if defined?(@oauth_client)
149
+ @oauth_client = MyJohnDeereApi::Consumer.new(@api_key, @api_secret, environment: environment).platform_client
141
150
  end
142
151
 
143
152
  def headers
@@ -3,12 +3,12 @@ module MyJohnDeereApi
3
3
  include Helpers::CaseConversion
4
4
  include Helpers::EnvironmentHelper
5
5
 
6
- attr_reader :api_key, :api_secret, :environment, :base_url
6
+ attr_reader :api_key, :api_secret, :environment, :site
7
7
 
8
8
  # valid API urls
9
9
  URLS = {
10
10
  sandbox: 'https://sandboxapi.deere.com',
11
- live: 'https://api.soa-proxy.deere.com',
11
+ live: 'https://partnerapi.deere.com',
12
12
  }
13
13
 
14
14
  DEFAULTS = {
@@ -22,65 +22,76 @@ module MyJohnDeereApi
22
22
  @api_secret = api_secret
23
23
 
24
24
  self.environment = options[:environment]
25
- @base_url = options[:base_url] || URLS[@environment]
25
+ @site = options[:site] || URLS[@environment]
26
26
  end
27
27
 
28
28
  ##
29
- # oAuth Consumer which uses just the base url, for
30
- # app-wide, non user-specific GET requests.
29
+ # oAuth client for platform requests
31
30
 
32
- def app_get
33
- @app_get ||= consumer(base_url)
31
+ def platform_client
32
+ return @platform_client if defined?(@platform_client)
33
+
34
+ @platform_client = OAuth2::Client.new(
35
+ api_key,
36
+ api_secret,
37
+ site: site,
38
+ headers: headers,
39
+ raise_errors: false,
40
+ )
34
41
  end
35
42
 
36
43
  ##
37
- # oAuth Consumer which uses the proper url for user-specific GET requests.
44
+ # oAuth client for user authentication
38
45
 
39
- def user_get
40
- @user_get ||= consumer("#{base_url}/platform")
41
- end
46
+ def auth_client
47
+ return @auth_client if defined?(@auth_client)
42
48
 
43
- private
49
+ # We build this without the `client` method because the authorization links
50
+ # require an extra API call to JD that is only needed for authorization.
44
51
 
45
- def consumer(site)
46
- OAuth::Consumer.new(
52
+ @auth_client = OAuth2::Client.new(
47
53
  api_key,
48
54
  api_secret,
49
55
  site: site,
50
- header: header,
51
- http_method: :get,
52
- request_token_url: links[:request_token],
53
- access_token_url: links[:access_token],
54
- authorize_url: links[:authorize_request_token]
56
+ authorize_url: authorization_links[:authorization],
57
+ token_url: authorization_links[:token],
58
+ raise_errors: false,
55
59
  )
56
60
  end
57
61
 
58
- def links
59
- return @links if defined?(@links)
62
+ private
63
+
64
+ def authorization
65
+ return @authorization if defined?(@authorization)
60
66
 
61
- catalog = OAuth::Consumer.new(api_key, api_secret)
67
+ json = OAuth2::Client.new(api_key, api_secret)
62
68
  .request(
63
69
  :get,
64
- "#{base_url}/platform/",
65
- nil,
66
- {},
67
- header
70
+ 'https://signin.johndeere.com/oauth2/aus78tnlaysMraFhC1t7/.well-known/oauth-authorization-server',
71
+ headers: headers,
72
+ raise_errors: false,
68
73
  ).body
69
74
 
70
- @links = {}
75
+ @authorization = JSON.parse(json)
76
+ end
71
77
 
72
- JSON.parse(catalog)['links'].each do |link|
73
- uri = URI.parse(link['uri'])
74
- uri.query = nil
78
+ def authorization_links
79
+ return @authorization_links if defined?(@authorization_links)
75
80
 
76
- @links[keyify(link['rel'])] = uri.to_s
77
- end
81
+ @authorization_links = {
82
+ authorization: authorization['authorization_endpoint'],
83
+ token: authorization['token_endpoint'],
84
+ organizations: "https://connections.deere.com/connections/#{api_key}/select-organizations",
85
+ }
86
+ end
78
87
 
79
- @links
88
+ def scopes
89
+ return @scopes if defined?(@scopes)
90
+ @scopes = authorization['scopes_supported']
80
91
  end
81
92
 
82
- def header
83
- @header ||= {accept: 'application/vnd.deere.axiom.v3+json'}
93
+ def headers
94
+ @headers ||= {accept: 'application/vnd.deere.axiom.v3+json'}
84
95
  end
85
96
 
86
97
  def keyify key_name
@@ -22,7 +22,8 @@ module MyJohnDeereApi::Helpers::CaseConversion
22
22
  def camelize(something)
23
23
  something = something.to_s if something.is_a?(Symbol)
24
24
 
25
- if something.is_a?(String)
25
+ case something
26
+ when String
26
27
  list = something.strip.split(/[_\s]+/)
27
28
 
28
29
  # preserve case of the first element
@@ -30,8 +31,10 @@ module MyJohnDeereApi::Helpers::CaseConversion
30
31
  new_list += list.map(&:capitalize)
31
32
 
32
33
  new_list.join('')
33
- elsif something.is_a?(Hash)
34
+ when Hash
34
35
  something.transform_keys{ |key| camelize(key) }
36
+ when Array
37
+ something.map{|element| camelize(element)}
35
38
  end
36
39
  end
37
40
  end
@@ -6,7 +6,7 @@ module MyJohnDeereApi::Helpers::UriHelpers
6
6
  ##
7
7
  # extract just the path from the uri, excluding the platform prefix
8
8
  def uri_path(uri)
9
- URI.parse(uri).path.gsub(/^\/platform/, '')
9
+ URI.parse(uri).path
10
10
  end
11
11
 
12
12
  ##
@@ -5,7 +5,7 @@ module MyJohnDeereApi
5
5
  private
6
6
 
7
7
  def map_attributes(record)
8
- @name = record['name']
8
+ @name = record['name']
9
9
  end
10
10
 
11
11
  def expected_record_type
@@ -7,41 +7,40 @@ module MyJohnDeereApi
7
7
  request_methods: [:get, :post, :put, :delete],
8
8
  retry_delay_exponent: 2,
9
9
  max_retries: 12,
10
- retry_codes: ['429', '503'],
11
- valid_codes: ['200', '201', '204'],
10
+ retry_codes: [429, 503],
11
+ valid_codes: [200, 201, 204],
12
12
  }
13
13
 
14
14
  def initialize(object, options={})
15
15
  @object = object
16
16
 
17
- # defaults that can be used as-is
18
- [:request_methods, :retry_delay_exponent, :max_retries].each do |option|
17
+ # options that can be used as-is
18
+ [:request_methods, :retry_delay_exponent, :max_retries, :retry_codes, :valid_codes].each do |option|
19
19
  instance_variable_set(:"@#{option}", options[option] || DEFAULTS[option])
20
20
  end
21
21
 
22
- # defaults that require casting as string arrays
22
+ # options that require casting as integer arrays
23
23
  [:retry_codes, :valid_codes].each do |option|
24
- instance_variable_set(:"@#{option}", (options[option] || DEFAULTS[option]).map(&:to_s))
24
+ instance_variable_set(:"@#{option}", (options[option] || DEFAULTS[option]).map(&:to_i))
25
25
  end
26
26
  end
27
27
 
28
28
  def request(method_name, *args)
29
29
  retries = 0
30
30
  result = object.send(method_name, *args)
31
-
32
- while retry_codes.include?(result.code)
31
+ while retry_codes.include?(result.status)
33
32
  if retries >= max_retries
34
- raise MaxRetriesExceededError.new(method_name, "#{result.code} #{result.message}")
33
+ raise MaxRetriesExceededError.new(method_name, "#{result.status} #{result.response.reason_phrase}")
35
34
  end
36
35
 
37
- delay = [result['retry-after'].to_i, retry_delay_exponent ** retries].max
36
+ delay = [result.headers['retry-after'].to_i, retry_delay_exponent ** retries].max
38
37
  sleep(delay)
39
38
 
40
39
  result = object.send(method_name, *args)
41
40
  retries += 1
42
41
  end
43
42
 
44
- unless valid_codes.include?(result.code)
43
+ unless valid_codes.include?(result.status)
45
44
  raise InvalidResponseError.new(result)
46
45
  end
47
46