Skip to content

Commit

Permalink
Introduce the request.body stream. Lazy-read to parse parameters rath…
Browse files Browse the repository at this point in the history
…er than always setting RAW_POST_DATA. Reduces the memory footprint of large binary PUT requests.

git-svn-id: http://svn-commit.rubyonrails.org/rails/trunk@6740 5ecf4fe2-1ee6-0310-87b1-e25e094e27de
  • Loading branch information
jeremy committed May 15, 2007
1 parent 9e3a51e commit 32d03af
Show file tree
Hide file tree
Showing 12 changed files with 142 additions and 79 deletions.
2 changes: 2 additions & 0 deletions actionpack/CHANGELOG
@@ -1,5 +1,7 @@
*SVN* *SVN*


* Introduce the request.body stream. Lazy-read to parse parameters rather than always setting RAW_POST_DATA. Reduces the memory footprint of large binary PUT requests. [Jeremy Kemper]

* Add some performance enhancements to ActionView. * Add some performance enhancements to ActionView.


* Cache base_paths in @@cached_base_paths * Cache base_paths in @@cached_base_paths
Expand Down
10 changes: 10 additions & 0 deletions actionpack/lib/action_controller/cgi_ext.rb
@@ -1,8 +1,18 @@
require 'action_controller/cgi_ext/stdinput'
require 'action_controller/cgi_ext/parameters' require 'action_controller/cgi_ext/parameters'
require 'action_controller/cgi_ext/query_extension' require 'action_controller/cgi_ext/query_extension'
require 'action_controller/cgi_ext/cookie' require 'action_controller/cgi_ext/cookie'
require 'action_controller/cgi_ext/session' require 'action_controller/cgi_ext/session'


class CGI #:nodoc: class CGI #:nodoc:
include ActionController::CgiExt::Stdinput
include ActionController::CgiExt::Parameters include ActionController::CgiExt::Parameters

class << self
alias :escapeHTML_fail_on_nil :escapeHTML

def escapeHTML(string)
escapeHTML_fail_on_nil(string) unless string.nil?
end
end
end end
20 changes: 5 additions & 15 deletions actionpack/lib/action_controller/cgi_ext/parameters.rb
@@ -1,16 +1,6 @@
require 'cgi' require 'cgi'
require 'strscan' require 'strscan'


class CGI #:nodoc:
class << self
alias :escapeHTML_fail_on_nil :escapeHTML

def escapeHTML(string)
escapeHTML_fail_on_nil(string) unless string.nil?
end
end
end

module ActionController module ActionController
module CgiExt module CgiExt
module Parameters module Parameters
Expand Down Expand Up @@ -72,18 +62,18 @@ def parse_request_parameters(params)
parser.result parser.result
end end


def parse_formatted_request_parameters(mime_type, raw_post_data) def parse_formatted_request_parameters(mime_type, body)
case strategy = ActionController::Base.param_parsers[mime_type] case strategy = ActionController::Base.param_parsers[mime_type]
when Proc when Proc
strategy.call(raw_post_data) strategy.call(body)
when :xml_simple, :xml_node when :xml_simple, :xml_node
raw_post_data.blank? ? {} : Hash.from_xml(raw_post_data).with_indifferent_access body.blank? ? {} : Hash.from_xml(body).with_indifferent_access
when :yaml when :yaml
YAML.load(raw_post_data) YAML.load(body)
end end
rescue Exception => e # YAML, XML or Ruby code block errors rescue Exception => e # YAML, XML or Ruby code block errors
{ "exception" => "#{e.message} (#{e.class})", "backtrace" => e.backtrace, { "exception" => "#{e.message} (#{e.class})", "backtrace" => e.backtrace,
"raw_post_data" => raw_post_data, "format" => mime_type } "body" => body, "format" => mime_type }
end end


private private
Expand Down
10 changes: 3 additions & 7 deletions actionpack/lib/action_controller/cgi_ext/query_extension.rb
Expand Up @@ -31,18 +31,14 @@ def initialize_query
# Set multipart to false by default. # Set multipart to false by default.
@multipart = false @multipart = false


