rest_framework 1.2.0 → 2.0.0.beta2

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.
@@ -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,
@@ -17,7 +15,6 @@ module RESTFramework::Controller
17
15
  # Options related to models.
18
16
  model: nil,
19
17
  recordset: nil,
20
- excluded_actions: nil,
21
18
 
22
19
  # Bulk configuration.
23
20
  #
@@ -43,9 +40,6 @@ module RESTFramework::Controller
43
40
  find_by_fields: nil,
44
41
  find_by_query_param: "find_by".freeze,
45
42
 
46
- # What should be included/excluded from default fields.
47
- exclude_associations: false,
48
-
49
43
  # Handling request body parameters.
50
44
  allowed_parameters: nil,
51
45
 
@@ -57,9 +51,26 @@ module RESTFramework::Controller
57
51
  native_serializer_except_query_param: "except".freeze,
58
52
  native_serializer_include_query_param: "include".freeze,
59
53
  native_serializer_exclude_query_param: "exclude".freeze,
60
- native_serializer_associations_limit: nil,
61
- native_serializer_associations_limit_query_param: "associations_limit".freeze,
62
- native_serializer_include_associations_count: false,
54
+
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,
63
74
 
64
75
  # Options for filtering, ordering, and searching.
65
76
  filter_backends: [
@@ -93,12 +104,16 @@ module RESTFramework::Controller
93
104
  serialize_to_json: true,
94
105
  serialize_to_xml: true,
95
106
 
96
- # Options related to pagination.
97
- 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,
98
110
  page_size: 20,
99
- page_query_param: "page",
100
- page_size_query_param: "page_size",
101
- 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,
102
117
 
103
118
  # Option to disable serializer adapters by default, mainly introduced because Active Model
104
119
  # Serializers will do things like serialize `[]` into `{"":[]}`.
@@ -125,6 +140,7 @@ module RESTFramework::Controller
125
140
  ActiveRecord::RecordNotSaved,
126
141
  ActiveRecord::RecordNotDestroyed,
127
142
  ActiveRecord::RecordNotUnique,
143
+ ActiveRecord::StatementInvalid,
128
144
  ActiveModel::UnknownAttributeError,
129
145
  ].freeze
130
146
 
@@ -142,14 +158,84 @@ module RESTFramework::Controller
142
158
  }
143
159
  RRF_ACTIVESTORAGE_KEYS = [ :io, :content_type, :filename, :identify, :key ]
144
160
 
145
- # Default action for API root.
146
- def root
147
- render(api: { message: "This is the API root." })
148
- end
149
-
150
161
  module ClassMethods
151
162
  IGNORE_VALIDATORS_WITH_KEYS = [ :if, :unless ].freeze
152
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
+
153
239
  # By default, this is the name of the controller class, titleized and with any custom inflection
154
240
  # acronyms applied.
155
241
  def get_title
@@ -167,48 +253,29 @@ module RESTFramework::Controller
167
253
  self.model&.human_attribute_name(s, default: default_title) || default_title
168
254
  end
169
255
 
