aboutsummaryrefslogtreecommitdiffstats
path: root/actionpack
diff options
context:
space:
mode:
authorEzra Zygmuntowicz <ez@engineyard.com>2008-06-01 11:25:11 -0700
committerJoshua Peek <josh@joshpeek.com>2008-06-01 11:25:11 -0700
commit06cb20708be13fbf736447aa0e5e6dd7d64c8b5d (patch)
treec2ac7798ef3aacbf7b44ef4b104fac71bc8a2eb7 /actionpack
parent3282bf3b5016f0c9028cfff1012e8c31a13b40b7 (diff)
downloadrails-06cb20708be13fbf736447aa0e5e6dd7d64c8b5d.tar.gz
rails-06cb20708be13fbf736447aa0e5e6dd7d64c8b5d.tar.bz2
rails-06cb20708be13fbf736447aa0e5e6dd7d64c8b5d.zip
Added Rack processor
Signed-off-by: Joshua Peek <josh@joshpeek.com>
Diffstat (limited to 'actionpack')
-rw-r--r--actionpack/CHANGELOG3
-rwxr-xr-xactionpack/lib/action_controller.rb1
-rw-r--r--actionpack/lib/action_controller/dispatcher.rb8
-rw-r--r--actionpack/lib/action_controller/rack_process.rb321
-rwxr-xr-xactionpack/test/controller/cgi_test.rb33
-rw-r--r--actionpack/test/controller/rack_test.rb150
6 files changed, 515 insertions, 1 deletions
diff --git a/actionpack/CHANGELOG b/actionpack/CHANGELOG
index cb684a925d..9622029362 100644
--- a/actionpack/CHANGELOG
+++ b/actionpack/CHANGELOG
@@ -1,3 +1,6 @@
+* Added Rack processor [Ezra Zygmuntowicz, Josh Peek]
+
+
*2.1.0 (May 31st, 2008)*
* InstanceTag#default_time_from_options overflows to DateTime [Geoff Buesing]
diff --git a/actionpack/lib/action_controller.rb b/actionpack/lib/action_controller.rb
index 810a5fb9b5..3c4a339d50 100755
--- a/actionpack/lib/action_controller.rb
+++ b/actionpack/lib/action_controller.rb
@@ -53,6 +53,7 @@ require 'action_controller/streaming'
require 'action_controller/session_management'
require 'action_controller/http_authentication'
require 'action_controller/components'
+require 'action_controller/rack_process'
require 'action_controller/record_identifier'
require 'action_controller/request_forgery_protection'
require 'action_controller/headers'
diff --git a/actionpack/lib/action_controller/dispatcher.rb b/actionpack/lib/action_controller/dispatcher.rb
index 6e1e7a261f..b40f1ba9be 100644
--- a/actionpack/lib/action_controller/dispatcher.rb
+++ b/actionpack/lib/action_controller/dispatcher.rb
@@ -96,7 +96,7 @@ module ActionController
include ActiveSupport::Callbacks
define_callbacks :prepare_dispatch, :before_dispatch, :after_dispatch
- def initialize(output, request = nil, response = nil)
+ def initialize(output = $stdout, request = nil, response = nil)
@output, @request, @response = output, request, response
end
@@ -123,6 +123,12 @@ module ActionController
failsafe_rescue exception
end
+ def call(env)
+ @request = RackRequest.new(env)
+ @response = RackResponse.new
+ dispatch
+ end
+
def reload_application
# Run prepare callbacks before every request in development mode
run_callbacks :prepare_dispatch
diff --git a/actionpack/lib/action_controller/rack_process.rb b/actionpack/lib/action_controller/rack_process.rb
new file mode 100644
index 0000000000..16625519b6
--- /dev/null
+++ b/actionpack/lib/action_controller/rack_process.rb
@@ -0,0 +1,321 @@
+require 'action_controller/cgi_ext'
+require 'action_controller/session/cookie_store'
+
+module ActionController #:nodoc:
+ class RackRequest < AbstractRequest #:nodoc:
+ attr_accessor :env, :session_options
+
+ class SessionFixationAttempt < StandardError #:nodoc:
+ end
+
+ DEFAULT_SESSION_OPTIONS = {
+ :database_manager => CGI::Session::CookieStore, # store data in cookie
+ :prefix => "ruby_sess.", # prefix session file names
+ :session_path => "/", # available to all paths in app
+ :session_key => "_session_id",
+ :cookie_only => true
+ } unless const_defined?(:DEFAULT_SESSION_OPTIONS)
+
+ def initialize(env, session_options = DEFAULT_SESSION_OPTIONS)
+ @session_options = session_options
+ @env = env
+ @cgi = CGIWrapper.new(self)
+ super()
+ 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
+ @env['rack.input']
+ end
+ end
+
+ def key?(key)
+ @env.key? key
+ end
+
+ def query_parameters
+ @query_parameters ||= self.class.parse_query_parameters(query_string)
+ end
+
+ def request_parameters
+ @request_parameters ||= parse_formatted_request_parameters
+ end
+
+ def cookies
+ return {} unless @env["HTTP_COOKIE"]
+
+ if @env["rack.request.cookie_string"] == @env["HTTP_COOKIE"]
+ @env["rack.request.cookie_hash"]
+ else
+ @env["rack.request.cookie_string"] = @env["HTTP_COOKIE"]
+ # According to RFC 2109:
+ # If multiple cookies satisfy the criteria above, they are ordered in
+ # the Cookie header such that those with more specific Path attributes
+ # precede those with less specific. Ordering with respect to other
+ # attributes (e.g., Domain) is unspecified.
+ @env["rack.request.cookie_hash"] =
+ parse_query(@env["rack.request.cookie_string"], ';,').inject({}) { |h, (k,v)|
+ h[k] = Array === v ? v.first : v
+ h
+ }
+ end
+ end
+
+ def host_with_port_without_standard_port_handling
+ if forwarded = @env["HTTP_X_FORWARDED_HOST"]
+ forwarded.split(/,\s?/).last
+ elsif http_host = @env['HTTP_HOST']
+ http_host
+ elsif server_name = @env['SERVER_NAME']
+ server_name
+ else
+ "#{env['SERVER_ADDR']}:#{env['SERVER_PORT']}"
+ end
+ end
+
+ def host
+ host_with_port_without_standard_port_handling.sub(/:\d+$/, '')
+ end
+
+ def port
+ if host_with_port_without_standard_port_handling =~ /:(\d+)$/
+ $1.to_i
+ else
+ standard_port
+ end
+ end
+
+ def remote_addr
+ @env['REMOTE_ADDR']
+ end
+
+ def session
+ unless defined?(@session)
+ if @session_options == false
+ @session = Hash.new
+ else
+ stale_session_check! do
+ if cookie_only? && query_parameters[session_options_with_string_keys['session_key']]
+ raise SessionFixationAttempt
+ end
+ case value = session_options_with_string_keys['new_session']
+ when true
+ @session = new_session
+ when false
+ begin
+ @session = CGI::Session.new(@cgi, session_options_with_string_keys)
+ # CGI::Session raises ArgumentError if 'new_session' == false
+ # and no session cookie or query param is present.
+ rescue ArgumentError
+ @session = Hash.new
+ end
+ when nil
+ @session = CGI::Session.new(@cgi, session_options_with_string_keys)
+ else
+ raise ArgumentError, "Invalid new_session option: #{value}"
+ end
+ @session['__valid_session']
+ end
+ end
+ end
+ @session
+ end
+
+ def reset_session
+ @session.delete if defined?(@session) && @session.is_a?(CGI::Session)
+ @session = new_session
+ end
+
+ private
+ # Delete an old session if it exists then create a new one.
+ def new_session
+ if @session_options == false
+ Hash.new
+ else
+ CGI::Session.new(@cgi, session_options_with_string_keys.merge("new_session" => false)).delete rescue nil
+ CGI::Session.new(@cgi, session_options_with_string_keys.merge("new_session" => true))
+ end
+ end
+
+ def cookie_only?
+ session_options_with_string_keys['cookie_only']
+ end
+
+ def stale_session_check!
+ yield
+ rescue ArgumentError => argument_error
+ if argument_error.message =~ %r{undefined class/module ([\w:]*\w)}
+ begin
+ # Note that the regexp does not allow $1 to end with a ':'
+ $1.constantize
+ rescue LoadError, NameError => const_error
+ raise ActionController::SessionRestoreError, <<-end_msg
+Session contains objects whose class definition isn\'t available.
+Remember to require the classes for all objects kept in the session.
+(Original exception: #{const_error.message} [#{const_error.class}])
+end_msg
+ end
+
+ retry
+ else
+ raise
+ end
+ end
+
+ def session_options_with_string_keys
+ @session_options_with_string_keys ||= DEFAULT_SESSION_OPTIONS.merge(@session_options).stringify_keys
+ end
+
+ # From Rack::Utils
+ def parse_query(qs, d = '&;')
+ params = {}
+ (qs || '').split(/[#{d}] */n).inject(params) { |h,p|
+ k, v = unescape(p).split('=',2)
+ if cur = params[k]
+ if cur.class == Array
+ params[k] << v
+ else
+ params[k] = [cur, v]
+ end
+ else
+ params[k] = v
+ end
+ }
+
+ return params
+ end
+
+ def unescape(s)
+ s.tr('+', ' ').gsub(/((?:%[0-9a-fA-F]{2})+)/n){
+ [$1.delete('%')].pack('H*')
+ }
+ end
+ end
+
+ class RackResponse < AbstractResponse #:nodoc:
+ attr_accessor :status
+
+ def initialize
+ @writer = lambda { |x| @body << x }
+ @block = nil
+ super()
+ end
+
+ def out(output = $stdout, &block)
+ @block = block
+ normalize_headers(@headers)
+ if [204, 304].include?(@status.to_i)
+ @headers.delete "Content-Type"
+ [status.to_i, @headers.to_hash, []]
+ else
+ [status.to_i, @headers.to_hash, self]
+ end
+ end
+ alias to_a out
+
+ def each(&callback)
+ if @body.respond_to?(:call)
+ @writer = lambda { |x| callback.call(x) }
+ @body.call(self, self)
+ else
+ @body.each(&callback)
+ end
+
+ @writer = callback
+ @block.call(self) if @block
+ end
+
+ def write(str)
+ @writer.call str.to_s
+ str
+ end
+
+ def close
+ @body.close if @body.respond_to?(:close)
+ end
+
+ def empty?
+ @block == nil && @body.empty?
+ end
+
+ private
+ def normalize_headers(options = "text/html")
+ if options.is_a?(String)
+ headers['Content-Type'] = options unless headers['Content-Type']
+ else
+ headers['Content-Length'] = options.delete('Content-Length').to_s if options['Content-Length']
+
+ headers['Content-Type'] = options.delete('type') || "text/html"
+ headers['Content-Type'] += "; charset=" + options.delete('charset') if options['charset']
+
+ headers['Content-Language'] = options.delete('language') if options['language']
+ headers['Expires'] = options.delete('expires') if options['expires']
+
+ @status = options.delete('Status') if options['Status']
+ @status ||= 200
+ # Convert 'cookie' header to 'Set-Cookie' headers.
+ # Because Set-Cookie header can appear more the once in the response body,
+ # we store it in a line break seperated string that will be translated to
+ # multiple Set-Cookie header by the handler.
+ if cookie = options.delete('cookie')
+ cookies = []
+
+ case cookie
+ when Array then cookie.each { |c| cookies << c.to_s }
+ when Hash then cookie.each { |_, c| cookies << c.to_s }
+ else cookies << cookie.to_s
+ end
+
+ @output_cookies.each { |c| cookies << c.to_s } if @output_cookies
+
+ headers['Set-Cookie'] = [headers['Set-Cookie'], cookies].compact.join("\n")
+ end
+
+ options.each { |k,v| headers[k] = v }
+ end
+
+ ""
+ end
+ end
+
+ class CGIWrapper < ::CGI
+ def initialize(request, *args)
+ @request = request
+ @args = *args
+ @input = request.body
+
+ super *args
+ end
+
+ def params
+ @params ||= @request.params
+ end
+
+ def cookies
+ @request.cookies
+ end
+
+ def query_string
+ @request.query_string
+ end
+
+ # Used to wrap the normal args variable used inside CGI.
+ def args
+ @args
+ end
+
+ # Used to wrap the normal env_table variable used inside CGI.
+ def env_table
+ @request.env
+ end
+
+ # Used to wrap the normal stdinput variable used inside CGI.
+ def stdinput
+ @input
+ end
+ end
+end
diff --git a/actionpack/test/controller/cgi_test.rb b/actionpack/test/controller/cgi_test.rb
index 87f72fda77..f0f3a4b826 100755
--- a/actionpack/test/controller/cgi_test.rb
+++ b/actionpack/test/controller/cgi_test.rb
@@ -114,3 +114,36 @@ class CgiRequestNeedsRewoundTest < BaseCgiTest
assert_equal 0, request.body.pos
end
end
+
+class CgiResponseTest < BaseCgiTest
+ def setup
+ super
+ @fake_cgi.expects(:header).returns("HTTP/1.0 200 OK\nContent-Type: text/html\n")
+ @response = ActionController::CgiResponse.new(@fake_cgi)
+ @output = StringIO.new('')
+ end
+
+ def test_simple_output
+ @response.body = "Hello, World!"
+
+ @response.out(@output)
+ assert_equal "HTTP/1.0 200 OK\nContent-Type: text/html\nHello, World!", @output.string
+ end
+
+ def test_head_request
+ @fake_cgi.env_table['REQUEST_METHOD'] = 'HEAD'
+ @response.body = "Hello, World!"
+
+ @response.out(@output)
+ assert_equal "HTTP/1.0 200 OK\nContent-Type: text/html\n", @output.string
+ end
+
+ def test_streaming_block
+ @response.body = Proc.new do |response, output|
+ 5.times { |n| output.write(n) }
+ end
+
+ @response.out(@output)
+ assert_equal "HTTP/1.0 200 OK\nContent-Type: text/html\n01234", @output.string
+ end
+end
diff --git a/actionpack/test/controller/rack_test.rb b/actionpack/test/controller/rack_test.rb
new file mode 100644
index 0000000000..cd4151783e
--- /dev/null
+++ b/actionpack/test/controller/rack_test.rb
@@ -0,0 +1,150 @@
+require 'abstract_unit'
+require 'action_controller/rack_process'
+
+class BaseRackTest < Test::Unit::TestCase
+ def setup
+ @env = {"HTTP_MAX_FORWARDS"=>"10", "SERVER_NAME"=>"glu.ttono.us:8007", "FCGI_ROLE"=>"RESPONDER", "HTTP_X_FORWARDED_HOST"=>"glu.ttono.us", "HTTP_ACCEPT_ENCODING"=>"gzip, deflate", "HTTP_USER_AGENT"=>"Mozilla/5.0 (Macintosh; U; PPC Mac OS X; en) AppleWebKit/312.5.1 (KHTML, like Gecko) Safari/312.3.1", "PATH_INFO"=>"", "HTTP_ACCEPT_LANGUAGE"=>"en", "HTTP_HOST"=>"glu.ttono.us:8007", "SERVER_PROTOCOL"=>"HTTP/1.1", "REDIRECT_URI"=>"/dispatch.fcgi", "SCRIPT_NAME"=>"/dispatch.fcgi", "SERVER_ADDR"=>"207.7.108.53", "REMOTE_ADDR"=>"207.7.108.53", "SERVER_SOFTWARE"=>"lighttpd/1.4.5", "HTTP_COOKIE"=>"_session_id=c84ace84796670c052c6ceb2451fb0f2; is_admin=yes", "HTTP_X_FORWARDED_SERVER"=>"glu.ttono.us", "REQUEST_URI"=>"/admin", "DOCUMENT_ROOT"=>"/home/kevinc/sites/typo/public", "SERVER_PORT"=>"8007", "QUERY_STRING"=>"", "REMOTE_PORT"=>"63137", "GATEWAY_INTERFACE"=>"CGI/1.1", "HTTP_X_FORWARDED_FOR"=>"65.88.180.234", "HTTP_ACCEPT"=>"*/*", "SCRIPT_FILENAME"=>"/home/kevinc/sites/typo/public/dispatch.fcgi", "REDIRECT_STATUS"=>"200", "REQUEST_METHOD"=>"GET"}
+ # some Nokia phone browsers omit the space after the semicolon separator.
+ # some developers have grown accustomed to using comma in cookie values.
+ @alt_cookie_fmt_request_hash = {"HTTP_COOKIE"=>"_session_id=c84ace847,96670c052c6ceb2451fb0f2;is_admin=yes"}
+ @request = ActionController::RackRequest.new(@env)
+ end
+
+ def default_test; end
+end
+
+
+class RackRequestTest < BaseRackTest
+ def test_proxy_request
+ assert_equal 'glu.ttono.us', @request.host_with_port
+ end
+
+ def test_http_host
+ @env.delete "HTTP_X_FORWARDED_HOST"
+ @env['HTTP_HOST'] = "rubyonrails.org:8080"
+ assert_equal "rubyonrails.org:8080", @request.host_with_port
+
+ @env['HTTP_X_FORWARDED_HOST'] = "www.firsthost.org, www.secondhost.org"
+ assert_equal "www.secondhost.org", @request.host
+ end
+
+ def test_http_host_with_default_port_overrides_server_port
+ @env.delete "HTTP_X_FORWARDED_HOST"
+ @env['HTTP_HOST'] = "rubyonrails.org"
+ assert_equal "rubyonrails.org", @request.host_with_port
+ end
+
+ def test_host_with_port_defaults_to_server_name_if_no_host_headers
+ @env.delete "HTTP_X_FORWARDED_HOST"
+ @env.delete "HTTP_HOST"
+ assert_equal "glu.ttono.us:8007", @request.host_with_port
+ end
+
+ def test_host_with_port_falls_back_to_server_addr_if_necessary
+ @env.delete "HTTP_X_FORWARDED_HOST"
+ @env.delete "HTTP_HOST"
+ @env.delete "SERVER_NAME"
+ assert_equal "207.7.108.53:8007", @request.host_with_port
+ end
+
+ def test_host_with_port_if_http_standard_port_is_specified
+ @env['HTTP_X_FORWARDED_HOST'] = "glu.ttono.us:80"
+ assert_equal "glu.ttono.us", @request.host_with_port
+ end
+
+ def test_host_with_port_if_https_standard_port_is_specified
+ @env['HTTP_X_FORWARDED_PROTO'] = "https"
+ @env['HTTP_X_FORWARDED_HOST'] = "glu.ttono.us:443"
+ assert_equal "glu.ttono.us", @request.host_with_port
+ end
+
+ def test_host_if_ipv6_reference
+ @env.delete "HTTP_X_FORWARDED_HOST"
+ @env['HTTP_HOST'] = "[2001:1234:5678:9abc:def0::dead:beef]"
+ assert_equal "[2001:1234:5678:9abc:def0::dead:beef]", @request.host
+ end
+
+ def test_host_if_ipv6_reference_with_port
+ @env.delete "HTTP_X_FORWARDED_HOST"
+ @env['HTTP_HOST'] = "[2001:1234:5678:9abc:def0::dead:beef]:8008"
+ assert_equal "[2001:1234:5678:9abc:def0::dead:beef]", @request.host
+ end
+
+ def test_cookie_syntax_resilience
+ cookies = CGI::Cookie::parse(@env["HTTP_COOKIE"]);
+ assert_equal ["c84ace84796670c052c6ceb2451fb0f2"], cookies["_session_id"], cookies.inspect
+ assert_equal ["yes"], cookies["is_admin"], cookies.inspect
+
+ alt_cookies = CGI::Cookie::parse(@alt_cookie_fmt_request_hash["HTTP_COOKIE"]);
+ assert_equal ["c84ace847,96670c052c6ceb2451fb0f2"], alt_cookies["_session_id"], alt_cookies.inspect
+ assert_equal ["yes"], alt_cookies["is_admin"], alt_cookies.inspect
+ end
+end
+
+
+class RackRequestParamsParsingTest < BaseRackTest
+ def test_doesnt_break_when_content_type_has_charset
+ data = 'flamenco=love'
+ @request.env['CONTENT_LENGTH'] = data.length
+ @request.env['CONTENT_TYPE'] = 'application/x-www-form-urlencoded; charset=utf-8'
+ @request.env['RAW_POST_DATA'] = data
+ assert_equal({"flamenco"=> "love"}, @request.request_parameters)
+ end
+
+ def test_doesnt_interpret_request_uri_as_query_string_when_missing
+ @request.env['REQUEST_URI'] = 'foo'
+ assert_equal({}, @request.query_parameters)
+ end
+end
+
+
+class RackRequestNeedsRewoundTest < BaseRackTest
+ def test_body_should_be_rewound
+ data = 'foo'
+ @env['rack.input'] = StringIO.new(data)
+ @env['CONTENT_LENGTH'] = data.length
+ @env['CONTENT_TYPE'] = 'application/x-www-form-urlencoded; charset=utf-8'
+
+ # Read the request body by parsing params.
+ request = ActionController::RackRequest.new(@env)
+ request.request_parameters
+
+ # Should have rewound the body.
+ assert_equal 0, request.body.pos
+ end
+end
+
+
+class RackResponseTest < BaseRackTest
+ def setup
+ super
+ @response = ActionController::RackResponse.new
+ @output = StringIO.new('')
+ end
+
+ def test_simple_output
+ @response.body = "Hello, World!"
+
+ status, headers, body = @response.out(@output)
+ assert_equal 200, status
+ assert_equal({"Content-Type" => "text/html", "Cache-Control" => "no-cache", "Set-Cookie" => ""}, headers)
+
+ parts = []
+ body.each { |part| parts << part }
+ assert_equal ["Hello, World!"], parts
+ end
+
+ def test_streaming_block
+ @response.body = Proc.new do |response, output|
+ 5.times { |n| output.write(n) }
+ end
+
+ status, headers, body = @response.out(@output)
+ assert_equal 200, status
+ assert_equal({"Content-Type" => "text/html", "Cache-Control" => "no-cache", "Set-Cookie" => ""}, headers)
+
+ parts = []
+ body.each { |part| parts << part }
+ assert_equal ["0", "1", "2", "3", "4"], parts
+ end
+end