forest_liana 9.20.13 → 9.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/forest_liana/application_controller.rb +111 -5
  3. data/app/controllers/forest_liana/associations_controller.rb +108 -7
  4. data/app/controllers/forest_liana/base_controller.rb +24 -5
  5. data/app/controllers/forest_liana/capabilities_controller.rb +13 -0
  6. data/app/controllers/forest_liana/resources_controller.rb +135 -22
  7. data/app/controllers/forest_liana/smart_actions_controller.rb +3 -0
  8. data/app/deserializers/forest_liana/resource_deserializer.rb +5 -1
  9. data/app/models/forest_liana/model/collection.rb +47 -1
  10. data/app/serializers/forest_liana/missing_attribute_valve.rb +84 -0
  11. data/app/serializers/forest_liana/serializer_factory.rb +3 -4
  12. data/app/services/forest_liana/ability/exceptions/approval_selection_too_large.rb +17 -0
  13. data/app/services/forest_liana/ability/exceptions/require_approval.rb +3 -2
  14. data/app/services/forest_liana/ability/exceptions/unauthorized_fields_error.rb +44 -0
  15. data/app/services/forest_liana/ability/exceptions/unauthorized_query_field_error.rb +21 -0
  16. data/app/services/forest_liana/ability/exceptions/unexposed_query_collection_error.rb +28 -0
  17. data/app/services/forest_liana/ability/fetch.rb +1 -1
  18. data/app/services/forest_liana/ability/permission/smart_action_checker.rb +39 -2
  19. data/app/services/forest_liana/ability/permission.rb +248 -21
  20. data/app/services/forest_liana/base_getter.rb +476 -1
  21. data/app/services/forest_liana/belongs_to_updater.rb +8 -0
  22. data/app/services/forest_liana/capabilities_getter.rb +162 -0
  23. data/app/services/forest_liana/field_path.rb +39 -0
  24. data/app/services/forest_liana/filters_parser.rb +38 -1
  25. data/app/services/forest_liana/has_many_associator.rb +9 -0
  26. data/app/services/forest_liana/has_many_dissociator.rb +19 -0
  27. data/app/services/forest_liana/has_many_getter.rb +62 -7
  28. data/app/services/forest_liana/projection_parser.rb +68 -0
  29. data/app/services/forest_liana/resource_getter.rb +48 -2
  30. data/app/services/forest_liana/resources_getter.rb +88 -212
  31. data/app/services/forest_liana/schema_adapter.rb +2 -3
  32. data/app/services/forest_liana/scope_manager.rb +12 -0
  33. data/app/services/forest_liana/search_query_builder.rb +173 -45
  34. data/app/services/forest_liana/smart_field_dependencies.rb +82 -0
  35. data/app/services/forest_liana/utils/context_variables_injector.rb +9 -1
  36. data/config/initializers/errors.rb +16 -0
  37. data/config/routes.rb +3 -0
  38. data/lib/forest_liana/bootstrapper.rb +22 -0
  39. data/lib/forest_liana/collection.rb +47 -0
  40. data/lib/forest_liana/engine.rb +4 -2
  41. data/lib/forest_liana/version.rb +1 -1
  42. data/lib/forest_liana.rb +9 -0
  43. data/spec/deserializers/forest_liana/resource_deserializer_spec.rb +43 -0
  44. data/spec/dummy/app/models/article.rb +3 -0
  45. data/spec/dummy/app/models/flag.rb +3 -0
  46. data/spec/dummy/app/models/island.rb +3 -0
  47. data/spec/dummy/app/models/membership.rb +4 -0
  48. data/spec/dummy/app/models/owner.rb +9 -0
  49. data/spec/dummy/app/models/town.rb +5 -0
  50. data/spec/dummy/app/models/user.rb +9 -1
  51. data/spec/dummy/config/database.yml +14 -9
  52. data/spec/dummy/config/environments/test.rb +3 -2
  53. data/spec/dummy/config/initializers/forest_liana.rb +4 -0
  54. data/spec/dummy/db/garage_schema.rb +107 -0
  55. data/spec/dummy/db/migrate/20220719094127_create_cars.rb +6 -0
  56. data/spec/dummy/db/migrate/20220719094450_create_drivers.rb +3 -0
  57. data/spec/dummy/db/migrate/20260909120000_create_memberships.rb +10 -0
  58. data/spec/dummy/db/migrate/20260909130000_create_flags.rb +10 -0
  59. data/spec/dummy/db/migrate/20260914120000_create_articles_and_taggings.rb +34 -0
  60. data/spec/dummy/db/migrate/20260922120000_create_trees_users.rb +7 -0
  61. data/spec/dummy/db/schema.rb +56 -1
  62. data/spec/dummy/db/user_schema.rb +105 -0
  63. data/spec/dummy/lib/forest_liana/collections/address.rb +1 -1
  64. data/spec/dummy/lib/forest_liana/collections/location.rb +7 -0
  65. data/spec/dummy/lib/forest_liana/collections/owner.rb +18 -0
  66. data/spec/dummy/lib/forest_liana/collections/product.rb +5 -0
  67. data/spec/dummy/lib/forest_liana/collections/tree.rb +46 -0
  68. data/spec/dummy/lib/forest_liana/collections/user.rb +20 -1
  69. data/spec/lib/forest_liana/bootstrapper_spec.rb +60 -3
  70. data/spec/lib/forest_liana/collection_spec.rb +86 -1
  71. data/spec/models/forest_liana/model/collection_spec.rb +79 -0
  72. data/spec/rails_helper.rb +13 -1
  73. data/spec/requests/actions_controller_spec.rb +5 -1
  74. data/spec/requests/associations_spec.rb +669 -0
  75. data/spec/requests/capabilities_spec.rb +301 -0
  76. data/spec/requests/cors_spec.rb +6 -0
  77. data/spec/requests/count_spec.rb +82 -0
  78. data/spec/requests/missing_attribute_valve_spec.rb +161 -0
  79. data/spec/requests/projection_header_spec.rb +602 -0
  80. data/spec/requests/projection_inherited_loads_spec.rb +140 -0
  81. data/spec/requests/query_footprint_spec.rb +549 -0
  82. data/spec/requests/resources_spec.rb +549 -13
  83. data/spec/requests/sti_record_conversion_spec.rb +79 -0
  84. data/spec/serializers/forest_liana/missing_attribute_valve_spec.rb +26 -0
  85. data/spec/services/forest_liana/ability/assert_can_read_query_fields_spec.rb +391 -0
  86. data/spec/services/forest_liana/ability/permission/smart_action_checker_spec.rb +132 -0
  87. data/spec/services/forest_liana/ability/permission_spec.rb +45 -2
  88. data/spec/services/forest_liana/ability/redact_fields_spec.rb +495 -0
  89. data/spec/services/forest_liana/apimap_sorter_spec.rb +20 -0
  90. data/spec/services/forest_liana/base_getter_spec.rb +227 -0
  91. data/spec/services/forest_liana/belongs_to_updater_spec.rb +35 -0
  92. data/spec/services/forest_liana/field_path_spec.rb +142 -0
  93. data/spec/services/forest_liana/filters_parser_spec.rb +103 -0
  94. data/spec/services/forest_liana/has_many_associator_spec.rb +26 -0
  95. data/spec/services/forest_liana/has_many_dissociator_spec.rb +76 -0
  96. data/spec/services/forest_liana/has_many_getter_pagination_spec.rb +39 -0
  97. data/spec/services/forest_liana/has_many_getter_spec.rb +49 -0
  98. data/spec/services/forest_liana/projection_parser_spec.rb +82 -0
  99. data/spec/services/forest_liana/resources_getter_spec.rb +128 -4
  100. data/spec/services/forest_liana/schema_adapter_default_values_spec.rb +8 -2
  101. data/spec/services/forest_liana/schema_adapter_spec.rb +9 -3
  102. data/spec/services/forest_liana/search_query_builder_spec.rb +573 -1
  103. data/spec/services/forest_liana/smart_field_dependencies_spec.rb +147 -0
  104. data/spec/support/forest_liana/query_capture.rb +85 -0
  105. data/spec/support/forest_liana/query_capture_spec.rb +119 -0
  106. data/test/dummy/app/models/has_one_field.rb +6 -1
  107. data/test/dummy/app/models/serialize_field.rb +6 -1
  108. data/test/dummy/config/environments/test.rb +3 -2
  109. data/test/test_helper.rb +4 -1
  110. metadata +78 -8
  111. data/spec/dummy/app/config/routes.rb +0 -3
  112. data/spec/dummy/config/initializers/assets.rb +0 -11
  113. data/test/dummy/config/initializers/assets.rb +0 -11
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3e53d6922c4422fed246ccc6d625ac21668788f962873e6b04e83689684e9a39
4
- data.tar.gz: 63d031e91c573ec7413d82bfa0c718b3b61736d6aa6583efd5a7dc848e7baf1b
3
+ metadata.gz: 6042deb02adc0ae0d73c97cac99e2abdf0807cdf759cfd0cde116bf94fdc3dff
4
+ data.tar.gz: 351c37c6fd6cfc17e9c4de3c8f760b84527ca7c1d7a0e2df6976b67b8d549329
5
5
  SHA512:
