• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

gregschmit / rails-rest-framework / 24538335705

16 Apr 2026 10:51PM UTC coverage: 88.164% (-2.7%) from 90.879%
24538335705

push

github

gregschmit
Refactor mixins into single Controller include.

Makes overloading builtin actions simpler.
Keep mixins backwards compatible, but replace with `model`, `bulk`, and
`excluded_actions` config attributes.
Remove UnknownModelError; no more auto model detection.
Update README.md.
Update test/demo APIs to use the new style, keep plain API on old mixin
style.
Ensure usage of old mixins triggers deprecation notices.
Update dependencies.
Update GH actions checkout to v6.

445 of 548 new or added lines in 13 files covered. (81.2%)

1095 of 1242 relevant lines covered (88.16%)

214.01 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

82.14
/lib/rest_framework/controller.rb
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
2✔
5
  RRF_BASE_CONFIG = {
6
    extra_actions: nil,
2✔
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
    # Core attributes related to models.
18
    model: nil,
19
    recordset: nil,
20
    excluded_actions: nil,
21
    bulk: false,
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
    # Options related to serialization.
79
    rescue_unknown_format_with: :json,
80
    serializer_class: nil,
81
    serialize_to_json: true,
82
    serialize_to_xml: true,
83

84
    # Options related to pagination.
85
    paginator_class: nil,
86
    page_size: 20,
87
    page_query_param: "page",
88
    page_size_query_param: "page_size",
89
    max_page_size: nil,
90

91
    # Option to disable serializer adapters by default, mainly introduced because Active Model
92
    # Serializers will do things like serialize `[]` into `{"":[]}`.
93
    disable_adapters_by_default: true,
94

95
    # Custom integrations (reduces serializer performance due to method calls).
96
    enable_action_text: false,
97
    enable_active_storage: false,
98
  }
99
  BASE64_REGEX = /data:(.*);base64,(.*)/
2✔
100
  BASE64_TRANSLATE = ->(field, value) {
2✔
NEW
101
    return value unless BASE64_REGEX.match?(value)
×
102

NEW
103
    _, content_type, payload = value.match(BASE64_REGEX).to_a
×
104
    {
NEW
105
      io: StringIO.new(Base64.decode64(payload)),
×
106
      content_type: content_type,
107
      filename: "file_#{field}#{Rack::Mime::MIME_TYPES.invert[content_type]}",
108
    }
109
  }
110
  ACTIVESTORAGE_KEYS = [ :io, :content_type, :filename, :identify, :key ]
2✔
111

112
  # Default action for API root.
113
  def root
2✔
114
    render(api: { message: "This is the API root." })
10✔
115
  end
116

117
  module ClassMethods
2✔
118
    IGNORE_VALIDATORS_WITH_KEYS = [ :if, :unless ].freeze
2✔
119

120
    # By default, this is the name of the controller class, titleized and with any custom inflection
121
    # acronyms applied.
122
    def get_title
2✔
123
      self.title || RESTFramework::Utils.inflect(
68✔
124
        self.name.demodulize.chomp("Controller").titleize(keep_id_suffix: true),
125
        self.inflect_acronyms,
126
      )
127
    end
128

129
    # Get a label from a field/column name, titleized and inflected.
130
    def label_for(s)
2✔
131
      default_title = RESTFramework::Utils.inflect(
654✔
132
        s.to_s.titleize(keep_id_suffix: true), self.inflect_acronyms
133
      )
134
      self.model&.human_attribute_name(s, default: default_title) || default_title
654✔
135
    end
136

137
    # Define any behavior to execute at the end of controller definition.
138
    # :nocov:
139
    def rrf_finalize
✔
140
      if RESTFramework.config.freeze_config
141
        self::RRF_BASE_CONFIG.keys.each { |k|
142
          v = self.send(k)
143
          v.freeze if v.is_a?(Hash) || v.is_a?(Array)
144
        }
145
      end
146

147
      self.setup_delegation if self.model
