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

gregschmit / rails-rest-framework / 30497225596

29 Jul 2026 10:45PM UTC coverage: 94.303% (+2.3%) from 91.975%
30497225596

Pull #40

github

gregschmit
Sanitize StatementInvalid error messages.
Pull Request #40: v2

212 of 217 new or added lines in 8 files covered. (97.7%)

1 existing line in 1 file now uncovered.

1225 of 1299 relevant lines covered (94.3%)

335.27 hits per line

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

91.74
/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
    singular: nil,
2✔
7

8
    # Options related to metadata and display.
9
    title: nil,
10
    description: nil,
11
    version: nil,
12
    inflect_acronyms: RESTFramework.config.inflect_acronyms,
13
    openapi_include_children: false,
14

15
    # Options related to models.
16
    model: nil,
17
    recordset: nil,
18

19
    # Bulk configuration.
20
    #
21
    # When `bulk` is truthy, it enables the default bulk behavior (`:default`), which is per-record
22
    # processing (e.g., `create` for each record). When `bulk` is set to `:raw`, it enables single
23
    # SQL query behavior (e.g., `insert_all` for bulk create) which skips validations/callbacks.
24
    bulk: false,
25
    bulk_partial: false,
26
    bulk_partial_query_param: "bulk_partial".freeze,
27
    bulk_allow_mode_override: false,
28
    bulk_mode_query_param: "bulk_mode".freeze,
29
    bulk_max_size: nil,
30
    bulk_max_raw_size: nil,
31

32
    # Configuring record fields.
33
    fields: nil,
34
    field_config: nil,
35
    read_only_fields: RESTFramework.config.read_only_fields,
36
    write_only_fields: RESTFramework.config.write_only_fields,
37
    hidden_fields: nil,
38

39
    # Finding records.
40
    find_by_fields: nil,
41
    find_by_query_param: "find_by".freeze,
42

43
    # What should be included/excluded from default fields.
44
    exclude_associations: false,
45

46
    # Handling request body parameters.
47
    allowed_parameters: nil,
48

49
    # Options for the default native serializer.
50
    native_serializer_config: nil,
51
    native_serializer_singular_config: nil,
52
    native_serializer_plural_config: nil,
53
    native_serializer_only_query_param: "only".freeze,
54
    native_serializer_except_query_param: "except".freeze,
55
    native_serializer_include_query_param: "include".freeze,
56
    native_serializer_exclude_query_param: "exclude".freeze,
57
    native_serializer_associations_limit: 5,
58
    native_serializer_associations_limit_max: 10,
59
    native_serializer_associations_limit_query_param: "associations_limit".freeze,
60
    native_serializer_include_associations_count: false,
61

62
    # Options for filtering, ordering, and searching.
63
    filter_backends: [
64
      RESTFramework::QueryFilter,
65
      RESTFramework::OrderingFilter,
66
      RESTFramework::SearchFilter,
67
    ].freeze,
68
    filter_recordset_before_find: true,
69
    filter_fields: nil,
70
    ordering_fields: nil,
71
    ordering_query_param: "ordering".freeze,
72
    ordering_no_reorder: false,
73
    search_fields: nil,
74
    search_query_param: "search".freeze,
75
    search_ilike: false,
76
    ransack_options: nil,
77
    ransack_query_param: "q".freeze,
78
    ransack_distinct: true,
79
    ransack_distinct_query_param: "distinct".freeze,
80

81
    # Options for association assignment.
82
    permit_id_assignment: true,
83
    permit_nested_attributes_assignment: true,
84

85
    # Option for `recordset.create` vs `Model.create` behavior.
86
    create_from_recordset: true,
87

88
    # Options related to serialization.
89
    rescue_unknown_format_with: :json,
90
    serializer_class: nil,
91
    serialize_to_json: true,
92
    serialize_to_xml: true,
93

94
    # Options related to pagination. Pagination is on by default (page-number based) with a capped
95
    # page size, so responses are bounded out of the box; set `paginator_class = nil` to disable.
96
    paginator_class: RESTFramework::PageNumberPaginator,
97
    page_size: 20,
98
    page_query_param: "page".freeze,
99
    page_size_query_param: "page_size".freeze,
100
    max_page_size: 40,
101

102
    # Option to disable serializer adapters by default, mainly introduced because Active Model
103
    # Serializers will do things like serialize `[]` into `{"":[]}`.
104
    disable_adapters_by_default: true,
105

106
    # Custom integrations (reduces serializer performance due to method calls).
107
    enable_action_text: false,
108
    enable_active_storage: false,
109
  }
110

111
  # Exceptions to be rescued and handled by returning a reasonable error response.
