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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +107 -41
  3. data/VERSION +1 -1
  4. data/app/views/rest_framework/routes_and_forms/_html_form.html.erb +1 -1
  5. data/lib/rest_framework/controller/actions.rb +256 -0
  6. data/lib/rest_framework/controller/bulk.rb +247 -32
  7. data/lib/rest_framework/controller/crud.rb +13 -9
  8. data/lib/rest_framework/controller/openapi.rb +12 -8
  9. data/lib/rest_framework/controller.rb +275 -164
  10. data/lib/rest_framework/errors.rb +70 -3
  11. data/lib/rest_framework/filters/base_filter.rb +10 -0
  12. data/lib/rest_framework/filters/ordering_filter.rb +41 -23
  13. data/lib/rest_framework/filters/query_filter.rb +24 -5
  14. data/lib/rest_framework/filters/search_filter.rb +8 -4
  15. data/lib/rest_framework/paginators/page_number_paginator.rb +34 -19
  16. data/lib/rest_framework/routers.rb +52 -182
  17. data/lib/rest_framework/serializers/active_model_serializer_adapter_factory.rb +2 -2
  18. data/lib/rest_framework/serializers/base_serializer.rb +2 -2
  19. data/lib/rest_framework/serializers/native_serializer.rb +78 -24
  20. data/lib/rest_framework/utils.rb +39 -70
  21. data/lib/rest_framework/version.rb +8 -5
  22. data/lib/rest_framework.rb +7 -41
  23. metadata +5 -12
  24. data/lib/rest_framework/errors/base_error.rb +0 -5
  25. data/lib/rest_framework/errors/nil_passed_to_render_api_error.rb +0 -14
  26. data/lib/rest_framework/generators/controller_generator.rb +0 -64
  27. data/lib/rest_framework/generators.rb +0 -4
  28. data/lib/rest_framework/mixins/base_controller_mixin.rb +0 -12
  29. data/lib/rest_framework/mixins/bulk_model_controller_mixin.rb +0 -55
  30. data/lib/rest_framework/mixins/model_controller_mixin.rb +0 -110
  31. data/lib/rest_framework/mixins.rb +0 -7
@@ -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,258 @@ 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
74
+ end
75
+
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
26
93
  end
27
94
 
28
- # Perform the `update` call and return the collection of (possibly) updated records.
29
- def update_all!
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_api(
103
+ { 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
+ pk_type = self.class.model.type_for_attribute(pk)
159
+ data = self._bulk_object_data(:update, :raw)
160
+
161
+ # Cast ids like `update_all_default!` so the existence check compares matching types.
162
+ data_ids = data.map { |r| pk_type.cast(r[pk]) }.uniq
163
+ if data_ids.include?(nil)
164
+ raise RESTFramework::InvalidBulkParametersError.new(
165
+ "Bulk update requires the primary key (#{pk}) for all records.",
166
+ )
167
+ end
168
+ found_ids = self.get_recordset.where(pk => data_ids).pluck(pk)
169
+ if found_ids.length != data_ids.length
170
+ missing = data_ids - found_ids
171
+ raise RESTFramework::InvalidBulkParametersError.new(
172
+ "Records not found with #{pk}: #{missing.join(', ')}.",
173
+ )
174
+ end
175
+
176
+ unless first_keys = data.first&.keys&.sort
177
+ raise RESTFramework::InvalidBulkParametersError.new("Expected objects with attrs.")
178
+ end
179
+ unless data.all? { |r| r.keys.sort == first_keys }
180
+ raise RESTFramework::InvalidBulkParametersError.new("All objects must have the same attrs.")
181
+ end
182
+
183
+ self.get_recordset.upsert_all(data, unique_by: pk)
184
+ end
185
+
186
+ def update_all_default!
187
+ pk = self.class.model.primary_key
188
+ pk_type = self.class.model.type_for_attribute(pk)
189
+ data = self._bulk_object_data(:update, :default)
190
+
191
+ # Cast ids to the pk's type so they match records fetched from the DB; JSON clients often send
192
+ # ids as strings, which otherwise never match the type-cast keys of `existing` below.
193
+ data_ids = data.map { |r| pk_type.cast(r[pk]) }.uniq
194
+ if data_ids.include?(nil)
195
+ raise RESTFramework::InvalidBulkParametersError.new(
196
+ "Bulk update requires the primary key (#{pk}) for all records.",
197
+ )
36
198
  end
199
+ existing = self.get_recordset.where(pk => data_ids).index_by { |r| r.send(pk) }
200
+ if existing.length != data_ids.length
201
+ missing = data_ids - existing.keys
202
+ raise RESTFramework::InvalidBulkParametersError.new(
203
+ "Records not found with #{pk}: #{missing.join(', ')}.",
204
+ )
205
+ end
206
+
207
+ # Assign attributes to each record.
208
+ records = data.map { |attrs|
209
+ record = existing[pk_type.cast(attrs[pk])]
210
+ record.assign_attributes(attrs.except(pk))
211
+ record
212
+ }
37
213
 
38
- # Perform bulk update in a transaction.
39
- ActiveRecord::Base.transaction { self.get_recordset.update(data.keys, data.values) }
214
+ if self._bulk_partial
215
+ # Partial: save each record individually.
216
+ records.each(&:save)
217
+ records
218
+ else
219
+ # Transactional: validate all first, then save in a transaction or raise.
220
+ failed = records.reject(&:valid?)
221
+
222
+ if failed.any?
223
+ raise RESTFramework::BulkRecordErrorsError.new(records)
224
+ end
225
+
226
+ self.class.model.transaction do
227
+ records.each(&:save!)
228
+ end
229
+
230
+ records
231
+ end
40
232
  end
41
233
 
42
234
  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)
235
+ if self._bulk_mode == :raw
236
+ deleted = self.destroy_all_raw!
237
+ return render_api({ message: "Bulk destroy successful.", result: deleted })
47
238
  end
48
239
 
49
- render(
50
- api: { message: "Bulk destroy requires an array of primary keys as input." }, status: 400,
51
- )
240
+ records = self.destroy_all_default!
241
+ render_api({ message: "Bulk destroy successful.", records: self._bulk_serialize(records) })
52
242
  end
53
243
 
54
- # Perform the `destroy!` call and return the destroyed (and frozen) record.
55
- def destroy_all!
244
+ def destroy_all_raw!
245
+ data = self._bulk_pk_data
56
246
  pk = self.class.model.primary_key
57
- destroy_data = self.request.request_parameters[:_json]
247
+ self.get_recordset.where(pk => data).delete_all
248
+ end
58
249
 
59
- # Perform bulk destroy in a transaction.
60
- ActiveRecord::Base.transaction { self.get_recordset.where(pk => destroy_data).destroy_all }
250
+ def destroy_all_default!
251
+ data = self._bulk_pk_data
252
+ pk = self.class.model.primary_key
253
+ records = self.get_recordset.where(pk => data).to_a
254
+
255
+ # In transactional mode, verify all requested records exist.
256
+ if !self._bulk_partial && records.length != data.uniq.length
257
+ found_ids = records.map { |r| r.send(pk) }
258
+ missing = data.uniq - found_ids
259
+ raise RESTFramework::InvalidBulkParametersError.new(
260
+ "Bulk destroy requires all records to exist. Missing #{pk}: #{missing.join(', ')}.",
261
+ )
262
+ end
263
+
264
+ if self._bulk_partial
265
+ # Partial: destroy each record individually.
266
+ records.each(&:destroy)
267
+ records
268
+ else
269
+ # Transactional: destroy all in a transaction, roll back on failure.
270
+ self.class.model.transaction do
271
+ records.each(&:destroy!)
272
+ end
273
+
274
+ records
275
+ end
61
276
  end
62
277
  end
@@ -1,12 +1,11 @@
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
- render(api: self.create!, status: :created)
8
+ render_api(self.create!, status: :created)
10
9
  end
11
10
 
12
11
  # Perform the `create!` call and return the created record.
@@ -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.class.model ? self.index! : self.index_content)
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.
@@ -39,12 +38,17 @@ module RESTFramework::Controller
39
38
  records