# POST and PUT may have params in entity body. If content type is # POST and PUT may have params in entity body. If content type is missing
# missing for POST, assume urlencoded. If content type is missing # or non-urlencoded, don't read the body or parse parameters: assume it's
# for PUT, don't assume anything and don't parse the parameters: # binary data.
# it's likely binary data.
#
# The other HTTP methods have their params in the query string.
if method == :post || method == :put if method == :post || method == :put
if boundary = extract_multipart_form_boundary(content_type) if boundary = extract_multipart_form_boundary(content_type)
@multipart = true @multipart = true
@params = read_multipart(boundary, content_length) @params = read_multipart(boundary, content_length)
elsif content_type.blank? || content_type !~ %r{application/x-www-form-urlencoded}i elsif content_type.blank? || content_type !~ %r{application/x-www-form-urlencoded}i
read_params(method, content_length)
@params = {} @params = {}
end end
end end
Expand Down
23 changes: 23 additions & 0 deletions actionpack/lib/action_controller/cgi_ext/stdinput.rb
@@ -0,0 +1,23 @@
require 'cgi'

module ActionController
module CgiExt
# Publicize the CGI's internal input stream so we can lazy-read
# request.body. Make it writable so we don't have to play $stdin games.
module Stdinput
def self.included(base)
base.class_eval do
remove_method :stdinput
attr_accessor :stdinput
end

base.alias_method_chain :initialize, :stdinput
end

def initialize_with_stdinput(type = nil, stdinput = $stdin)
@stdinput = stdinput
initialize_without_stdinput(type || 'query')
end
end
end
end
12 changes: 11 additions & 1 deletion actionpack/lib/action_controller/cgi_process.rb
Expand Up @@ -58,6 +58,16 @@ def query_string
end end
end end


# The request body is an IO input stream. If the RAW_POST_DATA environment
# variable is already set, wrap it in a StringIO.
def body
if raw_post = env['RAW_POST_DATA']
StringIO.new(raw_post)
else
@cgi.stdinput
end
end

def query_parameters def query_parameters
@query_parameters ||= @query_parameters ||=
(qs = self.query_string).empty? ? {} : CGI.parse_query_parameters(qs) (qs = self.query_string).empty? ? {} : CGI.parse_query_parameters(qs)
Expand All @@ -66,7 +76,7 @@ def query_parameters
def request_parameters def request_parameters
@request_parameters ||= @request_parameters ||=
if ActionController::Base.param_parsers.has_key?(content_type) if ActionController::Base.param_parsers.has_key?(content_type)
CGI.parse_formatted_request_parameters(content_type, @env['RAW_POST_DATA']) CGI.parse_formatted_request_parameters(content_type, body.read)
else else
CGI.parse_request_parameters(@cgi.params) CGI.parse_request_parameters(@cgi.params)
end end
Expand Down
34 changes: 21 additions & 13 deletions actionpack/lib/action_controller/request.rb
@@ -1,24 +1,27 @@
module ActionController module ActionController
# Subclassing AbstractRequest makes these methods available to the request objects used in production and testing, # CgiRequest and TestRequest provide concrete implementations.
# CgiRequest and TestRequest
class AbstractRequest class AbstractRequest
cattr_accessor :relative_url_root cattr_accessor :relative_url_root
remove_method :relative_url_root remove_method :relative_url_root


# Returns the hash of environment variables for this request, # The hash of environment variables for this request,
# such as { 'RAILS_ENV' => 'production' }. # such as { 'RAILS_ENV' => 'production' }.
attr_reader :env attr_reader :env


attr_accessor :format # The requested content type, such as :html or :xml.
attr_writer :format


# Returns the HTTP request method as a lowercase symbol (:get, for example). Note, HEAD is returned as :get # The HTTP request method as a lowercase symbol, such as :get.
# since the two are supposedly to be functionaly equivilent for all purposes except that HEAD won't return a response # Note, HEAD is returned as :get since the two are functionally
# body (which Rails also takes care of elsewhere). # equivalent from the application's perspective.
def method def method
@request_method ||= (!parameters[:_method].blank? && @env['REQUEST_METHOD'] == 'POST') ? @request_method ||=
parameters[:_method].to_s.downcase.to_sym : if @env['REQUEST_METHOD'] == 'POST' && !parameters[:_method].blank?
@env['REQUEST_METHOD'].downcase.to_sym parameters[:_method].to_s.downcase.to_sym

else
@env['REQUEST_METHOD'].downcase.to_sym
end

@request_method == :head ? :get : @request_method @request_method == :head ? :get : @request_method
end end


