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

gregschmit / rails-rest-framework / 30846681115

03 Aug 2026 07:38PM UTC coverage: 94.982% (+0.1%) from 94.866%
30846681115

push

github

gregschmit
Fix test typo.

1344 of 1415 relevant lines covered (94.98%)

490.22 hits per line

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

92.99
/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
    model: nil,
2✔
7
    singular: nil,
8

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

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

29
    # Configuring record fields.
30
    fields: nil,
31
    field_config: nil,
32
    read_only_fields: RESTFramework.config.read_only_fields,
33
    write_only_fields: RESTFramework.config.write_only_fields,
34
    hidden_fields: nil,
35

36
    # Finding records.
37
    find_by_fields: nil,
38
    find_by_query_param: "find_by".freeze,
39

40
    # Handling request body parameters.
41
    allowed_parameters: nil,
42

43
    # Options for the default native serializer.
44
    native_serializer_config: nil,
45
    native_serializer_singular_config: nil,
46
    native_serializer_plural_config: nil,
47
    native_serializer_only_query_param: "only".freeze,
48
    native_serializer_except_query_param: "except".freeze,
49
    native_serializer_include_query_param: "include".freeze,
50
    native_serializer_exclude_query_param: "exclude".freeze,
51

52
    # Options for including associations and collection counts.
53
    exclude_associations: false,
54
    include_association_count: false,
55

56
    # The number of records serialized per collection association, so responses are bounded out of
57
    # the box (`nil` = unlimited). With `enable_association_queries`, a client can raise it for a
58
    # given association via `?<prefix>.<name>.limit=N` or `limit=all` (`none`/`0` are aliases), both
59
    # capped at `association_limit_max` (the "all" forms yield the cap). Set the max to `nil` to let
60
    # a client request unlimited records.
61
    association_limit: 10,
62
    association_limit_max: 100,
63

64
    # Let clients request extra fields for a serialized association via
65
    # `?<prefix>.<association>.fields=a,b,c`. The allowlist keeps an association from ever exposing
66
    # more than its own endpoint would: an explicit per-association `requestable_fields` in
67
    # `field_config`, else the fields the associated model's sibling controller serializes.
68
    # Off/secure by default.
69
    enable_association_queries: false,
70
    association_query_prefix: "associations".freeze,
71

72
    # Options for filtering, ordering, and searching.
73
    filter_backends: [
74
      RESTFramework::QueryFilter,
75
      RESTFramework::OrderingFilter,
76
      RESTFramework::SearchFilter,
77
    ].freeze,
78
    filter_recordset_before_find: true,
79
    filter_fields: nil,
80
    ordering_fields: nil,
81
    ordering_query_param: "ordering".freeze,
82
    ordering_no_reorder: false,
83
    search_fields: nil,
84
    search_query_param: "search".freeze,
85
    search_ilike: false,
86
    ransack_options: nil,
87
    ransack_query_param: "q".freeze,
88
    ransack_distinct: true,
89
    ransack_distinct_query_param: "distinct".freeze,
90

91
    # Options for association assignment.
92
    permit_id_assignment: true,
93
    permit_nested_attributes_assignment: true,
94

95
    # Option for `recordset.create` vs `Model.create` behavior.
96
    create_from_recordset: true,
97

98
    # Options for scoped nested routing.
99
    scope_nested_by_parent: true,
100
    scope_nested_through_controllers: true,
101

102
    # Options related to serialization.
103
    rescue_unknown_format_with: :json,
104
    serializer_class: nil,
105
    serialize_to_json: true,
106
    serialize_to_xml: true,
107

108
    # Options related to pagination. Pagination is on by default (page-number based) with a capped
109
    # page size, so responses are bounded out of the box; set `paginator_class = nil` to disable.
110
    paginator_class: RESTFramework::PageNumberPaginator,
111
    page_size: 20,
112
    page_query_param: "page".freeze,
113
    page_size_query_param: "page_size".freeze,
114
    max_page_size: 40,
115
    # Whether the page-number paginator computes the total record count to report `count` and
116
    # `total_pages`. Set to `false` on large tables to skip that query.
117
    page_total_count: true,
118

119
    # Option to disable serializer adapters by default, mainly introduced because Active Model
120
    # Serializers will do things like serialize `[]` into `{"":[]}`.
121
    disable_adapters_by_default: true,
122

123
    # Custom integrations (reduces serializer performance due to method calls).
124
    enable_action_text: false,
125
    enable_active_storage: false,
126
  }
127

128
  # Exceptions to be rescued and handled by returning a reasonable error response.
129
  RRF_RESCUED_EXCEPTIONS = [
130
    RESTFramework::InvalidBulkParametersError,
2✔
131
    RESTFramework::BulkRecordErrorsError,
132
  ].freeze
133
  RRF_RESCUED_RAILS_EXCEPTIONS = [
134
    ActionController::ParameterMissing,
2✔
135
    ActionController::UnpermittedParameters,
136
    ActionDispatch::Http::Parameters::ParseError,
137
    ActiveRecord::AssociationTypeMismatch,
138
    ActiveRecord::NotNullViolation,
139
    ActiveRecord::RecordNotFound,
140
    ActiveRecord::RecordInvalid,
141
    ActiveRecord::RecordNotSaved,
142
    ActiveRecord::RecordNotDestroyed,
143
    ActiveRecord::RecordNotUnique,
144
    ActiveRecord::StatementInvalid,
145
    ActiveModel::UnknownAttributeError,
146
  ].freeze
