├── .gitignore ├── .travis.yml ├── Gemfile ├── History.txt ├── LICENSE ├── README.textile ├── Rakefile ├── demo ├── client.rb ├── config.ru ├── demo.rb └── demo_spec.rb ├── examples ├── basic.rb ├── google.rb ├── rubyurl.rb └── whoismyrep.rb ├── lib └── rack │ ├── client.rb │ └── client │ ├── adapter.rb │ ├── adapter │ ├── base.rb │ ├── faraday.rb │ └── simple.rb │ ├── body.rb │ ├── core.rb │ ├── core │ ├── dual_band.rb │ ├── headers.rb │ └── response.rb │ ├── handler.rb │ ├── handler │ ├── em-http.rb │ ├── excon.rb │ ├── net_http.rb │ └── typhoeus.rb │ ├── middleware.rb │ ├── middleware │ ├── auth.rb │ ├── auth │ │ ├── abstract │ │ │ └── challenge.rb │ │ ├── basic.rb │ │ └── digest │ │ │ ├── challenge.rb │ │ │ ├── md5.rb │ │ │ └── params.rb │ ├── cache.rb │ ├── cache │ │ ├── cachecontrol.rb │ │ ├── context.rb │ │ ├── entitystore.rb │ │ ├── key.rb │ │ ├── metastore.rb │ │ ├── options.rb │ │ ├── request.rb │ │ ├── response.rb │ │ └── storage.rb │ ├── cookie_jar.rb │ ├── cookie_jar │ │ ├── context.rb │ │ ├── cookie.rb │ │ ├── cookiestore.rb │ │ ├── options.rb │ │ ├── request.rb │ │ ├── response.rb │ │ └── storage.rb │ └── follow_redirects.rb │ ├── parser.rb │ ├── parser │ ├── base.rb │ ├── body_collection.rb │ ├── context.rb │ ├── json.rb │ ├── middleware.rb │ ├── request.rb │ ├── response.rb │ └── yaml.rb │ └── version.rb ├── rack-client.gemspec └── spec ├── adapter ├── faraday_spec.rb └── simple_spec.rb ├── core ├── headers_spec.rb └── response_spec.rb ├── handler ├── em_http_spec.rb ├── excon_spec.rb ├── net_http_spec.rb └── typhoeus_spec.rb ├── helpers ├── async_helper.rb ├── em_http_helper.rb ├── excon_helper.rb ├── handler_helper.rb ├── net_http_helper.rb ├── sync_helper.rb └── typhoeus_helper.rb ├── middleware ├── auth │ └── basic_spec.rb ├── cache_spec.rb ├── cookie_jar_spec.rb ├── etag_spec.rb ├── follow_redirects_spec.rb └── lint_spec.rb ├── shared ├── handler_api.rb └── streamed_response_api.rb ├── spec_apps ├── basic_auth.ru ├── cache.ru ├── cookie.ru ├── delete.ru ├── faraday.ru ├── get.ru ├── head.ru ├── hello_world.ru ├── post.ru ├── put.ru ├── redirect.ru └── stream.ru ├── spec_config.ru └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | pkg 2 | bin 3 | .bundle 4 | *.rbc 5 | Gemfile.lock 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - "1.8.7" 4 | - "1.9.2" 5 | - "1.9.3" 6 | - "2.0.0" 7 | - jruby-18mode # JRuby in 1.8 mode 8 | - jruby-19mode # JRuby in 1.9 mode 9 | - rbx-18mode 10 | - rbx-19mode 11 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | gemspec 3 | 4 | ruby_version = Gem::Version.new(RUBY_VERSION.dup) 5 | ruby_19 = Gem::Version.new('1.9') 6 | ruby_20 = Gem::Version.new('2.0') 7 | 8 | group :optional do 9 | gem 'rack-cache', :require => 'rack/cache' 10 | gem 'rack-contrib', :require => 'rack/contrib' 11 | end 12 | 13 | group :test do 14 | gem 'rake' 15 | gem 'sinatra', :require => 'sinatra/base' 16 | gem 'rspec', '>=2.0.0' 17 | gem 'realweb' 18 | 19 | if ruby_version >= ruby_20 20 | gem 'em-synchrony' 21 | elsif ruby_version >= ruby_19 22 | gem 'debugger' if RUBY_ENGINE == 'ruby' 23 | gem 'em-synchrony' 24 | else 25 | gem 'ruby-debug' 26 | gem 'mongrel' 27 | gem 'cgi_multipart_eof_fix' 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /History.txt: -------------------------------------------------------------------------------- 1 | == 0.1.0 / 2009-06-14 2 | 3 | * 1 major enhancement 4 | 5 | * Birthday! 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 Tim Carey-Smith 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.textile: -------------------------------------------------------------------------------- 1 | h1. What's this? 2 | 3 | Rack::Client is an HTTP client that aims to be a good Rack 4 | citizen. 5 | 6 | h1. Install 7 | 8 | To install the latest release as a gem: 9 |
sudo gem install rack-client
10 | Then in Ruby: 11 |
require "rubygems"; require "rack/client" # and you're off!
12 | 13 | h2. Rack responses 14 | 15 | Rack::Client can be used to make HTTP requests to any type of server, not 16 | just ones using Rack. However, when a request is made then a proper 17 | Rack response (specifically a Rack::MockResponse) object is returned. 18 | For Rubyists, this means you don't need to learn yet another interface 19 | and can just stick with Rack both on the server, test, and client side of 20 | things. 21 | 22 |
23 | response = Rack::Client.get("http://some-website.com/blah.txt")
24 | response.status #=> 200
25 | response.body #=> "some body"
26 | 
27 | 28 | h2. Middleware 29 | 30 | Rack::Client is actually a subclass of Rack::Builder. This means that 31 | Rack::Client objects yield actual Rack apps. More importantly, this 32 | means you can reuse existing Rack middleware on the client side too 33 | (but also feel free to make new middleware that only makes sense on 34 | the client side under the Rack::Client namespace). Note that by default 35 | Rack::Client will "run" Rack::Client::HTTP as an endpoint, but this 36 | will not be performed if you specify your own "run" endpoint. 37 | 38 |
39 | client = Rack::Client.new { use Rack::ETag }
40 | response = client.get("http://localhost:9292/no-etag")
41 | 
42 | 43 | h2. Rack::Test compatibility 44 | 45 | Rack::Client reuses a lot of Rack::Test to provide users with a 46 | familiar interface. What's even cooler is that you can use a 47 | Rack::Client object as your "app" in Rack::Test. This means that you 48 | can test-drive an application with Rack::Test, then when ready 49 | actually run your Rack app, switch your Rack::Test "app" to a 50 | Rack::Client, and get free full-blown integration testing! Note that 51 | the integration-tested server does not need to be all-Rack, so you can 52 | develop quickly with middleware like Rack::Cache but then remove it 53 | and integration test with a dedicated cache server like Varnish. 54 | 55 |
56 | # NOTE: For a complete example, look in the "demo" directory
57 | describe Demo, "/store resource" do
58 |   include Rack::Test::Methods
59 |   def app
60 |     # replace this with Rack::Client.new
61 |     # for integration testing
62 |     Demo::App.new
63 |   end
64 |   # ... etc
65 | end
66 | 
67 | 68 | h1. Contributors 69 | 70 | halorgium, larrytheliquid, benburkert 71 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake' 2 | require 'rake/clean' 3 | require 'rspec/core/rake_task' 4 | 5 | 6 | $:.unshift File.join(File.dirname(__FILE__), 'lib') 7 | require 'rack/client/version' 8 | 9 | desc 'Install the package as a gem.' 10 | task :install => [:clean, :package] do 11 | gem = Dir['pkg/*.gem'].first 12 | sh "sudo gem install --no-rdoc --no-ri --local #{gem}" 13 | end 14 | 15 | RSpec::Core::RakeTask.new do |t| 16 | t.rspec_opts = %w[ -c -f documentation -r ./spec/spec_helper.rb ] 17 | t.pattern = 'spec/**/*_spec.rb' 18 | end 19 | 20 | task :default => :spec 21 | -------------------------------------------------------------------------------- /demo/client.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | require "rack/client" 3 | require "rack/contrib" 4 | 5 | puts "PUT'ing /store/fruit (with strawberry)" 6 | puts 7 | Rack::Client.put "http://localhost:9292/store/fruit", "strawberry" 8 | 9 | puts "GET'ing /store/fruit" 10 | response = Rack::Client.get "http://localhost:9292/store/fruit" 11 | puts ">> status: #{response.status}" 12 | puts ">> body: #{response.body.inspect}" 13 | puts ">> etag: #{response.headers["ETag"].inspect}" 14 | puts 15 | 16 | puts "GET'ing /store/fruit (with ETag middleware)" 17 | response = Rack::Client.new do 18 | use Rack::ETag 19 | end.get "http://localhost:9292/store/fruit" 20 | puts ">> status: #{response.status}" 21 | puts ">> body: #{response.body.inspect}" 22 | puts ">> etag: #{response.headers["ETag"].inspect}" 23 | puts 24 | 25 | puts "DELETE'ing /store" 26 | Rack::Client.delete("http://localhost:9292/store") 27 | -------------------------------------------------------------------------------- /demo/config.ru: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.dirname(__FILE__) + "/demo") 2 | 3 | run Demo::App -------------------------------------------------------------------------------- /demo/demo.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | require "rack" 3 | require "sinatra/base" 4 | 5 | module Demo 6 | Store = Hash.new 7 | 8 | class App < Sinatra::Base 9 | get "/store/:id" do 10 | if item = Store[ params[:id] ] 11 | item 12 | else 13 | status 404 14 | "" 15 | end 16 | end 17 | 18 | put "/store/:id" do 19 | Store[ params[:id] ] = request.body.read 20 | end 21 | 22 | delete "/store" do 23 | Store.clear 24 | "" 25 | end 26 | 27 | delete "/store/:id" do 28 | Store.delete params[:id] 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /demo/demo_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.dirname(__FILE__) + "/demo") 2 | require "rubygems" 3 | require "spec" 4 | require "rack" 5 | require "rack/test" 6 | require "rack/client" 7 | 8 | describe Demo, "/store resource" do 9 | include Rack::Test::Methods 10 | def app 11 | # Be sure to run "rackup" on the config.ru in this demo directory 12 | Rack::Client.new 13 | # Demo::App.new 14 | end 15 | before(:all) { delete "http://localhost:9292/store" } 16 | after { delete "http://localhost:9292/store" } 17 | 18 | it "should return a 404 if a resource does not exist" do 19 | get "http://localhost:9292/store/does-not-exist" 20 | last_response.status.should == 404 21 | last_response.body.should be_empty 22 | end 23 | 24 | it "should be able to store and retrieve invididual items" do 25 | put "http://localhost:9292/store/fruit", "strawberry" 26 | put "http://localhost:9292/store/car", "lotus" 27 | get "http://localhost:9292/store/fruit" 28 | last_response.status.should == 200 29 | last_response.body.should == "strawberry" 30 | get "http://localhost:9292/store/car" 31 | last_response.status.should == 200 32 | last_response.body.should == "lotus" 33 | end 34 | 35 | it "should be able to clear the store of all items" do 36 | put "http://localhost:9292/store/fruit", "strawberry" 37 | put "http://localhost:9292/store/car", "lotus" 38 | delete "http://localhost:9292/store" 39 | get "http://localhost:9292/store/fruit" 40 | last_response.status.should == 404 41 | last_response.body.should be_empty 42 | get "http://localhost:9292/store/car" 43 | last_response.status.should == 404 44 | last_response.body.should be_empty 45 | end 46 | 47 | it "should be able to clear the store of an invididual item" do 48 | put "http://localhost:9292/store/fruit", "strawberry" 49 | delete "http://localhost:9292/store/fruit" 50 | get "http://localhost:9292/store/fruit" 51 | last_response.status.should == 404 52 | last_response.body.should be_empty 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /examples/basic.rb: -------------------------------------------------------------------------------- 1 | $:.unshift File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib')) 2 | require 'rack/client' 3 | require 'pp' 4 | require 'yaml' 5 | require 'json' 6 | 7 | creds = YAML.load_file("#{ENV['HOME']}/.twitter.yml") 8 | 9 | client = Rack::Client.new('http://twitter.com') do 10 | use Rack::Client::Auth::Basic, creds[:username], creds[:password] 11 | run Rack::Client::Handler::NetHTTP 12 | end 13 | 14 | response = client.get('/statuses/public_timeline.json') 15 | 16 | response.each do |json| 17 | JSON.parse(json).each do |item| 18 | puts item['user']['screen_name'] 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /examples/google.rb: -------------------------------------------------------------------------------- 1 | $:.unshift File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib')) 2 | require 'rack/client' 3 | require 'pp' 4 | 5 | client = Rack::Client.new do 6 | use Rack::Client::FollowRedirects 7 | run Rack::Client::Handler::NetHTTP 8 | end 9 | 10 | # google.com redirects to www.google.com so this is live test for redirection 11 | 12 | pp client.get('http://google.com/').status 13 | 14 | puts '', '*'*70, '' 15 | 16 | # check that ssl is requesting right 17 | pp client.get('https://google.com/').status 18 | -------------------------------------------------------------------------------- /examples/rubyurl.rb: -------------------------------------------------------------------------------- 1 | $:.unshift File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib')) 2 | require 'rack/client' 3 | require 'json' 4 | require 'pp' 5 | 6 | client = Rack::Client.new('http://rubyurl.com/') 7 | client.post('/api/links.json', :website_url => 'http://istwitterdown.com/').body.each do |json| 8 | puts JSON.parse(json)['link']['permalink'] 9 | end 10 | -------------------------------------------------------------------------------- /examples/whoismyrep.rb: -------------------------------------------------------------------------------- 1 | $:.unshift File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib')) 2 | require 'rack/client' 3 | require 'pp' 4 | 5 | client = Rack::Client.new('http://whoismyrepresentative.com') 6 | 7 | pp client.get('/whoismyrep.php?zip=94107').body 8 | pp client.get('/whoismyrep.php', :zip => 94107).body 9 | -------------------------------------------------------------------------------- /lib/rack/client.rb: -------------------------------------------------------------------------------- 1 | require 'forwardable' 2 | require 'stringio' 3 | require 'uri' 4 | require 'rack' 5 | 6 | module Rack 7 | module Client 8 | include Forwardable 9 | 10 | class << self 11 | extend Forwardable 12 | def_delegators :new, :head, :get, :put, :post, :delete 13 | end 14 | 15 | def self.new(*a, &block) 16 | block ||= lambda {|opt| run Rack::Client::Handler::NetHTTP } 17 | Rack::Client::Simple.new(Rack::Builder.app(&block), *a) 18 | end 19 | end 20 | end 21 | 22 | require 'rack/client/version' 23 | 24 | require 'rack/client/core' 25 | 26 | require 'rack/client/handler' 27 | 28 | require 'rack/client/adapter' 29 | 30 | require 'rack/client/middleware' 31 | 32 | require 'rack/client/parser' 33 | -------------------------------------------------------------------------------- /lib/rack/client/adapter.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | autoload :Base, 'rack/client/adapter/base' 4 | autoload :Simple, 'rack/client/adapter/simple' 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/rack/client/adapter/base.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | class Base 4 | extend Forwardable 5 | 6 | ASCII_ENCODING = 'ASCII-8BIT' 7 | 8 | def_delegator :@app, :call 9 | 10 | def initialize(app) 11 | @app = app 12 | end 13 | 14 | %w[ options get head post put delete trace connect ].each do |method| 15 | eval <<-RUBY, binding, __FILE__, __LINE__ + 1 16 | def #{method}(url, headers = {}, body = nil, &block) 17 | request('#{method.upcase}', url, headers, body, &block) 18 | end 19 | RUBY 20 | end 21 | 22 | def request(method, url, headers = {}, body = nil) 23 | if block_given? 24 | call(build_env(method.upcase, url, headers, body)) {|tuple| yield *tuple } 25 | else 26 | return *call(build_env(method.upcase, url, headers, body)) 27 | end 28 | end 29 | 30 | def build_env(request_method, url, headers = {}, body = nil) 31 | env = Headers.new(headers).to_env 32 | 33 | env.update 'REQUEST_METHOD' => request_method 34 | 35 | env['CONTENT_TYPE'] ||= 'application/x-www-form-urlencoded' 36 | 37 | uri = URI.parse(url) 38 | 39 | path_info = uri.path.empty? ? '/' : uri.path 40 | 41 | env.update 'PATH_INFO' => path_info 42 | env.update 'REQUEST_URI' => uri.to_s 43 | env.update 'SERVER_NAME' => uri.host.to_s 44 | env.update 'SERVER_PORT' => uri.port.to_s 45 | env.update 'SCRIPT_NAME' => '' 46 | env.update 'QUERY_STRING' => uri.query.to_s 47 | 48 | input = ensure_acceptable_input(body) 49 | errors = StringIO.new 50 | 51 | [ input, errors ].each do |io| 52 | io.set_encoding(ASCII_ENCODING) if io.respond_to?(:set_encoding) 53 | end 54 | 55 | env.update 'rack.input' => input 56 | env.update 'rack.errors' => errors 57 | env.update 'rack.url_scheme' => uri.scheme || 'http' 58 | env.update 'rack.version' => Rack::VERSION 59 | env.update 'rack.multithread' => true 60 | env.update 'rack.multiprocess' => true 61 | env.update 'rack.run_once' => false 62 | 63 | env.update 'HTTPS' => env["rack.url_scheme"] == "https" ? "on" : "off" 64 | 65 | env 66 | end 67 | 68 | def ensure_acceptable_input(body) 69 | if %w[gets each read rewind].all? {|m| body.respond_to?(m.to_sym) } 70 | body 71 | elsif body.respond_to?(:each) 72 | input = StringIO.new 73 | body.each {|chunk| input << chunk } 74 | input.rewind 75 | input 76 | else 77 | input = StringIO.new(body.to_s) 78 | input.rewind 79 | input 80 | end 81 | end 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/rack/client/adapter/faraday.rb: -------------------------------------------------------------------------------- 1 | require 'faraday' 2 | 3 | module Faraday 4 | class Adapter 5 | 6 | class RackClient < Faraday::Adapter 7 | dependency 'rack/client' 8 | 9 | def initialize(faraday_app, rack_client_adapter = :default, *a, &b) 10 | super(faraday_app) 11 | 12 | klass = case rack_client_adapter 13 | when :default then ::Rack::Client 14 | when :simple then ::Rack::Client::Simple 15 | when :base then ::Rack::Client::Base 16 | else rack_client_adapter 17 | end 18 | 19 | @rack_client_app = klass.new(*a, &b) 20 | end 21 | 22 | def call(faraday_env) 23 | rack_env = to_rack_env(faraday_env) 24 | timeout = faraday_env[:request][:timeout] || faraday_env[:request][:open_timeout] 25 | 26 | rack_response = if timeout 27 | Timer.timeout(timeout, Faraday::Error::TimeoutError) { @rack_client_app.call(rack_env) } 28 | else 29 | @rack_client_app.call(rack_env) 30 | end 31 | 32 | status, headers, rack_body = rack_response.to_a 33 | 34 | body = '' 35 | rack_body.each {|part| body << part } 36 | 37 | rack_body.close if rack_body.respond_to? :close 38 | 39 | save_response(faraday_env, status, body, headers) 40 | 41 | @app.call faraday_env 42 | end 43 | 44 | def to_rack_env(faraday_env) 45 | body = faraday_env.body 46 | body = body.read if body.respond_to? :read 47 | 48 | @rack_client_app.build_env(faraday_env.method.to_s.upcase, 49 | faraday_env.url.to_s, 50 | faraday_env.request_headers, 51 | body) 52 | end 53 | 54 | end 55 | 56 | register_middleware nil, :rack_client => RackClient 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/rack/client/adapter/simple.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | class Simple < Base 4 | 5 | def self.new(app, *a, &b) 6 | app = inject_middleware(app) if middlewares.any? 7 | 8 | super(app, *a, &b) 9 | end 10 | 11 | def self.middlewares 12 | @middlewares ||= [] 13 | end 14 | 15 | def self.use(middleware, *args) 16 | middlewares << [middleware, args] 17 | end 18 | 19 | def self.inject_middleware(app) 20 | middlewares = self.middlewares 21 | 22 | Rack::Builder.app do |builder| 23 | middlewares.each do |(middleware, args)| 24 | builder.use middleware, *args 25 | end 26 | 27 | builder.run app 28 | end 29 | end 30 | 31 | class CollapsedResponse < Response 32 | extend Forwardable 33 | attr_accessor :response 34 | 35 | def_delegators :response, :[], :[]=, :close, :delete_cookie, :each, 36 | :empty?, :finish, :headers, :redirect, 37 | :set_cookie, :status, :to_a, :to_ary, :write 38 | def_delegator :response, :body, :chunked_body 39 | 40 | def initialize(*tuple) 41 | @response, @body = Response.new(*tuple), nil 42 | end 43 | 44 | def body 45 | collapse! unless @body 46 | @body 47 | end 48 | 49 | def collapse! 50 | @body = '' 51 | each {|chunk| @body << chunk } 52 | @body 53 | end 54 | end 55 | 56 | def initialize(app, url = nil) 57 | super(app) 58 | @base_uri = URI.parse(url) unless url.nil? 59 | end 60 | 61 | %w[ options get head delete trace ].each do |method| 62 | eval <<-RUBY, binding, __FILE__, __LINE__ + 1 63 | def #{method}(url, headers = {}, query_params = {}, &block) 64 | request('#{method.upcase}', url, headers, nil, query_params, &block) 65 | end 66 | RUBY 67 | end 68 | 69 | %w[ post put ].each do |method| 70 | eval <<-RUBY, binding, __FILE__, __LINE__ + 1 71 | def #{method}(url, headers = {}, body_or_params = nil, query_params = {}, &block) 72 | request('#{method.upcase}', url, headers, body_or_params, query_params, &block) 73 | end 74 | RUBY 75 | end 76 | 77 | def request(method, url, headers = {}, body_or_params = nil, query_params = {}) 78 | tuple = request_tuple(url, headers, body_or_params, query_params) 79 | if block_given? 80 | super(method, *tuple) {|*tuple| yield CollapsedResponse.new(*tuple) } 81 | else 82 | CollapsedResponse.new(*super(method, *tuple)) 83 | end 84 | end 85 | 86 | def request_tuple(url, headers = {}, body_or_params = nil, query_params = {}, &block) 87 | query_hash = Hash === query_params ? query_params : Utils.build_query(query_params) 88 | 89 | uri = url.is_a?(URI) ? url : URI.parse(url) 90 | 91 | unless query_params.empty? 92 | uri.query = Utils.build_nested_query(Utils.parse_nested_query(uri.query).merge(query_params)) 93 | end 94 | 95 | body = Hash === body_or_params ? Utils.build_nested_query(body_or_params) : body_or_params 96 | 97 | return uri.to_s, headers, body 98 | end 99 | 100 | def build_env(request_method, url, headers = {}, body = nil) 101 | uri = @base_uri.nil? ? URI.parse(url) : @base_uri + url 102 | 103 | env = super(request_method, uri.to_s, headers, body) 104 | 105 | env['HTTP_HOST'] ||= http_host_for(uri) 106 | env['HTTP_USER_AGENT'] ||= http_user_agent 107 | 108 | env 109 | end 110 | 111 | def http_host_for(uri) 112 | if uri.to_s.include?(":#{uri.port}") 113 | [uri.host, uri.port].join(':') 114 | else 115 | uri.host 116 | end 117 | end 118 | 119 | def http_user_agent 120 | "rack-client #{VERSION} (app: #{@app.class})" 121 | end 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /lib/rack/client/body.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | module Parser 4 | class Body < Array 5 | 6 | def initialize(&proc) 7 | @proc = proc 8 | end 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/rack/client/core.rb: -------------------------------------------------------------------------------- 1 | require 'rack/client/core/dual_band' 2 | require 'rack/client/core/headers' 3 | require 'rack/client/core/response' 4 | -------------------------------------------------------------------------------- /lib/rack/client/core/dual_band.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | module DualBand 4 | def call(env, &block) 5 | if block_given? 6 | async_call(env, &block) 7 | else 8 | sync_call(env) 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/rack/client/core/headers.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | class Headers < Hash 4 | def self.from(env) 5 | new env.reject {|(header,_)| header !~ %r'^HTTP_' unless %w( CONTENT_TYPE CONTENT_LENGTH ).include?(header) } 6 | end 7 | 8 | def initialize(headers = {}) 9 | super() 10 | merge!(headers) 11 | end 12 | 13 | def clean(header) 14 | header.gsub(/HTTP_/, '').gsub('_', '-').gsub(/(\w+)/) do |matches| 15 | matches.downcase.sub(/^./) do |char| 16 | char.upcase 17 | end 18 | end 19 | end 20 | 21 | def to_http 22 | self.inject({}) {|h,(header,value)| h.update(clean(header) => Array(value).join("\n")) } 23 | end 24 | 25 | def to_env 26 | self.inject({}) {|h,(header,value)| h.update((rackish?(header) ? header : rackify(header)) => value) } 27 | end 28 | 29 | def rackish?(header) 30 | case header 31 | when 'CONTENT_TYPE', 'CONTENT_LENGTH' then true 32 | when /^rack[-.]/ then true 33 | when /^HTTP_/ then true 34 | else false 35 | end 36 | end 37 | 38 | def rackify(original) 39 | header = original.upcase.gsub('-', '_') 40 | 41 | case header 42 | when 'CONTENT_TYPE', 'CONTENT_LENGTH' then header 43 | when /^HTTP_/ then header 44 | else "HTTP_#{header}" 45 | end 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/rack/client/core/response.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | class Response < Rack::Response 4 | def initialize(status, headers = {}, body = [], &block) 5 | @status = status.to_i 6 | @header = Utils::HeaderHash.new({"Content-Type" => "text/html"}. 7 | merge(headers)) 8 | @body = body 9 | @loaded = false 10 | 11 | @stream = block if block_given? 12 | end 13 | 14 | def each(&block) 15 | load_body(&block) 16 | 17 | @body 18 | end 19 | 20 | def body 21 | load_body 22 | 23 | @body 24 | end 25 | 26 | def load_body(&block) 27 | return @body.each(&block) if @body_loaded 28 | body = [] 29 | 30 | @body.each do |chunk| 31 | unless chunk.empty? 32 | body << chunk 33 | yield chunk if block_given? 34 | end 35 | end 36 | 37 | if @stream 38 | @stream.call(lambda do |chunk| 39 | unless chunk.empty? 40 | body << chunk 41 | yield chunk if block_given? 42 | end 43 | end) 44 | end 45 | 46 | @body, @body_loaded = body, true 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/rack/client/handler.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | module Handler 4 | autoload :NetHTTP, 'rack/client/handler/net_http' 5 | autoload :Excon, 'rack/client/handler/excon' 6 | autoload :EmHttp, 'rack/client/handler/em-http' 7 | autoload :Typhoeus, 'rack/client/handler/typhoeus' 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/rack/client/handler/em-http.rb: -------------------------------------------------------------------------------- 1 | require 'em-http' 2 | 3 | begin 4 | require 'em-synchrony' 5 | rescue LoadError 6 | end 7 | 8 | module Rack 9 | module Client 10 | module Handler 11 | class EmHttp 12 | include Rack::Client::DualBand 13 | 14 | class << self 15 | extend Forwardable 16 | def_delegator :new, :call 17 | end 18 | 19 | def sync_call(env) 20 | raise("Synchronous API is not supported for EmHttp Handler without EM::Synchrony") unless defined?(EventMachine::Synchrony) 21 | 22 | request, fiber = Rack::Request.new(env), Fiber.current 23 | 24 | conn = connection(request.url).send(request.request_method.downcase.to_sym, request_options(request)) 25 | conn.callback { fiber.resume(conn) } 26 | conn.errback { fiber.resume(conn) } 27 | 28 | parse(Fiber.yield).finish 29 | end 30 | 31 | def async_call(env) 32 | request = Rack::Request.new(env) 33 | 34 | EM.schedule do 35 | em_http = connection(request.url).send(request.request_method.downcase, request_options(request)) 36 | em_http.callback do 37 | yield parse(em_http).finish 38 | end 39 | 40 | em_http.errback do 41 | yield parse(em_http).finish 42 | end 43 | end 44 | end 45 | 46 | def connection(url) 47 | EventMachine::HttpRequest.new(url) 48 | end 49 | 50 | def request_options(request) 51 | options = {} 52 | 53 | if request.body 54 | options[:body] = case request.body 55 | when Array then request.body.join 56 | when StringIO then request.body.string 57 | when IO then request.body.read 58 | when String then request.body 59 | end 60 | end 61 | 62 | headers = Headers.from(request.env).to_http 63 | options[:head] = headers unless headers.empty? 64 | 65 | options 66 | end 67 | 68 | def parse(em_http) 69 | body = em_http.response.empty? ? [] : StringIO.new(em_http.response) 70 | Response.new(em_http.response_header.status, Headers.new(em_http.response_header).to_http, body) 71 | end 72 | 73 | def normalize_headers(em_http) 74 | headers = em_http.response_header 75 | 76 | headers['LOCATION'] = URI.parse(headers['LOCATION']).path if headers.include?('LOCATION') 77 | 78 | headers 79 | end 80 | end 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/rack/client/handler/excon.rb: -------------------------------------------------------------------------------- 1 | require 'excon' 2 | 3 | module Rack 4 | module Client 5 | module Handler 6 | class Excon 7 | include DualBand 8 | 9 | def async_call(env) 10 | raise("Asynchronous API is not supported for EmHttp Handler") unless block_given? 11 | end 12 | 13 | def sync_call(env) 14 | request = Rack::Request.new(env) 15 | 16 | body = case request.body 17 | when StringIO then request.body.string 18 | when IO then request.body.read 19 | when Array then request.body.join 20 | when String then request.body 21 | end 22 | 23 | response = parse connection_for(request).request(:method => request.request_method, 24 | :path => request.path, 25 | :headers => Headers.from(env).to_http, 26 | :body => body) 27 | 28 | response.finish 29 | end 30 | 31 | def parse(excon_response) 32 | body = excon_response.body.empty? ? [] : StringIO.new(excon_response.body) 33 | headers = {} 34 | excon_response.headers.map do |key,value| 35 | headers[key] = value.split(", ") 36 | end 37 | Response.new(excon_response.status, Headers.new(headers).to_http, body) 38 | end 39 | 40 | def connection_for(request) 41 | connection = ::Excon.new(request.url) 42 | end 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/rack/client/handler/net_http.rb: -------------------------------------------------------------------------------- 1 | require 'net/http' 2 | require 'net/https' 3 | 4 | module Rack 5 | module Client 6 | module Handler 7 | class NetHTTP 8 | include Rack::Client::DualBand 9 | 10 | class << self 11 | extend Forwardable 12 | def_delegator :new, :call 13 | end 14 | 15 | def sync_call(env) 16 | request = Rack::Request.new(env) 17 | 18 | net_connection, net_request = net_connection_for(request), net_request_for(request) 19 | 20 | if streaming_body?(request) 21 | net_response = net_connection.request(net_request) 22 | else 23 | net_response = net_connection.request(net_request, body_for(request)) 24 | end 25 | 26 | parse(net_response).finish 27 | end 28 | 29 | def async_call(env) 30 | request = Rack::Request.new(env) 31 | 32 | net_connection_for(request).request(net_request_for(request), body_for(request)) do |net_response| 33 | yield parse_stream(net_response).finish 34 | end 35 | end 36 | 37 | def net_connection_for(request) 38 | connection = Net::HTTP.new(request.host, request.port) 39 | 40 | if request.scheme == 'https' 41 | connection.use_ssl = true 42 | connection.verify_mode = OpenSSL::SSL::VERIFY_NONE 43 | end 44 | 45 | connection.start 46 | connection 47 | end 48 | 49 | def net_request_for(request) 50 | klass = case request.request_method 51 | when 'DELETE' then Net::HTTP::Delete 52 | when 'GET' then Net::HTTP::Get 53 | when 'HEAD' then Net::HTTP::Head 54 | when 'POST' then Net::HTTP::Post 55 | when 'PUT' then Net::HTTP::Put 56 | end 57 | 58 | net_request = klass.new(request.fullpath, Headers.from(request.env).to_http) 59 | 60 | net_request.body_stream = request.body if streaming_body?(request) 61 | 62 | net_request 63 | end 64 | 65 | def body_for(request) 66 | case request.body 67 | when StringIO then request.body.string 68 | when IO then request.body.read 69 | when Array then request.body.join 70 | when String then request.body 71 | end 72 | end 73 | 74 | def streaming_body?(request) 75 | request.request_method == 'POST' && 76 | required_streaming_headers?(request) && 77 | request.body.is_a?(IO) && 78 | !request.body.is_a?(StringIO) 79 | end 80 | 81 | def required_streaming_headers?(request) 82 | request.env.keys.include?('HTTP_CONTENT_LENGTH') || 83 | request.env['HTTP_TRANSFER_ENCODING'] == 'chunked' 84 | end 85 | 86 | def parse(net_response) 87 | body = (net_response.body.nil? || net_response.body.empty?) ? [] : StringIO.new(net_response.body) 88 | Response.new(net_response.code.to_i, parse_headers(net_response), body) 89 | end 90 | 91 | def parse_stream(net_response) 92 | Response.new(net_response.code.to_i, parse_headers(net_response)) {|block| net_response.read_body(&block) } 93 | end 94 | 95 | def parse_headers(net_response) 96 | headers = Headers.new 97 | 98 | net_response.to_hash.each do |k,v| 99 | headers.update(k => v) 100 | end 101 | 102 | headers.to_http 103 | end 104 | 105 | def connections 106 | @connections ||= {} 107 | end 108 | end 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /lib/rack/client/handler/typhoeus.rb: -------------------------------------------------------------------------------- 1 | require 'typhoeus' 2 | 3 | module Rack 4 | module Client 5 | module Handler 6 | class Typhoeus 7 | include Rack::Client::DualBand 8 | 9 | def initialize(hydra = ::Typhoeus::Hydra.new) 10 | @hydra = hydra 11 | end 12 | 13 | def async_call(env) 14 | rack_request = Rack::Request.new(env) 15 | 16 | typhoeus_request = request_for(rack_request) 17 | 18 | typhoeus_request.on_complete do |response| 19 | yield parse(response).finish 20 | end 21 | 22 | @hydra.queue typhoeus_request 23 | end 24 | 25 | def sync_call(env) 26 | rack_request = Rack::Request.new(env) 27 | 28 | parse(process(rack_request)).finish 29 | end 30 | 31 | def parse(typhoeus_response) 32 | body = (typhoeus_response.body.nil? || typhoeus_response.body.empty?) ? [] : StringIO.new(typhoeus_response.body) 33 | Response.new(typhoeus_response.code, headers_for(typhoeus_response).to_http, body) 34 | end 35 | 36 | def request_for(rack_request) 37 | ::Typhoeus::Request.new((rack_request.url).to_s, params_for(rack_request)) 38 | end 39 | 40 | def headers_for(typhoeus_response) 41 | headers = typhoeus_response.headers_hash 42 | headers.reject! {|k,v| v.nil? } # Typhoeus Simple bug: http://github.com/pauldix/typhoeus/issues#issue/42 43 | 44 | Headers.new(headers) 45 | end 46 | 47 | def process(rack_request) 48 | ::Typhoeus::Request.new((url_for(rack_request)).to_s, params_for(rack_request)).run 49 | end 50 | 51 | def url_for(rack_request) 52 | rack_request.url.split('?').first 53 | end 54 | 55 | def params_for(rack_request) 56 | { 57 | :method => rack_request.request_method.downcase.to_sym, 58 | :headers => Headers.from(rack_request.env).to_http, 59 | :params => query_params_for(rack_request) 60 | }.merge(body_params_for(rack_request)) 61 | end 62 | 63 | def query_params_for(rack_request) 64 | Rack::Utils.parse_nested_query(rack_request.query_string) 65 | end 66 | 67 | def body_params_for(rack_request) 68 | unless %w[ HEAD GET ].include? rack_request.request_method 69 | {:body => request_body(rack_request) } 70 | else 71 | {} 72 | end 73 | end 74 | 75 | def request_body(rack_request) 76 | input = rack_request.env['rack.input'] 77 | 78 | if input.respond_to?(:each) 79 | body = [] 80 | input.each do |chunk| 81 | body << chunk 82 | end 83 | body.join 84 | else 85 | body.to_s 86 | end 87 | end 88 | end 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/rack/client/middleware.rb: -------------------------------------------------------------------------------- 1 | require 'rack/client/middleware/auth' 2 | require 'rack/client/middleware/cache' 3 | require 'rack/client/middleware/cookie_jar' 4 | require 'rack/client/middleware/follow_redirects' 5 | -------------------------------------------------------------------------------- /lib/rack/client/middleware/auth.rb: -------------------------------------------------------------------------------- 1 | require 'rack/client/middleware/auth/abstract/challenge' 2 | require 'rack/client/middleware/auth/basic' 3 | require 'rack/client/middleware/auth/digest/challenge' 4 | require 'rack/client/middleware/auth/digest/params' 5 | require 'rack/client/middleware/auth/digest/md5' 6 | -------------------------------------------------------------------------------- /lib/rack/client/middleware/auth/abstract/challenge.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | module Auth 4 | module Abstract 5 | class Challenge 6 | extend Forwardable 7 | 8 | def_delegators :@request, :request_method, :path 9 | def_delegators :@response, :status, :headers 10 | 11 | def initialize(request, response) 12 | @request, @response = request, response 13 | end 14 | 15 | def required? 16 | status == 401 17 | end 18 | 19 | def unspecified? 20 | scheme.nil? 21 | end 22 | 23 | def www_authenticate 24 | @www_authenticate ||= headers.detect {|h,_| h =~ /^WWW-AUTHENTICATE$/i } 25 | end 26 | 27 | def parts 28 | @parts ||= www_authenticate if www_authenticate 29 | end 30 | 31 | def scheme 32 | @scheme ||= www_authenticate.last[/^(\w+)/, 1].downcase.to_sym if www_authenticate 33 | end 34 | 35 | def nonce 36 | @nonce ||= Rack::Auth::Digest::Nonce.parse(params['nonce']) 37 | end 38 | 39 | def params 40 | @params ||= Rack::Auth::Digest::Params.parse(parts.last) 41 | end 42 | 43 | def method_missing(sym) 44 | if params.has_key? key = sym.to_s 45 | return params[key] 46 | end 47 | super 48 | end 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/rack/client/middleware/auth/basic.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | module Auth 4 | class Basic 5 | include Rack::Client::DualBand 6 | 7 | def initialize(app, username, password, force = false) 8 | @app, @username, @password, @force = app, username, password, force 9 | end 10 | 11 | def sync_call(env) 12 | return authorized_call(env) if @force 13 | 14 | request = Rack::Request.new(env) 15 | response = Response.new(*@app.call(env)) 16 | challenge = Basic::Challenge.new(request, response) 17 | 18 | if challenge.required? && (challenge.unspecified? || challenge.basic?) 19 | return authorized_call(env) 20 | end 21 | 22 | response.finish 23 | end 24 | 25 | def async_call(env, &b) 26 | return authorized_call(env, &b) if @force 27 | 28 | @app.call(env) do |response_parts| 29 | request = Rack::Request.new(env) 30 | response = Response.new(*response_parts) 31 | challenge = Basic::Challenge.new(request, response) 32 | 33 | if challenge.required? && (challenge.unspecified? || challenge.basic?) 34 | authorized_call(env, &b) 35 | else 36 | yield response.finish 37 | end 38 | end 39 | end 40 | 41 | def authorized_call(env, &b) 42 | @app.call(env.merge(auth_header), &b) 43 | end 44 | 45 | def auth_header 46 | {'HTTP_AUTHORIZATION' => "Basic #{encoded_login}"} 47 | end 48 | 49 | def encoded_login 50 | ["#{@username}:#{@password}"].pack("m*").chomp 51 | end 52 | 53 | class Challenge < Abstract::Challenge 54 | def basic? 55 | :basic == scheme 56 | end 57 | end 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/rack/client/middleware/auth/digest/challenge.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | module Auth 4 | module Digest 5 | class Challenge < Abstract::Challenge 6 | def initialize(request, response, realm, username, password) 7 | super(request, response) 8 | @realm, @username, @password = realm, username, password 9 | end 10 | 11 | def digest? 12 | :digest == scheme 13 | end 14 | 15 | def cnonce 16 | @cnonce ||= Rack::Auth::Digest::Nonce.new.to_s 17 | end 18 | 19 | def response(nc) 20 | H([ A1(), nonce, nc, cnonce, qop, A2() ] * ':') 21 | end 22 | 23 | def A1 24 | H([ @username, @realm, @password ] * ':') 25 | end 26 | 27 | def A2 28 | H([ request_method, path ] * ':') 29 | end 30 | 31 | def H(data) 32 | ::Digest::MD5.hexdigest(data) 33 | end 34 | end 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/rack/client/middleware/auth/digest/md5.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | module Auth 4 | module Digest 5 | class MD5 < Rack::Auth::Digest::MD5 6 | include Rack::Client::DualBand 7 | 8 | def initialize(app, realm, username, password, options = {}) 9 | @app, @realm, @username, @password = app, realm, username, password 10 | @nc = 0 11 | end 12 | 13 | def sync_call(env) 14 | request = Rack::Request.new(env) 15 | response = Response.new(*@app.call(env)) 16 | challenge = Digest::Challenge.new(request, response, @realm, @username, @password) 17 | 18 | if challenge.required? && challenge.digest? && valid?(challenge) 19 | return @app.call(env.merge(authorization(challenge))) 20 | end 21 | 22 | response.finish 23 | end 24 | 25 | def async_call(env) 26 | @app.call(env) do |response_parts| 27 | request = Rack::Request.new(env) 28 | response = Response.new(*response_parts) 29 | challenge = Digest::Challenge.new(request, response, @realm, @username, @password) 30 | 31 | if challenge.required? && challenge.digest? && valid?(challenge) 32 | @app.call(env.merge(authorization(challenge))) {|response_parts| yield response_parts } 33 | else 34 | @app.call(env) {|response_parts| yield response_parts } 35 | end 36 | end 37 | end 38 | 39 | def valid?(challenge) 40 | valid_opaque?(challenge) && valid_nonce?(challenge) 41 | end 42 | 43 | def valid_opaque?(challenge) 44 | !(challenge.opaque.nil? || challenge.opaque.empty?) 45 | end 46 | 47 | def valid_nonce?(challenge) 48 | challenge.nonce.valid? 49 | end 50 | 51 | def authorization(challenge) 52 | return 'HTTP_AUTHORIZATION' => "Digest #{params_for(challenge)}" 53 | end 54 | 55 | def params_for(challenge) 56 | nc = next_nc 57 | 58 | Rack::Auth::Digest::Params.new do |params| 59 | params['username'] = @username 60 | params['realm'] = @realm 61 | params['nonce'] = challenge.nonce.to_s 62 | params['uri'] = challenge.path 63 | params['qop'] = challenge.qop 64 | params['nc'] = nc 65 | params['cnonce'] = challenge.cnonce 66 | params['response'] = challenge.response(nc) 67 | params['opaque'] = challenge.opaque 68 | end 69 | end 70 | 71 | def next_nc 72 | sprintf("%08x", @nc += 1) 73 | end 74 | end 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/rack/client/middleware/auth/digest/params.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | module Auth 4 | module Digest 5 | class Params < Rack::Auth::Digest::Params 6 | end 7 | end 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/rack/client/middleware/cache.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | module Cache 4 | def self.new(backend, options={}, &b) 5 | Context.new(backend, options, &b) 6 | end 7 | end 8 | end 9 | end 10 | 11 | require 'rack/client/middleware/cache/options' 12 | require 'rack/client/middleware/cache/cachecontrol' 13 | require 'rack/client/middleware/cache/context' 14 | require 'rack/client/middleware/cache/entitystore' 15 | require 'rack/client/middleware/cache/key' 16 | require 'rack/client/middleware/cache/metastore' 17 | require 'rack/client/middleware/cache/request' 18 | require 'rack/client/middleware/cache/response' 19 | require 'rack/client/middleware/cache/storage' 20 | -------------------------------------------------------------------------------- /lib/rack/client/middleware/cache/cachecontrol.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | module Cache 4 | 5 | # Parses a Cache-Control header and exposes the directives as a Hash. 6 | # Directives that do not have values are set to +true+. 7 | class CacheControl < Hash 8 | def initialize(value=nil) 9 | parse(value) 10 | end 11 | 12 | # Indicates that the response MAY be cached by any cache, even if it 13 | # would normally be non-cacheable or cacheable only within a non- 14 | # shared cache. 15 | # 16 | # A response may be considered public without this directive if the 17 | # private directive is not set and the request does not include an 18 | # Authorization header. 19 | def public? 20 | self['public'] 21 | end 22 | 23 | # Indicates that all or part of the response message is intended for 24 | # a single user and MUST NOT be cached by a shared cache. This 25 | # allows an origin server to state that the specified parts of the 26 | # response are intended for only one user and are not a valid 27 | # response for requests by other users. A private (non-shared) cache 28 | # MAY cache the response. 29 | # 30 | # Note: This usage of the word private only controls where the 31 | # response may be cached, and cannot ensure the privacy of the 32 | # message content. 33 | def private? 34 | self['private'] 35 | end 36 | 37 | # When set in a response, a cache MUST NOT use the response to satisfy a 38 | # subsequent request without successful revalidation with the origin 39 | # server. This allows an origin server to prevent caching even by caches 40 | # that have been configured to return stale responses to client requests. 41 | # 42 | # Note that this does not necessary imply that the response may not be 43 | # stored by the cache, only that the cache cannot serve it without first 44 | # making a conditional GET request with the origin server. 45 | # 46 | # When set in a request, the server MUST NOT use a cached copy for its 47 | # response. This has quite different semantics compared to the no-cache 48 | # directive on responses. When the client specifies no-cache, it causes 49 | # an end-to-end reload, forcing each cache to update their cached copies. 50 | def no_cache? 51 | self['no-cache'] 52 | end 53 | 54 | # Indicates that the response MUST NOT be stored under any circumstances. 55 | # 56 | # The purpose of the no-store directive is to prevent the 57 | # inadvertent release or retention of sensitive information (for 58 | # example, on backup tapes). The no-store directive applies to the 59 | # entire message, and MAY be sent either in a response or in a 60 | # request. If sent in a request, a cache MUST NOT store any part of 61 | # either this request or any response to it. If sent in a response, 62 | # a cache MUST NOT store any part of either this response or the 63 | # request that elicited it. This directive applies to both non- 64 | # shared and shared caches. "MUST NOT store" in this context means 65 | # that the cache MUST NOT intentionally store the information in 66 | # non-volatile storage, and MUST make a best-effort attempt to 67 | # remove the information from volatile storage as promptly as 68 | # possible after forwarding it. 69 | # 70 | # The purpose of this directive is to meet the stated requirements 71 | # of certain users and service authors who are concerned about 72 | # accidental releases of information via unanticipated accesses to 73 | # cache data structures. While the use of this directive might 74 | # improve privacy in some cases, we caution that it is NOT in any 75 | # way a reliable or sufficient mechanism for ensuring privacy. In 76 | # particular, malicious or compromised caches might not recognize or 77 | # obey this directive, and communications networks might be 78 | # vulnerable to eavesdropping. 79 | def no_store? 80 | self['no-store'] 81 | end 82 | 83 | # The expiration time of an entity MAY be specified by the origin 84 | # server using the Expires header (see section 14.21). Alternatively, 85 | # it MAY be specified using the max-age directive in a response. When 86 | # the max-age cache-control directive is present in a cached response, 87 | # the response is stale if its current age is greater than the age 88 | # value given (in seconds) at the time of a new request for that 89 | # resource. The max-age directive on a response implies that the 90 | # response is cacheable (i.e., "public") unless some other, more 91 | # restrictive cache directive is also present. 92 | # 93 | # If a response includes both an Expires header and a max-age 94 | # directive, the max-age directive overrides the Expires header, even 95 | # if the Expires header is more restrictive. This rule allows an origin 96 | # server to provide, for a given response, a longer expiration time to 97 | # an HTTP/1.1 (or later) cache than to an HTTP/1.0 cache. This might be 98 | # useful if certain HTTP/1.0 caches improperly calculate ages or 99 | # expiration times, perhaps due to desynchronized clocks. 100 | # 101 | # Many HTTP/1.0 cache implementations will treat an Expires value that 102 | # is less than or equal to the response Date value as being equivalent 103 | # to the Cache-Control response directive "no-cache". If an HTTP/1.1 104 | # cache receives such a response, and the response does not include a 105 | # Cache-Control header field, it SHOULD consider the response to be 106 | # non-cacheable in order to retain compatibility with HTTP/1.0 servers. 107 | # 108 | # When the max-age directive is included in the request, it indicates 109 | # that the client is willing to accept a response whose age is no 110 | # greater than the specified time in seconds. 111 | def max_age 112 | self['max-age'].to_i if key?('max-age') 113 | end 114 | 115 | # If a response includes an s-maxage directive, then for a shared 116 | # cache (but not for a private cache), the maximum age specified by 117 | # this directive overrides the maximum age specified by either the 118 | # max-age directive or the Expires header. The s-maxage directive 119 | # also implies the semantics of the proxy-revalidate directive. i.e., 120 | # that the shared cache must not use the entry after it becomes stale 121 | # to respond to a subsequent request without first revalidating it with 122 | # the origin server. The s-maxage directive is always ignored by a 123 | # private cache. 124 | def shared_max_age 125 | self['s-maxage'].to_i if key?('s-maxage') 126 | end 127 | alias_method :s_maxage, :shared_max_age 128 | 129 | # Because a cache MAY be configured to ignore a server's specified 130 | # expiration time, and because a client request MAY include a max- 131 | # stale directive (which has a similar effect), the protocol also 132 | # includes a mechanism for the origin server to require revalidation 133 | # of a cache entry on any subsequent use. When the must-revalidate 134 | # directive is present in a response received by a cache, that cache 135 | # MUST NOT use the entry after it becomes stale to respond to a 136 | # subsequent request without first revalidating it with the origin 137 | # server. (I.e., the cache MUST do an end-to-end revalidation every 138 | # time, if, based solely on the origin server's Expires or max-age 139 | # value, the cached response is stale.) 140 | # 141 | # The must-revalidate directive is necessary to support reliable 142 | # operation for certain protocol features. In all circumstances an 143 | # HTTP/1.1 cache MUST obey the must-revalidate directive; in 144 | # particular, if the cache cannot reach the origin server for any 145 | # reason, it MUST generate a 504 (Gateway Timeout) response. 146 | # 147 | # Servers SHOULD send the must-revalidate directive if and only if 148 | # failure to revalidate a request on the entity could result in 149 | # incorrect operation, such as a silently unexecuted financial 150 | # transaction. Recipients MUST NOT take any automated action that 151 | # violates this directive, and MUST NOT automatically provide an 152 | # unvalidated copy of the entity if revalidation fails. 153 | def must_revalidate? 154 | self['must-revalidate'] 155 | end 156 | 157 | # The proxy-revalidate directive has the same meaning as the must- 158 | # revalidate directive, except that it does not apply to non-shared 159 | # user agent caches. It can be used on a response to an 160 | # authenticated request to permit the user's cache to store and 161 | # later return the response without needing to revalidate it (since 162 | # it has already been authenticated once by that user), while still 163 | # requiring proxies that service many users to revalidate each time 164 | # (in order to make sure that each user has been authenticated). 165 | # Note that such authenticated responses also need the public cache 166 | # control directive in order to allow them to be cached at all. 167 | def proxy_revalidate? 168 | self['proxy-revalidate'] 169 | end 170 | 171 | def to_s 172 | bools, vals = [], [] 173 | each do |key,value| 174 | if value == true 175 | bools << key 176 | elsif value 177 | vals << "#{key}=#{value}" 178 | end 179 | end 180 | (bools.sort + vals.sort).join(', ') 181 | end 182 | 183 | private 184 | def parse(value) 185 | return if value.nil? || value.empty? 186 | value.delete(' ').split(',').inject(self) do |hash,part| 187 | name, value = part.split('=', 2) 188 | hash[name.downcase] = (value || true) unless name.empty? 189 | hash 190 | end 191 | end 192 | end 193 | end 194 | end 195 | end 196 | -------------------------------------------------------------------------------- /lib/rack/client/middleware/cache/context.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | module Cache 4 | class Context 5 | include Options 6 | include DualBand 7 | 8 | def initialize(app, options = {}) 9 | @app = app 10 | 11 | initialize_options options 12 | end 13 | 14 | def sync_call(env) 15 | @trace = [] 16 | request = Request.new(options.merge(env)) 17 | 18 | return @app.call(env) unless request.cacheable? 19 | 20 | response = Response.new(*@app.call(env = request.env)) 21 | 22 | if response.not_modified? 23 | response = lookup(request) 24 | elsif response.cacheable? 25 | store(request, response) 26 | else 27 | pass(request) 28 | end 29 | 30 | trace = @trace.join(', ') 31 | response.headers['X-Rack-Client-Cache'] = trace 32 | 33 | response.to_a 34 | end 35 | 36 | def async_call(env) 37 | @trace = [] 38 | request = Request.new(options.merge(env)) 39 | 40 | if request.cacheable? 41 | @app.call(env = request.env) do |response_parts| 42 | response = Response.new(*response_parts) 43 | 44 | if response.not_modified? 45 | response = lookup(request) 46 | elsif response.cacheable? 47 | store(request, response) 48 | else 49 | pass(env) 50 | end 51 | 52 | trace = @trace.join(', ') 53 | response.headers['X-Rack-Client-Cache'] = trace 54 | 55 | yield response.to_a 56 | end 57 | else 58 | @app.call(env) {|*response| yield *response } 59 | end 60 | end 61 | 62 | def lookup(request) 63 | begin 64 | entry = metastore.lookup(request, entitystore) 65 | record :fresh 66 | entry 67 | rescue Exception => e 68 | log_error(e) 69 | return pass(request) 70 | end 71 | end 72 | 73 | def store(request, response) 74 | metastore.store(request, response, entitystore) 75 | record :store 76 | end 77 | 78 | def metastore 79 | uri = options['rack-client-cache.metastore'] 80 | storage.resolve_metastore_uri(uri) 81 | end 82 | 83 | def entitystore 84 | uri = options['rack-client-cache.entitystore'] 85 | storage.resolve_entitystore_uri(uri) 86 | end 87 | 88 | # Record that an event took place. 89 | def record(event) 90 | @trace << event 91 | end 92 | 93 | def pass(request) 94 | record :pass 95 | forward(request) 96 | end 97 | 98 | def forward(request) 99 | Response.new(*@app.call(request.env)) 100 | end 101 | end 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /lib/rack/client/middleware/cache/entitystore.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | module Cache 4 | class EntityStore 5 | # Read body calculating the SHA1 checksum and size while 6 | # yielding each chunk to the block. If the body responds to close, 7 | # call it after iteration is complete. Return a two-tuple of the form: 8 | # [ hexdigest, size ]. 9 | def slurp(body) 10 | digest, size = Digest::SHA1.new, 0 11 | body.each do |part| 12 | size += bytesize(part) 13 | digest << part 14 | yield part 15 | end 16 | body.close if body.respond_to? :close 17 | [digest.hexdigest, size] 18 | end 19 | 20 | if ''.respond_to?(:bytesize) 21 | def bytesize(string); string.bytesize; end 22 | else 23 | def bytesize(string); string.size; end 24 | end 25 | 26 | private :slurp, :bytesize 27 | 28 | class Heap < EntityStore 29 | 30 | # Create the store with the specified backing Hash. 31 | def initialize(hash={}) 32 | @hash = hash 33 | end 34 | 35 | # Determine whether the response body with the specified key (SHA1) 36 | # exists in the store. 37 | def exist?(key) 38 | @hash.include?(key) 39 | end 40 | 41 | # Return an object suitable for use as a Rack response body for the 42 | # specified key. 43 | def open(key) 44 | (body = @hash[key]) && body.dup 45 | end 46 | 47 | # Read all data associated with the given key and return as a single 48 | # String. 49 | def read(key) 50 | (body = @hash[key]) && body.join 51 | end 52 | 53 | # Write the Rack response body immediately and return the SHA1 key. 54 | def write(body) 55 | buf = [] 56 | key, size = slurp(body) { |part| buf << part } 57 | @hash[key] = buf 58 | [key, size] 59 | end 60 | 61 | # Remove the body corresponding to key; return nil. 62 | def purge(key) 63 | @hash.delete(key) 64 | nil 65 | end 66 | 67 | def self.resolve(uri) 68 | new 69 | end 70 | end 71 | 72 | HEAP = Heap 73 | MEM = Heap 74 | end 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/rack/client/middleware/cache/key.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | module Cache 4 | class Key 5 | include Rack::Utils 6 | def self.call(request) 7 | new(request).generate 8 | end 9 | 10 | def initialize(request) 11 | @request = request 12 | end 13 | 14 | # Generate a normalized cache key for the request. 15 | def generate 16 | parts = [] 17 | parts << @request.scheme << "://" 18 | parts << @request.host 19 | 20 | if @request.scheme == "https" && @request.port != 443 || 21 | @request.scheme == "http" && @request.port != 80 22 | parts << ":" << @request.port.to_s 23 | end 24 | 25 | parts << @request.script_name 26 | parts << @request.path_info 27 | 28 | if qs = query_string 29 | parts << "?" 30 | parts << qs 31 | end 32 | 33 | parts.join 34 | end 35 | 36 | private 37 | # Build a normalized query string by alphabetizing all keys/values 38 | # and applying consistent escaping. 39 | def query_string 40 | return nil if @request.query_string.nil? 41 | 42 | @request.query_string.split(/[&;] */n). 43 | map { |p| unescape(p).split('=', 2) }. 44 | sort. 45 | map { |k,v| "#{escape(k)}=#{escape(v)}" }. 46 | join('&') 47 | end 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/rack/client/middleware/cache/metastore.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | module Cache 4 | class MetaStore 5 | 6 | def lookup(request, entity_store) 7 | key = cache_key(request) 8 | entries = read(key) 9 | 10 | # bail out if we have nothing cached 11 | return nil if entries.empty? 12 | 13 | # find a cached entry that matches the request. 14 | env = request.env 15 | match = entries.detect{|req,res| requests_match?(res['Vary'], env, req)} 16 | return nil if match.nil? 17 | 18 | req, res = match 19 | if body = entity_store.open(res['X-Content-Digest']) 20 | restore_response(res, body) 21 | else 22 | # TODO the metastore referenced an entity that doesn't exist in 23 | # the entitystore. we definitely want to return nil but we should 24 | # also purge the entry from the meta-store when this is detected. 25 | end 26 | end 27 | 28 | # Write a cache entry to the store under the given key. Existing 29 | # entries are read and any that match the response are removed. 30 | # This method calls #write with the new list of cache entries. 31 | def store(request, response, entity_store) 32 | key = cache_key(request) 33 | stored_env = persist_request(request) 34 | 35 | # write the response body to the entity store if this is the 36 | # original response. 37 | if response.headers['X-Content-Digest'].nil? 38 | digest, size = entity_store.write(response.body) 39 | response.headers['X-Content-Digest'] = digest 40 | response.headers['Content-Length'] = size.to_s unless response.headers['Transfer-Encoding'] 41 | response.body = entity_store.open(digest) 42 | end 43 | 44 | # read existing cache entries, remove non-varying, and add this one to 45 | # the list 46 | vary = response.vary 47 | entries = 48 | read(key).reject do |env,res| 49 | (vary == res['Vary']) && 50 | requests_match?(vary, env, stored_env) 51 | end 52 | 53 | headers = persist_response(response) 54 | headers.delete 'Age' 55 | 56 | entries.unshift [stored_env, headers] 57 | write key, entries 58 | key 59 | end 60 | 61 | def cache_key(request) 62 | keygen = request.env['rack-client-cache.cache_key'] || Key 63 | keygen.call(request) 64 | end 65 | 66 | # Extract the environment Hash from +request+ while making any 67 | # necessary modifications in preparation for persistence. The Hash 68 | # returned must be marshalable. 69 | def persist_request(request) 70 | env = request.env.dup 71 | env.reject! { |key,val| key =~ /[^0-9A-Z_]/ } 72 | env 73 | end 74 | 75 | def persist_response(response) 76 | hash = response.headers.to_hash 77 | hash['X-Status'] = response.status.to_s 78 | hash 79 | end 80 | 81 | # Converts a stored response hash into a Response object. The caller 82 | # is responsible for loading and passing the body if needed. 83 | def restore_response(hash, body=nil) 84 | status = hash.delete('X-Status').to_i 85 | Rack::Client::Cache::Response.new(status, hash, body) 86 | end 87 | 88 | # Determine whether the two environment hashes are non-varying based on 89 | # the vary response header value provided. 90 | def requests_match?(vary, env1, env2) 91 | return true if vary.nil? || vary == '' 92 | vary.split(/[\s,]+/).all? do |header| 93 | key = "HTTP_#{header.upcase.tr('-', '_')}" 94 | env1[key] == env2[key] 95 | end 96 | end 97 | 98 | class Heap < MetaStore 99 | def initialize(hash={}) 100 | @hash = hash 101 | end 102 | 103 | def read(key) 104 | @hash.fetch(key, []).collect do |req,res| 105 | [req.dup, res.dup] 106 | end 107 | end 108 | 109 | def write(key, entries) 110 | @hash[key] = entries 111 | end 112 | 113 | def purge(key) 114 | @hash.delete(key) 115 | nil 116 | end 117 | 118 | def to_hash 119 | @hash 120 | end 121 | 122 | def self.resolve(uri) 123 | new 124 | end 125 | end 126 | 127 | HEAP = Heap 128 | MEM = HEAP 129 | 130 | end 131 | end 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /lib/rack/client/middleware/cache/options.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | module Cache 4 | # vendored, originally from rack-cache. 5 | module Options 6 | def self.option_accessor(key) 7 | name = option_name(key) 8 | define_method(key) { || options[name] } 9 | define_method("#{key}=") { |value| options[name] = value } 10 | define_method("#{key}?") { || !! options[name] } 11 | end 12 | 13 | def option_name(key) 14 | case key 15 | when Symbol ; "rack-client-cache.#{key}" 16 | when String ; key 17 | else raise ArgumentError 18 | end 19 | end 20 | module_function :option_name 21 | 22 | # Enable verbose trace logging. This option is currently enabled by 23 | # default but is likely to be disabled in a future release. 24 | option_accessor :verbose 25 | 26 | # The storage resolver. Defaults to the Rack::Cache.storage singleton instance 27 | # of Rack::Cache::Storage. This object is responsible for resolving metastore 28 | # and entitystore URIs to an implementation instances. 29 | option_accessor :storage 30 | 31 | # A URI specifying the meta-store implementation that should be used to store 32 | # request/response meta information. The following URIs schemes are 33 | # supported: 34 | # 35 | # * heap:/ 36 | # * file:/absolute/path or file:relative/path 37 | # * memcached://localhost:11211[/namespace] 38 | # 39 | # If no meta store is specified the 'heap:/' store is assumed. This 40 | # implementation has significant draw-backs so explicit configuration is 41 | # recommended. 42 | option_accessor :metastore 43 | 44 | # A custom cache key generator, which can be anything that responds to :call. 45 | # By default, this is the Rack::Cache::Key class, but you can implement your 46 | # own generator. A cache key generator gets passed a request and generates the 47 | # appropriate cache key. 48 | # 49 | # In addition to setting the generator to an object, you can just pass a block 50 | # instead, which will act as the cache key generator: 51 | # 52 | # set :cache_key do |request| 53 | # request.fullpath.replace(/\//, '-') 54 | # end 55 | option_accessor :cache_key 56 | 57 | # A URI specifying the entity-store implementation that should be used to 58 | # store response bodies. See the metastore option for information on 59 | # supported URI schemes. 60 | # 61 | # If no entity store is specified the 'heap:/' store is assumed. This 62 | # implementation has significant draw-backs so explicit configuration is 63 | # recommended. 64 | option_accessor :entitystore 65 | 66 | # The number of seconds that a cache entry should be considered 67 | # "fresh" when no explicit freshness information is provided in 68 | # a response. Explicit Cache-Control or Expires headers 69 | # override this value. 70 | # 71 | # Default: 0 72 | option_accessor :default_ttl 73 | 74 | # Set of request headers that trigger "private" cache-control behavior 75 | # on responses that don't explicitly state whether the response is 76 | # public or private via a Cache-Control directive. Applications that use 77 | # cookies for authorization may need to add the 'Cookie' header to this 78 | # list. 79 | # 80 | # Default: ['Authorization', 'Cookie'] 81 | option_accessor :private_headers 82 | 83 | # Specifies whether the client can force a cache reload by including a 84 | # Cache-Control "no-cache" directive in the request. This is enabled by 85 | # default for compliance with RFC 2616. 86 | option_accessor :allow_reload 87 | 88 | # Specifies whether the client can force a cache revalidate by including 89 | # a Cache-Control "max-age=0" directive in the request. This is enabled by 90 | # default for compliance with RFC 2616. 91 | option_accessor :allow_revalidate 92 | 93 | # The underlying options Hash. During initialization (or outside of a 94 | # request), this is a default values Hash. During a request, this is the 95 | # Rack environment Hash. The default values Hash is merged in underneath 96 | # the Rack environment before each request is processed. 97 | def options 98 | @env || @default_options 99 | end 100 | 101 | # Set multiple options. 102 | def options=(hash={}) 103 | hash.each { |key,value| write_option(key, value) } 104 | end 105 | 106 | # Set an option. When +option+ is a Symbol, it is set in the Rack 107 | # Environment as "rack-cache.option". When +option+ is a String, it 108 | # exactly as specified. The +option+ argument may also be a Hash in 109 | # which case each key/value pair is merged into the environment as if 110 | # the #set method were called on each. 111 | def set(option, value=self, &block) 112 | if block_given? 113 | write_option option, block 114 | elsif value == self 115 | self.options = option.to_hash 116 | else 117 | write_option option, value 118 | end 119 | end 120 | 121 | private 122 | def initialize_options(options={}) 123 | @default_options = { 124 | 'rack-client-cache.cache_key' => Key, 125 | 'rack-client-cache.verbose' => true, 126 | 'rack-client-cache.storage' => Storage.instance, 127 | 'rack-client-cache.metastore' => 'heap:/', 128 | 'rack-client-cache.entitystore' => 'heap:/', 129 | 'rack-client-cache.default_ttl' => 0, 130 | 'rack-client-cache.private_headers' => ['Authorization', 'Cookie'], 131 | 'rack-client-cache.allow_reload' => false, 132 | 'rack-client-cache.allow_revalidate' => false 133 | } 134 | self.options = options 135 | end 136 | 137 | def read_option(key) 138 | options[option_name(key)] 139 | end 140 | 141 | def write_option(key, value) 142 | options[option_name(key)] = value 143 | end 144 | end 145 | end 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /lib/rack/client/middleware/cache/request.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | module Cache 4 | class Request < Rack::Request 5 | include Options 6 | 7 | def cacheable? 8 | request_method == 'GET' 9 | end 10 | 11 | def env 12 | return super if @calculating_headers 13 | cache_control_headers.merge(super) 14 | end 15 | 16 | def cache_control_headers 17 | @calculating_headers = true 18 | return {} unless cacheable? 19 | entry = metastore.lookup(self, entitystore) 20 | 21 | if entry 22 | headers_for(entry) 23 | else 24 | {} 25 | end 26 | ensure 27 | @calculating_headers = nil 28 | end 29 | 30 | def headers_for(response) 31 | return 'HTTP_If-None-Match' => response.etag 32 | end 33 | 34 | def metastore 35 | uri = options['rack-client-cache.metastore'] 36 | storage.resolve_metastore_uri(uri) 37 | end 38 | 39 | def entitystore 40 | uri = options['rack-client-cache.entitystore'] 41 | storage.resolve_entitystore_uri(uri) 42 | end 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/rack/client/middleware/cache/response.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | module Cache 4 | class Response < Rack::Client::Response 5 | include Rack::Response::Helpers 6 | 7 | def not_modified? 8 | status == 304 9 | end 10 | 11 | alias_method :finish, :to_a 12 | 13 | # Status codes of responses that MAY be stored by a cache or used in reply 14 | # to a subsequent request. 15 | # 16 | # http://tools.ietf.org/html/rfc2616#section-13.4 17 | CACHEABLE_RESPONSE_CODES = [ 18 | 200, # OK 19 | 203, # Non-Authoritative Information 20 | 300, # Multiple Choices 21 | 301, # Moved Permanently 22 | 302, # Found 23 | 404, # Not Found 24 | 410 # Gone 25 | ].to_set 26 | 27 | # A Hash of name=value pairs that correspond to the Cache-Control header. 28 | # Valueless parameters (e.g., must-revalidate, no-store) have a Hash value 29 | # of true. This method always returns a Hash, empty if no Cache-Control 30 | # header is present. 31 | def cache_control 32 | @cache_control ||= CacheControl.new(headers['Cache-Control']) 33 | end 34 | 35 | def cacheable? 36 | return false unless CACHEABLE_RESPONSE_CODES.include?(status) 37 | return false if cache_control.no_store? || cache_control.private? 38 | validateable? || fresh? 39 | end 40 | 41 | # The literal value of ETag HTTP header or nil if no ETag is specified. 42 | def etag 43 | headers['ETag'] 44 | end 45 | 46 | def validateable? 47 | headers.key?('Last-Modified') || headers.key?('ETag') 48 | end 49 | 50 | # The literal value of the Vary header, or nil when no header is present. 51 | def vary 52 | headers['Vary'] 53 | end 54 | 55 | # Does the response include a Vary header? 56 | def vary? 57 | ! vary.nil? 58 | end 59 | 60 | def fresh? 61 | ttl && ttl > 0 62 | end 63 | 64 | def ttl 65 | max_age - age if max_age 66 | end 67 | 68 | def max_age 69 | cache_control.shared_max_age || 70 | cache_control.max_age || 71 | (expires && (expires - date)) 72 | end 73 | 74 | def expires 75 | headers['Expires'] && Time.httpdate(headers['Expires']) 76 | end 77 | 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/rack/client/middleware/cache/storage.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | module Cache 4 | class Storage 5 | def initialize 6 | @metastores = {} 7 | @entitystores = {} 8 | end 9 | 10 | def resolve_metastore_uri(uri) 11 | @metastores[uri.to_s] ||= create_store(MetaStore, uri) 12 | end 13 | 14 | def resolve_entitystore_uri(uri) 15 | @entitystores[uri.to_s] ||= create_store(EntityStore, uri) 16 | end 17 | 18 | def create_store(type, uri) 19 | if uri.respond_to?(:scheme) || uri.respond_to?(:to_str) 20 | uri = URI.parse(uri) unless uri.respond_to?(:scheme) 21 | if type.const_defined?(uri.scheme.upcase) 22 | klass = type.const_get(uri.scheme.upcase) 23 | klass.resolve(uri) 24 | else 25 | fail "Unknown storage provider: #{uri.to_s}" 26 | end 27 | end 28 | end 29 | 30 | def clear 31 | @metastores.clear 32 | @entitystores.clear 33 | nil 34 | end 35 | 36 | @@singleton_instance = new 37 | def self.instance 38 | @@singleton_instance 39 | end 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/rack/client/middleware/cookie_jar.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | module CookieJar 4 | def self.new(app, &b) 5 | Context.new(app, &b) 6 | end 7 | end 8 | end 9 | end 10 | 11 | require 'rack/client/middleware/cookie_jar/options' 12 | require 'rack/client/middleware/cookie_jar/cookie' 13 | require 'rack/client/middleware/cookie_jar/cookiestore' 14 | require 'rack/client/middleware/cookie_jar/context' 15 | require 'rack/client/middleware/cookie_jar/request' 16 | require 'rack/client/middleware/cookie_jar/response' 17 | require 'rack/client/middleware/cookie_jar/storage' 18 | -------------------------------------------------------------------------------- /lib/rack/client/middleware/cookie_jar/context.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | module CookieJar 4 | class Context 5 | include Options 6 | include DualBand 7 | 8 | def initialize(app, options = {}) 9 | @app = app 10 | 11 | initialize_options options 12 | end 13 | 14 | def sync_call(env) 15 | request = Request.new(env) 16 | cookies = lookup(request) 17 | request.inject(cookies) 18 | 19 | response = Response.new(*@app.call(request.env)) 20 | cookies = Cookie.merge(cookies, response.cookies) 21 | store cookies 22 | 23 | response['rack-client-cookiejar.cookies'] = cookies.map {|c| c.to_header } * ', ' unless cookies.empty? 24 | response.finish 25 | end 26 | 27 | def async_call(env) 28 | request = Request.new(env) 29 | cookies = lookup(request) 30 | request.inject(cookies) 31 | 32 | @app.call(request.env) do |request_parts| 33 | response = Response.new(*request_parts) 34 | cookies = Cookie.merge(cookies, response.cookies) 35 | store cookies 36 | 37 | response['rack-client-cookiejar.cookies'] = cookies.map {|c| c.to_header } * ', ' unless cookies.empty? 38 | yield response.finish 39 | end 40 | end 41 | 42 | def lookup(request) 43 | cookiestore.match(request.host, request.path) 44 | end 45 | 46 | def store(cookies) 47 | cookies.each do |cookie| 48 | cookiestore.store(cookie) 49 | end 50 | end 51 | 52 | def cookiestore 53 | uri = options['rack-client-cookiejar.cookiestore'] 54 | storage.resolve_cookiestore_uri(uri) 55 | end 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/rack/client/middleware/cookie_jar/cookie.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | module CookieJar 4 | class Cookie < Struct.new(:key, :value, :domain, :path) 5 | 6 | def self.merge(bottom, top) 7 | bottom.reject {|a| top.any? {|b| a == b } } | top 8 | end 9 | 10 | def self.parse(raw) 11 | raw.split(', ').map {|header| from(header) } 12 | end 13 | 14 | def self.from(header) 15 | data = header.split('; ') 16 | tuple = data.shift.split('=') 17 | parts = data.map {|s| s.split('=') } 18 | 19 | new parts.inject('key'=> tuple.first, 'value'=> tuple.last) {|h,(k,v)| h.update(k => v)} 20 | end 21 | 22 | def initialize(parts = {}) 23 | parts.each do |k,v| 24 | send(:"#{k}=", v) 25 | end 26 | end 27 | 28 | def to_key 29 | [ key, domain, path ] * ';' 30 | end 31 | 32 | def to_header 33 | hash = members.zip(values).inject({}) {|h,(k,v)| h.update(k => v) }.reject {|k,v| v.nil?} 34 | "#{hash.delete('key')}=#{hash.delete('value')}" << ('; ' + hash.map {|(k,v)| "#{k}=#{v}" } * '; ' unless hash.empty?) 35 | end 36 | 37 | def eql?(other) 38 | to_key == other.to_key 39 | end 40 | 41 | def match?(domain, path) 42 | fuzzy_domain_equal(domain) && fuzzy_path_equal(path) 43 | end 44 | 45 | def fuzzy_domain_equal(other_domain) 46 | if domain =~ /^\./ 47 | other_domain =~ /#{Regexp.escape(domain)}$/ 48 | else 49 | domain == other_domain 50 | end 51 | end 52 | 53 | def fuzzy_path_equal(other_path) 54 | path == '/' || path == other_path 55 | end 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/rack/client/middleware/cookie_jar/cookiestore.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | module CookieJar 4 | class CookieStore 5 | def store(cookie) 6 | write cookie.to_key, cookie.to_header 7 | end 8 | 9 | def match(domain, path) 10 | cookies = map {|header| Cookie.from(header) } 11 | cookies.select {|cookie| cookie.match?(domain, path) } 12 | end 13 | 14 | class Heap < CookieStore 15 | def initialize 16 | @heap = Hash.new {|h,k| h[k] = [] } 17 | end 18 | 19 | def write(key, value) 20 | @heap[key] << value 21 | end 22 | 23 | def map 24 | @heap.values.flatten.map {|*a| yield *a } 25 | end 26 | 27 | def self.resolve(uri) 28 | new 29 | end 30 | end 31 | 32 | HEAP = Heap 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/rack/client/middleware/cookie_jar/options.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | module CookieJar 4 | module Options 5 | def self.option_accessor(key) 6 | name = option_name(key) 7 | define_method(key) { || options[name] } 8 | define_method("#{key}=") { |value| options[name] = value } 9 | define_method("#{key}?") { || !! options[name] } 10 | end 11 | 12 | def options 13 | @default_options.merge(@options) 14 | end 15 | 16 | def options=(hash = {}) 17 | @options = hash.each { |key,value| write_option(key, value) } 18 | end 19 | 20 | def option_name(key) 21 | case key 22 | when Symbol ; "rack-client-cookiejar.#{key}" 23 | when String ; key 24 | else raise ArgumentError 25 | end 26 | end 27 | module_function :option_name 28 | 29 | option_accessor :storage 30 | option_accessor :cookiestore 31 | option_accessor :cookies 32 | 33 | def initialize_options(options={}) 34 | @default_options = { 35 | 'rack-client-cookiejar.storage' => Storage.new, 36 | 'rack-client-cookiejar.cookiestore' => 'heap:/', 37 | } 38 | self.options = options 39 | end 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/rack/client/middleware/cookie_jar/request.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | module CookieJar 4 | class Request < Rack::Request 5 | def inject(cookies) 6 | if raw_cookies = env['HTTP_COOKIE'] 7 | cookies = Cookie.merge(cookies, raw_cookies) 8 | end 9 | 10 | env['HTTP_COOKIE'] = cookies.map {|c| c.to_header } * ', ' unless cookies.empty? 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/rack/client/middleware/cookie_jar/response.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | module CookieJar 4 | class Response < Client::Response 5 | def cookies 6 | return [] unless set_cookie 7 | Cookie.parse(set_cookie.last) 8 | end 9 | 10 | def set_cookie 11 | @set_cookie ||= headers.detect {|(k,v)| k =~ /Set-Cookie/i } 12 | end 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/rack/client/middleware/cookie_jar/storage.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | module CookieJar 4 | class Storage 5 | def initialize 6 | @cookiestores = {} 7 | end 8 | 9 | def resolve_cookiestore_uri(uri) 10 | @cookiestores[uri.to_s] ||= create_store(uri) 11 | end 12 | 13 | def create_store(uri) 14 | if uri.respond_to?(:scheme) || uri.respond_to?(:to_str) 15 | uri = URI.parse(uri) unless uri.respond_to?(:scheme) 16 | if CookieStore.const_defined?(uri.scheme.upcase) 17 | klass = CookieStore.const_get(uri.scheme.upcase) 18 | klass.resolve(uri) 19 | else 20 | fail "Unknown storage provider: #{uri.to_s}" 21 | end 22 | else 23 | fail "Unknown storage provider: #{uri.to_s}" 24 | end 25 | end 26 | 27 | @@singleton_instance = new 28 | def self.instance 29 | @@singleton_instance 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/rack/client/middleware/follow_redirects.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | class FollowRedirects 4 | include DualBand 5 | 6 | def initialize(app) 7 | @app = app 8 | end 9 | 10 | def async_call(env, &block) 11 | @app.call(env) do |tuple| 12 | response = Response.new(*tuple) 13 | 14 | if response.redirect? 15 | follow_redirect(response, env, &block) 16 | else 17 | yield response.finish 18 | end 19 | end 20 | end 21 | 22 | def sync_call(env, &block) 23 | response = Response.new(*@app.call(env)) 24 | response.redirect? ? follow_redirect(response, env, &block) : response 25 | end 26 | 27 | def follow_redirect(response, env, &block) 28 | call(next_env(response, env), &block) 29 | end 30 | 31 | def next_env(response, env) 32 | env = env.dup 33 | 34 | original = URI.parse(env['REQUEST_URI']) 35 | redirection = URI.parse(response['Location']) 36 | 37 | uri = original.merge(redirection) 38 | 39 | env.update 'REQUEST_METHOD' => 'GET' 40 | env.update 'PATH_INFO' => uri.path.empty? ? '/' : uri.path 41 | env.update 'REQUEST_URI' => uri.to_s 42 | env.update 'SERVER_NAME' => uri.host 43 | env.update 'SERVER_PORT' => uri.port 44 | env.update 'SCRIPT_NAME' => '' 45 | 46 | env.update 'rack.url_scheme' => uri.scheme 47 | 48 | env.update 'HTTPS' => env["rack.url_scheme"] == "https" ? "on" : "off" 49 | 50 | env 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/rack/client/parser.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | module Parser 4 | autoload :JSON, 'rack/client/parser/json' 5 | autoload :YAML, 'rack/client/parser/yaml' 6 | 7 | def self.new(app, &b) 8 | Context.new(app, &b) 9 | end 10 | end 11 | end 12 | end 13 | 14 | require 'rack/client/parser/base' 15 | require 'rack/client/parser/body_collection' 16 | require 'rack/client/parser/context' 17 | require 'rack/client/parser/request' 18 | require 'rack/client/parser/response' 19 | -------------------------------------------------------------------------------- /lib/rack/client/parser/base.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | module Parser 4 | class Base 5 | CONTENT_TYPE = %r'^([^/]+)/([^;]+)\s?(?:;\s?(.*))?$' 6 | 7 | @@type_table ||= Hash.new {|h,k| h[k] = Hash.new {|hh,kk| hh[kk] = {} } } 8 | 9 | def self.content_type(type, subtype, *parameters) 10 | type_table[type][subtype][parameters] = self 11 | end 12 | 13 | def self.type_table 14 | @@type_table 15 | end 16 | 17 | def self.lookup(content_type) 18 | type, subtype, *parameters = content_type.scan(CONTENT_TYPE).first 19 | 20 | type_table[type][subtype][parameters.compact] 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/rack/client/parser/body_collection.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | class BodyCollection 4 | instance_methods.each { |m| undef_method m unless (m =~ /^__/ || m =~ /^object_id$/ ) } 5 | 6 | def initialize(&load_with_proc) 7 | raise ArgumentException, 'BodyCollection must be initialized with a block' unless block_given? 8 | 9 | @loaded = false 10 | @finished = false 11 | @load_with_proc = load_with_proc 12 | @callbacks = [] 13 | @array = [] 14 | end 15 | 16 | def each(&block) 17 | @callbacks << block 18 | @array.each {|a| yield(*a) } 19 | 20 | lazy_load unless finished? 21 | ensure 22 | @callbacks.delete(block) 23 | end 24 | 25 | def lazy_load 26 | until finished? 27 | @load_with_proc[self] 28 | end 29 | end 30 | 31 | def <<(value) 32 | @array << value 33 | @callbacks.each {|cb| cb[value] } 34 | end 35 | 36 | def finished? 37 | @finished 38 | end 39 | 40 | def finish 41 | @finished = true 42 | end 43 | 44 | def method_missing(sym, *a, &b) 45 | lazy_load 46 | @array.send(sym, *a, &b) 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/rack/client/parser/context.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | module Parser 4 | class Context 5 | def initialize(app) 6 | @app = app 7 | end 8 | 9 | def call(env) 10 | Response.new(*@app.call(Request.new(env).env)).finish 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/rack/client/parser/json.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | 3 | module Rack 4 | module Client 5 | module Parser 6 | class JSON < Parser::Base 7 | 8 | content_type 'application', 'json' 9 | 10 | def encode(input) 11 | output = StringIO.new 12 | 13 | input.each do |object| 14 | ::JSON.dump(object, output) 15 | end 16 | 17 | output 18 | end 19 | 20 | def decode(body) 21 | BodyCollection.new do |collection| 22 | begin 23 | data = if body.respond_to? :read 24 | body.read 25 | elsif body.respond_to? :to_path 26 | File.read(body.to_path) 27 | else 28 | io = StringIO.new 29 | 30 | body.each do |part| 31 | io << part 32 | end 33 | 34 | io.rewind 35 | io.read 36 | end 37 | 38 | case result = ::JSON.parse(data) 39 | when Array then result.each {|object| collection << object } 40 | else collection << result 41 | end 42 | 43 | collection.finish 44 | ensure 45 | body.close if body.respond_to? :close 46 | end 47 | end 48 | end 49 | end 50 | 51 | Json = JSON 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/rack/client/parser/middleware.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | module Parser 4 | class Middleware 5 | end 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/rack/client/parser/request.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | module Parser 4 | class Request < Rack::Request 5 | def initialize(*) 6 | super 7 | 8 | if @env['rack-client.body_collection'] && content_type 9 | parse_input(@env['rack-client.body_collection']) 10 | end 11 | end 12 | 13 | def parse_input(collection) 14 | if parser = Base.lookup(content_type) 15 | @env['rack.input'] = parser.new.encode(collection) 16 | end 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/rack/client/parser/response.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | module Parser 4 | class Response < Rack::Client::Response 5 | def finish(*) 6 | super 7 | ensure 8 | parse_body_as(headers['Content-Type']) if headers['Content-Type'] 9 | end 10 | 11 | def parse_body_as(content_type) 12 | if parser = Base.lookup(content_type) 13 | headers['rack-client.body_collection'] = parser.new.decode(body) 14 | end 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/rack/client/parser/yaml.rb: -------------------------------------------------------------------------------- 1 | require 'yaml' 2 | 3 | module Rack 4 | module Client 5 | module Parser 6 | class YAML < Parser::Base 7 | 8 | content_type 'application', 'x-yaml' 9 | 10 | def encode(input) 11 | output = StringIO.new 12 | 13 | input.each do |object| 14 | ::YAML.dump(object, output) 15 | end 16 | 17 | output 18 | end 19 | 20 | def decode(body) 21 | BodyCollection.new do |collection| 22 | begin 23 | io = if body.respond_to? :to_path 24 | File.open(body.to_path, 'r') 25 | else 26 | io = StringIO.new 27 | 28 | body.each do |part| 29 | io << part 30 | end 31 | 32 | io.rewind 33 | io 34 | end 35 | 36 | ::YAML.load_documents(io) do |object| 37 | collection << object 38 | end 39 | 40 | collection.finish 41 | ensure 42 | io.close if io.respond_to? :close 43 | body.close if body.respond_to? :close 44 | end 45 | end 46 | end 47 | end 48 | 49 | Yaml = YAML 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/rack/client/version.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Client 3 | VERSION = "0.4.3.pre" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /rack-client.gemspec: -------------------------------------------------------------------------------- 1 | dir = File.dirname(__FILE__) 2 | require File.expand_path(File.join(dir, 'lib', 'rack', 'client', 'version')) 3 | 4 | Gem::Specification.new do |s| 5 | s.name = 'rack-client' 6 | s.version = Rack::Client::VERSION 7 | s.platform = Gem::Platform::RUBY 8 | s.author = "Tim Carey-Smith" 9 | s.email = "tim" + "@" + "spork.in" 10 | s.homepage = "http://github.com/halorgium/rack-client" 11 | s.summary = "A client wrapper around a Rack app or HTTP" 12 | s.description = s.summary 13 | s.files = %w[History.txt LICENSE README.textile Rakefile] + Dir["lib/**/*.rb"] + Dir["demo/**/*.rb"] 14 | 15 | s.add_dependency 'rack', '>=1.0.0' 16 | 17 | s.add_development_dependency 'excon' 18 | s.add_development_dependency 'em-http-request' 19 | s.add_development_dependency 'typhoeus' 20 | s.add_development_dependency 'json' 21 | s.add_development_dependency 'faraday', '>= 0.9.0.rc1' 22 | end 23 | -------------------------------------------------------------------------------- /spec/adapter/faraday_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'rack/client/adapter/faraday' 3 | 4 | describe Faraday::Adapter::RackClient do 5 | 6 | let(:url) { @base_url } 7 | 8 | let(:conn) do 9 | Faraday.new(:url => url) do |faraday| 10 | faraday.request :multipart 11 | faraday.request :url_encoded 12 | 13 | faraday.adapter(:rack_client) do |builder| 14 | builder.use Rack::Lint 15 | builder.run LiveServer 16 | end 17 | end 18 | end 19 | 20 | describe 'GET' do 21 | 22 | it 'retrieves the response body' do 23 | conn.get('echo').body.should == 'get' 24 | end 25 | 26 | it 'send url encoded params' do 27 | conn.get('echo', :name => 'zack').body.should == %(get ?{"name"=>"zack"}) 28 | end 29 | 30 | it 'retrieves the response headers' do 31 | response = conn.get('echo') 32 | 33 | response.headers['Content-Type'].should =~ %r{text/plain} 34 | response.headers['content-type'].should =~ %r{text/plain} 35 | end 36 | 37 | it 'handles headers with multiple values' do 38 | conn.get('multi').headers['set-cookie'].should == 'one, two' 39 | end 40 | 41 | it 'with body' do 42 | response = conn.get('echo') do |req| 43 | req.body = {'bodyrock' => true} 44 | end 45 | 46 | response.body.should == %(get {"bodyrock"=>"true"}) 47 | end 48 | 49 | it 'sends user agent' do 50 | response = conn.get('echo_header', {:name => 'user-agent'}, :user_agent => 'Agent Faraday') 51 | response.body.should == 'Agent Faraday' 52 | end 53 | 54 | end 55 | 56 | describe 'POST' do 57 | 58 | it 'send url encoded params' do 59 | conn.post('echo', :name => 'zack').body.should == %(post {"name"=>"zack"}) 60 | end 61 | 62 | it 'send url encoded nested params' do 63 | response = conn.post('echo', 'name' => {'first' => 'zack'}) 64 | response.body.should == %(post {"name"=>{"first"=>"zack"}}) 65 | end 66 | 67 | it 'retrieves the response headers' do 68 | conn.post('echo').headers['content-type'].should =~ %r{text/plain} 69 | end 70 | 71 | it 'sends files' do 72 | response = conn.post('file') do |req| 73 | req.body = {'uploaded_file' => Faraday::UploadIO.new(__FILE__, 'text/x-ruby')} 74 | end 75 | 76 | response.body.should == 'file faraday_spec.rb text/x-ruby' 77 | end 78 | 79 | end 80 | 81 | describe 'PUT' do 82 | 83 | it 'send url encoded params' do 84 | conn.put('echo', :name => 'zack').body.should == %(put {"name"=>"zack"}) 85 | end 86 | 87 | it 'send url encoded nested params' do 88 | response = conn.put('echo', 'name' => {'first' => 'zack'}) 89 | response.body.should == %(put {"name"=>{"first"=>"zack"}}) 90 | end 91 | 92 | it 'retrieves the response headers' do 93 | conn.put('echo').headers['content-type'].should =~ %r{text/plain} 94 | end 95 | 96 | end 97 | 98 | describe 'PATCH' do 99 | 100 | it 'send url encoded params' do 101 | conn.patch('echo', :name => 'zack').body.should == %(patch {"name"=>"zack"}) 102 | end 103 | 104 | end 105 | 106 | describe 'OPTIONS' do 107 | 108 | specify { conn.run_request(:options, 'echo', nil, {}).body.should == 'options' } 109 | 110 | end 111 | 112 | describe 'HEAD' do 113 | 114 | it 'retrieves no response body' do 115 | conn.head('echo').body.should == '' 116 | end 117 | 118 | it 'retrieves the response headers' do 119 | conn.head('echo').headers['content-type'].should =~ %r{text/plain} 120 | end 121 | 122 | end 123 | 124 | describe 'DELETE' do 125 | 126 | it 'retrieves the response headers' do 127 | conn.delete('echo').headers['content-type'].should =~ %r{text/plain} 128 | end 129 | 130 | it 'retrieves the body' do 131 | conn.delete('echo').body.should == %(delete) 132 | end 133 | 134 | end 135 | end 136 | -------------------------------------------------------------------------------- /spec/adapter/simple_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Rack::Client::Simple do 4 | 5 | describe "request headers" do 6 | let(:app) { lambda {|env| [200, {}, [env['HTTP_X_FOO']]] } } 7 | 8 | it 'will be rackified (e.g. HTTP_*)' do 9 | client = Rack::Client::Simple.new(app) 10 | client.get('/', 'X-Foo' => 'bar').body.should == 'bar' 11 | end 12 | end 13 | 14 | describe "HTTP_USER_AGENT" do 15 | let(:app) { lambda {|env| [200, {}, [env['HTTP_USER_AGENT']]] } } 16 | 17 | it 'adds the user agent header to requests' do 18 | client = Rack::Client::Simple.new(app) 19 | client.get('/hitme').body.should == "rack-client #{Rack::Client::VERSION} (app: Proc)" 20 | end 21 | 22 | it 'can be overridden' do 23 | client = Rack::Client::Simple.new(app) 24 | client.get('/foo', 'User-Agent' => 'IE6').body.should == 'IE6' 25 | end 26 | end 27 | 28 | describe "HTTP_HOST" do 29 | let(:app) { lambda {|env| [200, {}, [env['HTTP_HOST']]]} } 30 | 31 | it 'adds the host as the hostname of REQUEST_URI' do 32 | client = Rack::Client::Simple.new(app, 'http://example.org/') 33 | client.get('/foo').body.should == 'example.org' 34 | end 35 | 36 | it 'adds the host and port for explicit ports in the REQUEST_URI' do 37 | client = Rack::Client::Simple.new(app, 'http://example.org:81/') 38 | client.get('/foo').body.should == 'example.org:81' 39 | end 40 | 41 | it 'can be overridden' do 42 | client = Rack::Client::Simple.new(app, 'http://example.org/') 43 | client.get('/foo', 'Host' => '127.0.0.1').body.should == '127.0.0.1' 44 | end 45 | end 46 | 47 | describe "REQUEST_URI" do 48 | let(:app) { lambda {|env| [200, {}, [env['REQUEST_URI']]]} } 49 | 50 | it 'uses the base uri if the url is relative' do 51 | client = Rack::Client::Simple.new(app, 'http://example.org/') 52 | client.get('/foo').body.should == 'http://example.org/foo' 53 | end 54 | 55 | it 'does not use the base uri if the url is absolute' do 56 | client = Rack::Client::Simple.new(app, 'http://example.org/') 57 | client.get('http://example.com/bar').body.should == 'http://example.com/bar' 58 | end 59 | 60 | it 'should accept a URI as the url' do 61 | client = Rack::Client::Simple.new(app, 'http://example.org/') 62 | client.get(URI('http://example.com/bar')).body.should == 'http://example.com/bar' 63 | end 64 | end 65 | 66 | describe '.use' do 67 | it 'injects a middleware' do 68 | middleware = Struct.new(:app) do 69 | def call(env) 70 | [200, {}, ['Hello Middleware!']] 71 | end 72 | end 73 | 74 | klass = Class.new(Rack::Client::Simple) do 75 | use middleware 76 | end 77 | 78 | client = klass.new(lambda {|_| [500, {}, ['FAIL']] }) 79 | client.get('/').body.should == 'Hello Middleware!' 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /spec/core/headers_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Rack::Client::Headers do 4 | context ".from" do 5 | it 'rejects headers which do not do not start with HTTP_, excluding CONTENT_TYPE and CONTENT_LENGTH' do 6 | headers = Rack::Client::Headers.from('rack.foo' => 'bar', 7 | 'HTTP_X_RACK_FOO' => 'bar', 8 | 'CONTENT_TYPE' => 'text/plain', 9 | 'CONTENT_LENGTH' => 100) 10 | headers['rack.foo'].should be_nil 11 | headers['HTTP_X_RACK_FOO'].should == 'bar' 12 | headers['CONTENT_TYPE'].should == 'text/plain' 13 | headers['CONTENT_LENGTH'].should == 100 14 | end 15 | end 16 | 17 | context "#initialize" do 18 | it 'merges in any arguments' do 19 | Rack::Client::Headers.new('HTTP_X_RACK_FOO' => 'bar')['HTTP_X_RACK_FOO'] = 'bar' 20 | end 21 | end 22 | 23 | context "#to_http" do 24 | it 'removes the leading "HTTP_" from headers' do 25 | headers = Rack::Client::Headers.new('HTTP_X_RACK_FOO' => 'bar').to_http 26 | headers.keys.detect {|header| header =~ /^HTTP_/ }.should be_nil 27 | end 28 | 29 | it 'Titleizes the header name' do 30 | headers = Rack::Client::Headers.new('HTTP_X_RACK_FOO' => 'bar').to_http 31 | headers['X-Rack-Foo'].should == 'bar' 32 | end 33 | end 34 | 35 | context "#to_env" do 36 | it 'rackifies http header names, except CONTENT_TYPE and CONTENT_LENGTH' do 37 | headers = Rack::Client::Headers.new('rack.foo' => 'bar', 38 | 'X-Rack-Foo' => 'bar', 39 | 'CONTENT_TYPE' => 'text/plain', 40 | 'CONTENT_LENGTH' => 100).to_env 41 | 42 | headers['HTTP_X_RACK_FOO'].should == 'bar' 43 | headers['CONTENT_TYPE'].should == 'text/plain' 44 | headers['CONTENT_LENGTH'].should == 100 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/core/response_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Rack::Client::Response do 4 | context "#initialize" do 5 | it 'follows the rack tuple convention for parameters' do 6 | response = Rack::Client::Response.new(200, {'X-Foo' => 'Bar'}, ['Hello World!']) 7 | 8 | response.status.should == 200 9 | response.headers['X-Foo'].should == 'Bar' 10 | response.body.should == ['Hello World!'] 11 | end 12 | 13 | it 'accepts a callback for streamed responses' do 14 | body = %w[ This is a streamed response ] 15 | check = body.dup 16 | 17 | response = Rack::Client::Response.new(200) {|block| body.each(&block) } 18 | 19 | response.each do |part| 20 | part.should == check.shift 21 | end 22 | 23 | check.should be_empty 24 | end 25 | end 26 | 27 | context "#each" do 28 | it 'will not loose the streamed body chunks' do 29 | body = %w[ This is also a streamed response ] 30 | check = body.dup 31 | 32 | response = Rack::Client::Response.new(200) {|block| body.each(&block) } 33 | 34 | response.each {|chunk| } 35 | 36 | response.instance_variable_get(:@body).should == check 37 | end 38 | 39 | it 'will yield the existing body before the streamed body' do 40 | existing, streamed = %w[ This is mostly ], %w[ a streamed response ] 41 | check = existing + streamed 42 | 43 | response = Rack::Client::Response.new(200, {}, existing) {|block| streamed.each(&block) } 44 | 45 | response.each do |part| 46 | part.should == check.shift 47 | end 48 | 49 | check.should be_empty 50 | end 51 | 52 | it 'is idempotent' do 53 | body = %w[ This should only appear once ] 54 | check = body.dup 55 | 56 | response = Rack::Client::Response.new(200) {|block| body.each(&block) } 57 | 58 | response.each {|chunk| } 59 | 60 | response.instance_variable_get(:@body).should == check 61 | 62 | response.each {|chunk| } 63 | 64 | response.instance_variable_get(:@body).should == check 65 | end 66 | end 67 | 68 | context '#body' do 69 | it 'will include the body stream' do 70 | body = %w[ This is sorta streamed ] 71 | check = body.dup 72 | 73 | response = Rack::Client::Response.new(200) {|block| body.each(&block) } 74 | 75 | response.body.should == check 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /spec/handler/em_http_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Rack::Client::Handler::EmHttp do 4 | unless mri_187? 5 | async_handler_context(Rack::Client::Handler::EmHttp) do 6 | it_should_behave_like "Handler API" 7 | end 8 | 9 | if defined?(EM::Synchrony) 10 | sync_handler_context(Rack::Client::Handler::EmHttp) do 11 | it_should_behave_like "Handler API" 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/handler/excon_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Rack::Client::Handler::Excon do 4 | sync_handler_context(Rack::Client::Handler::Excon) do 5 | it_should_behave_like "Handler API" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/handler/net_http_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Rack::Client::Handler::NetHTTP do 4 | async_handler_context(Rack::Client::Handler::NetHTTP) do 5 | it_should_behave_like "Handler API" 6 | end 7 | 8 | sync_handler_context(Rack::Client::Handler::NetHTTP) do 9 | it_should_behave_like "Handler API" 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/handler/typhoeus_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Rack::Client::Handler::Typhoeus do 4 | async_handler_context(Rack::Client::Handler::Typhoeus) do 5 | it_should_behave_like "Handler API" 6 | end 7 | 8 | sync_handler_context(Rack::Client::Handler::Typhoeus) do 9 | it_should_behave_like "Handler API" 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/helpers/async_helper.rb: -------------------------------------------------------------------------------- 1 | module AsyncHelper 2 | class AsyncProxy < Struct.new(:subject, :callback) 3 | def method_missing(*a) 4 | subject.send(*a, &callback) 5 | end 6 | end 7 | 8 | def request(&b) 9 | @_request_block = b 10 | end 11 | 12 | def response(&b) 13 | @_response_block = b 14 | run 15 | end 16 | 17 | def run 18 | proxy = AsyncProxy.new(subject, method(:callback)) 19 | proxy.instance_eval(&@_request_block) 20 | end 21 | 22 | def callback(response) 23 | response.instance_eval(&@_response_block) 24 | ensure 25 | finish 26 | end 27 | 28 | def finish 29 | #noop 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/helpers/em_http_helper.rb: -------------------------------------------------------------------------------- 1 | module EmHttpHelper 2 | 3 | module Async 4 | def build_subject(*middlewares) 5 | Rack::Client.new(@base_url) do |builder| 6 | middlewares.each do |middleware| 7 | builder.use *Array(middleware) 8 | end 9 | 10 | builder.run Rack::Client::Handler::EmHttp.new 11 | end 12 | end 13 | 14 | def finish 15 | EM.stop 16 | end 17 | 18 | def run_around(group) 19 | EM.run do 20 | group.call 21 | end 22 | end 23 | end 24 | 25 | module Sync 26 | def build_subject(*middlewares) 27 | Rack::Client.new(@base_url) do |builder| 28 | middlewares.each do |middleware| 29 | builder.use *Array(middleware) 30 | end 31 | 32 | builder.run Rack::Client::Handler::EmHttp.new 33 | end 34 | end 35 | 36 | def run_around(group) 37 | EM.synchrony do 38 | group.call 39 | EM.stop 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/helpers/excon_helper.rb: -------------------------------------------------------------------------------- 1 | module ExconHelper 2 | module Sync 3 | def build_subject(*middlewares) 4 | Rack::Client.new(@base_url) do |builder| 5 | middlewares.each do |middleware| 6 | builder.use *Array(middleware) 7 | end 8 | 9 | builder.run Rack::Client::Handler::Excon.new 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/helpers/handler_helper.rb: -------------------------------------------------------------------------------- 1 | module HandlerHelper 2 | module Ext 3 | 4 | def async_handler_map 5 | handler = { 6 | Rack::Client::Handler::NetHTTP => NetHTTPHelper::Async, 7 | Rack::Client::Handler::Typhoeus => TyphoeusHelper::Async, 8 | } 9 | 10 | unless mri_187? 11 | handler[Rack::Client::Handler::EmHttp] = EmHttpHelper::Async 12 | end 13 | 14 | handler 15 | end 16 | 17 | def sync_handler_map 18 | handler = { 19 | Rack::Client::Handler::NetHTTP => NetHTTPHelper::Sync, 20 | Rack::Client::Handler::Typhoeus => TyphoeusHelper::Sync, 21 | Rack::Client::Handler::Excon => ExconHelper::Sync, 22 | } 23 | 24 | if defined?(EM::Synchrony) 25 | handler[Rack::Client::Handler::EmHttp] = EmHttpHelper::Sync 26 | end 27 | 28 | handler 29 | end 30 | 31 | def async_handler_context(handler, *middlewares, &block) 32 | context "#{handler.name} Asynchronous" do 33 | include AsyncHelper 34 | include async_handler_map[handler] 35 | 36 | let(:handler) { handler } 37 | 38 | subject { build_subject(*middlewares) } 39 | 40 | around do |group| 41 | run_around(group) if respond_to?(:run_around) 42 | end 43 | 44 | instance_eval(&block) 45 | end 46 | end 47 | 48 | def sync_handler_context(handler, *middlewares, &block) 49 | context "#{handler.name} Synchronous" do 50 | include SyncHelper 51 | include sync_handler_map[handler] 52 | 53 | let(:handler) { handler } 54 | 55 | subject { build_subject(*middlewares) } 56 | 57 | around do |group| 58 | if respond_to?(:run_around) 59 | run_around(group) 60 | else 61 | group.call 62 | end 63 | end 64 | 65 | instance_eval(&block) 66 | end 67 | end 68 | 69 | def handler_contexts(*middlewares, &block) 70 | async_handler_contexts(*middlewares, &block) 71 | sync_handler_contexts(*middlewares, &block) 72 | end 73 | 74 | def async_handler_contexts(*middlewares, &block) 75 | async_handler_map.keys.each do |handler| 76 | async_handler_context(handler, *middlewares, &block) 77 | end 78 | end 79 | 80 | def sync_handler_contexts(*middlewares, &block) 81 | sync_handler_map.keys.each do |handler| 82 | sync_handler_context(handler, *middlewares, &block) 83 | end 84 | end 85 | end 86 | 87 | def self.included(context) 88 | context.extend Ext 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /spec/helpers/net_http_helper.rb: -------------------------------------------------------------------------------- 1 | module NetHTTPHelper 2 | 3 | module Async 4 | def build_subject(*middlewares) 5 | Rack::Client.new(@base_url) do |builder| 6 | middlewares.each do |middleware| 7 | builder.use *Array(middleware) 8 | end 9 | 10 | builder.run Rack::Client::Handler::NetHTTP.new 11 | end 12 | end 13 | end 14 | 15 | module Sync 16 | def build_subject(*middlewares) 17 | Rack::Client.new(@base_url) do |builder| 18 | middlewares.each do |middleware| 19 | builder.use *Array(middleware) 20 | end 21 | 22 | builder.run Rack::Client::Handler::NetHTTP.new 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/helpers/sync_helper.rb: -------------------------------------------------------------------------------- 1 | module SyncHelper 2 | def request(&b) 3 | @_response = subject.instance_eval(&b) 4 | end 5 | 6 | def response(&b) 7 | @_response.instance_eval(&b) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/helpers/typhoeus_helper.rb: -------------------------------------------------------------------------------- 1 | module TyphoeusHelper 2 | 3 | module Async 4 | def build_subject(*middlewares) 5 | @hydra = hydra = Typhoeus::Hydra.new 6 | 7 | Rack::Client.new(@base_url) do |builder| 8 | middlewares.each do |middleware| 9 | builder.use *Array(middleware) 10 | end 11 | 12 | builder.run Rack::Client::Handler::Typhoeus.new(hydra) 13 | end 14 | end 15 | 16 | def finish 17 | @hydra.run 18 | end 19 | end 20 | 21 | module Sync 22 | def build_subject(*middlewares) 23 | Rack::Client.new(@base_url) do |builder| 24 | middlewares.each do |middleware| 25 | builder.use *Array(middleware) 26 | end 27 | 28 | builder.run Rack::Client::Handler::Typhoeus.new 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/middleware/auth/basic_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Rack::Client::Auth::Basic do 4 | handler_contexts([Rack::Client::Auth::Basic, 'foo', 'bar']) do 5 | it 'can authenticate requests' do 6 | request { get('/basic_auth/') } 7 | response { status.should == 200 } 8 | end 9 | end 10 | 11 | handler_contexts([Rack::Client::Auth::Basic, 'foo', 'bar', true]) do 12 | it 'can force authentication for servers that do not challenge' do 13 | request { get('/basic_auth/unchallenged/') } 14 | response { status.should == 200 } 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/middleware/cache_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Rack::Client::Cache do 4 | sync_handler_contexts(Rack::Client::Cache) do 5 | after do 6 | Rack::Client::Cache::Storage.instance.clear 7 | end 8 | 9 | it 'can retrieve a cache hit' do 10 | original_body = nil 11 | 12 | request { get('/cache/able') } 13 | response do 14 | headers['X-Rack-Client-Cache'].should == 'store' 15 | original_body = body 16 | end 17 | 18 | request { get('/cache/able') } 19 | response do 20 | headers['X-Rack-Client-Cache'].should == 'fresh' 21 | body.should == original_body 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/middleware/cookie_jar_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Rack::Client::CookieJar do 4 | sync_handler_contexts(Rack::Client::CookieJar) do 5 | it 'includes the cookie in future responses' do 6 | request { get('/cookie/') } 7 | response do 8 | headers['Set-Cookie'].should_not == nil 9 | headers['rack-client-cookiejar.cookies'].should_not == nil 10 | end 11 | 12 | request { get('/cookie/') } 13 | response do 14 | headers['Set-Cookie'].should == nil 15 | headers['rack-client-cookiejar.cookies'].should_not == nil 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/middleware/etag_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Rack::ETag do 4 | sync_handler_contexts(Rack::ETag) do 5 | it 'will work with Rack::ETag right out of the box' do 6 | request { get('/hello_world') } 7 | response { headers['ETag'].should == %Q{"#{Digest::MD5.hexdigest(body)}"} } 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/middleware/follow_redirects_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Rack::Client::FollowRedirects do 4 | handler_contexts(Rack::Client::FollowRedirects) do 5 | 6 | it 'will follow a single redirect' do 7 | request { get('/redirect/') } 8 | response { body.should == 'Redirected' } 9 | end 10 | 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/middleware/lint_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Rack::Lint do 4 | sync_handler_contexts(Rack::Lint) do 5 | it 'will work with Rack::Lint right out of the box' do 6 | request { get('/hello_world') } 7 | response { body.should == 'Hello World!' } 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/shared/handler_api.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for "Handler API" do 2 | 3 | context 'GET request' do 4 | it 'has the correct status code' do 5 | request { get('/get/hello_world') } 6 | response { status.should == 200 } 7 | end 8 | 9 | it "handles multiple cookies according to Rack Specification" do 10 | request { get('/cookie') } 11 | response do 12 | raw_status, raw_headers, raw_body = Cookie.new.call({"rack.input" => StringIO.new, "PATH_INFO"=>"", "REQUEST_METHOD"=>"GET"}) 13 | headers["Set-Cookie"].should == raw_headers["Set-Cookie"] 14 | end 15 | end 16 | 17 | it 'has the correct headers' do 18 | request { get('/get/hello_world') } 19 | response do 20 | %w[Content-Type Date Content-Length Connection].each do |header| 21 | headers.keys.include?(header).should == true 22 | end 23 | end 24 | end 25 | 26 | it 'has the correct body' do 27 | request { get('/get/hello_world') } 28 | response { body.should == 'Hello World!' } 29 | end 30 | 31 | it 'has the correct query string' do 32 | request { get('/get/params?one=hello&two=goodbye') } 33 | response { body.should == 'hello goodbye' } 34 | end 35 | end 36 | 37 | context 'POST request' do 38 | it 'can accept a string post body' do 39 | request { post('/post/echo', {}, 'Hello, World!') } 40 | response { body.should == 'Hello, World!' } 41 | end 42 | 43 | it 'can accept an IO post body' do 44 | o,i = IO.pipe 45 | i << 'Hello, World!' 46 | i.close 47 | 48 | request { post('/post/echo', {}, o) } 49 | response { body.should == 'Hello, World!' } 50 | end 51 | 52 | it 'can accept a body object that responds to each and yields strings' do 53 | b = ['Hello, ', 'World!'] 54 | 55 | request { post('/post/echo', {}, b) } 56 | response { body.should == 'Hello, World!' } 57 | end 58 | end 59 | 60 | context 'PUT request' do 61 | it 'can accept a string post body' do 62 | headers = { 'Content-Type' => 'application/x-www-form-urlencoded' } 63 | 64 | request { put('/put/sum', headers, 'a=1&b=2') } 65 | response { body.should == '3' } 66 | end 67 | 68 | it 'can accept an IO post body' do 69 | headers = { 'Content-Type' => 'application/x-www-form-urlencoded' } 70 | 71 | o,i = IO.pipe 72 | i << 'a=3&b=4' 73 | i.close 74 | 75 | request { put('/put/sum', headers, o) } 76 | response { body.should == '7' } 77 | end 78 | 79 | it 'can accept a body object that responds to each and yields strings' do 80 | headers = { 'Content-Type' => 'application/x-www-form-urlencoded' } 81 | 82 | b = ['a=5', '&', 'b=6'] 83 | 84 | request { put('/put/sum', headers, b) } 85 | response { body.should == '11' } 86 | end 87 | end 88 | 89 | context 'DELETE request' do 90 | it 'can handle a No Content response' do 91 | request { delete('/delete/no-content') } 92 | response { body.should == '' } 93 | end 94 | end 95 | 96 | context 'HEAD request' do 97 | it 'can handle ETag headers' do 98 | request { head('/head/etag') } 99 | response { headers['ETag'].should == 'DEADBEEF' } 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /spec/shared/streamed_response_api.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for "Streamed Response API" do 2 | 3 | subject do 4 | Rack::Client.new(@base_url, &method(:rackup)) 5 | end 6 | 7 | it 'can properly chunk a streamed response' do 8 | response_body = %w[ this is a stream ] 9 | request { get('/stream') } 10 | response do 11 | each {|part| part.should == response_body.shift } 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/spec_apps/basic_auth.ru: -------------------------------------------------------------------------------- 1 | require 'sinatra/base' 2 | 3 | class BasicAuth < Sinatra::Base 4 | 5 | use Rack::Auth::Basic do |username, password| 6 | [username, password] == ['foo', 'bar'] 7 | end 8 | 9 | get '/' do 10 | status 200 11 | end 12 | end 13 | 14 | class UnchallengedBasicAuth < Sinatra::Base 15 | get '/' do 16 | case env['HTTP_AUTHORIZATION'] 17 | when 'Basic ' + %w[foo:bar].pack("m*").chomp then status 200 18 | else status 404 19 | end 20 | end 21 | end 22 | 23 | map '/unchallenged' do 24 | run UnchallengedBasicAuth 25 | end 26 | 27 | map '/' do 28 | run BasicAuth.new 29 | end 30 | -------------------------------------------------------------------------------- /spec/spec_apps/cache.ru: -------------------------------------------------------------------------------- 1 | require 'sinatra/base' 2 | 3 | class Cache < Sinatra::Base 4 | get '/able' do 5 | if env['HTTP_IF_NONE_MATCH'] == '123456789abcde' 6 | status 304 7 | else 8 | response['ETag'] = '123456789abcde' 9 | Time.now.to_f.to_s 10 | end 11 | end 12 | end 13 | 14 | run Cache 15 | -------------------------------------------------------------------------------- /spec/spec_apps/cookie.ru: -------------------------------------------------------------------------------- 1 | require 'sinatra/base' 2 | 3 | class Cookie < Sinatra::Base 4 | get '/' do 5 | if request.cookies.empty? 6 | response.set_cookie('time', :domain => 'localhost', :path => '/', :value => 1359507195.0) 7 | response.set_cookie('time2', :domain => 'localhost', :path => '/cookie/', :value => 1359507195.0) 8 | end 9 | end 10 | end 11 | 12 | run Cookie 13 | -------------------------------------------------------------------------------- /spec/spec_apps/delete.ru: -------------------------------------------------------------------------------- 1 | require 'sinatra/base' 2 | 3 | class Delete < Sinatra::Base 4 | delete '/no-content' do 5 | status 204 6 | '' 7 | end 8 | end 9 | 10 | run Delete 11 | -------------------------------------------------------------------------------- /spec/spec_apps/faraday.ru: -------------------------------------------------------------------------------- 1 | require 'sinatra/base' 2 | 3 | class LiveServer < Sinatra::Base 4 | set :environment, :test 5 | disable :logging 6 | disable :protection 7 | 8 | [:get, :post, :put, :patch, :delete, :options].each do |method| 9 | send(method, '/echo') do 10 | kind = request.request_method.downcase 11 | out = kind.dup 12 | out << ' ?' << request.GET.inspect if request.GET.any? 13 | out << ' ' << request.POST.inspect if request.POST.any? 14 | 15 | content_type 'text/plain' 16 | return out 17 | end 18 | end 19 | 20 | get '/echo_header' do 21 | header = "HTTP_#{params[:name].tr('-', '_').upcase}" 22 | request.env.fetch(header) { 'NONE' } 23 | end 24 | 25 | post '/file' do 26 | if params[:uploaded_file].respond_to? :each_key 27 | "file %s %s" % [ 28 | params[:uploaded_file][:filename], 29 | params[:uploaded_file][:type]] 30 | else 31 | status 400 32 | end 33 | end 34 | 35 | get '/multi' do 36 | [200, { 'Set-Cookie' => 'one, two' }, ''] 37 | end 38 | 39 | get '/who-am-i' do 40 | request.env['REMOTE_ADDR'] 41 | end 42 | 43 | get '/slow' do 44 | sleep 10 45 | [200, {}, 'ok'] 46 | end 47 | 48 | get '/204' do 49 | status 204 # no content 50 | end 51 | 52 | get '/ssl' do 53 | request.secure?.to_s 54 | end 55 | 56 | error do |e| 57 | "#{e.class}\n#{e.to_s}\n#{e.backtrace.join("\n")}" 58 | end 59 | end 60 | 61 | run LiveServer 62 | -------------------------------------------------------------------------------- /spec/spec_apps/get.ru: -------------------------------------------------------------------------------- 1 | require 'sinatra/base' 2 | 3 | class Get < Sinatra::Base 4 | get '/hello_world' do 5 | 'Hello World!' 6 | end 7 | 8 | get '/stream' do 9 | %w[ this is a stream ] 10 | end 11 | 12 | get '/params' do 13 | [params[:one], params[:two]].join(' ') 14 | end 15 | end 16 | 17 | run Get 18 | -------------------------------------------------------------------------------- /spec/spec_apps/head.ru: -------------------------------------------------------------------------------- 1 | require 'sinatra/base' 2 | 3 | class Head < Sinatra::Base 4 | head '/etag' do 5 | response['ETag'] = 'DEADBEEF' 6 | '' 7 | end 8 | end 9 | 10 | run Head 11 | -------------------------------------------------------------------------------- /spec/spec_apps/hello_world.ru: -------------------------------------------------------------------------------- 1 | run lambda {|_| [200, {}, ['Hello World!']] } 2 | -------------------------------------------------------------------------------- /spec/spec_apps/post.ru: -------------------------------------------------------------------------------- 1 | require 'sinatra/base' 2 | 3 | class Post < Sinatra::Base 4 | post '/echo' do 5 | response = [] 6 | env['rack.input'].each do |chunk| 7 | response << chunk 8 | end 9 | 10 | status 200 11 | response 12 | end 13 | end 14 | 15 | run Post 16 | -------------------------------------------------------------------------------- /spec/spec_apps/put.ru: -------------------------------------------------------------------------------- 1 | require 'sinatra/base' 2 | 3 | class Put < Sinatra::Base 4 | put '/sum' do 5 | a, b = params[:a], params[:b] 6 | a = a.nil? ? 0 : a.to_i 7 | b = b.nil? ? 0 : b.to_i 8 | 9 | (a + b).to_s 10 | end 11 | end 12 | 13 | run Put 14 | -------------------------------------------------------------------------------- /spec/spec_apps/redirect.ru: -------------------------------------------------------------------------------- 1 | require 'sinatra/base' 2 | 3 | class Redirect < Sinatra::Base 4 | get '/' do 5 | redirect request.script_name + "/redirected" 6 | end 7 | 8 | get '/redirected' do 9 | 'Redirected' 10 | end 11 | end 12 | 13 | run Redirect 14 | -------------------------------------------------------------------------------- /spec/spec_apps/stream.ru: -------------------------------------------------------------------------------- 1 | class SlowArray < Array 2 | def each 3 | super {|*a| sleep 0.1 ; yield(*a) } 4 | end 5 | end 6 | 7 | use Rack::Chunked # net/http is a POS 8 | 9 | run lambda { [200, {}, SlowArray[*%w[ this is a stream ]]] } 10 | -------------------------------------------------------------------------------- /spec/spec_config.ru: -------------------------------------------------------------------------------- 1 | dir = File.expand_path(File.join(File.dirname(__FILE__), 'spec_apps')) 2 | 3 | Dir["#{dir}/*.ru"].each do |configru| 4 | route = File.basename(configru).gsub(/\.ru$/, '') 5 | 6 | map "/#{route}" do 7 | app, params = Rack::Builder.parse_file(configru) 8 | run app 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $:.unshift File.expand_path(File.dirname(__FILE__) + '/../lib') 2 | require 'rack/client' 3 | 4 | Bundler.require(:test) 5 | 6 | dir = File.expand_path(File.dirname(__FILE__)) 7 | 8 | Dir["#{dir}/shared/*.rb"].each {|shared| require shared } 9 | Dir["#{dir}/helpers/*.rb"].each {|helper| require helper } 10 | 11 | def mri_187? 12 | RUBY_VERSION == '1.8.7' && !defined?(RUBY_ENGINE) 13 | end 14 | 15 | RSpec.configure do |config| 16 | config.color_enabled = true 17 | #config.filter_run :focused => true 18 | #config.run_all_when_everything_filtered = true 19 | config.include(HandlerHelper) 20 | 21 | config.before(:all) do 22 | configru = dir + '/spec_config.ru' 23 | $server ||= RealWeb.start_server_in_thread(configru) 24 | @base_url = "http://localhost:#{$server.port}" 25 | end 26 | end 27 | --------------------------------------------------------------------------------