148
      # self.setup_channel if self.model
149
    end
150
    # :nocov:
151

152
    # Get the available fields. Fallback to this controller's model columns, or an empty array. This
153
    # should always return an array of strings.
154
    def get_fields(input_fields: nil)
2✔
155
      input_fields ||= self.fields
644✔
156

157
      # If fields is a hash, then parse it.
158
      if input_fields.is_a?(Hash)
644✔
159
        return RESTFramework::Utils.parse_fields_hash(
94✔
160
          input_fields,
161
          self.model,
162
          exclude_associations: self.exclude_associations,
163
          action_text: self.enable_action_text,
164
          active_storage: self.enable_active_storage,
165
        )
166
      elsif !input_fields
550✔
167
        # Otherwise, if fields is nil, then fallback to columns.
168
        return self.model ? RESTFramework::Utils.fields_for(
524✔
169
          self.model,
170
          exclude_associations: self.exclude_associations,
171
          action_text: self.enable_action_text,
172
          active_storage: self.enable_active_storage,
173
        ) : []
174
      elsif input_fields
26✔
175
        input_fields = input_fields.map(&:to_s)
26✔
176
      end
177

178
      input_fields
26✔
179
    end
180

181
    # Get a full field configuration, including defaults and inferred values.
182
    def field_configuration
2✔
183
      return @field_configuration if @field_configuration
3,658✔
184

185
      field_config = self.field_config&.with_indifferent_access || {}
24✔
186
      columns = self.model.columns_hash
24✔
187
      column_defaults = self.model.column_defaults
24✔
188
      reflections = self.model.reflections
24✔
189
      attributes = self.model._default_attributes
24✔
190
      readonly_attributes = self.model.readonly_attributes
24✔
191
      read_only_fields = self.read_only_fields&.map(&:to_s)&.to_set || Set[]
24✔
192
      write_only_fields = self.write_only_fields&.map(&:to_s)&.to_set || Set[]
24✔
193
      hidden_fields = self.hidden_fields&.map(&:to_s)&.to_set || Set[]
24✔
194
      rich_text_association_names = self.model.reflect_on_all_associations(:has_one)
24✔
195
        .collect(&:name)
196
        .select { |n| n.to_s.start_with?("rich_text_") }
40✔
197
      attachment_reflections = self.model.attachment_reflections
24✔
198

