actionpack/lib/action_controller/metal/params_wrapper.rb

Summary

Maintainability
B
4 hrs
Test Coverage
# frozen_string_literal: true

# :markup: markdown

require "active_support/core_ext/hash/slice"
require "active_support/core_ext/hash/except"
require "active_support/core_ext/module/anonymous"
require "action_dispatch/http/mime_type"

module ActionController
  # # Action Controller Params Wrapper
  #
  # Wraps the parameters hash into a nested hash. This will allow clients to
  # submit requests without having to specify any root elements.
  #
  # This functionality is enabled by default for JSON, and can be customized by
  # setting the format array:
  #
  #     class ApplicationController < ActionController::Base
  #       wrap_parameters format: [:json, :xml]
  #     end
  #
  # You could also turn it on per controller:
  #
  #     class UsersController < ApplicationController
  #       wrap_parameters format: [:json, :xml, :url_encoded_form, :multipart_form]
  #     end
  #
  # If you enable `ParamsWrapper` for `:json` format, instead of having to send
  # JSON parameters like this:
  #
  #     {"user": {"name": "Konata"}}
  #
  # You can send parameters like this:
  #
  #     {"name": "Konata"}
  #
  # And it will be wrapped into a nested hash with the key name matching the
  # controller's name. For example, if you're posting to `UsersController`, your
  # new `params` hash will look like this:
  #
  #     {"name" => "Konata", "user" => {"name" => "Konata"}}
  #
  # You can also specify the key in which the parameters should be wrapped to, and
  # also the list of attributes it should wrap by using either `:include` or
  # `:exclude` options like this:
  #
  #     class UsersController < ApplicationController
  #       wrap_parameters :person, include: [:username, :password]
  #     end
  #
  # On Active Record models with no `:include` or `:exclude` option set, it will
  # only wrap the parameters returned by the class method `attribute_names`.
  #
  # If you're going to pass the parameters to an `ActiveModel` object (such as
  # `User.new(params[:user])`), you might consider passing the model class to the
  # method instead. The `ParamsWrapper` will actually try to determine the list of
  # attribute names from the model and only wrap those attributes:
  #
  #     class UsersController < ApplicationController
  #       wrap_parameters Person
  #     end
  #
  # You still could pass `:include` and `:exclude` to set the list of attributes
  # you want to wrap.
  #
  # By default, if you don't specify the key in which the parameters would be
  # wrapped to, `ParamsWrapper` will actually try to determine if there's a model
  # related to it or not. This controller, for example:
  #
  #     class Admin::UsersController < ApplicationController
  #     end
  #
  # will try to check if `Admin::User` or `User` model exists, and use it to
  # determine the wrapper key respectively. If both models don't exist, it will
  # then fall back to use `user` as the key.
  #
  # To disable this functionality for a controller:
  #
  #     class UsersController < ApplicationController
  #       wrap_parameters false
  #     end
  module ParamsWrapper
    extend ActiveSupport::Concern

    EXCLUDE_PARAMETERS = %w(authenticity_token _method utf8)

    class Options < Struct.new(:name, :format, :include, :exclude, :klass, :model) # :nodoc:
      def self.from_hash(hash)
        name    = hash[:name]
        format  = Array(hash[:format])
        include = hash[:include] && Array(hash[:include]).collect(&:to_s)
        exclude = hash[:exclude] && Array(hash[:exclude]).collect(&:to_s)
        new name, format, include, exclude, nil, nil
      end

      def initialize(name, format, include, exclude, klass, model) # :nodoc:
        super
        @mutex = Mutex.new
        @include_set = include
        @name_set    = name
      end

      def model
        super || self.model = _default_wrap_model
      end

      def include
        return super if @include_set

        m = model
        @mutex.synchronize do
          return super if @include_set

          @include_set = true

          unless super || exclude
            if m.respond_to?(:attribute_names) && m.attribute_names.any?
              self.include = m.attribute_names

              if m.respond_to?(:stored_attributes) && !m.stored_attributes.empty?
                self.include += m.stored_attributes.values.flatten.map(&:to_s)
              end

              if m.respond_to?(:attribute_aliases) && m.attribute_aliases.any?
                self.include += m.attribute_aliases.keys
              end

              if m.respond_to?(:nested_attributes_options) && m.nested_attributes_options.keys.any?
                self.include += m.nested_attributes_options.keys.map do |key|
                  (+key.to_s).concat("_attributes")
                end
              end

              self.include
            end
          end
        end
      end

      def name
        return super if @name_set

        m = model
        @mutex.synchronize do
          return super if @name_set

          @name_set = true

          unless super || klass.anonymous?
            self.name = m ? m.to_s.demodulize.underscore :
              klass.controller_name.singularize
          end
        end
      end

      private
        # Determine the wrapper model from the controller's name. By convention, this
        # could be done by trying to find the defined model that has the same singular
        # name as the controller. For example, `UsersController` will try to find if the
        # `User` model exists.
        #
        # This method also does namespace lookup. Foo::Bar::UsersController will try to
        # find Foo::Bar::User, Foo::User and finally User.
        def _default_wrap_model
          return nil if klass.anonymous?
          model_name = klass.name.delete_suffix("Controller").classify

          begin
            if model_klass = model_name.safe_constantize
              model_klass
            else
              namespaces = model_name.split("::")
              namespaces.delete_at(-2)
              break if namespaces.last == model_name
              model_name = namespaces.join("::")
            end
          end until model_klass

          model_klass
        end
    end

    included do
      class_attribute :_wrapper_options, default: Options.from_hash(format: [])
    end

    module ClassMethods
      def _set_wrapper_options(options)
        self._wrapper_options = Options.from_hash(options)
      end

      # Sets the name of the wrapper key, or the model which `ParamsWrapper` would use
      # to determine the attribute names from.
      #
      # #### Examples
      #     wrap_parameters format: :xml
      #       # enables the parameter wrapper for XML format
      #
      #     wrap_parameters :person
      #       # wraps parameters into +params[:person]+ hash
      #
      #     wrap_parameters Person
      #       # wraps parameters by determining the wrapper key from Person class
      #       # (+person+, in this case) and the list of attribute names
      #
      #     wrap_parameters include: [:username, :title]
      #       # wraps only +:username+ and +:title+ attributes from parameters.
      #
      #     wrap_parameters false
      #       # disables parameters wrapping for this controller altogether.
      #
      # #### Options
      # *   `:format` - The list of formats in which the parameters wrapper will be
      #     enabled.
      # *   `:include` - The list of attribute names which parameters wrapper will
      #     wrap into a nested hash.
      # *   `:exclude` - The list of attribute names which parameters wrapper will
      #     exclude from a nested hash.
      #
      def wrap_parameters(name_or_model_or_options, options = {})
        model = nil

        case name_or_model_or_options
        when Hash
          options = name_or_model_or_options
        when false
          options = options.merge(format: [])
        when Symbol, String
          options = options.merge(name: name_or_model_or_options)
        else
          model = name_or_model_or_options
        end

        opts = Options.from_hash _wrapper_options.to_h.slice(:format).merge(options)
        opts.model = model
        opts.klass = self

        self._wrapper_options = opts
      end

      # Sets the default wrapper key or model which will be used to determine wrapper
      # key and attribute names. Called automatically when the module is inherited.
      def inherited(klass)
        if klass._wrapper_options.format.any?
          params = klass._wrapper_options.dup
          params.klass = klass
          klass._wrapper_options = params
        end
        super
      end
    end

    private
      # Performs parameters wrapping upon the request. Called automatically by the
      # metal call stack.
      def process_action(*)
        _perform_parameter_wrapping if _wrapper_enabled?
        super
      end

      # Returns the wrapper key which will be used to store wrapped parameters.
      def _wrapper_key
        _wrapper_options.name
      end

      # Returns the list of enabled formats.
      def _wrapper_formats
        _wrapper_options.format
      end

      # Returns the list of parameters which will be selected for wrapped.
      def _wrap_parameters(parameters)
        { _wrapper_key => _extract_parameters(parameters) }
      end

      def _extract_parameters(parameters)
        if include_only = _wrapper_options.include
          parameters.slice(*include_only)
        elsif _wrapper_options.exclude
          exclude = _wrapper_options.exclude + EXCLUDE_PARAMETERS
          parameters.except(*exclude)
        else
          parameters.except(*EXCLUDE_PARAMETERS)
        end
      end

      # Checks if we should perform parameters wrapping.
      def _wrapper_enabled?
        return false unless request.has_content_type?

        ref = request.content_mime_type.ref

        _wrapper_formats.include?(ref) && _wrapper_key && !request.parameters.key?(_wrapper_key)
      rescue ActionDispatch::Http::Parameters::ParseError
        false
      end

      def _perform_parameter_wrapping
        wrapped_hash = _wrap_parameters request.request_parameters
        wrapped_keys = request.request_parameters.keys
        wrapped_filtered_hash = _wrap_parameters request.filtered_parameters.slice(*wrapped_keys)

        # This will make the wrapped hash accessible from controller and view.
        request.parameters.merge! wrapped_hash
        request.request_parameters.merge! wrapped_hash

        # This will display the wrapped hash in the log file.
        request.filtered_parameters.merge! wrapped_filtered_hash
      end
  end
end