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,475 @@
1
+ module Helio
2
+ class HelioObject
3
+ include Enumerable
4
+
5
+ @@permanent_attributes = Set.new([:id])
6
+
7
+ # The default :id method is deprecated and isn't useful to us
8
+ undef :id if method_defined?(:id)
9
+
10
+ def initialize(id = nil, opts = {})
11
+ id, @retrieve_params = Util.normalize_id(id)
12
+ @opts = Util.normalize_opts(opts)
13
+ @original_values = {}
14
+ @values = {}
15
+ # This really belongs in APIResource, but not putting it there allows us
16
+ # to have a unified inspect method
17
+ @unsaved_values = Set.new
18
+ @transient_values = Set.new
19
+ @values[:id] = id if id
20
+ end
21
+
22
+ def self.construct_from(values, opts = {})
23
+ values = Helio::Util.symbolize_names(values)
24
+
25
+ # work around protected #initialize_from for now
26
+ new(values[:id]).send(:initialize_from, values, opts)
27
+ end
28
+
29
+ # Determines the equality of two Helio objects. Helio objects are
30
+ # considered to be equal if they have the same set of values and each one
31
+ # of those values is the same.
32
+ def ==(other)
33
+ other.is_a?(HelioObject) && @values == other.instance_variable_get(:@values)
34
+ end
35
+
36
+ # Indicates whether or not the resource has been deleted on the server.
37
+ # Note that some, but not all, resources can indicate whether they have
38
+ # been deleted.
39
+ def deleted?
40
+ @values.fetch(:deleted, false)
41
+ end
42
+
43
+ def to_s(*_args)
44
+ JSON.pretty_generate(to_hash)
45
+ end
46
+
47
+ def inspect
48
+ id_string = respond_to?(:id) && !id.nil? ? " id=#{id}" : ""
49
+ "#<#{self.class}:0x#{object_id.to_s(16)}#{id_string}> JSON: " + JSON.pretty_generate(@values)
50
+ end
51
+
52
+ # Re-initializes the object based on a hash of values (usually one that's
53
+ # come back from an API call). Adds or removes value accessors as necessary
54
+ # and updates the state of internal data.
55
+ #
56
+ # Please don't use this method. If you're trying to do mass assignment, try
57
+ # #initialize_from instead.
58
+ def refresh_from(values, opts, partial = false)
59
+ initialize_from(values, opts, partial)
60
+ end
61
+ extend Gem::Deprecate
62
+ deprecate :refresh_from, "#update_attributes", 2016, 1
63
+
64
+ # Mass assigns attributes on the model.
65
+ #
66
+ # This is a version of +update_attributes+ that takes some extra options
67
+ # for internal use.
68
+ #
69
+ # ==== Attributes
70
+ #
71
+ # * +values+ - Hash of values to use to update the current attributes of
72
+ # the object.
73
+ # * +opts+ - Options for +HelioObject+ like an API key that will be reused
74
+ # on subsequent API calls.
75
+ #
76
+ # ==== Options
77
+ #
78
+ # * +:dirty+ - Whether values should be initiated as "dirty" (unsaved) and
79
+ # which applies only to new HelioObjects being initiated under this
80
+ # HelioObject. Defaults to true.
81
+ def update_attributes(values, opts = {}, method_options = {})
82
+ # Default to true. TODO: Convert to optional arguments after we're off
83
+ # 1.9 which will make this quite a bit more clear.
84
+ dirty = method_options.fetch(:dirty, true)
85
+ values.each do |k, v|
86
+ add_accessors([k], values) unless metaclass.method_defined?(k.to_sym)
87
+ @values[k] = Util.convert_to_helio_object(v, opts)
88
+ dirty_value!(@values[k]) if dirty
89
+ @unsaved_values.add(k)
90
+ end
91
+ end
92
+
93
+ def [](k)
94
+ @values[k.to_sym]
95
+ end
96
+
97
+ def []=(k, v)
98
+ send(:"#{k}=", v)
99
+ end
100
+
101
+ def keys
102
+ @values.keys
103
+ end
104
+
105
+ def values
106
+ @values.values
107
+ end
108
+
109
+ def to_json(*_a)
110
+ JSON.generate(@values)
111
+ end
112
+
113
+ def as_json(*a)
114
+ @values.as_json(*a)
115
+ end
116
+
117
+ def to_hash
118
+ maybe_to_hash = lambda do |value|
119
+ value.respond_to?(:to_hash) ? value.to_hash : value
120
+ end
121
+
122
+ @values.each_with_object({}) do |(key, value), acc|
123
+ acc[key] = case value
124
+ when Array
125
+ value.map(&maybe_to_hash)
126
+ else
127
+ maybe_to_hash.call(value)
128
+ end
129
+ end
130
+ end
131
+
132
+ def each(&blk)
133
+ @values.each(&blk)
134
+ end
135
+
136
+ # Sets all keys within the HelioObject as unsaved so that they will be
137
+ # included with an update when #serialize_params is called. This method is
138
+ # also recursive, so any HelioObjects contained as values or which are
139
+ # values in a tenant array are also marked as dirty.
140
+ def dirty!
141
+ @unsaved_values = Set.new(@values.keys)
142
+ @values.each_value do |v|
143
+ dirty_value!(v)
144
+ end
145
+ end
146
+
147
+ # Implements custom encoding for Ruby's Marshal. The data produced by this
148
+ # method should be comprehendable by #marshal_load.
149
+ #
150
+ # This allows us to remove certain features that cannot or should not be
151
+ # serialized.
152
+ def marshal_dump
153
+ # The HelioClient instance in @opts is not serializable and is not
154
+ # really a property of the HelioObject, so we exclude it when
155
+ # dumping
156
+ opts = @opts.clone
157
+ opts.delete(:client)
158
+ [@values, opts]
159
+ end
160
+
161
+ # Implements custom decoding for Ruby's Marshal. Consumes data that's
162
+ # produced by #marshal_dump.
163
+ def marshal_load(data)
164
+ values, opts = data
165
+ initialize(values[:id])
166
+ initialize_from(values, opts)
167
+ end
168
+
169
+ def serialize_params(options = {})
170
+ update_hash = {}
171
+
172
+ @values.each do |k, v|
173
+ # There are a few reasons that we may want to add in a parameter for
174
+ # update:
175
+ #
176
+ # 1. The `force` option has been set.
177
+ # 2. We know that it was modified.
178
+ # 3. Its value is a HelioObject. A HelioObject may contain modified
179
+ # values within in that its parent HelioObject doesn't know about.
180
+ #
181
+ unsaved = @unsaved_values.include?(k)
182
+ if options[:force] || unsaved || v.is_a?(HelioObject)
183
+ update_hash[k.to_sym] =
184
+ serialize_params_value(@values[k], @original_values[k], unsaved, options[:force], key: k)
185
+ end
186
+ end
187
+
188
+ # a `nil` that makes it out of `#serialize_params_value` signals an empty
189
+ # value that we shouldn't appear in the serialized form of the object
190
+ update_hash.reject! { |_, v| v.nil? }
191
+
192
+ update_hash
193
+ end
194
+
195
+ class << self
196
+ # This class method has been deprecated in favor of the instance method
197
+ # of the same name.
198
+ def serialize_params(obj, options = {})
199
+ obj.serialize_params(options)
200
+ end
201
+ extend Gem::Deprecate
202
+ deprecate :serialize_params, "#serialize_params", 2016, 9
203
+ end
204
+
205
+ # A protected field is one that doesn't get an accessor assigned to it
206
+ # (i.e. `obj.public = ...`) and one which is not allowed to be updated via
207
+ # the class level `Model.update(id, { ... })`.
208
+ def self.protected_fields
209
+ []
210
+ end
211
+
212
+ protected
213
+
214
+ def metaclass
215
+ class << self; self; end
216
+ end
217
+
218
+ def remove_accessors(keys)
219
+ # not available in the #instance_eval below
220
+ protected_fields = self.class.protected_fields
221
+
222
+ metaclass.instance_eval do
223
+ keys.each do |k|
224
+ next if protected_fields.include?(k)
225
+ next if @@permanent_attributes.include?(k)
226
+
227
+ # Remove methods for the accessor's reader and writer.
228
+ [k, :"#{k}=", :"#{k}?"].each do |method_name|
229
+ remove_method(method_name) if method_defined?(method_name)
230
+ end
231
+ end
232
+ end
233
+ end
234
+
235
+ def add_accessors(keys, values)
236
+ # not available in the #instance_eval below
237
+ protected_fields = self.class.protected_fields
238
+
239
+ metaclass.instance_eval do
240
+ keys.each do |k|
241
+ next if protected_fields.include?(k)
242
+ next if @@permanent_attributes.include?(k)
243
+
244
+ if k == :method
245
+ # Object#method is a built-in Ruby method that accepts a symbol
246
+ # and returns the corresponding Method object. Because the API may
247
+ # also use `method` as a field name, we check the arity of *args
248
+ # to decide whether to act as a getter or call the parent method.
249
+ define_method(k) { |*args| args.empty? ? @values[k] : super(*args) }
250
+ else
251
+ define_method(k) { @values[k] }
252
+ end
253
+
254
+ define_method(:"#{k}=") do |v|
255
+ if v == ""
256
+ raise ArgumentError, "You cannot set #{k} to an empty string. " \
257
+ "We interpret empty strings as nil in requests. " \
258
+ "You may set (object).#{k} = nil to delete the property."
259
+ end
260
+ @values[k] = Util.convert_to_helio_object(v, @opts)
261
+ dirty_value!(@values[k])
262
+ @unsaved_values.add(k)
263
+ end
264
+
265
+ if [FalseClass, TrueClass].include?(values[k].class)
266
+ define_method(:"#{k}?") { @values[k] }
267
+ end
268
+ end
269
+ end
270
+ end
271
+
272
+ def method_missing(name, *args)
273
+ # TODO: only allow setting in updateable classes.
274
+ if name.to_s.end_with?("=")
275
+ attr = name.to_s[0...-1].to_sym
276
+
277
+ # Pull out the assigned value. This is only used in the case of a
278
+ # boolean value to add a question mark accessor (i.e. `foo?`) for
279
+ # convenience.
280
+ val = args.first
281
+
282
+ # the second argument is only required when adding boolean accessors
283
+ add_accessors([attr], attr => val)
284
+
285
+ begin
286
+ mth = method(name)
287
+ rescue NameError
288
+ raise NoMethodError, "Cannot set #{attr} on this object. HINT: you can't set: #{@@permanent_attributes.to_a.join(', ')}"
289
+ end
290
+ return mth.call(args[0])
291
+ elsif @values.key?(name)
292
+ return @values[name]
293
+ end
294
+
295
+ begin
296
+ super
297
+ rescue NoMethodError => e
298
+ # If we notice the accessed name if our set of transient values we can
299
+ # give the user a slightly more helpful error message. If not, just
300
+ # raise right away.
301
+ raise unless @transient_values.include?(name)
302
+
303
+ raise NoMethodError, e.message + ". HINT: The '#{name}' attribute was set in the past, however. It was then wiped when refreshing the object with the result returned by Helio's API, probably as a result of a save(). The attributes currently available on this object are: #{@values.keys.join(', ')}"
304
+ end
305
+ end
306
+
307
+ def respond_to_missing?(symbol, include_private = false)
308
+ @values && @values.key?(symbol) || super
309
+ end
310
+
311
+ # Re-initializes the object based on a hash of values (usually one that's
312
+ # come back from an API call). Adds or removes value accessors as necessary
313
+ # and updates the state of internal data.
314
+ #
315
+ # Protected on purpose! Please do not expose.
316
+ #
317
+ # ==== Options
318
+ #
319
+ # * +:values:+ Hash used to update accessors and values.
320
+ # * +:opts:+ Options for HelioObject like an API key.
321
+ # * +:partial:+ Indicates that the re-initialization should not attempt to
322
+ # remove accessors.
323
+ def initialize_from(values, opts, partial = false)
324
+ @opts = Util.normalize_opts(opts)
325
+
326
+ # the `#send` is here so that we can keep this method private
327
+ @original_values = self.class.send(:deep_copy, values)
328
+
329
+ removed = partial ? Set.new : Set.new(@values.keys - values.keys)
330
+ added = Set.new(values.keys - @values.keys)
331
+
332
+ # Wipe old state before setting new. This is useful for e.g. updating a
333
+ # customer, where there is no persistent card parameter. Mark those values
334
+ # which don't persist as transient
335
+
336
+ remove_accessors(removed)
337
+ add_accessors(added, values)
338
+
339
+ removed.each do |k|
340
+ @values.delete(k)
341
+ @transient_values.add(k)
342
+ @unsaved_values.delete(k)
343
+ end
344
+
345
+ update_attributes(values, opts, dirty: false)
346
+ values.each_key do |k|
347
+ @transient_values.delete(k)
348
+ @unsaved_values.delete(k)
349
+ end
350
+
351
+ self
352
+ end
353
+
354
+ def serialize_params_value(value, original, unsaved, force, key: nil)
355
+ if value.nil?
356
+ ""
357
+
358
+ # The logic here is that essentially any object embedded in another
359
+ # object that had a `type` is actually an API resource of a different
360
+ # type that's been included in the response. These other resources must
361
+ # be updated from their proper endpoints, and therefore they are not
362
+ # included when serializing even if they've been modified.
363
+ #
364
+ # There are _some_ known exceptions though.
365
+ #
366
+ # For example, if the value is unsaved (meaning the user has set it), and
367
+ # it looks like the API resource is persisted with an ID, then we include
368
+ # the object so that parameters are serialized with a reference to its
369
+ # ID.
370
+ #
371
+ # Another example is that on save API calls it's sometimes desirable to
372
+ # update a customer's default source by setting a new card (or other)
373
+ # object with `#source=` and then saving the customer. The
374
+ # `#save_with_parent` flag to override the default behavior allows us to
375
+ # handle these exceptions.
376
+ #
377
+ # We throw an error if a property was set explicitly but we can't do
378
+ # anything with it because the integration is probably not working as the
379
+ # user intended it to.
380
+ elsif value.is_a?(APIResource) && !value.save_with_parent
381
+ if !unsaved
382
+ nil
383
+ elsif value.respond_to?(:id) && !value.id.nil?
384
+ value
385
+ else
386
+ raise ArgumentError, "Cannot save property `#{key}` containing " \
387
+ "an API resource. It doesn't appear to be persisted and is " \
388
+ "not marked as `save_with_parent`."
389
+ end
390
+
391
+ elsif value.is_a?(Array)
392
+ update = value.map { |v| serialize_params_value(v, nil, true, force) }
393
+
394
+ # This prevents an array that's unchanged from being resent.
395
+ update if update != serialize_params_value(original, nil, true, force)
396
+
397
+ # Handle a Hash for now, but in the long run we should be able to
398
+ # eliminate all places where hashes are stored as values internally by
399
+ # making sure any time one is set, we convert it to a HelioObject. This
400
+ # will simplify our model by making data within an object more
401
+ # consistent.
402
+ #
403
+ # For now, you can still run into a hash if someone appends one to an
404
+ # existing array being held by a HelioObject. This could happen for
405
+ # example by appending a new hash onto `additional_owners` for an
406
+ # account.
407
+ elsif value.is_a?(Hash)
408
+ Util.convert_to_helio_object(value, @opts).serialize_params
409
+
410
+ elsif value.is_a?(HelioObject)
411
+ update = value.serialize_params(force: force)
412
+
413
+ # If the entire object was replaced, then we need blank each field of
414
+ # the old object that held a value. The new serialized values will
415
+ # override any of these empty values.
416
+ update = empty_values(original).merge(update) if original && unsaved
417
+
418
+ update
419
+
420
+ else
421
+ value
422
+ end
423
+ end
424
+
425
+ private
426
+
427
+ # Produces a deep copy of the given object including support for arrays,
428
+ # hashes, and HelioObjects.
429
+ def self.deep_copy(obj)
430
+ case obj
431
+ when Array
432
+ obj.map { |e| deep_copy(e) }
433
+ when Hash
434
+ obj.each_with_object({}) do |(k, v), copy|
435
+ copy[k] = deep_copy(v)
436
+ copy
437
+ end
438
+ when HelioObject
439
+ obj.class.construct_from(
440
+ deep_copy(obj.instance_variable_get(:@values)),
441
+ obj.instance_variable_get(:@opts).select do |k, _v|
442
+ Util::OPTS_COPYABLE.include?(k)
443
+ end
444
+ )
445
+ else
446
+ obj
447
+ end
448
+ end
449
+ private_class_method :deep_copy
450
+
451
+ def dirty_value!(value)
452
+ case value
453
+ when Array
454
+ value.map { |v| dirty_value!(v) }
455
+ when HelioObject
456
+ value.dirty!
457
+ end
458
+ end
459
+
460
+ # Returns a hash of empty values for all the values that are in the given
461
+ # HelioObject.
462
+ def empty_values(obj)
463
+ values = case obj
464
+ when Hash then obj
465
+ when HelioObject then obj.instance_variable_get(:@values)
466
+ else
467
+ raise ArgumentError, "#empty_values got unexpected object type: #{obj.class.name}"
468
+ end
469
+
470
+ values.each_with_object({}) do |(k, _), update|
471
+ update[k] = ""
472
+ end
473
+ end
474
+ end
475
+ end