199
      @field_configuration = self.get_fields.map { |f|
24✔
200
        cfg = field_config[f]&.dup || {}
242✔
201
        cfg[:label] ||= self.label_for(f)
242✔
202

203
        # Annotate primary key.
204
        if self.model.primary_key == f
242✔
205
          cfg[:primary_key] = true
24✔
206

207
          unless cfg.key?(:read_only)
24✔
208
            cfg[:read_only] = true
24✔
209
          end
210
        end
211

212
        # Annotate field mutability and display properties.
213
        cfg[:read_only] = true if f.in?(readonly_attributes) || f.in?(read_only_fields)
242✔
214
        cfg[:write_only] = true if f.in?(write_only_fields)
242✔
215
        cfg[:hidden] = true if f.in?(hidden_fields)
242✔
216

217
        # Raise warnings on some bad combinations of properties.
218
        if cfg[:write_only]
242✔
NEW
219
          if cfg[:read_only]
×
NEW
220
            Rails.logger.warn("RRF: `#{f}` write_only conflicts with read_only.")
×
221
          end
222

NEW
223
          if cfg[:hidden]
×
NEW
224
            Rails.logger.warn("RRF: `#{f}` write_only implies hidden.")
×
225
          end
226

NEW
227
          if cfg[:hidden_from_index]
×
NEW
228
            Rails.logger.warn("RRF: `#{f}` write_only implies hidden_from_index.")
×
229
          end
230
        end
231

232
        # Annotate column data.
233
        if column = columns[f]
242✔
234
          cfg[:kind] = "column"
158✔
235
          cfg[:type] ||= column.type
158✔
236
          cfg[:required] = true unless column.null
158✔
237
        end
238

239
        # Add default values from the model's schema.
240
        if cfg[:default].nil? && (column_default = column_defaults[f])
242✔
241
          cfg[:default] = column_default
56✔
242
        end
243

244
        # Add metadata from the model's attributes hash.
245
        if attributes.key?(f) && attribute = attributes[f]
242✔
246
          if cfg[:default].nil? && default = attribute.value_before_type_cast
158✔
NEW
247
            cfg[:default] = default
×
248
          end
249
          cfg[:kind] ||= "attribute"
158✔
250

251
          # Get any type information from the attribute.
252
          if type = attribute.type
158✔
253
            cfg[:type] ||= type.type if type.type
158✔
254

255
            # Get enum variants.
256
            if type.is_a?(ActiveRecord::Enum::EnumType)
158✔
257
              cfg[:enum_variants] = type.send(:mapping)
8✔
258

259
              # TranslateEnum Integration:
260
              translate_method = "translated_#{f.pluralize}"
8✔
261
              if self.model.respond_to?(translate_method)
8✔
262
                cfg[:enum_translations] = self.model.send(translate_method)
8✔
263
              end
264
            end
265
          end
266
        end
267

268
        # Get association metadata.
269
        if ref = reflections[f]
242✔
270
          cfg[:kind] = "association"
66✔
271

272
          # Determine sub-fields for associations.
273
          if ref.polymorphic?
66✔
NEW
274
            ref_columns = {}
×
275
          else
276
            ref_columns = ref.klass.columns_hash
66✔
277
          end
278
          cfg[:sub_fields] ||= RESTFramework::Utils.sub_fields_for(ref)
66✔
279
          cfg[:sub_fields] = cfg[:sub_fields].map(&:to_s)
66✔
280

281
          # Very basic metadata about sub-fields.
282
          cfg[:sub_fields_metadata] = cfg[:sub_fields].map { |sf|
66✔
283
            v = {}
126✔
284

285
            if ref_columns[sf]
126✔
286
              v[:kind] = "column"
126✔
287
            else
NEW
288
              v[:kind] = "method"
×
289
            end
290

291
            next [ sf, v ]
126✔
292
          }.to_h.compact.presence
293

294
          # Determine if we render id/ids fields. Unfortunately, `has_one` does not provide this
295
          # interface.
296
          if self.permit_id_assignment && id_field = RESTFramework::Utils.id_field_for(f, ref)
66✔
297
            cfg[:id_field] = id_field
58✔
298
          end
299

300
          # Determine if we render nested attributes options.
301
          if self.permit_nested_attributes_assignment && (
66✔
302
            nested_opts = self.model.nested_attributes_options[f.to_sym].presence
66✔
303
          )
304
            cfg[:nested_attributes_options] = { field: "#{f}_attributes", **nested_opts }
24✔
305
          end
306

307
          begin
308
            cfg[:association_pk] = ref.active_record_primary_key
66✔
309
          rescue ActiveRecord::UnknownPrimaryKey
310
          end
311

312
          cfg[:reflection] = ref
66✔
313
        end
314

315
        # Determine if this is an ActionText "rich text".
316
        if :"rich_text_#{f}".in?(rich_text_association_names)
242✔
317
          cfg[:kind] = "rich_text"
4✔
318
        end
319

320
        # Determine if this is an ActiveStorage attachment.
321
        if ref = attachment_reflections[f]
242✔
322
          cfg[:kind] = "attachment"
8✔
323
          cfg[:attachment_type] = ref.macro
8✔
324
        end
325

326
        # Determine if this is just a method.
327
        if !cfg[:kind] && self.model.method_defined?(f)
242✔
328
          cfg[:kind] = "method"
4✔
329
          cfg[:read_only] = true if cfg[:read_only].nil?
4✔
330
        end
331

332
        # Collect validator options into a hash on their type, while also updating `required` based
333
        # on any presence validators.
334
        self.model.validators_on(f).each do |validator|
242✔
335
          kind = validator.kind
78✔
336
          options = validator.options
78✔
337

338
          # Reject validator if it includes keys like `:if` and `:unless` because those are
339
          # conditionally applied in a way that is not feasible to communicate via the API.
340
          next if IGNORE_VALIDATORS_WITH_KEYS.any? { |k| options.key?(k) }
234✔
341

342
          # Update `required` if we find a presence validator.
343
          cfg[:required] = true if kind == :presence
78✔
344

345
          # Resolve procs (and lambdas), and symbols for certain arguments.
346
          if options[:in].is_a?(Proc)
78✔
NEW
347
            options = options.merge(in: options[:in].call)
×
348
          elsif options[:in].is_a?(Symbol)
78✔
349
            options = options.merge(in: self.model.send(options[:in]))
8✔
350
          end
351

352
          cfg[:validators] ||= {}
78✔
353
          cfg[:validators][kind] ||= []
78✔
354
          cfg[:validators][kind] << options
78✔
355
        end
356

357
        next [ f, cfg ]
242✔
358
      }.to_h.compact.with_indifferent_access
