Module: RESTFramework::Controller

Defined in:
lib/rest_framework/controller.rb,
lib/rest_framework/controller/bulk.rb,
lib/rest_framework/controller/crud.rb,
lib/rest_framework/controller/openapi.rb

Overview

This module provides the common functionality for all REST controllers. The implementation is split across several files under ‘controller/` for readability; each of those files reopens this module rather than defining a separate submodule.

Defined Under Namespace

Modules: ClassMethods

Constant Summary collapse

RRF_BASE_CONFIG =
{
  extra_actions: nil,
  extra_member_actions: nil,
  singleton_controller: nil,

  # Options related to metadata and display.
  title: nil,
  description: nil,
  version: nil,
  inflect_acronyms: RESTFramework.config.inflect_acronyms,
  openapi_include_children: false,

  # Options related to models.
  model: nil,
  recordset: nil,
  excluded_actions: nil,

  # Bulk configuration.
  #
  # When `bulk` is truthy, it enables the default bulk behavior (`:default`), which is per-record
  # processing (e.g., `create` for each record). When `bulk` is set to `:raw`, it enables single
  # SQL query behavior (e.g., `insert_all` for bulk create) which skips validations/callbacks.
  bulk: false,
  bulk_partial: false,
  bulk_partial_query_param: "bulk_partial".freeze,
  bulk_allow_mode_override: false,
  bulk_mode_query_param: "bulk_mode".freeze,
  bulk_max_size: nil,
  bulk_max_raw_size: nil,

  # Configuring record fields.
  fields: nil,
  field_config: nil,
  read_only_fields: RESTFramework.config.read_only_fields,
  write_only_fields: RESTFramework.config.write_only_fields,
  hidden_fields: nil,

  # Finding records.
  find_by_fields: nil,
  find_by_query_param: "find_by".freeze,

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

  # Handling request body parameters.
  allowed_parameters: nil,

  # Options for the default native serializer.
  native_serializer_config: nil,
  native_serializer_singular_config: nil,
  native_serializer_plural_config: nil,
  native_serializer_only_query_param: "only".freeze,
  native_serializer_except_query_param: "except".freeze,
  native_serializer_include_query_param: "include".freeze,
  native_serializer_exclude_query_param: "exclude".freeze,
  native_serializer_associations_limit: nil,
  native_serializer_associations_limit_query_param: "associations_limit".freeze,
  native_serializer_include_associations_count: false,

  # Options for filtering, ordering, and searching.
  filter_backends: [
    RESTFramework::QueryFilter,
    RESTFramework::OrderingFilter,
    RESTFramework::SearchFilter,
  ].freeze,
  filter_recordset_before_find: true,
  filter_fields: nil,
  ordering_fields: nil,
  ordering_query_param: "ordering".freeze,
  ordering_no_reorder: false,
  search_fields: nil,
  search_query_param: "search".freeze,
  search_ilike: false,
  ransack_options: nil,
  ransack_query_param: "q".freeze,
  ransack_distinct: true,
  ransack_distinct_query_param: "distinct".freeze,

  # Options for association assignment.
  permit_id_assignment: true,
  permit_nested_attributes_assignment: true,

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

  # Options related to serialization.
  rescue_unknown_format_with: :json,
  serializer_class: nil,
  serialize_to_json: true,
  serialize_to_xml: true,

  # Options related to pagination.
  paginator_class: nil,
  page_size: 20,
  page_query_param: "page",
  page_size_query_param: "page_size",
  max_page_size: nil,

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

  # Custom integrations (reduces serializer performance due to method calls).
  enable_action_text: false,
  enable_active_storage: false,
}
RRF_RESCUED_EXCEPTIONS =

Exceptions to be rescued and handled by returning a reasonable error response.

[
  RESTFramework::InvalidBulkParametersError,
  RESTFramework::BulkRecordErrorsError,
].freeze
RRF_RESCUED_RAILS_EXCEPTIONS =
[
  ActionController::ParameterMissing,
  ActionController::UnpermittedParameters,
  ActionDispatch::Http::Parameters::ParseError,
  ActiveRecord::AssociationTypeMismatch,
  ActiveRecord::NotNullViolation,
  ActiveRecord::RecordNotFound,
  ActiveRecord::RecordInvalid,
  ActiveRecord::RecordNotSaved,
  ActiveRecord::RecordNotDestroyed,
  ActiveRecord::RecordNotUnique,
  ActiveModel::UnknownAttributeError,
].freeze
RRF_BASE64_REGEX =

Anchored regex with non-greedy content_type match to prevent over-matching on malicious input.

/\Adata:([^;]*);base64,(.*)\z/m
RRF_BASE64_TRANSLATE =
->(field, value) {
  return value unless RRF_BASE64_REGEX.match?(value)

  _, content_type, payload = value.match(RRF_BASE64_REGEX).to_a
  {
    io: StringIO.new(Base64.decode64(payload)),
    content_type: content_type,
    filename: "file_#{field}#{Rack::Mime::MIME_TYPES.invert[content_type]}",
  }
}
RRF_ACTIVESTORAGE_KEYS =
[ :io, :content_type, :filename, :identify, :key ]
RRF_DEFAULT_BULK_MAX_SIZE =
1000
RRF_DEFAULT_BULK_MAX_RAW_SIZE =
10000

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.included(base) ⇒ Object



428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
# File 'lib/rest_framework/controller.rb', line 428

def self.included(base)
  return unless base.is_a?(Class)

  base.extend(ClassMethods)

  # By default, the layout should be set to `rest_framework`.
  base.layout("rest_framework")

  # Add class attributes unless they already exist.
  RRF_BASE_CONFIG.each do |a, default|
    next if base.respond_to?(a)

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

  # Alias `extra_actions` to `extra_collection_actions`.
  unless base.respond_to?(:extra_collection_actions)
    base.singleton_class.alias_method(:extra_collection_actions, :extra_actions)
    base.singleton_class.alias_method(:extra_collection_actions=, :extra_actions=)
  end

  # Skip CSRF since this is an API.
  begin
    base.skip_before_action(:verify_authenticity_token)
  rescue ArgumentError
    # The callback may not exist if forgery protection isn't enabled; this is expected.
    nil
  end

  # Handle exceptions.
  base.rescue_from(*RRF_RESCUED_EXCEPTIONS, with: :rrf_error_handler)
  base.rescue_from(*RRF_RESCUED_RAILS_EXCEPTIONS, with: :rrf_error_handler)

  # Use `TracePoint` hook to automatically call `rrf_finalize`.
  if RESTFramework.config.auto_finalize
    # :nocov:
    TracePoint.trace(:end) do |t|
      next if base != t.self

      base.rrf_finalize

      # It's important to disable the trace once we've found the end of the base class definition,
      # for performance.
      t.disable
    end
    # :nocov:
  end
end

Instance Method Details

#_bulk_max_raw_sizeObject



9
10
11
# File 'lib/rest_framework/controller/bulk.rb', line 9

def _bulk_max_raw_size
  @_bulk_max_raw_size ||= self.class.bulk_max_raw_size || RRF_DEFAULT_BULK_MAX_RAW_SIZE
end

#_bulk_max_sizeObject



5
6
7
# File 'lib/rest_framework/controller/bulk.rb', line 5

def _bulk_max_size
  @_bulk_max_size ||= self.class.bulk_max_size || RRF_DEFAULT_BULK_MAX_SIZE
end

#_bulk_modeObject



23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# File 'lib/rest_framework/controller/bulk.rb', line 23

def _bulk_mode
  return @_bulk_mode if defined?(@_bulk_mode)

  # If mode override is allowed, check the query param.
  if self.class.bulk_allow_mode_override && (qp = self.class.bulk_mode_query_param)
    if (requested = request.query_parameters[qp].presence)
      requested = requested.to_sym
      unless requested.in?([ :default, :raw ])
        raise RESTFramework::InvalidBulkParametersError.new(
          "Invalid bulk mode: #{requested}. Must be `default` or `raw`.",
        )
      end
      return @_bulk_mode = requested
    end
  end

  # Normalize: `true` and `:default` both mean per-record processing.
  @_bulk_mode = self.class.bulk == :raw ? :raw : :default
end

#_bulk_object_data(bulk_action, bulk_mode) ⇒ Object

Validate and extract bulk object data from request parameters.



58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# File 'lib/rest_framework/controller/bulk.rb', line 58

def _bulk_object_data(bulk_action, bulk_mode)
  data = self.get_body_params(bulk_action: bulk_action)[:_json]

  unless data&.is_a?(Array) && data.all? { |r| r.is_a?(ActionController::Parameters) }
    raise RESTFramework::InvalidBulkParametersError.new("Expected an array of objects.")
  end

  # Enforce size limits.
  max = bulk_mode == :raw ? self._bulk_max_raw_size : self._bulk_max_size
  if max && data.length > max
    raise RESTFramework::InvalidBulkParametersError.new(
      "Too many records (#{data.length}) for #{bulk_mode} mode; maximum is #{max}.",
    )
  end

  data
end

#_bulk_partialObject

Resolve whether partial fulfillment is enabled for this request.



44
45
46
47
48
49
50
51
52
53
54
55
# File 'lib/rest_framework/controller/bulk.rb', line 44

def _bulk_partial
  return @_bulk_partial if defined?(@_bulk_partial)

  # Check the query param first if configured.
  if (qp = self.class.bulk_partial_query_param)
    if (requested = request.query_parameters[qp].presence)
      return @_bulk_partial = ActiveModel::Type::Boolean.new.cast(requested)
    end
  end

  @_bulk_partial = self.class.bulk_partial
end

#_bulk_pk_dataObject

Validate and extract bulk primary key data from request parameters.



77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
# File 'lib/rest_framework/controller/bulk.rb', line 77

def _bulk_pk_data
  data = self.get_destroy_params(bulk_action: :destroy)[:_json]

  unless data&.is_a?(Array) && data.all? { |r| r.is_a?(String) || r.is_a?(Numeric) }
    raise RESTFramework::InvalidBulkParametersError.new("Expected an array of primary keys.")
  end

  # Enforce size limits.
  max = self._bulk_mode == :raw ? self._bulk_max_raw_size : self._bulk_max_size
  if max && data.length > max
    raise RESTFramework::InvalidBulkParametersError.new(
      "Too many records (#{data.length}) for #{self._bulk_mode} mode; maximum is #{max}.",
    )
  end

  data
end

#_bulk_serialize(records) ⇒ Object



13
14
15
16
17
18
19
20
21
# File 'lib/rest_framework/controller/bulk.rb', line 13

def _bulk_serialize(records)
  # This is kinda slow, so perhaps we should eventually integrate `errors` serialization into
  # the serializer directly. This would fail for active model serializers, but maybe we don't
  # care?
  s = RESTFramework::Utils.wrap_ams(self.get_serializer_class)
  records.map do |record|
    s.new(record, controller: self).serialize.merge!({ errors: record.errors.presence }.compact)
  end
end

#api_response(*args, **kwargs) ⇒ Object

Deprecated alias for ‘render_api`.