112
  RRF_RESCUED_EXCEPTIONS = [
113
    RESTFramework::InvalidBulkParametersError,
2✔
114
    RESTFramework::BulkRecordErrorsError,
115
  ].freeze
116
  RRF_RESCUED_RAILS_EXCEPTIONS = [
117
    ActionController::ParameterMissing,
2✔
118
    ActionController::UnpermittedParameters,
119
    ActionDispatch::Http::Parameters::ParseError,
120
    ActiveRecord::AssociationTypeMismatch,
121
    ActiveRecord::NotNullViolation,
122
    ActiveRecord::RecordNotFound,
123
    ActiveRecord::RecordInvalid,
124
    ActiveRecord::RecordNotSaved,
125
    ActiveRecord::RecordNotDestroyed,
126
    ActiveRecord::RecordNotUnique,
127
    ActiveRecord::StatementInvalid,
128
    ActiveModel::UnknownAttributeError,
129
  ].freeze
130

131
  # Anchored regex with non-greedy content_type match to prevent over-matching on malicious input.
132
  RRF_BASE64_REGEX = /\Adata:([^;]*);base64,(.*)\z/m
2✔
133
  RRF_BASE64_TRANSLATE = ->(field, value) {
2✔
134
    return value unless RRF_BASE64_REGEX.match?(value)
×
135

136
    _, content_type, payload = value.match(RRF_BASE64_REGEX).to_a
×
137
    {
138
      io: StringIO.new(Base64.decode64(payload)),
×
139
      content_type: content_type,
140
      filename: "file_#{field}#{Rack::Mime::MIME_TYPES.invert[content_type]}",
141
    }
142
  }
143
  RRF_ACTIVESTORAGE_KEYS = [ :io, :content_type, :filename, :identify, :key ]
2✔
144

145
  module ClassMethods
2✔
146
    IGNORE_VALIDATORS_WITH_KEYS = [ :if, :unless ].freeze
2✔
147

148
    # Thread-local key toggled by `propagate` while its block runs.
149
    RRF_PROPAGATING_KEY = :rrf_propagating
2✔
150

151
    # Define one or more class-level configuration attributes. Assignments are **local by default**:
152
    #
153
    #   self.x = value               # applies to this controller ONLY; descendants don't inherit it
154
    #   propagate { self.x = value } # applies to this controller AND all descendants
155
    #
156
    # This gives a single, uniform rule (an assignment is local unless wrapped in `propagate`), so
157
    # there's no per-attribute "does this inherit?" knowledge to carry around. Values are stored in
158
    # closures on redefined singleton methods (the same mechanism as `class_attribute`), never in
159
    # instance variables, so there is exactly one interface for configuration: the setter.
160
    #
161
    # Only singleton (class-level) methods are defined, so config never leaks to controller
162
    # instances (which would risk colliding with action methods).
163
    def rrf_class_attribute(*names, default: nil)
2✔
164
      names.each do |name|
3,552✔
165
        # Propagating baseline: every controller sees the default until it's overridden. This lives
166
        # in the propagated module (see `rrf_propagated_module`) rather than directly on the
167
        # singleton class, so a local assignment can coexist with it via `super`.
168
        rrf_propagated_module.define_method(name) { default }
13,630✔
169

170
        # Parity with `class_attribute`, which also defines a predicate.
171
        singleton_class.define_method("#{name}?") { !!public_send(name) }
3,560✔
172

173
        singleton_class.define_method("#{name}=") do |value|
3,554✔
174
          if Thread.current[RRF_PROPAGATING_KEY]
204✔
175
            # Propagate: descendants inherit this getter via the module in the singleton chain. It's
176
            # kept separate from any local getter (defined directly on the singleton class) so a
177
            # subsequent local assignment doesn't clobber the value propagated to descendants.
178
            rrf_propagated_module.define_method(name) { value }
5,176✔
179
          else
180
            # Local: `value` for this class only; descendants fall back through `super` to the
181
            # nearest propagated ancestor value, or the default.
182
            klass = self
168✔
183
            singleton_class.define_method(name) do
168✔
184
              if equal?(klass)
7,310✔
185
                value
7,194✔
186
              elsif defined?(super)
116✔
187
                super()
116✔
188
              else
NEW
189
                default
×
190
              end
191
            end
192
          end
193
        end
194
      end
195
    end
196

197
    # The per-class module holding this class's propagated attribute getters (and the default
198
    # baseline). It's included into the singleton class so descendants inherit propagated values
199
    # through the singleton-class chain, while local assignments—defined directly on the singleton
200
    # class—take precedence for the class itself and can `super()` back into this module. Created
201
    # lazily and memoized per class (instance variables aren't inherited, so each class gets its