6
- metadata.gz: 3a56ce38a01bbebe42696a893bf6e50e762135c8b0579ac488e578468469a3a381963bee3ed1a9c5efb5c73b168aaf80bcfe410ec2f90c25ac80235c29799778
7
- data.tar.gz: f93ccc8fcd6cfdbb189e623a3cd3095d79bbd8fd94a70cda8d0cf263476ea6fe3226617b8ebffc3b14a804153f975dddd6903a9791b7c75e85da5e15c72a3b91
6
+ metadata.gz: 7a952e5b05d2c1491f74b77a53e16012d89419af19040e6168488e000afba0651ebea4f3d7cb21b1ef3045598be8317c30930f7a1e6a681fd5458b8ce956672f
7
+ data.tar.gz: 9884f0447c34ccb867abf7a2b41326229391e6a3c2689ad9884a15d09ac0ad067ba3a7ebfb30df13cab9502524ef29cd69de941779bfd3185cd385a893261960
@@ -4,10 +4,24 @@ require 'csv'
4
4
  module ForestLiana
5
5
  class ApplicationController < ForestLiana::BaseController
6
6
  rescue_from ForestLiana::Ability::Exceptions::AccessDenied, with: :render_error
7
+ rescue_from ForestLiana::Errors::HTTP400Error, with: :render_error
7
8
  rescue_from ForestLiana::Errors::HTTP403Error, with: :render_error
8
9
  rescue_from ForestLiana::Errors::HTTP422Error, with: :render_error
10
+ rescue_from ForestLiana::Errors::PermissionsUnavailableError, with: :render_error
9
11
  rescue_from ForestLiana::Ability::Exceptions::ActionConditionError, with: :render_error
10
12
  rescue_from ForestLiana::Ability::Exceptions::UnknownCollection, with: :render_error