587
588
589
590
# File 'lib/rest_framework/controller.rb', line 587

def api_response(*args, **kwargs)
  RESTFramework.deprecator.warn("`api_response` is deprecated; use `render_api` instead.")
  render_api(*args, **kwargs)
end

#createObject



2
3
4
5
6
7
8
9
# File 'lib/rest_framework/controller/crud.rb', line 2

def create
  # Bulk create: if `bulk` is enabled and the request body is an array, delegate to `create_all`.
  if self.class.bulk && params[:_json].is_a?(Array)
    return self.create_all
  end

  render(api: self.create!, status: :created)
end

#create!Object

Perform the ‘create!` call and return the created record.



12
13
14
# File 'lib/rest_framework/controller/crud.rb', line 12

def create!
  self.create_from.create!(self.get_create_params)
end

#create_allObject



95
96
97
98
99
100
101
102
103
104
105
106
# File 'lib/rest_framework/controller/bulk.rb', line 95

def create_all
  if self._bulk_mode == :raw
    result = self.create_all_raw!
    return render(api: { message: "Bulk create successful.", result: result })
  end

  records = self.create_all_default!
  render(
    api: { message: "Bulk create successful.", records: self._bulk_serialize(records) },
    status: :created,
  )
end

#create_all_default!Object



122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
# File 'lib/rest_framework/controller/bulk.rb', line 122

def create_all_default!
  data = self._bulk_object_data(:create, :default)
  collection = self.create_from

  if self._bulk_partial
    # Partial: save each record individually, return all (some may have errors).
    data.map { |attrs| collection.create(attrs) }
  else
    # Transactional: validate all first, then save in a transaction or raise.
    records = data.map { |attrs| collection.new(attrs) }
    failed = records.reject(&:valid?)

    if failed.any?
      raise RESTFramework::BulkRecordErrorsError.new(records)
    end

    self.class.model.transaction do
      records.each(&:save!)
    end

    records
  end
end

#create_all_raw!Object



108
109
110
111
112
113
114
115
116
117
118
119
120
# File 'lib/rest_framework/controller/bulk.rb', line 108

def create_all_raw!
  pk = self.class.model.primary_key
  data = self._bulk_object_data(:create, :raw)

  unless first_keys = data.first&.keys&.sort
    raise RESTFramework::InvalidBulkParametersError.new("Expected objects with attrs.")
  end
  unless data.all? { |r| r.keys.sort == first_keys }
    raise RESTFramework::InvalidBulkParametersError.new("All objects must have the same attrs.")
  end

  self.create_from.insert_all(data, unique_by: pk)
end

#create_fromObject

Determine what collection to call ‘create` on.



