rest_framework 1.0.2 → 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.
Files changed (29) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +31 -26
  3. data/VERSION +1 -1
  4. data/app/views/rest_framework/_routes_and_forms.html.erb +2 -5
  5. data/app/views/rest_framework/routes_and_forms/_html_form.html.erb +1 -1
  6. data/app/views/rest_framework/routes_and_forms/_raw_form.html.erb +1 -1
  7. data/lib/rest_framework/controller/bulk.rb +272 -0
  8. data/lib/rest_framework/controller/crud.rb +65 -0
  9. data/lib/rest_framework/controller/openapi.rb +252 -0
  10. data/lib/rest_framework/controller.rb +839 -0
  11. data/lib/rest_framework/engine.rb +12 -2
  12. data/lib/rest_framework/errors.rb +53 -4
  13. data/lib/rest_framework/filters/ordering_filter.rb +0 -1
  14. data/lib/rest_framework/filters/query_filter.rb +7 -2
  15. data/lib/rest_framework/filters/search_filter.rb +5 -5
  16. data/lib/rest_framework/mixins/base_controller_mixin.rb +3 -383
  17. data/lib/rest_framework/mixins/bulk_model_controller_mixin.rb +27 -68
  18. data/lib/rest_framework/mixins/model_controller_mixin.rb +60 -807
  19. data/lib/rest_framework/paginators/page_number_paginator.rb +10 -11
  20. data/lib/rest_framework/routers.rb +20 -9
  21. data/lib/rest_framework/serializers/native_serializer.rb +5 -3
  22. data/lib/rest_framework/utils.rb +24 -6
  23. data/lib/rest_framework.rb +13 -5
  24. metadata +6 -7
  25. data/lib/rest_framework/errors/base_error.rb +0 -5
  26. data/lib/rest_framework/errors/nil_passed_to_render_api_error.rb +0 -14
  27. data/lib/rest_framework/errors/unknown_model_error.rb +0 -18
  28. data/lib/rest_framework/generators/controller_generator.rb +0 -64
  29. data/lib/rest_framework/generators.rb +0 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fa4f1c8277a69cc73b8683ac06a9a31c0706fa144d1e1d030eb0f97987da2429
4
- data.tar.gz: 7f275441694689003a4f25df054c040a238a55ea63f331a77ff0fb370bff102b
3
+ metadata.gz: 8119cab13c69b27ebfa36906e6bcd577688f2ba63b136e0833f4cfdeae3ca5c3
4
+ data.tar.gz: 149343b13dfdf94b5804beb502d2296d95f487774d1cb3a1dd30dd63f4ab2afc
5
5
  SHA512:
6
- metadata.gz: 373581c9bb9eb2887bb0a66ed8013cbe2f595fa2f0ddb0e9e7180b89b9d81d02991556c14ade1dd2a9ac86844a76a3080daa41fb4d5debba8357959d2fe7ec52
7
- data.tar.gz: f26c82b74d3cb344e65bc863a3718e152e4f107d7d709c64a7c47779c9ed60024e26c00709fd934177099b71cbdfdda37719f43e864a4aed907ca2676f31897f
6
+ metadata.gz: 17d7143b9ac7a9a3f01fab29a369a083750d26f5923bea7d6b9b9dad407efe25bc30321de0024e1d1f3883aa86838bb250b2ee8c4c749e159386b42d5038f5c8
7
+ data.tar.gz: b1794ca103409bc69f235d72603bd8c9ecd91f674cf4e41c8072ec4c35b4e0d5ca26058f6903554df404183ee7c9624dac9b8f33b67fe212874e98bc4a600355
data/README.md CHANGED
@@ -38,10 +38,22 @@ bundle install
38
38
 
39
39
  ## Quick Usage Tutorial
40
40
 
41
- This section provides some simple examples to quickly get you started using the framework.
41
+ To add REST framework features to a controller, include the `Controller` module:
42
42
 
43
- For the purpose of this example, you'll want to add an `api_controller.rb` to your controllers, as
44
- well as a directory for the resources:
43
+ ```ruby
44
+ class ApiController < ApplicationController
45
+ include RESTFramework::Controller
46
+
47
+ # Here is where you can set configuration class attributes that will propagate to child
48
+ # controllers.
49
+
50
+ # Setting up a paginator class here makes more sense than defining it on every child controller.
51
+ self.paginator_class = RESTFramework::PageNumberPaginator
52
+ self.page_size = 30
53
+ end
54
+ ```
55
+
56
+ Here is what the directory structure might look like for resource controllers:
45
57
 