13
+ rescue_from ForestLiana::Ability::Exceptions::UnauthorizedFieldsError, with: :render_error
14
+ rescue_from ForestLiana::Ability::Exceptions::UnauthorizedQueryFieldError, with: :render_error
15
+ rescue_from ForestLiana::Ability::Exceptions::UnexposedQueryCollectionError, with: :render_error
16
+
17
+ # `render_error` above already gives these their 403 + name/data payload — every list here is
18
+ # for an action whose own `begin/rescue` would otherwise let a bare `rescue => error` swallow
19
+ # them into a 500 first. Those actions catch this list and re-`raise`, letting it reach here.
20
+ QUERY_PERMISSION_ERRORS = [
21
+ ForestLiana::Ability::Exceptions::UnauthorizedFieldsError,
22
+ ForestLiana::Ability::Exceptions::UnauthorizedQueryFieldError,
23
+ ForestLiana::Ability::Exceptions::UnexposedQueryCollectionError,
24
+ ].freeze
11
25
 
12
26
  def self.papertrail?
13
27
  Object.const_get('PaperTrail::Version').is_a?(Class) rescue false
@@ -40,7 +54,7 @@ module ForestLiana
40
54
 
41
55
  def serialize_model(record, options = {})
42
56
  options[:is_collection] = false
43
- options[:context] = { unoptimized: true }.merge(options[:context] || {})
57
+ options[:context] = { unoptimized: options[:fields].nil? }.merge(options[:context] || {})
44
58
 
45
59
  json = ForestAdmin::JSONAPI::Serializer.serialize(record, options)
46
60
 
@@ -49,7 +63,7 @@ module ForestLiana
49
63
 
50
64
  def serialize_models(records, options = {}, fields_searched = [])
51
65
  options[:is_collection] = true
52
- if options[:params] && options[:params][:fields].nil?
66
+ if options[:fields].nil?
53
67
  options[:context] = { unoptimized: true }.merge(options[:context] || {})
54
68
  end
55
69
 
@@ -110,9 +124,88 @@ module ForestLiana
110
124
  render serializer: nil, json: { meta: { count: 'deactivated'} }
111
125
  end
112
126
 
127
+ def count_deactivated?(model)
128
+ name = ForestLiana.name_for(model)
129
+ ForestLiana.apimap.find { |collection| collection.name.to_s == name }&.is_countable == false
130
+ end
131
+
113
132
  private
114
133
 
134
+ # NOTICE: What survives becomes(), which allocates a fresh instance and copies @attributes
135
+ # onto it and nothing else. Shared by both controllers' get_record, unlike the rest of
136
+ # their rendering, because getting it wrong is a correctness bug rather than a
137
+ # difference of route.
138
+ #
139
+ # An association proxy is bound to the reflection of the class that built it, and
140
+ # association(name) hands back whatever sits under that key without re-checking it
141
+ # against the new class. An STI subclass is free to redefine a same-named association
142
+ # with its own class_name, scope or foreign_key, so copying such an entry wholesale
143
+ # would serve the subclass's target under the base class's field — plausible, wrong,
144
+ # and raising nothing. Only entries both classes reflect identically are carried over;
145
+ # anything else is left to load lazily, the behaviour it had before this existed.
146
+ def carry_preloaded_associations(record, became)
147
+ cache = record.instance_variable_get(:@association_cache) || {}
148
+ carried = cache.select do |name, _|
149
+ record.class.reflect_on_association(name) == became.class.reflect_on_association(name)
150
+ end
151
+ became.instance_variable_set(:@association_cache, carried)
152
+
153
+ became
154
+ end
155
+
156
+ # NOTICE: Header-then-query fallback, decided here and nowhere else: the header is rewritten
157
+ # into params[:fields], so every route keeps reading the projection it always read
158
+ # and the header wins over the query params without any route knowing about it.
159
+ #
160
+ # Declared as a before_action by the routes that project, and by them only: a count,
161
+ # a stats or a write carries no projection, must not have its params rewritten by a
162
+ # header meant for the list next to it, and must not answer a 400 for one. The same
163
+ # rule as agent-nodejs, whose own suite pins the header ignored on count.
164
+ def apply_projection_header
165
+ header = request.headers[ForestLiana::ProjectionParser::HEADER_NAME]
166
+ return if header.nil?
167
+
168
+ root_model = projection_root_model
169
+ # NOTICE: An unresolvable collection is left to the route, which answers its own 404.
170
+ return if root_model.nil?
171
+
172
+ fields = ForestLiana::ProjectionParser.new(header, ForestLiana.name_for(root_model)).perform
173
+ params[:fields] = ActionController::Parameters.new(fields)
174
+ rescue ForestLiana::Errors::ExpectedError
175
+ # NOTICE: The deliberate 400 on a malformed header, which render_error answers.
176
+ raise
177
+ rescue => error
178
+ FOREST_REPORTER.report error
179
+ FOREST_LOGGER.error "Forest-Projection header error: #{error}\n#{format_stacktrace(error)}"
180
+ internal_server_error
181
+ end
182
+
183
+ # NOTICE: A projection is rooted on the collection the records come from, which is the
184
+ # association target on the relationships routes, not the collection in the URL.
185
+ def projection_root_model
186
+ return nil if params[:collection].blank?
187
+
188
+ model = ForestLiana::SchemaUtils.find_model_from_collection_name(params[:collection])
189
+ return nil unless model.respond_to?(:reflect_on_association)
190
+ return model if params[:association_name].blank?
191
+
192
+ association = model.reflect_on_association(params[:association_name].to_sym)
193
+ # NOTICE: klass raises on a polymorphic reflection, and this runs in a before_action: the
194
+ # guard is what keeps a 500 from replacing the 404 the route answers on its own. A
195
+ # relationships index rejects a belongs_to, which a polymorphic relation always is.
196
+ return nil if association.nil? || ForestLiana::SchemaUtils.polymorphic?(association)
197
+
198
+ association.klass
199
+ end
200
+
115
201
  def render_error(exception)
