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.
- checksums.yaml +4 -4
- data/README.md +109 -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 +231 -137
- 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 +43 -23
- data/lib/rest_framework/filters/query_filter.rb +19 -5
- data/lib/rest_framework/filters/search_filter.rb +8 -8
- 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,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
|
-
#
|
|
171
|
-
#
|
|
172
|
-
def
|
|
173
|
-
if
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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
|
|
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[:
|
|
312
|
-
cfg[:
|
|
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
|
|
315
|
-
cfg[:
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
#
|
|
397
|
-
self
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
470
|
+
@field_configuration
|
|
471
|
+
end
|
|
417
472
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
|
|
642
|
+
render_api(self.openapi_document)
|
|
594
643
|
end
|
|
595
644
|
|
|
596
645
|
def get_fields
|
|
597
|
-
self.class.get_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[:
|
|
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
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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
|
-
|
|
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
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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.
|
|
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.
|