147

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

153
    _, content_type, payload = value.match(RRF_BASE64_REGEX).to_a
×
154
    {
155
      io: StringIO.new(Base64.decode64(payload)),
×
156
      content_type: content_type,
157
      filename: "file_#{field}#{Rack::Mime::MIME_TYPES.invert[content_type]}",
158
    }
159
  }
160
  RRF_ACTIVESTORAGE_KEYS = [ :io, :content_type, :filename, :identify, :key ]
2✔
161

162
  module ClassMethods
2✔
163
    IGNORE_VALIDATORS_WITH_KEYS = [ :if, :unless ].freeze
2✔
164

165
    # Thread-local key toggled by `propagate` while its block runs.
166
    RRF_PROPAGATING_KEY = :rrf_propagating
2✔
167

168
    # Define one or more class-level configuration attributes. Assignments are **local by default**:
169
    #
170
    #   self.x = value               # applies to this controller ONLY; descendants don't inherit it
171
    #   propagate { self.x = value } # applies to this controller AND all descendants
172
    #
173
    # This gives a single, uniform rule (an assignment is local unless wrapped in `propagate`), so
174
    # there's no per-attribute "does this inherit?" knowledge to carry around. Values are stored in
175
    # closures on redefined singleton methods (the same mechanism as `class_attribute`), never in
176
    # instance variables, so there is exactly one interface for configuration: the setter.
177
    #
178
    # Only singleton (class-level) methods are defined, so config never leaks to controller
179
    # instances (which would risk colliding with action methods).
180
    def rrf_class_attribute(*names, default: nil)
2✔
181
      names.each do |name|
3,720✔
182
        # Propagating baseline: every controller sees the default until it's overridden. This lives
183
        # in the propagated module (see `rrf_propagated_module`) rather than directly on the
184
        # singleton class, so a local assignment can coexist with it via `super`.
185
        rrf_propagated_module.define_method(name) { default }
15,270✔
186

187
        # Parity with `class_attribute`, which also defines a predicate.
188
        singleton_class.define_method("#{name}?") { !!public_send(name) }
3,728✔
189

190
        singleton_class.define_method("#{name}=") do |value|
3,722✔
191
          if Thread.current[RRF_PROPAGATING_KEY]
326✔
192
            # Propagate: descendants inherit this getter via the module in the singleton chain. It's
193
            # kept separate from any local getter (defined directly on the singleton class) so a
194
            # subsequent local assignment doesn't clobber the value propagated to descendants.
195
            rrf_propagated_module.define_method(name) { value }
4,714✔
196
          else
197
            # Local: `value` for this class only; descendants fall back through `super` to the
198
            # nearest propagated ancestor value, or the default.
199
            klass = self
288✔
200
            singleton_class.define_method(name) do
288✔
201
              if equal?(klass)
8,998✔
202
                value
8,874✔
203
              elsif defined?(super)
124✔
204
                super()
124✔
205
              else
206
                default
×
207
              end
208
            end
209
          end
210
        end
211
      end
212
    end
213

214
    # The per-class module holding this class's propagated attribute getters (and the default
215
    # baseline). It's included into the singleton class so descendants inherit propagated values
216
    # through the singleton-class chain, while local assignments—defined directly on the singleton
217
    # class—take precedence for the class itself and can `super()` back into this module. Created
218
    # lazily and memoized per class (instance variables aren't inherited, so each class gets its
219
    # own).
220
    def rrf_propagated_module
2✔
221
      @rrf_propagated_module ||= Module.new.tap { |mod| singleton_class.include(mod) }
3,846✔
222
    end
223

224
    # Run a block in which configuration setters (`self.x = value`) propagate to descendant
225
    # controllers instead of applying locally. Use this on a shared base controller for settings you
226
    # want every subclass to inherit:
227
    #
228
    #   propagate do
229
    #     self.paginator_class = RESTFramework::PageNumberPaginator
230
    #     self.page_size = 30
231
    #   end
232
    def propagate
2✔
233
      previous = Thread.current[RRF_PROPAGATING_KEY]
18✔
234
      Thread.current[RRF_PROPAGATING_KEY] = true
18✔
235
      yield
18✔
236
    ensure
237
      Thread.current[RRF_PROPAGATING_KEY] = previous
18✔
238
    end
239

240
    # By default, this is the name of the controller class, titleized and with any custom inflection
241
    # acronyms applied.
242
    def get_title
2✔
243
      self.title || RESTFramework::Utils.inflect(
98✔
244
        self.name.demodulize.chomp("Controller").titleize(keep_id_suffix: true),
245
        self.inflect_acronyms,
246
      )
247
    end
248

249
    # Get a label from a field/column name, titleized and inflected.
250
    def label_for(s)
2✔
251
      default_title = RESTFramework::Utils.inflect(
1,342✔
252
        s.to_s.titleize(keep_id_suffix: true), self.inflect_acronyms
253
      )
254
      self.model&.human_attribute_name(s, default: default_title) || default_title
1,342✔
255
    end
256

257
    # Resolve the `fields` config to an array of strings. Memoized, since `fields` and the flags it
