rest_framework 1.2.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.
@@ -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,21 +253,6 @@ 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
256
  # Get the available fields. Fallback to this controller's model columns, or an empty array. This
186
257
  # should always return an array of strings.
187
258
  def get_fields(input_fields: nil)
@@ -302,17 +373,22 @@ module RESTFramework::Controller
302
373
  if ref = reflections[f]
303
374
  cfg[:kind] = "association"
304
375
 
305
- # Determine sub-fields for associations.
376
+ # Determine the association's fields.
306
377
  if ref.polymorphic?
307
378
  ref_columns = {}
308
379
  else
309
380
  ref_columns = ref.klass.columns_hash
310
381
  end
311
- cfg[:sub_fields] ||= RESTFramework::Utils.sub_fields_for(ref)
312
- 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)
384
+
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
313
389
 
314
- # Very basic metadata about sub-fields.
315
- cfg[:sub_fields_metadata] = cfg[:sub_fields].map { |sf|
390
+ # Very basic metadata about the association's fields.
391
+ cfg[:association_fields_metadata] = cfg[:fields].map { |sf|
316
392
  v = {}
317
393
 
318
394
  if ref_columns[sf]
@@ -375,13 +451,6 @@ module RESTFramework::Controller
375
451
  # Update `required` if we find a presence validator.
376
452
  cfg[:required] = true if kind == :presence
377
453
 
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
454
  cfg[:validators] ||= {}
386
455
  cfg[:validators][kind] ||= []
387
456
  cfg[:validators][kind] << options
@@ -389,39 +458,42 @@ module RESTFramework::Controller
389
458
 
390
459
  next [ f, cfg ]
391
460
  }.to_h.compact.with_indifferent_access
392
- end
393
461
 
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)
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"
400
469
 
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
470
+ cfg[:requestable_fields] ||= self.association_requestable_fields(cfg[:reflection])
407
471
  end
408
472
  end
409
473
 
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
474
+ @field_configuration
475
+ end
417
476
 
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
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
+ }
425
497
  end
426
498
  end
427
499
 
@@ -433,18 +505,12 @@ module RESTFramework::Controller
433
505
  # By default, the layout should be set to `rest_framework`.
434
506
  base.layout("rest_framework")
435
507
 
436
- # Add class attributes unless they already exist.
508
+ # Materialize config with `rrf_class_attribute` (local by default) rather than `class_attribute`
509
+ # (always inherited).
437
510
  RRF_BASE_CONFIG.each do |a, default|
438
511
  next if base.respond_to?(a)
439
512
 
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=)
513
+ base.rrf_class_attribute(a, default: default)
448
514
  end
449
515
 
450
516
  # Skip CSRF since this is an API.
@@ -458,21 +524,6 @@ module RESTFramework::Controller
458
524
  # Handle exceptions.
459
525
  base.rescue_from(*RRF_RESCUED_EXCEPTIONS, with: :rrf_error_handler)
460
526
  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
527
  end
477
528
 
478
529
  def get_serializer_class
@@ -496,9 +547,17 @@ module RESTFramework::Controller
496
547
  400
497
548
  end
498
549
 
499
- render(
500
- api: {
501
- 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,
502
561
  errors: e.try(:record).try(:errors),
503
562
  exception: RESTFramework.config.show_backtrace ? e.full_message : nil,
504
563
  }.compact,
@@ -583,14 +642,8 @@ module RESTFramework::Controller
583
642
  end
584
643
  end
585
644
 
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
645
  def options
593
- render(api: self.openapi_document)
646
+ render_api(self.openapi_document)
594
647
  end
595
648
 
596
649
  def get_fields
@@ -643,7 +696,7 @@ module RESTFramework::Controller
643
696
  # TODO: Consider adjusting this based on `nested_attributes_options`.
644
697
  if self.class.permit_nested_attributes_assignment
645
698
  hash_variations["#{f}_attributes"] = (
646
- config[:sub_fields] + [ "_destroy" ]
699
+ config[:fields] + [ "_destroy" ]
647
700
  )
648
701
  end
649
702
 
@@ -753,10 +806,18 @@ module RESTFramework::Controller
753
806
  body_params[k].unshift(*v)
754
807
  end
755
808
 
756
- # Filter read-only fields.
757
- body_params.delete_if do |f, _|
758
- cfg = self.class.field_configuration[f]
759
- 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)
760
821
  end
