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.
- checksums.yaml +4 -4
- data/README.md +31 -26
- data/VERSION +1 -1
- data/app/views/rest_framework/_routes_and_forms.html.erb +2 -5
- data/app/views/rest_framework/routes_and_forms/_html_form.html.erb +1 -1
- data/app/views/rest_framework/routes_and_forms/_raw_form.html.erb +1 -1
- data/lib/rest_framework/controller/bulk.rb +272 -0
- data/lib/rest_framework/controller/crud.rb +65 -0
- data/lib/rest_framework/controller/openapi.rb +252 -0
- data/lib/rest_framework/controller.rb +839 -0
- data/lib/rest_framework/engine.rb +12 -2
- data/lib/rest_framework/errors.rb +53 -4
- data/lib/rest_framework/filters/ordering_filter.rb +0 -1
- data/lib/rest_framework/filters/query_filter.rb +7 -2
- data/lib/rest_framework/filters/search_filter.rb +5 -5
- data/lib/rest_framework/mixins/base_controller_mixin.rb +3 -383
- data/lib/rest_framework/mixins/bulk_model_controller_mixin.rb +27 -68
- data/lib/rest_framework/mixins/model_controller_mixin.rb +60 -807
- data/lib/rest_framework/paginators/page_number_paginator.rb +10 -11
- data/lib/rest_framework/routers.rb +20 -9
- data/lib/rest_framework/serializers/native_serializer.rb +5 -3
- data/lib/rest_framework/utils.rb +24 -6
- data/lib/rest_framework.rb +13 -5
- metadata +6 -7
- data/lib/rest_framework/errors/base_error.rb +0 -5
- data/lib/rest_framework/errors/nil_passed_to_render_api_error.rb +0 -14
- data/lib/rest_framework/errors/unknown_model_error.rb +0 -18
- data/lib/rest_framework/generators/controller_generator.rb +0 -64
- data/lib/rest_framework/generators.rb +0 -4
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
# This module provides the common functionality for all REST controllers. The implementation is
|
|
2
|
+
# split across several files under `controller/` for readability; each of those files reopens this
|
|
3
|
+
# module rather than defining a separate submodule.
|
|
4
|
+
module RESTFramework::Controller
|
|
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: RESTFramework.config.inflect_acronyms,
|
|
15
|
+
openapi_include_children: false,
|
|
16
|
+
|
|
17
|
+
# Options related to models.
|
|
18
|
+
model: nil,
|
|
19
|
+
recordset: nil,
|
|
20
|
+
excluded_actions: nil,
|
|
21
|
+
|
|
22
|
+
# Bulk configuration.
|
|
23
|
+
#
|
|
24
|
+
# When `bulk` is truthy, it enables the default bulk behavior (`:default`), which is per-record
|
|
25
|
+
# processing (e.g., `create` for each record). When `bulk` is set to `:raw`, it enables single
|
|
26
|
+
# SQL query behavior (e.g., `insert_all` for bulk create) which skips validations/callbacks.
|
|
27
|
+
bulk: false,
|
|
28
|
+
bulk_partial: false,
|
|
29
|
+
bulk_partial_query_param: "bulk_partial".freeze,
|
|
30
|
+
bulk_allow_mode_override: false,
|
|
31
|
+
bulk_mode_query_param: "bulk_mode".freeze,
|
|
32
|
+
bulk_max_size: nil,
|
|
33
|
+
bulk_max_raw_size: nil,
|
|
34
|
+
|
|
35
|
+
# Configuring record fields.
|
|
36
|
+
fields: nil,
|
|
37
|
+
field_config: nil,
|
|
38
|
+
read_only_fields: RESTFramework.config.read_only_fields,
|
|
39
|
+
write_only_fields: RESTFramework.config.write_only_fields,
|
|
40
|
+
hidden_fields: nil,
|
|
41
|
+
|
|
42
|
+
# Finding records.
|
|
43
|
+
find_by_fields: nil,
|
|
44
|
+
find_by_query_param: "find_by".freeze,
|
|
45
|
+
|
|
46
|
+
# What should be included/excluded from default fields.
|
|
47
|
+
exclude_associations: false,
|
|
48
|
+
|
|
49
|
+
# Handling request body parameters.
|
|
50
|
+
allowed_parameters: nil,
|
|
51
|
+
|
|
52
|
+
# Options for the default native serializer.
|
|
53
|
+
native_serializer_config: nil,
|
|
54
|
+
native_serializer_singular_config: nil,
|
|
55
|
+
native_serializer_plural_config: nil,
|
|
56
|
+
native_serializer_only_query_param: "only".freeze,
|
|
57
|
+
native_serializer_except_query_param: "except".freeze,
|
|
58
|
+
native_serializer_include_query_param: "include".freeze,
|
|
59
|
+
native_serializer_exclude_query_param: "exclude".freeze,
|
|
60
|
+
native_serializer_associations_limit: nil,
|
|
61
|
+
native_serializer_associations_limit_query_param: "associations_limit".freeze,
|
|
62
|
+
native_serializer_include_associations_count: false,
|
|
63
|
+
|
|
64
|
+
# Options for filtering, ordering, and searching.
|
|
65
|
+
filter_backends: [
|
|
66
|
+
RESTFramework::QueryFilter,
|
|
67
|
+
RESTFramework::OrderingFilter,
|
|
68
|
+
RESTFramework::SearchFilter,
|
|
69
|
+
].freeze,
|
|
70
|
+
filter_recordset_before_find: true,
|
|
71
|
+
filter_fields: nil,
|
|
72
|
+
ordering_fields: nil,
|
|
73
|
+
ordering_query_param: "ordering".freeze,
|
|
74
|
+
ordering_no_reorder: false,
|
|
75
|
+
search_fields: nil,
|
|
76
|
+
search_query_param: "search".freeze,
|
|
77
|
+
search_ilike: false,
|
|
78
|
+
ransack_options: nil,
|
|
79
|
+
ransack_query_param: "q".freeze,
|
|
80
|
+
ransack_distinct: true,
|
|
81
|
+
ransack_distinct_query_param: "distinct".freeze,
|
|
82
|
+
|
|
83
|
+
# Options for association assignment.
|
|
84
|
+
permit_id_assignment: true,
|
|
85
|
+
permit_nested_attributes_assignment: true,
|
|
86
|
+
|
|
87
|
+
# Option for `recordset.create` vs `Model.create` behavior.
|
|
88
|
+
create_from_recordset: true,
|
|
89
|
+
|
|
90
|
+
# Options related to serialization.
|
|
91
|
+
rescue_unknown_format_with: :json,
|
|
92
|
+
serializer_class: nil,
|
|
93
|
+
serialize_to_json: true,
|
|
94
|
+
serialize_to_xml: true,
|
|
95
|
+
|
|
96
|
+
# Options related to pagination.
|
|
97
|
+
paginator_class: nil,
|
|
98
|
+
page_size: 20,
|
|
99
|
+
page_query_param: "page",
|
|
100
|
+
page_size_query_param: "page_size",
|
|
101
|
+
max_page_size: nil,
|
|
102
|
+
|
|
103
|
+
# Option to disable serializer adapters by default, mainly introduced because Active Model
|
|
104
|
+
# Serializers will do things like serialize `[]` into `{"":[]}`.
|
|
105
|
+
disable_adapters_by_default: true,
|
|
106
|
+
|
|
107
|
+
# Custom integrations (reduces serializer performance due to method calls).
|
|
108
|
+
enable_action_text: false,
|
|
109
|
+
enable_active_storage: false,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
# Exceptions to be rescued and handled by returning a reasonable error response.
|
|
113
|
+
RRF_RESCUED_EXCEPTIONS = [
|
|
114
|
+
RESTFramework::InvalidBulkParametersError,
|
|
115
|
+
RESTFramework::BulkRecordErrorsError,
|
|
116
|
+
].freeze
|
|
117
|
+
RRF_RESCUED_RAILS_EXCEPTIONS = [
|
|
118
|
+
ActionController::ParameterMissing,
|
|
119
|
+
ActionController::UnpermittedParameters,
|
|
120
|
+
ActionDispatch::Http::Parameters::ParseError,
|
|
121
|
+
ActiveRecord::AssociationTypeMismatch,
|
|
122
|
+
ActiveRecord::NotNullViolation,
|
|
123
|
+
ActiveRecord::RecordNotFound,
|
|
124
|
+
ActiveRecord::RecordInvalid,
|
|
125
|
+
ActiveRecord::RecordNotSaved,
|
|
126
|
+
ActiveRecord::RecordNotDestroyed,
|
|
127
|
+
ActiveRecord::RecordNotUnique,
|
|
128
|
+
ActiveModel::UnknownAttributeError,
|
|
129
|
+
].freeze
|
|
130
|
+
|
|
131
|
+
# Anchored regex with non-greedy content_type match to prevent over-matching on malicious input.
|
|
132
|
+
RRF_BASE64_REGEX = /\Adata:([^;]*);base64,(.*)\z/m
|
|
133
|
+
RRF_BASE64_TRANSLATE = ->(field, value) {
|
|
134
|
+
return value unless RRF_BASE64_REGEX.match?(value)
|
|
135
|
+
|
|
136
|
+
_, content_type, payload = value.match(RRF_BASE64_REGEX).to_a
|
|
137
|
+
{
|
|
138
|
+
io: StringIO.new(Base64.decode64(payload)),
|
|
139
|
+
content_type: content_type,
|
|
140
|
+
filename: "file_#{field}#{Rack::Mime::MIME_TYPES.invert[content_type]}",
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
RRF_ACTIVESTORAGE_KEYS = [ :io, :content_type, :filename, :identify, :key ]
|
|
144
|
+
|
|
145
|
+
# Default action for API root.
|
|
146
|
+
def root
|
|
147
|
+
render(api: { message: "This is the API root." })
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
module ClassMethods
|
|
151
|
+
IGNORE_VALIDATORS_WITH_KEYS = [ :if, :unless ].freeze
|
|
152
|
+
|
|
153
|
+
# By default, this is the name of the controller class, titleized and with any custom inflection
|
|
154
|
+
# acronyms applied.
|
|
155
|
+
def get_title
|
|
156
|
+
self.title || RESTFramework::Utils.inflect(
|
|
157
|
+
self.name.demodulize.chomp("Controller").titleize(keep_id_suffix: true),
|
|
158
|
+
self.inflect_acronyms,
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Get a label from a field/column name, titleized and inflected.
|
|
163
|
+
def label_for(s)
|
|
164
|
+
default_title = RESTFramework::Utils.inflect(
|
|
165
|
+
s.to_s.titleize(keep_id_suffix: true), self.inflect_acronyms
|
|
166
|
+
)
|
|
167
|
+
self.model&.human_attribute_name(s, default: default_title) || default_title
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Define any behavior to execute at the end of controller definition.
|
|
171
|
+
# :nocov:
|
|
172
|
+
def rrf_finalize
|
|
173
|
+
if RESTFramework.config.freeze_config
|
|
174
|
+
self::RRF_BASE_CONFIG.keys.each { |k|
|
|
175
|
+
v = self.send(k)
|
|
176
|
+
v.freeze if v.is_a?(Hash) || v.is_a?(Array)
|
|
177
|
+
}
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
self.setup_delegation if self.model
|
|
181
|
+
# self.setup_channel if self.model
|
|
182
|
+
end
|
|
183
|
+
# :nocov:
|
|
184
|
+
|
|
185
|
+
# Get the available fields. Fallback to this controller's model columns, or an empty array. This
|
|
186
|
+
# should always return an array of strings.
|
|
187
|
+
def get_fields(input_fields: nil)
|
|
188
|
+
input_fields ||= self.fields
|
|
189
|
+
|
|
190
|
+
# If fields is a hash, then parse it.
|
|
191
|
+
if input_fields.is_a?(Hash)
|
|
192
|
+
return RESTFramework::Utils.parse_fields_hash(
|
|
193
|
+
input_fields,
|
|
194
|
+
self.model,
|
|
195
|
+
exclude_associations: self.exclude_associations,
|
|
196
|
+
action_text: self.enable_action_text,
|
|
197
|
+
active_storage: self.enable_active_storage,
|
|
198
|
+
)
|
|
199
|
+
elsif !input_fields
|
|
200
|
+
# Otherwise, if fields is nil, then fallback to columns.
|
|
201
|
+
return self.model ? RESTFramework::Utils.fields_for(
|
|
202
|
+
self.model,
|
|
203
|
+
exclude_associations: self.exclude_associations,
|
|
204
|
+
action_text: self.enable_action_text,
|
|
205
|
+
active_storage: self.enable_active_storage,
|
|
206
|
+
) : []
|
|
207
|
+
elsif input_fields
|
|
208
|
+
input_fields = input_fields.map(&:to_s)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
input_fields
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Get a full field configuration, including defaults and inferred values.
|
|
215
|
+
def field_configuration
|
|
216
|
+
return @field_configuration if @field_configuration
|
|
217
|
+
|
|
218
|
+
field_config = self.field_config&.with_indifferent_access || {}
|
|
219
|
+
columns = self.model.columns_hash
|
|
220
|
+
column_defaults = self.model.column_defaults
|
|
221
|
+
reflections = self.model.reflections
|
|
222
|
+
attributes = self.model._default_attributes
|
|
223
|
+
readonly_attributes = self.model.readonly_attributes
|
|
224
|
+
read_only_fields = self.read_only_fields&.map(&:to_s)&.to_set || Set[]
|
|
225
|
+
write_only_fields = self.write_only_fields&.map(&:to_s)&.to_set || Set[]
|
|
226
|
+
hidden_fields = self.hidden_fields&.map(&:to_s)&.to_set || Set[]
|
|
227
|
+
rich_text_association_names = self.model.reflect_on_all_associations(:has_one)
|
|
228
|
+
.collect(&:name)
|
|
229
|
+
.select { |n| n.to_s.start_with?("rich_text_") }
|
|
230
|
+
attachment_reflections = self.model.attachment_reflections
|
|
231
|
+
|
|
232
|
+
@field_configuration = self.get_fields.map { |f|
|
|
233
|
+
cfg = field_config[f]&.dup || {}
|
|
234
|
+
cfg[:label] ||= self.label_for(f)
|
|
235
|
+
|
|
236
|
+
# Annotate primary key.
|
|
237
|
+
if self.model.primary_key == f
|
|
238
|
+
cfg[:primary_key] = true
|
|
239
|
+
|
|
240
|
+
unless cfg.key?(:read_only)
|
|
241
|
+
cfg[:read_only] = true
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Annotate field mutability and display properties.
|
|
246
|
+
cfg[:read_only] = true if f.in?(readonly_attributes) || f.in?(read_only_fields)
|
|
247
|
+
cfg[:write_only] = true if f.in?(write_only_fields)
|
|
248
|
+
cfg[:hidden] = true if f.in?(hidden_fields)
|
|
249
|
+
|
|
250
|
+
# Raise warnings on some bad combinations of properties.
|
|
251
|
+
if cfg[:write_only]
|
|
252
|
+
if cfg[:read_only]
|
|
253
|
+
Rails.logger.warn("RRF: `#{f}` write_only conflicts with read_only.")
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
if cfg[:hidden]
|
|
257
|
+
Rails.logger.warn("RRF: `#{f}` write_only implies hidden.")
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
if cfg[:hidden_from_index]
|
|
261
|
+
Rails.logger.warn("RRF: `#{f}` write_only implies hidden_from_index.")
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Annotate column data.
|
|
266
|
+
if column = columns[f]
|
|
267
|
+
cfg[:kind] = "column"
|
|
268
|
+
cfg[:type] ||= column.type
|
|
269
|
+
cfg[:required] = true unless column.null
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Add default values from the model's schema.
|
|
273
|
+
if cfg[:default].nil? && (column_default = column_defaults[f])
|
|
274
|
+
cfg[:default] = column_default
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Add metadata from the model's attributes hash.
|
|
278
|
+
if attributes.key?(f) && attribute = attributes[f]
|
|
279
|
+
if cfg[:default].nil? && default = attribute.value_before_type_cast
|
|
280
|
+
cfg[:default] = default
|
|
281
|
+
end
|
|
282
|
+
cfg[:kind] ||= "attribute"
|
|
283
|
+
|
|
284
|
+
# Get any type information from the attribute.
|
|
285
|
+
if type = attribute.type
|
|
286
|
+
cfg[:type] ||= type.type if type.type
|
|
287
|
+
|
|
288
|
+
# Get enum variants.
|
|
289
|
+
if type.is_a?(ActiveRecord::Enum::EnumType)
|
|
290
|
+
cfg[:enum_variants] = type.send(:mapping)
|
|
291
|
+
|
|
292
|
+
# TranslateEnum Integration:
|
|
293
|
+
translate_method = "translated_#{f.pluralize}"
|
|
294
|
+
if self.model.respond_to?(translate_method)
|
|
295
|
+
cfg[:enum_translations] = self.model.send(translate_method)
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Get association metadata.
|
|
302
|
+
if ref = reflections[f]
|
|
303
|
+
cfg[:kind] = "association"
|
|
304
|
+
|
|
305
|
+
# Determine sub-fields for associations.
|
|
306
|
+
if ref.polymorphic?
|
|
307
|
+
ref_columns = {}
|
|
308
|
+
else
|
|
309
|
+
ref_columns = ref.klass.columns_hash
|
|
310
|
+
end
|
|
311
|
+
cfg[:sub_fields] ||= RESTFramework::Utils.sub_fields_for(ref)
|
|
312
|
+
cfg[:sub_fields] = cfg[:sub_fields].map(&:to_s)
|
|
313
|
+
|
|
314
|
+
# Very basic metadata about sub-fields.
|
|
315
|
+
cfg[:sub_fields_metadata] = cfg[:sub_fields].map { |sf|
|
|
316
|
+
v = {}
|
|
317
|
+
|
|
318
|
+
if ref_columns[sf]
|
|
319
|
+
v[:kind] = "column"
|
|
320
|
+
else
|
|
321
|
+
v[:kind] = "method"
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
next [ sf, v ]
|
|
325
|
+
}.to_h.compact.presence
|
|
326
|
+
|
|
327
|
+
# Determine if we render id/ids fields. Unfortunately, `has_one` does not provide this
|
|
328
|
+
# interface.
|
|
329
|
+
if self.permit_id_assignment && id_field = RESTFramework::Utils.id_field_for(f, ref)
|
|
330
|
+
cfg[:id_field] = id_field
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Determine if we render nested attributes options.
|
|
334
|
+
if self.permit_nested_attributes_assignment && (
|
|
335
|
+
nested_opts = self.model.nested_attributes_options[f.to_sym].presence
|
|
336
|
+
)
|
|
337
|
+
cfg[:nested_attributes_options] = { field: "#{f}_attributes", **nested_opts }
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
begin
|
|
341
|
+
cfg[:association_pk] = ref.active_record_primary_key
|
|
342
|
+
rescue ActiveRecord::UnknownPrimaryKey
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
cfg[:reflection] = ref
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Determine if this is an ActionText "rich text".
|
|
349
|
+
if :"rich_text_#{f}".in?(rich_text_association_names)
|
|
350
|
+
cfg[:kind] = "rich_text"
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Determine if this is an ActiveStorage attachment.
|
|
354
|
+
if ref = attachment_reflections[f]
|
|
355
|
+
cfg[:kind] = "attachment"
|
|
356
|
+
cfg[:attachment_type] = ref.macro
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Determine if this is just a method.
|
|
360
|
+
if !cfg[:kind] && self.model.method_defined?(f)
|
|
361
|
+
cfg[:kind] = "method"
|
|
362
|
+
cfg[:read_only] = true if cfg[:read_only].nil?
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# Collect validator options into a hash on their type, while also updating `required` based
|
|
366
|
+
# on any presence validators.
|
|
367
|
+
self.model.validators_on(f).each do |validator|
|
|
368
|
+
kind = validator.kind
|
|
369
|
+
options = validator.options
|
|
370
|
+
|
|
371
|
+
# Reject validator if it includes keys like `:if` and `:unless` because those are
|
|
372
|
+
# conditionally applied in a way that is not feasible to communicate via the API.
|
|
373
|
+
next if IGNORE_VALIDATORS_WITH_KEYS.any? { |k| options.key?(k) }
|
|
374
|
+
|
|
375
|
+
# Update `required` if we find a presence validator.
|
|
376
|
+
cfg[:required] = true if kind == :presence
|
|
377
|
+
|
|
378
|
+
# Resolve procs (and lambdas), and symbols for certain arguments.
|
|
379
|
+
if options[:in].is_a?(Proc)
|
|
380
|
+
options = options.merge(in: options[:in].call)
|
|
381
|
+
elsif options[:in].is_a?(Symbol)
|
|
382
|
+
options = options.merge(in: self.model.send(options[:in]))
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
cfg[:validators] ||= {}
|
|
386
|
+
cfg[:validators][kind] ||= []
|
|
387
|
+
cfg[:validators][kind] << options
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
next [ f, cfg ]
|
|
391
|
+
}.to_h.compact.with_indifferent_access
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Only for model controllers.
|
|
395
|
+
def setup_delegation
|
|
396
|
+
# Delegate extra actions.
|
|
397
|
+
self.extra_actions&.each do |action, config|
|
|
398
|
+
next unless config.is_a?(Hash) && config.dig(:metadata, :delegate)
|
|
399
|
+
next unless self.model.respond_to?(action)
|
|
400
|
+
|
|
401
|
+
self.define_method(action) do
|
|
402
|
+
if self.class.model.method(action).parameters.last&.first == :keyrest
|
|
403
|
+
render(api: self.class.model.send(action, **request.query_parameters.symbolize_keys))
|
|
404
|
+
else
|
|
405
|
+
render(api: self.class.model.send(action))
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Delegate extra member actions.
|
|
411
|
+
self.extra_member_actions&.each do |action, config|
|
|
412
|
+
next unless config.is_a?(Hash) && config.dig(:metadata, :delegate)
|
|
413
|
+
next unless self.model.method_defined?(action)
|
|
414
|
+
|
|
415
|
+
self.define_method(action) do
|
|
416
|
+
record = self.get_record
|
|
417
|
+
|
|
418
|
+
if record.method(action).parameters.last&.first == :keyrest
|
|
419
|
+
render(api: record.send(action, **request.query_parameters.symbolize_keys))
|
|
420
|
+
else
|
|
421
|
+
render(api: record.send(action))
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def self.included(base)
|
|
429
|
+
return unless base.is_a?(Class)
|
|
430
|
+
|
|
431
|
+
base.extend(ClassMethods)
|
|
432
|
+
|
|
433
|
+
# By default, the layout should be set to `rest_framework`.
|
|
434
|
+
base.layout("rest_framework")
|
|
435
|
+
|
|
436
|
+
# Add class attributes unless they already exist.
|
|
437
|
+
RRF_BASE_CONFIG.each do |a, default|
|
|
438
|
+
next if base.respond_to?(a)
|
|
439
|
+
|
|
440
|
+
# Don't leak class attributes to the instance to avoid conflicting with action methods.
|
|
441
|
+
base.class_attribute(a, default: default, instance_accessor: false)
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Alias `extra_actions` to `extra_collection_actions`.
|
|
445
|
+
unless base.respond_to?(:extra_collection_actions)
|
|
446
|
+
base.singleton_class.alias_method(:extra_collection_actions, :extra_actions)
|
|
447
|
+
base.singleton_class.alias_method(:extra_collection_actions=, :extra_actions=)
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# Skip CSRF since this is an API.
|
|
451
|
+
begin
|
|
452
|
+
base.skip_before_action(:verify_authenticity_token)
|
|
453
|
+
rescue ArgumentError
|
|
454
|
+
# The callback may not exist if forgery protection isn't enabled; this is expected.
|
|
455
|
+
nil
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# Handle exceptions.
|
|
459
|
+
base.rescue_from(*RRF_RESCUED_EXCEPTIONS, with: :rrf_error_handler)
|
|
460
|
+
base.rescue_from(*RRF_RESCUED_RAILS_EXCEPTIONS, with: :rrf_error_handler)
|
|
461
|
+
|
|
462
|
+
# Use `TracePoint` hook to automatically call `rrf_finalize`.
|
|
463
|
+
if RESTFramework.config.auto_finalize
|
|
464
|
+
# :nocov:
|
|
465
|
+
TracePoint.trace(:end) do |t|
|
|
466
|
+
next if base != t.self
|
|
467
|
+
|
|
468
|
+
base.rrf_finalize
|
|
469
|
+
|
|
470
|
+
# It's important to disable the trace once we've found the end of the base class definition,
|
|
471
|
+
# for performance.
|
|
472
|
+
t.disable
|
|
473
|
+
end
|
|
474
|
+
# :nocov:
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def get_serializer_class
|
|
479
|
+
self.class.serializer_class || RESTFramework::NativeSerializer
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# Serialize the given data using the `serializer_class`.
|
|
483
|
+
def serialize(data, **kwargs)
|
|
484
|
+
RESTFramework::Utils.wrap_ams(self.get_serializer_class).new(
|
|
485
|
+
data, controller: self, **kwargs
|
|
486
|
+
).serialize
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def rrf_error_handler(e)
|
|
490
|
+
status = case e
|
|
491
|
+
when ActiveRecord::RecordNotFound
|
|
492
|
+
404
|
|
493
|
+
when RESTFramework::BulkRecordErrorsError
|
|
494
|
+
422
|
|
495
|
+
else
|
|
496
|
+
400
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
render(
|
|
500
|
+
api: {
|
|
501
|
+
message: e.message,
|
|
502
|
+
errors: e.try(:record).try(:errors),
|
|
503
|
+
exception: RESTFramework.config.show_backtrace ? e.full_message : nil,
|
|
504
|
+
}.compact,
|
|
505
|
+
status: status,
|
|
506
|
+
)
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
def route_groups
|
|
510
|
+
@route_groups ||= RESTFramework::Utils.get_routes(Rails.application.routes, request)
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# Render a browsable API for `html` format, along with basic `json`/`xml` formats, and with
|
|
514
|
+
# support or passing custom `kwargs` to the underlying `render` calls.
|
|
515
|
+
def render_api(payload, **kwargs)
|
|
516
|
+
html_kwargs = kwargs.delete(:html_kwargs) || {}
|
|
517
|
+
json_kwargs = kwargs.delete(:json_kwargs) || {}
|
|
518
|
+
xml_kwargs = kwargs.delete(:xml_kwargs) || {}
|
|
519
|
+
|
|
520
|
+
# Raise helpful error if payload is nil. Usually this happens when a record is not found (e.g.,
|
|
521
|
+
# when passing something like `User.find_by(id: some_id)` to `render_api`). The caller should
|
|
522
|
+
# actually be calling `find_by!` to raise ActiveRecord::RecordNotFound and allowing the REST
|
|
523
|
+
# framework to catch this error and return an appropriate error response.
|
|
524
|
+
if payload.nil?
|
|
525
|
+
raise RESTFramework::NilPassedToRenderAPIError
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# If `payload` is an `ActiveRecord::Relation` or `ActiveRecord::Base`, then serialize it.
|
|
529
|
+
if payload.is_a?(ActiveRecord::Base) || payload.is_a?(ActiveRecord::Relation)
|
|
530
|
+
payload = self.serialize(payload)
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
# Do not use any adapters by default, if configured.
|
|
534
|
+
if self.class.disable_adapters_by_default && !kwargs.key?(:adapter)
|
|
535
|
+
kwargs[:adapter] = nil
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# Flag to track if we had to rescue unknown format.
|
|
539
|
+
already_rescued_unknown_format = false
|
|
540
|
+
|
|
541
|
+
begin
|
|
542
|
+
respond_to do |format|
|
|
543
|
+
if payload == ""
|
|
544
|
+
format.json { head(kwargs[:status] || :no_content) } if self.class.serialize_to_json
|
|
545
|
+
format.xml { head(kwargs[:status] || :no_content) } if self.class.serialize_to_xml
|
|
546
|
+
else
|
|
547
|
+
format.json {
|
|
548
|
+
render(json: payload, **kwargs.merge(json_kwargs))
|
|
549
|
+
} if self.class.serialize_to_json
|
|
550
|
+
format.xml {
|
|
551
|
+
render(xml: payload, **kwargs.merge(xml_kwargs))
|
|
552
|
+
} if self.class.serialize_to_xml
|
|
553
|
+
# TODO: possibly support more formats here if supported?
|
|
554
|
+
end
|
|
555
|
+
format.html {
|
|
556
|
+
@payload = payload
|
|
557
|
+
if payload == ""
|
|
558
|
+
@json_payload = "" if self.class.serialize_to_json
|
|
559
|
+
@xml_payload = "" if self.class.serialize_to_xml
|
|
560
|
+
else
|
|
561
|
+
@json_payload = payload.to_json if self.class.serialize_to_json
|
|
562
|
+
@xml_payload = payload.to_xml if self.class.serialize_to_xml
|
|
563
|
+
end
|
|
564
|
+
@title ||= self.class.get_title
|
|
565
|
+
@description ||= self.class.description
|
|
566
|
+
self.route_groups
|
|
567
|
+
begin
|
|
568
|
+
render(**kwargs.merge(html_kwargs))
|
|
569
|
+
rescue ActionView::MissingTemplate
|
|
570
|
+
# A view is not required, so just use `html: ""`.
|
|
571
|
+
render(html: "", layout: true, **kwargs.merge(html_kwargs))
|
|
572
|
+
end
|
|
573
|
+
}
|
|
574
|
+
end
|
|
575
|
+
rescue ActionController::UnknownFormat
|
|
576
|
+
if !already_rescued_unknown_format && rescue_format = self.class.rescue_unknown_format_with
|
|
577
|
+
request.format = rescue_format
|
|
578
|
+
already_rescued_unknown_format = true
|
|
579
|
+
retry
|
|
580
|
+
else
|
|
581
|
+
raise
|
|
582
|
+
end
|
|
583
|
+
end
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
# Deprecated alias for `render_api`.
|
|
587
|
+
def api_response(*args, **kwargs)
|
|
588
|
+
RESTFramework.deprecator.warn("`api_response` is deprecated; use `render_api` instead.")
|
|
589
|
+
render_api(*args, **kwargs)
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def options
|
|
593
|
+
render(api: self.openapi_document)
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
def get_fields
|
|
597
|
+
self.class.get_fields(input_fields: self.class.fields)
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
# Get a hash of strong parameters for the current action.
|
|
601
|
+
def get_allowed_parameters
|
|
602
|
+
return @_get_allowed_parameters if defined?(@_get_allowed_parameters)
|
|
603
|
+
|
|
604
|
+
@_get_allowed_parameters = self.class.allowed_parameters
|
|
605
|
+
return @_get_allowed_parameters if @_get_allowed_parameters
|
|
606
|
+
|
|
607
|
+
# Assemble strong parameters.
|
|
608
|
+
variations = []
|
|
609
|
+
hash_variations = {}
|
|
610
|
+
reflections = self.class.model.reflections
|
|
611
|
+
@_get_allowed_parameters = self.get_fields.map { |f|
|
|
612
|
+
f = f.to_s
|
|
613
|
+
config = self.class.field_configuration[f]
|
|
614
|
+
|
|
615
|
+
# ActionText Integration:
|
|
616
|
+
if self.class.enable_action_text && reflections.key?("rich_text_#{f}")
|
|
617
|
+
next f
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
# ActiveStorage Integration: `has_one_attached`
|
|
621
|
+
if self.class.enable_active_storage && reflections.key?("#{f}_attachment")
|
|
622
|
+
hash_variations[f] = RRF_ACTIVESTORAGE_KEYS
|
|
623
|
+
next f
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
# ActiveStorage Integration: `has_many_attached`
|
|
627
|
+
if self.class.enable_active_storage && reflections.key?("#{f}_attachments")
|
|
628
|
+
hash_variations[f] = RRF_ACTIVESTORAGE_KEYS
|
|
629
|
+
next nil
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
if config[:reflection]
|
|
633
|
+
# Add `_id`/`_ids` variations for associations.
|
|
634
|
+
if id_field = config[:id_field]
|
|
635
|
+
if id_field.ends_with?("_ids")
|
|
636
|
+
hash_variations[id_field] = []
|
|
637
|
+
else
|
|
638
|
+
variations << id_field
|
|
639
|
+
end
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
# Add `_attributes` variations for associations.
|
|
643
|
+
# TODO: Consider adjusting this based on `nested_attributes_options`.
|
|
644
|
+
if self.class.permit_nested_attributes_assignment
|
|
645
|
+
hash_variations["#{f}_attributes"] = (
|
|
646
|
+
config[:sub_fields] + [ "_destroy" ]
|
|
647
|
+
)
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
# Associations are not allowed to be submitted in their bare form (if they are submitted
|
|
651
|
+
# that way, they will be translated to either id/ids or nested attributes assignment).
|
|
652
|
+
next nil
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
next f
|
|
656
|
+
}.compact
|
|
657
|
+
@_get_allowed_parameters += variations
|
|
658
|
+
@_get_allowed_parameters << hash_variations
|
|
659
|
+
|
|
660
|
+
@_get_allowed_parameters
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
# Use strong parameters to filter the request body.
|
|
664
|
+
def get_body_params(bulk_action: nil)
|
|
665
|
+
data = self.request.request_parameters
|
|
666
|
+
pk = self.class.model&.primary_key
|
|
667
|
+
allowed_params = self.get_allowed_parameters
|
|
668
|
+
|
|
669
|
+
# Before we filter the data, dynamically dispatch association assignment to either the id/ids
|
|
670
|
+
# assignment ActiveRecord API or the nested assignment ActiveRecord API. Note that there is no
|
|
671
|
+
# need to check for `permit_id_assignment` or `permit_nested_attributes_assignment` here, since
|
|
672
|
+
# that is enforced by strong parameters generated by `get_allowed_parameters`.
|
|
673
|
+
if !bulk_action && self.class.model
|
|
674
|
+
self.class.model.reflections.each do |name, ref|
|
|
675
|
+
if payload = data[name]
|
|
676
|
+
if payload.is_a?(Hash) || (payload.is_a?(Array) && payload.all? { |x| x.is_a?(Hash) })
|
|
677
|
+
# Assume nested attributes assignment.
|
|
678
|
+
attributes_key = "#{name}_attributes"
|
|
679
|
+
data[attributes_key] = data.delete(name) unless data[attributes_key]
|
|
680
|
+
elsif id_field = RESTFramework::Utils.id_field_for(name, ref)
|
|
681
|
+
# Assume id/ids assignment.
|
|
682
|
+
data[id_field] = data.delete(name) unless data[id_field]
|
|
683
|
+
end
|
|
684
|
+
end
|
|
685
|
+
end
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
# ActiveStorage Integration: Translate base64 encoded attachments to upload objects.
|
|
689
|
+
#
|
|
690
|
+
# rubocop:disable Layout/LineLength
|
|
691
|
+
#
|
|
692
|
+
# Example base64 images (red, green, and blue squares):
|
|
693
|
+
# data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC
|
|
694
|
+
# data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mNk+M9Qz0AEYBxVSF+FAAhKDveksOjmAAAAAElFTkSuQmCC
|
|
695
|
+
# data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mNkYPhfz0AEYBxVSF+FAP5FDvcfRYWgAAAAAElFTkSuQmCC
|
|
696
|
+
#
|
|
697
|
+
# rubocop:enable Layout/LineLength
|
|
698
|
+
has_many_attached_scalar_data = {}
|
|
699
|
+
if !bulk_action && self.class.enable_active_storage && self.class.model
|
|
700
|
+
self.class.model.attachment_reflections.keys.each do |k|
|
|
701
|
+
if data[k].is_a?(Array)
|
|
702
|
+
data[k] = data[k].map { |v|
|
|
703
|
+
if v.is_a?(String)
|
|
704
|
+
v = RRF_BASE64_TRANSLATE.call(k, v)
|
|
705
|
+
|
|
706
|
+
# Remember scalars because Rails strong params will remove it.
|
|
707
|
+
if v.is_a?(String)
|
|
708
|
+
has_many_attached_scalar_data[k] ||= []
|
|
709
|
+
has_many_attached_scalar_data[k] << v
|
|
710
|
+
end
|
|
711
|
+
elsif v.is_a?(Hash)
|
|
712
|
+
if v[:io].is_a?(String)
|
|
713
|
+
v[:io] = StringIO.new(Base64.decode64(v[:io]))
|
|
714
|
+
end
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
next v
|
|
718
|
+
}
|
|
719
|
+
elsif data[k].is_a?(Hash)
|
|
720
|
+
if data[k][:io].is_a?(String)
|
|
721
|
+
data[k][:io] = StringIO.new(Base64.decode64(data[k][:io]))
|
|
722
|
+
end
|
|
723
|
+
elsif data[k].is_a?(String)
|
|
724
|
+
data[k] = RRF_BASE64_TRANSLATE.call(k, data[k])
|
|
725
|
+
end
|
|
726
|
+
end
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
# Filter the request body with strong params. If `bulk` is true, then we apply allowed
|
|
730
|
+
# parameters to the `_json` key of the request body.
|
|
731
|
+
body_params = if allowed_params == true
|
|
732
|
+
ActionController::Parameters.new(data).permit!
|
|
733
|
+
elsif bulk_action
|
|
734
|
+
if bulk_action == :create
|
|
735
|
+
ActionController::Parameters.new(data).permit({ _json: allowed_params })
|
|
736
|
+
elsif bulk_action == :update
|
|
737
|
+
ActionController::Parameters.new(data).permit({ _json: allowed_params + [ pk ] })
|
|
738
|
+
elsif bulk_action == :destroy
|
|
739
|
+
ActionController::Parameters.new(data).permit({ _json: [] })
|
|
740
|
+
else
|
|
741
|
+
raise ArgumentError, "Invalid bulk action: #{bulk_action}"
|
|
742
|
+
end
|
|
743
|
+
else
|
|
744
|
+
ActionController::Parameters.new(data).permit(*allowed_params)
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
# ActiveStorage Integration: Workaround for Rails strong params not allowing you to permit an
|
|
748
|
+
# array containing a mix of scalars and hashes. This is needed for `has_many_attached`, because
|
|
749
|
+
# API consumers must be able to provide scalar `signed_id` values for existing attachments along
|
|
750
|
+
# with hashes for new attachments. It's worth mentioning that base64 scalars are converted to
|
|
751
|
+
# hashes that conform to the ActiveStorage API.
|
|
752
|
+
has_many_attached_scalar_data.each do |k, v|
|
|
753
|
+
body_params[k].unshift(*v)
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
# Filter read-only fields.
|
|
757
|
+
body_params.delete_if do |f, _|
|
|
758
|
+
cfg = self.class.field_configuration[f]
|
|
759
|
+
cfg && cfg[:read_only]
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
body_params
|
|
763
|
+
end
|
|
764
|
+
alias_method :get_create_params, :get_body_params
|
|
765
|
+
alias_method :get_update_params, :get_body_params
|
|
766
|
+
alias_method :get_destroy_params, :get_body_params
|
|
767
|
+
|
|
768
|
+
# Get the set of records this controller has access to.
|
|
769
|
+
def get_recordset
|
|
770
|
+
return self.class.recordset if self.class.recordset
|
|
771
|
+
|
|
772
|
+
# If there is a model, return that model's default scope (all records by default).
|
|
773
|
+
if self.class.model
|
|
774
|
+
return self.class.model.all
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
nil
|
|
778
|
+
end
|
|
779
|
+
|
|
780
|
+
# Filter the recordset and return records this request has access to.
|
|
781
|
+
def get_records
|
|
782
|
+
data = self.get_recordset
|
|
783
|
+
|
|
784
|
+
@records ||= self.class.filter_backends&.reduce(data) { |d, filter|
|
|
785
|
+
filter.new(controller: self).filter_data(d)
|
|
786
|
+
} || data
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
# Get a single record by primary key or another column, if allowed.
|
|
790
|
+
def get_record
|
|
791
|
+
return @record if @record
|
|
792
|
+
|
|
793
|
+
find_by_key = self.class.model.primary_key
|
|
794
|
+
is_pk = true
|
|
795
|
+
|
|
796
|
+
# Find by another column if it's permitted.
|
|
797
|
+
if find_by_param = self.class.find_by_query_param.presence
|
|
798
|
+
if find_by = request.query_parameters[find_by_param].presence
|
|
799
|
+
find_by_fields = self.class.find_by_fields&.map(&:to_s) || self.get_fields
|
|
800
|
+
|
|
801
|
+
if find_by.in?(find_by_fields)
|
|
802
|
+
is_pk = false unless find_by_key == find_by
|
|
803
|
+
find_by_key = find_by
|
|
804
|
+
end
|
|
805
|
+
end
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
# Get the recordset, filtering if configured.
|
|
809
|
+
collection = if self.class.filter_recordset_before_find
|
|
810
|
+
self.get_records
|
|
811
|
+
else
|
|
812
|
+
self.get_recordset
|
|
813
|
+
end
|
|
814
|
+
|
|
815
|
+
# Return the record. Route key is always `:id` by Rails' convention.
|
|
816
|
+
if is_pk
|
|
817
|
+
@record = collection.find(request.path_parameters[:id])
|
|
818
|
+
else
|
|
819
|
+
@record = collection.find_by!(find_by_key => request.path_parameters[:id])
|
|
820
|
+
end
|
|
821
|
+
end
|
|
822
|
+
|
|
823
|
+
# Determine what collection to call `create` on.
|
|
824
|
+
def create_from
|
|
825
|
+
if self.class.create_from_recordset
|
|
826
|
+
# Create with any properties inherited from the recordset. We exclude any `select` clauses
|
|
827
|
+
# in case model callbacks need to call `count` on this collection, which typically raises a
|
|
828
|
+
# SQL `SyntaxError`.
|
|
829
|
+
self.get_recordset.except(:select)
|
|
830
|
+
else
|
|
831
|
+
# Otherwise, perform a "bare" insert_all.
|
|
832
|
+
self.class.model
|
|
833
|
+
end
|
|
834
|
+
end
|
|
835
|
+
end
|
|
836
|
+
|
|
837
|
+
require_relative "controller/bulk"
|
|
838
|
+
require_relative "controller/crud"
|
|
839
|
+
require_relative "controller/openapi"
|