202
+ # Same rule as render_expected_error, for the errors that reach here through a rescue_from
203
+ # instead: a 5xx is a server-side failure, reported and logged as one.
204
+ if exception.error_code.to_i >= 500
205
+ FOREST_REPORTER.report exception
206
+ ForestLiana::Errors::ExceptionHelper.recursively_print(exception, is_error: true)
207
+ end
208
+
116
209
  errors = {
117
210
  status: exception.error_code,
118
211
  detail: exception.message,
@@ -207,14 +300,27 @@ module ForestLiana
207
300
  end
208
301
 
209
302
  def render_csv getter, model
303
+ # Unlike index/show/update, an unreadable column here is dropped rather than refusing the
304
+ # whole export (named_collections: [] — nothing is ever "named") — matching agent-nodejs's
305
+ # own CSV route, which runs the same redactProjection as its JSON list but drops a denied
306
+ # column instead of 403ing the file whenever it doesn't error there either.
307
+ requested_fields = fields_per_model(params[:fields], model)
308
+ fields_to_serialize = redact_fields(forest_user, model, requested_fields, named_collections: [])
309
+
210
310
  set_headers_file
211
311
  set_headers_streaming
212
312
 
213
313
  response.status = 200
214
- csv_header = params[:header].split(',')
215
314
  collection_name = ForestLiana.name_for(model)
216
- field_names_requested = params[:fields][collection_name].split(',').map { |name| name.to_s }
217
- fields_to_serialize = fields_per_model(params[:fields], model)
315
+ requested_field_names = params[:fields][collection_name].split(',').map { |name| name.to_s }
316
+ requested_header = params[:header].split(',')
317
+
318
+ # Keep the header row aligned with whatever redact_fields left in fields_to_serialize,
319
+ # the same way agent-nodejs's CsvGenerator.filterHeader drops a redacted column's label.
320
+ surviving = (fields_to_serialize[collection_name] || '').split(',')
321
+ kept = requested_field_names.zip(requested_header).select { |name, _| surviving.include?(name) }
322
+ field_names_requested = kept.map(&:first)
323
+ csv_header = kept.map(&:last)
218
324
 
219
325
  self.response_body = Enumerator.new do |content|
220
326
  content << ::CSV::Row.new(field_names_requested, csv_header, true).to_s
@@ -1,14 +1,26 @@
1
1
  module ForestLiana
2
2
  class AssociationsController < ForestLiana::ApplicationController
3
+ include ForestLiana::Ability
4
+
5
+ # NOTICE: index covers the relationship list and its CSV export. count answers no
6
+ # projection, and update/associate/dissociate none either.
3
7
  if Rails::VERSION::MAJOR < 4
4
8
  before_filter :find_resource, except: :count
5
9
  before_filter :find_association, except: :count
10
+ before_filter :apply_projection_header, only: :index
6
11
  else
7
12
  before_action :find_resource, except: :count
8
13
  before_action :find_association, except: :count
14
+ before_action :apply_projection_header, only: :index
9
15
  end
10
16
 
11
17
  def index
18
+ # Parity with agent-nodejs's list-related route: browse/export on the foreign collection.
19
+ # Authorized outside the begin, like update/associate/dissociate, so a denial reaches
20
+ # ApplicationController's rescue_from and keeps its name/data instead of falling into the
21
+ # generic ExpectedError rescue below.
22
+ action = request.format == 'csv' ? 'export' : 'browse'
23
+ forest_authorize!(action, forest_user, @association.klass)
12
24
  begin
13
25
  getter = HasManyGetter.new(@resource, @association, params, forest_user)
14
26
  getter.perform
@@ -17,6 +29,25 @@ module ForestLiana
17
29
  format.json { render_jsonapi(getter) }
18
30
  format.csv { render_csv(getter, @association.klass) }
19
31
  end
32
+ rescue ForestLiana::Ability::Exceptions::UnauthorizedFieldsError,
33
+ ForestLiana::Ability::Exceptions::UnauthorizedQueryFieldError => error
34
+ # A CSV request already has its response Content-Type/Content-Disposition set by the
35
+ # respond_to format match before this rescue ever runs — force JSON back, or the client
36
+ # downloads a ".csv" file whose content is this JSON error. UnauthorizedQueryFieldError is
37
+ # raised earlier (before respond_to picks a format) and doesn't strictly need this fix, but
38
+ # is caught here anyway since UnauthorizedFieldsError (raised during serialization, after
39
+ # the format is already picked) does — same response shape either way.
40
+ render(serializer: nil, json: { errors: [{
41
+ status: error.error_code,
42
+ detail: error.message,
43
+ name: error.name,
44
+ data: error.data
45
+ }] }, status: error.status, content_type: 'application/json')
46
+ response.headers.delete('Content-Disposition')
47
+ rescue *QUERY_PERMISSION_ERRORS
48
+ raise
49
+ rescue ForestLiana::Errors::ExpectedError => error
50
+ render_expected_error(error)
20
51
  rescue => error
21
52
  FOREST_REPORTER.report error
22
53
  FOREST_LOGGER.error "Association Index error: #{error}\n#{format_stacktrace(error)}"
@@ -32,11 +63,23 @@ module ForestLiana
32
63
  # through and dereferencing a nil @association, which surfaced as
33
64
  # a double-render / 500 rather than the intended 404.
34
65
  return if performed?
66
+ # Authorized outside the begin, like index above, so a denial keeps its name/data. A
67
+ # polymorphic @association already raises here (Rails: "Polymorphic associations do not
68
+ # support computing the class") — pre-existing, and shared with associate/dissociate below
69
+ # (index and update never reach it: both are structurally limited to a non-polymorphic
70
+ # macro before ever calling .klass).
71
+ forest_authorize!('browse', forest_user, @association.klass)
72
+ return deactivate_count_response if count_deactivated?(@association.klass)
73
+
35
74
  begin
36
75
  getter = HasManyGetter.new(@resource, @association, params, forest_user)
37
76
  getter.count
38
77
 
39
78
  render serializer: nil, json: { count: getter.records_count }
79
+ rescue *QUERY_PERMISSION_ERRORS
80
+ raise
81
+ rescue ForestLiana::Errors::ExpectedError => error
82
+ render_expected_error(error)
40
83
  rescue => error
41
84
  FOREST_REPORTER.report error
42
85
  FOREST_LOGGER.error "Association Index Count error: #{error}\n#{format_stacktrace(error)}"
@@ -45,6 +88,12 @@ module ForestLiana
45
88
  end
46
89
 
47
90
  def update
91
+ # BelongsToUpdater's writer saves the FK on the target for a has_one, on @resource only
92
+ # for a belongsTo. Authorized outside the begin, like ResourcesController's own actions, so
93
+ # a denial reaches ApplicationController's rescue_from and keeps its name/data.
94
+ edit_subject = @association.macro == :has_one ? @association.klass : @resource
95
+ forest_authorize!('edit', forest_user, edit_subject)
96
+ forest_authorize!('delete', forest_user, @association.klass) if BelongsToUpdater.replaces_destructively?(@association)
48
97
  begin
49
98
  updater = BelongsToUpdater.new(@resource, @association, params)
50
99
  updater.perform
@@ -55,6 +104,8 @@ module ForestLiana
55
104
  else
56
105
  head :no_content
57
106
  end
107
+ rescue ForestLiana::Errors::ExpectedError => error
108
+ render_expected_error(error)
58
109
  rescue => error
59
110
  FOREST_REPORTER.report error
60
111
  FOREST_LOGGER.error "Association Update error: #{error}\n#{format_stacktrace(error)}"
@@ -63,11 +114,14 @@ module ForestLiana
63
114
  end
64
115
 
65
116
  def associate
117
+ forest_authorize!('edit', forest_user, HasManyAssociator.authorize_target(@association))
66
118
  begin
67
119
  associator = HasManyAssociator.new(@resource, @association, params)
68
120
  associator.perform
69
121
 
70
122
  head :no_content
123
+ rescue ForestLiana::Errors::ExpectedError => error
124
+ render_expected_error(error)
71
125
  rescue => error
72
126
  FOREST_REPORTER.report error
73
127
  FOREST_LOGGER.error "Association Associate error: #{error}\n#{format_stacktrace(error)}"
@@ -76,6 +130,15 @@ module ForestLiana
76
130
  end
77
131
 
78
132
  def dissociate
133
+ if params[:delete].to_s == 'true'
134
+ # Explicit delete destroys the far record directly, regardless of association shape.
135
+ action = 'delete'
136
+ authorize_target = @association.klass
137
+ else
138
+ action = HasManyDissociator.destroys_on_unlink?(@association) ? 'delete' : 'edit'
139
+ authorize_target = HasManyDissociator.destroy_target(@association)
140
+ end
141
+ forest_authorize!(action, forest_user, authorize_target)
79
142
  begin
80
143
  dissociator = HasManyDissociator.new(@resource, @association, params, forest_user)
81
144
  dissociator.perform
@@ -83,6 +146,10 @@ module ForestLiana
83
146
  head :no_content
84
147
  rescue ActiveRecord::RecordNotDestroyed => error
85
148
  render json: { errors: [{ status: :bad_request, detail: error.message }] }, status: :bad_request
149
+ rescue *QUERY_PERMISSION_ERRORS
150
+ raise
151
+ rescue ForestLiana::Errors::ExpectedError => error
152
+ render_expected_error(error)
86
153
  rescue => error
87
154
  FOREST_REPORTER.report error
88
155
  FOREST_LOGGER.error "Association Dissociate error: #{error}\n#{format_stacktrace(error)}"
@@ -122,20 +189,33 @@ module ForestLiana
122
189
  @association.klass.columns.any? { |column| column.name == @association.klass.inheritance_column })
