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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ebb2a69a51a24192200122bf40a9218e15e1f0d696bfdd63bb8f3aed9f43f82c
4
- data.tar.gz: 3b630e5e5f8ec28096e245bc8ced22d5a63423d52799983543c1616b636999d8
3
+ metadata.gz: 8119cab13c69b27ebfa36906e6bcd577688f2ba63b136e0833f4cfdeae3ca5c3
4
+ data.tar.gz: 149343b13dfdf94b5804beb502d2296d95f487774d1cb3a1dd30dd63f4ab2afc
5
5
  SHA512:
6
- metadata.gz: d0eae7105c8fe37a710ac56c9ba0c4df9b0f0663883d117123af6e778735c63117ca58d82e60e260e353aefcc9536e996848341a6537ca963d764b498dfba979
7
- data.tar.gz: 72e217a7d2c9bed976d6801f333be3a97ebdedf1998b07fa4a4594c48256919dcd1cd4b48030582fc75bdd9e2e6d002dc29df931e217ab78cd97b87872f65c78
6
+ metadata.gz: 17d7143b9ac7a9a3f01fab29a369a083750d26f5923bea7d6b9b9dad407efe25bc30321de0024e1d1f3883aa86838bb250b2ee8c4c749e159386b42d5038f5c8
7
+ data.tar.gz: b1794ca103409bc69f235d72603bd8c9ecd91f674cf4e41c8072ec4c35b4e0d5ca26058f6903554df404183ee7c9624dac9b8f33b67fe212874e98bc4a600355
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.1.0
1
+ 1.2.0
@@ -18,7 +18,7 @@
18
18
  scope: "",
19
19
  local: true,
20
20
  }.compact) do |form| %>
21
- <% controller.get_fields.map(&:to_s).each do |f| %>
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
- # Serialize the records, but also include any errors that might exist. This is used for bulk
3
- # actions, however we include it here so the helper is available everywhere.
4
- def bulk_serialize(records)
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
- # Perform the `create` call, and return the collection of (possibly) created records.
15
- def create_all!
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
- # Perform bulk create in a transaction.
19
- ActiveRecord::Base.transaction { self.create_from.create(create_data) }
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
- def update_all
23
- records = self.update_all!
24
- serialized_records = self.bulk_serialize(records)
25
- render(api: serialized_records)
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
- # Perform the `update` call and return the collection of (possibly) updated records.
29
- def update_all!
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 = if params[:_json].is_a?(Array)
32
- self.get_create_params(bulk_mode: :update)[:_json].index_by { |r| r[pk] }
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
- create_params = self.get_create_params
35
- { create_params[pk] => create_params }
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
- # Perform bulk update in a transaction.
39
- ActiveRecord::Base.transaction { self.get_recordset.update(data.keys, data.values) }
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 params[:_json].is_a?(Array)
44
- records = self.destroy_all!
45
- serialized_records = self.bulk_serialize(records)
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
- render(
50
- api: { message: "Bulk destroy requires an array of primary keys as input." }, status: 400,
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
- # Perform the `destroy!` call and return the destroyed (and frozen) record.
55
- def destroy_all!
239
+ def destroy_all_raw!
240
+ data = self._bulk_pk_data
56
241
  pk = self.class.model.primary_key
57
- destroy_data = self.request.request_parameters[:_json]
242
+ self.get_recordset.where(pk => data).delete_all
243
+ end
58
244
 
59
- # Perform bulk destroy in a transaction.
60
- ActiveRecord::Base.transaction { self.get_recordset.where(pk => destroy_data).destroy_all }
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
- records = self.create_all!
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.get_index_records)
17
+ render(api: self.index!)
19
18
  end
20
19
 
21
20
  # Get records with both filtering and pagination applied.
22
- def get_index_records
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: cfg[:reflection].class_name,
199
- foreign_key: cfg[:reflection].foreign_key,
200
- association_foreign_key: cfg[:reflection].association_foreign_key,
201
- association_primary_key: cfg[:reflection].association_primary_key,
202
- inverse_of: cfg[:reflection].inverse_of&.name,
203
- join_table: cfg[:reflection].join_table,
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
- # Core attributes related to models.
17
+ # Options related to models.
18
18
  model: nil,