824
825
826
827
828
829
830
831
832
833
834
# File 'lib/rest_framework/controller.rb', line 824

def create_from
  if self.class.create_from_recordset
    # Create with any properties inherited from the recordset. We exclude any `select` clauses
    # in case model callbacks need to call `count` on this collection, which typically raises a
    # SQL `SyntaxError`.
    self.get_recordset.except(:select)
  else
    # Otherwise, perform a "bare" insert_all.
    self.class.model
  end
end

#destroyObject



56
57
58
59
# File 'lib/rest_framework/controller/crud.rb', line 56

def destroy
  self.destroy!
  render(api: "")
end

#destroy!Object

Perform the ‘destroy!` call and return the destroyed (and frozen) record.



62
63
64
# File 'lib/rest_framework/controller/crud.rb', line 62

def destroy!
  self.get_record.destroy!
end

#destroy_allObject



229
230
231
232
233
234
235
236
237
# File 'lib/rest_framework/controller/bulk.rb', line 229

def destroy_all
  if self._bulk_mode == :raw
    deleted = self.destroy_all_raw!
    return render(api: { message: "Bulk destroy successful.", result: deleted })
  end

  records = self.destroy_all_default!
  render(api: { message: "Bulk destroy successful.", records: self._bulk_serialize(records) })
end

#destroy_all_default!Object



245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
# File 'lib/rest_framework/controller/bulk.rb', line 245

def destroy_all_default!
  data = self._bulk_pk_data
  pk = self.class.model.primary_key
  records = self.get_recordset.where(pk => data).to_a

  # In transactional mode, verify all requested records exist.
  if !self._bulk_partial && records.length != data.uniq.length
    found_ids = records.map { |r| r.send(pk) }
    missing = data.uniq - found_ids
    raise RESTFramework::InvalidBulkParametersError.new(
      "Bulk destroy requires all records to exist. Missing #{pk}: #{missing.join(', ')}.",
    )
  end

  if self._bulk_partial
    # Partial: destroy each record individually.
    records.each(&:destroy)
    records
  else
    # Transactional: destroy all in a transaction, roll back on failure.
    self.class.model.transaction do
      records.each(&:destroy!)
    end

    records
  end
end

#destroy_all_raw!Object



239
240
241
242
243
# File 'lib/rest_framework/controller/bulk.rb', line 239

def destroy_all_raw!
  data = self._bulk_pk_data
  pk = self.class.model.primary_key
  self.get_recordset.where(pk => data).delete_all
end

#get_allowed_parametersObject

Get a hash of strong parameters for the current action.



601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
# File 'lib/rest_framework/controller.rb', line 601

def get_allowed_parameters
  return @_get_allowed_parameters if defined?(@_get_allowed_parameters)

  @_get_allowed_parameters = self.class.allowed_parameters
  return @_get_allowed_parameters if @_get_allowed_parameters

  # Assemble strong parameters.
  variations = []
  hash_variations = {}
  reflections = self.class.model.reflections
  @_get_allowed_parameters = self.get_fields.map { |f|
    f = f.to_s
    config = self.class.field_configuration[f]

    # ActionText Integration:
    if self.class.enable_action_text && reflections.key?("rich_text_#{f}")
      next f
    end

    # ActiveStorage Integration: `has_one_attached`
    if self.class.enable_active_storage && reflections.key?("#{f}_attachment")
      hash_variations[f] = RRF_ACTIVESTORAGE_KEYS
      next f
    end

    # ActiveStorage Integration: `has_many_attached`
    if self.class.enable_active_storage && reflections.key?("#{f}_attachments")
      hash_variations[f] = RRF_ACTIVESTORAGE_KEYS
      next nil
    end

    if config[:reflection]
      # Add `_id`/`_ids` variations for associations.
      if id_field = config[:id_field]
        if id_field.ends_with?("_ids")
          hash_variations[id_field] = []
        else
          variations << id_field
        end
      end

      # Add `_attributes` variations for associations.
      # TODO: Consider adjusting this based on `nested_attributes_options`.
      if self.class.permit_nested_attributes_assignment
        hash_variations["#{f}_attributes"] = (
          config[:sub_fields] + [ "_destroy" ]
        )
      end

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

    next f
  }.compact
  @_get_allowed_parameters += variations
  @_get_allowed_parameters << hash_variations

  @_get_allowed_parameters
end

#get_body_params(bulk_action: nil) ⇒ Object Also known as: get_create_params, get_update_params, get_destroy_params

Use strong parameters to filter the request body.



664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
# File 'lib/rest_framework/controller.rb', line 664

def get_body_params(bulk_action: nil)
  data = self.request.request_parameters
  pk = self.class.model&.primary_key
  allowed_params = self.get_allowed_parameters

  # Before we filter the data, dynamically dispatch association assignment to either the id/ids
  # assignment ActiveRecord API or the nested assignment ActiveRecord API. Note that there is no
  # need to check for `permit_id_assignment` or `permit_nested_attributes_assignment` here, since
  # that is enforced by strong parameters generated by `get_allowed_parameters`.
  if !bulk_action && self.class.model
    self.class.model.reflections.each do |name, ref|
      if payload = data[name]
        if payload.is_a?(Hash) || (payload.is_a?(Array) && payload.all? { |x| x.is_a?(Hash) })
          # Assume nested attributes assignment.
          attributes_key = "#{name}_attributes"
          data[attributes_key] = data.delete(name) unless data[attributes_key]
        elsif id_field = RESTFramework::Utils.id_field_for(name, ref)
          # Assume id/ids assignment.
          data[id_field] = data.delete(name) unless data[id_field]
        end
      end
    end
  end

  # ActiveStorage Integration: Translate base64 encoded attachments to upload objects.
  #
  # rubocop:disable Layout/LineLength
  #
  # Example base64 images (red, green, and blue squares):
  #   data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC
  #   data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mNk+M9Qz0AEYBxVSF+FAAhKDveksOjmAAAAAElFTkSuQmCC
  #   data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mNkYPhfz0AEYBxVSF+FAP5FDvcfRYWgAAAAAElFTkSuQmCC
  #
  # rubocop:enable Layout/LineLength
  has_many_attached_scalar_data = {}
  if !bulk_action && self.class.enable_active_storage && self.class.model
    self.class.model.attachment_reflections.keys.each do |k|
      if data[k].is_a?(Array)
        data[k] = data[k].map { |v|
          if v.is_a?(String)
            v = RRF_BASE64_TRANSLATE.call(k, v)

            # Remember scalars because Rails strong params will remove it.
            if v.is_a?(String)
              has_many_attached_scalar_data[k] ||= []
              has_many_attached_scalar_data[k] << v
            end
          elsif v.is_a?(Hash)
            if v[:io].is_a?(String)
              v[:io] = StringIO.new(Base64.decode64(v[:io]))
            end
          end

          next v
        }
      elsif data[k].is_a?(Hash)
        if data[k][:io].is_a?(String)
          data[k][:io] = StringIO.new(Base64.decode64(data[k][:io]))
        end
      elsif data[k].is_a?(String)
        data[k] = RRF_BASE64_TRANSLATE.call(k, data[k])
      end
    end
  end

  # Filter the request body with strong params. If `bulk` is true, then we apply allowed
  # parameters to the `_json` key of the request body.
  body_params = if allowed_params == true
    ActionController::Parameters.new(data).permit!
  elsif bulk_action
    if bulk_action == :create
      ActionController::Parameters.new(data).permit({ _json: allowed_params })
    elsif bulk_action == :update
      ActionController::Parameters.new(data).permit({ _json: allowed_params + [ pk ] })
    elsif bulk_action == :destroy
      ActionController::Parameters.new(data).permit({ _json: [] })
    else
      raise ArgumentError, "Invalid bulk action: #{bulk_action}"
    end
  else
    ActionController::Parameters.new(data).permit(*allowed_params)
  end

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

  # Filter read-only fields.
  body_params.delete_if do |f, _|
    cfg = self.class.field_configuration[f]
    cfg && cfg[:read_only]
  end

  body_params
end

#get_fieldsObject



596
597
598
# File 'lib/rest_framework/controller.rb', line 596

def get_fields
  self.class.get_fields(input_fields: self.class.fields)
end

#get_recordObject

Get a single record by primary key or another column, if allowed.



790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
# File 'lib/rest_framework/controller.rb', line 790

def get_record
  return @record if @record

  find_by_key = self.class.model.primary_key
  is_pk = true

  # Find by another column if it's permitted.
  if find_by_param = self.class.find_by_query_param.presence
    if find_by = request.query_parameters[find_by_param].presence
      find_by_fields = self.class.find_by_fields&.map(&:to_s) || self.get_fields

      if find_by.in?(find_by_fields)
        is_pk = false unless find_by_key == find_by
        find_by_key = find_by
      end
    end
  end

  # Get the recordset, filtering if configured.
  collection = if self.class.filter_recordset_before_find
    self.get_records
  else
    self.get_recordset
  end

  # Return the record. Route key is always `:id` by Rails' convention.
  if is_pk
    @record = collection.find(request.path_parameters[:id])
  else
    @record = collection.find_by!(find_by_key => request.path_parameters[:id])
  end
end

#get_recordsObject

Filter the recordset and return records this request has access to.



781
782
783
784
785
786
787
# File 'lib/rest_framework/controller.rb', line 781

def get_records
  data = self.get_recordset

  @records ||= self.class.filter_backends&.reduce(data) { |d, filter|
    filter.new(controller: self).filter_data(d)
  } || data
end

#get_recordsetObject

Get the set of records this controller has access to.



769
770
771
772
773
774
775
776
777
778
# File 'lib/rest_framework/controller.rb', line 769

def get_recordset
  return self.class.recordset if self.class.recordset

  # If there is a model, return that model's default scope (all records by default).
  if self.class.model
    return self.class.model.all
  end

  nil
end

#get_serializer_classObject



478
479
480
# File 'lib/rest_framework/controller.rb', line 478

def get_serializer_class
  self.class.serializer_class || RESTFramework::NativeSerializer
end

#indexObject



16
17
18
# File 'lib/rest_framework/controller/crud.rb', line 16

def index
  render(api: self.index!)
end

#index!Object

Get records with both filtering and pagination applied.



21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# File 'lib/rest_framework/controller/crud.rb', line 21

def index!
  records = self.get_records

  # Handle pagination, if enabled.
  if paginator_class = self.class.paginator_class
    # Paginate if there is a `max_page_size`, or if there is no `page_size_query_param`, or if the
    # page size is not set to "0".
    max_page_size = self.class.max_page_size
    page_size_query_param = self.class.page_size_query_param
    if max_page_size || !page_size_query_param || params[page_size_query_param] != "0"
      paginator = paginator_class.new(data: records, controller: self)
      page = paginator.get_page
      serialized_page = self.serialize(page)
      return paginator.get_paginated_response(serialized_page)
    end
  end

  records
end

#openapi_documentObject



228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
# File 'lib/rest_framework/controller/openapi.rb', line 228

def openapi_document
  first, *rest = self.route_groups.to_a
  document = self.class.openapi_document(request, *first)

  if self.class.openapi_include_children
    rest.each do |route_group_name, routes|
      controller = "#{routes.first[:route].defaults[:controller]}_controller".camelize.constantize
      child_document = controller.openapi_document(request, route_group_name, routes)

      # Merge child paths and tags into the parent document.
      document[:paths].merge!(child_document[:paths])
      document[:tags] += child_document[:tags]

      # If the child document has schemas, merge them into the parent document.
      if schemas = child_document.dig(:components, :schemas)  # rubocop:disable Style/Next
        document[:components] ||= {}
        document[:components][:schemas] ||= {}
        document[:components][:schemas].merge!(schemas)
      end
    end
  end

  document
end

#optionsObject



592
593
594
# File 'lib/rest_framework/controller.rb', line 592

def options
  render(api: self.openapi_document)
end

#render_api(payload, **kwargs) ⇒ Object

Render a browsable API for ‘html` format, along with basic `json`/`xml` formats, and with support or passing custom `kwargs` to the underlying `render` calls.



