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

gregschmit / rails-rest-framework / 24615084524

18 Apr 2026 10:14PM UTC coverage: 87.679% (-0.5%) from 88.164%
24615084524

push

github

gregschmit
Fix rubocop and tests.

2 of 2 new or added lines in 1 file covered. (100.0%)

74 existing lines in 7 files now uncovered.

1103 of 1258 relevant lines covered (87.68%)

205.64 hits per line

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

82.28
/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

100
  # Anchored regex with non-greedy content_type match to prevent over-matching on malicious input.
101
  BASE64_REGEX = /\Adata:([^;]*);base64,(.*)\z/m
2✔
102
  BASE64_TRANSLATE = ->(field, value) {
2✔
103
    return value unless BASE64_REGEX.match?(value)
×
104

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

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

119
  module ClassMethods
2✔
120
    IGNORE_VALIDATORS_WITH_KEYS = [ :if, :unless ].freeze
2✔
121

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

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

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

149
      self.setup_delegation if self.model
150
      # self.setup_channel if self.model
151
    end
152
    # :nocov:
153

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

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

180
      input_fields
26✔
181
    end
182

183
    # Get a full field configuration, including defaults and inferred values.
184
    def field_configuration
2✔
185
      return @field_configuration if @field_configuration
3,394✔
186

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

287
            if ref_columns[sf]
126✔
288
              v[:kind] = "column"
126✔
289
            else
UNCOV
290
              v[:kind] = "method"
×
291
            end
292

293
            next [ sf, v ]
126✔
294
          }.to_h.compact.presence
295

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

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

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

314
          cfg[:reflection] = ref
66✔
315
        end
316

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

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

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

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

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

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

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

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

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

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

370
        self.define_method(action) do
×
UNCOV
371
          if self.class.model.method(action).parameters.last&.first == :keyrest
×
372
            render(api: self.class.model.send(action, **request.query_parameters.symbolize_keys))
×
373
          else
UNCOV
374
            render(api: self.class.model.send(action))
×
375
          end
376
        end
377
      end
378

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

UNCOV
384
        self.define_method(action) do
×
385
          record = self.get_record
×
386

UNCOV
387
          if record.method(action).parameters.last&.first == :keyrest
×
388
            render(api: record.send(action, **request.query_parameters.symbolize_keys))
×
389
          else
UNCOV
390
            render(api: record.send(action))
×
391
          end
392
        end
393
      end
394
    end
395
  end
396

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

400
    base.extend(ClassMethods)
18✔
401

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

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

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

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

419
    # Skip CSRF since this is an API.
420
    begin
421
      base.skip_before_action(:verify_authenticity_token)
18✔
422
    rescue ArgumentError
423
      # The callback may not exist if forgery protection isn't enabled; this is expected.
424
      nil
10✔
425
    end
426

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

445
    # Always handle some framework-specific errors.
446
    base.rescue_from(RESTFramework::InvalidBulkParametersError, with: :rrf_error_handler)
18✔
447

448
    # Use `TracePoint` hook to automatically call `rrf_finalize`.
449
    if RESTFramework.config.auto_finalize
18✔
450
      # :nocov:
451
      TracePoint.trace(:end) do |t|
✔
452
        next if base != t.self
453

454
        base.rrf_finalize
455

456
        # It's important to disable the trace once we've found the end of the base class definition,
457
        # for performance.
458
        t.disable
459
      end
460
      # :nocov:
461
    end
462
  end
463

464
  def get_serializer_class
2✔
465
    self.class.serializer_class || RESTFramework::NativeSerializer
170✔
466
  end
467

468
  # Serialize the given data using the `serializer_class`.
469
  def serialize(data, **kwargs)
2✔
470
    RESTFramework::Utils.wrap_ams(self.get_serializer_class).new(
170✔
471
      data, controller: self, **kwargs
472
    ).serialize
473
  end
474

475
  def rrf_error_handler(e)
2✔
476
    status = case e
30✔
477
    when ActiveRecord::RecordNotFound
478
      404
26✔
479
    else
480
      400
4✔
481
    end
482