202
    # own).
203
    def rrf_propagated_module
2✔
204
      @rrf_propagated_module ||= Module.new.tap { |mod| singleton_class.include(mod) }
3,676✔
205
    end
206

207
    # Run a block in which configuration setters (`self.x = value`) propagate to descendant
208
    # controllers instead of applying locally. Use this on a shared base controller for settings you
209
    # want every subclass to inherit:
210
    #
211
    #   propagate do
212
    #     self.paginator_class = RESTFramework::PageNumberPaginator
213
    #     self.page_size = 30
214
    #   end
215
    def propagate
2✔
216
      previous = Thread.current[RRF_PROPAGATING_KEY]
18✔
217
      Thread.current[RRF_PROPAGATING_KEY] = true
18✔
218
      yield
18✔
219
    ensure
220
      Thread.current[RRF_PROPAGATING_KEY] = previous
18✔
221
    end
222

223
    # By default, this is the name of the controller class, titleized and with any custom inflection
224
    # acronyms applied.
225
    def get_title
2✔
226
      self.title || RESTFramework::Utils.inflect(
94✔
227
        self.name.demodulize.chomp("Controller").titleize(keep_id_suffix: true),
228
        self.inflect_acronyms,
229
      )
230
    end
231

232
    # Get a label from a field/column name, titleized and inflected.
233
    def label_for(s)
2✔
234
      default_title = RESTFramework::Utils.inflect(
974✔
235
        s.to_s.titleize(keep_id_suffix: true), self.inflect_acronyms
236
      )
237
      self.model&.human_attribute_name(s, default: default_title) || default_title
974✔
238
    end
239

240
    # Get the available fields. Fallback to this controller's model columns, or an empty array. This
241
    # should always return an array of strings.
242
    def get_fields(input_fields: nil)
2✔
243
      input_fields ||= self.fields
778✔
244

245
      # If fields is a hash, then parse it.
246
      if input_fields.is_a?(Hash)
778✔
247
        return RESTFramework::Utils.parse_fields_hash(
98✔
248
          input_fields,
249
          self.model,
250
          exclude_associations: self.exclude_associations,
251
          action_text: self.enable_action_text,
252
          active_storage: self.enable_active_storage,
253
        )
254
      elsif !input_fields
680✔
255
        # Otherwise, if fields is nil, then fallback to columns.
256
        return self.model ? RESTFramework::Utils.fields_for(
630✔
257
          self.model,
258
          exclude_associations: self.exclude_associations,
259
          action_text: self.enable_action_text,
260
          active_storage: self.enable_active_storage,
261
        ) : []
262
      elsif input_fields
50✔
263
        input_fields = input_fields.map(&:to_s)
50✔
264
      end
265

266
      input_fields
50✔
267
    end
268

269
    # Get a full field configuration, including defaults and inferred values.
270
    def field_configuration
2✔
271
      return @field_configuration if @field_configuration
3,882✔
272

273
      field_config = self.field_config&.with_indifferent_access || {}
34✔
274
      columns = self.model.columns_hash
34✔
275
      column_defaults = self.model.column_defaults
34✔
276
      reflections = self.model.reflections
34✔
277
      attributes = self.model._default_attributes
34✔
278
      readonly_attributes = self.model.readonly_attributes
34✔
279
      read_only_fields = self.read_only_fields&.map(&:to_s)&.to_set || Set[]
34✔
280
      write_only_fields = self.write_only_fields&.map(&:to_s)&.to_set || Set[]
34✔
281
      hidden_fields = self.hidden_fields&.map(&:to_s)&.to_set || Set[]
34✔
282
      rich_text_association_names = self.model.reflect_on_all_associations(:has_one)
34✔
283
        .collect(&:name)
284
        .select { |n| n.to_s.start_with?("rich_text_") }
42✔
285
      attachment_reflections = self.model.attachment_reflections
34✔
286