515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
# File 'lib/rest_framework/controller.rb', line 515

def render_api(payload, **kwargs)
  html_kwargs = kwargs.delete(:html_kwargs) || {}
  json_kwargs = kwargs.delete(:json_kwargs) || {}
  xml_kwargs = kwargs.delete(:xml_kwargs) || {}

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

  # If `payload` is an `ActiveRecord::Relation` or `ActiveRecord::Base`, then serialize it.
  if payload.is_a?(ActiveRecord::Base) || payload.is_a?(ActiveRecord::Relation)
    payload = self.serialize(payload)
  end

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

  # Flag to track if we had to rescue unknown format.
  already_rescued_unknown_format = false

  begin
    respond_to do |format|
      if payload == ""
        format.json { head(kwargs[:status] || :no_content) } if self.class.serialize_to_json
        format.xml { head(kwargs[:status] || :no_content) } if self.class.serialize_to_xml
      else
        format.json {
          render(json: payload, **kwargs.merge(json_kwargs))
        } if self.class.serialize_to_json
        format.xml {
          render(xml: payload, **kwargs.merge(xml_kwargs))
        } if self.class.serialize_to_xml
        # TODO: possibly support more formats here if supported?
      end
      format.html {
        @payload = payload
        if payload == ""
          @json_payload = "" if self.class.serialize_to_json
          @xml_payload = "" if self.class.serialize_to_xml
        else
          @json_payload = payload.to_json if self.class.serialize_to_json
          @xml_payload = payload.to_xml if self.class.serialize_to_xml
        end
        @title ||= self.class.get_title
        @description ||= self.class.description
        self.route_groups
        begin
          render(**kwargs.merge(html_kwargs))
        rescue ActionView::MissingTemplate
          # A view is not required, so just use `html: ""`.
          render(html: "", layout: true, **kwargs.merge(html_kwargs))
        end
      }
    end
  rescue ActionController::UnknownFormat
    if !already_rescued_unknown_format && rescue_format = self.class.rescue_unknown_format_with
      request.format = rescue_format
      already_rescued_unknown_format = true
      retry
    else
      raise
    end
  end