359
    end
360

361
    # Only for model controllers.
362
    def setup_delegation
2✔
363
      # Delegate extra actions.
NEW
364
      self.extra_actions&.each do |action, config|
×
NEW
365
        next unless config.is_a?(Hash) && config.dig(:metadata, :delegate)
×
NEW
366
        next unless self.model.respond_to?(action)
×
367

NEW
368
        self.define_method(action) do
×
NEW
369
          if self.model.method(action).parameters.last&.first == :keyrest
×
NEW
370
            render(api: self.model.send(action, **params))
×
371
          else
NEW
372
            render(api: self.model.send(action))
×
373
          end
374
        end
375
      end
376

377
      # Delegate extra member actions.
NEW
378
      self.extra_member_actions&.each do |action, config|
×
NEW
379
        next unless config.is_a?(Hash) && config.dig(:metadata, :delegate)
×
NEW
380
        next unless self.model.method_defined?(action)
×
381

NEW
382
        self.define_method(action) do
×
NEW
383
          record = self.get_record
×
384

NEW
385
          if record.method(action).parameters.last&.first == :keyrest
×
NEW
386
            render(api: record.send(action, **params))
×
387
          else
NEW
388
            render(api: record.send(action))
×
389
          end
390
        end
391
      end
392
    end
393
  end
394

395
  def self.included(base)
2✔
396
    return unless base.is_a?(Class)
18✔
397

398
    base.extend(ClassMethods)
18✔
399

400
    # By default, the layout should be set to `rest_framework`.
401
    base.layout("rest_framework")
18✔
402

403
    # Add class attributes unless they already exist.
404
    RRF_BASE_CONFIG.each do |a, default|
18✔
405
      next if base.respond_to?(a)
1,062✔
406

407
      # Don't leak class attributes to the instance to avoid conflicting with action methods.
408
      base.class_attribute(a, default: default, instance_accessor: false)
472✔
409
    end
410

411
    # Alias `extra_actions` to `extra_collection_actions`.
412
    unless base.respond_to?(:extra_collection_actions)
18✔
413
      base.singleton_class.alias_method(:extra_collection_actions, :extra_actions)
8✔
414
      base.singleton_class.alias_method(:extra_collection_actions=, :extra_actions=)
8✔
415
    end
416

417
    # Skip CSRF since this is an API.
418
    begin
419
      base.skip_before_action(:verify_authenticity_token)
18✔
420
    rescue
421
      nil
10✔
422
    end
423

424
    # Handle some common exceptions.
425
    unless RESTFramework.config.disable_rescue_from