483
    render(
30✔
484
      api: {
485
        message: e.message,
486
        errors: e.try(:record).try(:errors),
487
        exception: RESTFramework.config.show_backtrace ? e.full_message : nil,
30✔
488
      }.compact,
489
      status: status,
490
    )
491
  end
492

493
  def route_groups
2✔
494
    @route_groups ||= RESTFramework::Utils.get_routes(Rails.application.routes, request)
68✔
495
  end
496

497
  # Render a browsable API for `html` format, along with basic `json`/`xml` formats, and with
498
  # support or passing custom `kwargs` to the underlying `render` calls.
499
  def render_api(payload, **kwargs)
2✔
500
    html_kwargs = kwargs.delete(:html_kwargs) || {}
260✔
501
    json_kwargs = kwargs.delete(:json_kwargs) || {}
260✔
502
    xml_kwargs = kwargs.delete(:xml_kwargs) || {}
260✔
503

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

512
    # If `payload` is an `ActiveRecord::Relation` or `ActiveRecord::Base`, then serialize it.
513
    if payload.is_a?(ActiveRecord::Base) || payload.is_a?(ActiveRecord::Relation)
254✔
514
      payload = self.serialize(payload)
132✔
515
    end
516

517
    # Do not use any adapters by default, if configured.
518
    if self.class.disable_adapters_by_default && !kwargs.key?(:adapter)
254✔
519
      kwargs[:adapter] = nil
254✔
520
    end
521

522
    # Flag to track if we had to rescue unknown format.
523
    already_rescued_unknown_format = false
254✔
524

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

570
  # Deprecated alias for `render_api`.
571
  def api_response(*args, **kwargs)
2✔
UNCOV
572
    RESTFramework.deprecator.warn("`api_response` is deprecated; use `render_api` instead.")
×
UNCOV
573
    render_api(*args, **kwargs)
×
574
  end
575

576
  def options
2✔
577
    render(api: self.openapi_document)
8✔
578
  end
579

580
  def get_fields
2✔
581
    self.class.get_fields(input_fields: self.class.fields)
598✔
582
  end
583

584
  # Get a hash of strong parameters for the current action.
585
  def get_allowed_parameters
2✔
586
    return @_get_allowed_parameters if defined?(@_get_allowed_parameters)
46✔
587

588
    @_get_allowed_parameters = self.class.allowed_parameters
46✔
589
    return @_get_allowed_parameters if @_get_allowed_parameters
46✔
590

591
    # Assemble strong parameters.
592
    variations = []
46✔
593
    hash_variations = {}
46✔
594
    reflections = self.class.model.reflections
46✔
595
    @_get_allowed_parameters = self.get_fields.map { |f|
46✔
596
      f = f.to_s
576✔
597
      config = self.class.field_configuration[f]
576✔
598

599
      # ActionText Integration:
600
      if self.class.enable_action_text && reflections.key?("rich_text_#{f}")
576✔
601
        next f
18✔
602
      end
603

604
      # ActiveStorage Integration: `has_one_attached`
605
      if self.class.enable_active_storage && reflections.key?("#{f}_attachment")
558✔
606
        hash_variations[f] = ACTIVESTORAGE_KEYS
18✔
607
        next f
18✔
608
      end
609

610
      # ActiveStorage Integration: `has_many_attached`
611
      if self.class.enable_active_storage && reflections.key?("#{f}_attachments")
540✔
612
        hash_variations[f] = ACTIVESTORAGE_KEYS
18✔
613
        next nil
18✔
614
      end
615

616
      if config[:reflection]
522✔
617
        # Add `_id`/`_ids` variations for associations.
618
        if id_field = config[:id_field]
174✔
619
          if id_field.ends_with?("_ids")
158✔
620
            hash_variations[id_field] = []
100✔
621
          else
622
            variations << id_field
58✔
623
          end
624
        end
625

626
        # Add `_attributes` variations for associations.
627
        # TODO: Consider adjusting this based on `nested_attributes_options`.
628
        if self.class.permit_nested_attributes_assignment
174✔
629
          hash_variations["#{f}_attributes"] = (
174✔
630
            config[:sub_fields] + [ "_destroy" ]
174✔
631
          )
632
        end
633

634
        # Associations are not allowed to be submitted in their bare form (if they are submitted
635
        # that way, they will be translated to either id/ids or nested attributes assignment).
636
        next nil
174✔
637
      end
638

639
      next f
348✔
640
    }.compact
