rest_framework 0.7.4 → 0.7.6

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: e37d7baf0b2e805e4df451a8ef646e57ed078a7edd3f9d03b524198eef9fc83f
4
- data.tar.gz: c6f5bf3f8f7bfa79566a1292f7634f94b29a09a934a0179adea6e3320291cb9e
3
+ metadata.gz: edeb7963dbca0185ff384e628a185c83341e3154d6414c9d8f70a05115abd23a
4
+ data.tar.gz: 8d6bc94fea07e57c20625c8aa192ced88789e078bf3a4b29b71e6c80099f2436
5
5
  SHA512:
6
- metadata.gz: 13b7905e307bbc0f204d2570c05298cf9c4bcc025719210c3f496bac64a387f611ec4ab3772f8c268cc41b608973b7d3502cf5542d9fee0cce5587ef80a773cc
7
- data.tar.gz: 49ef1e4a637fa2f3dee50f40042cdf50a28f68f632a0e5587a339e492954ef90c1f7cd8d80f4b0fb39668838ee020556b29dfd806377e82541b3c4a8940d6b55
6
+ metadata.gz: 68ecf8a8a045c3e217c597f86b46573dc80bb931e20be45af01b939079f8cf2dd27d68e8bebd8d458726b04b8b55fb2dd65a60bcf4c847c30feb1257f7cfb9ea
7
+ data.tar.gz: 388478f7c8f2c51a96740f25043aa7d188b5e59731478f4bca2ce6171fadc5ba1e201b12ee051859386ce151c1114b9b6f207dbe840e732dec652d9639323825
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.7.4
1
+ 0.7.6
@@ -3,8 +3,20 @@
3
3
  <%= csrf_meta_tags %>
4
4
  <%= csp_meta_tag rescue nil %>
5
5
 
6
+ <!-- Bootstrap -->
6
7
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/css/bootstrap.min.css" integrity="sha384-F3w7mX95PdgyTmZZMECAngseQB83DfGTowi0iMjiWaeVhAn4FJkqJByhZMI3AhiU" crossorigin="anonymous">
7
- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/styles/vs.min.css" integrity="sha512-aWjgJTbdG4imzxTxistV5TVNffcYGtIQQm2NBNahV6LmX14Xq9WwZTL1wPjaSglUuVzYgwrq+0EuI4+vKvQHHw==" crossorigin="anonymous">
8
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/js/bootstrap.bundle.min.js" integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ" crossorigin="anonymous"></script>
9
+
10
+ <!-- Highlight.js -->
11
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/vs.min.css" integrity="sha512-AVoZ71dJLtHRlsgWwujPT1hk2zxtFWsPlpTPCc/1g0WgpbmlzkqlDFduAvnOV4JJWKUquPc1ZyMc5eq4fRnKOQ==" crossorigin="anonymous" referrerpolicy="no-referrer" />
12
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js" integrity="sha512-bgHRAiTjGrzHzLyKOnpFvaEpGzJet3z4tZnXGjpsCcqOnAH6VGUx9frc5bcIhKTVLEiCO6vEhNAgx5jtLUYrfA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
13
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/languages/json.min.js" integrity="sha512-0xYvyncS9OLE7GOpNBZFnwyh9+bq4HVgk4yVVYI678xRvE22ASicF1v6fZ1UiST+M6pn17MzFZdvVCI3jTHSyw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
14
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/languages/xml.min.js" integrity="sha512-5zBcw+OKRkaNyvUEPlTSfYylVzgpi7KpncY36b0gRudfxIYIH0q0kl2j26uCUB3YBRM6ytQQEZSgRg+ZlBTmdA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
15
+
16
+ <!-- NeatJSON -->
17
+ <script src="https://cdn.jsdelivr.net/npm/neatjson@0.10.5/javascript/neatjson.min.js"></script>
18
+
19
+ <!-- Custom Style -->
8
20
  <style>
9
21
  /* Adjust headers to always take up their entire row, and tweak the sizing. */
10
22
  h1,h2,h3,h4,h5,h6 { display: inline-block; font-weight: normal; margin-bottom: 0; }
@@ -17,7 +29,7 @@ h6 { font-size: 1rem; }
17
29
 
18
30
  /* Make code and code blocks a little nicer looking. */