18✔
426
      base.rescue_from(
18✔
427
        ActionController::ParameterMissing,
428
        ActionController::UnpermittedParameters,
429
        ActionDispatch::Http::Parameters::ParseError,
430
        ActiveRecord::AssociationTypeMismatch,
431
        ActiveRecord::NotNullViolation,
432
        ActiveRecord::RecordNotFound,
433
        ActiveRecord::RecordInvalid,
434
        ActiveRecord::RecordNotSaved,
435
        ActiveRecord::RecordNotDestroyed,
436
        ActiveRecord::RecordNotUnique,
437
        ActiveModel::UnknownAttributeError,
438
        with: :rrf_error_handler,
439
      )
440
    end
441

442
    # Use `TracePoint` hook to automatically call `rrf_finalize`.
443
    if RESTFramework.config.auto_finalize
18✔
444
      # :nocov:
445
      TracePoint.trace(:end) do |t|
✔
446
        next if base != t.self
447

448
        base.rrf_finalize
449

450
        # It's important to disable the trace once we've found the end of the base class definition,
451
        # for performance.
452
        t.disable
453
      end
454
      # :nocov:
455
    end
456
  end
457

458
  def get_serializer_class
2✔
459
    self.class.serializer_class || RESTFramework::NativeSerializer
182✔
460
  end
461

462
  # Serialize the given data using the `serializer_class`.
463
  def serialize(data, **kwargs)
2✔
464
    RESTFramework::Utils.wrap_ams(self.get_serializer_class).new(
170✔
465
      data, controller: self, **kwargs
466
    ).serialize
467
  end
468

469
  def rrf_error_handler(e)
2✔
470
    status = case e
26✔
471
    when ActiveRecord::RecordNotFound
472
      404
26✔
473
    else
NEW
474
      400
×
475
    end
476

477
    render(
26✔
478
      api: {
479
        message: e.message,
480
        errors: e.try(:record).try(:errors),
481
        exception: RESTFramework.config.show_backtrace ? e.full_message : nil,
26✔
482
      }.compact,
483
      status: status,
484
    )
485
  end
486

487
  def route_groups
2✔
488
    @route_groups ||= RESTFramework::Utils.get_routes(Rails.application.routes, request)
68✔
489
  end
490

491
  # Render a browsable API for `html` format, along with basic `json`/`xml` formats, and with
492
  # support or passing custom `kwargs` to the underlying `render` calls.
493
  def render_api(payload, **kwargs)
2✔
494
    html_kwargs = kwargs.delete(:html_kwargs) || {}
262✔
495
    json_kwargs = kwargs.delete(:json_kwargs) || {}
262✔
496
    xml_kwargs = kwargs.delete(:xml_kwargs) || {}
262✔
497

498
    # Raise helpful error if payload is nil. Usually this happens when a record is not found (e.g.,
499
    # when passing something like `User.find_by(id: some_id)` to `render_api`). The caller should
500
    # actually be calling `find_by!` to raise ActiveRecord::RecordNotFound and allowing the REST
501
    # framework to catch this error and return an appropriate error response.
502
    if payload.nil?
262✔
503
      raise RESTFramework::NilPassedToRenderAPIError
6✔
504
    end
505

506
    # If `payload` is an `ActiveRecord::Relation` or `ActiveRecord::Base`, then serialize it.
507
    if payload.is_a?(ActiveRecord::Base) || payload.is_a?(ActiveRecord::Relation)
256✔
508
      payload = self.serialize(payload)
132✔
509
    end
510

511
    # Do not use any adapters by default, if configured.
512
    if self.class.disable_adapters_by_default && !kwargs.key?(:adapter)
256✔
513
      kwargs[:adapter] = nil
256✔
514
    end
515

516
    # Flag to track if we had to rescue unknown format.
517
    already_rescued_unknown_format = false
256✔
518

519
    begin
520
      respond_to do |format|
258✔
521
        if payload == ""
258✔
522
          format.json { head(kwargs[:status] || :no_content) } if self.class.serialize_to_json
16✔
523
          format.xml { head(kwargs[:status] || :no_content) } if self.class.serialize_to_xml
16✔
524
        else
