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