19
19
  recordset: nil,
20
20
  excluded_actions: nil,
21
- bulk: false,
22
21
 
23
- # Attributes for configuring record fields.
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
- # Attributes for finding records.
42
+ # Finding records.
31
43
  find_by_fields: nil,
32
44
  find_by_query_param: "find_by".freeze,
33
45
 
34
- # Options for what should be included/excluded from default fields.
46
+ # What should be included/excluded from default fields.
35
47
  exclude_associations: false,
36
48
 
37
- # Options for handling request body parameters.
49
+ # Handling request body parameters.
38
50
  allowed_parameters: nil,
39
51
 
40
- # Attributes for the default native serializer.
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
- # Attributes for filtering, ordering, and searching.
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
- _, content_type, payload = value.match(BASE64_REGEX).to_a
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
- ACTIVESTORAGE_KEYS = [ :io, :content_type, :filename, :identify, :key ]
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, **params))
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, **params))
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 some common exceptions.
425
- unless RESTFramework.config.disable_rescue_from
426
- base.rescue_from(
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
- # Compatibility alias for deprecated `api_response`.
565
- alias_method :api_response, :render_api
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] = ACTIVESTORAGE_KEYS
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] = ACTIVESTORAGE_KEYS
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(bulk_mode: nil)
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.reflections.each do |name, ref|
649
- if payload = data[name]
650
- if payload.is_a?(Hash) || (payload.is_a?(Array) && payload.all? { |x| x.is_a?(Hash) })
651
- # Assume nested attributes assignment.
652
- attributes_key = "#{name}_attributes"
653
- data[attributes_key] = data.delete(name) unless data[attributes_key]
654
- elsif id_field = RESTFramework::Utils.id_field_for(name, ref)
655
- # Assume id/ids assignment.
656
- data[id_field] = data.delete(name) unless data[id_field]
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 = BASE64_TRANSLATE.call(k, 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] = BASE64_TRANSLATE.call(k, 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 bulk_mode
707
- pk = bulk_mode == :update ? [ pk ] : []
708
- ActionController::Parameters.new(data).permit({ _json: allowed_params + pk })
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 = params[find_by_param].presence
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 !find_by_fields || find_by.in?(find_by_fields)
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
- end
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
- require_relative "errors/base_error"
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
- require_relative "errors/nil_passed_to_render_api_error"
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) { [ "#{f} LIKE ?", "%#{ActiveRecord::Base.sanitize_sql_like(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
- # Ensure we pass user input as arguments to prevent SQL injection.
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}) #{@controller.class.search_ilike ? "ILIKE" : "LIKE"} ?"
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 = 1
16
+ page_size = nil
17
17
 
18
- # Get from context, if allowed.
18
+ # Get from query param, if allowed.
19
19
  if param = @controller.class.page_size_query_param
20
- if page_size = @controller.params[param].presence
21
- page_size = page_size.to_i
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
- # Otherwise, get from config.
26
- if !page_size && @controller.class.page_size
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&.to_i
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.zero?
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.params.to_unsafe_h
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
- public_send(m, "", action: action) if self.respond_to?(m)
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
- public_send(m, "", action: action) if self.respond_to?(m)
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
- public_send(m, "", action: action) if self.respond_to?(m)
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[0].class if
32
- @many && @object.is_a?(Enumerable) && @object.is_a?(ActiveRecord::Base)
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
@@ -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
 
@@ -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.1.0
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-16 00:00:00.000000000 Z
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,5 +0,0 @@
1
- class RESTFramework::Errors::BaseError < StandardError
2
- end
3
-
4
- # Alias for convenience.
5
- RESTFramework::BaseError = RESTFramework::Errors::BaseError
@@ -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
@@ -1,4 +0,0 @@
1
- module RESTFramework::Generators
2
- end
3
-
4
- require_relative "generators/controller_generator"