123
190
  end
124
191
 
192
+ # See ResourcesController#get_record — becomes() drops the association cache and the
193
+ # polymorphic singleton readers, and with them everything the getter preloaded for this page.
125
194
  def get_record record
126
- is_sti_model? ? record.becomes(@association.klass) : record
195
+ return record unless is_sti_model?
196
+
197
+ carry_preloaded_associations(record, record.becomes(@association.klass))
127
198
  end
128
199
 
129
200
  def render_jsonapi getter
130
- fields_to_serialize = fields_per_model(params[:fields], @association.klass)
131
- records = getter.records.map { |record| get_record(record) }
132
-
133
201
  includes = getter.includes_for_serialization
134
- if fields_to_serialize && includes.length > 0
135
- association_name = ForestLiana.name_for(@association.klass)
136
- fields_to_serialize[association_name] += ",#{includes.join(',')}"
202
+ requested_fields = fields_per_model(params[:fields], @association.klass)
203
+
204
+ # The getter may include a relation the caller's own field list omitted (search decoration,
205
+ # scoped includes); splice it in before redaction runs, so it is checked like any other field
206
+ # instead of bypassing the check entirely by being added back afterwards.
207
+ association_name = ForestLiana.name_for(@association.klass)
208
+ if requested_fields && includes.length > 0 && requested_fields[association_name]
209
+ requested_fields[association_name] += ",#{includes.join(',')}"
137
210
  end
138
211
 