641
    @_get_allowed_parameters += variations
46✔
642
    @_get_allowed_parameters << hash_variations
46✔
643

644
    @_get_allowed_parameters
46✔
645
  end
646

647
  # Use strong parameters to filter the request body.
648
  def get_body_params(bulk_mode: nil)
2✔
649
    data = self.request.request_parameters
46✔
650
    pk = self.class.model&.primary_key
46✔
651
    allowed_params = self.get_allowed_parameters
46✔
652

653
    # Before we filter the data, dynamically dispatch association assignment to either the id/ids
654
    # assignment ActiveRecord API or the nested assignment ActiveRecord API. Note that there is no
655
    # need to check for `permit_id_assignment` or `permit_nested_attributes_assignment` here, since
656
    # that is enforced by strong parameters generated by `get_allowed_parameters`.
657
    if !bulk_mode && self.class.model
46✔
658
      self.class.model.reflections.each do |name, ref|
36✔
659
        if payload = data[name]
252✔
UNCOV
660
          if payload.is_a?(Hash) || (payload.is_a?(Array) && payload.all? { |x| x.is_a?(Hash) })
×
661
            # Assume nested attributes assignment.
UNCOV
662
            attributes_key = "#{name}_attributes"
×
UNCOV
663
            data[attributes_key] = data.delete(name) unless data[attributes_key]
×
UNCOV
664
          elsif id_field = RESTFramework::Utils.id_field_for(name, ref)
×
665
            # Assume id/ids assignment.
UNCOV
666
            data[id_field] = data.delete(name) unless data[id_field]
×
667
          end
668
        end
669
      end
670
    end
671

672
    # ActiveStorage Integration: Translate base64 encoded attachments to upload objects.
673
    #
674
    # rubocop:disable Layout/LineLength
675
    #
676
    # Example base64 images (red, green, and blue squares):
677
    #   data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC
678
    #   data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mNk+M9Qz0AEYBxVSF+FAAhKDveksOjmAAAAAElFTkSuQmCC
679
    #   data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mNkYPhfz0AEYBxVSF+FAP5FDvcfRYWgAAAAAElFTkSuQmCC
680
    #
681
    # rubocop:enable Layout/LineLength
682
    has_many_attached_scalar_data = {}
46✔
683
    if !bulk_mode && self.class.enable_active_storage && self.class.model
46✔
684
      self.class.model.attachment_reflections.keys.each do |k|
20✔
685
        if data[k].is_a?(Array)
20✔
686
          data[k] = data[k].map { |v|
×
UNCOV
687
            if v.is_a?(String)
×
UNCOV
688
              v = BASE64_TRANSLATE.call(k, v)
×
689

690
              # Remember scalars because Rails strong params will remove it.
UNCOV
691
              if v.is_a?(String)
×
UNCOV
692
                has_many_attached_scalar_data[k] ||= []
×
693
                has_many_attached_scalar_data[k] << v
×
694
              end
UNCOV
695
            elsif v.is_a?(Hash)
×
UNCOV
696
              if v[:io].is_a?(String)
×
697
                v[:io] = StringIO.new(Base64.decode64(v[:io]))
×
698
              end
699
            end
700

UNCOV
701
            next v
×
702
          }
703
        elsif data[k].is_a?(Hash)
20✔
UNCOV
704
          if data[k][:io].is_a?(String)
×
705
            data[k][:io] = StringIO.new(Base64.decode64(data[k][:io]))
×
706
          end
707
        elsif data[k].is_a?(String)
20✔
UNCOV
708
          data[k] = BASE64_TRANSLATE.call(k, data[k])
×
709
        end
710
      end
711
    end
712

713
    # Filter the request body with strong params. If `bulk` is true, then we apply allowed
714
    # parameters to the `_json` key of the request body.
715
    body_params = if allowed_params == true
46✔
UNCOV
716
      ActionController::Parameters.new(data).permit!