258
    # depends on are class-level config fixed at load time.
259
    def get_fields
2✔
260
      @get_fields ||= if self.fields.is_a?(Hash)
1,060✔
261
        RESTFramework::Utils.parse_fields_hash(
10✔
262
          self.fields,
263
          self.model,
264
          exclude_associations: self.exclude_associations,
265
          action_text: self.enable_action_text,
266
          active_storage: self.enable_active_storage,
267
        )
268
      elsif self.fields
60✔
269
        self.fields.map(&:to_s)
34✔
270
      elsif self.model
26✔
271
        RESTFramework::Utils.fields_for(
26✔
272
          self.model,
273
          exclude_associations: self.exclude_associations,
274
          action_text: self.enable_action_text,
275
          active_storage: self.enable_active_storage,
276
        )
277
      else
278
        []
×
279
      end
280
    end
281

282
    # Get a full field configuration, including defaults and inferred values.
283
    def field_configuration
2✔
284
      return @field_configuration if @field_configuration
5,266✔
285

286
      field_config = self.field_config&.with_indifferent_access || {}
70✔
287
      columns = self.model.columns_hash
70✔
288
      column_defaults = self.model.column_defaults
70✔
289
      reflections = self.model.reflections
70✔
290
      attributes = self.model._default_attributes
70✔
291
      readonly_attributes = self.model.readonly_attributes
70✔
292
      read_only_fields = self.read_only_fields&.map(&:to_s)&.to_set || Set[]
70✔
293
      write_only_fields = self.write_only_fields&.map(&:to_s)&.to_set || Set[]
70✔
294
      hidden_fields = self.hidden_fields&.map(&:to_s)&.to_set || Set[]
70✔
295
      rich_text_association_names = self.model.reflect_on_all_associations(:has_one)
70✔
296
        .collect(&:name)
297
        .select { |n| n.to_s.start_with?("rich_text_") }
84✔
298
      attachment_reflections = self.model.attachment_reflections
70✔
299

300
      @field_configuration = self.get_fields.map { |f|
70✔
301
        cfg = field_config[f]&.dup || {}
544✔
302
        cfg[:label] ||= self.label_for(f)
544✔
303

304
        # Annotate primary key.
305
        if self.model.primary_key == f
544✔
306
          cfg[:primary_key] = true
70✔
307

308
          unless cfg.key?(:read_only)
70✔
309
            cfg[:read_only] = true
70✔
310
          end
311
        end
312

313
        # Annotate field mutability and display properties.
314
        cfg[:read_only] = true if f.in?(readonly_attributes) || f.in?(read_only_fields)
544✔
315
        cfg[:write_only] = true if f.in?(write_only_fields)
544✔
316
        cfg[:hidden] = true if f.in?(hidden_fields)
544✔
317

318
        # Raise warnings on some bad combinations of properties.
319
        if cfg[:write_only]
544✔
320
          if cfg[:read_only]
10✔
321
            Rails.logger.warn("RRF: `#{f}` write_only conflicts with read_only.")
×
322
          end
323

324
          if cfg[:hidden]
10✔
325
            Rails.logger.warn("RRF: `#{f}` write_only implies hidden.")
×
326
          end
327

328
          if cfg[:hidden_from_index]
10✔
329
            Rails.logger.warn("RRF: `#{f}` write_only implies hidden_from_index.")
×
330
          end
331
        end
332

333
        # Annotate column data.
334
        if column = columns[f]
544✔
335
          cfg[:kind] = "column"
364✔
336
          cfg[:type] ||= column.type
364✔
337
          cfg[:required] = true unless column.null
364✔
338
        end
339

340
        # Add default values from the model's schema.
341
        if cfg[:default].nil? && (column_default = column_defaults[f])
544✔
342
          cfg[:default] = column_default
144✔
343
        end
344

345
        # Add metadata from the model's attributes hash.
346
        if attributes.key?(f) && attribute = attributes[f]
544✔
347
          if cfg[:default].nil? && default = attribute.value_before_type_cast
364✔
348
            cfg[:default] = default
×
349
          end
350
          cfg[:kind] ||= "attribute"
364✔
351

352
          # Get any type information from the attribute.
353
          if type = attribute.type
364✔
354
            cfg[:type] ||= type.type if type.type
364✔
355

356
            # Get enum variants.
357
            if type.is_a?(ActiveRecord::Enum::EnumType)
364✔
358
              cfg[:enum_variants] = type.send(:mapping)
16✔
359

360
              # TranslateEnum Integration:
361
              translate_method = "translated_#{f.pluralize}"
16✔
362
              if self.model.respond_to?(translate_method)
16✔
363
                cfg[:enum_translations] = self.model.send(translate_method)
16✔
364
              end
365
            end
366
          end
367
        end
368

369
        # Get association metadata.
370
        if ref = reflections[f]
544✔
371
          cfg[:kind] = "association"
154✔
372

373
          # Determine the association's fields.
374
          if ref.polymorphic?
154✔
375
            ref_columns = {}
×
376
          else
377
            ref_columns = ref.klass.columns_hash
154✔
378
          end
379
          cfg[:fields] ||= RESTFramework::Utils.association_fields_for(ref)
154✔
380
          cfg[:fields] = cfg[:fields].map(&:to_s)
154✔
381

382
          # Strings, to match `:fields` when intersecting requested fields against the allowlist.
383
          if cfg[:requestable_fields]
154✔
384
            cfg[:requestable_fields] = cfg[:requestable_fields].map(&:to_s)
2✔
385
          end
386

387
          # Very basic metadata about the association's fields.
388
          cfg[:association_fields_metadata] = cfg[:fields].map { |sf|
154✔
389
            v = {}
296✔
390

391
            if ref_columns[sf]
296✔
392
              v[:kind] = "column"
294✔
393
            else
394
              v[:kind] = "method"
2✔
395
            end
396

397
            next [ sf, v ]
296✔
398
          }.to_h.compact.presence
399

400
          # Determine if we render id/ids fields. Unfortunately, `has_one` does not provide this
401
          # interface.
402
          if self.permit_id_assignment && id_field = RESTFramework::Utils.id_field_for(f, ref)
154✔
403
            cfg[:id_field] = id_field
134✔
404
          end
405

406
          # Determine if we render nested attributes options.
407
          if self.permit_nested_attributes_assignment && (
154✔
408
            nested_opts = self.model.nested_attributes_options[f.to_sym].presence
154✔
409
          )
410
            cfg[:nested_attributes_options] = { field: "#{f}_attributes", **nested_opts }
58✔
411
          end
412

413
          begin
414
            cfg[:association_pk] = ref.active_record_primary_key
154✔
415
          rescue ActiveRecord::UnknownPrimaryKey
416
          end
417

418
          cfg[:reflection] = ref
154✔
419
        end
420

421
        # Determine if this is an ActionText "rich text".
422
        if :"rich_text_#{f}".in?(rich_text_association_names)
544✔
423
          cfg[:kind] = "rich_text"
6✔
424
        end
425

426
        # Determine if this is an ActiveStorage attachment.
427
        if ref = attachment_reflections[f]
544✔
428
          cfg[:kind] = "attachment"
12✔
429
          cfg[:attachment_type] = ref.macro
12✔
430
        end
431

432
        # Determine if this is just a method.
433
        if !cfg[:kind] && self.model.method_defined?(f)
544✔
434
          cfg[:kind] = "method"
6✔
435
          cfg[:read_only] = true if cfg[:read_only].nil?
6✔
436
        end
437

438
        # Collect validator options into a hash on their type, while also updating `required` based
439
        # on any presence validators.
440
        self.model.validators_on(f).each do |validator|
544✔
441
          kind = validator.kind
228✔
442
          options = validator.options
228✔
443

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

448
          # Update `required` if we find a presence validator.
449
          cfg[:required] = true if kind == :presence
228✔
450

451
          cfg[:validators] ||= {}
228✔
452
          cfg[:validators][kind] ||= []
228✔
453
          cfg[:validators][kind] << options
228✔
454
        end
455

456
        next [ f, cfg ]
544✔
457
      }.to_h.compact.with_indifferent_access
