rest_framework 1.1.0 → 2.0.0.beta1

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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +107 -41
  3. data/VERSION +1 -1
  4. data/app/views/rest_framework/routes_and_forms/_html_form.html.erb +1 -1
  5. data/lib/rest_framework/controller/actions.rb +256 -0
  6. data/lib/rest_framework/controller/bulk.rb +247 -32
  7. data/lib/rest_framework/controller/crud.rb +13 -9
  8. data/lib/rest_framework/controller/openapi.rb +12 -8
  9. data/lib/rest_framework/controller.rb +275 -164
  10. data/lib/rest_framework/errors.rb +70 -3
  11. data/lib/rest_framework/filters/base_filter.rb +10 -0
  12. data/lib/rest_framework/filters/ordering_filter.rb +41 -23
  13. data/lib/rest_framework/filters/query_filter.rb +24 -5
  14. data/lib/rest_framework/filters/search_filter.rb +8 -4
  15. data/lib/rest_framework/paginators/page_number_paginator.rb +34 -19
  16. data/lib/rest_framework/routers.rb +52 -182
  17. data/lib/rest_framework/serializers/active_model_serializer_adapter_factory.rb +2 -2
  18. data/lib/rest_framework/serializers/base_serializer.rb +2 -2
  19. data/lib/rest_framework/serializers/native_serializer.rb +78 -24
  20. data/lib/rest_framework/utils.rb +39 -70
  21. data/lib/rest_framework/version.rb +8 -5
  22. data/lib/rest_framework.rb +7 -41
  23. metadata +5 -12
  24. data/lib/rest_framework/errors/base_error.rb +0 -5
  25. data/lib/rest_framework/errors/nil_passed_to_render_api_error.rb +0 -14
  26. data/lib/rest_framework/generators/controller_generator.rb +0 -64
  27. data/lib/rest_framework/generators.rb +0 -4
  28. data/lib/rest_framework/mixins/base_controller_mixin.rb +0 -12
  29. data/lib/rest_framework/mixins/bulk_model_controller_mixin.rb +0 -55
  30. data/lib/rest_framework/mixins/model_controller_mixin.rb +0 -110
  31. data/lib/rest_framework/mixins.rb +0 -7
@@ -3,9 +3,7 @@
3
3
  # module rather than defining a separate submodule.
4
4
  module RESTFramework::Controller
