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,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'view_model/after_transaction_runner'
4
+
5
+ # Concern providing caching configuration and lookup on viewmodels.
6
+ module ViewModel::ActiveRecord::Cache::CacheableView
7
+ extend ActiveSupport::Concern
8
+
9
+ # Callback handler to participate in ActiveRecord::ConnectionAdapters::
10
+ # Transaction callbacks: invalidates a given cache member after the current
11
+ # transaction commits.
12
+ CacheClearer = Struct.new(:cache, :id) do
13
+ include ViewModel::AfterTransactionRunner
14
+
15
+ def after_transaction
16
+ cache.delete(id)
17
+ end
18
+
19
+ def connection
20
+ cache.viewmodel_class.model_class.connection
21
+ end
22
+ end
23
+
24
+ class_methods do
25
+ def create_viewmodel_cache!(**opts)
26
+ @viewmodel_cache = ViewModel::ActiveRecord::Cache.new(self, **opts)
27
+ end
28
+
29
+ def viewmodel_cache
30
+ @viewmodel_cache
31
+ end
32
+
33
+ def serialize_from_cache(views, serialize_context:)
34
+ plural = views.is_a?(Array)
35
+ views = Array.wrap(views)
36
+ json_views, json_refs = viewmodel_cache.fetch_by_viewmodel(views, serialize_context: serialize_context)
37
+ json_views = json_views.first unless plural
38
+ return json_views, json_refs
39
+ end
40
+ end
41
+
42
+ # Clear the cache if the view or its owned children were changed during
43
+ # deserialization
44
+ def after_deserialize(deserialize_context:, changes:)
45
+ super if defined?(super)
46
+
47
+ if !changes.new? && changes.changed_tree?
48
+ CacheClearer.new(self.class.viewmodel_cache, id).add_to_transaction
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,113 @@
1
+ # Simple visitor for cloning models through the tree structure defined by
2
+ # ViewModel::ActiveRecord. Owned associations will be followed and cloned, while
3
+ # shared associations will be copied directly. Attributes (including association
4
+ # foreign keys not covered by ViewModel `association`s) will be copied from the
5
+ # original.
6
+ #
7
+ # To customize, subclasses may define methods `visit_x_view(node, new_model)`
8
+ # for each type they wish to affect. These callbacks may update attributes of
9
+ # the new model, and additionally can call `ignore!` or
10
+ # `ignore_association!(name)` to prune the current model or the target of the
11
+ # named association from the cloned tree.
12
+ class ViewModel::ActiveRecord::Cloner
13
+ def clone(node)
14
+ reset_state!
15
+
16
+ new_model = node.model.dup
17
+
18
+ pre_visit(node, new_model)
19
+ return nil if ignored?
20
+
21
+ if node.class.name
22
+ class_name = node.class.name.underscore.gsub('/', '__')
23
+ visit = :"visit_#{class_name}"
24
+ end_visit = :"end_visit_#{class_name}"
25
+ end
26
+
27
+ if visit && respond_to?(visit, true)
28
+ self.send(visit, node, new_model)
29
+ return nil if ignored?
30
+ end
31
+
32
+ # visit the underlying viewmodel for each association, ignoring any
33
+ # customization
34
+ node.class._members.each do |name, association_data|
35
+ next unless association_data.is_a?(ViewModel::ActiveRecord::AssociationData)
36
+
37
+ reflection = association_data.direct_reflection
38
+
39
+ if association_ignored?(name)
40
+ new_associated = nil
41
+ else
42
+ # Load the record associated with the old model
43
+ associated = node.model.public_send(reflection.name)
44
+
45
+ if associated.nil?
46
+ new_associated = nil
47
+ elsif association_data.shared? && !association_data.through?
48
+ # simply attach the associated target to the new model
49
+ new_associated = associated
50
+ else
51
+ # Otherwise descend into the child, and attach the result
52
+ vm_class =
53
+ case
54
+ when association_data.through?
55
+ # descend into the synthetic join table viewmodel
56
+ association_data.direct_viewmodel
57
+ when association_data.collection?
58
+ association_data.viewmodel_class
59
+ else
60
+ association_data.viewmodel_class_for_model!(associated.class)
61
+ end
62
+
63
+ new_associated =
64
+ if ViewModel::Utils.array_like?(associated)
65
+ associated.map { |m| clone(vm_class.new(m)) }.compact
66
+ else
67
+ clone(vm_class.new(associated))
68
+ end
69
+ end
70
+ end
71
+
72
+ new_association = new_model.association(reflection.name)
73
+ new_association.writer(new_associated)
74
+ end
75
+
76
+ if end_visit && respond_to?(end_visit, true)
77
+ self.send(end_visit, node, new_model)
78
+ end
79
+
80
+ post_visit(node, new_model)
81
+
82
+ new_model
83
+ end
84
+
85
+ def pre_visit(node, new_model)
86
+ end
87
+
88
+ def post_visit(node, new_model)
89
+ end
90
+
91
+ private
92
+
93
+ def reset_state!
94
+ @ignored = false
95
+ @ignored_associations = Set.new
96
+ end
97
+
98
+ def ignore!
99
+ @ignored = true
100
+ end
101
+
102
+ def ignore_association!(name)
103
+ @ignored_associations.add(name.to_s)
104
+ end
105
+
106
+ def ignored?
107
+ @ignored
108
+ end
109
+
110
+ def association_ignored?(name)
111
+ @ignored_associations.include?(name.to_s)
112
+ end
113
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'view_model/active_record/nested_controller_base'
4
+
5
+ # Controller mixin for accessing a root ViewModel which can be accessed in a
6
+ # collection by a parent model. Enabled by calling `nested_in :parent, as:
7
+ # :children` on the viewmodel controller
8
+
9
+ # Contributes the following routes:
10
+ # PUT /parents/:parent_id/children #append -- deserialize (possibly existing) children and append to collection
11
+ # POST /parents/:parent_id/children #replace -- deserialize (possibly existing) children, replacing existing collection
12
+ # GET /parents/:parent_id/children #index_associated -- list collection
13
+ # DELETE /parents/:parent_id/children/:child_id #disassociate -- delete relationship between parent/child (possibly garbage-collecting child)
14
+ # DELETE /parents/:parent_id/children #disassociate_all -- delete relationship from parent to all children
15
+
16
+ ## Inherits the following routes to manipulate children directly:
17
+ # POST /children #create -- create or update without parent
18
+ # GET /children #index -- list all child models regardless of parent
19
+ # GET /children/:id #show
20
+ # DELETE /children/:id #destroy
21
+ module ViewModel::ActiveRecord::CollectionNestedController
22
+ extend ActiveSupport::Concern
23
+ include ViewModel::ActiveRecord::NestedControllerBase
24
+
25
+ def index_associated(scope: nil, serialize_context: new_serialize_context)
26
+ show_association(scope: scope, serialize_context: serialize_context)
27
+ end
28
+
29
+ # Deserialize items of the associated type and associate them with the owner,
30
+ # replacing previously associated items.
31
+ def replace(serialize_context: new_serialize_context, deserialize_context: new_deserialize_context)
32
+ write_association(serialize_context: serialize_context, deserialize_context: deserialize_context)
33
+ end
34
+
35
+ def disassociate_all(serialize_context: new_serialize_context, deserialize_context: new_deserialize_context)
36
+ destroy_association(true, serialize_context: serialize_context, deserialize_context: deserialize_context)
37
+ end
38
+
39
+ # Deserialize items of the associated type and append them to the owner's
40
+ # collection.
41
+ def append(serialize_context: new_serialize_context, deserialize_context: new_deserialize_context)
42
+ assoc_view = nil
43
+ pre_rendered = owner_viewmodel.transaction do
44
+ update_hash, refs = parse_viewmodel_updates
45
+
46
+ before = parse_relative_position(:before)
47
+ after = parse_relative_position(:after)
48
+
49
+ if before && after
50
+ raise ViewModel::DeserializationError::InvalidSyntax.new("Can not provide both `before` and `after` anchors for a collection append")
51
+ end
52
+
53
+ owner_view = owner_viewmodel.find(owner_viewmodel_id, eager_include: false, serialize_context: serialize_context)
54
+
55
+ assoc_view = owner_view.append_associated(association_name,
56
+ update_hash,
57
+ references: refs,
58
+ before: before,
59
+ after: after,
60
+ deserialize_context: deserialize_context)
61
+ ViewModel::Callbacks.wrap_serialize(owner_view, context: serialize_context) do
62
+ child_context = owner_view.context_for_child(association_name, context: serialize_context)
63
+ ViewModel.preload_for_serialization(assoc_view, serialize_context: child_context)
64
+ prerender_viewmodel(assoc_view, serialize_context: child_context)
65
+ end
66
+ end
67
+ render_json_string(pre_rendered)
68
+ assoc_view
69
+ end
70
+
71
+ def disassociate(serialize_context: new_serialize_context, deserialize_context: new_deserialize_context)
72
+ owner_viewmodel.transaction do
73
+ owner_view = owner_viewmodel.find(owner_viewmodel_id, eager_include: false, serialize_context: serialize_context)
74
+ owner_view.delete_associated(association_name, viewmodel_id, type: viewmodel_class, deserialize_context: deserialize_context)
75
+ render_viewmodel(nil)
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def parse_relative_position(name)
82
+ id = parse_uuid_param(name, default: nil)
83
+
84
+ if id
85
+ if association_data.polymorphic?
86
+ type_name = parse_param("#{name}_type")
87
+ type = association_data.viewmodel_class_for_name(type_name)
88
+ if type.nil?
89
+ raise ViewModel::DeserializationError::InvalidAssociationType.new(association_data.association_name.to_s, type_name)
90
+ end
91
+ else
92
+ type = owner_viewmodel.viewmodel_class
93
+ end
94
+
95
+ ViewModel::Reference.new(type, id)
96
+ else
97
+ nil
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'view_model/active_record/controller_base'
4
+ require 'view_model/active_record/collection_nested_controller'
5
+ require 'view_model/active_record/singular_nested_controller'
6
+
7
+ # Controller for accessing an ViewModel::ActiveRecord
8
+ # Provides for the following routes:
9
+ # POST /models #create -- create or update one or more models
10
+ # GET /models #index
11
+ # GET /models/:id #show
12
+ # DELETE /models/:id #destroy
13
+
14
+ module ViewModel::ActiveRecord::Controller
15
+ extend ActiveSupport::Concern
16
+ include ViewModel::ActiveRecord::ControllerBase
17
+ include ViewModel::ActiveRecord::CollectionNestedController
18
+ include ViewModel::ActiveRecord::SingularNestedController
19
+
20
+ def show(scope: nil, viewmodel_class: self.viewmodel_class, serialize_context: new_serialize_context(viewmodel_class: viewmodel_class))
21
+ view = nil
22
+ pre_rendered = viewmodel_class.transaction do
23
+ view = viewmodel_class.find(viewmodel_id, scope: scope, serialize_context: serialize_context)
24
+ view = yield(view) if block_given?
25
+ prerender_viewmodel(view, serialize_context: serialize_context)
26
+ end
27
+ render_json_string(pre_rendered)
28
+ view
29
+ end
30
+
31
+ def index(scope: nil, viewmodel_class: self.viewmodel_class, serialize_context: new_serialize_context(viewmodel_class: viewmodel_class))
32
+ views = nil
33
+ pre_rendered = viewmodel_class.transaction do
34
+ views = viewmodel_class.load(scope: scope, serialize_context: serialize_context)
35
+ views = yield(views) if block_given?
36
+ prerender_viewmodel(views, serialize_context: serialize_context)
37
+ end
38
+ render_json_string(pre_rendered)
39
+ views
40
+ end
41
+
42
+ def create(serialize_context: new_serialize_context, deserialize_context: new_deserialize_context)
43
+ update_hash, refs = parse_viewmodel_updates
44
+
45
+ view = nil
46
+ pre_rendered = viewmodel_class.transaction do
47
+ view = viewmodel_class.deserialize_from_view(update_hash, references: refs, deserialize_context: deserialize_context)
48
+
49
+ serialize_context.add_includes(deserialize_context.updated_associations)
50
+
51
+ view = yield(view) if block_given?
52
+
53
+ ViewModel.preload_for_serialization(view, serialize_context: serialize_context)
54
+ prerender_viewmodel(view, serialize_context: serialize_context)
55
+ end
56
+ render_json_string(pre_rendered)
57
+ view
58
+ end
59
+
60
+ def destroy(serialize_context: new_serialize_context, deserialize_context: new_deserialize_context)
61
+ viewmodel_class.transaction do
62
+ view = viewmodel_class.find(viewmodel_id, eager_include: false, serialize_context: serialize_context)
63
+ view.destroy!(deserialize_context: deserialize_context)
64
+ end
65
+ render_viewmodel(nil)
66
+ end
67
+
68
+ included do
69
+ etag { self.viewmodel_class.deep_schema_version }
70
+ end
71
+
72
+ private
73
+
74
+ def viewmodel_id
75
+ parse_param(:id)
76
+ end
77
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'iknow_params'
4
+
5
+ module ViewModel::ActiveRecord::ControllerBase
6
+ extend ActiveSupport::Concern
7
+ include IknowParams::Parser
8
+ include ViewModel::Controller
9
+
10
+ protected
11
+
12
+ # Override (pre)render_viewmodel to use the default serialization context from this controller.
13
+ def render_viewmodel(viewmodel, serialize_context: new_serialize_context, **args)
14
+ super(viewmodel, serialize_context: serialize_context, **args)
15
+ end
16
+
17
+ def prerender_viewmodel(viewmodel, serialize_context: new_serialize_context, **args)
18
+ super(viewmodel, serialize_context: serialize_context, **args)
19
+ end
20
+
21
+ def new_deserialize_context(viewmodel_class: self.viewmodel_class, access_control: self.access_control.new, **args)
22
+ viewmodel_class.new_deserialize_context(access_control: access_control, **args)
23
+ end
24
+
25
+ def new_serialize_context(viewmodel_class: self.viewmodel_class, access_control: self.access_control.new, **args)
26
+ viewmodel_class.new_serialize_context(access_control: access_control, **args)
27
+ end
28
+
29
+ class_methods do
30
+ def viewmodel_class
31
+ unless instance_variable_defined?(:@viewmodel_class)
32
+ # try to autodetect the viewmodel based on our name
33
+ if (match = /(.*)Controller$/.match(self.name))
34
+ self.viewmodel_name = match[1].singularize
35
+ else
36
+ raise ArgumentError.new("Could not auto-determine ViewModel from Controller name '#{self.name}'") if match.nil?
37
+ end
38
+ end
39
+ @viewmodel_class
40
+ end
41
+
42
+ def access_control
43
+ unless instance_variable_defined?(:@access_control)
44
+ raise ArgumentError.new("AccessControl instance not set for Controller '#{self.name}'")
45
+ end
46
+ @access_control
47
+ end
48
+
49
+ def model_class
50
+ viewmodel_class.model_class
51
+ end
52
+
53
+ protected
54
+
55
+ def viewmodel_name=(name)
56
+ self.viewmodel_class = ViewModel::Registry.for_view_name(name)
57
+ end
58
+
59
+ def viewmodel_class=(type)
60
+ if instance_variable_defined?(:@viewmodel_class)
61
+ raise ArgumentError.new("ViewModel class for Controller '#{self.name}' already set")
62
+ end
63
+
64
+ unless type < ViewModel
65
+ raise ArgumentError.new("'#{type.inspect}' is not a valid ViewModel")
66
+ end
67
+ @viewmodel_class = type
68
+ end
69
+
70
+ def access_control=(access_control)
71
+ if instance_variable_defined?(:@access_control)
72
+ raise ArgumentError.new("AccessControl class for Controller '#{self.name}' already set")
73
+ end
74
+
75
+ unless access_control.is_a?(Class) && access_control < ViewModel::AccessControl
76
+ raise ArgumentError.new("'#{access_control.inspect}' is not a valid AccessControl")
77
+ end
78
+ @access_control = access_control
79
+ end
80
+ end
81
+
82
+ def viewmodel_class
83
+ self.class.viewmodel_class
84
+ end
85
+
86
+ def model_class
87
+ self.class.model_class
88
+ end
89
+
90
+ def access_control
91
+ self.class.access_control
92
+ end
93
+ end
94
+
95
+ module ActionDispatch
96
+ module Routing
97
+ class Mapper
98
+ module Resources
99
+ def arvm_resources(resource_name, options = {}, &block)
100
+ except = options.delete(:except) { [] }
101
+ add_shallow_routes = options.delete(:add_shallow_routes) { true }
102
+
103
+ nested = shallow_nesting_depth > 0
104
+
105
+ only_routes = []
106
+ only_routes += [:create] unless nested
107
+ only_routes += [:show, :destroy] if add_shallow_routes
108
+ only_routes -= except
109
+
110
+ resources resource_name, shallow: true, only: only_routes, **options do
111
+ instance_eval(&block) if block_given?
112
+
113
+ if nested
114
+ # Nested controllers also get :append and :disassociate, and alias a top level create.
115
+ collection do
116
+ name_route = { as: '' } # Only one route may take the name
117
+ get('', action: :index_associated, **name_route.extract!(:as)) unless except.include?(:index)
118
+ put('', action: :append, **name_route.extract!(:as)) unless except.include?(:append)
119
+ post('', action: :replace, **name_route.extract!(:as)) unless except.include?(:replace)
120
+ delete('', action: :disassociate_all, **name_route.extract!(:as)) unless except.include?(:disassociate_all)
121
+ end
122
+
123
+ scope shallow: false do
124
+ member do
125
+ delete '', action: :disassociate, as: '' unless except.include?(:disassociate)
126
+ end
127
+ end
128
+
129
+ # Add top level `create` route to manipulate existing viewmodels
130
+ # without providing parent context
131
+ shallow_scope do
132
+ collection do
133
+ name_route = { as: '' } # Only one route may take the name
134
+ post('', action: :create, **name_route.extract!(:as)) unless except.include?(:create) || !add_shallow_routes
135
+ get('', action: :index, **name_route.extract!(:as)) unless except.include?(:index) || !add_shallow_routes
136
+ end
137
+ end
138
+ else
139
+ collection do
140
+ get('', action: :index, as: '') unless except.include?(:index)
141
+ end
142
+ end
143
+ end
144
+ end
145
+
146
+ def arvm_resource(resource_name, options = {}, &block)
147
+ except = options.delete(:except) { [] }
148
+ add_shallow_routes = options.delete(:add_shallow_routes) { true }
149
+
150
+ only_routes = []
151
+ is_shallow = false
152
+ resource resource_name, shallow: true, only: only_routes, **options do
153
+ is_shallow = shallow_nesting_depth > 1
154
+ instance_eval(&block) if block_given?
155
+
156
+ name_route = { as: '' } # Only one route may take the name
157
+
158
+ if is_shallow
159
+ post('', action: :create_associated, **name_route.extract!(:as)) unless except.include?(:create)
160
+ get('', action: :show_associated, **name_route.extract!(:as)) unless except.include?(:show)
161
+ delete('', action: :destroy_associated, **name_route.extract!(:as)) unless except.include?(:destroy)
162
+ else
163
+ post('', action: :create, **name_route.extract!(:as)) unless except.include?(:create)
164
+ get('', action: :show, **name_route.extract!(:as)) unless except.include?(:show)
165
+ delete('', action: :destroy, **name_route.extract!(:as)) unless except.include?(:destroy)
166
+ end
167
+ end
168
+
169
+ # singularly nested resources provide collection accessors at the top level
170
+ if is_shallow && add_shallow_routes
171
+ resources resource_name.to_s.pluralize, shallow: true, only: [:show, :destroy] - except do
172
+ shallow_scope do
173
+ collection do
174
+ name_route = { as: '' } # Only one route may take the name
175
+ post('', action: :create, **name_route.extract!(:as)) unless except.include?(:create)
176
+ get('', action: :index, **name_route.extract!(:as)) unless except.include?(:index)
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end