activejsonmodel 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.
- 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
|