458

459
      # Compile each association's requestable-fields allowlist once (see
460
      # `enable_association_queries`). This runs as a second pass, after `@field_configuration` is
461
      # memoized, because resolving a sibling's fields reads its `field_configuration` — and a
462
      # self-referential or mutual association would otherwise recurse into this build.
463
      if self.enable_association_queries
70✔
464
        @field_configuration.each do |_f, cfg|
24✔
465
          next unless cfg[:kind] == "association"
150✔
466

467
          cfg[:requestable_fields] ||= self.association_requestable_fields(cfg[:reflection])
46✔
468
        end
469
      end
470

471
      @field_configuration
70✔
472
    end
473

474
    # The fields a consumer may request for an association beyond its defaults, derived from the
475
    # associated model's sibling controller: what that controller serializes, so the association can
476
    # never expose more than its own endpoint would. Empty unless the sibling is discoverable and
477
    # introspectable — a custom serializer makes its `get_fields` meaningless. Hidden fields are
478
    # included (retrievable via `?only=` there); write-only fields and nested associations aren't.
479
    def association_requestable_fields(ref)
2✔
480
      return [] if ref.polymorphic?
44✔
481

482
      sibling = RESTFramework::Utils.controller_for_model(self, ref.klass)
44✔
483
      return [] unless sibling
44✔
484
      return [] if sibling.serializer_class ||
38✔
485
        sibling.native_serializer_config ||
486
        sibling.native_serializer_singular_config ||
487
        sibling.native_serializer_plural_config
488

489
      cfg = sibling.field_configuration
36✔
490
      sibling.get_fields.reject { |sf|
36✔
491
        c = cfg[sf]
388✔
492
        c.nil? || c[:write_only] || c[:kind] == "association"
388✔
493
      }
494
    end
495
  end
496

497
  def self.included(base)
2✔
498
    return unless base.is_a?(Class)
60✔
499

500
    base.extend(ClassMethods)
60✔
501

502
    # By default, the layout should be set to `rest_framework`.
503
    base.layout("rest_framework")
60✔
504

505
    # Materialize config with `rrf_class_attribute` (local by default) rather than `class_attribute`
506
    # (always inherited).
507
    RRF_BASE_CONFIG.each do |a, default|
60✔
508
      next if base.respond_to?(a)
3,960✔
509

510
      base.rrf_class_attribute(a, default: default)
