activejsonmodel 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveJsonModel
4
+ # Instance of an attribute for a model backed by JSON persistence. Data object
5
+ # used for tracking the attributes on the models.
6
+ #
7
+ # e.g.
8
+ # class class Credentials < ::ActiveJsonModel
9
+ # json_attribute :username
10
+ # json_attribute :password
11
+ # end
12
+ #
13
+ # #...
14
+ #
15
+ # # Returns instances of JsonAttribute
16
+ # Credentials.active_json_model_attributes
17
+ class JsonAttribute
18
+ attr_reader :name
19
+ attr_reader :clazz
20
+ attr_reader :default
21
+ attr_reader :render_default
22
+ attr_reader :validation
23
+ attr_reader :load_proc
24
+ attr_reader :dump_proc
25
+
26
+ # Creates a record of a JSON-backed attribute
27
+ #
28
+ # @param name [Symbol, String] the name of the attribute
29
+ # @param clazz [Class] the Class that implements the type of the attribute (ActiveJsonModel)
30
+ # @param default [Object, ...] the default value for the attribute if unspecified
31
+ # @param validation [Hash] an object with properties that represent ActiveModel validation
32
+ # @param dump_proc [Proc] proc to generate a value from the value to be rendered to JSON. Given <code>value</code>
33
+ # and <code>parent_model</code> values. The value returned is assumed to be a valid JSON value. The proc
34
+ # can take either one or two parameters this is automatically handled by the caller.
35
+ # @param load_proc [Proc] proc to generate a value from the JSON. May take <code>json_value</code> and
36
+ # <code>json_hash</code>. The raw value and the parent hash being parsed, respectively. May return either
37
+ # a class (which will be instantiated) or a value directly. The proc can take either one or two parameters
38
+ # this is automatically handled by the caller.
39
+ # @param render_default [Boolean] should the default value be rendered to JSON? Default is true. Note this only
40
+ # applies if the value has not be explicitly set. If explicitly set, the value renders, regardless of if
41
+ # the value is the same as the default value.
42
+ def initialize(name:, clazz:, default:, validation:, dump_proc:, load_proc:, render_default: true)
43
+ @name = name.to_sym
44
+ @clazz = clazz
45
+ @default = default
46
+ @render_default = render_default
47
+ @validation = validation
48
+ @dump_proc = dump_proc
49
+ @load_proc = load_proc
50
+ end
51
+
52
+ # Get a default value for this attribute. Handles defaults that can be generators with callbacks and proper
53
+ # cloning of real values to avoid cross-object mutation.
54
+ def get_default_value
55
+ if default
56
+ if default.respond_to?(:call)
57
+ default.call
58
+ else
59
+ default.clone
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,597 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+ require_relative './json_attribute'
5
+ require_relative './after_load_callback'
6
+
7
+ if defined?(::ActiveRecord)
8
+ require_relative './active_record_type'
9
+ require_relative './active_record_encrypted_type'
10
+ end
11
+
12
+ module ActiveJsonModel
13
+ module Model
14
+ def self.included(base_class)
15
+ # Add all the class methods to the included class
16
+ base_class.extend(ClassMethods)
17
+
18
+ # Add additional settings into the class
19
+ base_class.class_eval do
20
+ # Make sure the objects will be ActiveModels
21
+ include ::ActiveModel::Model unless include?(::ActiveModel::Model)
22
+
23
+ # Make sure that it has dirty tracking
24
+ include ::ActiveModel::Dirty unless include?(::ActiveModel::Dirty)
25
+
26
+ # Has this model changed? Override's <code>ActiveModel::Dirty</code>'s base behavior to properly handle
27
+ # recursive changes.
28
+ #
29
+ # @return [Boolean] true if any attribute has changed, false otherwise
30
+ def changed?
31
+ # Note: this method is implemented here versus in the module overall because if it is implemented in the
32
+ # module overall, it doesn't properly override the implementation for <code>ActiveModel::Dirty</code> that
33
+ # gets dynamically pulled in using the <code>included</code> hook.
34
+ super || self.class.ancestry_active_json_model_attributes.any? do |attr|
35
+ val = send(attr.name)
36
+ val&.respond_to?(:changed?) && val.changed?
37
+ end
38
+ end
39
+
40
+ # For new/loaded tracking
41
+ @_active_json_model_dumped = false
42
+ @_active_json_model_loaded = false
43
+
44
+ # Register model validation to handle recursive validation into the model tree
45
+ validate :active_json_model_validate
46
+
47
+ def initialize(**kwargs)
48
+ # Apply default values values that weren't specified
49
+ self.class.active_json_model_attributes.filter{|attr| !attr.default.nil?}.each do |attr|
50
+ unless kwargs.key?(attr.name)
51
+ # Set as an instance variable to avoid being recorded as a true set value
52
+ instance_variable_set("@#{attr.name}", attr.get_default_value)
53
+
54
+ # Record that the value is a default
55
+ instance_variable_set("@#{attr.name}_is_default", true)
56
+ end
57
+ end
58
+
59
+ # You cannot set the fixed JSON attributes by a setter method. Instead, initialize the member variable
60
+ # directly
61
+ self.class.active_json_model_fixed_attributes.each do |k, v|
62
+ instance_variable_set("@#{k}", v)
63
+ end
64
+
65
+ # Invoke the superclass constructor to let active model do the work of setting the attributes
66
+ super(**kwargs).tap do |_|
67
+ # Clear out any recorded changes as this object is starting fresh
68
+ clear_changes_information
69
+ end
70
+ end
71
+ end
72
+ end
73
+
74
+ # Was this instance loaded from JSON?
75
+ # @return [Boolean] true if loaded from JSON, false otherwise
76
+ def loaded?
77
+ @_active_json_model_loaded
78
+ end
79
+
80
+ # Was this instance dumped to JSON?
81
+ # @return [Boolean] true if dumped to JSON, false otherwise
82
+ def dumped?
83
+ @_active_json_model_dumped
84
+ end
85
+
86
+ # Is this a new instance that was created without loading, and has yet to be dumped?
87
+ # @return [Boolean] true if new, false otherwise
88
+ def new?
89
+ !loaded? && !dumped?
90
+ end
91
+
92
+ # Load data for this instance from a JSON hash
93
+ #
94
+ # @param json_hash [Hash] hash of data to be loaded into a model instance
95
+ def load_from_json(json_hash)
96
+ # Record this object was loaded
97
+ @_active_json_model_loaded = true
98
+
99
+ # Cache fixed attributes
100
+ fixed_attributes = self.class.ancestry_active_json_model_fixed_attributes
101
+
102
+ # Iterate over all the allowed attributes
103
+ self.class.ancestry_active_json_model_attributes.each do |attr|
104
+ begin
105
+ # The value that was set from the hash
106
+ json_value = json_hash[attr.name]
107
+ rescue TypeError
108
+ raise ArgumentError.new("Invalid value specified for json_hash. Expected hash-like object received #{json_hash.class}")
109
+ end
110
+
111
+
112
+ # Now translate the raw value into how it should interpreted
113
+ if fixed_attributes.key?(attr.name)
114
+ # Doesn't matter what the value was. Must confirm to the fixed value.
115
+ value = fixed_attributes[attr.name]
116
+ elsif !json_hash.key?(attr.name) && attr.default
117
+ # Note that this logic reflects that an explicit nil value is not the same as not set. Only not set
118
+ # generates the default.
119
+ value = attr.get_default_value
120
+ elsif attr.load_proc && json_value
121
+ # Invoke the proc to get a value back. This gives the proc the opportunity to either generate a value
122
+ # concretely or return a class to use.
123
+ value = if attr.load_proc.arity == 2
124
+ attr.load_proc.call(json_value, json_hash)
125
+ else
126
+ attr.load_proc.call(json_value)
127
+ end
128
+
129
+ if value
130
+ # If it's a class, new it up assuming it will support loading from JSON.
131
+ if value.is_a?(Class)
132
+ # First check if it supports polymorphic behavior.
133
+ if value.respond_to?(:active_json_model_concrete_class_from_ancestry_polymorphic)
134
+ value = value.active_json_model_concrete_class_from_ancestry_polymorphic(json_value) || value
135
+ end
136
+
137
+ # New up an instance of the class for loading
138
+ value = value.new
139
+ end
140
+
141
+ # If supported, recursively allow the model to load from JSON
142
+ if value.respond_to?(:load_from_json)
143
+ value.load_from_json(json_value)
144
+ end
145
+ end
146
+ elsif attr.clazz && json_value
147
+ # Special case certain builtin types
148
+ if Integer == attr.clazz
149
+ value = json_value.to_i
150
+ elsif Float == attr.clazz
151
+ value = json_value.to_f
152
+ elsif String == attr.clazz
153
+ value = json_value.to_s
154
+ elsif Symbol == attr.clazz
155
+ value = json_value.to_sym
156
+ elsif DateTime == attr.clazz
157
+ value = DateTime.iso8601(json_value)
158
+ elsif Date == attr.clazz
159
+ value = Date.iso8601(json_value)
160
+ else
161
+ # First check if it supports polymorphic behavior.
162
+ clazz = if attr.clazz.respond_to?(:active_json_model_concrete_class_from_ancestry_polymorphic)
163
+ value = attr.clazz.active_json_model_concrete_class_from_ancestry_polymorphic(json_value) || attr.clazz
164
+ else
165
+ attr.clazz
166
+ end
167
+
168
+ # New up the instance
169
+ value = clazz.new
170
+
171
+ # If supported, recursively allow the model to load from JSON
172
+ if value.respond_to?(:load_from_json)
173
+ value.load_from_json(json_value)
174
+ end
175
+ end
176
+ else
177
+ value = json_value
178
+ end
179
+
180
+ # Actually set the value on the instance
181
+ send("#{attr.name}=", value)
182
+ end
183
+
184
+ # Now that the load is complete, mark dirty tracking as clean
185
+ clear_changes_information
186
+
187
+ # Invoke any on-load callbacks
188
+ self.class.ancestry_active_json_model_load_callbacks.each do |cb|
189
+ cb.invoke(self)
190
+ end
191
+ end
192
+
193
+ def dump_to_json
194
+ # Record that the data has been dumped
195
+ @_active_json_model_dumped = true
196
+
197
+ # Get the attributes that are constants in the JSON rendering
198
+ fixed_attributes = self.class.ancestry_active_json_model_fixed_attributes
199
+
200
+ key_values = []
201
+
202
+ self.class.ancestry_active_json_model_attributes.each do |attr|
203
+ # Skip on the off chance of a name collision between normal and fixed attributes
204
+ next if fixed_attributes.key?(attr.name)
205
+
206
+ # Don't render the value if it is a default and configured not to
207
+ next unless attr.render_default || !send("#{attr.name}_is_default?")
208
+
209
+ # Get the value from the underlying attribute from the instance
210
+ value = send(attr.name)
211
+
212
+ # Recurse if the value is itself an ActiveJsonModel
213
+ if value&.respond_to?(:dump_to_json)
214
+ value = value.dump_to_json
215
+ end
216
+
217
+ if attr.dump_proc
218
+ # Invoke the proc to do the translation
219
+ value = if attr.dump_proc.arity == 2
220
+ attr.dump_proc.call(value, self)
221
+ else
222
+ attr.dump_proc.call(value)
223
+ end
224
+ end
225
+
226
+ key_values.push([attr.name, value])
227
+ end
228
+
229
+ # Iterate over all the allowed attributes (fixed and regular)
230
+ fixed_attributes.each do |key, value|
231
+ # Recurse if the value is itself an ActiveJsonModel (unlikely)
232
+ if value&.respond_to?(:dump_to_json)
233
+ value = value.dump_to_json
234
+ end
235
+
236
+ key_values.push([key, value])
237
+ end
238
+
239
+ # Render the array of key-value pairs to a hash
240
+ key_values.to_h.tap do |_|
241
+ # All changes are cleared after dump
242
+ clear_changes_information
243
+ end
244
+ end
245
+
246
+ # Validate method that handles recursive validation into <code>json_attribute</code>s. Individual validations
247
+ # on attributes for this model will be handled by the standard mechanism.
248
+ def active_json_model_validate
249
+ self.class.active_json_model_attributes.each do |attr|
250
+ val = send(attr.name)
251
+
252
+ # Check if attribute value is an ActiveJsonModel
253
+ if val && val.respond_to?(:valid?)
254
+ # This call to <code>valid?</code> is important because it will actually trigger recursive validations
255
+ unless val.valid?
256
+ val.errors.each do |error|
257
+ errors.add("#{attr.name}.#{error.attribute}".to_sym, error.message)
258
+ end
259
+ end
260
+ end
261
+ end
262
+ end
263
+
264
+ module ClassMethods
265
+ if defined?(::ActiveRecord)
266
+ # Allow this model to be used as ActiveRecord attribute type in Rails 5+.
267
+ #
268
+ # E.g.
269
+ # class Credentials < ::ActiveJsonModel; end;
270
+ #
271
+ # class Integration < ActiveRecord::Base
272
+ # attribute :credentials, Credentials.attribute_type
273
+ # end
274
+ #
275
+ # Note that this data would be stored as jsonb in the database
276
+ def attribute_type
277
+ @attribute_type ||= ActiveModelJsonSerializableType.new(self)
278
+ end
279
+
280
+ # Allow this model to be used as ActiveRecord attribute type in Rails 5+.
281
+ #
282
+ # E.g.
283
+ # class SecureCredentials < ::ActiveJsonModel; end;
284
+ #
285
+ # class Integration < ActiveRecord::Base
286
+ # attribute :secure_credentials, SecureCredentials.encrypted_attribute_type
287
+ # end
288
+ #
289
+ # Note that this data would be stored as a string in the database, encrypted using
290
+ # a symmetric key at the application level.
291
+ def encrypted_attribute_type
292
+ @encrypted_attribute_type ||= ActiveModelJsonSerializableEncryptedType.new(self)
293
+ end
294
+ end
295
+
296
+ # Attributes that have been defined for this class using <code>json_attribute</code>.
297
+ #
298
+ # @return [Array<JsonAttribute>] Json attributes for this class
299
+ def active_json_model_attributes
300
+ @__active_json_model_attributes ||= []
301
+ end
302
+
303
+ # A list of procs that will be executed after data has been loaded.
304
+ #
305
+ # @return [Array<Proc>] array of procs executed after data is loaded
306
+ def active_json_model_load_callbacks
307
+ @__active_json_model_load_callbacks ||= []
308
+ end
309
+
310
+ # A factory defined via <code>json_polymorphic_via</code> that allows the class to choose different concrete
311
+ # classes based on the data in the JSON. Property is for only this class, not the entire class hierarchy.
312
+ #
313
+ # @ return [Proc, nil] proc used to select the concrete base class for the model class
314
+ def active_json_model_polymorphic_factory
315
+ @__active_json_model_polymorphic_factory
316
+ end
317
+
318
+ # Filter the ancestor hierarchy to those built with <code>ActiveJsonModel::Model</code> concerns
319
+ #
320
+ # @return [Array<Class>] reversed array of classes in the hierarchy of this class that include ActiveJsonModel
321
+ def active_json_model_ancestors
322
+ self.ancestors.filter{|o| o.respond_to?(:active_json_model_attributes)}.reverse
323
+ end
324
+
325
+ # Get all active json model attributes for all the class hierarchy tree
326
+ #
327
+ # @return [Array<JsonAttribute>] Json attributes for the ancestry tree
328
+ def ancestry_active_json_model_attributes
329
+ self.active_json_model_ancestors.flat_map(&:active_json_model_attributes)
330
+ end
331
+
332
+ # Get all active json model after load callbacks for all the class hierarchy tree
333
+ #
334
+ # @return [Array<AfterLoadCallback>] After load callbacks for the ancestry tree
335
+ def ancestry_active_json_model_load_callbacks
336
+ self.active_json_model_ancestors.flat_map(&:active_json_model_load_callbacks)
337
+ end
338
+
339
+ # Get all polymorphic factories in the ancestry chain.
340
+ #
341
+ # @return [Array<Proc>] After load callbacks for the ancestry tree
342
+ def ancestry_active_json_model_polymorphic_factory
343
+ self.active_json_model_ancestors.map(&:active_json_model_polymorphic_factory).filter(&:present?)
344
+ end
345
+
346
+ # Get the hash of key-value pairs that are fixed for this class. Fixed attributes render to the JSON payload
347
+ # but cannot be set directly.
348
+ #
349
+ # @return [Hash] set of fixed attributes for this class
350
+ def active_json_model_fixed_attributes
351
+ @__active_json_fixed_attributes ||= {}
352
+ end
353
+
354
+ # Get the hash of key-value pairs that are fixed for this class hierarchy. Fixed attributes render to the JSON
355
+ # payload but cannot be set directly.
356
+ #
357
+ # @return [Hash] set of fixed attributes for this class hierarchy
358
+ def ancestry_active_json_model_fixed_attributes
359
+ self
360
+ .active_json_model_ancestors
361
+ .map{|a| a.active_json_model_fixed_attributes}
362
+ .reduce({}, :merge)
363
+ end
364
+
365
+ # Set a fixed attribute for the current class. A fixed attribute is a constant value that is set at the class
366
+ # level that still renders to the underlying JSON structure. This is useful when you have a hierarchy of classes
367
+ # which may have certain properties set that differentiate them in the rendered json. E.g. a <code>type</code>
368
+ # attribute.
369
+ #
370
+ # Example:
371
+ #
372
+ # class BaseWorkflow
373
+ # include ::ActiveJsonModel::Model
374
+ # json_attribute :name
375
+ # end
376
+ #
377
+ # class EmailWorkflow < BaseWorkflow
378
+ # include ::ActiveJsonModel::Model
379
+ # json_fixed_attribute :type, 'email'
380
+ # end
381
+ #
382
+ # class WebhookWorkflow < BaseWorkflow
383
+ # include ::ActiveJsonModel::Model
384
+ # json_fixed_attribute :type, 'webhook'
385
+ # end
386
+ #
387
+ # workflows = [EmailWorkflow.new(name: 'wf1'), WebhookWorkflow.new(name: 'wf2')].map(&:dump_to_json)
388
+ # # [{"name": "wf1", "type": "email"}, {"name": "wf2", "type": "webhook"}]
389
+ #
390
+ # @param name [Symbol] the name of the attribute
391
+ # @param value [Object] the value to set the attribute to
392
+ def json_fixed_attribute(name, value:)
393
+ active_json_model_fixed_attributes[name.to_sym] = value
394
+
395
+ # We could handle fixed attributes as just a get method, but this approach keeps them consistent with the
396
+ # other attributes for things like changed tracking.
397
+ instance_variable_set("@#{name}", value)
398
+
399
+ # Define ActiveModel attribute methods (https://api.rubyonrails.org/classes/ActiveModel/AttributeMethods.html)
400
+ # for this class. E.g. reset_<name>
401
+ #
402
+ # Used for dirty tracking for the model.
403
+ #
404
+ # @see https://api.rubyonrails.org/classes/ActiveModel/AttributeMethods/ClassMethods.html#method-i-define_attribute_methods
405
+ define_attribute_methods name
406
+
407
+ # Define the getter for this attribute
408
+ attr_reader name
409
+
410
+ # Define the setter method to prevent the value from being changed.
411
+ define_method "#{name}=" do |v|
412
+ unless value == v
413
+ raise RuntimeError.new("#{self.class}.#{name} is an Active JSON Model fixed attribute with a value of '#{value}'. It's value cannot be set to '#{v}''.")
414
+ end
415
+ end
416
+ end
417
+
418
+ # Define a new attribute for the model that will be backed by a JSON attribute
419
+ #
420
+ # @param name [Symbol, String] the name of the attribute
421
+ # @param clazz [Class] the Class to use to initialize the object type
422
+ # @param default [Object] the default value for the attribute
423
+ # @param render_default [Boolean] should the default value be rendered to JSON? Default is true. Note this only
424
+ # applies if the value has not be explicitly set. If explicitly set, the value renders, regardless of if
425
+ # the value is the same as the default value.
426
+ # @param validation [Object] object whose properties correspond to settings for active model validators
427
+ # @param serialize_with [Proc] proc to generate a value from the value to be rendered to JSON. Given
428
+ # <code>value</code> and <code>parent_model</code> (optional parameter) values. The value returned is
429
+ # assumed to be a valid JSON value.
430
+ # @param deserialize_with [Proc] proc to deserialize a value from JSON. This is an alternative to passing a block
431
+ # (<code>load_proc</code>) to the method and has the same semantics.
432
+ # @param load_proc [Proc] proc that allows the model to customize the value generated. The proc is passed
433
+ # <code>value_json</code> and <code>parent_json</code>. <code>value_json</code> is the value for this
434
+ # sub-property, and <code>parent_json</code> (optional parameter) is the json for the parent object. This
435
+ # proc can either return a class or a concrete instance. If a class is returned, a new instance of the
436
+ # class will be created on JSON load, and if supported, the sub-JSON will be loaded into it. If a concrete
437
+ # value is returned, it is assumed this is the reconstructed value. This proc allows for simplified
438
+ # polymorphic load behavior as well as custom deserialization.
439
+ def json_attribute(name, clazz = nil, default: nil, render_default: true, validation: nil,
440
+ serialize_with: nil, deserialize_with: nil, &load_proc)
441
+ if deserialize_with && load_proc
442
+ raise ArgumentError.new("Cannot specify both deserialize_with and block to json_attribute")
443
+ end
444
+
445
+ name = name.to_sym
446
+
447
+ # Add the attribute to the collection of json attributes defined for this class
448
+ active_json_model_attributes.push(
449
+ JsonAttribute.new(
450
+ name: name,
451
+ clazz: clazz,
452
+ default: default,
453
+ render_default: render_default,
454
+ validation: validation,
455
+ dump_proc: serialize_with,
456
+ load_proc: load_proc || deserialize_with
457
+ )
458
+ )
459
+
460
+ # Define ActiveModel attribute methods (https://api.rubyonrails.org/classes/ActiveModel/AttributeMethods.html)
461
+ # for this class. E.g. reset_<name>
462
+ #
463
+ # Used for dirty tracking for the model.
464
+ #
465
+ # @see https://api.rubyonrails.org/classes/ActiveModel/AttributeMethods/ClassMethods.html#method-i-define_attribute_methods
466
+ define_attribute_methods name
467
+
468
+ # Define the getter for this attribute
469
+ attr_reader name
470
+
471
+ # Define the setter for this attribute with proper change tracking
472
+ #
473
+ # @param value [...] the value to set the attribute to
474
+ define_method "#{name}=" do |value|
475
+ # Trigger ActiveModle's change tracking system if the value is actually changing
476
+ # @see https://stackoverflow.com/questions/23958170/understanding-attribute-will-change-method
477
+ send("#{name}_will_change!") unless value == instance_variable_get("@#{name}")
478
+
479
+ # Set the value as a direct instance variable
480
+ instance_variable_set("@#{name}", value)
481
+
482
+ # Record that the value is not a default
483
+ instance_variable_set("@#{name}_is_default", false)
484
+ end
485
+
486
+ # Check if the attribute is set to the default value. This implies this value has never been set.
487
+ # @return [Boolean] true if the value has been explicitly set or loaded, false otherwise
488
+ define_method "#{name}_is_default?" do
489
+ !!instance_variable_get("@#{name}_is_default")
490
+ end
491
+
492
+ if validation
493
+ validates name, validation
494
+ end
495
+ end
496
+
497
+ # Define a polymorphic factory to choose the concrete class for the model.
498
+ #
499
+ # Example:
500
+ #
501
+ # class BaseWorkflow
502
+ # include ::ActiveJsonModel::Model
503
+ #
504
+ # json_polymorphic_via do |data|
505
+ # if data[:type] == 'email'
506
+ # EmailWorkflow
507
+ # else
508
+ # WebhookWorkflow
509
+ # end
510
+ # end
511
+ # end
512
+ #
513
+ # class EmailWorkflow < BaseWorkflow
514
+ # include ::ActiveJsonModel::Model
515
+ # json_fixed_attribute :type, 'email'
516
+ # end
517
+ #
518
+ # class WebhookWorkflow < BaseWorkflow
519
+ # include ::ActiveJsonModel::Model
520
+ # json_fixed_attribute :type, 'webhook'
521
+ # end
522
+ def json_polymorphic_via(&block)
523
+ @__active_json_model_polymorphic_factory = block
524
+ end
525
+
526
+ # Computes the concrete class that should be used to load the data based on the ancestry tree's
527
+ # <code>json_polymorphic_via</code>. Also handles potential recursion at the leaf nodes of the tree.
528
+ #
529
+ # @param data [Hash] the data being loaded from JSON
530
+ # @return [Class] the class to be used to load the JSON
531
+ def active_json_model_concrete_class_from_ancestry_polymorphic(data)
532
+ clazz = nil
533
+ ancestry_active_json_model_polymorphic_factory.each do |proc|
534
+ clazz = proc.call(data)
535
+ break if clazz
536
+ end
537
+
538
+ if clazz
539
+ if clazz != self && clazz.respond_to?(:active_json_model_concrete_class_from_ancestry_polymorphic)
540
+ clazz.active_json_model_concrete_class_from_ancestry_polymorphic(data) || clazz
541
+ else
542
+ clazz
543
+ end
544
+ else
545
+ self
546
+ end
547
+ end
548
+
549
+ # Register a new after load callback which is invoked after the instance is loaded from JSON
550
+ #
551
+ # @param method_name [Symbol, String] the name of the method to be invoked
552
+ # @param block [Proc] block to be executed after load. Will optionally be passed an instance of the loaded object.
553
+ def json_after_load(method_name=nil, &block)
554
+ raise ArgumentError.new("Must specify method or block for ActiveJsonModel after load") unless method_name || block
555
+ raise ArgumentError.new("Can only specify method or block for ActiveJsonModel after load") if method_name && block
556
+
557
+ active_json_model_load_callbacks.push(
558
+ AfterLoadCallback.new(
559
+ method_name: method_name,
560
+ block: block
561
+ )
562
+ )
563
+ end
564
+
565
+ # Load an instance of the class from JSON
566
+ #
567
+ # @param json_data [String, Hash] the data to be loaded into the instance. May be a hash or a string.
568
+ # @return Instance of the class
569
+ def load(json_data)
570
+ if json_data.nil? || (json_data.is_a?(String) && json_data.blank?)
571
+ return nil
572
+ end
573
+
574
+ # Get the data to a hash, regardless of the starting data type
575
+ data = json_data.is_a?(String) ? JSON.parse(json_data) : json_data
576
+
577
+ # Recursively make the value have indifferent access
578
+ data = ::ActiveJsonModel::Utils.recursively_make_indifferent(data)
579
+
580
+ # Get the concrete class from the ancestry tree's potential polymorphic behavior. Note this needs to be done
581
+ # for each sub property as well. This just covers the outermost case.
582
+ clazz = active_json_model_concrete_class_from_ancestry_polymorphic(data)
583
+ clazz.new.tap do |instance|
584
+ instance.load_from_json(data)
585
+ end
586
+ end
587
+
588
+ # Dump the specified object to JSON
589
+ #
590
+ # @param obj [self] object to dump to json
591
+ def dump(obj)
592
+ raise ArgumentError.new("Expected #{self} got #{obj.class} to dump to JSON") unless obj.is_a?(self)
593
+ obj.dump_to_json
594
+ end
595
+ end
596
+ end
597
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+
5
+ module ActiveJsonModel
6
+ module Utils
7
+ def self.recursively_make_indifferent(val)
8
+ return val unless val&.is_a?(Hash) || val&.respond_to?(:map)
9
+
10
+ if val.is_a?(Hash)
11
+ val.with_indifferent_access.tap do |w|
12
+ w.each do |k, v|
13
+ w[k] = recursively_make_indifferent(v)
14
+ end
15
+ end
16
+ else
17
+ val.map do |v|
18
+ recursively_make_indifferent(v)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveJsonModel
4
+ VERSION = "0.1.0".freeze
5
+ end