525
          format.json {
526
            render(json: payload, **kwargs.merge(json_kwargs))
162✔
527
          } if self.class.serialize_to_json
244✔
528
          format.xml {
529
            render(xml: payload, **kwargs.merge(xml_kwargs))
28✔
530
          } if self.class.serialize_to_xml
244✔
531
          # TODO: possibly support more formats here if supported?
532
        end
533
        format.html {
258✔
534
          @payload = payload
60✔
535
          if payload == ""
60✔
536
            @json_payload = "" if self.class.serialize_to_json
10✔
537
            @xml_payload = "" if self.class.serialize_to_xml
10✔
538
          else
539
            @json_payload = payload.to_json if self.class.serialize_to_json
50✔
540
            @xml_payload = payload.to_xml if self.class.serialize_to_xml
50✔
541
          end
542
          @title ||= self.class.get_title
60✔
543
          @description ||= self.class.description
60✔
544
          self.route_groups
60✔
545
          begin
546
            render(**kwargs.merge(html_kwargs))
60✔
547
          rescue ActionView::MissingTemplate
548
            # A view is not required, so just use `html: ""`.
549
            render(html: "", layout: true, **kwargs.merge(html_kwargs))
60✔
550
          end
551
        }
552
      end
553
    rescue ActionController::UnknownFormat
4✔
554
      if !already_rescued_unknown_format && rescue_format = self.class.rescue_unknown_format_with
4✔
555
        request.format = rescue_format
2✔
556
        already_rescued_unknown_format = true
2✔
557
        retry
2✔
558
      else
559
        raise
2✔
560
      end
561
    end
562
  end
563

564
  # Compatibility alias for deprecated `api_response`.
565
  alias_method :api_response, :render_api
2✔
566

567
  def options
2✔
568
    render(api: self.openapi_document)
8✔
569
  end
570

571
  def get_fields
2✔
572
    self.class.get_fields(input_fields: self.class.fields)
620✔
573
  end
574

575
  # Get a hash of strong parameters for the current action.
576
  def get_allowed_parameters
2✔
577
    return @_get_allowed_parameters if defined?(@_get_allowed_parameters)
44✔
578

579
    @_get_allowed_parameters = self.class.allowed_parameters
44✔
580
    return @_get_allowed_parameters if @_get_allowed_parameters
44✔
581

582
    # Assemble strong parameters.
583
    variations = []
44✔
584
    hash_variations = {}
44✔
585
    reflections = self.class.model.reflections
44✔
586
    @_get_allowed_parameters = self.get_fields.map { |f|
44✔
587
      f = f.to_s
554✔
588
      config = self.class.field_configuration[f]
554✔
589

590
      # ActionText Integration:
591
      if self.class.enable_action_text && reflections.key?("rich_text_#{f}")
554✔
592
        next f
16✔
593
      end
594

595
      # ActiveStorage Integration: `has_one_attached`
596
      if self.class.enable_active_storage && reflections.key?("#{f}_attachment")
538✔
597
        hash_variations[f] = ACTIVESTORAGE_KEYS
16✔
598
        next f
16✔
599
      end
600

601
      # ActiveStorage Integration: `has_many_attached`
602
      if self.class.enable_active_storage && reflections.key?("#{f}_attachments")
522✔
603
        hash_variations[f] = ACTIVESTORAGE_KEYS
16✔
604
        next nil
16✔
605
      end
606

607
      if config[:reflection]
506✔
608
        # Add `_id`/`_ids` variations for associations.
609
        if id_field = config[:id_field]
168✔
610
          if id_field.ends_with?("_ids")
152✔
611
            hash_variations[id_field] = []
96✔
612
          else
613
            variations << id_field
56✔
614
          end
615
        end
616

617
        # Add `_attributes` variations for associations.
618
        # TODO: Consider adjusting this based on `nested_attributes_options`.
619
        if self.class.permit_nested_attributes_assignment
168✔
620
          hash_variations["#{f}_attributes"] = (
168✔
621
            config[:sub_fields] + [ "_destroy" ]
168✔
622
          )
623
        end
624

625
        # Associations are not allowed to be submitted in their bare form (if they are submitted
626
        # that way, they will be translated to either id/ids or nested attributes assignment).
627
        next nil
168✔
628
      end
629

630
      next f
338✔
631
    }.compact