3,696✔
511
    end
512

513
    # Skip CSRF since this is an API.
514
    begin
515
      base.skip_before_action(:verify_authenticity_token)
60✔
516
    rescue ArgumentError
517
      # The callback may not exist if forgery protection isn't enabled; this is expected.
518
      nil
4✔
519
    end
520

521
    # Handle exceptions.
522
    base.rescue_from(*RRF_RESCUED_EXCEPTIONS, with: :rrf_error_handler)
60✔
523
    base.rescue_from(*RRF_RESCUED_RAILS_EXCEPTIONS, with: :rrf_error_handler)
60✔
524
  end
525

526
  def get_serializer_class
2✔
527
    self.class.serializer_class || RESTFramework::NativeSerializer
280✔
528
  end
529

530
  # Serialize the given data using the `serializer_class`.
531
  def serialize(data, **kwargs)
2✔
532
    RESTFramework::Utils.wrap_ams(self.get_serializer_class).new(
262✔
533
      data, controller: self, **kwargs
534
    ).serialize
535
  end
536

537
  def rrf_error_handler(e)
2✔
538
    status = case e
58✔
539
    when ActiveRecord::RecordNotFound
540
      404
36✔
541
    when RESTFramework::BulkRecordErrorsError
542
      422
4✔
543
    else
544
      400
18✔
545
    end
546

547
    # `StatementInvalid` messages commonly embed SQL fragments and schema details, so don't leak
548
    # them to clients unless backtraces are explicitly enabled.
549
    message = if e.is_a?(ActiveRecord::StatementInvalid) && !RESTFramework.config.show_backtrace
58✔
550
      "Invalid query."
2✔
551
    else
552
      e.message
56✔
553
    end
554

555
    render_api(
58✔
556
      {
557
        message: message,
558
        errors: e.try(:record).try(:errors),
559
        exception: RESTFramework.config.show_backtrace ? e.full_message : nil,
58✔
560
      }.compact,
561
      status: status,
562
    )
563
  end
564

565
  def route_groups
2✔
566
    @route_groups ||= RESTFramework::Utils.get_routes(Rails.application.routes, request)
74✔
567
  end
568

569
  # Render a browsable API for `html` format, along with basic `json`/`xml` formats, and with
570
  # support or passing custom `kwargs` to the underlying `render` calls.
571
  def render_api(payload, **kwargs)
2✔
572
    html_kwargs = kwargs.delete(:html_kwargs) || {}
432✔
573
    json_kwargs = kwargs.delete(:json_kwargs) || {}
432✔
574
    xml_kwargs = kwargs.delete(:xml_kwargs) || {}
432✔
575

576
    # Raise helpful error if payload is nil. Usually this happens when a record is not found (e.g.,
577
    # when passing something like `User.find_by(id: some_id)` to `render_api`). The caller should
578
    # actually be calling `find_by!` to raise ActiveRecord::RecordNotFound and allowing the REST
579
    # framework to catch this error and return an appropriate error response.
580
    if payload.nil?
432✔
581
      raise RESTFramework::NilPassedToRenderAPIError
6✔
582
    end
583

584
    # If `payload` is an `ActiveRecord::Relation` or `ActiveRecord::Base`, then serialize it.
585
    if payload.is_a?(ActiveRecord::Base) || payload.is_a?(ActiveRecord::Relation)
426✔
586
      payload = self.serialize(payload)
162✔
587
    end
588

589
    # Do not use any adapters by default, if configured.
590
    if self.class.disable_adapters_by_default && !kwargs.key?(:adapter)
426✔
591
      kwargs[:adapter] = nil
426✔
592
    end
593

594
    # Flag to track if we had to rescue unknown format.
595
    already_rescued_unknown_format = false
426✔
596

597
    begin
598
      respond_to do |format|
428✔
599
        if payload == ""
428✔
600
          format.json { head(kwargs[:status] || :no_content) } if self.class.serialize_to_json
14✔
601
          format.xml { head(kwargs[:status] || :no_content) } if self.class.serialize_to_xml
14✔
602
        else
603
          format.json {
604
            render(json: payload, **kwargs.merge(json_kwargs))
342✔
605
          } if self.class.serialize_to_json
416✔
606
          format.xml {
607
            render(xml: payload, **kwargs.merge(xml_kwargs))
20✔
608
          } if self.class.serialize_to_xml
416✔
609
          # TODO: possibly support more formats here if supported?
610
        end
611
        format.html {
428✔
612
          @payload = payload
58✔
613
          if payload == ""
58✔
614
            @json_payload = "" if self.class.serialize_to_json
8✔
615
            @xml_payload = "" if self.class.serialize_to_xml
8✔
616
          else
617
            @json_payload = payload.to_json if self.class.serialize_to_json
50✔
618
            @xml_payload = payload.to_xml if self.class.serialize_to_xml
50✔
619
          end
620
          @title ||= self.class.get_title
58✔
621
          @description ||= self.class.description
58✔
622
          self.route_groups
58✔
623
          begin
624
            render(**kwargs.merge(html_kwargs))
58✔
625
          rescue ActionView::MissingTemplate
626
            # A view is not required, so just use `html: ""`.
627
            render(html: "", layout: true, **kwargs.merge(html_kwargs))
58✔
628
          end
629
        }
630
      end