287
      @field_configuration = self.get_fields.map { |f|
34✔
288
        cfg = field_config[f]&.dup || {}
310✔
289
        cfg[:label] ||= self.label_for(f)
310✔
290

291
        # Annotate primary key.
292
        if self.model.primary_key == f
310✔
293
          cfg[:primary_key] = true
34✔
294

295
          unless cfg.key?(:read_only)
34✔
296
            cfg[:read_only] = true
34✔
297
          end
298
        end
299

300
        # Annotate field mutability and display properties.
301
        cfg[:read_only] = true if f.in?(readonly_attributes) || f.in?(read_only_fields)
310✔
302
        cfg[:write_only] = true if f.in?(write_only_fields)
310✔
303
        cfg[:hidden] = true if f.in?(hidden_fields)
310✔
304

305
        # Raise warnings on some bad combinations of properties.
306
        if cfg[:write_only]
310✔
307
          if cfg[:read_only]
2✔
308
            Rails.logger.warn("RRF: `#{f}` write_only conflicts with read_only.")
×
309
          end
310

311
          if cfg[:hidden]
2✔
312
            Rails.logger.warn("RRF: `#{f}` write_only implies hidden.")
×
313
          end
314

315
          if cfg[:hidden_from_index]
2✔
316
            Rails.logger.warn("RRF: `#{f}` write_only implies hidden_from_index.")
×
317
          end
318
        end
319

320
        # Annotate column data.
321
        if column = columns[f]
310✔
322
          cfg[:kind] = "column"
204✔
323
          cfg[:type] ||= column.type
204✔
324
          cfg[:required] = true unless column.null
204✔
325
        end
326

327
        # Add default values from the model's schema.
328
        if cfg[:default].nil? && (column_default = column_defaults[f])
310✔
329
          cfg[:default] = column_default
76✔
330
        end
331

332
        # Add metadata from the model's attributes hash.
333
        if attributes.key?(f) && attribute = attributes[f]
310✔
334
          if cfg[:default].nil? && default = attribute.value_before_type_cast
204✔
335
            cfg[:default] = default
×
336
          end
337
          cfg[:kind] ||= "attribute"
204✔
338

339
          # Get any type information from the attribute.
340
          if type = attribute.type
204✔
341
            cfg[:type] ||= type.type if type.type
204✔
342

343
            # Get enum variants.
344
            if type.is_a?(ActiveRecord::Enum::EnumType)
204✔
345
              cfg[:enum_variants] = type.send(:mapping)
10✔
346

347
              # TranslateEnum Integration:
348
              translate_method = "translated_#{f.pluralize}"
10✔
349
              if self.model.respond_to?(translate_method)
10✔
350
                cfg[:enum_translations] = self.model.send(translate_method)
10✔
351
              end
352
            end
353
          end
354
        end
355

356
        # Get association metadata.
357
        if ref = reflections[f]
310✔
358
          cfg[:kind] = "association"
86✔
359

360
          # Determine sub-fields for associations.
361
          if ref.polymorphic?
86✔
362
            ref_columns = {}
×
363
          else
364
            ref_columns = ref.klass.columns_hash
86✔
365
          end
366
          cfg[:sub_fields] ||= RESTFramework::Utils.sub_fields_for(ref)
86✔
367
          cfg[:sub_fields] = cfg[:sub_fields].map(&:to_s)
86✔
368

369
          # Very basic metadata about sub-fields.
370
          cfg[:sub_fields_metadata] = cfg[:sub_fields].map { |sf|
86✔
371
            v = {}
164✔
372

373
            if ref_columns[sf]
164✔
374
              v[:kind] = "column"
164✔
375
            else
376
              v[:kind] = "method"
×
377
            end
378

379
            next [ sf, v ]
164✔
380
          }.to_h.compact.presence
381

382
          # Determine if we render id/ids fields. Unfortunately, `has_one` does not provide this
383
          # interface.
384
          if self.permit_id_assignment && id_field = RESTFramework::Utils.id_field_for(f, ref)
86✔
385
            cfg[:id_field] = id_field
72✔
386
          end
387

388
          # Determine if we render nested attributes options.
389
          if self.permit_nested_attributes_assignment && (
86✔
390
            nested_opts = self.model.nested_attributes_options[f.to_sym].presence
86✔
391
          )
392
            cfg[:nested_attributes_options] = { field: "#{f}_attributes", **nested_opts }
30✔
393
          end
394

395
          begin
396
            cfg[:association_pk] = ref.active_record_primary_key
86✔
397
          rescue ActiveRecord::UnknownPrimaryKey
398
          end
399

400
          cfg[:reflection] = ref
86✔
401
        end
402

403
        # Determine if this is an ActionText "rich text".
404
        if :"rich_text_#{f}".in?(rich_text_association_names)
310✔
405
          cfg[:kind] = "rich_text"
4✔
406
        end
407

408
        # Determine if this is an ActiveStorage attachment.
409
        if ref = attachment_reflections[f]
310✔
410
          cfg[:kind] = "attachment"
8✔
411
          cfg[:attachment_type] = ref.macro
8✔
412
        end
413

414
        # Determine if this is just a method.
415
        if !cfg[:kind] && self.model.method_defined?(f)
310✔
416
          cfg[:kind] = "method"
6✔
417
          cfg[:read_only] = true if cfg[:read_only].nil?
6✔
418
        end
419

420
        # Collect validator options into a hash on their type, while also updating `required` based
421
        # on any presence validators.
422
        self.model.validators_on(f).each do |validator|
310✔
423
          kind = validator.kind
90✔
424
          options = validator.options
90✔
425

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

430
          # Update `required` if we find a presence validator.
431
          cfg[:required] = true if kind == :presence
90✔
432

433
          # Resolve procs (and lambdas), and symbols for certain arguments.
434
          if options[:in].is_a?(Proc)
90✔
435
            options = options.merge(in: options[:in].call)
×
436
          elsif options[:in].is_a?(Symbol)
90✔
437
            options = options.merge(in: self.model.send(options[:in]))
10✔
438
          end
439

440
          cfg[:validators] ||= {}
90✔
441
          cfg[:validators][kind] ||= []
90✔
442
          cfg[:validators][kind] << options
90✔
443
        end
444

445
        next [ f, cfg ]
310✔
446
      }.to_h.compact.with_indifferent_access
