I found the main reason for the above behavior: in Rails 5 this is because the CONTENT_TYPE header is set to 'application/x-www-form-urlencoded' by the ActionController :: TestRequest #assign_parameters method by default, however in Rails 4.2. 0 it is not.
The following are detailed findings on how I came to the conclusion:
In the context of the parameters passed in the specification example (shown in my question post), execution is performed in Rails 5 (and its version of Rack) and Rails 4.2.0 (and its version of Rack) goes as follows:
Rails 5
actionpack / Library / action_dispatch / http_request.rb # form_data? returns true
actionpack / lib / action_dispatch / http_request.rb # The POST method looks like this:
# Override Rack POST method to support indifferent access def POST fetch_header("action_dispatch.request.request_parameters") do pr = parse_formatted_parameters(params_parsers) do |params| super || {} end self.request_parameters = Request::Utils.normalize_encode_params(pr) end rescue ParamsParser::ParseError # one of the parse strategies blew up self.request_parameters = Request::Utils.normalize_encode_params(super || {}) raise rescue Rack::Utils::ParameterTypeError, Rack::Utils::InvalidParameterError => e raise ActionController::BadRequest.new("Invalid request parameters: #{e.message}") end alias :request_parameters :POST
When trying to evaluate fetch_header("action_dispatch.request.request_parameters") , a default block is executed that calls super , which calls the POST Rack Request method (/rack-c393176b0edf/lib/rack/request.rb). I showed this method code below with a few debuggers that I installed:
racks / lib / rack / request.rb # POST
# Returns the data received in the request body. # # This method support both application/x-www-form-urlencoded and # multipart/form-data. def POST puts ">>>>>>>>>>> DEBUG 2" if get_header(RACK_INPUT).nil? puts ">>>>>>>>>>> DEBUG 2.1" raise "Missing rack.input" elsif get_header(RACK_REQUEST_FORM_INPUT) == get_header(RACK_INPUT) puts ">>>>>>>>>>> DEBUG 2.2" get_header(RACK_REQUEST_FORM_HASH) elsif form_data? || parseable_data? puts ">>>>>>>>>>> DEBUG 2.3" unless set_header(RACK_REQUEST_FORM_HASH, parse_multipart) form_vars = get_header(RACK_INPUT).read # Fix for Safari Ajax postings that always append \0 # form_vars.sub!(/\0\z/, '') # performance replacement: form_vars.slice!(-1) if form_vars[-1] == ?\0 set_header RACK_REQUEST_FORM_VARS, form_vars set_header RACK_REQUEST_FORM_HASH, parse_query(form_vars, '&') get_header(RACK_INPUT).rewind end set_header RACK_REQUEST_FORM_INPUT, get_header(RACK_INPUT) get_header RACK_REQUEST_FORM_HASH else puts ">>>>>>>>>>> DEBUG 2.4" {} end
With these debugging operations, the thread ended in "β β β β β> DEBUG 2.3" . There I also checked get_header RACK_REQUEST_FORM_HASH and printed
>>>>>>>>>>> get_header RACK_REQUEST_FORM_HASH: {"address_other"=>"", "address_street"=>"", "city"=>"", "client_residence_type_id"=>"", "name"=>"Test Client 1", "phone"=>"", "provider_id"=>"64", "state"=>"", "zip_code"=>""}
So this is the parse_query(form_vars, '&') method, which converts nil values ββto empty strings.
Rails 4.2.0
actionpack / Library / action_dispatch / http_request.rb # form_data? returns false
actionpack / lib / action_dispatch / http_request.rb # The POST method looks like this:
# Override Rack POST method to support indifferent access def POST @env["action_dispatch.request.request_parameters"] ||= Utils.deep_munge(normalize_encode_params(super || {})) rescue Rack::Utils::ParameterTypeError, Rack::Utils::InvalidParameterError => e raise ActionController::BadRequest.new(:request, e) end alias :request_parameters :POST
This calls super , making the call a switch to the POST Rack Request method (rack-1.6.4 / lib / rack / request.rb). I showed this method code below with a few debuggers that I installed:
rack-mounted 1.6.4 / Library / rack / request.rb # parseable_data? returns false
rack-1.6.4 / lib / rack / request.rb # POST stream ended in "β β β β β> DEBUG 2.4"
def POST puts ">>>>>>>>>>> DEBUG 2" if @env["rack.input"].nil? puts ">>>>>>>>>>> DEBUG 2.1" raise "Missing rack.input" elsif @env["rack.request.form_input"].equal? @env["rack.input"] puts ">>>>>>>>>>> DEBUG 2.2" @env["rack.request.form_hash"] elsif form_data? || parseable_data? puts ">>>>>>>>>>> DEBUG 2.3" unless @env["rack.request.form_hash"] = parse_multipart(env) form_vars = @env["rack.input"].read
This leads to my observation that in Rails 5 the content_mime_type , which is used internally by form_data? , is set, and therefore, the parameters presented in the specification example are analyzed as form parameters.
However, in Rails 4.2.0 content_mime_type no set was found which does not lead to the analysis of parameters that should be analyzed as form_params.
Rails 4.2.0
The content_mime_type method is defined in the ActionDispatch::Http::MimeNegotiation
def content_mime_type @env["action_dispatch.request.content_type"] ||= begin if @env['CONTENT_TYPE'] =~ /^([^,\;]*)/ Mime::Type.lookup($1.strip.downcase) else nil end end end
which returns nil
Rails 5
The content_mime_type method is defined in the ActionDispatch::Http::MimeNegotiation
def content_mime_type fetch_header("action_dispatch.request.content_type") do |k| v = if get_header('CONTENT_TYPE') =~ /^([^,\;]*)/ Mime::Type.lookup($1.strip.downcase) else nil end set_header k, v end end
In this case, if get_header('CONTENT_TYPE') =~ /^([^,\;]*)/ evaluates to true and therefore Mime::Type.lookup($1.strip.downcase)
Rails 4.2.0
CONTENT_TYPE header not set
actionpack / lib / action_controller / test_case.rb # def assign_parameters (routes, controller_path, action, parameters = {}) method
def assign_parameters(routes, controller_path, action, parameters = {}) parameters = parameters.symbolize_keys.merge(:controller => controller_path, :action => action) extra_keys = routes.extra_keys(parameters) non_path_parameters = get? ? query_parameters : request_parameters parameters.each do |key, value| if value.is_a?(Array) && (value.frozen? || value.any?(&:frozen?)) value = value.map{ |v| v.duplicable? ? v.dup : v } elsif value.is_a?(Hash) && (value.frozen? || value.any?{ |k,v| v.frozen? }) value = Hash[value.map{ |k,v| [k, v.duplicable? ? v.dup : v] }] elsif value.frozen? && value.duplicable? value = value.dup end if extra_keys.include?(key) non_path_parameters[key] = value else if value.is_a?(Array) value = value.map(&:to_param) else value = value.to_param end path_parameters[key] = value end end
Rails 5
CONTENT_TYPE header is set.
actionpack / lib / action_controller / test_case.rb # assign_parameters (routes, controller_path, action, parameters, generated_package, query_string_keys) method
def assign_parameters(routes, controller_path, action, parameters, generated_path, query_string_keys) non_path_parameters = {} path_parameters = {} parameters.each do |key, value| if query_string_keys.include?(key) non_path_parameters[key] = value else if value.is_a?(Array) value = value.map(&:to_param) else value = value.to_param end path_parameters[key] = value end end if get? if self.query_string.blank? self.query_string = non_path_parameters.to_query end else if ENCODER.should_multipart?(non_path_parameters) self.content_type = ENCODER.content_type data = ENCODER.build_multipart non_path_parameters else fetch_header('CONTENT_TYPE') do |k| set_header k, 'application/x-www-form-urlencoded' end case content_mime_type.to_sym when nil raise "Unknown Content-Type: #{content_type}" when :json data = ActiveSupport::JSON.encode(non_path_parameters) when :xml data = non_path_parameters.to_xml when :url_encoded_form data = non_path_parameters.to_query else @custom_param_parsers[content_mime_type] = ->(_) { non_path_parameters } data = non_path_parameters.to_query end end set_header 'CONTENT_LENGTH', data.length.to_s set_header 'rack.input', StringIO.new(data) end fetch_header("PATH_INFO") do |k| set_header k, generated_path end path_parameters[:controller] = controller_path path_parameters[:action] = action self.path_parameters = path_parameters end
As you can see from the POST request, the following code is executed, which sets the CONTENT_TYPE header to the default value "application / x-www-form-urlencoded"
fetch_header('CONTENT_TYPE') do |k| set_header k, 'application/x-www-form-urlencoded' end
Thanks.