631
    rescue ActionController::UnknownFormat
4✔
632
      if !already_rescued_unknown_format && rescue_format = self.class.rescue_unknown_format_with
4✔
633
        request.format = rescue_format
2✔
634
        already_rescued_unknown_format = true
2✔
635
        retry
2✔
636
      else
637
        raise
2✔
638
      end
639
    end
640
  end
641

642
  def options
2✔
643
    render_api(self.openapi_document)
16✔
644
  end
645

646
  def get_fields
2✔
647
    self.class.get_fields
954✔
648
  end
649

650
  def readable_fields
2✔
651
    cfg = self.class.field_configuration
554✔
652
    self.get_fields.reject { |f| cfg[f]&.[](:write_only) }
6,214✔
653
  end
654

655
  # The fields a client may write (create/update): `get_fields` minus read_only fields. Excluding
656
  # them here — before strong parameters expand associations into `_id`/`_ids`/`_attributes` keys —
657
  # keeps a read_only association from ever producing a permitted (and otherwise unstrippable, since
658
  # those keys don't match a field name) assignment key.
659
  def writable_fields
2✔
660
    cfg = self.class.field_configuration
84✔
661
    self.get_fields.reject { |f| cfg[f]&.[](:read_only) }
760✔
662
  end
663

664
  # `readable_fields` restricted to real columns, for query surfaces that build SQL directly
665
  # (find_by, search) and would raise on a virtual/method field.
666
  def readable_columns
2✔
667
    self.readable_fields & self.class.model.column_names
14✔
668
  end
669

670
  # `readable_fields` restricted to columns and associations, for surfaces that also resolve dotted
671
  # `association.sub_field` paths (filtering, ordering). Excludes virtual/method fields, which have
672
  # no column to order or filter by.
673
  def readable_columns_or_associations
2✔
674
    cfg = self.class.field_configuration
536✔
675
    columns = self.class.model.column_names
536✔
676
    self.readable_fields.select { |f| f.in?(columns) || cfg[f]&.[](:kind) == "association" }
6,080✔
677
  end
678

679
  # Get a hash of strong parameters for the current action.
680
  def get_allowed_parameters
2✔
681
    return @_get_allowed_parameters if defined?(@_get_allowed_parameters)
86✔
682

683
    @_get_allowed_parameters = self.class.allowed_parameters
84✔
684
    return @_get_allowed_parameters if @_get_allowed_parameters
84✔
685

686
    # Assemble strong parameters from writable fields only, so read-only fields never produce a
687
    # permitted key — including the `_id`/`_ids`/`_attributes` variations an association expands
688
    # into, which a later key-name-based filter couldn't catch. Bulk update re-permits the primary
689
    # key (see `get_body_params`) since it needs it to locate each record.
690
    variations = []
84✔
691
    hash_variations = {}
84✔
692
    reflections = self.class.model.reflections
84✔
693
    @_get_allowed_parameters = self.writable_fields.map { |f|
84✔
694
      f = f.to_s
506✔
695
      config = self.class.field_configuration[f]
506✔
696

697
      # ActionText Integration:
698
      if self.class.enable_action_text && reflections.key?("rich_text_#{f}")
506✔
699
        next f
34✔
700
      end
701

702
      # ActiveStorage Integration: `has_one_attached`
703
      if self.class.enable_active_storage && reflections.key?("#{f}_attachment")
472✔
704
        hash_variations[f] = RRF_ACTIVESTORAGE_KEYS
34✔
705
        next f
34✔
706
      end
707

708
      # ActiveStorage Integration: `has_many_attached`
709
      if self.class.enable_active_storage && reflections.key?("#{f}_attachments")
438✔
710
        hash_variations[f] = RRF_ACTIVESTORAGE_KEYS
34✔
711
        next nil
34✔
712
      end
713

714
      if config[:reflection]
404✔
715
        # Add `_id`/`_ids` variations for associations.
716
        if id_field = config[:id_field]
192✔
717
          if id_field.ends_with?("_ids")
182✔
718
            hash_variations[id_field] = []
128✔
719
          else
720
            variations << id_field
54✔
721
          end
722
        end
723

724
        # Add `_attributes` variations for associations.
725
        # TODO: Consider adjusting this based on `nested_attributes_options`.
726
        if self.class.permit_nested_attributes_assignment
192✔
727
          hash_variations["#{f}_attributes"] = (
192✔
728
            config[:fields] + [ "_destroy" ]
192✔
729
          )
730
        end
731

732
        # Associations are not allowed to be submitted in their bare form (if they are submitted
733
        # that way, they will be translated to either id/ids or nested attributes assignment).
734
        next nil
192✔
735
      end
736

737
      next f
212✔
738
    }.compact
739
    @_get_allowed_parameters += variations
84✔
740
    @_get_allowed_parameters << hash_variations
84✔
741

742
    @_get_allowed_parameters
84✔
743
  end
744

745
  # Use strong parameters to filter the request body.
746
  def get_body_params(bulk_action: nil)
2✔
747
    data = self.request.request_parameters
80✔
748
    pk = self.class.model&.primary_key
80✔
749
    allowed_params = self.get_allowed_parameters
80✔
750

751
    # Before we filter the data, dynamically dispatch association assignment to either the id/ids
