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.
- checksums.yaml +4 -4
- data/README.md +107 -41
- data/VERSION +1 -1
- data/lib/rest_framework/controller/actions.rb +256 -0
- data/lib/rest_framework/controller/bulk.rb +15 -10
- data/lib/rest_framework/controller/crud.rb +10 -5
- data/lib/rest_framework/controller/openapi.rb +3 -2
- data/lib/rest_framework/controller.rb +194 -118
- data/lib/rest_framework/errors.rb +18 -1
- data/lib/rest_framework/filters/base_filter.rb +10 -0
- data/lib/rest_framework/filters/ordering_filter.rb +41 -22
- data/lib/rest_framework/filters/query_filter.rb +17 -3
- data/lib/rest_framework/filters/search_filter.rb +4 -0
- data/lib/rest_framework/paginators/page_number_paginator.rb +25 -9
- data/lib/rest_framework/routers.rb +52 -188
- data/lib/rest_framework/serializers/active_model_serializer_adapter_factory.rb +2 -2
- data/lib/rest_framework/serializers/base_serializer.rb +2 -2
- data/lib/rest_framework/serializers/native_serializer.rb +76 -24
- data/lib/rest_framework/utils.rb +32 -70
- data/lib/rest_framework/version.rb +8 -5
- data/lib/rest_framework.rb +7 -38
- metadata +5 -8
- data/lib/rest_framework/mixins/base_controller_mixin.rb +0 -12
- data/lib/rest_framework/mixins/bulk_model_controller_mixin.rb +0 -55
- data/lib/rest_framework/mixins/model_controller_mixin.rb +0 -110
- 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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
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:
|
|
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
|
|
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[:
|
|
312
|
-
cfg[:
|
|
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
|
|
315
|
-
cfg[:
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
#
|
|
397
|
-
self
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
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
|
-
|
|
411
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
|
|
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[:
|
|
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
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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 =
|
|
871
|
+
find_by_fields = (
|
|
872
|
+
self.class.find_by_fields&.map(&:to_s) || self.class.model.columns_hash.keys
|
|
873
|
+
)
|
|
800
874
|
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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.
|
|
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
|
|
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.
|
|
14
|
+
order_string = @controller.request.query_parameters[param]
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
+
ordering, references = self._get_ordering
|
|
61
|
+
return data unless ordering
|
|
43
62
|
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
if sub_field.in?(
|
|
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?(
|
|
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.
|