5
5
  RRF_BASE_CONFIG = {
6
- extra_actions: nil,
7
- extra_member_actions: nil,
8
- singleton_controller: nil,
6
+ singular: nil,
9
7
 
10
8
  # Options related to metadata and display.
11
9
  title: nil,
@@ -14,30 +12,38 @@ module RESTFramework::Controller
14
12
  inflect_acronyms: RESTFramework.config.inflect_acronyms,
15
13
  openapi_include_children: false,
16
14
 
17
- # Core attributes related to models.
15
+ # Options related to models.
18
16
  model: nil,
19
17
  recordset: nil,
20
- excluded_actions: nil,
21
- bulk: false,
22
18
 
23
- # Attributes for configuring record fields.
19
+ # Bulk configuration.
20
+ #
21
+ # When `bulk` is truthy, it enables the default bulk behavior (`:default`), which is per-record
22
+ # processing (e.g., `create` for each record). When `bulk` is set to `:raw`, it enables single
23
+ # SQL query behavior (e.g., `insert_all` for bulk create) which skips validations/callbacks.
24
+ bulk: false,
25
+ bulk_partial: false,
26
+ bulk_partial_query_param: "bulk_partial".freeze,
27
+ bulk_allow_mode_override: false,
28
+ bulk_mode_query_param: "bulk_mode".freeze,
29
+ bulk_max_size: nil,
30
+ bulk_max_raw_size: nil,
31
+
32
+ # Configuring record fields.
24
33
  fields: nil,
25
34
  field_config: nil,
26
35
  read_only_fields: RESTFramework.config.read_only_fields,
27
36
  write_only_fields: RESTFramework.config.write_only_fields,
28
37
  hidden_fields: nil,
29
38
 
30
- # Attributes for finding records.
39
+ # Finding records.
31
40
  find_by_fields: nil,
32
41
  find_by_query_param: "find_by".freeze,
33
42
 
34
- # Options for what should be included/excluded from default fields.
35
- exclude_associations: false,
36
-
37
- # Options for handling request body parameters.
43
+ # Handling request body parameters.
38
44
  allowed_parameters: nil,
39
45
 
40
- # Attributes for the default native serializer.
46
+ # Options for the default native serializer.
41
47
  native_serializer_config: nil,
42
48
  native_serializer_singular_config: nil,
43
49
  native_serializer_plural_config: nil,
@@ -45,11 +51,28 @@ module RESTFramework::Controller
45
51
  native_serializer_except_query_param: "except".freeze,
46
52
  native_serializer_include_query_param: "include".freeze,
47
53
  native_serializer_exclude_query_param: "exclude".freeze,
48
- native_serializer_associations_limit: nil,
49
- native_serializer_associations_limit_query_param: "associations_limit".freeze,
50
- native_serializer_include_associations_count: false,
51
54
 
52
- # Attributes for filtering, ordering, and searching.
55
+ # Options for including associations and collection counts.
56
+ exclude_associations: false,
57
+ include_association_count: false,
58
+
59
+ # The number of records serialized per collection association, so responses are bounded out of
60
+ # the box (`nil` = unlimited). With `enable_association_queries`, a client can raise it for a
61
+ # given association via `?<prefix>.<name>.limit=N` or `limit=all` (`none`/`0` are aliases), both
62
+ # capped at `association_limit_max` (the "all" forms yield the cap). Set the max to `nil` to let
63
+ # a client request unlimited records.
64
+ association_limit: 10,
65
+ association_limit_max: 100,
66
+
67
+ # Let clients request extra fields for a serialized association via
68
+ # `?<prefix>.<association>.fields=a,b,c`. The allowlist keeps an association from ever exposing
69
+ # more than its own endpoint would: an explicit per-association `requestable_fields` in
70
+ # `field_config`, else the fields the associated model's sibling controller serializes.
71
+ # Off/secure by default.
72
+ enable_association_queries: false,
73
+ association_query_prefix: "associations".freeze,
74
+
75
+ # Options for filtering, ordering, and searching.
53
76
  filter_backends: [
54
77
  RESTFramework::QueryFilter,
55
78
  RESTFramework::OrderingFilter,
@@ -81,12 +104,16 @@ module RESTFramework::Controller
81
104
  serialize_to_json: true,
82
105
  serialize_to_xml: true,
83
106
 
84
- # Options related to pagination.
85
- paginator_class: nil,
107
+ # Options related to pagination. Pagination is on by default (page-number based) with a capped
108
+ # page size, so responses are bounded out of the box; set `paginator_class = nil` to disable.
109
+ paginator_class: RESTFramework::PageNumberPaginator,
86
110
  page_size: 20,
87
- page_query_param: "page",
88
- page_size_query_param: "page_size",
89
- max_page_size: nil,
111
+ page_query_param: "page".freeze,
112
+ page_size_query_param: "page_size".freeze,
113
+ max_page_size: 40,
114
+ # Whether the page-number paginator computes the total record count to report `count` and
115
+ # `total_pages`. Set to `false` on large tables to skip that query.
116
+ page_total_count: true,
90
117
 
91
118
  # Option to disable serializer adapters by default, mainly introduced because Active Model
92
119
  # Serializers will do things like serialize `[]` into `{"":[]}`.
@@ -96,27 +123,119 @@ module RESTFramework::Controller
96
123
  enable_action_text: false,
97
124
  enable_active_storage: false,
98
125
  }
99
- BASE64_REGEX = /data:(.*);base64,(.*)/
100
- BASE64_TRANSLATE = ->(field, value) {
101
- return value unless BASE64_REGEX.match?(value)
102
126
 
103
- _, content_type, payload = value.match(BASE64_REGEX).to_a
127
+ # Exceptions to be rescued and handled by returning a reasonable error response.
128
+ RRF_RESCUED_EXCEPTIONS = [
129
+ RESTFramework::InvalidBulkParametersError,
130
+ RESTFramework::BulkRecordErrorsError,
131
+ ].freeze
132
+ RRF_RESCUED_RAILS_EXCEPTIONS = [
133
+ ActionController::ParameterMissing,
134
+ ActionController::UnpermittedParameters,
135
+ ActionDispatch::Http::Parameters::ParseError,
136
+ ActiveRecord::AssociationTypeMismatch,
137
+ ActiveRecord::NotNullViolation,
138
+ ActiveRecord::RecordNotFound,
139
+ ActiveRecord::RecordInvalid,
140
+ ActiveRecord::RecordNotSaved,
141
+ ActiveRecord::RecordNotDestroyed,
142
+ ActiveRecord::RecordNotUnique,
143
+ ActiveRecord::StatementInvalid,
144
+ ActiveModel::UnknownAttributeError,
145
+ ].freeze
146
+
147
+ # Anchored regex with non-greedy content_type match to prevent over-matching on malicious input.
148
+ RRF_BASE64_REGEX = /\Adata:([^;]*);base64,(.*)\z/m
149
+ RRF_BASE64_TRANSLATE = ->(field, value) {
150
+ return value unless RRF_BASE64_REGEX.match?(value)
151
+
152
+ _, content_type, payload = value.match(RRF_BASE64_REGEX).to_a
104
153
  {
105
154
  io: StringIO.new(Base64.decode64(payload)),
106
155
  content_type: content_type,
107
156
  filename: "file_#{field}#{Rack::Mime::MIME_TYPES.invert[content_type]}",
108
157
  }
109
158
  }
110
- ACTIVESTORAGE_KEYS = [ :io, :content_type, :filename, :identify, :key ]
111
-
112
- # Default action for API root.
113
- def root
114
- render(api: { message: "This is the API root." })
115
- end
159
+ RRF_ACTIVESTORAGE_KEYS = [ :io, :content_type, :filename, :identify, :key ]
116
160
 
117
161
  module ClassMethods
118
162
  IGNORE_VALIDATORS_WITH_KEYS = [ :if, :unless ].freeze
119
163
 
164
+ # Thread-local key toggled by `propagate` while its block runs.
165
+ RRF_PROPAGATING_KEY = :rrf_propagating
166
+
167
+ # Define one or more class-level configuration attributes. Assignments are **local by default**:
168
+ #
169
+ # self.x = value # applies to this controller ONLY; descendants don't inherit it
170
+ # propagate { self.x = value } # applies to this controller AND all descendants
171
+ #
172
+ # This gives a single, uniform rule (an assignment is local unless wrapped in `propagate`), so
173
+ # there's no per-attribute "does this inherit?" knowledge to carry around. Values are stored in
174
+ # closures on redefined singleton methods (the same mechanism as `class_attribute`), never in
175
+ # instance variables, so there is exactly one interface for configuration: the setter.
176
+ #
177
+ # Only singleton (class-level) methods are defined, so config never leaks to controller
178
+ # instances (which would risk colliding with action methods).
179
+ def rrf_class_attribute(*names, default: nil)
180
+ names.each do |name|
181
+ # Propagating baseline: every controller sees the default until it's overridden. This lives
182
+ # in the propagated module (see `rrf_propagated_module`) rather than directly on the
183
+ # singleton class, so a local assignment can coexist with it via `super`.
184
+ rrf_propagated_module.define_method(name) { default }
185
+
186
+ # Parity with `class_attribute`, which also defines a predicate.
187
+ singleton_class.define_method("#{name}?") { !!public_send(name) }
188
+
189
+ singleton_class.define_method("#{name}=") do |value|
190
+ if Thread.current[RRF_PROPAGATING_KEY]
191
+ # Propagate: descendants inherit this getter via the module in the singleton chain. It's
192
+ # kept separate from any local getter (defined directly on the singleton class) so a
193
+ # subsequent local assignment doesn't clobber the value propagated to descendants.
194
+ rrf_propagated_module.define_method(name) { value }
195
+ else
196
+ # Local: `value` for this class only; descendants fall back through `super` to the
197
+ # nearest propagated ancestor value, or the default.
198
+ klass = self
199
+ singleton_class.define_method(name) do
200
+ if equal?(klass)
201
+ value
202
+ elsif defined?(super)
203
+ super()
204
+ else
205
+ default
206
+ end
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end
212
+
213
+ # The per-class module holding this class's propagated attribute getters (and the default
214
+ # baseline). It's included into the singleton class so descendants inherit propagated values
215
+ # through the singleton-class chain, while local assignments—defined directly on the singleton
216
+ # class—take precedence for the class itself and can `super()` back into this module. Created
217
+ # lazily and memoized per class (instance variables aren't inherited, so each class gets its
218
+ # own).
219
+ def rrf_propagated_module
220
+ @rrf_propagated_module ||= Module.new.tap { |mod| singleton_class.include(mod) }
221
+ end
222
+
223
+ # Run a block in which configuration setters (`self.x = value`) propagate to descendant
224
+ # controllers instead of applying locally. Use this on a shared base controller for settings you
225
+ # want every subclass to inherit:
226
+ #
227
+ # propagate do
228
+ # self.paginator_class = RESTFramework::PageNumberPaginator
229
+ # self.page_size = 30
230
+ # end
231
+ def propagate
232
+ previous = Thread.current[RRF_PROPAGATING_KEY]
233
+ Thread.current[RRF_PROPAGATING_KEY] = true
234
+ yield
235
+ ensure
236
+ Thread.current[RRF_PROPAGATING_KEY] = previous
237
+ end
238
+
120
239
  # By default, this is the name of the controller class, titleized and with any custom inflection
121
240
  # acronyms applied.
122
241
  def get_title
@@ -134,21 +253,6 @@ module RESTFramework::Controller
134
253
  self.model&.human_attribute_name(s, default: default_title) || default_title
135
254
  end
136
255
 
137
- # Define any behavior to execute at the end of controller definition.
138
- # :nocov:
139
- def rrf_finalize
140
- if RESTFramework.config.freeze_config
141
- self::RRF_BASE_CONFIG.keys.each { |k|
142
- v = self.send(k)
143
- v.freeze if v.is_a?(Hash) || v.is_a?(Array)
144
- }
145
- end
146
-
147
- self.setup_delegation if self.model
148
- # self.setup_channel if self.model
149
- end
150
- # :nocov:
151
-
152
256
  # Get the available fields. Fallback to this controller's model columns, or an empty array. This
153
257
  # should always return an array of strings.
154
258
  def get_fields(input_fields: nil)
@@ -269,17 +373,22 @@ module RESTFramework::Controller
269
373
  if ref = reflections[f]
270
374
  cfg[:kind] = "association"
271
375
 
272
- # Determine sub-fields for associations.
376
+ # Determine the association's fields.
273
377
  if ref.polymorphic?
274
378
  ref_columns = {}
275
379
  else
276
380
  ref_columns = ref.klass.columns_hash
277
381
  end
278
- cfg[:sub_fields] ||= RESTFramework::Utils.sub_fields_for(ref)
279
- cfg[:sub_fields] = cfg[:sub_fields].map(&:to_s)
382
+ cfg[:fields] ||= RESTFramework::Utils.association_fields_for(ref)
383
+ cfg[:fields] = cfg[:fields].map(&:to_s)
280
384
 
281
- # Very basic metadata about sub-fields.
282
- cfg[:sub_fields_metadata] = cfg[:sub_fields].map { |sf|
385
+ # Strings, to match `:fields` when intersecting requested fields against the allowlist.
386
+ if cfg[:requestable_fields]
387
+ cfg[:requestable_fields] = cfg[:requestable_fields].map(&:to_s)
388
+ end
389
+
390
+ # Very basic metadata about the association's fields.
391
+ cfg[:association_fields_metadata] = cfg[:fields].map { |sf|
283
392
  v = {}
284
393
 
285
394
  if ref_columns[sf]
@@ -342,13 +451,6 @@ module RESTFramework::Controller
342
451
  # Update `required` if we find a presence validator.
343
452
  cfg[:required] = true if kind == :presence
344
453
 
345
- # Resolve procs (and lambdas), and symbols for certain arguments.
346
- if options[:in].is_a?(Proc)
347
- options = options.merge(in: options[:in].call)
348
- elsif options[:in].is_a?(Symbol)
349
- options = options.merge(in: self.model.send(options[:in]))
350
- end
351
-
352
454
  cfg[:validators] ||= {}
353
455
  cfg[:validators][kind] ||= []
354
456
  cfg[:validators][kind] << options
@@ -356,39 +458,42 @@ module RESTFramework::Controller
356
458
 
357
459
  next [ f, cfg ]
358
460
  }.to_h.compact.with_indifferent_access
359
- end
360
461
 
361
- # Only for model controllers.
362
- def setup_delegation
363
- # Delegate extra actions.
364
- self.extra_actions&.each do |action, config|
365
- next unless config.is_a?(Hash) && config.dig(:metadata, :delegate)
366
- next unless self.model.respond_to?(action)
462
+ # Compile each association's requestable-fields allowlist once (see
463
+ # `enable_association_queries`). This runs as a second pass, after `@field_configuration` is
464
+ # memoized, because resolving a sibling's fields reads its `field_configuration` — and a
465
+ # self-referential or mutual association would otherwise recurse into this build.
466
+ if self.enable_association_queries
467
+ @field_configuration.each do |_f, cfg|
468
+ next unless cfg[:kind] == "association"
367
469
 
368
- self.define_method(action) do
369
- if self.model.method(action).parameters.last&.first == :keyrest
370
- render(api: self.model.send(action, **params))
371
- else
372
- render(api: self.model.send(action))
373
- end
470
+ cfg[:requestable_fields] ||= self.association_requestable_fields(cfg[:reflection])
374
471
  end
375
472
  end
376
473
 
377
- # Delegate extra member actions.
378
- self.extra_member_actions&.each do |action, config|
379
- next unless config.is_a?(Hash) && config.dig(:metadata, :delegate)
380
- next unless self.model.method_defined?(action)
381
-
382
- self.define_method(action) do
383
- record = self.get_record
474
+ @field_configuration
475
+ end
384
476
 
385
- if record.method(action).parameters.last&.first == :keyrest
386
- render(api: record.send(action, **params))
387
- else
388
- render(api: record.send(action))
389
- end
390
- end
391
- end
477
+ # The fields a consumer may request for an association beyond its defaults, derived from the
478
+ # associated model's sibling controller: what that controller serializes, so the association can
479
+ # never expose more than its own endpoint would. Empty unless the sibling is discoverable and
480
+ # introspectable — a custom serializer makes its `get_fields` meaningless. Hidden fields are
481
+ # included (retrievable via `?only=` there); write-only fields and nested associations aren't.
482
+ def association_requestable_fields(ref)
483
+ return [] if ref.polymorphic?
484
+
485
+ sibling = RESTFramework::Utils.controller_for_model(self, ref.klass)
486
+ return [] unless sibling
487
+ return [] if sibling.serializer_class ||
488
+ sibling.native_serializer_config ||
489
+ sibling.native_serializer_singular_config ||
490
+ sibling.native_serializer_plural_config
491
+
492
+ cfg = sibling.field_configuration
493
+ sibling.get_fields.reject { |sf|
494
+ c = cfg[sf]
495
+ c.nil? || c[:write_only] || c[:kind] == "association"
496
+ }
392
497
  end
393
498
  end
394
499
 
@@ -400,59 +505,25 @@ module RESTFramework::Controller
400
505
  # By default, the layout should be set to `rest_framework`.
401
506
  base.layout("rest_framework")
402
507
 
403
- # Add class attributes unless they already exist.
508
+ # Materialize config with `rrf_class_attribute` (local by default) rather than `class_attribute`
509
+ # (always inherited).
404
510
  RRF_BASE_CONFIG.each do |a, default|
405
511
  next if base.respond_to?(a)
406
512
 
407
- # Don't leak class attributes to the instance to avoid conflicting with action methods.
408
- base.class_attribute(a, default: default, instance_accessor: false)
409
- end
410
-
411
- # Alias `extra_actions` to `extra_collection_actions`.
412
- unless base.respond_to?(:extra_collection_actions)
413
- base.singleton_class.alias_method(:extra_collection_actions, :extra_actions)
414
- base.singleton_class.alias_method(:extra_collection_actions=, :extra_actions=)
513
+ base.rrf_class_attribute(a, default: default)
415
514
  end
416
515
 
417
516
  # Skip CSRF since this is an API.
418
517
  begin
419
518
  base.skip_before_action(:verify_authenticity_token)
420
- rescue
519
+ rescue ArgumentError
520
+ # The callback may not exist if forgery protection isn't enabled; this is expected.
421
521
  nil
422
522
  end
423
523
 
424
- # Handle some common exceptions.
425
- unless RESTFramework.config.disable_rescue_from
426
- base.rescue_from(
427
- ActionController::ParameterMissing,
428
- ActionController::UnpermittedParameters,
429
- ActionDispatch::Http::Parameters::ParseError,
430
- ActiveRecord::AssociationTypeMismatch,
431
- ActiveRecord::NotNullViolation,
432
- ActiveRecord::RecordNotFound,
433
- ActiveRecord::RecordInvalid,
434
- ActiveRecord::RecordNotSaved,
435
- ActiveRecord::RecordNotDestroyed,
436
- ActiveRecord::RecordNotUnique,
437
- ActiveModel::UnknownAttributeError,
438
- with: :rrf_error_handler,
439
- )
440
- end
441
-
442
- # Use `TracePoint` hook to automatically call `rrf_finalize`.
443
- if RESTFramework.config.auto_finalize
444
- # :nocov:
445
- TracePoint.trace(:end) do |t|
446
- next if base != t.self
447
-
448
- base.rrf_finalize
449
-
450
- # It's important to disable the trace once we've found the end of the base class definition,
451
- # for performance.
452
- t.disable
453
- end
454
- # :nocov:
455
- end
524
+ # Handle exceptions.
525
+ base.rescue_from(*RRF_RESCUED_EXCEPTIONS, with: :rrf_error_handler)
526
+ base.rescue_from(*RRF_RESCUED_RAILS_EXCEPTIONS, with: :rrf_error_handler)
456
527
  end
457
528
 
458
529
  def get_serializer_class
@@ -470,13 +541,23 @@ module RESTFramework::Controller
470
541
  status = case e
471
542
  when ActiveRecord::RecordNotFound
472
543
  404
544
+ when RESTFramework::BulkRecordErrorsError
545
+ 422
473
546
  else
474
547
  400
475
548
  end
476
549
 
477
- render(
478
- api: {
479
- message: e.message,
550
+ # `StatementInvalid` messages commonly embed SQL fragments and schema details, so don't leak
551
+ # them to clients unless backtraces are explicitly enabled.
552
+ message = if e.is_a?(ActiveRecord::StatementInvalid) && !RESTFramework.config.show_backtrace
553
+ "Invalid query."
554
+ else
555
+ e.message
556
+ end
557
+
558
+ render_api(
559
+ {
560
+ message: message,
480
561
  errors: e.try(:record).try(:errors),
481
562
  exception: RESTFramework.config.show_backtrace ? e.full_message : nil,
482
563
  }.compact,
@@ -561,11 +642,8 @@ module RESTFramework::Controller
561
642
  end
562
643
  end
563
644
 
564
- # Compatibility alias for deprecated `api_response`.
565
- alias_method :api_response, :render_api
566
-
567
645
  def options
568
- render(api: self.openapi_document)
646
+ render_api(self.openapi_document)
569
647
  end
570
648
 
571
649
  def get_fields
@@ -594,13 +672,13 @@ module RESTFramework::Controller
594
672
 
595
673
  # ActiveStorage Integration: `has_one_attached`
596
674
  if self.class.enable_active_storage && reflections.key?("#{f}_attachment")
597
- hash_variations[f] = ACTIVESTORAGE_KEYS
675
+ hash_variations[f] = RRF_ACTIVESTORAGE_KEYS
598
676
  next f
599
677
  end
600
678
 
601
679
  # ActiveStorage Integration: `has_many_attached`
602
680
  if self.class.enable_active_storage && reflections.key?("#{f}_attachments")
603
- hash_variations[f] = ACTIVESTORAGE_KEYS
681
+ hash_variations[f] = RRF_ACTIVESTORAGE_KEYS
604
682
  next nil
605
683
  end
606
684
 
@@ -618,7 +696,7 @@ module RESTFramework::Controller
618
696
  # TODO: Consider adjusting this based on `nested_attributes_options`.
619
697
  if self.class.permit_nested_attributes_assignment
620
698
  hash_variations["#{f}_attributes"] = (
621
- config[:sub_fields] + [ "_destroy" ]
699
+ config[:fields] + [ "_destroy" ]
622
700
  )
623
701
  end
624
702
 
@@ -636,7 +714,7 @@ module RESTFramework::Controller
636
714
  end
637
715
 
638
716
  # Use strong parameters to filter the request body.
639
- def get_body_params(bulk_mode: nil)
717
+ def get_body_params(bulk_action: nil)
640
718
  data = self.request.request_parameters
641
719
  pk = self.class.model&.primary_key
642
720
  allowed_params = self.get_allowed_parameters
@@ -645,15 +723,17 @@ module RESTFramework::Controller
645
723
  # assignment ActiveRecord API or the nested assignment ActiveRecord API. Note that there is no
646
724
  # need to check for `permit_id_assignment` or `permit_nested_attributes_assignment` here, since
647
725
  # that is enforced by strong parameters generated by `get_allowed_parameters`.
648
- self.class.model.reflections.each do |name, ref|
649
- if payload = data[name]
650
- if payload.is_a?(Hash) || (payload.is_a?(Array) && payload.all? { |x| x.is_a?(Hash) })
651
- # Assume nested attributes assignment.
652
- attributes_key = "#{name}_attributes"
653
- data[attributes_key] = data.delete(name) unless data[attributes_key]
654
- elsif id_field = RESTFramework::Utils.id_field_for(name, ref)
655
- # Assume id/ids assignment.
656
- data[id_field] = data.delete(name) unless data[id_field]
726
+ if !bulk_action && self.class.model
727
+ self.class.model.reflections.each do |name, ref|
728
+ if payload = data[name]
729
+ if payload.is_a?(Hash) || (payload.is_a?(Array) && payload.all? { |x| x.is_a?(Hash) })
730
+ # Assume nested attributes assignment.
731
+ attributes_key = "#{name}_attributes"
732
+ data[attributes_key] = data.delete(name) unless data[attributes_key]
733
+ elsif id_field = RESTFramework::Utils.id_field_for(name, ref)
734
+ # Assume id/ids assignment.
735
+ data[id_field] = data.delete(name) unless data[id_field]
736
+ end
657
737
  end
658
738
  end
659
739
  end
@@ -669,12 +749,12 @@ module RESTFramework::Controller
669
749
  #
670
750
  # rubocop:enable Layout/LineLength
671
751
  has_many_attached_scalar_data = {}
672
- if self.class.enable_active_storage
752
+ if !bulk_action && self.class.enable_active_storage && self.class.model
673
753
  self.class.model.attachment_reflections.keys.each do |k|
674
754
  if data[k].is_a?(Array)
675
755
  data[k] = data[k].map { |v|
676
756
  if v.is_a?(String)
677
- v = BASE64_TRANSLATE.call(k, v)
757
+ v = RRF_BASE64_TRANSLATE.call(k, v)
678
758
 
679
759
  # Remember scalars because Rails strong params will remove it.
680
760
  if v.is_a?(String)
@@ -694,7 +774,7 @@ module RESTFramework::Controller
694
774
  data[k][:io] = StringIO.new(Base64.decode64(data[k][:io]))
695
775
  end
696
776
  elsif data[k].is_a?(String)
697
- data[k] = BASE64_TRANSLATE.call(k, data[k])
777
+ data[k] = RRF_BASE64_TRANSLATE.call(k, data[k])
698
778
  end
699
779
  end
700
780
  end
@@ -703,9 +783,16 @@ module RESTFramework::Controller
703
783
  # parameters to the `_json` key of the request body.
704
784
  body_params = if allowed_params == true
705
785
  ActionController::Parameters.new(data).permit!
706
- elsif bulk_mode
707
- pk = bulk_mode == :update ? [ pk ] : []
708
- ActionController::Parameters.new(data).permit({ _json: allowed_params + pk })
786
+ elsif bulk_action
787
+ if bulk_action == :create
788
+ ActionController::Parameters.new(data).permit({ _json: allowed_params })
789
+ elsif bulk_action == :update
790
+ ActionController::Parameters.new(data).permit({ _json: allowed_params + [ pk ] })
791
+ elsif bulk_action == :destroy
792
+ ActionController::Parameters.new(data).permit({ _json: [] })
793
+ else
794
+ raise ArgumentError, "Invalid bulk action: #{bulk_action}"
795
+ end
709
796
  else
710
797
  ActionController::Parameters.new(data).permit(*allowed_params)
711
798
  end
@@ -719,16 +806,36 @@ module RESTFramework::Controller
719
806
  body_params[k].unshift(*v)
720
807
  end
721
808
 
722
- # Filter read-only fields.
723
- body_params.delete_if do |f, _|
724
- cfg = self.class.field_configuration[f]
725
- cfg && cfg[:read_only]
809
+ # Filter read-only fields. For bulk actions the permitted structure is `{ _json: [...] }`, so we
810
+ # strip read-only keys from each element rather than the top-level hash (whose only key is
811
+ # `_json`). Bulk update keeps the primary key, which it needs to locate each record.
812
+ if bulk_action
813
+ keep = bulk_action == :update ? [ pk.to_s ] : []
814
+ body_params[:_json]&.each do |element|
815
+ next unless element.is_a?(ActionController::Parameters)
816
+
817
+ self._rrf_strip_read_only_fields(element, keep: keep)
818
+ end
819
+ else
820
+ self._rrf_strip_read_only_fields(body_params)
726
821
  end
727
822
 
728
823
  body_params
729
824
  end
730
825
  alias_method :get_create_params, :get_body_params
731
826
  alias_method :get_update_params, :get_body_params
827
+ alias_method :get_destroy_params, :get_body_params
828
+
829
+ # Remove read-only fields from a permitted params hash in place. `keep` lists field names to
830
+ # preserve even when read-only (e.g. the primary key on bulk update, used to locate records).
831
+ def _rrf_strip_read_only_fields(params, keep: [])
832
+ params.delete_if do |f, _|
833
+ next false if f.in?(keep)
834
+
835
+ cfg = self.class.field_configuration[f]
836
+ cfg && cfg[:read_only]
837
+ end
838
+ end
732
839
 
733
840
  # Get the set of records this controller has access to.
734
841
  def get_recordset
@@ -760,13 +867,16 @@ module RESTFramework::Controller
760
867
 
761
868
  # Find by another column if it's permitted.
762
869
  if find_by_param = self.class.find_by_query_param.presence
763
- if find_by = params[find_by_param].presence
764
- find_by_fields = self.class.find_by_fields&.map(&:to_s)
870
+ if find_by = request.query_parameters[find_by_param].presence
871
+ find_by_fields = (
872
+ self.class.find_by_fields&.map(&:to_s) || self.class.model.columns_hash.keys
873
+ )
765
874
 
766
- if !find_by_fields || find_by.in?(find_by_fields)
767
- is_pk = false unless find_by_key == find_by
768
- find_by_key = find_by
769
- end
875
+ # A `find_by` was explicitly requested, so it must be a permitted field.
876
+ raise ActiveRecord::RecordNotFound unless find_by.in?(find_by_fields)
877
+
878
+ is_pk = false unless find_by_key == find_by
879
+ find_by_key = find_by
770
880
  end
771
881
  end
772
882
 
@@ -799,6 +909,7 @@ module RESTFramework::Controller
799
909
  end
800
910
  end
801
911
 
912
+ require_relative "controller/actions"
802
913
  require_relative "controller/bulk"
803
914
  require_relative "controller/crud"
804
915
  require_relative "controller/openapi"