×
717
    elsif bulk_mode
46✔
718
      if bulk_mode == :update
10✔
719
        ActionController::Parameters.new(data).permit({ _json: allowed_params + [ pk ] })
4✔
720
      elsif bulk_mode == :destroy
6✔
721
        ActionController::Parameters.new(data).permit({ _json: [] })
2✔
722
      else
723
        ActionController::Parameters.new(data).permit({ _json: allowed_params })
4✔
724
      end
725
    else
726
      ActionController::Parameters.new(data).permit(*allowed_params)
36✔
727
    end
728

729
    # ActiveStorage Integration: Workaround for Rails strong params not allowing you to permit an
730
    # array containing a mix of scalars and hashes. This is needed for `has_many_attached`, because
731
    # API consumers must be able to provide scalar `signed_id` values for existing attachments along
732
    # with hashes for new attachments. It's worth mentioning that base64 scalars are converted to
733
    # hashes that conform to the ActiveStorage API.
734
    has_many_attached_scalar_data.each do |k, v|
46✔
UNCOV
735
      body_params[k].unshift(*v)
×
736
    end
737

738
    # Filter read-only fields.
739
    body_params.delete_if do |f, _|
46✔
740
      cfg = self.class.field_configuration[f]
64✔
741
      cfg && cfg[:read_only]
64✔
742
    end
743

744
    body_params
46✔
745
  end
746
  alias_method :get_create_params, :get_body_params
2✔
747
  alias_method :get_update_params, :get_body_params
2✔
748
  alias_method :get_destroy_params, :get_body_params
2✔
749

750
  # Get the set of records this controller has access to.
751
  def get_recordset
2✔
752
    return self.class.recordset if self.class.recordset
210✔
753

754
    # If there is a model, return that model's default scope (all records by default).
755
    if self.class.model
210✔
756
      return self.class.model.all
210✔
757
    end
758

759
    nil
760
  end
761

762
  # Filter the recordset and return records this request has access to.
763
  def get_records
2✔
764
    data = self.get_recordset
176✔
765

766
    @records ||= self.class.filter_backends&.reduce(data) { |d, filter|
176✔
767
      filter.new(controller: self).filter_data(d)
616✔
768
    } || data
769
  end
770

771
  # Get a single record by primary key or another column, if allowed.
772
  def get_record
2✔
773
    return @record if @record
76✔
774

775
    find_by_key = self.class.model.primary_key
76✔
776
    is_pk = true
76✔
777

778
    # Find by another column if it's permitted.
779
    if find_by_param = self.class.find_by_query_param.presence
76✔
780
      if find_by = params[find_by_param].presence
76✔
UNCOV
781
        find_by_fields = self.class.find_by_fields&.map(&:to_s)
×
782

UNCOV
783
        if !find_by_fields || find_by.in?(find_by_fields)
×
784
          is_pk = false unless find_by_key == find_by
×
UNCOV
785
          find_by_key = find_by
×
786
        end
787
      end
788
    end
789

790
    # Get the recordset, filtering if configured.
791
    collection = if self.class.filter_recordset_before_find
76✔
792
      self.get_records
76✔
793
    else
UNCOV
794
      self.get_recordset
×
795
    end
796

797
    # Return the record. Route key is always `:id` by Rails' convention.
798
    if is_pk
76✔
799
      @record = collection.find(request.path_parameters[:id])
76✔
800
    else
UNCOV
801
      @record = collection.find_by!(find_by_key => request.path_parameters[:id])
×
802
    end
803
  end
804

805
  # Determine what collection to call `create` on.
806
  def create_from
2✔
807
    if self.class.create_from_recordset
32✔
808
      # Create with any properties inherited from the recordset. We exclude any `select` clauses
809
      # in case model callbacks need to call `count` on this collection, which typically raises a
810
      # SQL `SyntaxError`.
811
      self.get_recordset.except(:select)
30✔
812
    else
813
      # Otherwise, perform a "bare" insert_all.
814
      self.class.model
2✔
815
    end
816
  end
817
end
818

819
require_relative "controller/bulk"
2✔
820
require_relative "controller/crud"
2✔
821
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