iknow_view_models 2.8.4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.circleci/config.yml +115 -0
- data/.gitignore +36 -0
- data/.travis.yml +31 -0
- data/Appraisals +9 -0
- data/Gemfile +19 -0
- data/LICENSE.txt +22 -0
- data/README.md +19 -0
- data/Rakefile +21 -0
- data/appveyor.yml +22 -0
- data/gemfiles/rails_5_2.gemfile +15 -0
- data/gemfiles/rails_6_0_beta.gemfile +15 -0
- data/iknow_view_models.gemspec +49 -0
- data/lib/iknow_view_models.rb +12 -0
- data/lib/iknow_view_models/railtie.rb +8 -0
- data/lib/iknow_view_models/version.rb +5 -0
- data/lib/view_model.rb +333 -0
- data/lib/view_model/access_control.rb +154 -0
- data/lib/view_model/access_control/composed.rb +216 -0
- data/lib/view_model/access_control/open.rb +13 -0
- data/lib/view_model/access_control/read_only.rb +13 -0
- data/lib/view_model/access_control/tree.rb +264 -0
- data/lib/view_model/access_control_error.rb +10 -0
- data/lib/view_model/active_record.rb +383 -0
- data/lib/view_model/active_record/association_data.rb +178 -0
- data/lib/view_model/active_record/association_manipulation.rb +389 -0
- data/lib/view_model/active_record/cache.rb +265 -0
- data/lib/view_model/active_record/cache/cacheable_view.rb +51 -0
- data/lib/view_model/active_record/cloner.rb +113 -0
- data/lib/view_model/active_record/collection_nested_controller.rb +100 -0
- data/lib/view_model/active_record/controller.rb +77 -0
- data/lib/view_model/active_record/controller_base.rb +185 -0
- data/lib/view_model/active_record/nested_controller_base.rb +93 -0
- data/lib/view_model/active_record/singular_nested_controller.rb +34 -0
- data/lib/view_model/active_record/update_context.rb +252 -0
- data/lib/view_model/active_record/update_data.rb +749 -0
- data/lib/view_model/active_record/update_operation.rb +810 -0
- data/lib/view_model/active_record/visitor.rb +77 -0
- data/lib/view_model/after_transaction_runner.rb +29 -0
- data/lib/view_model/callbacks.rb +219 -0
- data/lib/view_model/changes.rb +62 -0
- data/lib/view_model/config.rb +29 -0
- data/lib/view_model/controller.rb +142 -0
- data/lib/view_model/deserialization_error.rb +437 -0
- data/lib/view_model/deserialize_context.rb +16 -0
- data/lib/view_model/error.rb +191 -0
- data/lib/view_model/error_view.rb +35 -0
- data/lib/view_model/record.rb +367 -0
- data/lib/view_model/record/attribute_data.rb +48 -0
- data/lib/view_model/reference.rb +31 -0
- data/lib/view_model/references.rb +48 -0
- data/lib/view_model/registry.rb +73 -0
- data/lib/view_model/schemas.rb +45 -0
- data/lib/view_model/serialization_error.rb +10 -0
- data/lib/view_model/serialize_context.rb +118 -0
- data/lib/view_model/test_helpers.rb +103 -0
- data/lib/view_model/test_helpers/arvm_builder.rb +111 -0
- data/lib/view_model/traversal_context.rb +126 -0
- data/lib/view_model/utils.rb +24 -0
- data/lib/view_model/utils/collections.rb +49 -0
- data/test/helpers/arvm_test_models.rb +59 -0
- data/test/helpers/arvm_test_utilities.rb +187 -0
- data/test/helpers/callback_tracer.rb +27 -0
- data/test/helpers/controller_test_helpers.rb +270 -0
- data/test/helpers/match_enumerator.rb +58 -0
- data/test/helpers/query_logging.rb +71 -0
- data/test/helpers/test_access_control.rb +56 -0
- data/test/helpers/viewmodel_spec_helpers.rb +326 -0
- data/test/unit/view_model/access_control_test.rb +769 -0
- data/test/unit/view_model/active_record/alias_test.rb +35 -0
- data/test/unit/view_model/active_record/belongs_to_test.rb +376 -0
- data/test/unit/view_model/active_record/cache_test.rb +351 -0
- data/test/unit/view_model/active_record/cloner_test.rb +313 -0
- data/test/unit/view_model/active_record/controller_test.rb +561 -0
- data/test/unit/view_model/active_record/counter_test.rb +80 -0
- data/test/unit/view_model/active_record/customization_test.rb +388 -0
- data/test/unit/view_model/active_record/has_many_test.rb +957 -0
- data/test/unit/view_model/active_record/has_many_through_poly_test.rb +269 -0
- data/test/unit/view_model/active_record/has_many_through_test.rb +736 -0
- data/test/unit/view_model/active_record/has_one_test.rb +334 -0
- data/test/unit/view_model/active_record/namespacing_test.rb +75 -0
- data/test/unit/view_model/active_record/optional_attribute_view_test.rb +58 -0
- data/test/unit/view_model/active_record/poly_test.rb +320 -0
- data/test/unit/view_model/active_record/shared_test.rb +285 -0
- data/test/unit/view_model/active_record/version_test.rb +121 -0
- data/test/unit/view_model/active_record_test.rb +542 -0
- data/test/unit/view_model/callbacks_test.rb +582 -0
- data/test/unit/view_model/deserialization_error/unique_violation_test.rb +73 -0
- data/test/unit/view_model/record_test.rb +524 -0
- data/test/unit/view_model/traversal_context_test.rb +371 -0
- data/test/unit/view_model_test.rb +62 -0
- metadata +490 -0
@@ -0,0 +1,77 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class ViewModel::ActiveRecord::Visitor
|
4
|
+
attr_reader :visit_shared, :for_edit
|
5
|
+
|
6
|
+
def initialize(visit_shared: true, for_edit: false)
|
7
|
+
@visit_shared = visit_shared
|
8
|
+
@for_edit = for_edit
|
9
|
+
end
|
10
|
+
|
11
|
+
def visit(view, context: nil)
|
12
|
+
return unless pre_visit(view, context: context)
|
13
|
+
|
14
|
+
run_callback(ViewModel::Callbacks::Hook::BeforeVisit, view, context: context)
|
15
|
+
run_callback(ViewModel::Callbacks::Hook::BeforeDeserialize, view, context: context) if for_edit
|
16
|
+
|
17
|
+
class_name = view.class.name.underscore.gsub('/', '__')
|
18
|
+
visit = :"visit_#{class_name}"
|
19
|
+
end_visit = :"end_visit_#{class_name}"
|
20
|
+
|
21
|
+
visit_children =
|
22
|
+
if respond_to?(visit, true)
|
23
|
+
self.send(visit, view, context: context)
|
24
|
+
else
|
25
|
+
true
|
26
|
+
end
|
27
|
+
|
28
|
+
if visit_children
|
29
|
+
# visit the underlying viewmodel for each association, ignoring any
|
30
|
+
# customization
|
31
|
+
view.class._members.each do |name, member_data|
|
32
|
+
next unless member_data.is_a?(ViewModel::ActiveRecord::AssociationData)
|
33
|
+
next if member_data.shared? && !visit_shared
|
34
|
+
children = Array.wrap(view._read_association(name))
|
35
|
+
children.each do |child|
|
36
|
+
if context
|
37
|
+
child_context = view.context_for_child(name, context: context)
|
38
|
+
end
|
39
|
+
self.visit(child, context: child_context)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
self.send(end_visit, view, context: context) if respond_to?(end_visit, true)
|
45
|
+
|
46
|
+
if for_edit
|
47
|
+
view_changes = changes(view)
|
48
|
+
run_callback(ViewModel::Callbacks::Hook::OnChange, view, context: context, changes: view_changes) if view_changes.changed?
|
49
|
+
run_callback(ViewModel::Callbacks::Hook::AfterDeserialize, view, context: context, changes: view_changes)
|
50
|
+
end
|
51
|
+
|
52
|
+
run_callback(ViewModel::Callbacks::Hook::AfterVisit, view, context: context)
|
53
|
+
|
54
|
+
post_visit(view, context: context)
|
55
|
+
end
|
56
|
+
|
57
|
+
# Invoked for all view types before visit, may cancel visit by returning
|
58
|
+
# false.
|
59
|
+
def pre_visit(_view, context: nil)
|
60
|
+
true
|
61
|
+
end
|
62
|
+
|
63
|
+
# Invoked for all view types after visit.
|
64
|
+
def post_visit(_view, context: nil); end
|
65
|
+
|
66
|
+
# If a context is provided, run the specified callback hook on it
|
67
|
+
def run_callback(hook, view, context:, **args)
|
68
|
+
context.run_callback(hook, view, **args) if context
|
69
|
+
end
|
70
|
+
|
71
|
+
# This method may be overridden by subclasses to specify the changes to be
|
72
|
+
# provided to callback hooks for each view. By default returns an empty
|
73
|
+
# Changes.
|
74
|
+
def changes(_view)
|
75
|
+
ViewModel::Changes.new
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Module implementing the behaviour of a AR post-transaction hook. After calling
|
4
|
+
# `add_to_transaction`, the abstract method `after_transaction` will be invoked
|
5
|
+
# by AR's callbacks.
|
6
|
+
module ViewModel::AfterTransactionRunner
|
7
|
+
def committed!; end
|
8
|
+
|
9
|
+
def before_committed!
|
10
|
+
after_transaction
|
11
|
+
end
|
12
|
+
|
13
|
+
def rolledback!(*)
|
14
|
+
after_transaction
|
15
|
+
end
|
16
|
+
|
17
|
+
def add_to_transaction
|
18
|
+
if connection.transaction_open?
|
19
|
+
connection.add_transaction_record(self)
|
20
|
+
else
|
21
|
+
after_transaction
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
# Override to tie to a specific connection.
|
26
|
+
def connection
|
27
|
+
ActiveRecord::Base.connection
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,219 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'renum'
|
4
|
+
require 'safe_values'
|
5
|
+
|
6
|
+
# Callback hooks for viewmodel traversal contexts
|
7
|
+
module ViewModel::Callbacks
|
8
|
+
extend ActiveSupport::Concern
|
9
|
+
|
10
|
+
# Callbacks are run in the instance context of an Env class that wraps the
|
11
|
+
# callbacks instance with additional instance method access to the view,
|
12
|
+
# context and extra context-dependent parameters.
|
13
|
+
module CallbackEnvContext
|
14
|
+
def method_missing(method, *args, &block)
|
15
|
+
if _callbacks.respond_to?(method, true)
|
16
|
+
_callbacks.send(method, *args, &block)
|
17
|
+
else
|
18
|
+
super
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def respond_to_missing?(method, include_all = false)
|
23
|
+
_callbacks.respond_to?(method, false) || super
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# Define the possible callback hooks and their required parameters.
|
28
|
+
enum :Hook do
|
29
|
+
BeforeVisit(:context)
|
30
|
+
AfterVisit(:context)
|
31
|
+
|
32
|
+
# The before deserialize hook is called when the viewmodel is visited during
|
33
|
+
# deserialization. At this point we don't know whether deserialization will
|
34
|
+
# be making any changes.
|
35
|
+
BeforeDeserialize(:deserialize_context)
|
36
|
+
|
37
|
+
# The BeforeValidate hook is called during deserialization immediately
|
38
|
+
# before validating the viewmodel. For AR viewmodels, this is after
|
39
|
+
# deserializing attributes and points-to associations, but before saving and
|
40
|
+
# deserializing points-from associations. Callbacks on this hook may make
|
41
|
+
# changes to the model, but must call the viewmodel's `*_changed!` methods
|
42
|
+
# for any changes to viewmodel attributes/associations.
|
43
|
+
BeforeValidate(:deserialize_context)
|
44
|
+
|
45
|
+
# The on change hook is called when deserialization has visited the model
|
46
|
+
# and made changes. Keyword argument `changes` is a ViewModel::Changes
|
47
|
+
# describing the effects. Callbacks on this hook may not themselves make any
|
48
|
+
# changes to the model. ViewModels backed by a transactional model such as
|
49
|
+
# AR will have been saved once, allowing the hook to inspect changed model
|
50
|
+
# values on `previous_changes`.
|
51
|
+
OnChange(:deserialize_context, :changes)
|
52
|
+
|
53
|
+
# The after-deserialize hook is called when leaving the viewmodel during
|
54
|
+
# deserialization. The recorded ViewModel::Changes instance (which may have
|
55
|
+
# no changes) is passed to the hook.
|
56
|
+
AfterDeserialize(:deserialize_context, :changes)
|
57
|
+
|
58
|
+
attr_reader :context_name, :required_params, :env_class
|
59
|
+
|
60
|
+
def init(context_name, *other_params)
|
61
|
+
@context_name = context_name
|
62
|
+
@required_params = other_params
|
63
|
+
@env_class = Value.new(:_callbacks, :view, context_name, *other_params) do
|
64
|
+
include CallbackEnvContext
|
65
|
+
delegate :model, to: :view
|
66
|
+
|
67
|
+
unless context_name == :context
|
68
|
+
alias_method :context, context_name
|
69
|
+
end
|
70
|
+
|
71
|
+
# If we have any other params, generate a combined positional/keyword
|
72
|
+
# constructor wrapper
|
73
|
+
if other_params.present?
|
74
|
+
params = other_params.map { |x| "#{x}:" }.join(', ')
|
75
|
+
args = other_params.join(', ')
|
76
|
+
instance_eval(<<-SRC, __FILE__, __LINE__ + 1)
|
77
|
+
def create(callbacks, view, context, #{params})
|
78
|
+
self.new(callbacks, view, context, #{args})
|
79
|
+
end
|
80
|
+
SRC
|
81
|
+
else
|
82
|
+
def self.create(callbacks, view, context)
|
83
|
+
self.new(callbacks, view, context)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def dsl_add_hook_name
|
90
|
+
name.underscore
|
91
|
+
end
|
92
|
+
|
93
|
+
def dsl_viewmodel_callback_method
|
94
|
+
name.underscore.to_sym
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
# Placeholder for callbacks to invoked for all view types
|
99
|
+
ALWAYS = '__always'
|
100
|
+
|
101
|
+
# Callbacks classes may be inherited, including their callbacks and
|
102
|
+
# env method delegations.
|
103
|
+
included do
|
104
|
+
base_callbacks = {}
|
105
|
+
define_singleton_method(:class_callbacks) { base_callbacks }
|
106
|
+
define_singleton_method(:all_callbacks) do |&block|
|
107
|
+
return to_enum(__method__) unless block
|
108
|
+
block.call(base_callbacks)
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
class_methods do
|
113
|
+
def inherited(subclass)
|
114
|
+
subclass_callbacks = {}
|
115
|
+
subclass.define_singleton_method(:class_callbacks) { subclass_callbacks }
|
116
|
+
subclass.define_singleton_method(:all_callbacks) do |&block|
|
117
|
+
return to_enum(__method__) unless block
|
118
|
+
super(&block)
|
119
|
+
block.call(subclass_callbacks)
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
# Add dsl methods to declare hooks in subclasses
|
124
|
+
Hook.each do |hook|
|
125
|
+
define_method(hook.dsl_add_hook_name) do |view_name = ALWAYS, &block|
|
126
|
+
add_callback(hook, view_name, &block)
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
def each_callback(hook, view_name)
|
131
|
+
valid_hook!(hook)
|
132
|
+
return to_enum(__method__, hook, view_name) unless block_given?
|
133
|
+
|
134
|
+
all_callbacks do |callbacks|
|
135
|
+
if (hook_callbacks = callbacks[hook])
|
136
|
+
hook_callbacks[view_name.to_s]&.each { |c| yield(c) }
|
137
|
+
hook_callbacks[ALWAYS]&.each { |c| yield(c) }
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
def updates_view?
|
143
|
+
false
|
144
|
+
end
|
145
|
+
|
146
|
+
private
|
147
|
+
|
148
|
+
def updates_view!
|
149
|
+
define_singleton_method(:updates_view?) { true }
|
150
|
+
end
|
151
|
+
|
152
|
+
def add_callback(hook, view_name, &block)
|
153
|
+
valid_hook!(hook)
|
154
|
+
|
155
|
+
hook_callbacks = (class_callbacks[hook] ||= {})
|
156
|
+
view_callbacks = (hook_callbacks[view_name.to_s] ||= [])
|
157
|
+
view_callbacks << block
|
158
|
+
end
|
159
|
+
|
160
|
+
def valid_hook!(hook)
|
161
|
+
unless hook.is_a?(Hook)
|
162
|
+
raise ArgumentError.new("Invalid hook: '#{hook}'")
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
def run_callback(hook, view, context, **args)
|
168
|
+
return if ineligible(view)
|
169
|
+
|
170
|
+
callback_env = hook.env_class.create(self, view, context, **args)
|
171
|
+
|
172
|
+
view_name = view.class.view_name
|
173
|
+
self.class.each_callback(hook, view_name) do |callback|
|
174
|
+
callback_env.instance_exec(&callback)
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
def ineligible(view)
|
179
|
+
# ARVM synthetic views are considered part of their association and as such
|
180
|
+
# are not visited by callbacks. Eligibility exclusion is intended to be
|
181
|
+
# library-internal: subclasses should not attempt to extend this.
|
182
|
+
view.is_a?(ViewModel::ActiveRecord) && view.class.synthetic
|
183
|
+
end
|
184
|
+
|
185
|
+
def self.wrap_serialize(viewmodel, context:)
|
186
|
+
context.run_callback(ViewModel::Callbacks::Hook::BeforeVisit, viewmodel)
|
187
|
+
val = yield
|
188
|
+
context.run_callback(ViewModel::Callbacks::Hook::AfterVisit, viewmodel)
|
189
|
+
val
|
190
|
+
end
|
191
|
+
|
192
|
+
# Record changes made in the deserialization block so that they can be
|
193
|
+
# provided to the AfterDeserialize hook.
|
194
|
+
DeserializeHookControl = Struct.new(:changes) do
|
195
|
+
alias_method :record_changes, :changes=
|
196
|
+
end
|
197
|
+
|
198
|
+
def self.wrap_deserialize(viewmodel, deserialize_context:)
|
199
|
+
hook_control = DeserializeHookControl.new
|
200
|
+
|
201
|
+
wrap_serialize(viewmodel, context: deserialize_context) do
|
202
|
+
deserialize_context.run_callback(ViewModel::Callbacks::Hook::BeforeDeserialize,
|
203
|
+
viewmodel)
|
204
|
+
|
205
|
+
val = yield(hook_control)
|
206
|
+
|
207
|
+
if hook_control.changes.nil?
|
208
|
+
raise ViewModel::DeserializationError::Internal.new(
|
209
|
+
'Internal error: changes not recorded for deserialization of viewmodel',
|
210
|
+
viewmodel.blame_reference)
|
211
|
+
end
|
212
|
+
|
213
|
+
deserialize_context.run_callback(ViewModel::Callbacks::Hook::AfterDeserialize,
|
214
|
+
viewmodel,
|
215
|
+
changes: hook_control.changes)
|
216
|
+
val
|
217
|
+
end
|
218
|
+
end
|
219
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'view_model/utils/collections'
|
4
|
+
|
5
|
+
class ViewModel::Changes
|
6
|
+
using ViewModel::Utils::Collections
|
7
|
+
|
8
|
+
attr_reader :new, :changed_attributes, :changed_associations, :changed_children, :deleted
|
9
|
+
|
10
|
+
alias new? new
|
11
|
+
alias deleted? deleted
|
12
|
+
alias changed_children? changed_children
|
13
|
+
|
14
|
+
def initialize(new: false, changed_attributes: [], changed_associations: [], changed_children: false, deleted: false)
|
15
|
+
@new = new
|
16
|
+
@changed_attributes = changed_attributes.map(&:to_s)
|
17
|
+
@changed_associations = changed_associations.map(&:to_s)
|
18
|
+
@changed_children = changed_children
|
19
|
+
@deleted = deleted
|
20
|
+
end
|
21
|
+
|
22
|
+
def contained_to?(associations: [], attributes: [])
|
23
|
+
!deleted? &&
|
24
|
+
changed_associations.all? { |assoc| associations.include?(assoc.to_s) } &&
|
25
|
+
changed_attributes.all? { |attr| attributes.include?(attr.to_s) }
|
26
|
+
end
|
27
|
+
|
28
|
+
def changed_any?(associations: [], attributes: [])
|
29
|
+
associations.any? { |assoc| changed_associations.include?(assoc.to_s) } ||
|
30
|
+
attributes.any? { |attr| changed_attributes.include?(attr.to_s) }
|
31
|
+
end
|
32
|
+
|
33
|
+
def changed?
|
34
|
+
new? || deleted? || changed_attributes.present? || changed_associations.present?
|
35
|
+
end
|
36
|
+
|
37
|
+
def changed_tree?
|
38
|
+
changed? || changed_children?
|
39
|
+
end
|
40
|
+
|
41
|
+
def to_h
|
42
|
+
{
|
43
|
+
'changed_attributes' => changed_attributes.dup,
|
44
|
+
'changed_associations' => changed_associations.dup,
|
45
|
+
'new' => new?,
|
46
|
+
'changed_children' => changed_children?,
|
47
|
+
'deleted' => deleted?,
|
48
|
+
}
|
49
|
+
end
|
50
|
+
|
51
|
+
def ==(other)
|
52
|
+
return false unless other.is_a?(ViewModel::Changes)
|
53
|
+
|
54
|
+
self.new? == other.new? &&
|
55
|
+
self.changed_children? == other.changed_children? &&
|
56
|
+
self.deleted? == other.deleted? &&
|
57
|
+
self.changed_attributes.contains_exactly?(other.changed_attributes) &&
|
58
|
+
self.changed_associations.contains_exactly?(other.changed_associations)
|
59
|
+
end
|
60
|
+
|
61
|
+
alias eql? ==
|
62
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'safe_values'
|
4
|
+
require 'keyword_builder'
|
5
|
+
|
6
|
+
ViewModel::Config = Value.new(
|
7
|
+
show_cause_in_error_view: false,
|
8
|
+
debug_deserialization: false,
|
9
|
+
)
|
10
|
+
|
11
|
+
class ViewModel::Config
|
12
|
+
def self.configure!(&block)
|
13
|
+
if instance_variable_defined?(:@instance)
|
14
|
+
raise ArgumentError.new('ViewModel library already configured')
|
15
|
+
end
|
16
|
+
|
17
|
+
builder = KeywordBuilder.create(self, constructor: :with)
|
18
|
+
@instance = builder.build!(&block)
|
19
|
+
end
|
20
|
+
|
21
|
+
def self._option(opt)
|
22
|
+
configure! unless instance_variable_defined?(:@instance)
|
23
|
+
@instance[opt]
|
24
|
+
end
|
25
|
+
|
26
|
+
self.members.each do |opt|
|
27
|
+
define_singleton_method(opt) { _option(opt) }
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,142 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "view_model"
|
4
|
+
|
5
|
+
module ViewModel::Controller
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
included do
|
9
|
+
rescue_from ViewModel::AbstractError, with: ->(ex) do
|
10
|
+
render_error(ex.view, ex.status)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def render_viewmodel(viewmodel, status: nil, serialize_context: viewmodel.class.try(:new_serialize_context), &block)
|
15
|
+
prerender = prerender_viewmodel(viewmodel, serialize_context: serialize_context, &block)
|
16
|
+
render_json_string(prerender, status: status)
|
17
|
+
end
|
18
|
+
|
19
|
+
# Render viewmodel(s) to a JSON API response as a String
|
20
|
+
def prerender_viewmodel(viewmodel, status: nil, serialize_context: viewmodel.class.try(:new_serialize_context))
|
21
|
+
Jbuilder.encode do |json|
|
22
|
+
json.data do
|
23
|
+
ViewModel.serialize(viewmodel, json, serialize_context: serialize_context)
|
24
|
+
end
|
25
|
+
|
26
|
+
if serialize_context && serialize_context.has_references?
|
27
|
+
json.references do
|
28
|
+
serialize_context.serialize_references(json)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
yield(json) if block_given?
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
# Render an arbitrarily nested tree of hashes and arrays with pre-rendered
|
37
|
+
# JSON string terminals. Useful for rendering cached views without parsing
|
38
|
+
# then re-serializing the cached JSON.
|
39
|
+
def render_json_view(json_view, json_references: {}, status: nil, &block)
|
40
|
+
prerender = prerender_json_view(json_view, json_references: json_references, &block)
|
41
|
+
render_json_string(prerender, status: status)
|
42
|
+
end
|
43
|
+
|
44
|
+
def prerender_json_view(json_view, json_references: {})
|
45
|
+
json_view = wrap_json_view(json_view)
|
46
|
+
json_references = wrap_json_view(json_references)
|
47
|
+
|
48
|
+
Jbuilder.encode do |json|
|
49
|
+
json.data json_view
|
50
|
+
if json_references.present?
|
51
|
+
json.references do
|
52
|
+
json_references.sort.each do |key, value|
|
53
|
+
json.set!(key, value)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
yield(json) if block_given?
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def render_error(error_view, status = 500)
|
62
|
+
unless error_view.is_a?(ViewModel)
|
63
|
+
raise "Expected ViewModel error view, received #{error_view.inspect}"
|
64
|
+
end
|
65
|
+
|
66
|
+
render_jbuilder(status: status) do |json|
|
67
|
+
json.error do
|
68
|
+
ctx = error_view.class.new_serialize_context(access_control: ViewModel::AccessControl::Open.new)
|
69
|
+
ViewModel.serialize(error_view, json, serialize_context: ctx)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
protected
|
75
|
+
|
76
|
+
def parse_viewmodel_updates
|
77
|
+
update_hash = _extract_update_data(params.fetch(:data))
|
78
|
+
refs = _extract_param_hash(params.fetch(:references, {}))
|
79
|
+
|
80
|
+
return update_hash, refs
|
81
|
+
end
|
82
|
+
|
83
|
+
private
|
84
|
+
|
85
|
+
def _extract_update_data(data)
|
86
|
+
if data.is_a?(Array)
|
87
|
+
if data.blank?
|
88
|
+
raise ViewModel::Error.new(status: 400, detail: "No data submitted: #{data.inspect}")
|
89
|
+
end
|
90
|
+
data.map { |el| _extract_param_hash(el) }
|
91
|
+
else
|
92
|
+
_extract_param_hash(data)
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def _extract_param_hash(data)
|
97
|
+
case data
|
98
|
+
when Hash
|
99
|
+
data
|
100
|
+
when ActionController::Parameters
|
101
|
+
data.to_unsafe_h
|
102
|
+
else
|
103
|
+
raise ViewModel::Error.new(status: 400, detail: "Invalid data submitted, expected hash: #{data.inspect}")
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def render_jbuilder(status:)
|
108
|
+
response = Jbuilder.encode do |json|
|
109
|
+
yield json
|
110
|
+
end
|
111
|
+
|
112
|
+
render_json_string(response, status: status)
|
113
|
+
end
|
114
|
+
|
115
|
+
def render_json_string(response, status: nil)
|
116
|
+
render(json: response, status: status)
|
117
|
+
end
|
118
|
+
|
119
|
+
# Wrap raw JSON in such a way that MultiJSON knows to pass it through
|
120
|
+
# untouched. Requires a MultiJson adapter other than ActiveSupport's
|
121
|
+
# (modified) JsonGem.
|
122
|
+
class CompiledJson
|
123
|
+
def initialize(s); @s = s; end
|
124
|
+
def to_json(*args); @s; end
|
125
|
+
def to_s; @s; end
|
126
|
+
undef_method :as_json
|
127
|
+
end
|
128
|
+
|
129
|
+
# Traverse a tree and wrap all String terminals in CompiledJson
|
130
|
+
def wrap_json_view(view)
|
131
|
+
case view
|
132
|
+
when Array
|
133
|
+
view.map { |v| wrap_json_view(v) }
|
134
|
+
when Hash
|
135
|
+
view.transform_values { |v| wrap_json_view(v) }
|
136
|
+
when String, Symbol
|
137
|
+
CompiledJson.new(view)
|
138
|
+
else
|
139
|
+
view
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|