447
    end
448
  end
449

450
  def self.included(base)
2✔
451
    return unless base.is_a?(Class)
60✔
452

453
    base.extend(ClassMethods)
60✔
454

455
    # By default, the layout should be set to `rest_framework`.
456
    base.layout("rest_framework")
60✔
457

458
    # Materialize config with `rrf_class_attribute` (local by default) rather than `class_attribute`
459
    # (always inherited).
460
    RRF_BASE_CONFIG.each do |a, default|
60✔
461
      next if base.respond_to?(a)
3,780✔
462

463
      base.rrf_class_attribute(a, default: default)
3,528✔
464
    end
465

466
    # Skip CSRF since this is an API.
467
    begin
468
      base.skip_before_action(:verify_authenticity_token)
60✔
469
    rescue ArgumentError
470
      # The callback may not exist if forgery protection isn't enabled; this is expected.
471
      nil
4✔
472
    end
473

474
    # Handle exceptions.
475
    base.rescue_from(*RRF_RESCUED_EXCEPTIONS, with: :rrf_error_handler)
60✔
476
    base.rescue_from(*RRF_RESCUED_RAILS_EXCEPTIONS, with: :rrf_error_handler)
60✔
477
  end
478

479
  def get_serializer_class
2✔
480
    self.class.serializer_class || RESTFramework::NativeSerializer
222✔
481
  end
482

483
  # Serialize the given data using the `serializer_class`.
484
  def serialize(data, **kwargs)
2✔
485
    RESTFramework::Utils.wrap_ams(self.get_serializer_class).new(
204✔
486
      data, controller: self, **kwargs
487
    ).serialize
488
  end
489

490
  def rrf_error_handler(e)
2✔
491
    status = case e
50✔
492
    when ActiveRecord::RecordNotFound
493
      404
26✔
494
    when RESTFramework::BulkRecordErrorsError
495
      422
4✔
496
    else
497
      400
20✔
498
    end
499

500
    # `StatementInvalid` messages commonly embed SQL fragments and schema details, so don't leak
501
    # them to clients unless backtraces are explicitly enabled.
502
    message = if e.is_a?(ActiveRecord::StatementInvalid) && !RESTFramework.config.show_backtrace
50✔
503
      "Invalid query."
4✔
504
    else
505
      e.message
46✔
506
    end
507

508
    render_api(
50✔
509
      {
510
        message: message,
511
        errors: e.try(:record).try(:errors),
512
        exception: RESTFramework.config.show_backtrace ? e.full_message : nil,
50✔
513
      }.compact,
514
      status: status,
515
    )
516
  end
517

518
  def route_groups
2✔
519
    @route_groups ||= RESTFramework::Utils.get_routes(Rails.application.routes, request)
74✔
520
  end
521

522
  # Render a browsable API for `html` format, along with basic `json`/`xml` formats, and with
523
  # support or passing custom `kwargs` to the underlying `render` calls.
524
  def render_api(payload, **kwargs)
2✔
525
    html_kwargs = kwargs.delete(:html_kwargs) || {}
356✔
526
    json_kwargs = kwargs.delete(:json_kwargs) || {}
356✔
527
    xml_kwargs = kwargs.delete(:xml_kwargs) || {}
356✔
528

529
    # Raise helpful error if payload is nil. Usually this happens when a record is not found (e.g.,
530
    # when passing something like `User.find_by(id: some_id)` to `render_api`). The caller should
531
    # actually be calling `find_by!` to raise ActiveRecord::RecordNotFound and allowing the REST
532
    # framework to catch this error and return an appropriate error response.
533
    if payload.nil?
356✔
534
      raise RESTFramework::NilPassedToRenderAPIError
6✔
535
    end
536

537
    # If `payload` is an `ActiveRecord::Relation` or `ActiveRecord::Base`, then serialize it.