end

#rootObject

Default action for API root.



146
147
148
# File 'lib/rest_framework/controller.rb', line 146

def root
  render(api: { message: "This is the API root." })
end

#route_groupsObject



509
510
511
# File 'lib/rest_framework/controller.rb', line 509

def route_groups
  @route_groups ||= RESTFramework::Utils.get_routes(Rails.application.routes, request)
end

#rrf_error_handler(e) ⇒ Object



489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
# File 'lib/rest_framework/controller.rb', line 489

def rrf_error_handler(e)
  status = case e
  when ActiveRecord::RecordNotFound
    404
  when RESTFramework::BulkRecordErrorsError
    422
  else
    400
  end

  render(
    api: {
      message: e.message,
      errors: e.try(:record).try(:errors),
      exception: RESTFramework.config.show_backtrace ? e.full_message : nil,
    }.compact,
    status: status,
  )
end

#serialize(data, **kwargs) ⇒ Object

Serialize the given data using the ‘serializer_class`.



483
484
485
486
487
# File 'lib/rest_framework/controller.rb', line 483

def serialize(data, **kwargs)
  RESTFramework::Utils.wrap_ams(self.get_serializer_class).new(
    data, controller: self, **kwargs
  ).serialize
end

#showObject



41
42
43
# File 'lib/rest_framework/controller/crud.rb', line 41

def show
  render(api: self.get_record)
end

#updateObject



45
46
47
# File 'lib/rest_framework/controller/crud.rb', line 45

def update
  render(api: self.update!)
end

#update!Object

Perform the ‘update!` call and return the updated record.



