iknow_view_models 2.8.4

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.
Files changed (92) hide show
  1. checksums.yaml +7 -0
  2. data/.circleci/config.yml +115 -0
  3. data/.gitignore +36 -0
  4. data/.travis.yml +31 -0
  5. data/Appraisals +9 -0
  6. data/Gemfile +19 -0
  7. data/LICENSE.txt +22 -0
  8. data/README.md +19 -0
  9. data/Rakefile +21 -0
  10. data/appveyor.yml +22 -0
  11. data/gemfiles/rails_5_2.gemfile +15 -0
  12. data/gemfiles/rails_6_0_beta.gemfile +15 -0
  13. data/iknow_view_models.gemspec +49 -0
  14. data/lib/iknow_view_models.rb +12 -0
  15. data/lib/iknow_view_models/railtie.rb +8 -0
  16. data/lib/iknow_view_models/version.rb +5 -0
  17. data/lib/view_model.rb +333 -0
  18. data/lib/view_model/access_control.rb +154 -0
  19. data/lib/view_model/access_control/composed.rb +216 -0
  20. data/lib/view_model/access_control/open.rb +13 -0
  21. data/lib/view_model/access_control/read_only.rb +13 -0
  22. data/lib/view_model/access_control/tree.rb +264 -0
  23. data/lib/view_model/access_control_error.rb +10 -0
  24. data/lib/view_model/active_record.rb +383 -0
  25. data/lib/view_model/active_record/association_data.rb +178 -0
  26. data/lib/view_model/active_record/association_manipulation.rb +389 -0
  27. data/lib/view_model/active_record/cache.rb +265 -0
  28. data/lib/view_model/active_record/cache/cacheable_view.rb +51 -0
  29. data/lib/view_model/active_record/cloner.rb +113 -0
  30. data/lib/view_model/active_record/collection_nested_controller.rb +100 -0
  31. data/lib/view_model/active_record/controller.rb +77 -0
  32. data/lib/view_model/active_record/controller_base.rb +185 -0
  33. data/lib/view_model/active_record/nested_controller_base.rb +93 -0
  34. data/lib/view_model/active_record/singular_nested_controller.rb +34 -0
  35. data/lib/view_model/active_record/update_context.rb +252 -0
  36. data/lib/view_model/active_record/update_data.rb +749 -0
  37. data/lib/view_model/active_record/update_operation.rb +810 -0
  38. data/lib/view_model/active_record/visitor.rb +77 -0
  39. data/lib/view_model/after_transaction_runner.rb +29 -0
  40. data/lib/view_model/callbacks.rb +219 -0
  41. data/lib/view_model/changes.rb +62 -0
  42. data/lib/view_model/config.rb +29 -0
  43. data/lib/view_model/controller.rb +142 -0
  44. data/lib/view_model/deserialization_error.rb +437 -0
  45. data/lib/view_model/deserialize_context.rb +16 -0
  46. data/lib/view_model/error.rb +191 -0
  47. data/lib/view_model/error_view.rb +35 -0
  48. data/lib/view_model/record.rb +367 -0
  49. data/lib/view_model/record/attribute_data.rb +48 -0
  50. data/lib/view_model/reference.rb +31 -0
  51. data/lib/view_model/references.rb +48 -0
  52. data/lib/view_model/registry.rb +73 -0
  53. data/lib/view_model/schemas.rb +45 -0
  54. data/lib/view_model/serialization_error.rb +10 -0
  55. data/lib/view_model/serialize_context.rb +118 -0
  56. data/lib/view_model/test_helpers.rb +103 -0
  57. data/lib/view_model/test_helpers/arvm_builder.rb +111 -0
  58. data/lib/view_model/traversal_context.rb +126 -0
  59. data/lib/view_model/utils.rb +24 -0
  60. data/lib/view_model/utils/collections.rb +49 -0
  61. data/test/helpers/arvm_test_models.rb +59 -0
  62. data/test/helpers/arvm_test_utilities.rb +187 -0
  63. data/test/helpers/callback_tracer.rb +27 -0
  64. data/test/helpers/controller_test_helpers.rb +270 -0
  65. data/test/helpers/match_enumerator.rb +58 -0
  66. data/test/helpers/query_logging.rb +71 -0
  67. data/test/helpers/test_access_control.rb +56 -0
  68. data/test/helpers/viewmodel_spec_helpers.rb +326 -0
  69. data/test/unit/view_model/access_control_test.rb +769 -0
  70. data/test/unit/view_model/active_record/alias_test.rb +35 -0
  71. data/test/unit/view_model/active_record/belongs_to_test.rb +376 -0
  72. data/test/unit/view_model/active_record/cache_test.rb +351 -0
  73. data/test/unit/view_model/active_record/cloner_test.rb +313 -0
  74. data/test/unit/view_model/active_record/controller_test.rb +561 -0
  75. data/test/unit/view_model/active_record/counter_test.rb +80 -0
  76. data/test/unit/view_model/active_record/customization_test.rb +388 -0
  77. data/test/unit/view_model/active_record/has_many_test.rb +957 -0
  78. data/test/unit/view_model/active_record/has_many_through_poly_test.rb +269 -0
  79. data/test/unit/view_model/active_record/has_many_through_test.rb +736 -0
  80. data/test/unit/view_model/active_record/has_one_test.rb +334 -0
  81. data/test/unit/view_model/active_record/namespacing_test.rb +75 -0
  82. data/test/unit/view_model/active_record/optional_attribute_view_test.rb +58 -0
  83. data/test/unit/view_model/active_record/poly_test.rb +320 -0
  84. data/test/unit/view_model/active_record/shared_test.rb +285 -0
  85. data/test/unit/view_model/active_record/version_test.rb +121 -0
  86. data/test/unit/view_model/active_record_test.rb +542 -0
  87. data/test/unit/view_model/callbacks_test.rb +582 -0
  88. data/test/unit/view_model/deserialization_error/unique_violation_test.rb +73 -0
  89. data/test/unit/view_model/record_test.rb +524 -0
  90. data/test/unit/view_model/traversal_context_test.rb +371 -0
  91. data/test/unit/view_model_test.rb +62 -0
  92. 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