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,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