538
    if payload.is_a?(ActiveRecord::Base) || payload.is_a?(ActiveRecord::Relation)
350✔
539
      payload = self.serialize(payload)
120✔
540
    end
541

542
    # Do not use any adapters by default, if configured.
543
    if self.class.disable_adapters_by_default && !kwargs.key?(:adapter)
348✔
544
      kwargs[:adapter] = nil
348✔
545
    end
546

547
    # Flag to track if we had to rescue unknown format.
548
    already_rescued_unknown_format = false
348✔
549

550
    begin
551
      respond_to do |format|
350✔
552
        if payload == ""
350✔
553
          format.json { head(kwargs[:status] || :no_content) } if self.class.serialize_to_json
14✔
554
          format.xml { head(kwargs[:status] || :no_content) } if self.class.serialize_to_xml
14✔
555
        else
556
          format.json {
557
            render(json: payload, **kwargs.merge(json_kwargs))
264✔
558
          } if self.class.serialize_to_json
338✔
559
          format.xml {
560
            render(xml: payload, **kwargs.merge(xml_kwargs))
20✔
561
          } if self.class.serialize_to_xml
338✔
562
          # TODO: possibly support more formats here if supported?
563
        end
564
        format.html {
350✔
565
          @payload = payload
58✔
566
          if payload == ""
58✔
567
            @json_payload = "" if self.class.serialize_to_json
8✔
568
            @xml_payload = "" if self.class.serialize_to_xml
8✔
569
          else
570
            @json_payload = payload.to_json if self.class.serialize_to_json
50✔
571
            @xml_payload = payload.to_xml if self.class.serialize_to_xml
50✔
572
          end
573
          @title ||= self.class.get_title
58✔
574
          @description ||= self.class.description
58✔
575
          self.route_groups
58✔
576
          begin
577
            render(**kwargs.merge(html_kwargs))
58✔
578
          rescue ActionView::MissingTemplate
579
            # A view is not required, so just use `html: ""`.
580
            render(html: "", layout: true, **kwargs.merge(html_kwargs))
58✔
581
          end
582
        }
583
      end
584
    rescue ActionController::UnknownFormat
4✔
585
      if !already_rescued_unknown_format && rescue_format = self.class.rescue_unknown_format_with
4✔
586
        request.format = rescue_format
2✔
587
        already_rescued_unknown_format = true
2✔
588
        retry
2✔
589
      else
590
        raise
2✔
591
      end
592
    end
593
  end
594

595
  def options
2✔
596
    render_api(self.openapi_document)
16✔
597
  end
598

599
  def get_fields
2✔
600
    self.class.get_fields(input_fields: self.class.fields)
744✔
601
  end
602

603
  # Get a hash of strong parameters for the current action.
604
  def get_allowed_parameters
2✔
605
    return @_get_allowed_parameters if defined?(@_get_allowed_parameters)
80✔
606

607
    @_get_allowed_parameters = self.class.allowed_parameters
80✔
608
    return @_get_allowed_parameters if @_get_allowed_parameters
80✔
609

610
    # Assemble strong parameters.
611
    variations = []
80✔
612
    hash_variations = {}
80✔
613
    reflections = self.class.model.reflections
80✔
614
    @_get_allowed_parameters = self.get_fields.map { |f|
80✔
615
      f = f.to_s
664✔
616
      config = self.class.field_configuration[f]
664✔
617

618
      # ActionText Integration:
619
      if self.class.enable_action_text && reflections.key?("rich_text_#{f}")
664✔
620
        next f
34✔
621
      end
622

623
      # ActiveStorage Integration: `has_one_attached`
624
      if self.class.enable_active_storage && reflections.key?("#{f}_attachment")
630✔
625
        hash_variations[f] = RRF_ACTIVESTORAGE_KEYS
34✔
626
        next f
34✔
627
      end
628

629
      # ActiveStorage Integration: `has_many_attached`
630
      if self.class.enable_active_storage && reflections.key?("#{f}_attachments")
596✔
631
        hash_variations[f] = RRF_ACTIVESTORAGE_KEYS
34✔
632
        next nil
34✔
633
      end
634

635
      if config[:reflection]
562✔
636
        # Add `_id`/`_ids` variations for associations.
637
        if id_field = config[:id_field]
190✔
638
          if id_field.ends_with?("_ids")
180✔
639
            hash_variations[id_field] = []
128✔
640
          else
641
            variations << id_field
52✔
642
          end
643
        end
644

645
        # Add `_attributes` variations for associations.
646
        # TODO: Consider adjusting this based on `nested_attributes_options`.
647
        if self.class.permit_nested_attributes_assignment
190✔
648
          hash_variations["#{f}_attributes"] = (
190✔
649
            config[:sub_fields] + [ "_destroy" ]
190✔
650
          )
651
        end
652

653
        # Associations are not allowed to be submitted in their bare form (if they are submitted
654
        # that way, they will be translated to either id/ids or nested attributes assignment).
655
        next nil
190✔
656
      end
657

658
      next f
372✔
659
    }.compact