761
822
 
762
823
  body_params
@@ -765,6 +826,17 @@ module RESTFramework::Controller
765
826
  alias_method :get_update_params, :get_body_params
766
827
  alias_method :get_destroy_params, :get_body_params
767
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
839
+
768
840
  # Get the set of records this controller has access to.
769
841
  def get_recordset
770
842
  return self.class.recordset if self.class.recordset
@@ -796,12 +868,15 @@ module RESTFramework::Controller
796
868
  # Find by another column if it's permitted.
797
869
  if find_by_param = self.class.find_by_query_param.presence
798
870
  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
871
+ find_by_fields = (
872
+ self.class.find_by_fields&.map(&:to_s) || self.class.model.columns_hash.keys
873
+ )
800
874
 
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
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
805
880
  end
806
881
  end
807
882
 
@@ -834,6 +909,7 @@ module RESTFramework::Controller
834
909
  end
835
910
  end
836
911
 
912
+ require_relative "controller/actions"
837
913
  require_relative "controller/bulk"
838
914
  require_relative "controller/crud"
839
915
  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.
@@ -4,48 +4,67 @@ class RESTFramework::Filters::OrderingFilter < RESTFramework::Filters::BaseFilte
4
4
  @controller.class.ordering_fields&.map(&:to_s) || @controller.get_fields
5
5
  end
6
6
 
7
- # Convert ordering string to an ordering configuration.
7
+ # Convert the ordering param into an `[ordering, references]` pair: the ordering config for
8
+ # `order`/`reorder`, and the association names that must be joined for it to resolve.
8
9
  def _get_ordering
9
10
  return nil unless param = @controller.class.ordering_query_param.presence
10
11
 
11
12
  # Ensure ordering_fields are strings since the split param will be strings.
12
13
  fields = self._get_fields
13
- order_string = @controller.params[param]
14
+ order_string = @controller.request.query_parameters[param]
14
15
 
15
- if order_string.present?
16
- ordering = {}.with_indifferent_access
16
+ # Reject nested-hash inputs like `?ordering[evil]=x` (Rack parses these into
17
+ # a Hash, which can't be split into ordering tokens).
18
+ return nil unless self.class._safe_query_value?(order_string)
19
+ return nil unless order_string.present?
17
20
 
18
- order_string = order_string.join(",") if order_string.is_a?(Array)
19
- order_string.split(",").map(&:strip).each do |field|
20
- if field[0] == "-"
21
- column = field[1..-1]
22
- direction = :desc
23
- else
24
- column = field
25
- direction = :asc
26
- end
21
+ ordering = {}.with_indifferent_access
22
+ references = []
27
23
 
28
- next if !column.in?(fields) && !column.split(".").first.in?(fields)
24
+ order_string = order_string.join(",") if order_string.is_a?(Array)
25
+ order_string.split(",").map(&:strip).each do |field|
26
+ if field[0] == "-"
27
+ column = field[1..-1]
28
+ direction = :desc
29
+ else
30
+ column = field
31
+ direction = :asc
32
+ end
29
33
 
34
+ # A plain, directly-allowlisted field.
35
+ if column.in?(fields)
30
36
  ordering[column] = direction
37
+ next
31
38
  end
32
39
 
33
- return ordering
40
+ # A dotted `association.sub_field` token. The root must be an allowlisted association field,
41
+ # and the sub-field must be one of that association's allowlisted fields. Otherwise a client
42
+ # could order by (and infer, via an ordering oracle) a column that is never serialized.
43
+ root, sub = column.split(".", 2)
44
+ next unless sub && root.in?(fields)
45
+
46
+ cfg = @controller.class.field_configuration[root]
47
+ next unless cfg && sub.in?(cfg[:fields] || [])
48
+
49
+ ordering[column] = direction
50
+ references << root.to_sym
34
51
  end
35
52
 
36
- nil
53
+ return nil if ordering.empty?
54
+
55
+ [ ordering, references ]
37
56
  end
38
57
 
39
58
  # Order data according to the request query parameters.
40
59
  def filter_data(data)
41
- ordering = self._get_ordering
42
- reorder = !@controller.class.ordering_no_reorder
60
+ ordering, references = self._get_ordering
61
+ return data unless ordering
43
62
 