752
    # assignment ActiveRecord API or the nested assignment ActiveRecord API. Note that there is no
753
    # need to check for `permit_id_assignment` or `permit_nested_attributes_assignment` here, since
754
    # that is enforced by strong parameters generated by `get_allowed_parameters`.
755
    if !bulk_action && self.class.model
80✔
756
      self.class.model.reflections.each do |name, ref|
30✔
757
        if payload = data[name]
176✔
758
          if payload.is_a?(Hash) || (payload.is_a?(Array) && payload.all? { |x| x.is_a?(Hash) })
4✔
759
            # Assume nested attributes assignment.
760
            attributes_key = "#{name}_attributes"
2✔
761
            data[attributes_key] = data.delete(name) unless data[attributes_key]
2✔
762
          elsif id_field = RESTFramework::Utils.id_field_for(name, ref)
2✔
763
            # Assume id/ids assignment.
764
            data[id_field] = data.delete(name) unless data[id_field]
2✔
765
          end
766
        end
767
      end
768
    end
769

770
    # ActiveStorage Integration: Translate base64 encoded attachments to upload objects.
771
    #
772
    # rubocop:disable Layout/LineLength
773
    #
774
    # Example base64 images (red, green, and blue squares):
775
    #   data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC
776
    #   data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mNk+M9Qz0AEYBxVSF+FAAhKDveksOjmAAAAAElFTkSuQmCC
777
    #   data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mNkYPhfz0AEYBxVSF+FAP5FDvcfRYWgAAAAAElFTkSuQmCC
778
    #
779
    # rubocop:enable Layout/LineLength
780
    has_many_attached_scalar_data = {}
80✔
781
    if !bulk_action && self.class.enable_active_storage && self.class.model
80✔
782
      self.class.model.attachment_reflections.keys.each do |k|
30✔
783
        if data[k].is_a?(Array)
20✔
784
          data[k] = data[k].map { |v|
×
785
            if v.is_a?(String)
×
786
              v = RRF_BASE64_TRANSLATE.call(k, v)
×
787

788
              # Remember scalars because Rails strong params will remove it.
789
              if v.is_a?(String)
×
790
                has_many_attached_scalar_data[k] ||= []
×
791
                has_many_attached_scalar_data[k] << v
×
792
              end
793
            elsif v.is_a?(Hash)
×
794
              if v[:io].is_a?(String)
×
795
                v[:io] = StringIO.new(Base64.decode64(v[:io]))
×
796
              end
797
            end
798

799
            next v
×
800
          }
801
        elsif data[k].is_a?(Hash)
20✔
802
          if data[k][:io].is_a?(String)
×
803
            data[k][:io] = StringIO.new(Base64.decode64(data[k][:io]))
×
804
          end
805
        elsif data[k].is_a?(String)
20✔
806
          data[k] = RRF_BASE64_TRANSLATE.call(k, data[k])
×
807
        end
808
      end
809
    end
810

811
    # Filter the request body with strong params. If `bulk` is true, then we apply allowed
812
    # parameters to the `_json` key of the request body.
813
    body_params = if allowed_params == true
80✔
814
      ActionController::Parameters.new(data).permit!
×
815
    elsif bulk_action
80✔
816
      if bulk_action == :create
50✔
817
        ActionController::Parameters.new(data).permit({ _json: allowed_params })
20✔
818
      elsif bulk_action == :update
30✔
819
        ActionController::Parameters.new(data).permit({ _json: allowed_params + [ pk ] })
18✔
820
      elsif bulk_action == :destroy
12✔
821
        ActionController::Parameters.new(data).permit({ _json: [] })
12✔
822
      else
823
        raise ArgumentError, "Invalid bulk action: #{bulk_action}"
×
824
      end
825
    else
826
      ActionController::Parameters.new(data).permit(*allowed_params)
30✔
827
    end
828

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

838
    body_params
80✔
839
  end
840
  alias_method :get_create_params, :get_body_params
2✔
841
  alias_method :get_update_params, :get_body_params
2✔
842
  alias_method :get_destroy_params, :get_body_params
2✔
843

844
  # Get the set of records this controller has access to. Override this to scope records (e.g. to
845
  # the current user); the default scopes to a nested parent resource when one is present in the
846
  # path, otherwise the model's default scope (all records).
847
  def get_recordset
2✔
848
    return nil unless self.class.model
360✔
849

850
    self._rrf_nested_parent_recordset || self.class.model.all
360✔
851
  end
852

853
  # For a nested route, walk the whole parent chain in path order and return the innermost
854
  # collection of this controller's model — e.g. `/movies/:movie_id/genres/:genre_id/tracks` becomes
855
  # `Movie.find(movie_id).genres.find(genre_id).tracks`. Every link is enforced (a broken one raises
856
  # `RecordNotFound` -> 404), and each association is resolved from its parent, so `belongs_to`,
857
  # `has_many`, and `has_and_belongs_to_many` children all work. Each parent is looked up via its
858
  # own controller's recordset (see `scope_nested_through_controllers`), so per-level access scoping
859
  # is enforced. Returns `nil` when there is no nested parent, or a `<name>_id` param can't connect.
860
  def _rrf_nested_parent_recordset
2✔
861
    # Set on an ad-hoc parent instance below, so evaluating a parent's `get_recordset` doesn't