Expand All @@ -42,8 +45,8 @@ def delete?
method == :delete method == :delete
end end


# Is this a HEAD request? HEAD is mapped as :get for request.method, so here we ask the # Is this a HEAD request? request.method sees HEAD as :get, so check the
# REQUEST_METHOD header directly. Thus, for head, both get? and head? will return true. # HTTP method directly.
def head? def head?
@env['REQUEST_METHOD'].downcase.to_sym == :head @env['REQUEST_METHOD'].downcase.to_sym == :head
end end
Expand Down Expand Up @@ -269,6 +272,11 @@ def path_parameters
#-- #--
# Must be implemented in the concrete request # Must be implemented in the concrete request
#++ #++

# The request body is an IO input stream.
def body
end

def query_parameters #:nodoc: def query_parameters #:nodoc:
end end


Expand Down
30 changes: 19 additions & 11 deletions actionpack/lib/action_controller/test_process.rb
Expand Up @@ -40,18 +40,15 @@ def reset_session
@session = TestSession.new @session = TestSession.new
end end


# Wraps raw_post in a StringIO.
def body
StringIO.new(raw_post)
end

# Either the RAW_POST_DATA environment variable or the URL-encoded request
# parameters.
def raw_post def raw_post
if raw_post = env['RAW_POST_DATA'] env['RAW_POST_DATA'] ||= url_encoded_request_parameters
raw_post
else
params = self.request_parameters.dup
%w(controller action only_path).each do |k|
params.delete(k)
params.delete(k.to_sym)
end

params.map { |k,v| [ CGI.escape(k.to_s), CGI.escape(v.to_s) ].join('=') }.sort.join('&')
end
end end


def port=(number) def port=(number)
Expand Down Expand Up @@ -140,6 +137,17 @@ def initialize_default_values
@env["SERVER_PORT"] = 80 @env["SERVER_PORT"] = 80
@env['REQUEST_METHOD'] = "GET" @env['REQUEST_METHOD'] = "GET"
end end

def url_encoded_request_parameters
params = self.request_parameters.dup

%w(controller action only_path).each do |k|
params.delete(k)
params.delete(k.to_sym)
end

params.to_query
end
end end


# A refactoring of TestResponse to allow the same behavior to be applied # A refactoring of TestResponse to allow the same behavior to be applied
Expand Down
2 changes: 2 additions & 0 deletions actionpack/test/abstract_unit.rb
Expand Up @@ -3,8 +3,10 @@
$:.unshift(File.dirname(__FILE__) + '/fixtures/helpers') $:.unshift(File.dirname(__FILE__) + '/fixtures/helpers')


require 'yaml' require 'yaml'
require 'stringio'
require 'test/unit' require 'test/unit'
require 'action_controller' require 'action_controller'
require 'action_controller/cgi_ext'
require 'action_controller/test_process' require 'action_controller/test_process'


# Show backtraces for deprecated behavior for quicker cleanup. # Show backtraces for deprecated behavior for quicker cleanup.
Expand Down
53 changes: 30 additions & 23 deletions actionpack/test/controller/raw_post_test.rb
@@ -1,6 +1,4 @@
require "#{File.dirname(__FILE__)}/../abstract_unit" require "#{File.dirname(__FILE__)}/../abstract_unit"
require 'stringio'
require 'action_controller/cgi_ext/query_extension'


class RawPostDataTest < Test::Unit::TestCase class RawPostDataTest < Test::Unit::TestCase
def setup def setup
Expand All @@ -11,57 +9,66 @@ def setup
def test_post_with_urlencoded_body def test_post_with_urlencoded_body
ENV['REQUEST_METHOD'] = 'POST' ENV['REQUEST_METHOD'] = 'POST'
ENV['CONTENT_TYPE'] = ' apPlication/x-Www-form-urlEncoded; charset=utf-8' ENV['CONTENT_TYPE'] = ' apPlication/x-Www-form-urlEncoded; charset=utf-8'
assert_equal ['1'], cgi_params['a'] assert_equal ['1'], cgi.params['a']
assert_has_raw_post_data assert_raw_post_data
end end


def test_post_with_empty_content_type_treated_as_urlencoded def test_post_with_empty_content_type_treated_as_urlencoded
ENV['REQUEST_METHOD'] = 'POST' ENV['REQUEST_METHOD'] = 'POST'
ENV['CONTENT_TYPE'] = '' ENV['CONTENT_TYPE'] = ''
assert_equal ['1'], cgi_params['a'] assert_equal ['1'], cgi.params['a']
assert_has_raw_post_data assert_raw_post_data
end end


