rest_framework 1.0.2 → 1.1.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.
@@ -1,6 +1,6 @@
1
1
  class RESTFramework::Engine < Rails::Engine
2
- initializer "rest_framework.assets" do
3
- config.after_initialize do |app|
2
+ initializer "rest_framework.assets" do |app|
3
+ app.config.after_initialize do
4
4
  if RESTFramework.config.use_vendored_assets
5
5
  app.config.assets.precompile += [
6
6
  RESTFramework::EXTERNAL_CSS_NAME,
@@ -8,7 +8,11 @@ class RESTFramework::Engine < Rails::Engine
8
8
  *RESTFramework::EXTERNAL_UNSUMMARIZED_ASSETS.keys.map { |name| "rest_framework/#{name}" },
9
9
  ]
10
10
  end
11
+ end
12
+ end
11
13
 
14
+ initializer "rest_framework.register_api_renderer" do |app|
15
+ app.config.after_initialize do
12
16
  if RESTFramework.config.register_api_renderer
13
17
  ActionController::Renderers.add(:api) do |data, kwargs|
14
18
  render_api(data, **kwargs)
@@ -16,4 +20,10 @@ class RESTFramework::Engine < Rails::Engine
16
20
  end
17
21
  end
18
22
  end
23
+
24
+ initializer "rest_framework.deprecator" do |app|
25
+ if Rails::VERSION::MAJOR >= 8
26
+ app.deprecators[:rest_framework] = RESTFramework.deprecator
27
+ end
28
+ end
19
29
  end
@@ -4,4 +4,3 @@ end
4
4
  require_relative "errors/base_error"
5
5
 
6
6
  require_relative "errors/nil_passed_to_render_api_error"
7
- require_relative "errors/unknown_model_error"
@@ -5,7 +5,7 @@ class RESTFramework::Filters::SearchFilter < RESTFramework::Filters::BaseFilter
5
5
  return search_fields&.map(&:to_s)
6
6
  end
7
7
 
8
- columns = @controller.class.get_model.column_names
8
+ columns = @controller.class.model.column_names
9
9
  @controller.get_fields.select { |f|
10
10
  f.in?(RESTFramework.config.search_columns) && f.in?(columns)
11
11
  }
@@ -1,390 +1,10 @@
1
- # This module provides the common functionality for any controller mixins, a `root` action, and
2
- # the ability to route arbitrary actions with `extra_actions`. This is also where `render_api` is
3
- # implemented.
4
1
  module RESTFramework::Mixins::BaseControllerMixin