44
- if ordering && !ordering.empty?
45
- return data.send(reorder ? :reorder : :order, ordering)
46
- end
63
+ # Join any referenced associations so dotted ordering keys resolve instead of raising.
64
+ data = data.includes(*references).references(*references) if references.present?
47
65
 
48
- data
66
+ reorder = !@controller.class.ordering_no_reorder
67
+ data.send(reorder ? :reorder : :order, ordering)
49
68
  end
50
69
  end
51
70
 
@@ -37,6 +37,11 @@ class RESTFramework::Filters::QueryFilter < RESTFramework::Filters::BaseFilter
37
37
  }.freeze
38
38
  PREDICATES_REGEX = /^(.*)_(#{PREDICATES.keys.join("|")})$/
39
39
 
40
+ # Predicates whose value may be an array (e.g. `?id_in[]=1&id_in[]=2`). Every other predicate
41
+ # operates on a single scalar and skips array input, which would otherwise raise: `cont` in
42
+ # `sanitize_sql_like`, the range predicates while casting the endpoint.
43
+ ARRAY_PREDICATES = %i[in not].freeze
44
+
40
45
  def _get_fields
41
46
  # Always return a list of strings; `@controller.get_fields` already does this.
42
47
  @controller.class.filter_fields&.map(&:to_s) || @controller.get_fields
@@ -66,6 +71,11 @@ class RESTFramework::Filters::QueryFilter < RESTFramework::Filters::BaseFilter
66
71
  pred_queries = []
67
72
 
68
73
  base_query = @controller.request.query_parameters.map { |field, v|
74
+ # Skip params whose values aren't bind-safe (e.g. a user submitted
75
+ # `?field[evil]=x`, which Rack parses into a Hash). AR can't quote
76
+ # those, and the predicate lambdas below would also blow up on them.
77
+ next nil unless self.class._safe_query_value?(v)
78
+
69
79
  # First, if field is a simple filterable field, return early.
70
80
  if field.in?(fields)
71
81
  next [ field, v ]
@@ -84,11 +94,11 @@ class RESTFramework::Filters::QueryFilter < RESTFramework::Filters::BaseFilter
84
94
  if sub_field
85
95
  next nil unless root_field.in?(fields)
86
96
 
87
- sub_fields = @controller.class.field_configuration[root_field][:sub_fields] || []
88
- if sub_field.in?(sub_fields)
97
+ association_fields = @controller.class.field_configuration[root_field][:fields] || []
98
+ if sub_field.in?(association_fields)
89
99
  includes << root_field.to_sym
90
100
  next [ field, v ]
91
- elsif pred_sub_field && pred_sub_field.in?(sub_fields)
101
+ elsif pred_sub_field && pred_sub_field.in?(association_fields)
92
102
  includes << root_field.to_sym
93
103
  field = pred_field
94
104
  else
@@ -103,6 +113,10 @@ class RESTFramework::Filters::QueryFilter < RESTFramework::Filters::BaseFilter
103
113
  # value into a query that can be used in the ActiveRecord `where` API.
104
114
  cfg = PREDICATES[predicate.to_sym]
105
115
  if cfg.is_a?(Proc)
116
+ # Skip a scalar predicate given an array value (Rack parses `?field_cont[]=a&field_cont[]=b`
117
+ # into an array); only `in`/`not` accept arrays, the rest would raise.
118
+ next nil if v.is_a?(Array) && !predicate.to_sym.in?(ARRAY_PREDICATES)
119
+
106
120
  pred_queries << cfg.call(field, v)
107
121
  else
108
122
  pred_queries << { field => cfg }
@@ -14,6 +14,10 @@ class RESTFramework::Filters::SearchFilter < RESTFramework::Filters::BaseFilter
14
14
  def filter_data(data)
15
15
  search = @controller.request.query_parameters[@controller.class.search_query_param]
16
16
 
17
+ # Reject nested-hash inputs like `?search[evil]=x` (Rack parses these into a
18
+ # Hash, which `sanitize_sql_like` can't accept).
19
+ return data unless search.is_a?(String)
20
+
17
21
  if search.present?
18
22
  if fields = self._get_fields.presence
19
23
  # MySQL doesn't support casting to VARCHAR, so we need to use CHAR instead.