632
    @_get_allowed_parameters += variations
44✔
633
    @_get_allowed_parameters << hash_variations
44✔
634

635
    @_get_allowed_parameters
44✔
636
  end
637

638
  # Use strong parameters to filter the request body.
639
  def get_body_params(bulk_mode: nil)
2✔
640
    data = self.request.request_parameters
44✔
641
    pk = self.class.model&.primary_key
44✔
642
    allowed_params = self.get_allowed_parameters
44✔
643

644
    # Before we filter the data, dynamically dispatch association assignment to either the id/ids
645
    # assignment ActiveRecord API or the nested assignment ActiveRecord API. Note that there is no
646
    # need to check for `permit_id_assignment` or `permit_nested_attributes_assignment` here, since
647
    # that is enforced by strong parameters generated by `get_allowed_parameters`.
648
    self.class.model.reflections.each do |name, ref|
44✔
649
      if payload = data[name]
316✔
NEW
650
        if payload.is_a?(Hash) || (payload.is_a?(Array) && payload.all? { |x| x.is_a?(Hash) })
×
651
          # Assume nested attributes assignment.
NEW
652
          attributes_key = "#{name}_attributes"
×
NEW
653
          data[attributes_key] = data.delete(name) unless data[attributes_key]
×
NEW
654
        elsif id_field = RESTFramework::Utils.id_field_for(name, ref)
×
655
          # Assume id/ids assignment.
NEW
656
          data[id_field] = data.delete(name) unless data[id_field]
×
657
        end
658
      end
659
    end
660

661
    # ActiveStorage Integration: Translate base64 encoded attachments to upload objects.
662
    #
663
    # rubocop:disable Layout/LineLength
664
    #
665
    # Example base64 images (red, green, and blue squares):
666
    #   data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC
667
    #   data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mNk+M9Qz0AEYBxVSF+FAAhKDveksOjmAAAAAElFTkSuQmCC
668
    #   data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mNkYPhfz0AEYBxVSF+FAP5FDvcfRYWgAAAAAElFTkSuQmCC
669
    #
670
    # rubocop:enable Layout/LineLength
671
    has_many_attached_scalar_data = {}
44✔
672
    if self.class.enable_active_storage
44✔
673
      self.class.model.attachment_reflections.keys.each do |k|
28✔
674
        if data[k].is_a?(Array)
36✔
NEW
675
          data[k] = data[k].map { |v|
×
NEW
676
            if v.is_a?(String)
×
NEW
677
              v = BASE64_TRANSLATE.call(k, v)
×
678

679
              # Remember scalars because Rails strong params will remove it.
NEW
680
              if v.is_a?(String)
×
NEW
681
                has_many_attached_scalar_data[k] ||= []
×
NEW
682
                has_many_attached_scalar_data[k] << v
×
683
              end
NEW
684
            elsif v.is_a?(Hash)
×
NEW
685
              if v[:io].is_a?(String)
×
NEW
686
                v[:io] = StringIO.new(Base64.decode64(v[:io]))
×
687
              end
688
            end
689

NEW
690
            next v
×
691
          }
692
        elsif data[k].is_a?(Hash)
36✔
NEW
693
          if data[k][:io].is_a?(String)
×
NEW
694
            data[k][:io] = StringIO.new(Base64.decode64(data[k][:io]))
×
695
          end
696
        elsif data[k].is_a?(String)
36✔
NEW
697
          data[k] = BASE64_TRANSLATE.call(k, data[k])
×
698
        end
699
      end
700
    end
701

702
    # Filter the request body with strong params. If `bulk` is true, then we apply allowed
703
    # parameters to the `_json` key of the request body.