50
51
52
53
54
# File 'lib/rest_framework/controller/crud.rb', line 50

def update!
  record = self.get_record
  record.update!(self.get_update_params)
  record
end

#update_allObject



146
147
148
149
150
151
152
153
154
# File 'lib/rest_framework/controller/bulk.rb', line 146

def update_all
  if self._bulk_mode == :raw
    result = self.update_all_raw!
    return render(api: { message: "Bulk update successful.", result: result })
  end

  records = self.update_all_default!
  render(api: { message: "Bulk update successful.", records: self._bulk_serialize(records) })
end

#update_all_default!Object



184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
# File 'lib/rest_framework/controller/bulk.rb', line 184

def update_all_default!
  pk = self.class.model.primary_key
  data = self._bulk_object_data(:update, :default)

  data_ids = data.map { |r| r[pk] }.uniq
  if data_ids.include?(nil)
    raise RESTFramework::InvalidBulkParametersError.new(
      "Bulk update requires the primary key (#{pk}) for all records.",
    )
  end
  existing = self.get_recordset.where(pk => data_ids).index_by { |r| r.send(pk) }
  if existing.length != data_ids.length
    missing = data_ids - existing.keys
    raise RESTFramework::InvalidBulkParametersError.new(
      "Records not found with #{pk}: #{missing.join(', ')}.",
    )
  end

  # Assign attributes to each record.
  records = data.map { |attrs|
    record = existing[attrs[pk]]
    record.assign_attributes(attrs.except(pk))
    record
  }

  if self._bulk_partial
    # Partial: save each record individually.
    records.each(&:save)
    records
  else
    # Transactional: validate all first, then save in a transaction or raise.
    failed = records.reject(&:valid?)

    if failed.any?
      raise RESTFramework::BulkRecordErrorsError.new(records)
    end

    self.class.model.transaction do
      records.each(&:save!)
    end

    records
  end
end

#update_all_raw!Object



156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/rest_framework/controller/bulk.rb', line 156

def update_all_raw!
  pk = self.class.model.primary_key
  data = self._bulk_object_data(:update, :raw)

  data_ids = data.map { |r| r[pk] }.uniq
  if data_ids.include?(nil)
    raise RESTFramework::InvalidBulkParametersError.new(
      "Bulk update requires the primary key (#{pk}) for all records.",
    )
  end
  found_ids = self.get_recordset.where(pk => data_ids).pluck(pk)
  if found_ids.length != data_ids.length
    missing = data_ids - found_ids
    raise RESTFramework::InvalidBulkParametersError.new(
      "Records not found with #{pk}: #{missing.join(', ')}.",
    )
  end

  unless first_keys = data.first&.keys&.sort
    raise RESTFramework::InvalidBulkParametersError.new("Expected objects with attrs.")
  end
  unless data.all? { |r| r.keys.sort == first_keys }
    raise RESTFramework::InvalidBulkParametersError.new("All objects must have the same attrs.")
  end

  self.get_recordset.upsert_all(data, unique_by: pk)
end