170
- # Define any behavior to execute at the end of controller definition.
171
- # :nocov:
172
- def rrf_finalize
173
- if RESTFramework.config.freeze_config
174
- self::RRF_BASE_CONFIG.keys.each { |k|
175
- v = self.send(k)
176
- v.freeze if v.is_a?(Hash) || v.is_a?(Array)
177
- }
178
- end
179
-
180
- self.setup_delegation if self.model
181
- # self.setup_channel if self.model
182
- end
183
- # :nocov:
184
-
185
- # Get the available fields. Fallback to this controller's model columns, or an empty array. This
186
- # should always return an array of strings.
187
- def get_fields(input_fields: nil)
188
- input_fields ||= self.fields
189
-
190
- # If fields is a hash, then parse it.
191
- if input_fields.is_a?(Hash)
192
- return RESTFramework::Utils.parse_fields_hash(
193
- input_fields,
256
+ # Resolve the `fields` config to an array of strings. Memoized, since `fields` and the flags it
257
+ # depends on are class-level config fixed at load time.
258
+ def get_fields
259
+ @get_fields ||= if self.fields.is_a?(Hash)
260
+ RESTFramework::Utils.parse_fields_hash(
261
+ self.fields,
194
262
  self.model,
195
263
  exclude_associations: self.exclude_associations,
196
264
  action_text: self.enable_action_text,
197
265
  active_storage: self.enable_active_storage,
198
266
  )
199
- elsif !input_fields
200
- # Otherwise, if fields is nil, then fallback to columns.
201
- return self.model ? RESTFramework::Utils.fields_for(
267
+ elsif self.fields
268
+ self.fields.map(&:to_s)
269
+ elsif self.model
270
+ RESTFramework::Utils.fields_for(
202
271
  self.model,
203
272
  exclude_associations: self.exclude_associations,
204
273
  action_text: self.enable_action_text,
205
274
  active_storage: self.enable_active_storage,
206
- ) : []
207
- elsif input_fields
208
- input_fields = input_fields.map(&:to_s)
275
+ )
276
+ else
277
+ []
209
278
  end
210
-
211
- input_fields
212
279
  end
213
280
 
214
281
  # Get a full field configuration, including defaults and inferred values.
@@ -302,17 +369,22 @@ module RESTFramework::Controller
302
369
  if ref = reflections[f]
303
370
  cfg[:kind] = "association"
304
371
 
305
- # Determine sub-fields for associations.
372
+ # Determine the association's fields.
306
373
  if ref.polymorphic?
307
374
  ref_columns = {}
308
375
  else
309
376
  ref_columns = ref.klass.columns_hash
310
377
  end
311
- cfg[:sub_fields] ||= RESTFramework::Utils.sub_fields_for(ref)
312
- cfg[:sub_fields] = cfg[:sub_fields].map(&:to_s)
378
+ cfg[:fields] ||= RESTFramework::Utils.association_fields_for(ref)
379
+ cfg[:fields] = cfg[:fields].map(&:to_s)
380
+
381
+ # Strings, to match `:fields` when intersecting requested fields against the allowlist.
382
+ if cfg[:requestable_fields]
383
+ cfg[:requestable_fields] = cfg[:requestable_fields].map(&:to_s)
384
+ end
313
385
 
314
- # Very basic metadata about sub-fields.
315
- cfg[:sub_fields_metadata] = cfg[:sub_fields].map { |sf|
386
+ # Very basic metadata about the association's fields.
387
+ cfg[:association_fields_metadata] = cfg[:fields].map { |sf|
316
388
  v = {}
317
389
 
318
390
  if ref_columns[sf]
@@ -375,13 +447,6 @@ module RESTFramework::Controller
375
447
  # Update `required` if we find a presence validator.
376
448
  cfg[:required] = true if kind == :presence
377
449
 
378
- # Resolve procs (and lambdas), and symbols for certain arguments.
379
- if options[:in].is_a?(Proc)
380
- options = options.merge(in: options[:in].call)
381
- elsif options[:in].is_a?(Symbol)
382
- options = options.merge(in: self.model.send(options[:in]))
383
- end
384
-
385
450
  cfg[:validators] ||= {}
386
451
  cfg[:validators][kind] ||= []
387
452
  cfg[:validators][kind] << options
@@ -389,39 +454,42 @@ module RESTFramework::Controller
389
454
 
390
455
  next [ f, cfg ]
391
456
  }.to_h.compact.with_indifferent_access
392
- end
393
457
 
394
- # Only for model controllers.
395
- def setup_delegation
396
- # Delegate extra actions.
397
- self.extra_actions&.each do |action, config|
398
- next unless config.is_a?(Hash) && config.dig(:metadata, :delegate)
399
- next unless self.model.respond_to?(action)
458
+ # Compile each association's requestable-fields allowlist once (see
459
+ # `enable_association_queries`). This runs as a second pass, after `@field_configuration` is
460
+ # memoized, because resolving a sibling's fields reads its `field_configuration` — and a
461
+ # self-referential or mutual association would otherwise recurse into this build.
462
+ if self.enable_association_queries
463
+ @field_configuration.each do |_f, cfg|
464
+ next unless cfg[:kind] == "association"
400
465
 
401
- self.define_method(action) do
402
- if self.class.model.method(action).parameters.last&.first == :keyrest
403
- render(api: self.class.model.send(action, **request.query_parameters.symbolize_keys))
404
- else
405
- render(api: self.class.model.send(action))
406
- end
466
+ cfg[:requestable_fields] ||= self.association_requestable_fields(cfg[:reflection])
407
467
  end
408
468
  end
409
469
 
410
- # Delegate extra member actions.
411
- self.extra_member_actions&.each do |action, config|
412
- next unless config.is_a?(Hash) && config.dig(:metadata, :delegate)
413
- next unless self.model.method_defined?(action)
414
-
415
- self.define_method(action) do
416
- record = self.get_record
470
+ @field_configuration
471
+ end
417
472
 
418
- if record.method(action).parameters.last&.first == :keyrest
419
- render(api: record.send(action, **request.query_parameters.symbolize_keys))
420
- else
421
- render(api: record.send(action))
422
- end
423
- end
424
- end
473
+ # The fields a consumer may request for an association beyond its defaults, derived from the
474
+ # associated model's sibling controller: what that controller serializes, so the association can
475
+ # never expose more than its own endpoint would. Empty unless the sibling is discoverable and
476
+ # introspectable — a custom serializer makes its `get_fields` meaningless. Hidden fields are
477
+ # included (retrievable via `?only=` there); write-only fields and nested associations aren't.
478
+ def association_requestable_fields(ref)
479
+ return [] if ref.polymorphic?
480
+
481
+ sibling = RESTFramework::Utils.controller_for_model(self, ref.klass)
482
+ return [] unless sibling
483
+ return [] if sibling.serializer_class ||
484
+ sibling.native_serializer_config ||
485
+ sibling.native_serializer_singular_config ||
486
+ sibling.native_serializer_plural_config
487
+
488
+ cfg = sibling.field_configuration
489
+ sibling.get_fields.reject { |sf|
490
+ c = cfg[sf]
491
+ c.nil? || c[:write_only] || c[:kind] == "association"
492
+ }
425
493
  end
426
494
  end
427
495
 
@@ -433,18 +501,12 @@ module RESTFramework::Controller
433
501
  # By default, the layout should be set to `rest_framework`.
434
502
  base.layout("rest_framework")
435
503
 
436
- # Add class attributes unless they already exist.
504
+ # Materialize config with `rrf_class_attribute` (local by default) rather than `class_attribute`
505
+ # (always inherited).
437
506
  RRF_BASE_CONFIG.each do |a, default|
438
507
  next if base.respond_to?(a)
439
508
 
440
- # Don't leak class attributes to the instance to avoid conflicting with action methods.
441
- base.class_attribute(a, default: default, instance_accessor: false)
442
- end
443
-
444
- # Alias `extra_actions` to `extra_collection_actions`.
445
- unless base.respond_to?(:extra_collection_actions)
446
- base.singleton_class.alias_method(:extra_collection_actions, :extra_actions)
447
- base.singleton_class.alias_method(:extra_collection_actions=, :extra_actions=)
509
+ base.rrf_class_attribute(a, default: default)
448
510
  end
449
511
 
450
512
  # Skip CSRF since this is an API.
@@ -458,21 +520,6 @@ module RESTFramework::Controller
458
520
  # Handle exceptions.
459
521
  base.rescue_from(*RRF_RESCUED_EXCEPTIONS, with: :rrf_error_handler)
460
522
  base.rescue_from(*RRF_RESCUED_RAILS_EXCEPTIONS, with: :rrf_error_handler)
461
-
462
- # Use `TracePoint` hook to automatically call `rrf_finalize`.
463
- if RESTFramework.config.auto_finalize
464
- # :nocov:
465
- TracePoint.trace(:end) do |t|
466
- next if base != t.self
467
-
468
- base.rrf_finalize
469
-
470
- # It's important to disable the trace once we've found the end of the base class definition,
471
- # for performance.
472
- t.disable
473
- end
474
- # :nocov:
475
- end
476
523
  end
477
524
 
478
525
  def get_serializer_class
@@ -496,9 +543,17 @@ module RESTFramework::Controller
496
543
  400
497
544
  end
498
545
 
499
- render(
500
- api: {
501
- message: e.message,
546
+ # `StatementInvalid` messages commonly embed SQL fragments and schema details, so don't leak
547
+ # them to clients unless backtraces are explicitly enabled.
548
+ message = if e.is_a?(ActiveRecord::StatementInvalid) && !RESTFramework.config.show_backtrace
549
+ "Invalid query."
550
+ else
551
+ e.message
552
+ end
553
+
554
+ render_api(
555
+ {
556
+ message: message,
502
557
  errors: e.try(:record).try(:errors),
503
558
  exception: RESTFramework.config.show_backtrace ? e.full_message : nil,
504
559
  }.compact,
@@ -583,18 +638,32 @@ module RESTFramework::Controller
583
638
  end
584
639
  end
585
640
 
586
- # Deprecated alias for `render_api`.
587
- def api_response(*args, **kwargs)
588
- RESTFramework.deprecator.warn("`api_response` is deprecated; use `render_api` instead.")
589
- render_api(*args, **kwargs)
590
- end
591
-
592
641
  def options
593
- render(api: self.openapi_document)
642
+ render_api(self.openapi_document)
594
643
  end
595
644
 
596
645
  def get_fields
597
- self.class.get_fields(input_fields: self.class.fields)
646
+ self.class.get_fields
647
+ end
648
+
649
+ def readable_fields
650
+ cfg = self.class.field_configuration
651
+ self.get_fields.reject { |f| cfg[f]&.[](:write_only) }
652
+ end
653
+
654
+ # `readable_fields` restricted to real columns, for query surfaces that build SQL directly
655
+ # (find_by, search) and would raise on a virtual/method field.
656
+ def readable_columns
657
+ self.readable_fields & self.class.model.column_names
658
+ end
659
+
660
+ # `readable_fields` restricted to columns and associations, for surfaces that also resolve dotted
661
+ # `association.sub_field` paths (filtering, ordering). Excludes virtual/method fields, which have
662
+ # no column to order or filter by.
663
+ def readable_columns_or_associations
664
+ cfg = self.class.field_configuration
665
+ columns = self.class.model.column_names
666
+ self.readable_fields.select { |f| f.in?(columns) || cfg[f]&.[](:kind) == "association" }
598
667
  end
599
668
 
600
669
  # Get a hash of strong parameters for the current action.
@@ -604,7 +673,8 @@ module RESTFramework::Controller
604
673
  @_get_allowed_parameters = self.class.allowed_parameters
605
674
  return @_get_allowed_parameters if @_get_allowed_parameters
606
675
 
607
- # Assemble strong parameters.
676
+ # Assemble strong parameters. Read-only fields are permitted here and stripped later per-action
677
+ # by `_rrf_strip_read_only_fields` (which keeps the primary key on bulk update to find records).
608
678
  variations = []
609
679
  hash_variations = {}
610
680
  reflections = self.class.model.reflections
@@ -643,7 +713,7 @@ module RESTFramework::Controller
643
713
  # TODO: Consider adjusting this based on `nested_attributes_options`.
644
714
  if self.class.permit_nested_attributes_assignment
645
715
  hash_variations["#{f}_attributes"] = (
646
- config[:sub_fields] + [ "_destroy" ]
716
+ config[:fields] + [ "_destroy" ]
647
717
  )
648
718
  end
649
719
 
@@ -753,10 +823,18 @@ module RESTFramework::Controller
753
823
  body_params[k].unshift(*v)
754
824
  end
755
825
 
756
- # Filter read-only fields.
757
- body_params.delete_if do |f, _|
758
- cfg = self.class.field_configuration[f]
759
- cfg && cfg[:read_only]
826
+ # Filter read-only fields. For bulk actions the permitted structure is `{ _json: [...] }`, so we
827
+ # strip read-only keys from each element rather than the top-level hash (whose only key is
828
+ # `_json`). Bulk update keeps the primary key, which it needs to locate each record.
829
+ if bulk_action
830
+ keep = bulk_action == :update ? [ pk.to_s ] : []
831
+ body_params[:_json]&.each do |element|
832
+ next unless element.is_a?(ActionController::Parameters)
833
+
834
+ self._rrf_strip_read_only_fields(element, keep: keep)
835
+ end
836
+ else
837
+ self._rrf_strip_read_only_fields(body_params)
760
838
  end
761
839
 
762
840
  body_params
@@ -765,6 +843,17 @@ module RESTFramework::Controller
765
843
  alias_method :get_update_params, :get_body_params
766
844
  alias_method :get_destroy_params, :get_body_params
767
845
 
846
+ # Remove read-only fields from a permitted params hash in place. `keep` lists field names to
847
+ # preserve even when read-only (e.g. the primary key on bulk update, used to locate records).
848
+ def _rrf_strip_read_only_fields(params, keep: [])
849
+ params.delete_if do |f, _|
850
+ next false if f.in?(keep)
851
+
852
+ cfg = self.class.field_configuration[f]
853
+ cfg && cfg[:read_only]
854
+ end
855
+ end
856
+
768
857
  # Get the set of records this controller has access to.
769
858
  def get_recordset
770
859
  return self.class.recordset if self.class.recordset
@@ -796,12 +885,16 @@ module RESTFramework::Controller
796
885
  # Find by another column if it's permitted.
797
886
  if find_by_param = self.class.find_by_query_param.presence
798
887
  if find_by = request.query_parameters[find_by_param].presence
799
- find_by_fields = self.class.find_by_fields&.map(&:to_s) || self.get_fields
888
+ # Default to readable columns: excluding write_only keeps hidden values from being used as
889
+ # lookup keys, and restricting to real columns keeps virtual/method fields from reaching a
890
+ # doomed `find_by(<not a column>)` (which would raise on the DB).
891
+ find_by_fields = self.class.find_by_fields&.map(&:to_s) || self.readable_columns
800
892
 
801
- if find_by.in?(find_by_fields)
802
- is_pk = false unless find_by_key == find_by
803
- find_by_key = find_by
804
- end
893
+ # A `find_by` was explicitly requested, so it must be a permitted field.
894
+ raise ActiveRecord::RecordNotFound unless find_by.in?(find_by_fields)
895
+
896
+ is_pk = false unless find_by_key == find_by
897
+ find_by_key = find_by
805
898
  end
806
899
  end
807
900
 
@@ -834,6 +927,7 @@ module RESTFramework::Controller
834
927
  end
835
928
  end
836
929
 
930
+ require_relative "controller/actions"
837
931
  require_relative "controller/bulk"
838
932
  require_relative "controller/crud"
839
933
  require_relative "controller/openapi"
@@ -4,7 +4,7 @@ module RESTFramework::Errors
4
4
 
5
5
  class NilPassedToRenderAPIError < BaseError
6
6
  def message
7
- <<~MSG.split("\n").join(" ")
7
+ <<~MSG.squish
8
8
  Payload of `nil` was passed to `render_api`; this is unsupported. If you want a blank
9
9
  response, pass `''` (an empty string) as the payload. If this was the result of a `find_by`
10
10
  (or similar Active Record method) not finding a record, you should use the bang version
@@ -26,6 +26,22 @@ module RESTFramework::Errors
26
26
  end
27
27
  end
28
28
 
29
+ class DelegatedMethodError < BaseError
30
+ def initialize(receiver, target)
31
+ @receiver = receiver.is_a?(Class) ? receiver : receiver.class
32
+ @target = target
33
+ end
34
+
35
+ def message
36
+ <<~MSG.squish
37
+ Delegated action `#{@target}` does not resolve to a public method on `#{@receiver}`. This is
38
+ almost certainly a typo, a missing method, or a method that should be public. Define a
39
+ public class method (for a collection action) or instance method (for a member action), or
40
+ remove the action.
41
+ MSG
42
+ end
43
+ end
44
+
29
45
  class BulkRecordErrorsError < BaseError
30
46
  attr_reader :errors
31
47
 
@@ -53,4 +69,5 @@ end
53
69
  RESTFramework::BaseError = RESTFramework::Errors::BaseError
54
70
  RESTFramework::NilPassedToRenderAPIError = RESTFramework::Errors::NilPassedToRenderAPIError
55
71
  RESTFramework::InvalidBulkParametersError = RESTFramework::Errors::InvalidBulkParametersError
72
+ RESTFramework::DelegatedMethodError = RESTFramework::Errors::DelegatedMethodError
56
73
  RESTFramework::BulkRecordErrorsError = RESTFramework::Errors::BulkRecordErrorsError
@@ -6,6 +6,16 @@ class RESTFramework::Filters::BaseFilter
6
6
  def filter_data(data)
7
7
  raise NotImplementedError
8
8
  end
9
+
10
+ # True when `v` is a query-parameter value safe to feed into `where`, string
11
+ # operations, or `split` — i.e. a String or an Array of Strings. Guards against
12
+ # nested-hash inputs like `?field[evil]=x`, which Rack parses into a Hash and
13
+ # which AR cannot quote as a bind.
14
+ def self._safe_query_value?(v)
15
+ return true if v.is_a?(String)
16
+ return v.all? { |el| el.is_a?(String) } if v.is_a?(Array)
17
+ false
18
+ end
9
19
  end
10
20
 
11
21
  # Alias for convenience.