46
58
  ```text
47
59
  controllers/
@@ -52,29 +64,18 @@ controllers/
52
64
  └─ users_controller.rb
53
65
  ```
54
66
 
55
- ### Controller Mixins
56
-
57
- The root `ApiController` can include any common behavior you want to share across all your API
58
- controllers:
59
-
60
- ```ruby
61
- class ApiController < ApplicationController
62
- include RESTFramework::BaseControllerMixin
63
-
64
- # Setting up a paginator class here makes more sense than defining it on every child controller.
65
- self.paginator_class = RESTFramework::PageNumberPaginator
66
- self.page_size = 30
67
- end
68
- ```
67
+ ### Root Controller
69
68
 
70
- A root controller can provide actions that exist on the root of your API. It's best to define a
71
- dedicated root controller, rather than using the `ApiController` for this purpose, so that actions
72
- don't propagate to child controllers:
69
+ It is typically a good pattern for the root of your API to have a dedicated `Api::RootController`
70
+ outside the inheritance chain of your other API controllers, so that you can define actions on the
71
+ root without them propagating to child controllers, and so you can set global configuration on the
72
+ `ApiController`.
73
73
 
74
74
  ```ruby
75
75
  class Api::RootController < ApiController
76
76
  self.extra_actions = {test: :get}
77
77
 
78
+ # The root action is routed by `rest_root`.
78
79
  def root
79
80
  render(
80
81
  api: {
@@ -94,17 +95,19 @@ class Api::RootController < ApiController
94
95
  end
95
96
  ```
96
97
 
97
- And here is an example of a resource controller:
98
+ ### Resource Controllers
99
+
100
+ Other API controllers can be associated to a resource/model by setting the `model` class attribute.
98
101
 
99
102
  ```ruby
100
103
  class Api::MoviesController < ApiController
101
- include RESTFramework::ModelControllerMixin
102
-
104
+ self.model = Movie # Automatically routes the standard CRUD actions for this controller.
105
+ self.bulk = true # Enables bulk create/update/destroy actions for this controller.
103
106
  self.fields = [:id, :name, :release_date, :enabled]
104
107
  self.extra_member_actions = {first: :get}
105
108
 
106
109
  def first
107
- # Always use the bang method, since the framework will rescue `RecordNotFound` and return a
110
+ # Always use bang methods, since the framework will rescue `RecordNotFound` and return a
108
111
  # sensible error response.
109
112
  render(api: self.get_records.first!)
110
113
  end
@@ -120,9 +123,11 @@ to include or exclude fields rather than defining them manually:
120
123
 
121
124
  ```ruby
122
125
  class Api::UsersController < ApiController
123
- include RESTFramework::ModelControllerMixin
124
-
125
126
  self.fields = {include: [:calculated_popularity], exclude: [:impersonation_token]}
127
+
128
+ # You can even disable some of the builtin actions. For example, this effectively makes the
129
+ # resource read-only:
130
+ self.excluded_actions = [:create, :update, :destroy, :update_all, :destroy_all]
126
131
  end