40
39
  end
41
40
 
41
+ # The payload rendered at a non-model controller's index (e.g. an API root). Override this.
42
+ def index_content
43
+ { message: self.class.description.presence || "This is the API root." }
44
+ end
45
+
42
46
  def show
43
- render(api: self.get_record)
47
+ render_api(self.get_record)
44
48
  end
45
49
 
46
50
  def update
47
- render(api: self.update!)
51
+ render_api(self.update!)
48
52
  end
49
53
 
50
54
  # Perform the `update!` call and return the updated record.
@@ -56,7 +60,7 @@ module RESTFramework::Controller
56
60
 
57
61
  def destroy
58
62
  self.destroy!
59
- render(api: "")
63
+ render_api("")
60
64
  end
61
65
 
62
66
  # Perform the `destroy!` call and return the destroyed (and frozen) record.
@@ -180,6 +180,7 @@ module RESTFramework::Controller
180
180
  end
181
181
 
182
182
  v[:readOnly] = true if cfg[:read_only]
183
+ v[:writeOnly] = true if cfg[:write_only]
183
184
  v[:default] = cfg[:default] if cfg.key?(:default)
184
185
 
185
186
  if enum_variants = cfg[:enum_variants]
@@ -194,17 +195,20 @@ module RESTFramework::Controller
194
195
  v[:"x-rrf-kind"] = cfg[:kind] if cfg[:kind]
195
196
 
196
197
  if cfg[:reflection]
198
+ ref = cfg[:reflection]
197
199
  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,
200
+ class_name: ref.respond_to?(:class_name) ? ref.class_name : nil,
201
+ foreign_key: ref.respond_to?(:foreign_key) ? ref.foreign_key : nil,
202
+ association_foreign_key: ref.respond_to?(:association_foreign_key) ?
203
+ ref.association_foreign_key : nil,
204
+ association_primary_key: ref.respond_to?(:association_primary_key) ?
205
+ ref.association_primary_key : nil,
206
+ inverse_of: ref.respond_to?(:inverse_of) ? ref.inverse_of&.name : nil,
207
+ join_table: ref.respond_to?(:join_table) ? ref.join_table : nil,
204
208
  }.compact
205
209
  v[:"x-rrf-association_pk"] = cfg[:association_pk]
206
- v[:"x-rrf-sub_fields"] = cfg[:sub_fields]
207
- v[:"x-rrf-sub_fields_metadata"] = cfg[:sub_fields_metadata]
210
+ v[:"x-rrf-association_fields"] = cfg[:fields]
211
+ v[:"x-rrf-association_fields_metadata"] = cfg[:association_fields_metadata]
208
212
  v[:"x-rrf-id_field"] = cfg[:id_field]
209
213
  v[:"x-rrf-nested_attributes_options"] = cfg[:nested_attributes_options]
210
214
  end