660
    @_get_allowed_parameters += variations
80✔
661
    @_get_allowed_parameters << hash_variations
80✔
662

663
    @_get_allowed_parameters
80✔
664
  end
665

666
  # Use strong parameters to filter the request body.
667
  def get_body_params(bulk_action: nil)
2✔
668
    data = self.request.request_parameters
80✔
669
    pk = self.class.model&.primary_key
80✔
670
    allowed_params = self.get_allowed_parameters
80✔
671

672
    # Before we filter the data, dynamically dispatch association assignment to either the id/ids
673
    # assignment ActiveRecord API or the nested assignment ActiveRecord API. Note that there is no
674
    # need to check for `permit_id_assignment` or `permit_nested_attributes_assignment` here, since
675
    # that is enforced by strong parameters generated by `get_allowed_parameters`.
676
    if !bulk_action && self.class.model
80✔
677
      self.class.model.reflections.each do |name, ref|
30✔
678
        if payload = data[name]
176✔
679
          if payload.is_a?(Hash) || (payload.is_a?(Array) && payload.all? { |x| x.is_a?(Hash) })
4✔
680
            # Assume nested attributes assignment.
681
            attributes_key = "#{name}_attributes"
2✔
682
            data[attributes_key] = data.delete(name) unless data[attributes_key]
2✔
683
          elsif id_field = RESTFramework::Utils.id_field_for(name, ref)
2✔
684
            # Assume id/ids assignment.
685
            data[id_field] = data.delete(name) unless data[id_field]
2✔
686
          end
687
        end
688
      end
689
    end
690

691
    # ActiveStorage Integration: Translate base64 encoded attachments to upload objects.
692
    #
693
    # rubocop:disable Layout/LineLength
694
    #
695
    # Example base64 images (red, green, and blue squares):
696
    #   data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC
697
    #   data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mNk+M9Qz0AEYBxVSF+FAAhKDveksOjmAAAAAElFTkSuQmCC
698
    #   data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mNkYPhfz0AEYBxVSF+FAP5FDvcfRYWgAAAAAElFTkSuQmCC
699
    #
700
    # rubocop:enable Layout/LineLength
701
    has_many_attached_scalar_data = {}
80✔
702
    if !bulk_action && self.class.enable_active_storage && self.class.model
80✔
703
      self.class.model.attachment_reflections.keys.each do |k|
30✔
704
        if data[k].is_a?(Array)
20✔
705
          data[k] = data[k].map { |v|
×
706
            if v.is_a?(String)
×
707
              v = RRF_BASE64_TRANSLATE.call(k, v)
×
708

709
              # Remember scalars because Rails strong params will remove it.
710
              if v.is_a?(String)
×
711
                has_many_attached_scalar_data[k] ||= []
×
712
                has_many_attached_scalar_data[k] << v
×
713
              end
714
            elsif v.is_a?(Hash)
×
715
              if v[:io].is_a?(String)
×
716
                v[:io] = StringIO.new(Base64.decode64(v[:io]))
×
717
              end
718
            end
719

720
            next v
×
721
          }
722
        elsif data[k].is_a?(Hash)
20✔
723
          if data[k][:io].is_a?(String)
×
724
            data[k][:io] = StringIO.new(Base64.decode64(data[k][:io]))
×
725
          end
726
        elsif data[k].is_a?(String)
20✔
727
          data[k] = RRF_BASE64_TRANSLATE.call(k, data[k])
×
728
        end
729
      end
730
    end
731

732
    # Filter the request body with strong params. If `bulk` is true, then we apply allowed
733
    # parameters to the `_json` key of the request body.
734
    body_params = if allowed_params == true
80✔
735
      ActionController::Parameters.new(data).permit!
×
736
    elsif bulk_action
80✔
737
      if bulk_action == :create
50✔
738
        ActionController::Parameters.new(data).permit({ _json: allowed_params })
20✔
739
      elsif bulk_action == :update
30✔
740
        ActionController::Parameters.new(data).permit({ _json: allowed_params + [ pk ] })
18✔
741
      elsif bulk_action == :destroy
12✔
742
        ActionController::Parameters.new(data).permit({ _json: [] })
12✔
743
      else
744
        raise ArgumentError, "Invalid bulk action: #{bulk_action}"
×
745
      end
746
    else