def test_post_with_unrecognized_content_type_reads_body_but_doesnt_parse_params def test_post_with_unrecognized_content_type_ignores_body
ENV['REQUEST_METHOD'] = 'POST' ENV['REQUEST_METHOD'] = 'POST'
ENV['CONTENT_TYPE'] = 'foo/bar' ENV['CONTENT_TYPE'] = 'foo/bar'
assert cgi_params.empty? assert cgi.params.empty?
assert_has_raw_post_data assert_no_raw_post_data
end end


def test_put_with_urlencoded_body def test_put_with_urlencoded_body
ENV['REQUEST_METHOD'] = 'PUT' ENV['REQUEST_METHOD'] = 'PUT'
ENV['CONTENT_TYPE'] = 'application/x-www-form-urlencoded' ENV['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'
assert_equal ['1'], cgi_params['a'] assert_equal ['1'], cgi.params['a']
assert_has_raw_post_data assert_raw_post_data
end end


def test_put_with_empty_content_type_ignores_body def test_put_with_empty_content_type_ignores_body
ENV['REQUEST_METHOD'] = 'PUT' ENV['REQUEST_METHOD'] = 'PUT'
ENV['CONTENT_TYPE'] = '' ENV['CONTENT_TYPE'] = ''
assert cgi_params.empty? assert cgi.params.empty?
assert_has_raw_post_data assert_no_raw_post_data
end end


def test_put_with_unrecognized_content_type_ignores_body def test_put_with_unrecognized_content_type_ignores_body
ENV['REQUEST_METHOD'] = 'PUT' ENV['REQUEST_METHOD'] = 'PUT'
ENV['CONTENT_TYPE'] = 'foo/bar' ENV['CONTENT_TYPE'] = 'foo/bar'
assert cgi_params.empty? assert cgi.params.empty?
assert_has_raw_post_data assert_no_raw_post_data
end end


private private
def cgi_params def cgi
old_stdin, $stdin = $stdin, StringIO.new(@request_body.dup) unless defined? @cgi
ENV['CONTENT_LENGTH'] = $stdin.size.to_s ENV['CONTENT_LENGTH'] = @request_body.size.to_s
CGI.new.params @cgi = CGI.new('query', StringIO.new(@request_body.dup))
ensure end
$stdin = old_stdin
@cgi
end end


def assert_has_raw_post_data(expected_body = @request_body) def assert_raw_post_data
assert_not_nil ENV['RAW_POST_DATA'] assert_not_nil ENV['RAW_POST_DATA']
assert ENV['RAW_POST_DATA'].frozen? assert ENV['RAW_POST_DATA'].frozen?
assert_equal expected_body, ENV['RAW_POST_DATA'] assert_equal @request_body, ENV['RAW_POST_DATA']

assert_equal '', cgi.stdinput.read
end

def assert_no_raw_post_data
assert_nil ENV['RAW_POST_DATA']

assert_equal @request_body, cgi.stdinput.read
end end
end end
15 changes: 13 additions & 2 deletions actionpack/test/controller/test_test.rb
Expand Up @@ -13,6 +13,10 @@ def render_raw_post
render :text => request.raw_post render :text => request.raw_post
end end


def render_body
render :text => request.body.read
end

def test_params def test_params
render :text => params.inspect render :text => params.inspect
end end
Expand Down Expand Up @@ -93,8 +97,15 @@ def test_raw_post_handling
params = {:page => {:name => 'page name'}, 'some key' => 123} params = {:page => {:name => 'page name'}, 'some key' => 123}
get :render_raw_post, params.dup get :render_raw_post, params.dup


raw_post = params.map {|k,v| [CGI::escape(k.to_s), CGI::escape(v.to_s)].join('=')}.sort.join('&') assert_equal params.to_query, @response.body
assert_equal raw_post, @response.body end

def test_body_stream
params = { :page => { :name => 'page name' }, 'some key' => 123 }

get :render_body, params.dup

assert_equal params.to_query, @response.body
end end


def test_process_without_flash def test_process_without_flash
Expand Down

0 comments on commit 32d03af

Please sign in to comment.