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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fa4f1c8277a69cc73b8683ac06a9a31c0706fa144d1e1d030eb0f97987da2429
4
- data.tar.gz: 7f275441694689003a4f25df054c040a238a55ea63f331a77ff0fb370bff102b
3
+ metadata.gz: ebb2a69a51a24192200122bf40a9218e15e1f0d696bfdd63bb8f3aed9f43f82c
4
+ data.tar.gz: 3b630e5e5f8ec28096e245bc8ced22d5a63423d52799983543c1616b636999d8
5
5
  SHA512:
6
- metadata.gz: 373581c9bb9eb2887bb0a66ed8013cbe2f595fa2f0ddb0e9e7180b89b9d81d02991556c14ade1dd2a9ac86844a76a3080daa41fb4d5debba8357959d2fe7ec52
7
- data.tar.gz: f26c82b74d3cb344e65bc863a3718e152e4f107d7d709c64a7c47779c9ed60024e26c00709fd934177099b71cbdfdda37719f43e864a4aed907ca2676f31897f
6
+ metadata.gz: d0eae7105c8fe37a710ac56c9ba0c4df9b0f0663883d117123af6e778735c63117ca58d82e60e260e353aefcc9536e996848341a6537ca963d764b498dfba979
7
+ data.tar.gz: 72e217a7d2c9bed976d6801f333be3a97ebdedf1998b07fa4a4594c48256919dcd1cd4b48030582fc75bdd9e2e6d002dc29df931e217ab78cd97b87872f65c78
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.1.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>
@@ -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,62 @@
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)
5
+ # This is kinda slow, so perhaps we should eventually integrate `errors` serialization into
6
+ # the serializer directly. This would fail for active model serializers, but maybe we don't
7
+ # care?
8
+ s = RESTFramework::Utils.wrap_ams(self.get_serializer_class)
9
+ records.map do |record|
10
+ s.new(record, controller: self).serialize.merge!({ errors: record.errors.presence }.compact)
11
+ end
12
+ end
13
+
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]
17
+
18
+ # Perform bulk create in a transaction.
19
+ ActiveRecord::Base.transaction { self.create_from.create(create_data) }
20
+ end
21
+
22
+ def update_all
23
+ records = self.update_all!
24
+ serialized_records = self.bulk_serialize(records)
25
+ render(api: serialized_records)
26
+ end
27
+
28
+ # Perform the `update` call and return the collection of (possibly) updated records.
29
+ def update_all!
30
+ 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] }
33
+ else
34
+ create_params = self.get_create_params
35
+ { create_params[pk] => create_params }
36
+ end
37
+
38
+ # Perform bulk update in a transaction.
39
+ ActiveRecord::Base.transaction { self.get_recordset.update(data.keys, data.values) }
40
+ end
41
+
42
+ 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)
47
+ end
48
+
49
+ render(
50
+ api: { message: "Bulk destroy requires an array of primary keys as input." }, status: 400,
51
+ )
52
+ end
53
+
54
+ # Perform the `destroy!` call and return the destroyed (and frozen) record.
55
+ def destroy_all!
56
+ pk = self.class.model.primary_key
57
+ destroy_data = self.request.request_parameters[:_json]
58
+
59
+ # Perform bulk destroy in a transaction.
60
+ ActiveRecord::Base.transaction { self.get_recordset.where(pk => destroy_data).destroy_all }
61
+ end
62
+ end
@@ -0,0 +1,66 @@
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
+ records = self.create_all!
6
+ return render(api: self.bulk_serialize(records))
7
+ end
8
+
9
+ render(api: self.create!, status: :created)
10
+ end
11
+
12
+ # Perform the `create!` call and return the created record.
13
+ def create!
14
+ self.create_from.create!(self.get_create_params)
15
+ end
16
+
17
+ def index
18
+ render(api: self.get_index_records)
19
+ end
20
+
21
+ # Get records with both filtering and pagination applied.
22
+ def get_index_records
23
+ records = self.get_records
24
+
25
+ # Handle pagination, if enabled.
26
+ if paginator_class = self.class.paginator_class
27
+ # Paginate if there is a `max_page_size`, or if there is no `page_size_query_param`, or if the
28
+ # page size is not set to "0".
29
+ max_page_size = self.class.max_page_size
30
+ page_size_query_param = self.class.page_size_query_param
31
+ if max_page_size || !page_size_query_param || params[page_size_query_param] != "0"
32
+ paginator = paginator_class.new(data: records, controller: self)
33
+ page = paginator.get_page
34
+ serialized_page = self.serialize(page)
35
+ return paginator.get_paginated_response(serialized_page)
36
+ end
37
+ end
38
+
39
+ records
40
+ end
41
+
42
+ def show
43
+ render(api: self.get_record)
44
+ end
45
+
46
+ def update
47
+ render(api: self.update!)
48
+ end
49
+
50
+ # Perform the `update!` call and return the updated record.
51
+ def update!
52
+ record = self.get_record
53
+ record.update!(self.get_update_params)
54
+ record
55
+ end
56
+
57
+ def destroy
58
+ self.destroy!
59
+ render(api: "")
60
+ end
61
+
62
+ # Perform the `destroy!` call and return the destroyed (and frozen) record.
63
+ def destroy!
64
+ self.get_record.destroy!
65
+ end
66
+ end
@@ -0,0 +1,249 @@
1
+ module RESTFramework::Controller
2
+ module ClassMethods
3
+ def openapi_response_content_types
4
+ @openapi_response_content_types ||= [
5
+ "text/html",
6
+ self.serialize_to_json ? "application/json" : nil,
7
+ self.serialize_to_xml ? "application/xml" : nil,
8
+ ].compact
9
+ end
10
+
11
+ def openapi_request_content_types
12
+ @openapi_request_content_types ||= [
13
+ "application/json",
14
+ "application/x-www-form-urlencoded",
15
+ "multipart/form-data",
16
+ ]
17
+ end
18
+
19
+ def openapi_paths(routes, tag)
20
+ resp_cts = self.openapi_response_content_types
21
+ req_cts = self.openapi_request_content_types
22
+ schema_name = self.openapi_schema_name if self.model
23
+
24
+ routes.group_by { |r| r[:concat_path] }.map { |concat_path, routes|
25
+ [
26
+ concat_path.gsub(/:([0-9A-Za-z_-]+)/, "{\\1}"),
27
+ routes.map { |route|
28
+ metadata = RESTFramework::ROUTE_METADATA[route[:path]] || {}
29
+ summary = metadata.delete(:label).presence || self.label_for(route[:action])
30
+ description = metadata.delete(:description).presence
31
+ extra_action = RESTFramework::EXTRA_ACTION_ROUTES.include?(route[:path])
32
+ error_response = { "$ref" => "#/components/responses/BadRequest" }
33
+ not_found_response = { "$ref" => "#/components/responses/NotFound" }
34
+ spec = { tags: [ tag ], summary: summary, description: description }.compact
35
+
36
+ # All routes should have a successful response.
37
+ success_code = if !extra_action
38
+ if route[:action] == "create"
39
+ 201
40
+ elsif route[:action] == "destroy"
41
+ 204
42
+ else
43
+ 200
44
+ end
45
+ else
46
+ 200
47
+ end
48
+ spec[:responses] = {
49
+ success_code => {
50
+ content: resp_cts.map { |ct|
51
+ [
52
+ ct,
53
+ (self.model && !extra_action && route[:verb] != "OPTIONS") ? {
54
+ schema: { "$ref" => "#/components/schemas/#{schema_name}" },
55
+ } : {},
56
+ ]
57
+ }.to_h,
58
+ description: "Success",
59
+ },
60
+ }
61
+
62
+ # Builtin POST, PUT, PATCH, and DELETE should have a 400 and 404 response.
63
+ if route[:verb].in?([ "POST", "PUT", "PATCH", "DELETE" ]) && !extra_action
64
+ spec[:responses][400] = error_response
65
+ spec[:responses][404] = not_found_response
66
+ end
67
+
68
+ # All POST, PUT, PATCH should have a request body.
69
+ if route[:verb].in?([ "POST", "PUT", "PATCH" ])
70
+ spec[:requestBody] ||= {
71
+ content: req_cts.map { |ct|
72
+ [
73
+ ct,
74
+ (self.model && !extra_action) ? {
75
+ schema: { "$ref" => "#/components/schemas/#{schema_name}" },
76
+ } : {},
77
+ ]
78
+ }.to_h,
79
+ }
80
+ end
81
+
82
+ # Add remaining metadata as an extension.
83
+ spec["x-rrf-metadata"] = metadata if metadata.present?
84
+
85
+ next route[:verb].downcase, spec
86
+ }.to_h.merge(
87
+ {
88
+ parameters: routes.first[:route].required_parts.map { |p|
89
+ {
90
+ name: p,
91
+ in: "path",
92
+ required: true,
93
+ schema: { type: "integer" },
94
+ }
95
+ },
96
+ },
97
+ ),
98
+ ]
99
+ }.to_h
100
+ end
101
+
102
+ def openapi_document(request, route_group_name, routes)
103
+ server = request.base_url + request.original_fullpath.gsub(/\?.*/, "")
104
+
105
+ {
106
+ openapi: "3.1.1",
107
+ info: {
108
+ title: self.get_title,
109
+ description: self.description,
110
+ version: self.version.to_s,
111
+ }.compact,
112
+ servers: [ { url: server } ],
113
+ paths: self.openapi_paths(routes, route_group_name),
114
+ tags: [ { name: route_group_name, description: self.description }.compact ],
115
+ components: {
116
+ schemas: {
117
+ "Error" => {
118
+ type: "object",
119
+ required: [ "message" ],
120
+ properties: {
121
+ message: { type: "string" },
122
+ errors: { type: "object" },
123
+ exception: { type: "string" },
124
+ },
125
+ },
126
+ }.merge(self.model ? { self.openapi_schema_name => self.openapi_schema } : {}),
127
+ responses: {
128
+ "BadRequest": {
129
+ description: "Bad Request",
130
+ content: self.openapi_response_content_types.map { |ct|
131
+ [
132
+ ct,
133
+ ct == "text/html" ? {} : { schema: { "$ref" => "#/components/schemas/Error" } },
134
+ ]
135
+ }.to_h,
136
+ },
137
+ "NotFound": {
138
+ description: "Not Found",
139
+ content: self.openapi_response_content_types.map { |ct|
140
+ [
141
+ ct,
142
+ ct == "text/html" ? {} : { schema: { "$ref" => "#/components/schemas/Error" } },
143
+ ]
144
+ }.to_h,
145
+ },
146
+ },
147
+ },
148
+ }.merge(self.model ? {
149
+ "x-rrf-primary_key" => self.model.primary_key,
150
+ "x-rrf-callbacks" => self._process_action_callbacks.as_json,
151
+
152
+ # While bulk update/destroy are obvious because they create new router endpoints, bulk
153
+ # create overloads the existing collection `POST` endpoint, so we add a special key to the
154
+ # OpenAPI metadata to indicate bulk create is supported.
155
+ "x-rrf-bulk-create": self.bulk,
156
+ } : {}).compact
157
+ end
158
+
159
+ # Only for model controllers.
160
+ def openapi_schema
161
+ return @openapi_schema if @openapi_schema
162
+
163
+ field_configuration = self.field_configuration
164
+ @openapi_schema = {
165
+ required: field_configuration.select { |_, cfg| cfg[:required] }.keys,
166
+ type: "object",
167
+ properties: field_configuration.map { |f, cfg|
168
+ v = { title: cfg[:label] }
169
+
170
+ if cfg[:kind] == "association"
171
+ v[:type] = cfg[:reflection].collection? ? "array" : "object"
172
+ elsif cfg[:kind] == "rich_text"
173
+ v[:type] = "string"
174
+ v[:"x-rrf-rich_text"] = true
175
+ elsif cfg[:kind] == "attachment"
176
+ v[:type] = "string"
177
+ v[:"x-rrf-attachment"] = cfg[:attachment_type]
178
+ else
179
+ v[:type] = cfg[:type]
180
+ end
181
+
182
+ v[:readOnly] = true if cfg[:read_only]
183
+ v[:default] = cfg[:default] if cfg.key?(:default)
184
+
185
+ if enum_variants = cfg[:enum_variants]
186
+ v[:enum] = enum_variants.keys
187
+ v[:"x-rrf-enum_variants"] = enum_variants
188
+ end
189
+
190
+ if validators = cfg[:validators]
191
+ v[:"x-rrf-validators"] = validators
192
+ end
193
+
194
+ v[:"x-rrf-kind"] = cfg[:kind] if cfg[:kind]
195
+
196
+ if cfg[:reflection]
197
+ 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,
204
+ }.compact
205
+ 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]
208
+ v[:"x-rrf-id_field"] = cfg[:id_field]
209
+ v[:"x-rrf-nested_attributes_options"] = cfg[:nested_attributes_options]
210
+ end
211
+
212
+ next [ f, v ]
213
+ }.to_h,
214
+ }
215
+
216
+ @openapi_schema
217
+ end
218
+
219
+ # Only for model controllers.
220
+ def openapi_schema_name
221
+ @openapi_schema_name ||= self.name.chomp("Controller").gsub("::", ".")
222
+ end
223
+ end
224
+
225
+ def openapi_document
226
+ first, *rest = self.route_groups.to_a
227
+ document = self.class.openapi_document(request, *first)
228
+
229
+ if self.class.openapi_include_children
230
+ rest.each do |route_group_name, routes|
231
+ controller = "#{routes.first[:route].defaults[:controller]}_controller".camelize.constantize
232
+ child_document = controller.openapi_document(request, route_group_name, routes)
233
+
234
+ # Merge child paths and tags into the parent document.
235
+ document[:paths].merge!(child_document[:paths])
236
+ document[:tags] += child_document[:tags]
237
+
238
+ # If the child document has schemas, merge them into the parent document.
239
+ if schemas = child_document.dig(:components, :schemas) # rubocop:disable Style/Next
240
+ document[:components] ||= {}
241
+ document[:components][:schemas] ||= {}
242
+ document[:components][:schemas].merge!(schemas)
243
+ end
244
+ end
245
+ end
246
+
247
+ document
248
+ end
249
+ end