rest_framework 1.1.0 → 1.2.0
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/VERSION +1 -1
- data/app/views/rest_framework/routes_and_forms/_html_form.html.erb +1 -1
- data/lib/rest_framework/controller/bulk.rb +242 -32
- data/lib/rest_framework/controller/crud.rb +4 -5
- data/lib/rest_framework/controller/openapi.rb +9 -6
- data/lib/rest_framework/controller.rb +93 -58
- data/lib/rest_framework/errors.rb +53 -3
- data/lib/rest_framework/filters/ordering_filter.rb +0 -1
- data/lib/rest_framework/filters/query_filter.rb +7 -2
- data/lib/rest_framework/filters/search_filter.rb +4 -4
- data/lib/rest_framework/paginators/page_number_paginator.rb +10 -11
- data/lib/rest_framework/routers.rb +10 -4
- data/lib/rest_framework/serializers/native_serializer.rb +4 -2
- data/lib/rest_framework/utils.rb +7 -0
- data/lib/rest_framework.rb +1 -4
- metadata +2 -6
- 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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8119cab13c69b27ebfa36906e6bcd577688f2ba63b136e0833f4cfdeae3ca5c3
|
|
4
|
+
data.tar.gz: 149343b13dfdf94b5804beb502d2296d95f487774d1cb3a1dd30dd63f4ab2afc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 17d7143b9ac7a9a3f01fab29a369a083750d26f5923bea7d6b9b9dad407efe25bc30321de0024e1d1f3883aa86838bb250b2ee8c4c749e159386b42d5038f5c8
|
|
7
|
+
data.tar.gz: b1794ca103409bc69f235d72603bd8c9ecd91f674cf4e41c8072ec4c35b4e0d5ca26058f6903554df404183ee7c9624dac9b8f33b67fe212874e98bc4a600355
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.
|
|
1
|
+
1.2.0
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
scope: "",
|
|
19
19
|
local: true,
|
|
20
20
|
}.compact) do |form| %>
|
|
21
|
-
<% controller.get_fields.
|
|
21
|
+
<% controller.get_fields.each do |f| %>
|
|
22
22
|
<%
|
|
23
23
|
# Don't provide form fields for associations or read-only fields.
|
|
24
24
|
cfg = controller.class.field_configuration[f]
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
module RESTFramework::Controller
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
RRF_DEFAULT_BULK_MAX_SIZE = 1000
|
|
3
|
+
RRF_DEFAULT_BULK_MAX_RAW_SIZE = 10000
|
|
4
|
+
|
|
5
|
+
def _bulk_max_size
|
|
6
|
+
@_bulk_max_size ||= self.class.bulk_max_size || RRF_DEFAULT_BULK_MAX_SIZE
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def _bulk_max_raw_size
|
|
10
|
+
@_bulk_max_raw_size ||= self.class.bulk_max_raw_size || RRF_DEFAULT_BULK_MAX_RAW_SIZE
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def _bulk_serialize(records)
|
|
5
14
|
# This is kinda slow, so perhaps we should eventually integrate `errors` serialization into
|
|
6
15
|
# the serializer directly. This would fail for active model serializers, but maybe we don't
|
|
7
16
|
# care?
|
|
@@ -11,52 +20,253 @@ module RESTFramework::Controller
|
|
|
11
20
|
end
|
|
12
21
|
end
|
|
13
22
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
create_data = self.get_create_params(bulk_mode: true)[:_json]
|
|
23
|
+
def _bulk_mode
|
|
24
|
+
return @_bulk_mode if defined?(@_bulk_mode)
|
|
17
25
|
|
|
18
|
-
#
|
|
19
|
-
|
|
26
|
+
# If mode override is allowed, check the query param.
|
|
27
|
+
if self.class.bulk_allow_mode_override && (qp = self.class.bulk_mode_query_param)
|
|
28
|
+
if (requested = request.query_parameters[qp].presence)
|
|
29
|
+
requested = requested.to_sym
|
|
30
|
+
unless requested.in?([ :default, :raw ])
|
|
31
|
+
raise RESTFramework::InvalidBulkParametersError.new(
|
|
32
|
+
"Invalid bulk mode: #{requested}. Must be `default` or `raw`.",
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
return @_bulk_mode = requested
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Normalize: `true` and `:default` both mean per-record processing.
|
|
40
|
+
@_bulk_mode = self.class.bulk == :raw ? :raw : :default
|
|
20
41
|
end
|
|
21
42
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
43
|
+
# Resolve whether partial fulfillment is enabled for this request.
|
|
44
|
+
def _bulk_partial
|
|
45
|
+
return @_bulk_partial if defined?(@_bulk_partial)
|
|
46
|
+
|
|
47
|
+
# Check the query param first if configured.
|
|
48
|
+
if (qp = self.class.bulk_partial_query_param)
|
|
49
|
+
if (requested = request.query_parameters[qp].presence)
|
|
50
|
+
return @_bulk_partial = ActiveModel::Type::Boolean.new.cast(requested)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
@_bulk_partial = self.class.bulk_partial
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Validate and extract bulk object data from request parameters.
|
|
58
|
+
def _bulk_object_data(bulk_action, bulk_mode)
|
|
59
|
+
data = self.get_body_params(bulk_action: bulk_action)[:_json]
|
|
60
|
+
|
|
61
|
+
unless data&.is_a?(Array) && data.all? { |r| r.is_a?(ActionController::Parameters) }
|
|
62
|
+
raise RESTFramework::InvalidBulkParametersError.new("Expected an array of objects.")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Enforce size limits.
|
|
66
|
+
max = bulk_mode == :raw ? self._bulk_max_raw_size : self._bulk_max_size
|
|
67
|
+
if max && data.length > max
|
|
68
|
+
raise RESTFramework::InvalidBulkParametersError.new(
|
|
69
|
+
"Too many records (#{data.length}) for #{bulk_mode} mode; maximum is #{max}.",
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
data
|
|
26
74
|
end
|
|
27
75
|
|
|
28
|
-
#
|
|
29
|
-
def
|
|
76
|
+
# Validate and extract bulk primary key data from request parameters.
|
|
77
|
+
def _bulk_pk_data
|
|
78
|
+
data = self.get_destroy_params(bulk_action: :destroy)[:_json]
|
|
79
|
+
|
|
80
|
+
unless data&.is_a?(Array) && data.all? { |r| r.is_a?(String) || r.is_a?(Numeric) }
|
|
81
|
+
raise RESTFramework::InvalidBulkParametersError.new("Expected an array of primary keys.")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Enforce size limits.
|
|
85
|
+
max = self._bulk_mode == :raw ? self._bulk_max_raw_size : self._bulk_max_size
|
|
86
|
+
if max && data.length > max
|
|
87
|
+
raise RESTFramework::InvalidBulkParametersError.new(
|
|
88
|
+
"Too many records (#{data.length}) for #{self._bulk_mode} mode; maximum is #{max}.",
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
data
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def create_all
|
|
96
|
+
if self._bulk_mode == :raw
|
|
97
|
+
result = self.create_all_raw!
|
|
98
|
+
return render(api: { message: "Bulk create successful.", result: result })
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
records = self.create_all_default!
|
|
102
|
+
render(
|
|
103
|
+
api: { message: "Bulk create successful.", records: self._bulk_serialize(records) },
|
|
104
|
+
status: :created,
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def create_all_raw!
|
|
30
109
|
pk = self.class.model.primary_key
|
|
31
|
-
data =
|
|
32
|
-
|
|
110
|
+
data = self._bulk_object_data(:create, :raw)
|
|
111
|
+
|
|
112
|
+
unless first_keys = data.first&.keys&.sort
|
|
113
|
+
raise RESTFramework::InvalidBulkParametersError.new("Expected objects with attrs.")
|
|
114
|
+
end
|
|
115
|
+
unless data.all? { |r| r.keys.sort == first_keys }
|
|
116
|
+
raise RESTFramework::InvalidBulkParametersError.new("All objects must have the same attrs.")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
self.create_from.insert_all(data, unique_by: pk)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def create_all_default!
|
|
123
|
+
data = self._bulk_object_data(:create, :default)
|
|
124
|
+
collection = self.create_from
|
|
125
|
+
|
|
126
|
+
if self._bulk_partial
|
|
127
|
+
# Partial: save each record individually, return all (some may have errors).
|
|
128
|
+
data.map { |attrs| collection.create(attrs) }
|
|
33
129
|
else
|
|
34
|
-
|
|
35
|
-
{
|
|
130
|
+
# Transactional: validate all first, then save in a transaction or raise.
|
|
131
|
+
records = data.map { |attrs| collection.new(attrs) }
|
|
132
|
+
failed = records.reject(&:valid?)
|
|
133
|
+
|
|
134
|
+
if failed.any?
|
|
135
|
+
raise RESTFramework::BulkRecordErrorsError.new(records)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
self.class.model.transaction do
|
|
139
|
+
records.each(&:save!)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
records
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def update_all
|
|
147
|
+
if self._bulk_mode == :raw
|
|
148
|
+
result = self.update_all_raw!
|
|
149
|
+
return render(api: { message: "Bulk update successful.", result: result })
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
records = self.update_all_default!
|
|
153
|
+
render(api: { message: "Bulk update successful.", records: self._bulk_serialize(records) })
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def update_all_raw!
|
|
157
|
+
pk = self.class.model.primary_key
|
|
158
|
+
data = self._bulk_object_data(:update, :raw)
|
|
159
|
+
|
|
160
|
+
data_ids = data.map { |r| r[pk] }.uniq
|
|
161
|
+
if data_ids.include?(nil)
|
|
162
|
+
raise RESTFramework::InvalidBulkParametersError.new(
|
|
163
|
+
"Bulk update requires the primary key (#{pk}) for all records.",
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
found_ids = self.get_recordset.where(pk => data_ids).pluck(pk)
|
|
167
|
+
if found_ids.length != data_ids.length
|
|
168
|
+
missing = data_ids - found_ids
|
|
169
|
+
raise RESTFramework::InvalidBulkParametersError.new(
|
|
170
|
+
"Records not found with #{pk}: #{missing.join(', ')}.",
|
|
171
|
+
)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
unless first_keys = data.first&.keys&.sort
|
|
175
|
+
raise RESTFramework::InvalidBulkParametersError.new("Expected objects with attrs.")
|
|
176
|
+
end
|
|
177
|
+
unless data.all? { |r| r.keys.sort == first_keys }
|
|
178
|
+
raise RESTFramework::InvalidBulkParametersError.new("All objects must have the same attrs.")
|
|
36
179
|
end
|
|
37
180
|
|
|
38
|
-
|
|
39
|
-
|
|
181
|
+
self.get_recordset.upsert_all(data, unique_by: pk)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def update_all_default!
|
|
185
|
+
pk = self.class.model.primary_key
|
|
186
|
+
data = self._bulk_object_data(:update, :default)
|
|
187
|
+
|
|
188
|
+
data_ids = data.map { |r| r[pk] }.uniq
|
|
189
|
+
if data_ids.include?(nil)
|
|
190
|
+
raise RESTFramework::InvalidBulkParametersError.new(
|
|
191
|
+
"Bulk update requires the primary key (#{pk}) for all records.",
|
|
192
|
+
)
|
|
193
|
+
end
|
|
194
|
+
existing = self.get_recordset.where(pk => data_ids).index_by { |r| r.send(pk) }
|
|
195
|
+
if existing.length != data_ids.length
|
|
196
|
+
missing = data_ids - existing.keys
|
|
197
|
+
raise RESTFramework::InvalidBulkParametersError.new(
|
|
198
|
+
"Records not found with #{pk}: #{missing.join(', ')}.",
|
|
199
|
+
)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Assign attributes to each record.
|
|
203
|
+
records = data.map { |attrs|
|
|
204
|
+
record = existing[attrs[pk]]
|
|
205
|
+
record.assign_attributes(attrs.except(pk))
|
|
206
|
+
record
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if self._bulk_partial
|
|
210
|
+
# Partial: save each record individually.
|
|
211
|
+
records.each(&:save)
|
|
212
|
+
records
|
|
213
|
+
else
|
|
214
|
+
# Transactional: validate all first, then save in a transaction or raise.
|
|
215
|
+
failed = records.reject(&:valid?)
|
|
216
|
+
|
|
217
|
+
if failed.any?
|
|
218
|
+
raise RESTFramework::BulkRecordErrorsError.new(records)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
self.class.model.transaction do
|
|
222
|
+
records.each(&:save!)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
records
|
|
226
|
+
end
|
|
40
227
|
end
|
|
41
228
|
|
|
42
229
|
def destroy_all
|
|
43
|
-
if
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
return render(api: serialized_records)
|
|
230
|
+
if self._bulk_mode == :raw
|
|
231
|
+
deleted = self.destroy_all_raw!
|
|
232
|
+
return render(api: { message: "Bulk destroy successful.", result: deleted })
|
|
47
233
|
end
|
|
48
234
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
)
|
|
235
|
+
records = self.destroy_all_default!
|
|
236
|
+
render(api: { message: "Bulk destroy successful.", records: self._bulk_serialize(records) })
|
|
52
237
|
end
|
|
53
238
|
|
|
54
|
-
|
|
55
|
-
|
|
239
|
+
def destroy_all_raw!
|
|
240
|
+
data = self._bulk_pk_data
|
|
56
241
|
pk = self.class.model.primary_key
|
|
57
|
-
|
|
242
|
+
self.get_recordset.where(pk => data).delete_all
|
|
243
|
+
end
|
|
58
244
|
|
|
59
|
-
|
|
60
|
-
|
|
245
|
+
def destroy_all_default!
|
|
246
|
+
data = self._bulk_pk_data
|
|
247
|
+
pk = self.class.model.primary_key
|
|
248
|
+
records = self.get_recordset.where(pk => data).to_a
|
|
249
|
+
|
|
250
|
+
# In transactional mode, verify all requested records exist.
|
|
251
|
+
if !self._bulk_partial && records.length != data.uniq.length
|
|
252
|
+
found_ids = records.map { |r| r.send(pk) }
|
|
253
|
+
missing = data.uniq - found_ids
|
|
254
|
+
raise RESTFramework::InvalidBulkParametersError.new(
|
|
255
|
+
"Bulk destroy requires all records to exist. Missing #{pk}: #{missing.join(', ')}.",
|
|
256
|
+
)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
if self._bulk_partial
|
|
260
|
+
# Partial: destroy each record individually.
|
|
261
|
+
records.each(&:destroy)
|
|
262
|
+
records
|
|
263
|
+
else
|
|
264
|
+
# Transactional: destroy all in a transaction, roll back on failure.
|
|
265
|
+
self.class.model.transaction do
|
|
266
|
+
records.each(&:destroy!)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
records
|
|
270
|
+
end
|
|
61
271
|
end
|
|
62
272
|
end
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
module RESTFramework::Controller
|
|
2
2
|
def create
|
|
3
|
-
# Bulk create: if `bulk` is enabled and the request body is an array, delegate to `create_all
|
|
3
|
+
# Bulk create: if `bulk` is enabled and the request body is an array, delegate to `create_all`.
|
|
4
4
|
if self.class.bulk && params[:_json].is_a?(Array)
|
|
5
|
-
|
|
6
|
-
return render(api: self.bulk_serialize(records))
|
|
5
|
+
return self.create_all
|
|
7
6
|
end
|
|
8
7
|
|
|
9
8
|
render(api: self.create!, status: :created)
|
|
@@ -15,11 +14,11 @@ module RESTFramework::Controller
|
|
|
15
14
|
end
|
|
16
15
|
|
|
17
16
|
def index
|
|
18
|
-
render(api: self.
|
|
17
|
+
render(api: self.index!)
|
|
19
18
|
end
|
|
20
19
|
|
|
21
20
|
# Get records with both filtering and pagination applied.
|
|
22
|
-
def
|
|
21
|
+
def index!
|
|
23
22
|
records = self.get_records
|
|
24
23
|
|
|
25
24
|
# Handle pagination, if enabled.
|
|
@@ -194,13 +194,16 @@ module RESTFramework::Controller
|
|
|
194
194
|
v[:"x-rrf-kind"] = cfg[:kind] if cfg[:kind]
|
|
195
195
|
|
|
196
196
|
if cfg[:reflection]
|
|
197
|
+
ref = cfg[:reflection]
|
|
197
198
|
v[:"x-rrf-reflection"] = {
|
|
198
|
-
class_name:
|
|
199
|
-
foreign_key:
|
|
200
|
-
association_foreign_key:
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
199
|
+
class_name: ref.respond_to?(:class_name) ? ref.class_name : nil,
|
|
200
|
+
foreign_key: ref.respond_to?(:foreign_key) ? ref.foreign_key : nil,
|
|
201
|
+
association_foreign_key: ref.respond_to?(:association_foreign_key) ?
|
|
202
|
+
ref.association_foreign_key : nil,
|
|
203
|
+
association_primary_key: ref.respond_to?(:association_primary_key) ?
|
|
204
|
+
ref.association_primary_key : nil,
|
|
205
|
+
inverse_of: ref.respond_to?(:inverse_of) ? ref.inverse_of&.name : nil,
|
|
206
|
+
join_table: ref.respond_to?(:join_table) ? ref.join_table : nil,
|
|
204
207
|
}.compact
|
|
205
208
|
v[:"x-rrf-association_pk"] = cfg[:association_pk]
|
|
206
209
|
v[:"x-rrf-sub_fields"] = cfg[:sub_fields]
|
|
@@ -14,30 +14,42 @@ module RESTFramework::Controller
|
|
|
14
14
|
inflect_acronyms: RESTFramework.config.inflect_acronyms,
|
|
15
15
|
openapi_include_children: false,
|
|
16
16
|
|
|
17
|
-
#
|
|
17
|
+
# Options related to models.
|
|
18
18
|
model: nil,
|
|
19
19
|
recordset: nil,
|
|
20
20
|
excluded_actions: nil,
|
|
21
|
-
bulk: false,
|
|
22
21
|
|
|
23
|
-
#
|
|
22
|
+
# Bulk configuration.
|
|
23
|
+
#
|
|
24
|
+
# When `bulk` is truthy, it enables the default bulk behavior (`:default`), which is per-record
|
|
25
|
+
# processing (e.g., `create` for each record). When `bulk` is set to `:raw`, it enables single
|
|
26
|
+
# SQL query behavior (e.g., `insert_all` for bulk create) which skips validations/callbacks.
|
|
27
|
+
bulk: false,
|
|
28
|
+
bulk_partial: false,
|
|
29
|
+
bulk_partial_query_param: "bulk_partial".freeze,
|
|
30
|
+
bulk_allow_mode_override: false,
|
|
31
|
+
bulk_mode_query_param: "bulk_mode".freeze,
|
|
32
|
+
bulk_max_size: nil,
|
|
33
|
+
bulk_max_raw_size: nil,
|
|
34
|
+
|
|
35
|
+
# Configuring record fields.
|
|
24
36
|
fields: nil,
|
|
25
37
|
field_config: nil,
|
|
26
38
|
read_only_fields: RESTFramework.config.read_only_fields,
|
|
27
39
|
write_only_fields: RESTFramework.config.write_only_fields,
|
|
28
40
|
hidden_fields: nil,
|
|
29
41
|
|
|
30
|
-
#
|
|
42
|
+
# Finding records.
|
|
31
43
|
find_by_fields: nil,
|
|
32
44
|
find_by_query_param: "find_by".freeze,
|
|
33
45
|
|
|
34
|
-
#
|
|
46
|
+
# What should be included/excluded from default fields.
|
|
35
47
|
exclude_associations: false,
|
|
36
48
|
|
|
37
|
-
#
|
|
49
|
+
# Handling request body parameters.
|
|
38
50
|
allowed_parameters: nil,
|
|
39
51
|
|
|
40
|
-
#
|
|
52
|
+
# Options for the default native serializer.
|
|
41
53
|
native_serializer_config: nil,
|
|
42
54
|
native_serializer_singular_config: nil,
|
|
43
55
|
native_serializer_plural_config: nil,
|
|
@@ -49,7 +61,7 @@ module RESTFramework::Controller
|
|
|
49
61
|
native_serializer_associations_limit_query_param: "associations_limit".freeze,
|
|
50
62
|
native_serializer_include_associations_count: false,
|
|
51
63
|
|
|
52
|
-
#
|
|
64
|
+
# Options for filtering, ordering, and searching.
|
|
53
65
|
filter_backends: [
|
|
54
66
|
RESTFramework::QueryFilter,
|
|
55
67
|
RESTFramework::OrderingFilter,
|
|
@@ -96,18 +108,39 @@ module RESTFramework::Controller
|
|
|
96
108
|
enable_action_text: false,
|
|
97
109
|
enable_active_storage: false,
|
|
98
110
|
}
|
|
99
|
-
BASE64_REGEX = /data:(.*);base64,(.*)/
|
|
100
|
-
BASE64_TRANSLATE = ->(field, value) {
|
|
101
|
-
return value unless BASE64_REGEX.match?(value)
|
|
102
111
|
|
|
103
|
-
|
|
112
|
+
# Exceptions to be rescued and handled by returning a reasonable error response.
|
|
113
|
+
RRF_RESCUED_EXCEPTIONS = [
|
|
114
|
+
RESTFramework::InvalidBulkParametersError,
|
|
115
|
+
RESTFramework::BulkRecordErrorsError,
|
|
116
|
+
].freeze
|
|
117
|
+
RRF_RESCUED_RAILS_EXCEPTIONS = [
|
|
118
|
+
ActionController::ParameterMissing,
|
|
119
|
+
ActionController::UnpermittedParameters,
|
|
120
|
+
ActionDispatch::Http::Parameters::ParseError,
|
|
121
|
+
ActiveRecord::AssociationTypeMismatch,
|
|
122
|
+
ActiveRecord::NotNullViolation,
|
|
123
|
+
ActiveRecord::RecordNotFound,
|
|
124
|
+
ActiveRecord::RecordInvalid,
|
|
125
|
+
ActiveRecord::RecordNotSaved,
|
|
126
|
+
ActiveRecord::RecordNotDestroyed,
|
|
127
|
+
ActiveRecord::RecordNotUnique,
|
|
128
|
+
ActiveModel::UnknownAttributeError,
|
|
129
|
+
].freeze
|
|
130
|
+
|
|
131
|
+
# Anchored regex with non-greedy content_type match to prevent over-matching on malicious input.
|
|
132
|
+
RRF_BASE64_REGEX = /\Adata:([^;]*);base64,(.*)\z/m
|
|
133
|
+
RRF_BASE64_TRANSLATE = ->(field, value) {
|
|
134
|
+
return value unless RRF_BASE64_REGEX.match?(value)
|
|
135
|
+
|
|
136
|
+
_, content_type, payload = value.match(RRF_BASE64_REGEX).to_a
|
|
104
137
|
{
|
|
105
138
|
io: StringIO.new(Base64.decode64(payload)),
|
|
106
139
|
content_type: content_type,
|
|
107
140
|
filename: "file_#{field}#{Rack::Mime::MIME_TYPES.invert[content_type]}",
|
|
108
141
|
}
|
|
109
142
|
}
|
|
110
|
-
|
|
143
|
+
RRF_ACTIVESTORAGE_KEYS = [ :io, :content_type, :filename, :identify, :key ]
|
|
111
144
|
|
|
112
145
|
# Default action for API root.
|
|
113
146
|
def root
|
|
@@ -366,10 +399,10 @@ module RESTFramework::Controller
|
|
|
366
399
|
next unless self.model.respond_to?(action)
|
|
367
400
|
|
|
368
401
|
self.define_method(action) do
|
|
369
|
-
if self.model.method(action).parameters.last&.first == :keyrest
|
|
370
|
-
render(api: self.model.send(action, **
|
|
402
|
+
if self.class.model.method(action).parameters.last&.first == :keyrest
|
|
403
|
+
render(api: self.class.model.send(action, **request.query_parameters.symbolize_keys))
|
|
371
404
|
else
|
|
372
|
-
render(api: self.model.send(action))
|
|
405
|
+
render(api: self.class.model.send(action))
|
|
373
406
|
end
|
|
374
407
|
end
|
|
375
408
|
end
|
|
@@ -383,7 +416,7 @@ module RESTFramework::Controller
|
|
|
383
416
|
record = self.get_record
|
|
384
417
|
|
|
385
418
|
if record.method(action).parameters.last&.first == :keyrest
|
|
386
|
-
render(api: record.send(action, **
|
|
419
|
+
render(api: record.send(action, **request.query_parameters.symbolize_keys))
|
|
387
420
|
else
|
|
388
421
|
render(api: record.send(action))
|
|
389
422
|
end
|
|
@@ -417,27 +450,14 @@ module RESTFramework::Controller
|
|
|
417
450
|
# Skip CSRF since this is an API.
|
|
418
451
|
begin
|
|
419
452
|
base.skip_before_action(:verify_authenticity_token)
|
|
420
|
-
rescue
|
|
453
|
+
rescue ArgumentError
|
|
454
|
+
# The callback may not exist if forgery protection isn't enabled; this is expected.
|
|
421
455
|
nil
|
|
422
456
|
end
|
|
423
457
|
|
|
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
|
|
458
|
+
# Handle exceptions.
|
|
459
|
+
base.rescue_from(*RRF_RESCUED_EXCEPTIONS, with: :rrf_error_handler)
|
|
460
|
+
base.rescue_from(*RRF_RESCUED_RAILS_EXCEPTIONS, with: :rrf_error_handler)
|
|
441
461
|
|
|
442
462
|
# Use `TracePoint` hook to automatically call `rrf_finalize`.
|
|
443
463
|
if RESTFramework.config.auto_finalize
|
|
@@ -470,6 +490,8 @@ module RESTFramework::Controller
|
|
|
470
490
|
status = case e
|
|
471
491
|
when ActiveRecord::RecordNotFound
|
|
472
492
|
404
|
|
493
|
+
when RESTFramework::BulkRecordErrorsError
|
|
494
|
+
422
|
|
473
495
|
else
|
|
474
496
|
400
|
|
475
497
|
end
|
|
@@ -561,8 +583,11 @@ module RESTFramework::Controller
|
|
|
561
583
|
end
|
|
562
584
|
end
|
|
563
585
|
|
|
564
|
-
#
|
|
565
|
-
|
|
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
|
|
566
591
|
|
|
567
592
|
def options
|
|
568
593
|
render(api: self.openapi_document)
|
|
@@ -594,13 +619,13 @@ module RESTFramework::Controller
|
|
|
594
619
|
|
|
595
620
|
# ActiveStorage Integration: `has_one_attached`
|
|
596
621
|
if self.class.enable_active_storage && reflections.key?("#{f}_attachment")
|
|
597
|
-
hash_variations[f] =
|
|
622
|
+
hash_variations[f] = RRF_ACTIVESTORAGE_KEYS
|
|
598
623
|
next f
|
|
599
624
|
end
|
|
600
625
|
|
|
601
626
|
# ActiveStorage Integration: `has_many_attached`
|
|
602
627
|
if self.class.enable_active_storage && reflections.key?("#{f}_attachments")
|
|
603
|
-
hash_variations[f] =
|
|
628
|
+
hash_variations[f] = RRF_ACTIVESTORAGE_KEYS
|
|
604
629
|
next nil
|
|
605
630
|
end
|
|
606
631
|
|
|
@@ -636,7 +661,7 @@ module RESTFramework::Controller
|
|
|
636
661
|
end
|
|
637
662
|
|
|
638
663
|
# Use strong parameters to filter the request body.
|
|
639
|
-
def get_body_params(
|
|
664
|
+
def get_body_params(bulk_action: nil)
|
|
640
665
|
data = self.request.request_parameters
|
|
641
666
|
pk = self.class.model&.primary_key
|
|
642
667
|
allowed_params = self.get_allowed_parameters
|
|
@@ -645,15 +670,17 @@ module RESTFramework::Controller
|
|
|
645
670
|
# assignment ActiveRecord API or the nested assignment ActiveRecord API. Note that there is no
|
|
646
671
|
# need to check for `permit_id_assignment` or `permit_nested_attributes_assignment` here, since
|
|
647
672
|
# 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
|
-
|
|
673
|
+
if !bulk_action && self.class.model
|
|
674
|
+
self.class.model.reflections.each do |name, ref|
|
|
675
|
+
if payload = data[name]
|
|
676
|
+
if payload.is_a?(Hash) || (payload.is_a?(Array) && payload.all? { |x| x.is_a?(Hash) })
|
|
677
|
+
# Assume nested attributes assignment.
|
|
678
|
+
attributes_key = "#{name}_attributes"
|
|
679
|
+
data[attributes_key] = data.delete(name) unless data[attributes_key]
|
|
680
|
+
elsif id_field = RESTFramework::Utils.id_field_for(name, ref)
|
|
681
|
+
# Assume id/ids assignment.
|
|
682
|
+
data[id_field] = data.delete(name) unless data[id_field]
|
|
683
|
+
end
|
|
657
684
|
end
|
|
658
685
|
end
|
|
659
686
|
end
|
|
@@ -669,12 +696,12 @@ module RESTFramework::Controller
|
|
|
669
696
|
#
|
|
670
697
|
# rubocop:enable Layout/LineLength
|
|
671
698
|
has_many_attached_scalar_data = {}
|
|
672
|
-
if self.class.enable_active_storage
|
|
699
|
+
if !bulk_action && self.class.enable_active_storage && self.class.model
|
|
673
700
|
self.class.model.attachment_reflections.keys.each do |k|
|
|
674
701
|
if data[k].is_a?(Array)
|
|
675
702
|
data[k] = data[k].map { |v|
|
|
676
703
|
if v.is_a?(String)
|
|
677
|
-
v =
|
|
704
|
+
v = RRF_BASE64_TRANSLATE.call(k, v)
|
|
678
705
|
|
|
679
706
|
# Remember scalars because Rails strong params will remove it.
|
|
680
707
|
if v.is_a?(String)
|
|
@@ -694,7 +721,7 @@ module RESTFramework::Controller
|
|
|
694
721
|
data[k][:io] = StringIO.new(Base64.decode64(data[k][:io]))
|
|
695
722
|
end
|
|
696
723
|
elsif data[k].is_a?(String)
|
|
697
|
-
data[k] =
|
|
724
|
+
data[k] = RRF_BASE64_TRANSLATE.call(k, data[k])
|
|
698
725
|
end
|
|
699
726
|
end
|
|
700
727
|
end
|
|
@@ -703,9 +730,16 @@ module RESTFramework::Controller
|
|
|
703
730
|
# parameters to the `_json` key of the request body.
|
|
704
731
|
body_params = if allowed_params == true
|
|
705
732
|
ActionController::Parameters.new(data).permit!
|
|
706
|
-
elsif
|
|
707
|
-
|
|
708
|
-
|
|
733
|
+
elsif bulk_action
|
|
734
|
+
if bulk_action == :create
|
|
735
|
+
ActionController::Parameters.new(data).permit({ _json: allowed_params })
|
|
736
|
+
elsif bulk_action == :update
|
|
737
|
+
ActionController::Parameters.new(data).permit({ _json: allowed_params + [ pk ] })
|
|
738
|
+
elsif bulk_action == :destroy
|
|
739
|
+
ActionController::Parameters.new(data).permit({ _json: [] })
|
|
740
|
+
else
|
|
741
|
+
raise ArgumentError, "Invalid bulk action: #{bulk_action}"
|
|
742
|
+
end
|
|
709
743
|
else
|
|
710
744
|
ActionController::Parameters.new(data).permit(*allowed_params)
|
|
711
745
|
end
|
|
@@ -729,6 +763,7 @@ module RESTFramework::Controller
|
|
|
729
763
|
end
|
|
730
764
|
alias_method :get_create_params, :get_body_params
|
|
731
765
|
alias_method :get_update_params, :get_body_params
|
|
766
|
+
alias_method :get_destroy_params, :get_body_params
|
|
732
767
|
|
|
733
768
|
# Get the set of records this controller has access to.
|
|
734
769
|
def get_recordset
|
|
@@ -760,10 +795,10 @@ module RESTFramework::Controller
|
|
|
760
795
|
|
|
761
796
|
# Find by another column if it's permitted.
|
|
762
797
|
if find_by_param = self.class.find_by_query_param.presence
|
|
763
|
-
if find_by =
|
|
764
|
-
find_by_fields = self.class.find_by_fields&.map(&:to_s)
|
|
798
|
+
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
|
|
765
800
|
|
|
766
|
-
if
|
|
801
|
+
if find_by.in?(find_by_fields)
|
|
767
802
|
is_pk = false unless find_by_key == find_by
|
|
768
803
|
find_by_key = find_by
|
|
769
804
|
end
|
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
module RESTFramework::Errors
|
|
2
|
-
|
|
2
|
+
class BaseError < StandardError
|
|
3
|
+
end
|
|
4
|
+
|
|
5
|
+
class NilPassedToRenderAPIError < BaseError
|
|
6
|
+
def message
|
|
7
|
+
<<~MSG.split("\n").join(" ")
|
|
8
|
+
Payload of `nil` was passed to `render_api`; this is unsupported. If you want a blank
|
|
9
|
+
response, pass `''` (an empty string) as the payload. If this was the result of a `find_by`
|
|
10
|
+
(or similar Active Record method) not finding a record, you should use the bang version
|
|
11
|
+
(e.g., `find_by!`) to raise `ActiveRecord::RecordNotFound`, which the REST controller will
|
|
12
|
+
catch and return an appropriate error response.
|
|
13
|
+
MSG
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class InvalidBulkParametersError < BaseError
|
|
18
|
+
def initialize(detail = nil)
|
|
19
|
+
@detail = detail
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def message
|
|
23
|
+
msg = "Invalid request parameters for bulk action."
|
|
24
|
+
msg += " #{@detail}" if @detail
|
|
25
|
+
msg
|
|
26
|
+
end
|
|
27
|
+
end
|
|
3
28
|
|
|
4
|
-
|
|
29
|
+
class BulkRecordErrorsError < BaseError
|
|
30
|
+
attr_reader :errors
|
|
31
|
+
|
|
32
|
+
def initialize(records)
|
|
33
|
+
@errors = records.each_with_index.filter_map { |record, i|
|
|
34
|
+
next unless record.errors.any?
|
|
35
|
+
{ index: i, errors: record.errors.messages }
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Allow `e.try(:record).try(:errors)` to chain through the standard error handler.
|
|
40
|
+
def record
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def message
|
|
45
|
+
"Bulk operation failed due to validation errors on #{@errors.length} #{
|
|
46
|
+
'record'.pluralize(@errors.length)
|
|
47
|
+
}."
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
5
51
|
|
|
6
|
-
|
|
52
|
+
# Aliases for convenience.
|
|
53
|
+
RESTFramework::BaseError = RESTFramework::Errors::BaseError
|
|
54
|
+
RESTFramework::NilPassedToRenderAPIError = RESTFramework::Errors::NilPassedToRenderAPIError
|
|
55
|
+
RESTFramework::InvalidBulkParametersError = RESTFramework::Errors::InvalidBulkParametersError
|
|
56
|
+
RESTFramework::BulkRecordErrorsError = RESTFramework::Errors::BulkRecordErrorsError
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# A filter backend which handles ordering of the recordset.
|
|
2
2
|
class RESTFramework::Filters::OrderingFilter < RESTFramework::Filters::BaseFilter
|
|
3
|
-
# Get a list of ordering fields for the current action.
|
|
4
3
|
def _get_fields
|
|
5
4
|
@controller.class.ordering_fields&.map(&:to_s) || @controller.get_fields
|
|
6
5
|
end
|
|
@@ -20,7 +20,13 @@ class RESTFramework::Filters::QueryFilter < RESTFramework::Filters::BaseFilter
|
|
|
20
20
|
lte: ->(f, v) { { f => ..v } },
|
|
21
21
|
gte: ->(f, v) { { f => v.. } },
|
|
22
22
|
not: ->(f, v) { Not.new({ f => v }) },
|
|
23
|
-
cont: ->(f, v) {
|
|
23
|
+
cont: ->(f, v) {
|
|
24
|
+
[
|
|
25
|
+
"#{ActiveRecord::Base.connection.quote_column_name(f)} LIKE ?", "%#{
|
|
26
|
+
ActiveRecord::Base.sanitize_sql_like(v)
|
|
27
|
+
}%"
|
|
28
|
+
]
|
|
29
|
+
},
|
|
24
30
|
in: ->(f, v) {
|
|
25
31
|
if v.is_a?(Array)
|
|
26
32
|
{ f => v.map { |el| el == "null" ? nil : el } }
|
|
@@ -31,7 +37,6 @@ class RESTFramework::Filters::QueryFilter < RESTFramework::Filters::BaseFilter
|
|
|
31
37
|
}.freeze
|
|
32
38
|
PREDICATES_REGEX = /^(.*)_(#{PREDICATES.keys.join("|")})$/
|
|
33
39
|
|
|
34
|
-
# Get a list of filter fields for the current action.
|
|
35
40
|
def _get_fields
|
|
36
41
|
# Always return a list of strings; `@controller.get_fields` already does this.
|
|
37
42
|
@controller.class.filter_fields&.map(&:to_s) || @controller.get_fields
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
class RESTFramework::Filters::SearchFilter < RESTFramework::Filters::BaseFilter
|
|
2
|
-
# Get a list of search fields for the current action.
|
|
3
2
|
def _get_fields
|
|
4
3
|
if search_fields = @controller.class.search_fields
|
|
5
4
|
return search_fields&.map(&:to_s)
|
|
@@ -25,12 +24,13 @@ class RESTFramework::Filters::SearchFilter < RESTFramework::Filters::BaseFilter
|
|
|
25
24
|
"VARCHAR"
|
|
26
25
|
end
|
|
27
26
|
|
|
28
|
-
|
|
27
|
+
conn = data.connection
|
|
28
|
+
like_op = @controller.class.search_ilike ? "ILIKE" : "LIKE"
|
|
29
29
|
return data.where(
|
|
30
30
|
fields.map { |f|
|
|
31
|
-
"CAST(#{f} AS #{data_type}) #{
|
|
31
|
+
"CAST(#{conn.quote_column_name(f)} AS #{data_type}) #{like_op} ?"
|
|
32
32
|
}.join(" OR "),
|
|
33
|
-
*([ "%#{search}%" ] * fields.length),
|
|
33
|
+
*([ "%#{ActiveRecord::Base.sanitize_sql_like(search)}%" ] * fields.length),
|
|
34
34
|
)
|
|
35
35
|
end
|
|
36
36
|
end
|
|
@@ -13,22 +13,21 @@ class RESTFramework::Paginators::PageNumberPaginator < RESTFramework::Paginators
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def _page_size
|
|
16
|
-
page_size =
|
|
16
|
+
page_size = nil
|
|
17
17
|
|
|
18
|
-
# Get from
|
|
18
|
+
# Get from query param, if allowed.
|
|
19
19
|
if param = @controller.class.page_size_query_param
|
|
20
|
-
if
|
|
21
|
-
|
|
20
|
+
if raw = @controller.params[param].presence
|
|
21
|
+
parsed = raw.to_i
|
|
22
|
+
page_size = parsed if parsed > 0
|
|
22
23
|
end
|
|
23
24
|
end
|
|
24
25
|
|
|
25
|
-
#
|
|
26
|
-
|
|
27
|
-
page_size = @controller.class.page_size.to_i
|
|
28
|
-
end
|
|
26
|
+
# Fall back to the configured page size.
|
|
27
|
+
page_size ||= @controller.class.page_size&.to_i || 1
|
|
29
28
|
|
|
30
29
|
# Ensure we don't exceed the max page size.
|
|
31
|
-
max_page_size = @controller.class.max_page_size
|
|
30
|
+
max_page_size = @controller.class.max_page_size
|
|
32
31
|
if max_page_size && page_size > max_page_size
|
|
33
32
|
page_size = max_page_size
|
|
34
33
|
end
|
|
@@ -46,7 +45,7 @@ class RESTFramework::Paginators::PageNumberPaginator < RESTFramework::Paginators
|
|
|
46
45
|
page_number = 1
|
|
47
46
|
else
|
|
48
47
|
page_number = page_number.to_i
|
|
49
|
-
if page_number
|
|
48
|
+
if page_number < 1
|
|
50
49
|
page_number = 1
|
|
51
50
|
end
|
|
52
51
|
end
|
|
@@ -61,7 +60,7 @@ class RESTFramework::Paginators::PageNumberPaginator < RESTFramework::Paginators
|
|
|
61
60
|
# Wrap the serialized page with appropriate metadata.
|
|
62
61
|
def get_paginated_response(serialized_page)
|
|
63
62
|
page_query_param = @controller.class.page_query_param
|
|
64
|
-
base_params = @controller.
|
|
63
|
+
base_params = @controller.request.query_parameters.symbolize_keys
|
|
65
64
|
next_url = if @page_number < @total_pages
|
|
66
65
|
@controller.url_for({ **base_params, page_query_param => @page_number + 1 })
|
|
67
66
|
end
|
|
@@ -31,7 +31,7 @@ module ActionDispatch::Routing
|
|
|
31
31
|
|
|
32
32
|
begin
|
|
33
33
|
controller = mod.const_get(name_reverse)
|
|
34
|
-
rescue
|
|
34
|
+
rescue NameError
|
|
35
35
|
reraise = true
|
|
36
36
|
end
|
|
37
37
|
|
|
@@ -109,7 +109,9 @@ module ActionDispatch::Routing
|
|
|
109
109
|
next unless controller_class.method_defined?(action)
|
|
110
110
|
|
|
111
111
|
[ methods ].flatten.each do |m|
|
|
112
|
-
|
|
112
|
+
# Anchor the route since Rails 8.1 OPTIONS routes are non-anchored by default, which
|
|
113
|
+
# causes parent OPTIONS routes to greedily intercept sub-path requests.
|
|
114
|
+
public_send(m, "", action: action, anchor: true) if self.respond_to?(m)
|
|
113
115
|
end
|
|
114
116
|
end
|
|
115
117
|
|
|
@@ -122,7 +124,9 @@ module ActionDispatch::Routing
|
|
|
122
124
|
next if bulk_exclude.include?(action)
|
|
123
125
|
|
|
124
126
|
[ methods ].flatten.each do |m|
|
|
125
|
-
|
|
127
|
+
# Anchor the route since Rails 8.1 OPTIONS routes are non-anchored by default, which
|
|
128
|
+
# causes parent OPTIONS routes to greedily intercept sub-path requests.
|
|
129
|
+
public_send(m, "", action: action, anchor: true) if self.respond_to?(m)
|
|
126
130
|
end
|
|
127
131
|
end
|
|
128
132
|
end
|
|
@@ -184,7 +188,9 @@ module ActionDispatch::Routing
|
|
|
184
188
|
next unless controller_class.method_defined?(action)
|
|
185
189
|
|
|
186
190
|
[ methods ].flatten.each do |m|
|
|
187
|
-
|
|
191
|
+
# Anchor the route since Rails 8.1 OPTIONS routes are non-anchored by default, which
|
|
192
|
+
# causes parent OPTIONS routes to greedily intercept sub-path requests.
|
|
193
|
+
public_send(m, "", action: action, anchor: true) if self.respond_to?(m)
|
|
188
194
|
end
|
|
189
195
|
end
|
|
190
196
|
end
|
|
@@ -28,8 +28,10 @@ class RESTFramework::Serializers::NativeSerializer < RESTFramework::Serializers:
|
|
|
28
28
|
# Determine model either explicitly, or by inspecting @object or @controller.
|
|
29
29
|
@model = model
|
|
30
30
|
@model ||= @object.class if @object.is_a?(ActiveRecord::Base)
|
|
31
|
-
@model ||= @object
|
|
32
|
-
|
|
31
|
+
@model ||= @object.klass if @many && @object.is_a?(ActiveRecord::Relation)
|
|
32
|
+
@model ||= @object.first.class if @many &&
|
|
33
|
+
@object.is_a?(Enumerable) &&
|
|
34
|
+
@object.first.is_a?(ActiveRecord::Base)
|
|
33
35
|
|
|
34
36
|
@model ||= @controller.class.model if @controller
|
|
35
37
|
end
|
data/lib/rest_framework/utils.rb
CHANGED
|
@@ -56,6 +56,13 @@ module RESTFramework::Utils
|
|
|
56
56
|
|
|
57
57
|
# Get the first route pattern which matches the given request.
|
|
58
58
|
def self.get_request_route(application_routes, request)
|
|
59
|
+
# Prefer the route already resolved by the router to avoid an expensive `recognize` call. This
|
|
60
|
+
# is also required for Rails 8.1+ where OPTIONS routes are non-anchored, causing `path_info` to
|
|
61
|
+
# be modified during dispatch, which makes `recognize` fail from inside the controller action.
|
|
62
|
+
if route = request.env["action_dispatch.route"]
|
|
63
|
+
return route
|
|
64
|
+
end
|
|
65
|
+
|
|
59
66
|
application_routes.router.recognize(request) { |route, _| return route }
|
|
60
67
|
end
|
|
61
68
|
|
data/lib/rest_framework.rb
CHANGED
|
@@ -169,9 +169,6 @@ module RESTFramework
|
|
|
169
169
|
# Whether the backtrace should be shown in rescued errors.
|
|
170
170
|
attr_accessor :show_backtrace
|
|
171
171
|
|
|
172
|
-
# Disable `rescue_from` on the controller mixins.
|
|
173
|
-
attr_accessor :disable_rescue_from
|
|
174
|
-
|
|
175
172
|
# The default label fields to use when generating labels for `has_many` associations.
|
|
176
173
|
attr_accessor :label_fields
|
|
177
174
|
|
|
@@ -192,6 +189,7 @@ module RESTFramework
|
|
|
192
189
|
def initialize
|
|
193
190
|
self.register_api_renderer = true
|
|
194
191
|
self.auto_finalize = true
|
|
192
|
+
self.freeze_config = true
|
|
195
193
|
|
|
196
194
|
self.show_backtrace = Rails.env.development?
|
|
197
195
|
|
|
@@ -223,7 +221,6 @@ end
|
|
|
223
221
|
require_relative "rest_framework/engine"
|
|
224
222
|
require_relative "rest_framework/errors"
|
|
225
223
|
require_relative "rest_framework/filters"
|
|
226
|
-
require_relative "rest_framework/generators"
|
|
227
224
|
require_relative "rest_framework/mixins"
|
|
228
225
|
require_relative "rest_framework/paginators"
|
|
229
226
|
require_relative "rest_framework/routers"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rest_framework
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Gregory N. Schmit
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-04-
|
|
11
|
+
date: 2026-04-22 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -60,16 +60,12 @@ files:
|
|
|
60
60
|
- lib/rest_framework/controller/openapi.rb
|
|
61
61
|
- lib/rest_framework/engine.rb
|
|
62
62
|
- lib/rest_framework/errors.rb
|
|
63
|
-
- lib/rest_framework/errors/base_error.rb
|
|
64
|
-
- lib/rest_framework/errors/nil_passed_to_render_api_error.rb
|
|
65
63
|
- lib/rest_framework/filters.rb
|
|
66
64
|
- lib/rest_framework/filters/base_filter.rb
|
|
67
65
|
- lib/rest_framework/filters/ordering_filter.rb
|
|
68
66
|
- lib/rest_framework/filters/query_filter.rb
|
|
69
67
|
- lib/rest_framework/filters/ransack_filter.rb
|
|
70
68
|
- lib/rest_framework/filters/search_filter.rb
|
|
71
|
-
- lib/rest_framework/generators.rb
|
|
72
|
-
- lib/rest_framework/generators/controller_generator.rb
|
|
73
69
|
- lib/rest_framework/mixins.rb
|
|
74
70
|
- lib/rest_framework/mixins/base_controller_mixin.rb
|
|
75
71
|
- lib/rest_framework/mixins/bulk_model_controller_mixin.rb
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
class RESTFramework::Errors::NilPassedToRenderAPIError < RESTFramework::Errors::BaseError
|
|
2
|
-
def message
|
|
3
|
-
<<~MSG.split("\n").join(" ")
|
|
4
|
-
Payload of `nil` was passed to `render_api`; this is unsupported. If you want a blank
|
|
5
|
-
response, pass `''` (an empty string) as the payload. If this was the result of a `find_by`
|
|
6
|
-
(or similar Active Record method) not finding a record, you should use the bang version (e.g.,
|
|
7
|
-
`find_by!`) to raise `ActiveRecord::RecordNotFound`, which the REST controller will catch and
|
|
8
|
-
return an appropriate error response.
|
|
9
|
-
MSG
|
|
10
|
-
end
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
# Alias for convenience.
|
|
14
|
-
RESTFramework::NilPassedToRenderAPIError = RESTFramework::Errors::NilPassedToRenderAPIError
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
require "rails/generators"
|
|
2
|
-
|
|
3
|
-
# Most projects don't have the inflection "REST" as an acronym, so this is a helper class to prevent
|
|
4
|
-
# this generator from being namespaced as `"r_e_s_t_framework"`.
|
|
5
|
-
# :nocov:
|
|
6
|
-
class RESTFrameworkCustomGeneratorControllerNamespace < String
|
|
7
|
-
def camelize
|
|
8
|
-
"RESTFramework"
|
|
9
|
-
end
|
|
10
|
-
end
|
|
11
|
-
# :nocov:
|
|
12
|
-
|
|
13
|
-
class RESTFramework::Generators::ControllerGenerator < Rails::Generators::Base
|
|
14
|
-
PATH_REGEX = %r{^[a-z0-9][a-z0-9_/]+$}
|
|
15
|
-
|
|
16
|
-
desc <<~END
|
|
17
|
-
Description:
|
|
18
|
-
Generates a new REST Framework controller.
|
|
19
|
-
|
|
20
|
-
Specify the controller as a path, including the module, if needed, like:
|
|
21
|
-
'parent_module/controller_name'.
|
|
22
|
-
|
|
23
|
-
Example:
|
|
24
|
-
`rails generate rest_framework:controller user_api/groups`
|
|
25
|
-
|
|
26
|
-
Generates a controller at `app/controllers/user_api/groups_controller.rb` named
|
|
27
|
-
`UserApi::GroupsController`.
|
|
28
|
-
END
|
|
29
|
-
|
|
30
|
-
argument :path, type: :string
|
|
31
|
-
class_option(
|
|
32
|
-
:parent_class, type: :string, default: "ApplicationController", desc: "Inheritance parent"
|
|
33
|
-
)
|
|
34
|
-
class_option(
|
|
35
|
-
:include_base,
|
|
36
|
-
type: :boolean,
|
|
37
|
-
default: false,
|
|
38
|
-
desc: "Include `BaseControllerMixin`, not `ModelControllerMixin`",
|
|
39
|
-
)
|
|
40
|
-
|
|
41
|
-
# Some projects may not have the inflection "REST" as an acronym, which changes this generator to
|
|
42
|
-
# be namespaced in `r_e_s_t_framework`, which is weird.
|
|
43
|
-
def self.namespace
|
|
44
|
-
RESTFrameworkCustomGeneratorControllerNamespace.new("rest_framework:controller")
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def create_rest_controller_file
|
|
48
|
-
unless PATH_REGEX.match?(self.path)
|
|
49
|
-
raise StandardError, "Path isn't valid."
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
# Remove '_controller' from end of path, if it exists.
|
|
53
|
-
cleaned_path = self.path.delete_suffix("_controller")
|
|
54
|
-
|
|
55
|
-
content = <<~END
|
|
56
|
-
class #{cleaned_path.camelize}Controller < #{options[:parent_class]}
|
|
57
|
-
include RESTFramework::#{
|
|
58
|
-
options[:include_base] ? "BaseControllerMixin" : "ModelControllerMixin"
|
|
59
|
-
}
|
|
60
|
-
end
|
|
61
|
-
END
|
|
62
|
-
create_file("app/controllers/#{cleaned_path}_controller.rb", content)
|
|
63
|
-
end
|
|
64
|
-
end
|