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