5
- RRF_BASE_CONFIG = {
6
- extra_actions: nil,
7
- extra_member_actions: nil,
8
- singleton_controller: nil,
9
-
10
- # Options related to metadata and display.
11
- title: nil,
12
- description: nil,
13
- version: nil,
14
- inflect_acronyms: [ "ID", "IDs", "REST", "API", "APIs" ].freeze,
15
- openapi_include_children: false,
16
-
17
- # Options related to serialization.
18
- rescue_unknown_format_with: :json,
19
- serializer_class: nil,
20
- serialize_to_json: true,
21
- serialize_to_xml: true,
22
-
23
- # Options related to pagination.
24
- paginator_class: nil,
25
- page_size: 20,
26
- page_query_param: "page",
27
- page_size_query_param: "page_size",
28
- max_page_size: nil,
29
-
30
- # Option to disable serializer adapters by default, mainly introduced because Active Model
31
- # Serializers will do things like serialize `[]` into `{"":[]}`.
32
- disable_adapters_by_default: true,
33
-
34
- # Custom integrations (reduces serializer performance due to method calls).
35
- enable_action_text: false,
36
- enable_active_storage: false,
37
- }
38
-
39
- # Default action for API root.
40
- def root
41
- render(api: { message: "This is the API root." })
42
- end
43
-
44
- module ClassMethods
45
- # By default, this is the name of the controller class, titleized and with any custom inflection
46
- # acronyms applied.
47
- def get_title
48
- self.title || RESTFramework::Utils.inflect(
49
- self.name.demodulize.chomp("Controller").titleize(keep_id_suffix: true),
50
- self.inflect_acronyms,
51
- )
52
- end
53
-
54
- # Get a label from a field/column name, titleized and inflected.
55
- def label_for(s)
56
- RESTFramework::Utils.inflect(s.to_s.titleize(keep_id_suffix: true), self.inflect_acronyms)
57
- end
58
-
59
- # Define any behavior to execute at the end of controller definition.
60
- # :nocov:
61
- def rrf_finalize
62
- if RESTFramework.config.freeze_config
63
- self::RRF_BASE_CONFIG.keys.each { |k|
64
- v = self.send(k)
65
- v.freeze if v.is_a?(Hash) || v.is_a?(Array)
66
- }
67
- end
68
- end
69
- # :nocov:
70
-
71
- def openapi_response_content_types
72
- @openapi_response_content_types ||= [
73
- "text/html",
74
- self.serialize_to_json ? "application/json" : nil,
75
- self.serialize_to_xml ? "application/xml" : nil,
76
- ].compact
77
- end
78
-
79
- def openapi_request_content_types
80
- @openapi_request_content_types ||= [
81
- "application/json",
82
- "application/x-www-form-urlencoded",
83
- "multipart/form-data",
84
- ]
85
- end
86
-
87
- def openapi_paths(routes, tag)
88
- resp_cts = self.openapi_response_content_types
89
- req_cts = self.openapi_request_content_types
90
-
91
- routes.group_by { |r| r[:concat_path] }.map { |concat_path, routes|
92
- [
93
- concat_path.gsub(/:([0-9A-Za-z_-]+)/, "{\\1}"),
94
- routes.map { |route|
95
- metadata = RESTFramework::ROUTE_METADATA[route[:path]] || {}
96
- summary = metadata.delete(:label).presence || self.label_for(route[:action])
97
- description = metadata.delete(:description).presence
98
- extra_action = RESTFramework::EXTRA_ACTION_ROUTES.include?(route[:path])
99
- error_response = { "$ref" => "#/components/responses/BadRequest" }
100
- not_found_response = { "$ref" => "#/components/responses/NotFound" }
101
- spec = { tags: [ tag ], summary: summary, description: description }.compact
102
-
103
- # All routes should have a successful response.
104
- spec[:responses] = {
105
- 200 => { content: resp_cts.map { |ct| [ ct, {} ] }.to_h, description: "Success" },
106
- }
107
-
108
- # Builtin POST, PUT, PATCH, and DELETE should have a 400 and 404 response.
109
- if route[:verb].in?([ "POST", "PUT", "PATCH", "DELETE" ]) && !extra_action
110
- spec[:responses][400] = error_response
111
- spec[:responses][404] = not_found_response
112
- end
113
-
114
- # All POST, PUT, PATCH should have a request body.
115
- if route[:verb].in?([ "POST", "PUT", "PATCH" ])
116
- spec[:requestBody] ||= { content: req_cts.map { |ct| [ ct, {} ] }.to_h }
117
- end
118
-
119
- # Add remaining metadata as an extension.
120
- spec["x-rrf-metadata"] = metadata if metadata.present?
121
-
122
- next route[:verb].downcase, spec
123
- }.to_h.merge(
124
- {
125
- parameters: routes.first[:route].required_parts.map { |p|
126
- {
127
- name: p,
128
- in: "path",
129
- required: true,
130
- schema: { type: "integer" },
131
- }
132
- },
133
- },
134
- ),
135
- ]
136
- }.to_h
137
- end
138
-
139
- def openapi_document(request, route_group_name, routes)
140
- server = request.base_url + request.original_fullpath.gsub(/\?.*/, "")
141
-
142
- {
143
- openapi: "3.1.1",
144
- info: {
145
- title: self.get_title,
146
- description: self.description,
147
- version: self.version.to_s,
148
- }.compact,
149
- servers: [ { url: server } ],
150
- paths: self.openapi_paths(routes, route_group_name),
151
- tags: [ { name: route_group_name, description: self.description }.compact ],
152
- components: {
153
- schemas: {
154
- "Error" => {
155
- type: "object",
156
- required: [ "message" ],
157
- properties: {
158
- message: { type: "string" },
159
- errors: { type: "object" },
160
- exception: { type: "string" },
161
- },
162
- },
163
- },
164
- responses: {
165
- "BadRequest": {
166
- description: "Bad Request",
167
- content: self.openapi_response_content_types.map { |ct|
168
- [
169
- ct,
170
- ct == "text/html" ? {} : { schema: { "$ref" => "#/components/schemas/Error" } },
171
- ]
172
- }.to_h,
173
- },
174
- "NotFound": {
175
- description: "Not Found",
176
- content: self.openapi_response_content_types.map { |ct|
177
- [
178
- ct,
179
- ct == "text/html" ? {} : { schema: { "$ref" => "#/components/schemas/Error" } },
180
- ]
181
- }.to_h,
182
- },
183
- },
184
- },
185
- }.compact
186
- end
187
- end
188
-
189
2
  def self.included(base)