19
31
  code {
20
- padding: 0 .35em;
32
+ padding: .5em !important;
21
33
  background-color: #f3f3f3 !important;
22
34
  border: 1px solid #aaa;
23
35
  border-radius: 3px;
@@ -56,25 +68,22 @@ code {
56
68
  }
57
69
  </style>
58
70
 
59
- <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/js/bootstrap.bundle.min.js" integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ" crossorigin="anonymous"></script>
60
- <script src="https://cdn.jsdelivr.net/npm/neatjson@0.10.5/javascript/neatjson.min.js"></script>
61
- <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/highlight.min.js" integrity="sha512-TDKKr+IvoqZnPzc3l35hdjpHD0m+b2EC2SrLEgKDRWpxf2rFCxemkgvJ5kfU48ip+Y+m2XVKyOCD85ybtlZDmw==" crossorigin="anonymous"></script>
62
- <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/languages/json.min.js" integrity="sha512-FoN8JE+WWCdIGXAIT8KQXwpiavz0Mvjtfk7Rku3MDUNO0BDCiRMXAsSX+e+COFyZTcDb9HDgP+pM2RX12d4j+A==" crossorigin="anonymous"></script>
63
- <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/languages/xml.min.js" integrity="sha512-dICltIgnUP+QSJrnYGCV8943p3qSDgvcg2NU4W8IcOZP4tdrvxlXjbhIznhtVQEcXow0mOjLM0Q6/NvZsmUH4g==" crossorigin="anonymous"></script>
71
+ <!-- Custom JavaScript -->
64
72
  <script>
65
- hljs.initHighlightingOnLoad()
66
-
67
73
  // What to do when document loads.
68
74
  document.addEventListener("DOMContentLoaded", (event) => {
69
75
  // Pretty-print JSON.
70
76
  [...document.getElementsByClassName("language-json")].forEach((element, index) => {
71
- element.innerHTML = neatJSON(JSON.parse(element.innerHTML), {
77
+ element.innerHTML = neatJSON(JSON.parse(element.innerText), {
72
78
  wrap: 80,
73
79
  afterComma: 1,
74
80
  afterColon: 1,
75
81
  })
76
82
  });
77
83
 
84
+ // Then highlight it.
85
+ hljs.highlightAll();
86
+
78
87
  // Insert copy link and callback to copy contents of `<code>` element.
79
88
  [...document.getElementsByClassName("rrf-copy")].forEach((element, index) => {
80
89
  element.insertAdjacentHTML(
@@ -12,16 +12,15 @@ module RESTFramework::BaseControllerMixin
12
12
  :created_at, :created_by, :created_by_id, :updated_at, :updated_by, :updated_by_id
13
13
  ].freeze,
14
14
  accept_generic_params_as_body_params: false,
15
- show_backtrace: false,
16
15
  extra_actions: nil,
17
16
  extra_member_actions: nil,
18
17
  filter_backends: nil,
19
18
  singleton_controller: nil,
20
19
 
21
- # Metadata and display options.
20
+ # Options related to metadata and display.
22
21
  title: nil,
23
22
  description: nil,
24
- inflect_acronyms: ["ID", "REST", "API"].freeze,
23
+ inflect_acronyms: ["ID", "IDs", "REST", "API", "APIs"].freeze,
25
24
 
26
25
  # Options related to serialization.
27
26
  rescue_unknown_format_with: :json,
@@ -36,6 +35,10 @@ module RESTFramework::BaseControllerMixin
36
35
  page_size_query_param: "page_size",
37
36
  max_page_size: nil,
38
37
 
38
+ # Options related to bulk actions and batch processing.
39
+ bulk_guard_query_param: nil,
40
+ enable_batch_processing: nil,
41
+
39
42
  # Option to disable serializer adapters by default, mainly introduced because Active Model
40
43
  # Serializers will do things like serialize `[]` into `{"":[]}`.
41
44
  disable_adapters_by_default: true,
@@ -74,6 +77,13 @@ module RESTFramework::BaseControllerMixin
74
77
  end
75
78
  end
76
79
 
80
+ # Add builtin bulk actions.
81
+ RESTFramework::RRF_BUILTIN_BULK_ACTIONS.each do |action, methods|
82
+ if self.method_defined?(action)
83
+ actions[action] = {path: "", methods: methods, type: :builtin}
84
+ end
85
+ end
86
+
77
87
  # Add extra actions.
78
88
  if extra_actions = self.try(:extra_actions)
79
89
  actions.merge!(RESTFramework::Utils.parse_extra_actions(extra_actions, controller: self))
@@ -159,11 +169,17 @@ module RESTFramework::BaseControllerMixin
159
169
  end
160
170
 
161
171
  # Handle some common exceptions.
162
- base.rescue_from(ActiveRecord::RecordNotFound, with: :record_not_found)
163
- base.rescue_from(ActiveRecord::RecordInvalid, with: :record_invalid)
164
- base.rescue_from(ActiveRecord::RecordNotSaved, with: :record_not_saved)
165
- base.rescue_from(ActiveRecord::RecordNotDestroyed, with: :record_not_destroyed)
166
- base.rescue_from(ActiveModel::UnknownAttributeError, with: :unknown_attribute_error)
172
+ unless RESTFramework.config.disable_rescue_from
173
+ base.rescue_from(
174
+ ActiveRecord::RecordNotFound,
175
+ ActiveRecord::RecordInvalid,
176
+ ActiveRecord::RecordNotSaved,
177
+ ActiveRecord::RecordNotDestroyed,
178
+ ActiveRecord::RecordNotUnique,
179
+ ActiveModel::UnknownAttributeError,
180
+ with: :rrf_error_handler,
181
+ )
182
+ end
167
183
 
168
184
  # Use `TracePoint` hook to automatically call `rrf_finalize`.
169
185
  unless RESTFramework.config.disable_auto_finalize
@@ -211,6 +227,7 @@ module RESTFramework::BaseControllerMixin
211
227
 
212
228
  # Filter an arbitrary data set over all configured filter backends.
213
229
  def get_filtered_data(data)
230
+ # Apply each filter sequentially.
214
231
  self.get_filter_backends.each do |filter_class|
215
232
  filter = filter_class.new(controller: self)
216
233
  data = filter.get_filtered_data(data)
@@ -223,53 +240,26 @@ module RESTFramework::BaseControllerMixin
223
240
  return self.class.get_options_metadata
224
241
  end
225
242
 
226
- def record_invalid(e)
227
- return api_response(
228
- {
229
- message: "Record invalid.", errors: e.record&.errors
230
- }.merge(self.class.show_backtrace ? {exception: e.full_message} : {}),
231
- status: 400,
232
- )
233
- end
234
-
235
- def record_not_found(e)
236
- return api_response(
237
- {
238
- message: "Record not found.",
239
- }.merge(self.class.show_backtrace ? {exception: e.full_message} : {}),
240
- status: 404,
241
- )
242
- end
243
-
244
- def record_not_saved(e)
245
- return api_response(
246
- {
247
- message: "Record not saved.", errors: e.record&.errors
248
- }.merge(self.class.show_backtrace ? {exception: e.full_message} : {}),
249
- status: 400,
250
- )
251
- end
252
-
253
- def record_not_destroyed(e)
254
- return api_response(
255
- {
256
- message: "Record not destroyed.", errors: e.record&.errors
257
- }.merge(self.class.show_backtrace ? {exception: e.full_message} : {}),
258
- status: 400,
259
- )
260
- end
243
+ def rrf_error_handler(e)
244
+ status = case e
245
+ when ActiveRecord::RecordNotFound
246
+ 404
247
+ else
248
+ 400
249
+ end
261
250
 
262
- def unknown_attribute_error(e)
263
251
  return api_response(
264
252
  {
265
- message: e.message.capitalize,
266
- }.merge(self.class.show_backtrace ? {exception: e.full_message} : {}),
267
- status: 400,
253
+ message: e.message,
254
+ errors: e.try(:record).try(:errors),
255
+ exception: RESTFramework.config.show_backtrace ? e.full_message : nil,
256
+ }.compact,
257
+ status: status,
268
258
  )
269
259
  end
270
260
 
271
- # Helper to render a browsable API for `html` format, along with basic `json`/`xml` formats, and
272
- # with support or passing custom `kwargs` to the underlying `render` calls.
261
+ # Render a browsable API for `html` format, along with basic `json`/`xml` formats, and with
262
+ # support or passing custom `kwargs` to the underlying `render` calls.
273
263
  def api_response(payload, html_kwargs: nil, **kwargs)
274
264
  html_kwargs ||= {}
275
265
  json_kwargs = kwargs.delete(:json_kwargs) || {}
@@ -0,0 +1,82 @@
1
+ require_relative "models"
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
+ # :nocov:
6
+ module RESTFramework::BulkCreateModelMixin
7
+ def create
8
+ status, payload = self.create_all!
9
+ return api_response(payload, status: status)
10
+ end
11
+
12
+ # Perform the `create` or `insert_all` call and return the created records with any errors. The
13
+ # result should be of the form: `(status, payload)`, and `payload` should be of the form:
14
+ # `[{success:, record: | errors:}]`, unless batch mode is enabled, in which case `payload` is
15
+ # blank with a status of `202`.
16
+ def create_all!
17
+ if self.class.bulk_batch_mode
18
+ insert_from = if self.get_recordset.respond_to?(:insert_all) && self.create_from_recordset
19
+ # Create with any properties inherited from the recordset. We exclude any `select` clauses
20
+ # in case model callbacks need to call `count` on this collection, which typically raises a
21
+ # SQL `SyntaxError`.
22
+ self.get_recordset.except(:select)
23
+ else
24
+ # Otherwise, perform a "bare" insert_all.
25
+ self.class.get_model
26
+ end
27
+
28
+ insert_from
29
+ end
30
+
31
+ # Perform bulk creation, possibly in a transaction.
32
+ self.class._rrf_bulk_transaction do
33
+ if self.get_recordset.respond_to?(:insert_all) && self.create_from_recordset
34
+ # Create with any properties inherited from the recordset. We exclude any `select` clauses
35
+ # in case model callbacks need to call `count` on this collection, which typically raises a
36
+ # SQL `SyntaxError`.
37
+ return self.get_recordset.except(:select).create!(self.get_create_params)
38
+ else
39
+ # Otherwise, perform a "bare" insert_all.
40
+ return self.class.get_model.insert_all(self.get_create_params)
41
+ end
42
+ end
43
+ end
44
+ end
45
+
46
+ # Mixin for updating records in bulk.
47
+ module RESTFramework::BulkUpdateModelMixin
48
+ def update_all
49
+ raise NotImplementedError, "TODO"
50
+ end
51
+
52
+ # Perform the `update!` call and return the updated record.
53
+ def update_all!
54
+ raise NotImplementedError, "TODO"
55
+ end
56
+ end
57
+
58
+ # Mixin for destroying records in bulk.
59
+ module RESTFramework::BulkDestroyModelMixin
60
+ def destroy_all
61
+ raise NotImplementedError, "TODO"
62
+ end
63
+
64
+ # Perform the `destroy!` call and return the destroyed (and frozen) record.
65
+ def destroy_all!
66
+ raise NotImplementedError, "TODO"
67
+ end
68
+ end
69
+
70
+ # Mixin that includes all the CRUD bulk mixins.
71
+ module RESTFramework::BulkModelControllerMixin
72
+ include RESTFramework::ModelControllerMixin
73
+
74
+ include RESTFramework::BulkCreateModelMixin
75
+ include RESTFramework::BulkUpdateModelMixin
76
+ include RESTFramework::BulkDestroyModelMixin
77
+
78
+ def self.included(base)
79
+ RESTFramework::ModelControllerMixin.included(base)
80
+ end
81
+ end
82
+ # :nocov:
@@ -12,6 +12,7 @@ module RESTFramework::BaseModelControllerMixin
12
12
 
13
13
  # Attributes for configuring record fields.
14
14
  fields: nil,
15
+ field_config: nil,
15
16
  action_fields: nil,
16
17
 
17
18
  # Attributes for finding records.
@@ -38,9 +39,22 @@ module RESTFramework::BaseModelControllerMixin
38
39
  search_query_param: "search",
39
40
  search_ilike: false,
40
41
 
41
- # Other misc attributes.
42
- create_from_recordset: true, # Option for `recordset.create` vs `Model.create` behavior.
43
- filter_recordset_before_find: true, # Control if filtering is done before find.
42
+ # Option for `recordset.create` vs `Model.create` behavior.
43
+ create_from_recordset: true,
44
+
45
+ # Control if filtering is done before find.
46
+ filter_recordset_before_find: true,
47
+
48
+ # Option to exclude associations from default fields.
49
+ exclude_associations: false,
50
+
51
+ # Control if bulk operations are done in a transaction and rolled back on error, or if all bulk
52
+ # operations are attempted and errors simply returned in the response.
53
+ bulk_transactional: false,
54
+
55
+ # Control if bulk operations should be done in "batch" mode, using efficient queries, but also
56
+ # skipping model validations/callbacks.
57
+ bulk_batch_mode: false,
44
58
  }
45
59
 
46
60
  module ClassMethods
@@ -77,14 +91,54 @@ module RESTFramework::BaseModelControllerMixin
77
91
  return self.get_model.human_attribute_name(s, default: super)
78
92
  end
79
93
 
94
+ # Get fields without any action context. Always fallback to columns at the class level.
95
+ def get_fields
96
+ if self.fields.is_a?(Hash)
97
+ return RESTFramework::Utils.parse_fields_hash(
98
+ self.fields, self.get_model, exclude_associations: self.exclude_associations
99
+ )
100
+ end
101
+
102
+ return self.fields || (
103
+ self.get_model ? RESTFramework::Utils.fields_for(
104
+ self.get_model, exclude_associations: self.exclude_associations
105
+ ) : []
106
+ )
107
+ end
108
+
109
+ # Get a field's config, including defaults.
110
+ def get_field_config(f)
111
+ config = self.field_config&.dig(f.to_sym) || {}
112
+
113
+ # Default sub-fields if field is an association.
114
+ if ref = self.get_model.reflections[f]
115
+ model = ref.klass
116
+ columns = model.columns_hash
117
+ config[:sub_fields] ||= RESTFramework::Utils.sub_fields_for(ref)
118
+
119
+ # Serialize very basic metadata about sub-fields.
120
+ config[:sub_fields_metadata] = config[:sub_fields].map { |sf|
121
+ v = {}
122
+
123
+ if columns[sf]
124
+ v[:kind] = "column"
125
+ end
126
+
127
+ next [sf, v]
128
+ }.to_h.compact.presence
129
+ end
130
+
131
+ return config.compact
132
+ end
133
+
80
134
  # Get metadata about the resource's fields.
81
- def get_fields_metadata(fields: nil)
135
+ def get_fields_metadata
82
136
  # Get metadata sources.
83
137
  model = self.get_model
84
- fields ||= self.get_fields
85
- fields = fields.map(&:to_s)
138
+ fields = self.get_fields.map(&:to_s)
86
139
  columns = model.columns_hash
87
140
  column_defaults = model.column_defaults
141
+ reflections = model.reflections
88
142
  attributes = model._default_attributes
89
143
 
90
144
  return fields.map { |f|
@@ -105,9 +159,9 @@ module RESTFramework::BaseModelControllerMixin
105
159
 
106
160
  # Determine `type`, `required`, `label`, and `kind` based on schema.
107
161
  if column = columns[f]
162
+ metadata[:kind] = "column"
108
163
  metadata[:type] = column.type
109
164
  metadata[:required] = true unless column.null
110
- metadata[:kind] = "column"
111
165
  end
112
166
 
113
167
  # Determine `default` based on schema; we use `column_defaults` rather than `columns_hash`
@@ -117,25 +171,46 @@ module RESTFramework::BaseModelControllerMixin
117
171
  metadata[:default] = column_default
118
172
  end
119
173
 
120
- # Determine `default` and `kind` based on attribute only if not determined by the DB.
174
+ # Extract details from the model's attributes hash.
121
175
  if attributes.key?(f) && attribute = attributes[f]
122
176
  unless metadata.key?(:default)
123
177
  default = attribute.value_before_type_cast
124
178
  metadata[:default] = default unless default.nil?
125
179
  end
180
+ metadata[:kind] ||= "attribute"
181
+
182
+ # Get any type information from the attribute.
183
+ if type = attribute.type
184
+ metadata[:type] ||= type.type
126
185
 
127
- unless metadata[:kind]
128
- metadata[:kind] = "attribute"
186
+ # Get enum variants.
187
+ if type.is_a?(ActiveRecord::Enum::EnumType)
188
+ metadata[:enum_variants] = type.send(:mapping)
189
+ end
129
190
  end
130
191
  end
131
192
 
132
- # Determine if `kind` is a association or method if not determined already.
133
- unless metadata[:kind]
134
- if association = model.reflections[f]
135
- metadata[:kind] = "association.#{association.macro}"
136
- elsif model.method_defined?(f)
137
- metadata[:kind] = "method"
193
+ # Get association metadata.
194
+ if ref = reflections[f]
195
+ metadata[:kind] = "association"
196
+ begin
197
+ pk = ref.active_record_primary_key
198
+ rescue ActiveRecord::UnknownPrimaryKey
138
199
  end
200
+ metadata[:association] = {
201
+ macro: ref.macro,
202
+ class_name: ref.class_name,
203
+ foreign_key: ref.foreign_key,
204
+ primary_key: pk,
205
+ polymorphic: ref.polymorphic?,
206
+ table_name: ref.table_name,
207
+ options: ref.options.presence,
208
+ }.compact
209
+ end
210
+
211
+ # Determine if this is just a method.
212
+ if model.method_defined?(f)
213
+ metadata[:kind] ||= "method"
139
214
  end
140
215
 
141
216
  # Collect validator options into a hash on their type, while also updating `required` based
@@ -156,32 +231,18 @@ module RESTFramework::BaseModelControllerMixin
156
231
  metadata[:validators][kind] << options
157
232
  end
158
233
 
159
- next [f, metadata.compact]
160
- }.to_h
161
- end
234
+ # Serialize any field config.
235
+ metadata[:config] = self.get_field_config(f).presence
162
236
 
163
- # Get metadata about the resource's associations (reflections).
164
- def get_associations_metadata
165
- return self.get_model.reflections.map { |k, v|
166
- next [k, {
167
- macro: v.macro,
168
- label: self.get_label(k),
169
- class_name: v.class_name,
170
- foreign_key: v.foreign_key,
171
- primary_key: v.active_record_primary_key,
172
- polymorphic: v.polymorphic?,
173
- table_name: v.table_name,
174
- options: v.options,
175
- }.compact]
237
+ next [f, metadata.compact]
176
238
  }.to_h
177
239
  end
178
240
 
179
241
  # Get a hash of metadata to be rendered in the `OPTIONS` response. Cache the result.
180
- def get_options_metadata(fields: nil)
242
+ def get_options_metadata
181
243
  return super().merge(
182
244
  {
183
- fields: self.get_fields_metadata(fields: fields),
184
- associations: self.get_associations_metadata,
245
+ fields: self.get_fields_metadata,
185
246
  },
186
247
  )
187
248
  end
@@ -238,9 +299,10 @@ module RESTFramework::BaseModelControllerMixin
238
299
  end
239
300
 
240
301
  def self.included(base)
302
+ RESTFramework::BaseControllerMixin.included(base)
303
+
241
304
  return unless base.is_a?(Class)
242
305
 
243
- RESTFramework::BaseControllerMixin.included(base)
244
306
  base.extend(ClassMethods)
245
307
 
246
308
  # Add class attributes (with defaults) unless they already exist.
@@ -265,27 +327,23 @@ module RESTFramework::BaseModelControllerMixin
265
327
  return (action_config[action] if action) || self.class.send(generic_config_key)
266
328
  end
267
329
 
268
- # Get fields without any action context. Always fallback to columns at the class level.
269
- def self.get_fields
270
- if self.fields.is_a?(Hash)
271
- return RESTFramework::Utils.parse_fields_hash(self.fields, self.get_model)
272
- end
273
-
274
- return self.fields || self.get_model&.column_names || []
275
- end
276
-
277
330
  # Get a list of fields for the current action. Returning `nil` indicates that anything should be
278
331
  # accepted unless `fallback` is true, in which case we should fallback to this controller's model
279
332
  # columns, or en empty array.
280
333
  def get_fields(fallback: false)
281
334
  fields = _get_specific_action_config(:action_fields, :fields)
282
335
 
283
- # If fields is a hash, then parse using columns as a base, respecting `only` and `except`.
336
+ # If fields is a hash, then parse it.
284
337
  if fields.is_a?(Hash)
285
- return RESTFramework::Utils.parse_fields_hash(fields, self.class.get_model)
338
+ return RESTFramework::Utils.parse_fields_hash(
339
+ fields, self.class.get_model, exclude_associations: self.class.exclude_associations
340
+ )
286
341
  elsif !fields && fallback
287
342
  # Otherwise, if fields is nil and fallback is true, then fallback to columns.
288
- return self.class.get_model&.column_names || []
343
+ model = self.class.get_model
344
+ return model ? RESTFramework::Utils.fields_for(
345
+ model, exclude_associations: self.class.exclude_associations
346
+ ) : []
289
347
  end
290
348
 
291
349
  return fields
@@ -293,7 +351,7 @@ module RESTFramework::BaseModelControllerMixin
293
351
 
294
352
  # Pass fields to get dynamic metadata based on which fields are available.
295
353
  def get_options_metadata
296
- return self.class.get_options_metadata(fields: self.get_fields(fallback: true))
354
+ return self.class.get_options_metadata
297
355
  end
298
356
 
299
357
  # Get a list of find_by fields for the current action. Do not fallback to columns in case the user
@@ -311,12 +369,12 @@ module RESTFramework::BaseModelControllerMixin
311
369
  ) || self.get_fields
312
370
  end
313
371
 
314
- # Helper to get the configured serializer class, or `NativeSerializer` as a default.
372
+ # Get the configured serializer class, or `NativeSerializer` as a default.
315
373
  def get_serializer_class
316
374
  return super || RESTFramework::NativeSerializer
317
375
  end
318
376
 
319
- # Helper to get filtering backends, defaulting to using `ModelFilter` and `ModelOrderingFilter`.
377
+ # Get filtering backends, defaulting to using `ModelFilter` and `ModelOrderingFilter`.
320
378
  def get_filter_backends
321
379
  return self.class.filter_backends || [
322
380
  RESTFramework::ModelFilter, RESTFramework::ModelOrderingFilter
@@ -324,14 +382,16 @@ module RESTFramework::BaseModelControllerMixin
324
382
  end
325
383
 
326
384
  # Filter the request body for keys in current action's allowed_parameters/fields config.
327
- def get_body_params
385
+ def get_body_params(data: nil)
386
+ data ||= request.request_parameters
387
+
328
388
  # Filter the request body and map to strings. Return all params if we cannot resolve a list of
329
389
  # allowed parameters or fields.
330
390
  allowed_params = self.get_allowed_parameters&.map(&:to_s)
331
391
  body_params = if allowed_params
332
- request.request_parameters.select { |p| allowed_params.include?(p) }
392
+ data.select { |p| allowed_params.include?(p) }
333
393
  else
334
- request.request_parameters
394
+ data
335
395
  end
336
396
 
337
397
  # Add query params in place of missing body params, if configured.
@@ -370,7 +430,7 @@ module RESTFramework::BaseModelControllerMixin
370
430
  return @recordset = nil
371
431
  end
372
432
 
373
- # Helper to get the records this controller has access to *after* any filtering is applied.
433
+ # Get the records this controller has access to *after* any filtering is applied.
374
434
  def get_records
375
435
  return @records if instance_variable_defined?(:@records)
376
436
 
@@ -403,7 +463,17 @@ module RESTFramework::BaseModelControllerMixin
403
463
  end
404
464
 
405
465
  # Return the record. Route key is always `:id` by Rails convention.
406
- return @record = recordset.find_by!(find_by_key => params[:id])
466
+ return @record = recordset.find_by!(find_by_key => request.path_parameters[:id])
467
+ end
468
+
469
+ # Create a transaction around the passed block, if configured. This is used primarily for bulk
470
+ # actions, but we include it here so it's always available.
471
+ def self._rrf_bulk_transaction(&block)
472
+ if self.bulk_transactional
473
+ ActiveRecord::Base.transaction(&block)
474
+ else
475
+ yield
476
+ end
407
477
  end
408
478
  end
409
479
 
@@ -413,7 +483,7 @@ module RESTFramework::ListModelMixin
413
483
  return api_response(self.get_index_records)
414
484
  end
415
485
 
416
- # Helper to get records with both filtering and pagination applied.
486
+ # Get records with both filtering and pagination applied.
417
487
  def get_index_records
418
488
  records = self.get_records
419
489
 
@@ -448,17 +518,19 @@ module RESTFramework::CreateModelMixin
448
518
  return api_response(self.create!, status: :created)
449
519
  end
450
520
 
451
- # Helper to perform the `create!` call and return the created record.
521
+ # Perform the `create!` call and return the created record.
452
522
  def create!
453
- if self.get_recordset.respond_to?(:create!) && self.create_from_recordset
523
+ create_from = if self.get_recordset.respond_to?(:create!) && self.create_from_recordset
454
524
  # Create with any properties inherited from the recordset. We exclude any `select` clauses in
455
525
  # case model callbacks need to call `count` on this collection, which typically raises a SQL
456
526
  # `SyntaxError`.
457
- return self.get_recordset.except(:select).create!(self.get_create_params)
527
+ self.get_recordset.except(:select)
458
528
  else
459
529
  # Otherwise, perform a "bare" create.
460
- return self.class.get_model.create!(self.get_create_params)
530
+ self.class.get_model
461
531
  end
532
+
533
+ return create_from.create!(self.get_create_params)
462
534
  end
463
535
  end
464
536
 
@@ -468,7 +540,7 @@ module RESTFramework::UpdateModelMixin
468
540
  return api_response(self.update!)
469
541
  end
470
542
 
471
- # Helper to perform the `update!` call and return the updated record.
543
+ # Perform the `update!` call and return the updated record.
472
544
  def update!
473
545
  record = self.get_record
474
546
  record.update!(self.get_update_params)
@@ -483,7 +555,7 @@ module RESTFramework::DestroyModelMixin
483
555
  return api_response("")
484
556
  end
485
557
 
486
- # Helper to perform the `destroy!` call and return the destroyed (and frozen) record.
558
+ # Perform the `destroy!` call and return the destroyed (and frozen) record.
487
559
  def destroy!
488
560
  return self.get_record.destroy!
489
561
  end
@@ -493,29 +565,25 @@ end
493
565
  module RESTFramework::ReadOnlyModelControllerMixin
494
566
  include RESTFramework::BaseModelControllerMixin
495
567
 
496
- def self.included(base)
497
- return unless base.is_a?(Class)
568
+ include RESTFramework::ListModelMixin
569
+ include RESTFramework::ShowModelMixin
498
570
 
571
+ def self.included(base)
499
572
  RESTFramework::BaseModelControllerMixin.included(base)
500
573
  end
501
-
502
- include RESTFramework::ListModelMixin
503
- include RESTFramework::ShowModelMixin
504
574
  end
505
575
 
506
576
  # Mixin that includes all the CRUD mixins.
507
577
  module RESTFramework::ModelControllerMixin
508
578
  include RESTFramework::BaseModelControllerMixin
509
579
 
510
- def self.included(base)
511
- return unless base.is_a?(Class)
512
-
513
- RESTFramework::BaseModelControllerMixin.included(base)
514
- end
515
-
516
580
  include RESTFramework::ListModelMixin
517
581
  include RESTFramework::ShowModelMixin
518
582
  include RESTFramework::CreateModelMixin
519
583
  include RESTFramework::UpdateModelMixin
520
584
  include RESTFramework::DestroyModelMixin
585
+
586
+ def self.included(base)
587
+ RESTFramework::BaseModelControllerMixin.included(base)
588
+ end
521
589
  end
@@ -2,4 +2,5 @@ module RESTFramework::ControllerMixins
2
2
  end
3
3
 
4
4
  require_relative "controller_mixins/base"
5
+ require_relative "controller_mixins/bulk"
5
6
  require_relative "controller_mixins/models"
@@ -14,23 +14,59 @@ class RESTFramework::ModelFilter < RESTFramework::BaseFilter
14
14
  # Get a list of filterset fields for the current action. Fallback to columns because we don't want
15
15
  # to try filtering by any query parameter because that could clash with other query parameters.
16
16
  def _get_fields
17
- return @controller.class.filterset_fields || @controller.get_fields(fallback: true)
17
+ return @_get_fields ||= (
18
+ @controller.class.filterset_fields || @controller.get_fields(fallback: true)
19
+ ).map(&:to_s)
18
20
  end
19
21
 
20
22
  # Filter params for keys allowed by the current action's filterset_fields/fields config.
21
23
  def _get_filter_params
22
24
  # Map filterset fields to strings because query parameter keys are strings.
23
- if fields = self._get_fields.map(&:to_s)
24
- return @controller.request.query_parameters.select { |p, _| fields.include?(p) }
25
- end
25
+ fields = self._get_fields
26
+ @associations = []
27
+
28
+ return @controller.request.query_parameters.select { |p, _|
29
+ # Remove any trailing `__in` from the field name.
30
+ field = p.chomp("__in")
31
+
32
+ # Remove any associations whose sub-fields are not filterable. Also populate `@associations`
33
+ # so the caller can include them.
34
+ if match = /(.*)\.(.*)/.match(field)
35
+ field, sub_field = match[1..2]
36
+ next false unless field.in?(fields)
37
+
38
+ sub_fields = @controller.class.get_field_config(field)[:sub_fields]
39
+ if sub_field.in?(sub_fields)
40
+ @associations << field.to_sym
41
+ next true
42
+ end
43
+
44
+ next false
45
+ end
46
+
47
+ next field.in?(fields)
48
+ }.map { |p, v|
49
+ # Convert fields ending in `__in` to array values.
50
+ if p.end_with?("__in")
51
+ p = p.chomp("__in")
52
+ v = v.split(",")
53
+ end
54
+
55
+ # Convert "nil" and "null" to nil.
56
+ if v == "nil" || v == "null"
57
+ v = nil
58
+ end
26
59
 
27
- return @controller.request.query_parameters.to_h
60
+ [p, v]
61
+ }.to_h.symbolize_keys
28
62
  end
29
63
 
30
64
  # Filter data according to the request query parameters.
31
65
  def get_filtered_data(data)
32
- filter_params = self._get_filter_params.symbolize_keys
33
- unless filter_params.blank?
66
+ if filter_params = self._get_filter_params.presence
67
+ # Include any associations.
68
+ data = data.includes(*@associations) unless @associations.empty?
69
+
34
70
  return data.where(**filter_params)
35
71
  end
36
72
 
@@ -43,18 +79,22 @@ class RESTFramework::ModelOrderingFilter < RESTFramework::BaseFilter
43
79
  # Get a list of ordering fields for the current action. Do not fallback to columns in case the
44
80
  # user wants to order by a virtual column.
45
81
  def _get_fields
46
- return @controller.class.ordering_fields || @controller.get_fields
82
+ return @_get_fields ||= (
83
+ @controller.class.ordering_fields || @controller.get_fields
84
+ )&.map(&:to_s)
47
85
  end
48
86
 
49
87
  # Convert ordering string to an ordering configuration.
50
88
  def _get_ordering
51
89
  return nil if @controller.class.ordering_query_param.blank?
52
90
 
91
+ @associations = []
92
+
53
93
  # Ensure ordering_fields are strings since the split param will be strings.
54
- fields = self._get_fields&.map(&:to_s)
94
+ fields = self._get_fields
55
95
  order_string = @controller.params[@controller.class.ordering_query_param]
56
96
 
57
- if order_string.present? && fields
97
+ if order_string.present?
58
98
  ordering = {}.with_indifferent_access
59
99
  order_string.split(",").each do |field|
60
100
  if field[0] == "-"
@@ -64,9 +104,19 @@ class RESTFramework::ModelOrderingFilter < RESTFramework::BaseFilter
64
104
  column = field
65
105
  direction = :asc
66
106
  end
67
- if !fields || column.in?(fields)
68
- ordering[column] = direction
107
+ next unless !fields || column.in?(fields)
108
+
109
+ # Populate any `@associations` so the caller can include them.
110
+ if match = /(.*)\.(.*)/.match(column)
111
+ association, sub_field = match[1..2]
112
+ @associations << association.to_sym
113
+
114
+ # Also, due to Rails weirdness, we need to convert the association name to the table name.
115
+ table_name = @controller.class.get_model.reflections[association].table_name
116
+ column = "#{table_name}.#{sub_field}"
69
117
  end
118
+
119
+ ordering[column] = direction
70
120
  end
71
121
  return ordering
72
122
  end
@@ -80,7 +130,10 @@ class RESTFramework::ModelOrderingFilter < RESTFramework::BaseFilter
80
130
  reorder = !@controller.class.ordering_no_reorder
81
131
 
82
132
  if ordering && !ordering.empty?
83
- return data.send(reorder ? :reorder : :order, _get_ordering)
133
+ # Include any associations.
134
+ data = data.includes(*@associations) unless @associations.empty?
135
+
136
+ return data.send(reorder ? :reorder : :order, ordering)
84
137
  end
85
138
 
86
139
  return data
@@ -102,6 +102,15 @@ module ActionDispatch::Routing
102
102
  public_send(m, "", action: action) if self.respond_to?(m)
103
103
  end
104
104
  end
105
+
106
+ # Route bulk actions, if configured.
107
+ RESTFramework::RRF_BUILTIN_BULK_ACTIONS.each do |action, methods|
108
+ next unless controller_class.method_defined?(action)
109
+
110
+ [methods].flatten.each do |m|
111
+ public_send(m, "", action: action) if self.respond_to?(m)
112
+ end
113
+ end
105
114
  end
106
115
 
107
116
  if unscoped
@@ -88,7 +88,7 @@ class RESTFramework::NativeSerializer < RESTFramework::BaseSerializer
88
88
  return self.config || self.singular_config || self.plural_config
89
89
  end
90
90
 
91
- # Helper to get a native serializer configuration from the controller.
91
+ # Get a native serializer configuration from the controller.
92
92
  def get_controller_native_serializer_config
93
93
  return nil unless @controller
94
94
 
@@ -101,9 +101,8 @@ class RESTFramework::NativeSerializer < RESTFramework::BaseSerializer
101
101
  return controller_serializer || @controller.class.try(:native_serializer_config)
102
102
  end
103
103
 
104
- # Helper to filter a single subconfig for specific keys. By default, keys from `fields` are
105
- # removed from the provided `subcfg`. There are two (mutually exclusive) options to adjust the
106
- # behavior:
104
+ # Filter a single subconfig for specific keys. By default, keys from `fields` are removed from the
105
+ # provided `subcfg`. There are two (mutually exclusive) options to adjust the behavior:
107
106
  #
108
107
  # `add`: Add any `fields` to the `subcfg` which aren't already in the `subcfg`.
109
108
  # `only`: Remove any values found in the `subcfg` not in `fields`.
@@ -149,16 +148,14 @@ class RESTFramework::NativeSerializer < RESTFramework::BaseSerializer
149
148
  return subcfg
150
149
  end
151
150
 
152
- # Helper to filter out configuration properties based on the :except query parameter.
153
- def filter_except(cfg)
151
+ # Filter out configuration properties based on the :except/:only query parameters.
152
+ def filter_from_request(cfg)
154
153
  return cfg unless @controller
155
154
 
156
155
  except_param = @controller.class.try(:native_serializer_except_query_param)
157
156
  only_param = @controller.class.try(:native_serializer_only_query_param)
158
157
  if except_param && except = @controller.request.query_parameters[except_param].presence
159
- except = except.split(",").map(&:strip).map(&:to_sym)
160
-
161
- unless except.empty?
158
+ if except = except.split(",").map(&:strip).map(&:to_sym).presence
162
159
  # Filter `only`, `except` (additive), `include`, `methods`, and `serializer_methods`.
163
160
  if cfg[:only]
164
161
  cfg[:only] = self.class.filter_subcfg(cfg[:only], fields: except)
@@ -167,6 +164,7 @@ class RESTFramework::NativeSerializer < RESTFramework::BaseSerializer
167
164
  else
168
165
  cfg[:except] = except
169
166
  end
167
+
170
168
  cfg[:include] = self.class.filter_subcfg(cfg[:include], fields: except)
171
169
  cfg[:methods] = self.class.filter_subcfg(cfg[:methods], fields: except)
172
170
  cfg[:serializer_methods] = self.class.filter_subcfg(
@@ -174,20 +172,15 @@ class RESTFramework::NativeSerializer < RESTFramework::BaseSerializer
174
172
  )
175
173
  end
176
174
  elsif only_param && only = @controller.request.query_parameters[only_param].presence
177
- only = only.split(",").map(&:strip).map(&:to_sym)
178
-
179
- unless only.empty?
180
- # Filter `only`, `except` (additive), `include`, and `methods`.
175
+ if only = only.split(",").map(&:strip).map(&:to_sym).presence
176
+ # Filter `only`, `include`, and `methods`. Adding anything to `except` is not needed,
177
+ # because any configuration there takes precedence over `only`.
181
178
  if cfg[:only]
182
179
  cfg[:only] = self.class.filter_subcfg(cfg[:only], fields: only, only: true)
183
- elsif cfg[:except]
184
- # For the `except` part of the serializer, we need to append any columns not in `only`.
185
- model = @controller.class.get_model
186
- except_cols = model&.column_names&.map(&:to_sym)&.reject { |c| c.in?(only) }
187
- cfg[:except] = self.class.filter_subcfg(cfg[:except], fields: except_cols, add: true)
188
180
  else
189
181
  cfg[:only] = only
190
182
  end
183
+
191
184
  cfg[:include] = self.class.filter_subcfg(cfg[:include], fields: only, only: true)
192
185
  cfg[:methods] = self.class.filter_subcfg(cfg[:methods], fields: only, only: true)
193
186
  cfg[:serializer_methods] = self.class.filter_subcfg(
@@ -213,18 +206,28 @@ class RESTFramework::NativeSerializer < RESTFramework::BaseSerializer
213
206
  end
214
207
 
215
208
  # If the config wasn't determined, build a serializer config from controller fields.
216
- if fields = @controller&.get_fields
209
+ if fields = @controller&.get_fields(fallback: true)
217
210
  fields = fields.deep_dup
218
211
 
219
212
  columns = []
220
- includes = []
213
+ includes = {}
221
214
  methods = []
222
215
  if @model
223
216
  fields.each do |f|
224
217
  if f.in?(@model.column_names)
225
218
  columns << f
226
219
  elsif @model.reflections.key?(f)
227
- includes << f
220
+ sub_columns = []
221
+ sub_methods = []
222
+ @controller.class.get_field_config(f)[:sub_fields].each do |sf|
223
+ sub_model = @model.reflections[f].klass
224
+ if sf.in?(sub_model.column_names)
225
+ sub_columns << sf
226
+ elsif sub_model.method_defined?(sf)
227
+ sub_methods << sf
228
+ end
229
+ end
230
+ includes[f] = {only: sub_columns, methods: sub_methods}
228
231
  elsif @model.method_defined?(f)
229
232
  methods << f
230
233
  end
@@ -242,10 +245,10 @@ class RESTFramework::NativeSerializer < RESTFramework::BaseSerializer
242
245
 
243
246
  # Get a configuration passable to `serializable_hash` for the object, filtered if required.
244
247
  def get_serializer_config
245
- return filter_except(self._get_raw_serializer_config)
248
+ return filter_from_request(self._get_raw_serializer_config)
246
249
  end
247
250
 
248
- # Internal helper to serialize a single record and merge results of `serializer_methods`.
251
+ # Serialize a single record and merge results of `serializer_methods`.
249
252
  def _serialize(record, config, serializer_methods)
250
253
  # Ensure serializer_methods is either falsy, or an array.
251
254
  if serializer_methods && !serializer_methods.respond_to?(:to_ary)
@@ -1,5 +1,6 @@
1
1
  module RESTFramework::Utils
2
2
  HTTP_METHOD_ORDERING = %w(GET POST PUT PATCH DELETE OPTIONS HEAD)
3
+ LABEL_FIELDS = %w(name label login title email username)
3
4
 
4
5
  # Convert `extra_actions` hash to a consistent format: `{path:, methods:, kwargs:}`, and
5
6
  # additional metadata fields.
@@ -139,15 +140,63 @@ module RESTFramework::Utils
139
140
  end
140
141
 
141
142
  # Parse fields hashes.
142
- def self.parse_fields_hash(fields_hash, model)
143
- parsed_fields = fields_hash[:only] || model&.column_names || []
144
- parsed_fields -= fields_hash[:except] if fields_hash[:except]
143
+ def self.parse_fields_hash(fields_hash, model, exclude_associations: nil)
144
+ parsed_fields = fields_hash[:only] || (
145
+ model ? self.fields_for(model, exclude_associations: exclude_associations) : []
146
+ )
147
+ parsed_fields += fields_hash[:include] if fields_hash[:include]
148
+ parsed_fields -= fields_hash[:exclude] if fields_hash[:exclude]
145
149
 
146
150
  # Warn for any unknown keys.
147
- (fields_hash.keys - [:only, :except]).each do |k|
151
+ (fields_hash.keys - [:only, :include, :exclude]).each do |k|
148
152
  Rails.logger.warn("RRF: Unknown key in fields hash: #{k}")
149
153
  end
150
154
 
151
155
  return parsed_fields
152
156
  end
157
+
158
+ # Get the fields for a given model, including not just columns (which includes
159
+ # foreign keys), but also associations.
160
+ def self.fields_for(model, exclude_associations: nil)
161
+ foreign_keys = model.reflect_on_all_associations(:belongs_to).map(&:foreign_key)
162
+
163
+ if exclude_associations
164
+ return model.column_names.reject { |c| c.in?(foreign_keys) }
165
+ end
166
+
167
+ # Add associations in addition to normal columns.
168
+ return model.column_names.reject { |c|
169
+ c.in?(foreign_keys)
170
+ } + model.reflections.map { |association, ref|
171
+ if ref.macro.in?([:has_many, :has_and_belongs_to_many]) &&
172
+ RESTFramework.config.large_reverse_association_tables&.include?(ref.table_name)
173
+ next nil
174
+ end
175
+
176
+ next association
177
+ }.compact
178
+ end
179
+
180
+ # Get the sub-fields that may be serialized and filtered/ordered for a reflection.
181
+ def self.sub_fields_for(ref)
182
+ model = ref.klass
183
+
184
+ if model
185
+ sub_fields = [model.primary_key].flatten.compact
186
+
187
+ # Preferrably find a database column to use as label.
188
+ if match = LABEL_FIELDS.find { |f| f.in?(model.column_names) }
189
+ return sub_fields + [match]
190
+ end
191
+
192
+ # Otherwise, find a method.
193
+ if match = LABEL_FIELDS.find { |f| model.method_defined?(f) }
194
+ return sub_fields + [match]
195
+ end
196
+
197
+ return sub_fields
198
+ end
199
+
200
+ return ["id", "name"]
201
+ end
153
202
  end
@@ -13,6 +13,10 @@ module RESTFramework
13
13
  RRF_BUILTIN_ACTIONS = {
14
14
  options: :options,
15
15
  }.freeze
16
+ RRF_BUILTIN_BULK_ACTIONS = {
17
+ update_all: [:put, :patch].freeze,
18
+ destroy_all: :delete,
19
+ }.freeze
16
20
 
17
21
  # Global configuration should be kept minimal, as controller-level configurations allows multiple
18
22
  # APIs to be defined to behave differently.
@@ -24,11 +28,25 @@ module RESTFramework
24
28
  # in:
25
29
  # - Model delegation, for the helper methods to be defined dynamically.
26
30
  # - Websockets, for `::Channel` class to be defined dynamically.
27
- # - Controller configuration finalization.
31
+ # - Controller configuration freezing.
28
32
  attr_accessor :disable_auto_finalize
29
33
 
30
34
  # Freeze configuration attributes during finalization to prevent accidental mutation.
31
35
  attr_accessor :freeze_config
36
+
37
+ # Specify reverse association tables that are typically very large, andd therefore should not be
38
+ # added to fields by default.
39
+ attr_accessor :large_reverse_association_tables
40
+
41
+ # Whether the backtrace should be shown in rescued errors.
42
+ attr_accessor :show_backtrace
43
+
44
+ # Option to disable `rescue_from` on the controller mixins.
45
+ attr_accessor :disable_rescue_from
46
+
47
+ def initialize
48
+ self.show_backtrace = Rails.env.development?
49
+ end
32
50
  end
33
51
 
34
52
  def self.config
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: 0.7.4
4
+ version: 0.7.6
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: 2023-01-10 00:00:00.000000000 Z
11
+ date: 2023-01-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -42,6 +42,7 @@ files:
42
42
  - lib/rest_framework.rb
43
43
  - lib/rest_framework/controller_mixins.rb
44
44
  - lib/rest_framework/controller_mixins/base.rb
45
+ - lib/rest_framework/controller_mixins/bulk.rb
45
46
  - lib/rest_framework/controller_mixins/models.rb
46
47
  - lib/rest_framework/engine.rb
47
48
  - lib/rest_framework/errors.rb