Added Rack processor
Signed-off-by: Joshua Peek <josh@joshpeek.com>
This commit is contained in:
parent
3282bf3b50
commit
06cb20708b
|
@ -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]
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
Loading…
Reference in New Issue