862
    # recurse back into nested scoping (we want the parent's own scope, not to re-nest it).
863
    return nil if @_rrf_scoping_parent
360✔
864
    return nil unless self.class.scope_nested_by_parent && request
348✔
865

866
    # `<name>_id` path parameters that name a model, in route order (outermost parent first).
867
    parents = request.path_parameters.filter_map { |key, value|
348✔
868
      key = key.to_s
1,132✔
869
      next unless key.end_with?("_id")
1,132✔
870

871
      model = key.delete_suffix("_id").classify.safe_constantize
16✔
872
      next unless model.is_a?(Class) && model < ActiveRecord::Base
16✔
873

874
      [ model, value ]
16✔
875
    }
876
    return nil if parents.empty?
348✔
877

878
    # Find the outermost parent within its controller's scope, then descend: each next parent is
879
    # constrained both to the previous parent's association and to its own controller's scope.
880
    record = nil
12✔
881
    parents.each do |model, id|
12✔
882
      scope = _rrf_parent_recordset(model)
16✔
883

884
      unless record.nil?
16✔
885
        assoc = _rrf_collection_association_name(record.class, model)
4✔
886
        return nil unless assoc
4✔
887

888
        scope = record.public_send(assoc).merge(scope)
4✔
889
      end
890

891
      record = scope.find(id)
16✔
892
    end
893

894
    # Finally, the innermost parent's collection of this controller's model.
895
    assoc = _rrf_collection_association_name(record.class, self.class.model)
6✔
896
    return nil unless assoc
6✔
897

898
    record.public_send(assoc)
6✔
899
  end
900

901
  # A parent's recordset for the nested-scope walk: its own controller's `get_recordset` (so that
902
  # controller's access scoping is reused), or the bare model when the feature is off or no sibling
903
  # controller is found. The ad-hoc instance shares this request and skips its own nested scoping.
904
  def _rrf_parent_recordset(model)
2✔
905
    return model.all unless self.class.scope_nested_through_controllers
16✔
906

907
    controller = RESTFramework::Utils.controller_for_model(self.class, model)
16✔
908
    return model.all unless controller
16✔
909

910
    instance = controller.new
16✔
911
    instance.request = request
16✔
912
    instance.response = response
16✔
913
    instance.instance_variable_set(:@_rrf_scoping_parent, true)
16✔
914
    instance.get_recordset
16✔
915
  end
916

917
  # The name of `klass`'s collection association whose records are `target_model`, or `nil`.
918
  def _rrf_collection_association_name(klass, target_model)
2✔
919
    klass.reflect_on_all_associations.find { |ref|
10✔
920
      ref.collection? && !ref.polymorphic? && ref.klass == target_model
18✔
921
    }&.name
922
  end
923

924
  # Filter the recordset and return records this request has access to.
925
  def get_records
2✔
926
    data = self.get_recordset
286✔
927

928
    @records ||= self.class.filter_backends&.reduce(data) { |d, filter|
280✔
929
      filter.new(controller: self).filter_data(d)
1,004✔
930
    } || data
931
  end
932

933
  # Get a single record by primary key or another column, if allowed.
934
  def get_record
2✔
935
    return @record if @record
92✔
936

937
    find_by_key = self.class.model.primary_key
92✔
938
    is_pk = true
92✔
939

940
    # Find by another column if it's permitted.
941
    if find_by_param = self.class.find_by_query_param.presence
92✔
942
      if find_by = request.query_parameters[find_by_param].presence
92✔
943
        # Default to readable columns: excluding write_only keeps hidden values from being used as
944
        # lookup keys, and restricting to real columns keeps virtual/method fields from reaching a
945
        # doomed `find_by(<not a column>)` (which would raise on the DB).
946
        find_by_fields = self.class.find_by_fields&.map(&:to_s) || self.readable_columns
10✔
947

948
        # A `find_by` was explicitly requested, so it must be a permitted field.
949
        raise ActiveRecord::RecordNotFound unless find_by.in?(find_by_fields)
10✔
950

951
        is_pk = false unless find_by_key == find_by
4✔
952
        find_by_key = find_by
4✔
953
      end
954
    end
955

956
    # Get the recordset, filtering if configured.
957
    collection = if self.class.filter_recordset_before_find
86✔
958
      self.get_records
82✔
959
    else
960
      self.get_recordset
4✔
961
    end
962

963
    # Return the record. Route key is always `:id` by Rails' convention.
964
    if is_pk
86✔
965
      @record = collection.find(request.path_parameters[:id])
82✔
966
    else
967
      @record = collection.find_by!(find_by_key => request.path_parameters[:id])
4✔
968
    end
969
  end
970

971
  # Determine what collection to call `create` on.
972
  def create_from
2✔
973
    if self.class.create_from_recordset
42✔
974
      # Create with any properties inherited from the recordset. We exclude any `select` clauses
975
      # in case model callbacks need to call `count` on this collection, which typically raises a
976
      # SQL `SyntaxError`.
977
      self.get_recordset.except(:select)
40✔
978
    else
979
      # Otherwise, perform a "bare" insert_all.
980
      self.class.model
2✔
981
    end
982
  end
983
end
984

985
require_relative "controller/actions"
2✔
986
require_relative "controller/bulk"
2✔
987
require_relative "controller/crud"
2✔
988
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