747
      ActionController::Parameters.new(data).permit(*allowed_params)
30✔
748
    end
749

750
    # ActiveStorage Integration: Workaround for Rails strong params not allowing you to permit an
751
    # array containing a mix of scalars and hashes. This is needed for `has_many_attached`, because
752
    # API consumers must be able to provide scalar `signed_id` values for existing attachments along
753
    # with hashes for new attachments. It's worth mentioning that base64 scalars are converted to
754
    # hashes that conform to the ActiveStorage API.
755
    has_many_attached_scalar_data.each do |k, v|
80✔
756
      body_params[k].unshift(*v)
×
757
    end
758

759
    # Filter read-only fields. For bulk actions the permitted structure is `{ _json: [...] }`, so we
760
    # strip read-only keys from each element rather than the top-level hash (whose only key is
761
    # `_json`). Bulk update keeps the primary key, which it needs to locate each record.
762
    if bulk_action
80✔
763
      keep = bulk_action == :update ? [ pk.to_s ] : []
50✔
764
      body_params[:_json]&.each do |element|
50✔
765
        next unless element.is_a?(ActionController::Parameters)
86✔
766

767
        self._rrf_strip_read_only_fields(element, keep: keep)
66✔
768
      end
769
    else
770
      self._rrf_strip_read_only_fields(body_params)
30✔
771
    end
772

773
    body_params
80✔
774
  end
775
  alias_method :get_create_params, :get_body_params
2✔
776
  alias_method :get_update_params, :get_body_params
2✔
777
  alias_method :get_destroy_params, :get_body_params
2✔
778

779
  # Remove read-only fields from a permitted params hash in place. `keep` lists field names to
780
  # preserve even when read-only (e.g. the primary key on bulk update, used to locate records).
781
  def _rrf_strip_read_only_fields(params, keep: [])
2✔
782
    params.delete_if do |f, _|
96✔
783
      next false if f.in?(keep)
144✔
784

785
      cfg = self.class.field_configuration[f]
118✔
786
      cfg && cfg[:read_only]
118✔
787
    end
788
  end
789

790
  # Get the set of records this controller has access to.
791
  def get_recordset
2✔
792
    return self.class.recordset if self.class.recordset
272✔
793

794
    # If there is a model, return that model's default scope (all records by default).
795
    if self.class.model
272✔
796
      return self.class.model.all
272✔
797
    end
798

799
    nil
800
  end
801

802
  # Filter the recordset and return records this request has access to.
803
  def get_records
2✔
804
    data = self.get_recordset
208✔
805

806
    @records ||= self.class.filter_backends&.reduce(data) { |d, filter|
208✔
807
      filter.new(controller: self).filter_data(d)
782✔
808
    } || data
809
  end
810

811
  # Get a single record by primary key or another column, if allowed.
812
  def get_record
2✔
813
    return @record if @record
70✔
814

815
    find_by_key = self.class.model.primary_key
70✔
816
    is_pk = true
70✔
817

818
    # Find by another column if it's permitted.
819
    if find_by_param = self.class.find_by_query_param.presence
70✔
820
      if find_by = request.query_parameters[find_by_param].presence
70✔
821
        find_by_fields = (
822
          self.class.find_by_fields&.map(&:to_s) || self.class.model.columns_hash.keys
6✔
823
        )
824

825
        # A `find_by` was explicitly requested, so it must be a permitted field.
826
        raise ActiveRecord::RecordNotFound unless find_by.in?(find_by_fields)
6✔
827

828
        is_pk = false unless find_by_key == find_by
4✔
829
        find_by_key = find_by
4✔
830
      end
831
    end
832

833
    # Get the recordset, filtering if configured.
834
    collection = if self.class.filter_recordset_before_find
68✔
835
      self.get_records
64✔
836
    else
837
      self.get_recordset
4✔
838
    end
839

840
    # Return the record. Route key is always `:id` by Rails' convention.
841
    if is_pk
68✔
842
      @record = collection.find(request.path_parameters[:id])
64✔
843
    else
844
      @record = collection.find_by!(find_by_key => request.path_parameters[:id])
4✔
845
    end
846
  end
847

848
  # Determine what collection to call `create` on.
849
  def create_from
2✔
850
    if self.class.create_from_recordset
42✔
851
      # Create with any properties inherited from the recordset. We exclude any `select` clauses
852
      # in case model callbacks need to call `count` on this collection, which typically raises a
853
      # SQL `SyntaxError`.
854
      self.get_recordset.except(:select)
40✔
855
    else
856
      # Otherwise, perform a "bare" insert_all.
857
      self.class.model
2✔
858
    end
859
  end
860
end
861

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

© 2026 Coveralls, Inc