helio-ruby 0.1.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.
@@ -0,0 +1,48 @@
1
+ module Helio
2
+ # HelioResponse encapsulates some vitals of a response that came back from
3
+ # the Helio API.
4
+ class HelioResponse
5
+ # The data contained by the HTTP body of the response deserialized from
6
+ # JSON.
7
+ attr_accessor :data
8
+
9
+ # The raw HTTP body of the response.
10
+ attr_accessor :http_body
11
+
12
+ # A Hash of the HTTP headers of the response.
13
+ attr_accessor :http_headers
14
+
15
+ # The integer HTTP status code of the response.
16
+ attr_accessor :http_status
17
+
18
+ # The Helio request ID of the response.
19
+ attr_accessor :request_id
20
+
21
+ # Initializes a HelioResponse object from a Hash like the kind returned as
22
+ # part of a Faraday exception.
23
+ #
24
+ # This may throw JSON::ParserError if the response body is not valid JSON.
25
+ def self.from_faraday_hash(http_resp)
26
+ resp = HelioResponse.new
27
+ resp.data = JSON.parse(http_resp[:body], symbolize_names: true)
28
+ resp.http_body = http_resp[:body]
29
+ resp.http_headers = http_resp[:headers]
30
+ resp.http_status = http_resp[:status]
31
+ resp.request_id = http_resp[:headers]["Request-Id"]
32
+ resp
33
+ end
34
+
35
+ # Initializes a HelioResponse object from a Faraday HTTP response object.
36
+ #
37
+ # This may throw JSON::ParserError if the response body is not valid JSON.
38
+ def self.from_faraday_response(http_resp)
39
+ resp = HelioResponse.new
40
+ resp.data = JSON.parse(http_resp.body, symbolize_names: true)
41
+ resp.http_body = http_resp.body
42
+ resp.http_headers = http_resp.headers
43
+ resp.http_status = http_resp.status
44
+ resp.request_id = http_resp.headers["Request-Id"]
45
+ resp
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,103 @@
1
+ module Helio
2
+ class ListObject < HelioObject
3
+ include Enumerable
4
+ include Helio::APIOperations::List
5
+ include Helio::APIOperations::Request
6
+ include Helio::APIOperations::Create
7
+
8
+ OBJECT_NAME = "list".freeze
9
+
10
+ # This accessor allows a `ListObject` to inherit various filters that were
11
+ # given to a predecessor. This allows for things like consistent limits,
12
+ # expansions, and predicates as a user pages through resources.
13
+ attr_accessor :filters
14
+
15
+ # An empty list object. This is returned from +next+ when we know that
16
+ # there isn't a next page in order to replicate the behavior of the API
17
+ # when it attempts to return a page beyond the last.
18
+ def self.empty_list(opts = {})
19
+ ListObject.construct_from({ data: [] }, opts)
20
+ end
21
+
22
+ def initialize(*args)
23
+ super
24
+ self.filters = {}
25
+ end
26
+
27
+ def [](k)
28
+ case k
29
+ when String, Symbol
30
+ super
31
+ else
32
+ raise ArgumentError, "You tried to access the #{k.inspect} index, but ListObject types only support String keys. (HINT: List calls return an object with a 'data' (which is the data array). You likely want to call #data[#{k.inspect}])"
33
+ end
34
+ end
35
+
36
+ # Iterates through each resource in the page represented by the current
37
+ # `ListObject`.
38
+ #
39
+ # Note that this method makes no effort to fetch a new page when it gets to
40
+ # the end of the current page's resources. See also +auto_paging_each+.
41
+ def each(&blk)
42
+ data.each(&blk)
43
+ end
44
+
45
+ # Iterates through each resource in all pages, making additional fetches to
46
+ # the API as necessary.
47
+ #
48
+ # Note that this method will make as many API calls as necessary to fetch
49
+ # all resources. For more granular control, please see +each+ and
50
+ # +next_page+.
51
+ def auto_paging_each(&blk)
52
+ return enum_for(:auto_paging_each) unless block_given?
53
+
54
+ page = self
55
+ loop do
56
+ page.each(&blk)
57
+ page = page.next_page
58
+ break if page.empty?
59
+ end
60
+ end
61
+
62
+ # Returns true if the page object contains no elements.
63
+ def empty?
64
+ data.empty?
65
+ end
66
+
67
+ def retrieve(id, opts = {})
68
+ id, retrieve_params = Util.normalize_id(id)
69
+ resp, opts = request(:get, "#{resource_url}/#{CGI.escape(id)}", retrieve_params, opts)
70
+ Util.convert_to_helio_object(resp.data, opts)
71
+ end
72
+
73
+ # Fetches the next page in the resource list (if there is one).
74
+ #
75
+ # This method will try to respect the limit of the current page. If none
76
+ # was given, the default limit will be fetched again.
77
+ def next_page(params = {}, opts = {})
78
+ return self.class.empty_list(opts) unless has_more
79
+ last_id = data.last.id
80
+
81
+ params = filters.merge(starting_after: last_id).merge(params)
82
+
83
+ list(params, opts)
84
+ end
85
+
86
+ # Fetches the previous page in the resource list (if there is one).
87
+ #
88
+ # This method will try to respect the limit of the current page. If none
89
+ # was given, the default limit will be fetched again.
90
+ def previous_page(params = {}, opts = {})
91
+ first_id = data.first.id
92
+
93
+ params = filters.merge(ending_before: first_id).merge(params)
94
+
95
+ list(params, opts)
96
+ end
97
+
98
+ def resource_url
99
+ url ||
100
+ raise(ArgumentError, "List object does not contain a 'url' field.")
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,9 @@
1
+ module Helio
2
+ class Participant < APIResource
3
+ include Helio::APIOperations::Save
4
+ include Helio::APIOperations::Delete
5
+ extend Helio::APIOperations::List
6
+
7
+ OBJECT_NAME = "participant".freeze
8
+ end
9
+ end
@@ -0,0 +1,20 @@
1
+ module Helio
2
+ class SingletonAPIResource < APIResource
3
+ def self.resource_url
4
+ if self == SingletonAPIResource
5
+ raise NotImplementedError, "SingletonAPIResource is an abstract class. You should perform actions on its subclasses (Account, etc.)"
6
+ end
7
+ "/#{CGI.escape(class_name.downcase)}"
8
+ end
9
+
10
+ def resource_url
11
+ self.class.resource_url
12
+ end
13
+
14
+ def self.retrieve(opts = {})
15
+ instance = new(nil, Util.normalize_opts(opts))
16
+ instance.refresh
17
+ instance
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,401 @@
1
+ require "cgi"
2
+
3
+ module Helio
4
+ module Util
5
+ # Options that a user is allowed to specify.
6
+ OPTS_USER_SPECIFIED = Set[
7
+ :api_id,
8
+ :api_token,
9
+ :idempotency_key,
10
+ :helio_version
11
+ ].freeze
12
+
13
+ # Options that should be copyable from one HelioObject to another
14
+ # including options that may be internal.
15
+ OPTS_COPYABLE = (
16
+ OPTS_USER_SPECIFIED + Set[:api_base]
17
+ ).freeze
18
+
19
+ # Options that should be persisted between API requests. This includes
20
+ # client, which is an object containing an HTTP client to reuse.
21
+ OPTS_PERSISTABLE = (
22
+ OPTS_USER_SPECIFIED + Set[:client] - Set[:idempotency_key]
23
+ ).freeze
24
+
25
+ def self.objects_to_ids(h)
26
+ case h
27
+ when APIResource
28
+ h.id
29
+ when Hash
30
+ res = {}
31
+ h.each { |k, v| res[k] = objects_to_ids(v) unless v.nil? }
32
+ res
33
+ when Array
34
+ h.map { |v| objects_to_ids(v) }
35
+ else
36
+ h
37
+ end
38
+ end
39
+
40
+ def self.object_classes
41
+ @object_classes ||= {
42
+ # data structures
43
+ ListObject::OBJECT_NAME => ListObject,
44
+
45
+ # business objects
46
+ CustomerList::OBJECT_NAME => CustomerList,
47
+ Participant::OBJECT_NAME => Participant,
48
+ }
49
+ end
50
+
51
+ # Converts a hash of fields or an array of hashes into a +HelioObject+ or
52
+ # array of +HelioObject+s. These new objects will be created as a concrete
53
+ # type as dictated by their `object` field (e.g. an `object` value of
54
+ # `charge` would create an instance of +Charge+), but if `object` is not
55
+ # present or of an unknown type, the newly created instance will fall back
56
+ # to being a +HelioObject+.
57
+ #
58
+ # ==== Attributes
59
+ #
60
+ # * +data+ - Hash of fields and values to be converted into a HelioObject.
61
+ # * +opts+ - Options for +HelioObject+ like an API key that will be reused
62
+ # on subsequent API calls.
63
+ def self.convert_to_helio_object(data, opts = {})
64
+ case data
65
+ when Array
66
+ data.map { |i| convert_to_helio_object(i, opts) }
67
+ when Hash
68
+ # Try converting to a known object class. If none available, fall back to generic HelioObject
69
+ object_classes.fetch(data[:object], HelioObject).construct_from(data, opts)
70
+ else
71
+ data
72
+ end
73
+ end
74
+
75
+ def self.log_error(message, data = {})
76
+ if !Helio.logger.nil? ||
77
+ !Helio.log_level.nil? && Helio.log_level <= Helio::LEVEL_ERROR
78
+ log_internal(message, data, color: :cyan,
79
+ level: Helio::LEVEL_ERROR, logger: Helio.logger, out: $stderr)
80
+ end
81
+ end
82
+
83
+ def self.log_info(message, data = {})
84
+ if !Helio.logger.nil? ||
85
+ !Helio.log_level.nil? && Helio.log_level <= Helio::LEVEL_INFO
86
+ log_internal(message, data, color: :cyan,
87
+ level: Helio::LEVEL_INFO, logger: Helio.logger, out: $stdout)
88
+ end
89
+ end
90
+
91
+ def self.log_debug(message, data = {})
92
+ if !Helio.logger.nil? ||
93
+ !Helio.log_level.nil? && Helio.log_level <= Helio::LEVEL_DEBUG
94
+ log_internal(message, data, color: :blue,
95
+ level: Helio::LEVEL_DEBUG, logger: Helio.logger, out: $stdout)
96
+ end
97
+ end
98
+
99
+ def self.file_readable(file)
100
+ # This is nominally equivalent to File.readable?, but that can
101
+ # report incorrect results on some more oddball filesystems
102
+ # (such as AFS)
103
+
104
+ File.open(file) { |f| }
105
+ rescue StandardError
106
+ false
107
+ else
108
+ true
109
+ end
110
+
111
+ def self.symbolize_names(object)
112
+ case object
113
+ when Hash
114
+ new_hash = {}
115
+ object.each do |key, value|
116
+ key = (begin
117
+ key.to_sym
118
+ rescue StandardError
119
+ key
120
+ end) || key
121
+ new_hash[key] = symbolize_names(value)
122
+ end
123
+ new_hash
124
+ when Array
125
+ object.map { |value| symbolize_names(value) }
126
+ else
127
+ object
128
+ end
129
+ end
130
+
131
+ # Encodes a hash of parameters in a way that's suitable for use as query
132
+ # parameters in a URI or as form parameters in a request body. This mainly
133
+ # involves escaping special characters from parameter keys and values (e.g.
134
+ # `&`).
135
+ def self.encode_parameters(params)
136
+ Util.flatten_params(params)
137
+ .map { |k, v| "#{url_encode(k)}=#{url_encode(v)}" }.join("&")
138
+ end
139
+
140
+ # Transforms an array into a hash with integer keys. Used for a small
141
+ # number of API endpoints. If the argument is not an Array, return it
142
+ # unchanged. Example: [{foo: 'bar'}] => {"0" => {foo: "bar"}}
143
+ def self.array_to_hash(array)
144
+ case array
145
+ when Array
146
+ hash = {}
147
+ array.each_with_index { |v, i| hash[i.to_s] = v }
148
+ hash
149
+ else
150
+ array
151
+ end
152
+ end
153
+
154
+ # Encodes a string in a way that makes it suitable for use in a set of
155
+ # query parameters in a URI or in a set of form parameters in a request
156
+ # body.
157
+ def self.url_encode(key)
158
+ CGI.escape(key.to_s).
159
+ # Don't use strict form encoding by changing the square bracket control
160
+ # characters back to their literals. This is fine by the server, and
161
+ # makes these parameter strings easier to read.
162
+ gsub("%5B", "[").gsub("%5D", "]")
163
+ end
164
+
165
+ def self.flatten_params(params, parent_key = nil)
166
+ result = []
167
+
168
+ # do not sort the final output because arrays (and arrays of hashes
169
+ # especially) can be order sensitive, but do sort incoming parameters
170
+ params.each do |key, value|
171
+ calculated_key = parent_key ? "#{parent_key}[#{key}]" : key.to_s
172
+ if value.is_a?(Hash)
173
+ result += flatten_params(value, calculated_key)
174
+ elsif value.is_a?(Array)
175
+ check_array_of_maps_start_keys!(value)
176
+ result += flatten_params_array(value, calculated_key)
177
+ else
178
+ result << [calculated_key, value]
179
+ end
180
+ end
181
+
182
+ result
183
+ end
184
+
185
+ def self.flatten_params_array(value, calculated_key)
186
+ result = []
187
+ value.each do |elem|
188
+ if elem.is_a?(Hash)
189
+ result += flatten_params(elem, "#{calculated_key}[]")
190
+ elsif elem.is_a?(Array)
191
+ result += flatten_params_array(elem, calculated_key)
192
+ else
193
+ result << ["#{calculated_key}[]", elem]
194
+ end
195
+ end
196
+ result
197
+ end
198
+
199
+ def self.normalize_id(id)
200
+ if id.is_a?(Hash) # overloaded id
201
+ params_hash = id.dup
202
+ id = params_hash.delete(:id)
203
+ else
204
+ params_hash = {}
205
+ end
206
+ [id, params_hash]
207
+ end
208
+
209
+ # The secondary opts argument can either be a string or hash
210
+ # Turn this value into an api_token and a set of headers
211
+ def self.normalize_opts(opts)
212
+ case opts
213
+ when String
214
+ { api_token: opts }
215
+ when Hash
216
+ check_api_token!(opts.fetch(:api_token)) if opts.key?(:api_token)
217
+ opts.clone
218
+ else
219
+ raise TypeError, "normalize_opts expects a string or a hash"
220
+ end
221
+ end
222
+
223
+ def self.check_string_argument!(key)
224
+ raise TypeError, "argument must be a string" unless key.is_a?(String)
225
+ key
226
+ end
227
+
228
+ def self.check_api_token!(key)
229
+ raise TypeError, "api_token must be a string" unless key.is_a?(String)
230
+ key
231
+ end
232
+
233
+ # Normalizes header keys so that they're all lower case and each
234
+ # hyphen-delimited section starts with a single capitalized letter. For
235
+ # example, `request-id` becomes `Request-Id`. This is useful for extracting
236
+ # certain key values when the user could have set them with a variety of
237
+ # diffent naming schemes.
238
+ def self.normalize_headers(headers)
239
+ headers.each_with_object({}) do |(k, v), new_headers|
240
+ if k.is_a?(Symbol)
241
+ k = titlecase_parts(k.to_s.tr("_", "-"))
242
+ elsif k.is_a?(String)
243
+ k = titlecase_parts(k)
244
+ end
245
+
246
+ new_headers[k] = v
247
+ end
248
+ end
249
+
250
+ # Generates a Dashboard link to inspect a request ID based off of a request
251
+ # ID value and an API key, which is used to attempt to extract whether the
252
+ # environment is livemode or testmode.
253
+ def self.request_id_dashboard_url(request_id, api_token)
254
+ env = !api_token.nil? && api_token.start_with?("sk_live") ? "live" : "test"
255
+ "https://helio.zurb.com/#{env}/logs/#{request_id}"
256
+ end
257
+
258
+ # Constant time string comparison to prevent timing attacks
259
+ # Code borrowed from ActiveSupport
260
+ def self.secure_compare(a, b)
261
+ return false unless a.bytesize == b.bytesize
262
+
263
+ l = a.unpack "C#{a.bytesize}"
264
+
265
+ res = 0
266
+ b.each_byte { |byte| res |= byte ^ l.shift }
267
+ res.zero?
268
+ end
269
+
270
+ #
271
+ # private
272
+ #
273
+
274
+ COLOR_CODES = {
275
+ black: 0, light_black: 60,
276
+ red: 1, light_red: 61,
277
+ green: 2, light_green: 62,
278
+ yellow: 3, light_yellow: 63,
279
+ blue: 4, light_blue: 64,
280
+ magenta: 5, light_magenta: 65,
281
+ cyan: 6, light_cyan: 66,
282
+ white: 7, light_white: 67,
283
+ default: 9,
284
+ }.freeze
285
+ private_constant :COLOR_CODES
286
+
287
+ # We use a pretty janky version of form encoding (Rack's) that supports
288
+ # more complex data structures like maps and arrays through the use of
289
+ # specialized syntax. To encode an array of maps like:
290
+ #
291
+ # [{a: 1, b: 2}, {a: 3, b: 4}]
292
+ #
293
+ # We have to produce something that looks like this:
294
+ #
295
+ # arr[][a]=1&arr[][b]=2&arr[][a]=3&arr[][b]=4
296
+ #
297
+ # The only way for the server to recognize that this is a two item array is
298
+ # that it notices the repetition of element "a", so it's key that these
299
+ # repeated elements are encoded first.
300
+ #
301
+ # This method is invoked for any arrays being encoded and checks that if
302
+ # the array contains all non-empty maps, that each of those maps must start
303
+ # with the same key so that their boundaries can be properly encoded.
304
+ def self.check_array_of_maps_start_keys!(arr)
305
+ expected_key = nil
306
+ arr.each do |item|
307
+ break unless item.is_a?(Hash)
308
+ break if item.count.zero?
309
+
310
+ first_key = item.first[0]
311
+
312
+ if expected_key
313
+ if expected_key != first_key
314
+ raise ArgumentError,
315
+ "All maps nested in an array should start with the same key " \
316
+ "(expected starting key '#{expected_key}', got '#{first_key}')"
317
+ end
318
+ else
319
+ expected_key = first_key
320
+ end
321
+ end
322
+ end
323
+ private_class_method :check_array_of_maps_start_keys!
324
+
325
+ # Uses an ANSI escape code to colorize text if it's going to be sent to a
326
+ # TTY.
327
+ def self.colorize(val, color, isatty)
328
+ return val unless isatty
329
+
330
+ mode = 0 # default
331
+ foreground = 30 + COLOR_CODES.fetch(color)
332
+ background = 40 + COLOR_CODES.fetch(:default)
333
+
334
+ "\033[#{mode};#{foreground};#{background}m#{val}\033[0m"
335
+ end
336
+ private_class_method :colorize
337
+
338
+ # Turns an integer log level into a printable name.
339
+ def self.level_name(level)
340
+ case level
341
+ when LEVEL_DEBUG then "debug"
342
+ when LEVEL_ERROR then "error"
343
+ when LEVEL_INFO then "info"
344
+ else level
345
+ end
346
+ end
347
+ private_class_method :level_name
348
+
349
+ # TODO: Make these named required arguments when we drop support for Ruby
350
+ # 2.0.
351
+ def self.log_internal(message, data = {}, color: nil, level: nil, logger: nil, out: nil)
352
+ data_str = data.reject { |_k, v| v.nil? }
353
+ .map do |(k, v)|
354
+ format("%s=%s", colorize(k, color, !out.nil? && out.isatty), wrap_logfmt_value(v))
355
+ end.join(" ")
356
+
357
+ if !logger.nil?
358
+ # the library's log levels are mapped to the same values as the
359
+ # standard library's logger
360
+ logger.log(level,
361
+ format("message=%s %s", wrap_logfmt_value(message), data_str))
362
+ elsif out.isatty
363
+ out.puts format("%s %s %s", colorize(level_name(level)[0, 4].upcase, color, out.isatty), message, data_str)
364
+ else
365
+ out.puts format("message=%s level=%s %s", wrap_logfmt_value(message), level_name(level), data_str)
366
+ end
367
+ end
368
+ private_class_method :log_internal
369
+
370
+ def self.titlecase_parts(s)
371
+ s.split("-")
372
+ .reject { |p| p == "" }
373
+ .map { |p| p[0].upcase + p[1..-1].downcase }
374
+ .join("-")
375
+ end
376
+ private_class_method :titlecase_parts
377
+
378
+ # Wraps a value in double quotes if it looks sufficiently complex so that
379
+ # it can be read by logfmt parsers.
380
+ def self.wrap_logfmt_value(val)
381
+ # If value is any kind of number, just allow it to be formatted directly
382
+ # to a string (this will handle integers or floats).
383
+ return val if val.is_a?(Numeric)
384
+
385
+ # Hopefully val is a string, but protect in case it's not.
386
+ val = val.to_s
387
+
388
+ if %r{[^\w\-/]} =~ val
389
+ # If the string contains any special characters, escape any double
390
+ # quotes it has, remove newlines, and wrap the whole thing in quotes.
391
+ format(%("%s"), val.gsub('"', '\"').delete("\n"))
392
+ else
393
+ # Otherwise use the basic value if it looks like a standard set of
394
+ # characters (and allow a few special characters like hyphens, and
395
+ # slashes)
396
+ val
397
+ end
398
+ end
399
+ private_class_method :wrap_logfmt_value
400
+ end
401
+ end