activejsonmodel 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +79 -0
- data/lib/active_json_model.rb +8 -0
- data/lib/activejsonmodel/active_record_encrypted_type.rb +72 -0
- data/lib/activejsonmodel/active_record_type.rb +84 -0
- data/lib/activejsonmodel/after_load_callback.rb +35 -0
- data/lib/activejsonmodel/array.rb +699 -0
- data/lib/activejsonmodel/json_attribute.rb +64 -0
- data/lib/activejsonmodel/model.rb +597 -0
- data/lib/activejsonmodel/utils.rb +23 -0
- data/lib/activejsonmodel/version.rb +5 -0
- metadata +99 -0
@@ -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
|