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