iknow_view_models 2.8.4

Sign up to get free protection for your applications and to get access to all the features.
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