127
132
  ```
128
133
 
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.0.2
1
+ 1.2.0
@@ -1,6 +1,3 @@
1
- <%
2
- @is_model_controller = controller.class.included_modules.include?(RESTFramework::ModelControllerMixin)
3
- %>
4
1
  <div class="row">
5
2
  <div>
6
3
  <ul class="nav nav-tabs">
@@ -25,7 +22,7 @@
25
22
  </a>
26
23
  </li>
27
24
  <% end %>
28
- <% if @_rrf_form_routes_html.present? && @is_model_controller %>
25
+ <% if @_rrf_form_routes_html.present? && controller.class.model %>
29
26
  <li class="nav-item">
30
27
  <a class="nav-link" href="#tabHtmlForm" data-bs-toggle="tab" role="tab">
31
28
  HTML Form
@@ -43,7 +40,7 @@
43
40
  <%= render partial: "rest_framework/routes_and_forms/raw_form" %>
44
41
  </div>
45
42
  <% end %>
46
- <% if @_rrf_form_routes_html.present? && @is_model_controller %>
43
+ <% if @_rrf_form_routes_html.present? && controller.class.model %>
47
44
  <div class="tab-pane fade" id="tabHtmlForm" role="tabpanel">
48
45
  <%= render partial: "rest_framework/routes_and_forms/html_form" %>
49
46
  </div>
@@ -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]
@@ -29,7 +29,7 @@
29
29
  </label>
30
30
  </div>
31
31
 
32
- <% if @is_model_controller && model = controller.class.get_model %>
32
+ <% if model = controller.class.model %>
33
33
  <% if attachment_reflections = model.attachment_reflections.presence %>
34
34
  <div class="mb-2" style="display: none" id="rawFilesFormWrapper">
35
35
  <%= form_with(**{
@@ -0,0 +1,272 @@
1
+ module RESTFramework::Controller
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)
14
+ # This is kinda slow, so perhaps we should eventually integrate `errors` serialization into
15
+ # the serializer directly. This would fail for active model serializers, but maybe we don't
16
+ # care?
17
+ s = RESTFramework::Utils.wrap_ams(self.get_serializer_class)
18
+ records.map do |record|
19
+ s.new(record, controller: self).serialize.merge!({ errors: record.errors.presence }.compact)
20
+ end
21
+ end
22
+
23
+ def _bulk_mode
24
+ return @_bulk_mode if defined?(@_bulk_mode)
25
+
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
41
+ end
42
+
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
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!
109
+ pk = self.class.model.primary_key
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) }
129
+ else
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.")
179
+ end
180
+
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
227
+ end
228
+
229
+ def destroy_all
230
+ if self._bulk_mode == :raw
231
+ deleted = self.destroy_all_raw!
232
+ return render(api: { message: "Bulk destroy successful.", result: deleted })
233
+ end
234
+
235
+ records = self.destroy_all_default!
236
+ render(api: { message: "Bulk destroy successful.", records: self._bulk_serialize(records) })
237
+ end
238
+
239
+ def destroy_all_raw!
240
+ data = self._bulk_pk_data
241
+ pk = self.class.model.primary_key
242
+ self.get_recordset.where(pk => data).delete_all
243
+ end
244
+
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
271
+ end
272
+ end
@@ -0,0 +1,65 @@
1
+ module RESTFramework::Controller
2
+ def create
3
+ # Bulk create: if `bulk` is enabled and the request body is an array, delegate to `create_all`.
4
+ if self.class.bulk && params[:_json].is_a?(Array)
5
+ return self.create_all
6
+ end
7
+
8
+ render(api: self.create!, status: :created)
9
+ end
10
+
11
+ # Perform the `create!` call and return the created record.
12
+ def create!
13
+ self.create_from.create!(self.get_create_params)
14
+ end
15
+
16
+ def index
17
+ render(api: self.index!)
18
+ end
19
+
20
+ # Get records with both filtering and pagination applied.
21
+ def index!
22
+ records = self.get_records
23
+
24
+ # Handle pagination, if enabled.
25
+ if paginator_class = self.class.paginator_class
26
+ # Paginate if there is a `max_page_size`, or if there is no `page_size_query_param`, or if the
27
+ # page size is not set to "0".
28
+ max_page_size = self.class.max_page_size
29
+ page_size_query_param = self.class.page_size_query_param
30
+ if max_page_size || !page_size_query_param || params[page_size_query_param] != "0"
31
+ paginator = paginator_class.new(data: records, controller: self)
32
+ page = paginator.get_page
33
+ serialized_page = self.serialize(page)
34
+ return paginator.get_paginated_response(serialized_page)
35
+ end
36
+ end
37
+
38
+ records
39
+ end
40
+
41
+ def show
42
+ render(api: self.get_record)
43
+ end
44
+
45
+ def update
46
+ render(api: self.update!)
47
+ end
48
+
49
+ # Perform the `update!` call and return the updated record.
50
+ def update!
51
+ record = self.get_record
52
+ record.update!(self.get_update_params)
53
+ record
54
+ end
55
+
56
+ def destroy
57
+ self.destroy!
58
+ render(api: "")
59
+ end
60
+
61
+ # Perform the `destroy!` call and return the destroyed (and frozen) record.
62
+ def destroy!
63
+ self.get_record.destroy!
64
+ end
65
+ end