gregschmit/rails-rest-framework

View on GitHub
lib/rest_framework/serializers/native_serializer.rb

Summary

Maintainability
A
1 hr
Test Coverage
# This serializer uses `.serializable_hash` to convert objects to Ruby primitives (with the
# top-level being either an array or a hash).
class RESTFramework::Serializers::NativeSerializer < RESTFramework::Serializers::BaseSerializer
  class_attribute :config
  class_attribute :singular_config
  class_attribute :plural_config
  class_attribute :action_config

  # Accept/ignore `*args` to be compatible with the `ActiveModel::Serializer#initialize` signature.
  def initialize(object=nil, *args, many: nil, model: nil, **kwargs)
    super(object, *args, **kwargs)

    if many.nil?
      # Determine if we are dealing with many objects or just one.
      @many = @object.is_a?(Enumerable)
    else
      @many = many
    end

    # Determine model either explicitly, or by inspecting @object or @controller.
    @model = model
    @model ||= @object.class if @object.is_a?(ActiveRecord::Base)
    @model ||= @object[0].class if
      @many && @object.is_a?(Enumerable) && @object.is_a?(ActiveRecord::Base)

    @model ||= @controller.class.get_model if @controller
  end

  # Get controller action, if possible.
  def get_action
    return @controller&.action_name&.to_sym
  end

  # Get a locally defined native serializer configuration, if one is defined.
  def get_local_native_serializer_config
    action = self.get_action

    if action && self.action_config
      # Index action should use :list serializer config if :index is not provided.
      action = :list if action == :index && !self.action_config.key?(:index)

      return self.action_config[action] if self.action_config[action]
    end

    # No action_config, so try singular/plural config if explicitly instructed to via @many.
    return self.plural_config if @many == true && self.plural_config
    return self.singular_config if @many == false && self.singular_config

    # Lastly, try returning the default config, or singular/plural config in that order.
    return self.config || self.singular_config || self.plural_config
  end

  # Get a native serializer configuration from the controller.
  def get_controller_native_serializer_config
    return nil unless @controller

    if @many == true
      controller_serializer = @controller.try(:native_serializer_plural_config)
    elsif @many == false
      controller_serializer = @controller.try(:native_serializer_singular_config)
    end

    return controller_serializer || @controller.try(:native_serializer_config)
  end

  # Filter a single subconfig for specific keys. By default, keys from `fields` are removed from the
  # provided `subcfg`. There are two (mutually exclusive) options to adjust the behavior:
  #
  #  `add`: Add any `fields` to the `subcfg` which aren't already in the `subcfg`.
  #  `only`: Remove any values found in the `subcfg` not in `fields`.
  def self.filter_subcfg(subcfg, fields:, add: false, only: false)
    raise "`add` and `only` conflict with one another" if add && only

    # Don't process nil `subcfg`s.
    return subcfg unless subcfg

    if subcfg.is_a?(Array)
      subcfg = subcfg.map(&:to_sym)

      if add
        # Only add fields which are not already included.
        subcfg += fields - subcfg
      elsif only
        subcfg.select! { |c| c.in?(fields) }
      else
        subcfg -= fields
      end
    elsif subcfg.is_a?(Hash)
      subcfg = subcfg.symbolize_keys

      if add
        # Add doesn't make sense in a hash context since we wouldn't know the values.
      elsif only
        subcfg.select! { |k, _v| k.in?(fields) }
      else
        subcfg.reject! { |k, _v| k.in?(fields) }
      end
    else  # Subcfg is a single element (assume string/symbol).
      subcfg = subcfg.to_sym

      if add
        subcfg = subcfg.in?(fields) ? fields : [subcfg, *fields]
      elsif only
        subcfg = subcfg.in?(fields) ? subcfg : []
      else
        subcfg = subcfg.in?(fields) ? [] : subcfg
      end
    end

    return subcfg
  end

  # Filter out configuration properties based on the :except/:only query parameters.
  def filter_from_request(cfg)
    return cfg unless @controller

    except_param = @controller.try(:native_serializer_except_query_param)
    only_param = @controller.try(:native_serializer_only_query_param)
    if except_param && except = @controller.request&.query_parameters&.[](except_param).presence
      if except = except.split(",").map(&:strip).map(&:to_sym).presence
        # Filter `only`, `except` (additive), `include`, `methods`, and `serializer_methods`.
        if cfg[:only]
          cfg[:only] = self.class.filter_subcfg(cfg[:only], fields: except)
        elsif cfg[:except]
          cfg[:except] = self.class.filter_subcfg(cfg[:except], fields: except, add: true)
        else
          cfg[:except] = except
        end

        cfg[:include] = self.class.filter_subcfg(cfg[:include], fields: except)
        cfg[:methods] = self.class.filter_subcfg(cfg[:methods], fields: except)
        cfg[:serializer_methods] = self.class.filter_subcfg(
          cfg[:serializer_methods], fields: except
        )
        cfg[:includes_map] = self.class.filter_subcfg(cfg[:includes_map], fields: except)
      end
    elsif only_param && only = @controller.request&.query_parameters&.[](only_param).presence
      if only = only.split(",").map(&:strip).map(&:to_sym).presence
        # Filter `only`, `include`, and `methods`. Adding anything to `except` is not needed,
        # because any configuration there takes precedence over `only`.
        if cfg[:only]
          cfg[:only] = self.class.filter_subcfg(cfg[:only], fields: only, only: true)
        else
          cfg[:only] = only
        end

        cfg[:include] = self.class.filter_subcfg(cfg[:include], fields: only, only: true)
        cfg[:methods] = self.class.filter_subcfg(cfg[:methods], fields: only, only: true)
        cfg[:serializer_methods] = self.class.filter_subcfg(
          cfg[:serializer_methods], fields: only, only: true
        )
        cfg[:includes_map] = self.class.filter_subcfg(cfg[:includes_map], fields: only, only: true)
      end
    end

    return cfg
  end

  # Get the associations limit from the controller.
  def _get_associations_limit
    return @_get_associations_limit if defined?(@_get_associations_limit)

    limit = @controller&.native_serializer_associations_limit

    # Extract the limit from the query parameters if it's set.
    if query_param = @controller&.native_serializer_associations_limit_query_param
      if @controller.request.query_parameters.key?(query_param)
        query_limit = @controller.request.query_parameters[query_param].to_i
        if query_limit > 0
          limit = query_limit
        else
          limit = nil
        end
      end
    end

    return @_get_associations_limit = limit
  end

  # Get a serializer configuration from the controller. `@controller` and `@model` must be set.
  def _get_controller_serializer_config(fields)
    columns = []
    includes = {}
    methods = []
    serializer_methods = {}

    # We try to construct performant queries using Active Record's `includes` method. This is
    # sometimes impossible, for example when limiting the number of associated records returned, so
    # we should only add associations here when it's useful, and using the `Bullet` gem is helpful
    # in determining when that is the case.
    includes_map = {}

    column_names = @model.column_names
    reflections = @model.reflections
    attachment_reflections = @model.attachment_reflections

    fields.each do |f|
      field_config = @controller.class.get_field_config(f)
      next if field_config[:write_only]

      if f.in?(column_names)
        columns << f
      elsif ref = reflections[f]
        sub_columns = []
        sub_methods = []
        field_config[:sub_fields].each do |sf|
          if !ref.polymorphic? && sf.in?(ref.klass.column_names)
            sub_columns << sf
          else
            sub_methods << sf
          end
        end
        sub_config = {only: sub_columns, methods: sub_methods}

        # Apply certain rules regarding collection associations.
        if ref.collection?
          # If we need to limit the number of serialized association records, then dynamically add a
          # serializer method to do so.
          if limit = self._get_associations_limit
            serializer_methods[f] = f
            self.define_singleton_method(f) do |record|
              next record.send(f).limit(limit).as_json(**sub_config)
            end

            # Disable this for now, as it's not clear if this improves performance of count.
            #
            # # Even though we use a serializer method, if the count will later be added, then put
            # # this field into the includes_map.
            # if @controller.native_serializer_include_associations_count
            #   includes_map[f] = f.to_sym
            # end
          else
            includes[f] = sub_config
            includes_map[f] = f.to_sym
          end

          # If we need to include the association count, then add it here.
          if @controller.native_serializer_include_associations_count
            method_name = "#{f}.count"
            serializer_methods[method_name] = method_name
            self.define_singleton_method(method_name) do |record|
              next record.send(f).count
            end
          end
        else
          includes[f] = sub_config
          includes_map[f] = f.to_sym
        end
      elsif ref = reflections["rich_text_#{f}"]
        # ActionText Integration: Define rich text serializer method.
        includes_map[f] = :"rich_text_#{f}"
        serializer_methods[f] = f
        self.define_singleton_method(f) do |record|
          next record.send(f).to_s
        end
      elsif ref = attachment_reflections[f]
        # ActiveStorage Integration: Define attachment serializer method.
        if ref.macro == :has_one_attached
          serializer_methods[f] = f
          includes_map[f] = {"#{f}_attachment": :blob}
          self.define_singleton_method(f) do |record|
            next record.send(f).attachment&.url
          end
        elsif ref.macro == :has_many_attached
          serializer_methods[f] = f
          includes_map[f] = {"#{f}_attachments": :blob}
          self.define_singleton_method(f) do |record|
            # Iterating the collection yields attachment objects.
            next record.send(f).map(&:url)
          end
        end
      elsif @model.method_defined?(f)
        methods << f
      end
    end

    return {
      only: columns,
      include: includes,
      methods: methods,
      serializer_methods: serializer_methods,
      includes_map: includes_map,
    }
  end

  # Get the raw serializer config, prior to any adjustments from the request.
  #
  # Use `deep_dup` on any class mutables (array, hash, etc) to avoid mutating class state.
  def get_raw_serializer_config
    # Return a locally defined serializer config if one is defined.
    if local_config = self.get_local_native_serializer_config
      return local_config.deep_dup
    end

    # Return a serializer config if one is defined on the controller.
    if serializer_config = self.get_controller_native_serializer_config
      return serializer_config.deep_dup
    end

    # If the config wasn't determined, build a serializer config from controller fields.
    if @model && fields = @controller&.get_fields
      return self._get_controller_serializer_config(fields.deep_dup)
    end

    # By default, pass an empty configuration, using the default Rails serializer.
    return {}
  end

  # Get a configuration passable to `serializable_hash` for the object, filtered if required.
  def get_serializer_config
    return self.filter_from_request(self.get_raw_serializer_config)
  end

  # Serialize a single record and merge results of `serializer_methods`.
  def _serialize(record, config, serializer_methods)
    # Ensure serializer_methods is either falsy, or a hash.
    if serializer_methods && !serializer_methods.is_a?(Hash)
      serializer_methods = [serializer_methods].flatten.map { |m| [m, m] }.to_h
    end

    # Merge serialized record with any serializer method results.
    return record.serializable_hash(config).merge(
      serializer_methods&.map { |m, k| [k.to_sym, self.send(m, record)] }.to_h,
    )
  end

  def serialize(*args)
    config = self.get_serializer_config
    serializer_methods = config.delete(:serializer_methods)
    includes_map = config.delete(:includes_map)

    if @object.respond_to?(:to_ary)
      # Preload associations using `includes` to avoid N+1 queries. For now this also allows filter
      # backends to use associated data; perhaps it may be wise to have a system in place for
      # filters to preload their own associations?
      @object = @object.includes(*includes_map.values) if includes_map.present?

      return @object.map { |r| self._serialize(r, config, serializer_methods) }
    end

    return self._serialize(@object, config, serializer_methods)
  end

  # Allow a serializer instance to be used as a hash directly in a nested serializer config.
  def [](key)
    @_nested_config ||= self.get_serializer_config
    return @_nested_config[key]
  end

  def []=(key, value)
    @_nested_config ||= self.get_serializer_config
    return @_nested_config[key] = value
  end

  # Allow a serializer class to be used as a hash directly in a nested serializer config.
  def self.[](key)
    @_nested_config ||= self.new.get_serializer_config
    return @_nested_config[key]
  end

  def self.[]=(key, value)
    @_nested_config ||= self.new.get_serializer_config
    return @_nested_config[key] = value
  end
end

# Alias for convenience.
RESTFramework::NativeSerializer = RESTFramework::Serializers::NativeSerializer