704
    body_params = if allowed_params == true
44✔
NEW
705
      ActionController::Parameters.new(data).permit!
×
706
    elsif bulk_mode
44✔
707
      pk = bulk_mode == :update ? [ pk ] : []
8✔
708
      ActionController::Parameters.new(data).permit({ _json: allowed_params + pk })
8✔
709
    else
710
      ActionController::Parameters.new(data).permit(*allowed_params)
36✔
711
    end
712

713
    # ActiveStorage Integration: Workaround for Rails strong params not allowing you to permit an
714
    # array containing a mix of scalars and hashes. This is needed for `has_many_attached`, because
715
    # API consumers must be able to provide scalar `signed_id` values for existing attachments along
716
    # with hashes for new attachments. It's worth mentioning that base64 scalars are converted to
717
    # hashes that conform to the ActiveStorage API.
718
    has_many_attached_scalar_data.each do |k, v|
44✔
NEW
719
      body_params[k].unshift(*v)
×
720
    end
721

722
    # Filter read-only fields.
723
    body_params.delete_if do |f, _|
44✔
724
      cfg = self.class.field_configuration[f]
62✔
725
      cfg && cfg[:read_only]
62✔
726
    end
727

728
    body_params
44✔
729
  end
730
  alias_method :get_create_params, :get_body_params
2✔
731
  alias_method :get_update_params, :get_body_params
2✔
732

733
  # Get the set of records this controller has access to.
734
  def get_recordset
2✔
735
    return self.class.recordset if self.class.recordset
210✔
736

737
    # If there is a model, return that model's default scope (all records by default).
738
    if self.class.model
210✔
739
      return self.class.model.all
210✔
740
    end
741

742
    nil
743
  end
744

745
  # Filter the recordset and return records this request has access to.
746
  def get_records
2✔
747
    data = self.get_recordset
176✔
748

749
    @records ||= self.class.filter_backends&.reduce(data) { |d, filter|
176✔
750
      filter.new(controller: self).filter_data(d)
616✔
751
    } || data
752
  end
753

754
  # Get a single record by primary key or another column, if allowed.
755
  def get_record
2✔
756
    return @record if @record
76✔
757

758
    find_by_key = self.class.model.primary_key
76✔
759
    is_pk = true
76✔
760

761
    # Find by another column if it's permitted.
762
    if find_by_param = self.class.find_by_query_param.presence
76✔
763
      if find_by = params[find_by_param].presence
76✔
NEW
764
        find_by_fields = self.class.find_by_fields&.map(&:to_s)
×
765

NEW
766
        if !find_by_fields || find_by.in?(find_by_fields)
×
NEW
767
          is_pk = false unless find_by_key == find_by
×
NEW
768
          find_by_key = find_by
×
769
        end
770
      end
771
    end
772

773
    # Get the recordset, filtering if configured.
774
    collection = if self.class.filter_recordset_before_find
76✔
775
      self.get_records
76✔
776
    else
NEW
777
      self.get_recordset
×
778
    end
779

780
    # Return the record. Route key is always `:id` by Rails' convention.
781
    if is_pk
76✔
782
      @record = collection.find(request.path_parameters[:id])
76✔
783
    else
NEW
784
      @record = collection.find_by!(find_by_key => request.path_parameters[:id])
×
785
    end
786
  end
787

788
  # Determine what collection to call `create` on.
789
  def create_from
2✔
790
    if self.class.create_from_recordset
32✔
791
      # Create with any properties inherited from the recordset. We exclude any `select` clauses
792
      # in case model callbacks need to call `count` on this collection, which typically raises a
793
      # SQL `SyntaxError`.
794
      self.get_recordset.except(:select)
30✔
795
    else
796
      # Otherwise, perform a "bare" insert_all.
797
      self.class.model
2✔
798
    end
799
  end
800
end
801

802
require_relative "controller/bulk"
2✔
803
require_relative "controller/crud"
2✔
804
require_relative "controller/openapi"
2✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc