ibm_cloud_sdk_core 0.1.1

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a4246cbde9bd7f851cb748bec1b534632334e64508202a822810d0e2bb2f5de6
4
+ data.tar.gz: 5f388df8543fd24288f66edb14ce0e11394c3d76aa89de1830442d3b51b78fd2
5
+ SHA512:
6
+ metadata.gz: 57496087e3feb73ff6f6e8714ee22405cd43aaace2493af2c3b8d824b3ee198b3579a43c48f37b771bcbb9cca80b49c489c8659c9eba842673043b60b8c61287
7
+ data.tar.gz: 512e10063200783cf8032a1b1545df28244b7aad50bd1d5ba1653c4f010b082c2f3f03080b0a1c41490410da5687879b9a16c9ad5e7ac8404f549a308f069ee0
data/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # ruby-sdk-core
2
+ This project contains the core functionality used by Ruby SDK's generated by the IBM OpenAPI 3 SDK Generator (openapi-sdkgen).
3
+ Ruby code generated by openapi-sdkgen will depend on the classes contained in this project.
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Module for the IBM Cloud SDK Core
4
+ module IBMCloudSdkCore
5
+ require_relative("./ibm_cloud_sdk_core/base_service.rb")
6
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require("json")
4
+
5
+ module IBMCloudSdkCore
6
+ # Custom exception class for errors returned from the APIs
7
+ class ApiException < StandardError
8
+ attr_reader :code, :error, :info, :transaction_id, :global_transaction_id
9
+ # :param HTTP::Response response: The response object from the API
10
+ def initialize(code: nil, error: nil, info: nil, transaction_id: nil, global_transaction_id: nil, response: nil)
11
+ if code.nil? || error.nil?
12
+ @code = response.code
13
+ @error = response.reason
14
+ unless response.body.empty?
15
+ body_hash = JSON.parse(response.body.to_s)
16
+ error_message = body_hash["errors"] && body_hash["errors"][0] ? body_hash["errors"][0].message : nil
17
+ @code = body_hash["code"] || body_hash["error_code"] || body_hash["status"]
18
+ @error = error_message || body_hash["error"] || body_hash["message"]
19
+ %w[code error_code status errors error message].each { |k| body_hash.delete(k) }
20
+ @info = body_hash
21
+ end
22
+ else
23
+ # :nocov:
24
+ @code = code
25
+ @error = error
26
+ @info = info
27
+ # :nocov:
28
+ end
29
+ @transaction_id = transaction_id
30
+ @global_transaction_id = global_transaction_id
31
+ end
32
+
33
+ def to_s
34
+ msg = "Error: #{@error}, Code: #{@code}"
35
+ msg += ", Information: #{@info}" unless @info.nil?
36
+ msg += ", X-dp-watson-tran-id: #{@transaction_id}" unless @transaction_id.nil?
37
+ msg += ", X-global-transaction-id: #{@global_transaction_id}" unless @global_transaction_id.nil?
38
+ msg
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ require("http")
4
+ require("rbconfig")
5
+ require("stringio")
6
+ require("json")
7
+ require_relative("./detailed_response.rb")
8
+ require_relative("./api_exception.rb")
9
+ require_relative("./iam_token_manager.rb")
10
+ require_relative("./version.rb")
11
+
12
+ DEFAULT_CREDENTIALS_FILE_NAME = "ibm-credentials.env"
13
+ NORMALIZER = lambda do |uri| # Custom URI normalizer when using HTTP Client
14
+ HTTP::URI.parse uri
15
+ end
16
+
17
+ module IBMCloudSdkCore
18
+ # Class for interacting with the API
19
+ class BaseService
20
+ attr_accessor :password, :url, :username, :display_name
21
+ attr_reader :conn, :token_manager
22
+ def initialize(vars)
23
+ defaults = {
24
+ vcap_services_name: nil,
25
+ username: nil,
26
+ password: nil,
27
+ use_vcap_services: true,
28
+ iam_apikey: nil,
29
+ iam_access_token: nil,
30
+ iam_url: nil,
31
+ display_name: nil
32
+ }
33
+ vars = defaults.merge(vars)
34
+ @url = vars[:url]
35
+ @username = nil
36
+ @password = nil
37
+ @iam_apikey = nil
38
+ @token_manager = nil
39
+ @temp_headers = nil
40
+ @icp_prefix = vars[:password]&.start_with?("icp-") || vars[:iam_apikey]&.start_with?("icp-") ? true : false
41
+ @disable_ssl_verification = false
42
+ @display_name = vars[:display_name]
43
+
44
+ if (!vars[:iam_access_token].nil? || !vars[:iam_apikey].nil?) && !@icp_prefix
45
+ set_token_manager(iam_apikey: vars[:iam_apikey], iam_access_token: vars[:iam_access_token], iam_url: vars[:iam_url])
46
+ elsif !vars[:iam_apikey].nil? && @icp_prefix
47
+ @username = "apikey"
48
+ @password = vars[:iam_apikey]
49
+ elsif !vars[:username].nil? && !vars[:password].nil?
50
+ if vars[:username] == "apikey" && !@icp_prefix
51
+ iam_apikey(iam_apikey: vars[:password])
52
+ else
53
+ @username = vars[:username]
54
+ @password = vars[:password]
55
+ end
56
+ end
57
+
58
+ if @display_name && !@username && !@iam_apikey
59
+ service_name = @display_name.sub(" ", "_").downcase
60
+ load_from_credential_file(service_name)
61
+ @icp_prefix = @password&.start_with?("icp-") || @iam_apikey&.start_with?("icp-") ? true : false
62
+ end
63
+
64
+ if vars[:use_vcap_services] && !@username && !@iam_apikey
65
+ @vcap_service_credentials = load_from_vcap_services(service_name: vars[:vcap_services_name])
66
+ if !@vcap_service_credentials.nil? && @vcap_service_credentials.instance_of?(Hash)
67
+ @url = @vcap_service_credentials["url"]
68
+ @username = @vcap_service_credentials["username"] if @vcap_service_credentials.key?("username")
69
+ @password = @vcap_service_credentials["password"] if @vcap_service_credentials.key?("password")
70
+ @iam_apikey = @vcap_service_credentials["iam_apikey"] if @vcap_service_credentials.key?("iam_apikey")
71
+ @iam_access_token = @vcap_service_credentials["iam_access_token"] if @vcap_service_credentials.key?("iam_access_token")
72
+ @iam_url = @vcap_service_credentials["iam_url"] if @vcap_service_credentials.key?("iam_url")
73
+ @icp_prefix = @password&.start_with?("icp-") || @iam_apikey&.start_with?("icp-") ? true : false
74
+ end
75
+ end
76
+
77
+ raise ArgumentError.new('The username shouldn\'t start or end with curly brackets or quotes. Be sure to remove any {} and \" characters surrounding your username') if check_bad_first_or_last_char(@username)
78
+ raise ArgumentError.new('The password shouldn\'t start or end with curly brackets or quotes. Be sure to remove any {} and \" characters surrounding your password') if check_bad_first_or_last_char(@password)
79
+ raise ArgumentError.new('The url shouldn\'t start or end with curly brackets or quotes. Be sure to remove any {} and \" characters surrounding your url') if check_bad_first_or_last_char(@url)
80
+ raise ArgumentError.new('The apikey shouldn\'t start or end with curly brackets or quotes. Be sure to remove any {} and \" characters surrounding your apikey') if check_bad_first_or_last_char(@iam_apikey)
81
+
82
+ @conn = HTTP::Client.new(
83
+ headers: {}
84
+ ).use normalize_uri: { normalizer: NORMALIZER }
85
+ end
86
+
87
+ # Initiates the credentials based on the credential file
88
+ def load_from_credential_file(service_name, separator = "=")
89
+ credential_file_path = ENV["IBM_CREDENTIALS_FILE"]
90
+
91
+ # Home directory
92
+ if credential_file_path.nil?
93
+ file_path = ENV["HOME"] + DEFAULT_CREDENTIALS_FILE_NAME
94
+ credential_file_path = file_path if File.exist?(file_path)
95
+ end
96
+
97
+ # Top-level directory of the project
98
+ if credential_file_path.nil?
99
+ file_path = File.join(File.dirname(__FILE__), "/../../" + DEFAULT_CREDENTIALS_FILE_NAME)
100
+ credential_file_path = file_path if File.exist?(file_path)
101
+ end
102
+
103
+ return if credential_file_path.nil?
104
+
105
+ file_contents = File.open(credential_file_path, "r")
106
+ file_contents.each_line do |line|
107
+ key_val = line.strip.split(separator)
108
+ set_credential_based_on_type(service_name, key_val[0].downcase, key_val[1]) if key_val.length == 2
109
+ end
110
+ end
111
+
112
+ def load_from_vcap_services(service_name:)
113
+ vcap_services = ENV["VCAP_SERVICES"]
114
+ unless vcap_services.nil?
115
+ services = JSON.parse(vcap_services)
116
+ return services[service_name][0]["credentials"] if services.key?(service_name)
117
+ end
118
+ nil
119
+ end
120
+
121
+ def add_default_headers(headers: {})
122
+ raise TypeError unless headers.instance_of?(Hash)
123
+
124
+ headers.each_pair { |k, v| @conn.default_options.headers.add(k, v) }
125
+ end
126
+
127
+ def iam_access_token(iam_access_token:)
128
+ @token_manager = IAMTokenManager.new(iam_access_token: iam_access_token) if @token_manager.nil?
129
+ @iam_access_token = iam_access_token
130
+ end
131
+
132
+ def iam_apikey(iam_apikey:)
133
+ @token_manager = IAMTokenManager.new(iam_apikey: iam_apikey) if @token_manager.nil?
134
+ @iam_apikey = iam_apikey
135
+ end
136
+
137
+ # @return [DetailedResponse]
138
+ def request(args)
139
+ defaults = { method: nil, url: nil, accept_json: false, headers: nil, params: nil, json: {}, data: nil }
140
+ args = defaults.merge(args)
141
+ args[:data].delete_if { |_k, v| v.nil? } if args[:data].instance_of?(Hash)
142
+ args[:json] = args[:data].merge(args[:json]) if args[:data].respond_to?(:merge)
143
+ args[:json] = args[:data] if args[:json].empty? || (args[:data].instance_of?(String) && !args[:data].empty?)
144
+ args[:json].delete_if { |_k, v| v.nil? } if args[:json].instance_of?(Hash)
145
+ args[:headers]["Accept"] = "application/json" if args[:accept_json] && args[:headers]["Accept"].nil?
146
+ args[:headers]["Content-Type"] = "application/json" unless args[:headers].key?("Content-Type")
147
+ args[:json] = args[:json].to_json if args[:json].instance_of?(Hash)
148
+ args[:headers].delete_if { |_k, v| v.nil? } if args[:headers].instance_of?(Hash)
149
+ args[:params].delete_if { |_k, v| v.nil? } if args[:params].instance_of?(Hash)
150
+ args[:form].delete_if { |_k, v| v.nil? } if args.key?(:form)
151
+ args.delete_if { |_, v| v.nil? }
152
+ args[:headers].delete("Content-Type") if args.key?(:form) || args[:json].nil?
153
+
154
+ if @username == "apikey" && !@icp_prefix
155
+ iam_apikey(iam_apikey: @password)
156
+ @username = nil
157
+ end
158
+
159
+ conn = @conn
160
+ if !@iam_apikey.nil? && @icp_prefix
161
+ conn = @conn.basic_auth(user: "apikey", pass: @iam_apikey)
162
+ elsif !@token_manager.nil?
163
+ access_token = @token_manager.token
164
+ args[:headers]["Authorization"] = "Bearer #{access_token}"
165
+ elsif !@username.nil? && !@password.nil?
166
+ conn = @conn.basic_auth(user: @username, pass: @password)
167
+ end
168
+
169
+ args[:headers] = args[:headers].merge(@temp_headers) unless @temp_headers.nil?
170
+ @temp_headers = nil unless @temp_headers.nil?
171
+
172
+ if args.key?(:form)
173
+ response = conn.follow.request(
174
+ args[:method],
175
+ HTTP::URI.parse(@url + args[:url]),
176
+ headers: conn.default_options.headers.merge(HTTP::Headers.coerce(args[:headers])),
177
+ params: args[:params],
178
+ form: args[:form]
179
+ )
180
+ else
181
+ response = conn.follow.request(
182
+ args[:method],
183
+ HTTP::URI.parse(@url + args[:url]),
184
+ headers: conn.default_options.headers.merge(HTTP::Headers.coerce(args[:headers])),
185
+ body: args[:json],
186
+ params: args[:params]
187
+ )
188
+ end
189
+ return DetailedResponse.new(response: response) if (200..299).cover?(response.code)
190
+
191
+ raise ApiException.new(response: response)
192
+ end
193
+
194
+ # @note Chainable
195
+ # @param headers [Hash] Custom headers to be sent with the request
196
+ # @return [self]
197
+ def headers(headers)
198
+ raise TypeError("Expected Hash type, received #{headers.class}") unless headers.instance_of?(Hash)
199
+
200
+ @temp_headers = headers
201
+ self
202
+ end
203
+
204
+ # @!method configure_http_client(proxy: {}, timeout: {}, disable_ssl_verification: false)
205
+ # Sets the http client config, currently works with timeout and proxies
206
+ # @param proxy [Hash] The hash of proxy configurations
207
+ # @option proxy address [String] The address of the proxy
208
+ # @option proxy port [Integer] The port of the proxy
209
+ # @option proxy username [String] The username of the proxy, if authentication is needed
210
+ # @option proxy password [String] The password of the proxy, if authentication is needed
211
+ # @option proxy headers [Hash] The headers to be used with the proxy
212
+ # @param timeout [Hash] The hash for configuring timeouts. `per_operation` has priority over `global`
213
+ # @option timeout per_operation [Hash] Timeouts per operation. Requires `read`, `write`, `connect`
214
+ # @option timeout global [Integer] Upper bound on total request time
215
+ # @param disable_ssl_verification [Boolean] Disable the SSL verification (Note that this has serious security implications - only do this if you really mean to!)
216
+ def configure_http_client(proxy: {}, timeout: {}, disable_ssl_verification: false)
217
+ raise TypeError("proxy parameter must be a Hash") unless proxy.empty? || proxy.instance_of?(Hash)
218
+
219
+ raise TypeError("timeout parameter must be a Hash") unless timeout.empty? || timeout.instance_of?(Hash)
220
+
221
+ @disable_ssl_verification = disable_ssl_verification
222
+ if disable_ssl_verification
223
+ ssl_context = OpenSSL::SSL::SSLContext.new
224
+ ssl_context.verify_mode = OpenSSL::SSL::VERIFY_NONE
225
+ @conn.default_options = { ssl_context: ssl_context }
226
+ end
227
+ add_proxy(proxy) unless proxy.empty? || !proxy.dig(:address).is_a?(String) || !proxy.dig(:port).is_a?(Integer)
228
+ add_timeout(timeout) unless timeout.empty? || (!timeout.key?(:per_operation) && !timeout.key?(:global))
229
+ end
230
+
231
+ private
232
+
233
+ def set_credential_based_on_type(service_name, key, value)
234
+ return unless key.include?(service_name)
235
+
236
+ @iam_apikey = value if key.include?("iam_apikey")
237
+ @iam_url = value if key.include?("iam_url")
238
+ @url = value if key.include?("url")
239
+ @username = value if key.include?("username")
240
+ @password = value if key.include?("password")
241
+ end
242
+
243
+ def check_bad_first_or_last_char(str)
244
+ return str.start_with?("{", "\"") || str.end_with?("}", "\"") unless str.nil?
245
+ end
246
+
247
+ def set_token_manager(iam_apikey: nil, iam_access_token: nil, iam_url: nil)
248
+ @iam_apikey = iam_apikey
249
+ @iam_access_token = iam_access_token
250
+ @iam_url = iam_url
251
+ @token_manager = IAMTokenManager.new(iam_apikey: iam_apikey, iam_access_token: iam_access_token, iam_url: iam_url)
252
+ end
253
+
254
+ def add_timeout(timeout)
255
+ if timeout.key?(:per_operation)
256
+ raise TypeError("per_operation in timeout must be a Hash") unless timeout[:per_operation].instance_of?(Hash)
257
+
258
+ defaults = {
259
+ write: 0,
260
+ connect: 0,
261
+ read: 0
262
+ }
263
+ time = defaults.merge(timeout[:per_operation])
264
+ @conn = @conn.timeout(write: time[:write], connect: time[:connect], read: time[:read])
265
+ else
266
+ raise TypeError("global in timeout must be an Integer") unless timeout[:global].is_a?(Integer)
267
+
268
+ @conn = @conn.timeout(timeout[:global])
269
+ end
270
+ end
271
+
272
+ def add_proxy(proxy)
273
+ if (proxy[:username].nil? || proxy[:password].nil?) && proxy[:headers].nil?
274
+ @conn = @conn.via(proxy[:address], proxy[:port])
275
+ elsif !proxy[:username].nil? && !proxy[:password].nil? && proxy[:headers].nil?
276
+ @conn = @conn.via(proxy[:address], proxy[:port], proxy[:username], proxy[:password])
277
+ elsif !proxy[:headers].nil? && (proxy[:username].nil? || proxy[:password].nil?)
278
+ @conn = @conn.via(proxy[:address], proxy[:port], proxy[:headers])
279
+ else
280
+ @conn = @conn.via(proxy[:address], proxy[:port], proxy[:username], proxy[:password], proxy[:headers])
281
+ end
282
+ end
283
+ end
284
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require("json")
4
+
5
+ module IBMCloudSdkCore
6
+ # Custom class for objects returned from API calls
7
+ class DetailedResponse
8
+ attr_reader :status, :headers, :result
9
+ def initialize(status: nil, headers: nil, body: nil, response: nil)
10
+ if status.nil? || headers.nil? || body.nil?
11
+ @status = response.code
12
+ @headers = response.headers.to_h
13
+ @headers = response.headers.to_hash if response.headers.respond_to?("to_hash")
14
+ @result = response.body.to_s
15
+ @result = JSON.parse(response.body.to_s) if !response.body.to_s.empty? && @headers.key?("Content-Type") && @headers["Content-Type"].start_with?("application/json")
16
+ else
17
+ @status = status
18
+ @headers = headers
19
+ @result = body
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require("http")
4
+ require("json")
5
+ require("rbconfig")
6
+ require_relative("./version.rb")
7
+
8
+ module IBMCloudSdkCore
9
+ # Class to manage IAM Token Authentication
10
+ class IAMTokenManager
11
+ DEFAULT_IAM_URL = "https://iam.bluemix.net/identity/token"
12
+ CONTENT_TYPE = "application/x-www-form-urlencoded"
13
+ ACCEPT = "application/json"
14
+ DEFAULT_AUTHORIZATION = "Basic Yng6Yng="
15
+ REQUEST_TOKEN_GRANT_TYPE = "urn:ibm:params:oauth:grant-type:apikey"
16
+ REQUEST_TOKEN_RESPONSE_TYPE = "cloud_iam"
17
+ REFRESH_TOKEN_GRANT_TYPE = "refresh_token"
18
+
19
+ attr_accessor :token_info, :user_access_token
20
+ def initialize(iam_apikey: nil, iam_access_token: nil, iam_url: nil)
21
+ @iam_apikey = iam_apikey
22
+ @user_access_token = iam_access_token
23
+ @iam_url = iam_url.nil? ? DEFAULT_IAM_URL : iam_url
24
+ @token_info = {
25
+ "access_token" => nil,
26
+ "refresh_token" => nil,
27
+ "token_type" => nil,
28
+ "expires_in" => nil,
29
+ "expiration" => nil
30
+ }
31
+ end
32
+
33
+ def request(method:, url:, headers: nil, params: nil, data: nil)
34
+ response = nil
35
+ if headers.key?("Content-Type") && headers["Content-Type"] == CONTENT_TYPE
36
+ response = HTTP.request(
37
+ method,
38
+ url,
39
+ body: HTTP::URI.form_encode(data),
40
+ headers: headers,
41
+ params: params
42
+ )
43
+ end
44
+ return JSON.parse(response.body.to_s) if (200..299).cover?(response.code)
45
+
46
+ require_relative("./api_exception.rb")
47
+ raise ApiException.new(response: response)
48
+ end
49
+
50
+ # The source of the token is determined by the following logic:
51
+ # 1. If user provides their own managed access token, assume it is valid and send it
52
+ # 2. If this class is managing tokens and does not yet have one, make a request for one
53
+ # 3. If this class is managing tokens and the token has expired refresh it. In case the refresh token is expired, get a new one
54
+ # If this class is managing tokens and has a valid token stored, send it
55
+ def token
56
+ return @user_access_token unless @user_access_token.nil? || (@user_access_token.respond_to?(:empty?) && @user_access_token.empty?)
57
+
58
+ if @token_info.all? { |_k, v| v.nil? }
59
+ token_info = request_token
60
+ save_token_info(
61
+ token_info: token_info
62
+ )
63
+ return @token_info["access_token"]
64
+ elsif token_expired?
65
+ token_info = refresh_token_expired? ? request_token : refresh_token
66
+ save_token_info(
67
+ token_info: token_info
68
+ )
69
+ return @token_info["access_token"]
70
+ else
71
+ @token_info["access_token"]
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ # Request an IAM token using an API key
78
+ def request_token
79
+ headers = {
80
+ "Content-Type" => CONTENT_TYPE,
81
+ "Authorization" => DEFAULT_AUTHORIZATION,
82
+ "Accept" => ACCEPT
83
+ }
84
+ data = {
85
+ "grant_type" => REQUEST_TOKEN_GRANT_TYPE,
86
+ "apikey" => @iam_apikey,
87
+ "response_type" => REQUEST_TOKEN_RESPONSE_TYPE
88
+ }
89
+ response = request(
90
+ method: "POST",
91
+ url: @iam_url,
92
+ headers: headers,
93
+ data: data
94
+ )
95
+ response
96
+ end
97
+
98
+ # Refresh an IAM token using a refresh token
99
+ def refresh_token
100
+ headers = {
101
+ "Content-Type" => CONTENT_TYPE,
102
+ "Authorization" => DEFAULT_AUTHORIZATION,
103
+ "accept" => ACCEPT
104
+ }
105
+ data = {
106
+ "grant_type" => REFRESH_TOKEN_GRANT_TYPE,
107
+ "refresh_token" => @token_info["refresh_token"]
108
+ }
109
+ response = request(
110
+ method: "POST",
111
+ url: @iam_url,
112
+ headers: headers,
113
+ data: data
114
+ )
115
+ response
116
+ end
117
+
118
+ # Check if currently stored token is expired.
119
+ # Using a buffer to prevent the edge case of the
120
+ # token expiring before the request could be made.
121
+ # The buffer will be a fraction of the total TTL. Using 80%.
122
+ def token_expired?
123
+ return true if @token_info["expiration"].nil? || @token_info["expires_in"].nil?
124
+
125
+ fraction_of_ttl = 0.8
126
+ time_to_live = @token_info["expires_in"].nil? ? 0 : @token_info["expires_in"]
127
+ expire_time = @token_info["expiration"].nil? ? 0 : @token_info["expiration"]
128
+ refresh_time = expire_time - (time_to_live * (1.0 - fraction_of_ttl))
129
+ current_time = Time.now.to_i
130
+ refresh_time < current_time
131
+ end
132
+
133
+ # Used as a fail-safe to prevent the condition of a refresh token expiring,
134
+ # which could happen after around 30 days. This function will return true
135
+ # if it has been at least 7 days and 1 hour since the last token was set
136
+ def refresh_token_expired?
137
+ return true if @token_info["expiration"].nil?
138
+
139
+ seven_days = 7 * 24 * 3600
140
+ current_time = Time.now.to_i
141
+ new_token_time = @token_info["expiration"] + seven_days
142
+ new_token_time < current_time
143
+ end
144
+
145
+ # Save the response from the IAM service request to the object's state
146
+ def save_token_info(token_info:)
147
+ @token_info = token_info
148
+ end
149
+ end
150
+ end