helio-ruby 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,25 @@
1
+ module Helio
2
+ class CustomerList < APIResource
3
+ extend Helio::APIOperations::Create
4
+ include Helio::APIOperations::Delete
5
+ include Helio::APIOperations::Save
6
+ extend Helio::APIOperations::List
7
+ extend Helio::APIOperations::NestedResource
8
+
9
+ OBJECT_NAME = "customer_list".freeze
10
+
11
+ save_nested_resource :participant
12
+ nested_resource_class_methods :participant,
13
+ operations: %i[create retrieve update delete list]
14
+
15
+ # class << self
16
+ # alias detach_source delete_source
17
+ # end
18
+
19
+ def add_participant(params, opts = {})
20
+ opts = @opts.merge(Util.normalize_opts(opts))
21
+ Participant.create(params.merge(participant: id), opts)
22
+ end
23
+
24
+ end
25
+ end
@@ -0,0 +1,109 @@
1
+ module Helio
2
+ # HelioError is the base error from which all other more specific Helio
3
+ # errors derive.
4
+ class HelioError < StandardError
5
+ attr_reader :message
6
+
7
+ # Response contains a HelioResponse object that has some basic information
8
+ # about the response that conveyed the error.
9
+ attr_accessor :response
10
+
11
+ # These fields are now available as part of #response and that usage should
12
+ # be preferred.
13
+ attr_reader :http_body
14
+ attr_reader :http_headers
15
+ attr_reader :http_status
16
+ attr_reader :json_body # equivalent to #data
17
+ attr_reader :request_id
18
+
19
+ # Initializes a HelioError.
20
+ def initialize(message = nil, http_status: nil, http_body: nil, json_body: nil,
21
+ http_headers: nil)
22
+ @message = message
23
+ @http_status = http_status
24
+ @http_body = http_body
25
+ @http_headers = http_headers || {}
26
+ @json_body = json_body
27
+ @request_id = @http_headers[:request_id]
28
+ end
29
+
30
+ def to_s
31
+ status_string = @http_status.nil? ? "" : "(Status #{@http_status}) "
32
+ id_string = @request_id.nil? ? "" : "(Request #{@request_id}) "
33
+ "#{status_string}#{id_string}#{@message}"
34
+ end
35
+ end
36
+
37
+ # AuthenticationError is raised when invalid credentials are used to connect
38
+ # to Helio's servers.
39
+ class AuthenticationError < HelioError
40
+ end
41
+
42
+ # APIConnectionError is raised in the event that the SDK can't connect to
43
+ # Helio's servers. That can be for a variety of different reasons from a
44
+ # downed network to a bad TLS certificate.
45
+ class APIConnectionError < HelioError
46
+ end
47
+
48
+ # APIError is a generic error that may be raised in cases where none of the
49
+ # other named errors cover the problem. It could also be raised in the case
50
+ # that a new error has been introduced in the API, but this version of the
51
+ # Ruby SDK doesn't know how to handle it.
52
+ class APIError < HelioError
53
+ end
54
+
55
+ # ParticipantError is raised when a user enters a card that can't be charged for
56
+ # some reason.
57
+ class ParticipantError < HelioError
58
+ attr_reader :param, :code
59
+
60
+ def initialize(message, param, code, http_status: nil, http_body: nil, json_body: nil,
61
+ http_headers: nil)
62
+ super(message, http_status: http_status, http_body: http_body,
63
+ json_body: json_body, http_headers: http_headers)
64
+ @param = param
65
+ @code = code
66
+ end
67
+ end
68
+
69
+ # IdempotencyError is raised in cases where an idempotency key was used
70
+ # improperly.
71
+ class IdempotencyError < HelioError
72
+ end
73
+
74
+ # InvalidRequestError is raised when a request is initiated with invalid
75
+ # parameters.
76
+ class InvalidRequestError < HelioError
77
+ attr_accessor :param
78
+
79
+ def initialize(message, param, http_status: nil, http_body: nil, json_body: nil,
80
+ http_headers: nil)
81
+ super(message, http_status: http_status, http_body: http_body,
82
+ json_body: json_body, http_headers: http_headers)
83
+ @param = param
84
+ end
85
+ end
86
+
87
+ # PermissionError is raised in cases where access was attempted on a resource
88
+ # that wasn't allowed.
89
+ class PermissionError < HelioError
90
+ end
91
+
92
+ # RateLimitError is raised in cases where an account is putting too much load
93
+ # on Helio's API servers (usually by performing too many requests). Please
94
+ # back off on request rate.
95
+ class RateLimitError < HelioError
96
+ end
97
+
98
+ # SignatureVerificationError is raised when the signature verification for a
99
+ # webhook fails
100
+ class SignatureVerificationError < HelioError
101
+ attr_accessor :sig_header
102
+
103
+ def initialize(message, sig_header, http_body: nil)
104
+ super(message, http_body: http_body)
105
+ @sig_header = sig_header
106
+ end
107
+ end
108
+
109
+ end
@@ -0,0 +1,542 @@
1
+ module Helio
2
+ # HelioClient executes requests against the Helio API and allows a user to
3
+ # recover both a resource a call returns as well as a response object that
4
+ # contains information on the HTTP call.
5
+ class HelioClient
6
+ attr_accessor :conn
7
+
8
+ # Initializes a new HelioClient. Expects a Faraday connection object, and
9
+ # uses a default connection unless one is passed.
10
+ def initialize(conn = nil)
11
+ self.conn = conn || self.class.default_conn
12
+ @system_profiler = SystemProfiler.new
13
+ end
14
+
15
+ def self.active_client
16
+ Thread.current[:helio_client] || default_client
17
+ end
18
+
19
+ def self.default_client
20
+ Thread.current[:helio_client_default_client] ||= HelioClient.new(default_conn)
21
+ end
22
+
23
+ # A default Faraday connection to be used when one isn't configured. This
24
+ # object should never be mutated, and instead instantiating your own
25
+ # connection and wrapping it in a HelioClient object should be preferred.
26
+ def self.default_conn
27
+ # We're going to keep connections around so that we can take advantage
28
+ # of connection re-use, so make sure that we have a separate connection
29
+ # object per thread.
30
+ Thread.current[:helio_client_default_conn] ||= begin
31
+ conn = Faraday.new do |c|
32
+ c.use Faraday::Request::Multipart
33
+ c.use Faraday::Request::UrlEncoded
34
+ c.use Faraday::Response::RaiseError
35
+ c.adapter Faraday.default_adapter
36
+ end
37
+
38
+ if Helio.verify_ssl_certs
39
+ conn.ssl.verify = true
40
+ conn.ssl.cert_store = Helio.ca_store
41
+ else
42
+ conn.ssl.verify = false
43
+
44
+ unless @verify_ssl_warned
45
+ @verify_ssl_warned = true
46
+ $stderr.puts("WARNING: Running without SSL cert verification. " \
47
+ "You should never do this in production. " \
48
+ "Execute 'Helio.verify_ssl_certs = true' to enable verification.")
49
+ end
50
+ end
51
+
52
+ conn
53
+ end
54
+ end
55
+
56
+ # Checks if an error is a problem that we should retry on. This includes both
57
+ # socket errors that may represent an intermittent problem and some special
58
+ # HTTP statuses.
59
+ def self.should_retry?(e, num_retries)
60
+ return false if num_retries >= Helio.max_network_retries
61
+
62
+ # Retry on timeout-related problems (either on open or read).
63
+ return true if e.is_a?(Faraday::TimeoutError)
64
+
65
+ # Destination refused the connection, the connection was reset, or a
66
+ # variety of other connection failures. This could occur from a single
67
+ # saturated server, so retry in case it's intermittent.
68
+ return true if e.is_a?(Faraday::ConnectionFailed)
69
+
70
+ if e.is_a?(Faraday::ClientError) && e.response
71
+ # 409 conflict
72
+ return true if e.response[:status] == 409
73
+ end
74
+
75
+ false
76
+ end
77
+
78
+ def self.sleep_time(num_retries)
79
+ # Apply exponential backoff with initial_network_retry_delay on the
80
+ # number of num_retries so far as inputs. Do not allow the number to exceed
81
+ # max_network_retry_delay.
82
+ sleep_seconds = [Helio.initial_network_retry_delay * (2**(num_retries - 1)), Helio.max_network_retry_delay].min
83
+
84
+ # Apply some jitter by randomizing the value in the range of (sleep_seconds
85
+ # / 2) to (sleep_seconds).
86
+ sleep_seconds *= (0.5 * (1 + rand))
87
+
88
+ # But never sleep less than the base sleep seconds.
89
+ sleep_seconds = [Helio.initial_network_retry_delay, sleep_seconds].max
90
+
91
+ sleep_seconds
92
+ end
93
+
94
+ # Executes the API call within the given block. Usage looks like:
95
+ #
96
+ # client = HelioClient.new
97
+ # charge, resp = client.request { Charge.create }
98
+ #
99
+ def request
100
+ @last_response = nil
101
+ old_helio_client = Thread.current[:helio_client]
102
+ Thread.current[:helio_client] = self
103
+
104
+ begin
105
+ res = yield
106
+ [res, @last_response]
107
+ ensure
108
+ Thread.current[:helio_client] = old_helio_client
109
+ end
110
+ end
111
+
112
+ def execute_request(method, path,
113
+ api_base: nil, api_id: nil, api_token: nil, headers: {}, params: {})
114
+
115
+ api_base ||= Helio.api_base
116
+ api_id ||= Helio.api_id
117
+ api_token ||= Helio.api_token
118
+
119
+ check_api_token!(api_token)
120
+
121
+ params = Util.objects_to_ids(params)
122
+ url = api_url(path, api_base)
123
+
124
+ body = nil
125
+ query_params = nil
126
+
127
+ case method.to_s.downcase.to_sym
128
+ when :get, :head, :delete
129
+ query_params = params
130
+ else
131
+ body = if headers[:content_type] && headers[:content_type] == "multipart/form-data"
132
+ params
133
+ else
134
+ Util.encode_parameters(params)
135
+ end
136
+ end
137
+
138
+ headers = request_headers(api_token, method)
139
+ .update(Util.normalize_headers(headers))
140
+
141
+ # stores information on the request we're about to make so that we don't
142
+ # have to pass as many parameters around for logging.
143
+ context = RequestLogContext.new
144
+ context.api_id = headers["X-API-ID"]
145
+ context.api_token = api_token
146
+ context.api_version = headers["Helio-Version"]
147
+ context.body = body
148
+ context.idempotency_key = headers["Idempotency-Key"]
149
+ context.method = method
150
+ context.path = path
151
+ context.query_params = query_params ? Util.encode_parameters(query_params) : nil
152
+
153
+ http_resp = execute_request_with_rescues(api_base, context) do
154
+ conn.run_request(method, url, body, headers) do |req|
155
+ req.options.open_timeout = Helio.open_timeout
156
+ req.options.timeout = Helio.read_timeout
157
+ req.params = query_params unless query_params.nil?
158
+ end
159
+ end
160
+
161
+ begin
162
+ resp = HelioResponse.from_faraday_response(http_resp)
163
+ rescue JSON::ParserError
164
+ raise general_api_error(http_resp.status, http_resp.body)
165
+ end
166
+
167
+ # Allows HelioClient#request to return a response object to a caller.
168
+ @last_response = resp
169
+ [resp, api_token]
170
+ end
171
+
172
+ private
173
+
174
+ def api_url(url = "", api_base = nil)
175
+ (api_base || Helio.api_base) + url
176
+ end
177
+
178
+ def check_api_token!(api_token)
179
+ unless api_token
180
+ raise AuthenticationError, "No API key provided. " \
181
+ 'Set your API key using "Helio.api_token = <API-TOKEN>". ' \
182
+ "You can generate API keys from the Helio web interface. " \
183
+ "See https://helio.zurb.com for details, or email helio@zurb.com " \
184
+ "if you have any questions."
185
+ end
186
+
187
+ return unless api_token =~ /\s/
188
+
189
+ raise AuthenticationError, "Your API key is invalid, as it contains " \
190
+ "whitespace. (HINT: You can double-check your API key from the " \
191
+ "Helio web interface. See https://helio.zurb.com for details, or " \
192
+ "email helio@zurb.com if you have any questions.)"
193
+ end
194
+
195
+ def execute_request_with_rescues(api_base, context)
196
+ num_retries = 0
197
+ begin
198
+ request_start = Time.now
199
+ log_request(context, num_retries)
200
+ resp = yield
201
+ context = context.dup_from_response(resp)
202
+ log_response(context, request_start, resp.status, resp.body)
203
+
204
+ # We rescue all exceptions from a request so that we have an easy spot to
205
+ # implement our retry logic across the board. We'll re-raise if it's a type
206
+ # of exception that we didn't expect to handle.
207
+ rescue StandardError => e
208
+ # If we modify context we copy it into a new variable so as not to
209
+ # taint the original on a retry.
210
+ error_context = context
211
+
212
+ if e.respond_to?(:response) && e.response
213
+ error_context = context.dup_from_response(e.response)
214
+ log_response(error_context, request_start,
215
+ e.response[:status], e.response[:body])
216
+ else
217
+ log_response_error(error_context, request_start, e)
218
+ end
219
+
220
+ if self.class.should_retry?(e, num_retries)
221
+ num_retries += 1
222
+ sleep self.class.sleep_time(num_retries)
223
+ retry
224
+ end
225
+
226
+ case e
227
+ when Faraday::ClientError
228
+ if e.response
229
+ handle_error_response(e.response, error_context)
230
+ else
231
+ handle_network_error(e, error_context, num_retries, api_base)
232
+ end
233
+
234
+ # Only handle errors when we know we can do so, and re-raise otherwise.
235
+ # This should be pretty infrequent.
236
+ else
237
+ raise
238
+ end
239
+ end
240
+
241
+ resp
242
+ end
243
+
244
+ def general_api_error(status, body)
245
+ APIError.new("Invalid response object from API: #{body.inspect} " \
246
+ "(HTTP response code was #{status})",
247
+ http_status: status, http_body: body)
248
+ end
249
+
250
+ # Formats a plugin "app info" hash into a string that we can tack onto the
251
+ # end of a User-Agent string where it'll be fairly prominant in places like
252
+ # the Dashboard. Note that this formatting has been implemented to match
253
+ # other libraries, and shouldn't be changed without universal consensus.
254
+ def format_app_info(info)
255
+ str = info[:name]
256
+ str = "#{str}/#{info[:version]}" unless info[:version].nil?
257
+ str = "#{str} (#{info[:url]})" unless info[:url].nil?
258
+ str
259
+ end
260
+
261
+ def handle_error_response(http_resp, context)
262
+ begin
263
+ resp = HelioResponse.from_faraday_hash(http_resp)
264
+ error_data = resp.data[:error]
265
+
266
+ raise HelioError, "Indeterminate error" unless error_data
267
+ rescue JSON::ParserError, HelioError
268
+ raise general_api_error(http_resp[:status], http_resp[:body])
269
+ end
270
+
271
+ error = specific_api_error(resp, error_data, context)
272
+
273
+ error.response = resp
274
+ raise(error)
275
+ end
276
+
277
+ def specific_api_error(resp, error_data, context)
278
+ Util.log_error("Helio API error",
279
+ status: resp.http_status,
280
+ error_code: error_data[:code],
281
+ error_message: error_data[:message],
282
+ error_param: error_data[:param],
283
+ error_type: error_data[:type],
284
+ idempotency_key: context.idempotency_key,
285
+ request_id: context.request_id)
286
+
287
+ # The standard set of arguments that can be used to initialize most of
288
+ # the exceptions.
289
+ opts = {
290
+ http_body: resp.http_body,
291
+ http_headers: resp.http_headers,
292
+ http_status: resp.http_status,
293
+ json_body: resp.data,
294
+ }
295
+
296
+ case resp.http_status
297
+ when 400, 404
298
+ case error_data[:type]
299
+ when "idempotency_error"
300
+ IdempotencyError.new(error_data[:message], opts)
301
+ else
302
+ InvalidRequestError.new(
303
+ error_data[:message], error_data[:param],
304
+ opts
305
+ )
306
+ end
307
+ when 401
308
+ AuthenticationError.new(error_data[:message], opts)
309
+ when 402
310
+ ParticipantError.new(
311
+ error_data[:message], error_data[:param], error_data[:code],
312
+ opts
313
+ )
314
+ when 403
315
+ PermissionError.new(error_data[:message], opts)
316
+ when 429
317
+ RateLimitError.new(error_data[:message], opts)
318
+ else
319
+ APIError.new(error_data[:message], opts)
320
+ end
321
+ end
322
+
323
+ def handle_network_error(e, context, num_retries, api_base = nil)
324
+ Util.log_error("Helio network error",
325
+ error_message: e.message,
326
+ idempotency_key: context.idempotency_key,
327
+ request_id: context.request_id)
328
+
329
+ case e
330
+ when Faraday::ConnectionFailed
331
+ message = "Unexpected error communicating when trying to connect to Helio. " \
332
+ "You may be seeing this message because your DNS is not working. " \
333
+ "To check, try running 'host helio.zurb.com' from the command line."
334
+
335
+ when Faraday::SSLError
336
+ message = "Could not establish a secure connection to Helio, you may " \
337
+ "need to upgrade your OpenSSL version. To check, try running " \
338
+ "'openssl s_client -connect api.zurb.com:443' from the " \
339
+ "command line."
340
+
341
+ when Faraday::TimeoutError
342
+ api_base ||= Helio.api_base
343
+ message = "Could not connect to Helio (#{api_base}). " \
344
+ "Please check your internet connection and try again. " \
345
+ "If this problem persists, you should check Helio's service status at " \
346
+ "https://twitter.com/zurb, or let us know at helio@zurb.com."
347
+
348
+ else
349
+ message = "Unexpected error communicating with Helio. " \
350
+ "If this problem persists, let us know at helio@zurb.com."
351
+
352
+ end
353
+
354
+ message += " Request was retried #{num_retries} times." if num_retries > 0
355
+
356
+ raise APIConnectionError, message + "\n\n(Network error: #{e.message})"
357
+ end
358
+
359
+ def request_headers(api_token, method)
360
+ user_agent = "Helio/v1 RubyBindings/#{Helio::VERSION}"
361
+ unless Helio.app_info.nil?
362
+ user_agent += " " + format_app_info(Helio.app_info)
363
+ end
364
+
365
+ headers = {
366
+ "User-Agent" => user_agent,
367
+ "Authorization" => "Bearer #{api_token}",
368
+ "Content-Type" => "application/x-www-form-urlencoded",
369
+ }
370
+
371
+ # It is only safe to retry network failures on post and delete
372
+ # requests if we add an Idempotency-Key header
373
+ if %i[post delete].include?(method) && Helio.max_network_retries > 0
374
+ headers["Idempotency-Key"] ||= SecureRandom.uuid
375
+ end
376
+
377
+ headers["X-API-ID"] = Helio.api_id if Helio.api_id
378
+ headers["Helio-Version"] = Helio.api_version if Helio.api_version
379
+ headers["X-API-TOKEN"] = Helio.api_token if Helio.api_token
380
+
381
+ user_agent = @system_profiler.user_agent
382
+ begin
383
+ headers.update(
384
+ "X-Helio-Client-User-Agent" => JSON.generate(user_agent)
385
+ )
386
+ rescue StandardError => e
387
+ headers.update(
388
+ "X-Helio-Client-Raw-User-Agent" => user_agent.inspect,
389
+ :error => "#{e} (#{e.class})"
390
+ )
391
+ end
392
+
393
+ headers
394
+ end
395
+
396
+ def log_request(context, num_retries)
397
+ Util.log_info("Request to Helio API",
398
+ api_id: context.api_id,
399
+ api_version: context.api_version,
400
+ idempotency_key: context.idempotency_key,
401
+ method: context.method,
402
+ num_retries: num_retries,
403
+ path: context.path)
404
+ Util.log_debug("Request details",
405
+ body: context.body,
406
+ idempotency_key: context.idempotency_key,
407
+ query_params: context.query_params)
408
+ end
409
+ private :log_request
410
+
411
+ def log_response(context, request_start, status, body)
412
+ Util.log_info("Response from Helio API",
413
+ api_id: context.api_id,
414
+ api_version: context.api_version,
415
+ elapsed: Time.now - request_start,
416
+ idempotency_key: context.idempotency_key,
417
+ method: context.method,
418
+ path: context.path,
419
+ request_id: context.request_id,
420
+ status: status)
421
+ Util.log_debug("Response details",
422
+ body: body,
423
+ idempotency_key: context.idempotency_key,
424
+ request_id: context.request_id)
425
+
426
+ return unless context.request_id
427
+
428
+ Util.log_debug("Dashboard link for request",
429
+ idempotency_key: context.idempotency_key,
430
+ request_id: context.request_id,
431
+ url: Util.request_id_dashboard_url(context.request_id, context.api_token))
432
+ end
433
+ private :log_response
434
+
435
+ def log_response_error(context, request_start, e)
436
+ Util.log_error("Request error",
437
+ elapsed: Time.now - request_start,
438
+ error_message: e.message,
439
+ idempotency_key: context.idempotency_key,
440
+ method: context.method,
441
+ path: context.path)
442
+ end
443
+ private :log_response_error
444
+
445
+ # RequestLogContext stores information about a request that's begin made so
446
+ # that we can log certain information. It's useful because it means that we
447
+ # don't have to pass around as many parameters.
448
+ class RequestLogContext
449
+ attr_accessor :body
450
+ attr_accessor :api_id
451
+ attr_accessor :api_token
452
+ attr_accessor :api_version
453
+ attr_accessor :idempotency_key
454
+ attr_accessor :method
455
+ attr_accessor :path
456
+ attr_accessor :query_params
457
+ attr_accessor :request_id
458
+
459
+ # The idea with this method is that we might want to update some of
460
+ # context information because a response that we've received from the API
461
+ # contains information that's more authoritative than what we started
462
+ # with for a request. For example, we should trust whatever came back in
463
+ # a `Helio-Version` header beyond what configuration information that we
464
+ # might have had available.
465
+ def dup_from_response(resp)
466
+ return self if resp.nil?
467
+
468
+ # Faraday's API is a little unusual. Normally it'll produce a response
469
+ # object with a `headers` method, but on error what it puts into
470
+ # `e.response` is an untyped `Hash`.
471
+ headers = if resp.is_a?(Faraday::Response)
472
+ resp.headers
473
+ else
474
+ resp[:headers]
475
+ end
476
+
477
+ context = dup
478
+ context.api_id = headers["X-API-ID"]
479
+ context.api_version = headers["Helio-Version"]
480
+ context.idempotency_key = headers["Idempotency-Key"]
481
+ context.request_id = headers["Request-Id"]
482
+ context
483
+ end
484
+ end
485
+
486
+ # SystemProfiler extracts information about the system that we're running
487
+ # in so that we can generate a rich user agent header to help debug
488
+ # integrations.
489
+ class SystemProfiler
490
+ def self.uname
491
+ if File.exist?("/proc/version")
492
+ File.read("/proc/version").strip
493
+ else
494
+ case RbConfig::CONFIG["host_os"]
495
+ when /linux|darwin|bsd|sunos|solaris|cygwin/i
496
+ uname_from_system
497
+ when /mswin|mingw/i
498
+ uname_from_system_ver
499
+ else
500
+ "unknown platform"
501
+ end
502
+ end
503
+ end
504
+
505
+ def self.uname_from_system
506
+ (`uname -a 2>/dev/null` || "").strip
507
+ rescue Errno::ENOENT
508
+ "uname executable not found"
509
+ rescue Errno::ENOMEM # couldn't create subprocess
510
+ "uname lookup failed"
511
+ end
512
+
513
+ def self.uname_from_system_ver
514
+ (`ver` || "").strip
515
+ rescue Errno::ENOENT
516
+ "ver executable not found"
517
+ rescue Errno::ENOMEM # couldn't create subprocess
518
+ "uname lookup failed"
519
+ end
520
+
521
+ def initialize
522
+ @uname = self.class.uname
523
+ end
524
+
525
+ def user_agent
526
+ lang_version = "#{RUBY_VERSION} p#{RUBY_PATCHLEVEL} (#{RUBY_RELEASE_DATE})"
527
+
528
+ {
529
+ application: Helio.app_info,
530
+ bindings_version: Helio::VERSION,
531
+ lang: "ruby",
532
+ lang_version: lang_version,
533
+ platform: RUBY_PLATFORM,
534
+ engine: defined?(RUBY_ENGINE) ? RUBY_ENGINE : "",
535
+ publisher: "helio",
536
+ uname: @uname,
537
+ hostname: Socket.gethostname,
538
+ }.delete_if { |_k, v| v.nil? }
539
+ end
540
+ end
541
+ end
542
+ end