212
+ fields_to_serialize = requested_fields || default_fields_to_serialize(@association.klass, includes)
213
+ fields_to_serialize = redact_fields(
214
+ forest_user, @association.klass, fields_to_serialize,
215
+ named_collections: requested_fields ? requested_fields.keys : []
216
+ )
217
+ records = getter.records.map { |record| get_record(record) }
218
+
139
219
  json = serialize_models(
140
220
  records,
141
221
  {
@@ -154,5 +234,26 @@ module ForestLiana
154
234
  collection_name = ForestLiana.name_for(model_association)
155
235
  @collection ||= ForestLiana.apimap.find { |collection| collection.name.to_s == collection_name }
156
236
  end
237
+
238
+ # See ResourcesController#default_fields_to_serialize for the rationale; duplicated rather than
239
+ # shared, matching how this controller already keeps its own render_jsonapi/get_record instead
240
+ # of reusing ResourcesController's.
241
+ def default_fields_to_serialize(root_model, included_relation_names)
242
+ fields = { ForestLiana.name_for(root_model) => collection_field_names(root_model) }
243
+
244
+ Array(included_relation_names).each do |relation_name|
245
+ reflection = root_model.reflect_on_association(relation_name.to_sym)
246
+ next if reflection.nil? || reflection.polymorphic?
247
+
248
+ related_name = ForestLiana.name_for(reflection.klass)
249
+ fields[related_name] ||= collection_field_names(reflection.klass)
250
+ end
251
+
252
+ fields
253
+ end
254
+
255
+ def collection_field_names(model)
256
+ ForestLiana::SchemaHelper.find_collection_from_model(model).fields.map { |field| field[:field] }.join(',')
257
+ end
157
258
  end
158
259
  end
@@ -10,6 +10,29 @@ module ForestLiana
10
10
 
11
11
  private
12
12
 
13
+ # The one shape for an expected error an action renders itself, rather than handing back to
14
+ # ApplicationController's rescue_from. A 5xx among them is a server-side failure wearing an
15
+ # expected-error class — a permissions fetch that could not be answered, an unimplemented
16
+ # method — so it is reported and logged as an error like any other 500 would be. Without that
17
+ # it reads as a client error, and the incident reaches nobody.
18
+ def render_expected_error(error)
19
+ if error.error_code.to_i >= 500
20
+ FOREST_REPORTER.report error
21
+ ForestLiana::Errors::ExceptionHelper.recursively_print(error, is_error: true)
22
+ else
23
+ error.display_error
24
+ end
25
+
26
+ # name/data carried like render_error does, when the class sets them: it is what tells a
27
+ # PermissionsUnavailableError from the RBAC refusals sharing its shape, on the client and in
28
+ # a support thread alike. Absent on the classes that set neither, exactly as before.
29
+ payload = { status: error.error_code, detail: error.message }
30
+ payload[:name] = error.name if error.try(:name)
31
+ payload[:data] = error.data if error.try(:data)
32
+
33
+ render(serializer: nil, json: ForestAdmin::JSONAPI::Serializer.serialize_errors([payload]), status: error.status)
34
+ end
35
+
13
36
  def reject_unauthorized_ip
14
37
  begin
15
38
  ip = request.remote_ip
@@ -24,11 +47,7 @@ module ForestLiana
24
47
  end
25
48
  end
26
49
  rescue ForestLiana::Errors::ExpectedError => exception
27
- error_data = ForestAdmin::JSONAPI::Serializer.serialize_errors([{
28
- status: exception.error_code,
29
- detail: exception.message
30
- }])
31
- render(serializer: nil, json: error_data, status: exception.status)
50
+ render_expected_error(exception)
32
51
  rescue => exception
33
52
  FOREST_REPORTER.report exception
34
53
  FOREST_LOGGER.error(exception)
@@ -0,0 +1,13 @@
1
+ module ForestLiana
2
+ class CapabilitiesController < ForestLiana::ApplicationController
3
+ def index
4
+ capabilities = ForestLiana::CapabilitiesGetter.new(params[:collectionNames]).perform
5
+
6
+ render serializer: nil, json: capabilities, status: :ok
7
+ rescue => error
8
+ FOREST_REPORTER.report error
9
+ FOREST_LOGGER.error "Error while getting the capabilities: #{error.message}"
10
+ internal_server_error
11
+ end
12
+ end
13
+ end
@@ -8,10 +8,14 @@ module ForestLiana
8
8
 
9
9
  rescue_from ActiveRecord::RecordNotFound, :with => :record_not_found
10
10
 
11
+ # NOTICE: index covers the list and the CSV export, show the get-one. Every other action
12
+ # here answers no projection, count included.
11
13
  if Rails::VERSION::MAJOR < 4
12
14
  before_filter :find_resource, except: :count
15
+ before_filter :apply_projection_header, only: [:index, :show]
13
16
  else
14
17
  before_action :find_resource, except: :count
18
+ before_action :apply_projection_header, only: [:index, :show]
15
19
  end
16
20
 
17
21
  def index
@@ -27,13 +31,25 @@ module ForestLiana
27
31
  rescue ForestLiana::Errors::LiveQueryError => error
28
32
  render json: { errors: [{ status: 422, detail: error.message }] },
29
33
  status: :unprocessable_entity, serializer: nil
30
- rescue ForestLiana::Errors::ExpectedError => error
31
- error.display_error
32
- error_data = ForestAdmin::JSONAPI::Serializer.serialize_errors([{
34
+ rescue ForestLiana::Ability::Exceptions::UnauthorizedFieldsError,
35
+ ForestLiana::Ability::Exceptions::UnauthorizedQueryFieldError => error
36
+ # A CSV request already has its response Content-Type/Content-Disposition set by the
37
+ # respond_to format match before this rescue ever runs — force JSON back, or the client
38
+ # downloads a ".csv" file whose content is this JSON error. UnauthorizedQueryFieldError is
39
+ # raised earlier (before respond_to picks a format) and doesn't strictly need this fix, but
40
+ # is caught here anyway since UnauthorizedFieldsError (raised during serialization, after
41
+ # the format is already picked) does — same response shape either way.
42
+ render(serializer: nil, json: { errors: [{
33
43
  status: error.error_code,
34
- detail: error.message
35
- }])
36
- render(serializer: nil, json: error_data, status: error.status)
44
+ detail: error.message,
45
+ name: error.name,
46
+ data: error.data
47
+ }] }, status: error.status, content_type: 'application/json')
48
+ response.headers.delete('Content-Disposition')
49
+ rescue *QUERY_PERMISSION_ERRORS
50
+ raise
51
+ rescue ForestLiana::Errors::ExpectedError => error
52
+ render_expected_error(error)
37
53
  rescue => error
38
54
  FOREST_REPORTER.report error
39
55
  FOREST_LOGGER.error "Records Index error: #{error}\n#{format_stacktrace(error)}"
@@ -44,6 +60,8 @@ module ForestLiana
44
60
  def count
45
61
  find_resource
46
62
  forest_authorize!('browse', forest_user, @resource)
63
+ return deactivate_count_response if count_deactivated?(@resource)
64
+
47
65
  begin
48
66
  getter = ForestLiana::ResourcesGetter.new(@resource, params, forest_user)
49
67
  getter.count
@@ -53,13 +71,10 @@ module ForestLiana
53
71
  rescue ForestLiana::Errors::LiveQueryError => error
54
72
  render json: { errors: [{ status: 422, detail: error.message }] },
55
73
  status: :unprocessable_entity, serializer: nil
74
+ rescue *QUERY_PERMISSION_ERRORS
75
+ raise
56
76
  rescue ForestLiana::Errors::ExpectedError => error
57
- error.display_error
58
- error_data = ForestAdmin::JSONAPI::Serializer.serialize_errors([{
59
- status: error.error_code,
60
- detail: error.message
61
- }])
62
- render(serializer: nil, json: error_data, status: error.status)
77
+ render_expected_error(error)
63
78
  rescue => error
64
79
  FOREST_REPORTER.report error
65
80
  FOREST_LOGGER.error "Records Index Count error: #{error}\n#{format_stacktrace(error)}"
@@ -73,9 +88,18 @@ module ForestLiana
73
88
  getter = ForestLiana::ResourceGetter.new(@resource, params, forest_user)
74
89
  getter.perform
75
90
 
76
- render serializer: nil, json: render_record_jsonapi(getter.record)
91
+ render serializer: nil, json: render_record_jsonapi(getter.record, getter)
77
92
  rescue ActiveRecord::RecordNotFound
78
93
  render serializer: nil, json: { status: 404 }, status: :not_found
94
+ rescue *QUERY_PERMISSION_ERRORS
95
+ raise
96
+ rescue ForestLiana::Errors::ExpectedError => error
97
+ # Same shape as #index and #count: the permission errors above go back to
98
+ # ApplicationController's rescue_from for their name/data, every other expected error is
99
+ # rendered here with its own status (a 422 on a malformed field path) rather than
100
+ # re-raised. Re-raising the whole hierarchy would let the subclasses no rescue_from
101
+ # covers escape the controller entirely — no Forest error payload, and no report.
102
+ render_expected_error(error)
79
103
  rescue => error
80
104
  FOREST_REPORTER.report error
81
105
  FOREST_LOGGER.error "Record Show error: #{error}\n#{format_stacktrace(error)}"
@@ -162,6 +186,10 @@ module ForestLiana
162
186
  end
163
187
  rescue ActiveRecord::RecordNotDestroyed => error
164
188
  render json: { errors: [{ status: :bad_request, detail: error.message }] }, status: :bad_request
189
+ rescue *QUERY_PERMISSION_ERRORS
190
+ raise
191
+ rescue ForestLiana::Errors::ExpectedError => error
192
+ render_expected_error(error)
165
193
  rescue => error
166
194
  FOREST_REPORTER.report error
167
195
  FOREST_LOGGER.error "Records Destroy error: #{error}\n#{format_stacktrace(error)}"
@@ -203,26 +231,88 @@ module ForestLiana
203
231
  @resource.columns.any? { |column| column.name == @resource.inheritance_column })
204
232
  end
205
233
 
234
+ # NOTICE: becomes() allocates a fresh instance and copies @attributes onto it — not the
235
+ # association cache, which init_internals has just emptied. What the getters
236
+ # preloaded (smart_field_preloads, and the relations eager loaded for serialization)
237
+ # would be dropped here and re-queried once per row, so an STI collection alone would
238
+ # keep the N+1 the rest of this pipeline exists to remove.
239
+ #
240
+ # The singleton readers preload_polymorphic_associations defines are lost too — they
241
+ # belong to the old instance's singleton class — but their targets are not: Rails'
242
+ # own Preloader writes a polymorphic target into the association cache as well, so
243
+ # those ride along with everything else. That is what preload_polymorphic_associations
244
+ # calls #call for on Rails 7+. Pinned by sti_record_conversion_spec.rb.
245
+ #
246
+ # Carried over explicitly, and only where the two classes agree on what the name
247
+ # means — see carry_preloaded_associations.
206
248
  def get_record record
207
- is_sti_model? ? record.becomes(@resource) : record
249
+ return record unless is_sti_model?
250
+
251
+ carry_preloaded_associations(record, record.becomes(@resource))
208
252
  end
209
253
 
210
- def render_record_jsonapi record
211
- collection = ForestLiana::SchemaHelper.find_collection_from_model(@resource)
212
- collection_fields = collection.fields.map { |field| field[:field] }
213
- fields_to_serialize = {
214
- ForestLiana.name_for(@resource) => collection_fields.join(',')
215
- }
254
+ # NOTICE: create and update answer with the whole record: they carry no projection, so the
255
+ # getter is absent and every field is part of the default expansion, not something the
256
+ # caller named. show passes a getter; a field named through the projection header is
257
+ # refused rather than redacted if unreadable, same as index's own render_jsonapi below.
258
+ def render_record_jsonapi record, getter = nil
259
+ requested_fields = getter&.projection? ? fields_per_model(params[:fields], @resource) : nil
260
+ fields_to_serialize = redact_fields(
261
+ forest_user, @resource,
262
+ requested_fields || default_fields_to_serialize(@resource, record_includes),
263
+ named_collections: requested_fields ? requested_fields.keys : []
264
+ )
265
+ fields_to_serialize = keep_relationship_links(fields_to_serialize) if requested_fields
216
266
 
217
267
  serialize_model(get_record(record), {
218
- include: record_includes,
268
+ include: requested_fields ? getter.includes_for_serialization : record_includes,
219
269
  fields: fields_to_serialize
220
270
  })
221
271
  end
222
272
 
273
+ # NOTICE: A projection names what the caller reads off the record, never the to-many
274
+ # relations: the frontend does not fetch them here, it follows their
275
+ # `links.related` off this very payload to load each one on its own route. Left out
276
+ # of `fields` they are not serialized at all, the links go with them, and every
277
+ # related-data list on the record silently stays empty — its count, built from a
278
+ # hand-made URL rather than from a link, keeps answering. They cost no query: a
279
+ # to-many outside `include` serializes to its link and nothing else.
280
+ #
281
+ # Added back after the redaction, and without a read check on the target collection:
282
+ # a `links.related` is a URL, not data, and AssociationsController#index authorizes
283
+ # `browse` on that very collection before answering it. Checking here would buy
284
+ # nothing and cost a read permission lookup per to-many on every single get-one,
285
+ # only to turn a Forest API hiccup into a 403 on a record the role may read.
286
+ def keep_relationship_links(fields_to_serialize)
287
+ root_name = ForestLiana.name_for(@resource)
288
+ projected = (fields_to_serialize[root_name] || '').split(',')
289
+ missing = to_many_field_names - projected
290
+ return fields_to_serialize if missing.empty?
291
+
292
+ fields_to_serialize.merge(root_name => (projected + missing).join(','))
293
+ end
294
+
295
+ # NOTICE: What every to-many has in common, whatever it is made of. Keying on
296
+ # `field[:relationship]` would miss two of the three: SchemaAdapter types an
297
+ # association by its camelized macro, so a has_and_belongs_to_many reads
298
+ # "HasAndBelongsToMany" rather than "HasMany", and Collection#has_many — the smart
299
+ # DSL — never sets the key at all. The frontend makes none of these distinctions: it
300
+ # reads the array type, exactly as here. `reference` is what keeps a scalar list out:
301
+ # an array-typed smart field references nothing, and is a computed value the caller
302
+ # did not ask for rather than a relation with a link (User's `nicknames`).
303
+ def to_many_field_names
304
+ get_collection.fields
305
+ .select { |field| field[:type].is_a?(Array) && field[:reference].present? }
306
+ .map { |field| field[:field].to_s }
307
+ end
308
+
223
309
  def render_jsonapi getter
224
310
  records = getter.records.map { |record| get_record(record) }
225
- fields_to_serialize = fields_per_model(params[:fields], @resource)
311
+ requested_fields = fields_per_model(params[:fields], @resource)
312
+ fields_to_serialize = requested_fields || default_fields_to_serialize(@resource, includes(getter))
313
+ fields_to_serialize = redact_fields(
314
+ forest_user, @resource, fields_to_serialize, named_collections: requested_fields ? requested_fields.keys : []
315
+ )
226
316
 
227
317
  json = serialize_models(
228
318
  records,
@@ -237,6 +327,29 @@ module ForestLiana
237
327
  render serializer: nil, json: json
238
328
  end
239
329
 
330
+ # The full-schema field list `fields[...]` expands to when the caller never sent it — mirrors
331
+ # `render_record_jsonapi`'s own full-collection expansion, fanned out over the relations the
332
+ # response actually includes. A polymorphic relation is left out of this per-collection map: it
333
+ # has no single target collection to expand, and it already appears in the root's own field list
334
+ # (below), which is what makes the relationship link itself visible.
335
+ def default_fields_to_serialize(root_model, included_relation_names)
336
+ fields = { ForestLiana.name_for(root_model) => collection_field_names(root_model) }
337
+
338
+ Array(included_relation_names).each do |relation_name|
339
+ reflection = root_model.reflect_on_association(relation_name.to_sym)
340
+ next if reflection.nil? || reflection.polymorphic?
341
+
342
+ related_name = ForestLiana.name_for(reflection.klass)
343
+ fields[related_name] ||= collection_field_names(reflection.klass)
344
+ end
345
+
346
+ fields
347
+ end
348
+
349
+ def collection_field_names(model)
350
+ ForestLiana::SchemaHelper.find_collection_from_model(model).fields.map { |field| field[:field] }.join(',')
351
+ end
352
+
240
353
  def get_collection
241
354
  collection_name = ForestLiana.name_for(@resource)
242
355
  @collection ||= ForestLiana.apimap.find { |collection| collection.name.to_s == collection_name }