190
- return unless base.is_a?(Class)
191
-
192
- base.extend(ClassMethods)
193
-
194
- # By default, the layout should be set to `rest_framework`.
195
- base.layout("rest_framework")
196
-
197
- # Add class attributes unless they already exist.
198
- RRF_BASE_CONFIG.each do |a, default|
199
- next if base.respond_to?(a)
200
-
201
- # Don't leak class attributes to the instance to avoid conflicting with action methods.
202
- base.class_attribute(a, default: default, instance_accessor: false)
203
- end
204
-
205
- # Alias `extra_actions` to `extra_collection_actions`.
206
- unless base.respond_to?(:extra_collection_actions)
207
- base.singleton_class.alias_method(:extra_collection_actions, :extra_actions)
208
- base.singleton_class.alias_method(:extra_collection_actions=, :extra_actions=)
209
- end
210
-
211
- # Skip CSRF since this is an API.
212
- begin
213
- base.skip_before_action(:verify_authenticity_token)
214
- rescue
215
- nil
216
- end
217
-
218
- # Handle some common exceptions.
219
- unless RESTFramework.config.disable_rescue_from
220
- base.rescue_from(
221
- ActionController::ParameterMissing,
222
- ActionController::UnpermittedParameters,
223
- ActionDispatch::Http::Parameters::ParseError,
224
- ActiveRecord::AssociationTypeMismatch,
225
- ActiveRecord::NotNullViolation,
226
- ActiveRecord::RecordNotFound,
227
- ActiveRecord::RecordInvalid,
228
- ActiveRecord::RecordNotSaved,
229
- ActiveRecord::RecordNotDestroyed,
230
- ActiveRecord::RecordNotUnique,
231
- ActiveModel::UnknownAttributeError,
232
- with: :rrf_error_handler,
233
- )
234
- end
235
-
236
- # Use `TracePoint` hook to automatically call `rrf_finalize`.
237
- if RESTFramework.config.auto_finalize
238
- # :nocov:
239
- TracePoint.trace(:end) do |t|
240
- next if base != t.self
241
-
242
- base.rrf_finalize
243
-
244
- # It's important to disable the trace once we've found the end of the base class definition,
245
- # for performance.
246
- t.disable
247
- end
248
- # :nocov:
249
- end
250
- end
251
-
252
- def get_serializer_class
253
- self.class.serializer_class
254
- end
255
-
256
- # Serialize the given data using the `serializer_class`.
257
- def serialize(data, **kwargs)
258
- RESTFramework::Utils.wrap_ams(self.get_serializer_class).new(
259
- data, controller: self, **kwargs
260
- ).serialize
261
- end
262
-
263
- def rrf_error_handler(e)
264
- status = case e
265
- when ActiveRecord::RecordNotFound
266
- 404
267
- else
268
- 400
269
- end
270
-
271
- render(
272
- api: {
273
- message: e.message,
274
- errors: e.try(:record).try(:errors),
275
- exception: RESTFramework.config.show_backtrace ? e.full_message : nil,
276
- }.compact,
277
- status: status,
3
+ RESTFramework.deprecator.warn(
4
+ "BaseControllerMixin is deprecated; use RESTFramework::Controller instead.",
278
5
  )
279
- end
280
-
281
- def route_groups
282
- @route_groups ||= RESTFramework::Utils.get_routes(Rails.application.routes, request)
283
- end
284
-
285
- # Render a browsable API for `html` format, along with basic `json`/`xml` formats, and with
286
- # support or passing custom `kwargs` to the underlying `render` calls.
287
- def render_api(payload, **kwargs)
288
- html_kwargs = kwargs.delete(:html_kwargs) || {}
289
- json_kwargs = kwargs.delete(:json_kwargs) || {}
290
- xml_kwargs = kwargs.delete(:xml_kwargs) || {}
291
-
292
- # Raise helpful error if payload is nil. Usually this happens when a record is not found (e.g.,
293
- # when passing something like `User.find_by(id: some_id)` to `render_api`). The caller should
294
- # actually be calling `find_by!` to raise ActiveRecord::RecordNotFound and allowing the REST
295
- # framework to catch this error and return an appropriate error response.
296
- if payload.nil?
297
- raise RESTFramework::NilPassedToRenderAPIError
298
- end
299
-
300
- # If `payload` is an `ActiveRecord::Relation` or `ActiveRecord::Base`, then serialize it.
301
- if payload.is_a?(ActiveRecord::Base) || payload.is_a?(ActiveRecord::Relation)
302
- payload = self.serialize(payload)
303
- end
304
-
305
- # Do not use any adapters by default, if configured.
306
- if self.class.disable_adapters_by_default && !kwargs.key?(:adapter)
307
- kwargs[:adapter] = nil
308
- end
309
-
310
- # Flag to track if we had to rescue unknown format.
311
- already_rescued_unknown_format = false
312
-
313
- begin
314
- respond_to do |format|
315
- if payload == ""
316
- format.json { head(kwargs[:status] || :no_content) } if self.class.serialize_to_json
317
- format.xml { head(kwargs[:status] || :no_content) } if self.class.serialize_to_xml
318
- else
319
- format.json {
320
- render(json: payload, **kwargs.merge(json_kwargs))
321
- } if self.class.serialize_to_json
322
- format.xml {
323
- render(xml: payload, **kwargs.merge(xml_kwargs))
324
- } if self.class.serialize_to_xml
325
- # TODO: possibly support more formats here if supported?
326
- end
327
- format.html {
328
- @payload = payload
329
- if payload == ""
330
- @json_payload = "" if self.class.serialize_to_json
331
- @xml_payload = "" if self.class.serialize_to_xml
332
- else
333
- @json_payload = payload.to_json if self.class.serialize_to_json
334
- @xml_payload = payload.to_xml if self.class.serialize_to_xml
335
- end
336
- @title ||= self.class.get_title
337
- @description ||= self.class.description
338
- self.route_groups
339
- begin
340
- render(**kwargs.merge(html_kwargs))
341
- rescue ActionView::MissingTemplate
342
- # A view is not required, so just use `html: ""`.
343
- render(html: "", layout: true, **kwargs.merge(html_kwargs))
344
- end
345
- }
346
- end
347
- rescue ActionController::UnknownFormat
348
- if !already_rescued_unknown_format && rescue_format = self.class.rescue_unknown_format_with
349
- request.format = rescue_format
350
- already_rescued_unknown_format = true
351
- retry
352
- else
353
- raise
354
- end
355
- end
356
- end
357
-
358
- # Compatibility alias for deprecated `api_response`.
359
- alias_method :api_response, :render_api
360
-
361
- def openapi_document
362
- first, *rest = self.route_groups.to_a
363
- document = self.class.openapi_document(request, *first)
364
-
365
- if self.class.openapi_include_children
366
- rest.each do |route_group_name, routes|
367
- controller = "#{routes.first[:route].defaults[:controller]}_controller".camelize.constantize
368
- child_document = controller.openapi_document(request, route_group_name, routes)
369
-
370
- # Merge child paths and tags into the parent document.
371
- document[:paths].merge!(child_document[:paths])
372
- document[:tags] += child_document[:tags]
373
-
374
- # If the child document has schemas, merge them into the parent document.
375
- if schemas = child_document.dig(:components, :schemas) # rubocop:disable Style/Next
376
- document[:components] ||= {}
377
- document[:components][:schemas] ||= {}
378
- document[:components][:schemas].merge!(schemas)
379
- end
380
- end
381
- end
382
-
383
- document
384
- end
385
6
 
386
- def options
387
- render(api: self.openapi_document)
7
+ base.include(RESTFramework::Controller)
388
8
  end
389
9
  end
390
10
 
@@ -1,91 +1,50 @@
1
- require_relative "model_controller_mixin"
2
-
3
- # Mixin for creating records in bulk. This is unique compared to update/destroy because we overload
4
- # the existing `create` action to support bulk creation.
5
1
  module RESTFramework::Mixins::BulkCreateModelMixin
6
- # While bulk update/destroy are obvious because they create new router endpoints, bulk create
7
- # overloads the existing collection `POST` endpoint, so we add a special key to the OpenAPI
8
- # metadata to indicate bulk create is supported.
9
- def openapi_document
10
- super.merge({ "x-rrf-bulk-create": true })
11
- end
12
-
13
- def create
14
- if params[:_json].is_a?(Array)
15
- records = self.create_all!
16
- serialized_records = self.bulk_serialize(records)
17
- return render(api: serialized_records)
18
- end
19
-
20
- super
21
- end
22
-
23
- # Perform the `create` call, and return the collection of (possibly) created records.
24
- def create_all!
25
- create_data = self.get_create_params(bulk_mode: true)[:_json]
2
+ def self.included(base)
3
+ RESTFramework.deprecator.warn(<<~TXT).squish
4
+ BulkCreateModelMixin is deprecated; set the `bulk = true` class attribute instead.
5
+ TXT
26
6
 
27
- # Perform bulk create in a transaction.
28
- ActiveRecord::Base.transaction { self.create_from.create(create_data) }
7
+ base.bulk = true
29
8
  end
30
9
  end
31
10
 
32
11
  # Mixin for updating records in bulk.
33
12
  module RESTFramework::Mixins::BulkUpdateModelMixin
34
- def update_all
35
- records = self.update_all!
36
- serialized_records = self.bulk_serialize(records)
37
- render(api: serialized_records)
38
- end
39
-
40
- # Perform the `update` call and return the collection of (possibly) updated records.
41
- def update_all!
42
- pk = self.class.get_model.primary_key
43
- data = if params[:_json].is_a?(Array)
44
- self.get_create_params(bulk_mode: :update)[:_json].index_by { |r| r[pk] }
45
- else
46
- create_params = self.get_create_params
47
- { create_params[pk] => create_params }
48
- end
13
+ def self.included(base)
14
+ RESTFramework.deprecator.warn(<<~TXT).squish
15
+ BulkUpdateModelMixin is deprecated; set the `bulk = true`, and `excluded_actions` class
16
+ attributes instead.
17
+ TXT
49
18
 
50
- # Perform bulk update in a transaction.
51
- ActiveRecord::Base.transaction { self.get_recordset.update(data.keys, data.values) }
19
+ base.bulk = true
20
+ base.excluded_actions = (base.excluded_actions - [ :update_all ]).freeze
52
21
  end
53
22
  end
54
23
 
55
24
  # Mixin for destroying records in bulk.
56
25
  module RESTFramework::Mixins::BulkDestroyModelMixin
57
- def destroy_all
58
- if params[:_json].is_a?(Array)
59
- records = self.destroy_all!
60
- serialized_records = self.bulk_serialize(records)
61
- return render(api: serialized_records)
62
- end
63
-
64
- render(
65
- api: { message: "Bulk destroy requires an array of primary keys as input." }, status: 400,
66
- )
67
- end
68
-
69
- # Perform the `destroy!` call and return the destroyed (and frozen) record.
70
- def destroy_all!
71
- pk = self.class.get_model.primary_key
72
- destroy_data = self.request.request_parameters[:_json]
26
+ def self.included(base)
27
+ RESTFramework.deprecator.warn(<<~TXT).squish
28
+ BulkDestroyModelMixin is deprecated; set the `bulk = true`, and `excluded_actions` class
29
+ attributes instead.
30
+ TXT
73
31
 
74
- # Perform bulk destroy in a transaction.
75
- ActiveRecord::Base.transaction { self.get_recordset.where(pk => destroy_data).destroy_all }
32
+ base.bulk = true
33
+ base.excluded_actions = (base.excluded_actions - [ :destroy_all ]).freeze
76
34
  end
77
35
  end
78
36
 
79
37
  # Mixin that includes all the CRUD bulk mixins.
80
38
  module RESTFramework::Mixins::BulkModelControllerMixin
81
- include RESTFramework::Mixins::ModelControllerMixin
82
-
83
- include RESTFramework::Mixins::BulkCreateModelMixin
84
- include RESTFramework::Mixins::BulkUpdateModelMixin
85
- include RESTFramework::Mixins::BulkDestroyModelMixin
86
-
87
39
  def self.included(base)
88
- RESTFramework::Mixins::ModelControllerMixin.included(base)
40
+ RESTFramework.deprecator.warn(<<~TXT).squish
41
+ BulkModelControllerMixin is deprecated; use RESTFramework::Controller and set the `model` and
42
+ `bulk = true` class attributes instead.
43
+ TXT
44
+
45
+ base.include(RESTFramework::Controller)
46
+ base.model = RESTFramework::Utils.get_model(base)
47
+ base.bulk = true
89
48
  end
90
49
  end
91
50