├── .rspec ├── .gitignore ├── spec ├── master_shake.jpg ├── base.rb ├── raw_response_spec.rb ├── integration │ ├── certs │ │ ├── verisign.crt │ │ └── equifax.crt │ └── request_spec.rb ├── integration_spec.rb ├── request2_spec.rb ├── restclient_spec.rb ├── abstract_response_spec.rb ├── exceptions_spec.rb ├── resource_spec.rb ├── payload_spec.rb ├── response_spec.rb └── request_spec.rb ├── Gemfile ├── lib ├── rest-client.rb ├── rest_client.rb ├── restclient │ ├── response.rb │ ├── raw_response.rb │ ├── net_http_ext.rb │ ├── abstract_response.rb │ ├── resource.rb │ ├── payload.rb │ ├── exceptions.rb │ └── request.rb └── restclient.rb ├── .travis.yml ├── rest-client.gemspec ├── Rakefile ├── bin └── restclient ├── history.md └── README.rdoc /.rspec: -------------------------------------------------------------------------------- 1 | --colour --format progress --order random 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .rvmrc 3 | doc 4 | rdoc 5 | pkg 6 | *.gem 7 | scratchpad.rb 8 | -------------------------------------------------------------------------------- /spec/master_shake.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/b/rest-client/master/spec/master_shake.jpg -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | group :test do 6 | gem 'rake' 7 | end 8 | -------------------------------------------------------------------------------- /lib/rest-client.rb: -------------------------------------------------------------------------------- 1 | # More logical way to require 'rest-client' 2 | require File.dirname(__FILE__) + '/restclient' 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - "1.9.3" 4 | - "2.0.0" 5 | branches: 6 | except: 7 | - "readme-edits" 8 | -------------------------------------------------------------------------------- /lib/rest_client.rb: -------------------------------------------------------------------------------- 1 | # This file exists for backward compatbility with require 'rest_client' 2 | require File.dirname(__FILE__) + '/restclient' 3 | -------------------------------------------------------------------------------- /spec/base.rb: -------------------------------------------------------------------------------- 1 | def is_ruby_19? 2 | RUBY_VERSION > '1.9' 3 | end 4 | 5 | require 'rubygems' 6 | 7 | begin 8 | require "ruby-debug" 9 | rescue LoadError 10 | # NOP, ignore 11 | end 12 | 13 | require File.dirname(__FILE__) + '/../lib/restclient' 14 | -------------------------------------------------------------------------------- /spec/raw_response_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join( File.dirname(File.expand_path(__FILE__)), 'base') 2 | 3 | describe RestClient::RawResponse do 4 | before do 5 | @tf = mock("Tempfile", :read => "the answer is 42", :open => true) 6 | @net_http_res = mock('net http response') 7 | @response = RestClient::RawResponse.new(@tf, @net_http_res, {}) 8 | end 9 | 10 | it "behaves like string" do 11 | @response.to_s.should == 'the answer is 42' 12 | end 13 | 14 | it "exposes a Tempfile" do 15 | @response.file.should == @tf 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/restclient/response.rb: -------------------------------------------------------------------------------- 1 | module RestClient 2 | 3 | # A Response from RestClient, you can access the response body, the code or the headers. 4 | # 5 | module Response 6 | 7 | include AbstractResponse 8 | 9 | attr_accessor :args, :net_http_res 10 | 11 | attr_writer :body 12 | 13 | def body 14 | self 15 | end 16 | 17 | def Response.create body, net_http_res, args 18 | result = body || '' 19 | result.extend Response 20 | result.net_http_res = net_http_res 21 | result.args = args 22 | result 23 | end 24 | 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/integration/certs/verisign.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICPDCCAaUCEHC65B0Q2Sk0tjjKewPMur8wDQYJKoZIhvcNAQECBQAwXzELMAkG 3 | A1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMTcwNQYDVQQLEy5DbGFz 4 | cyAzIFB1YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTk2 5 | MDEyOTAwMDAwMFoXDTI4MDgwMTIzNTk1OVowXzELMAkGA1UEBhMCVVMxFzAVBgNV 6 | BAoTDlZlcmlTaWduLCBJbmMuMTcwNQYDVQQLEy5DbGFzcyAzIFB1YmxpYyBQcmlt 7 | YXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIGfMA0GCSqGSIb3DQEBAQUAA4GN 8 | ADCBiQKBgQDJXFme8huKARS0EN8EQNvjV69qRUCPhAwL0TPZ2RHP7gJYHyX3KqhE 9 | BarsAx94f56TuZoAqiN91qyFomNFx3InzPRMxnVx0jnvT0Lwdd8KkMaOIG+YD/is 10 | I19wKTakyYbnsZogy1Olhec9vn2a/iRFM9x2Fe0PonFkTGUugWhFpwIDAQABMA0G 11 | CSqGSIb3DQEBAgUAA4GBALtMEivPLCYATxQT3ab7/AoRhIzzKBxnki98tsX63/Do 12 | lbwdj2wsqFHMc9ikwFPwTtYmwHYBV4GSXiHx0bH/59AhWM1pF+NEHJwZRDmJXNyc 13 | AA9WjQKZ7aKQRUzkuxCkPfAyAw7xzvjoyVGM5mKf5p/AfbdynMk2OmufTqj/ZA1k 14 | -----END CERTIFICATE----- 15 | -------------------------------------------------------------------------------- /lib/restclient/raw_response.rb: -------------------------------------------------------------------------------- 1 | module RestClient 2 | # The response from RestClient on a raw request looks like a string, but is 3 | # actually one of these. 99% of the time you're making a rest call all you 4 | # care about is the body, but on the occassion you want to fetch the 5 | # headers you can: 6 | # 7 | # RestClient.get('http://example.com').headers[:content_type] 8 | # 9 | # In addition, if you do not use the response as a string, you can access 10 | # a Tempfile object at res.file, which contains the path to the raw 11 | # downloaded request body. 12 | class RawResponse 13 | 14 | include AbstractResponse 15 | 16 | attr_reader :file 17 | 18 | def initialize tempfile, net_http_res, args 19 | @net_http_res = net_http_res 20 | @args = args 21 | @file = tempfile 22 | end 23 | 24 | def to_s 25 | @file.open 26 | @file.read 27 | end 28 | 29 | def size 30 | File.size file 31 | end 32 | 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /rest-client.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | Gem::Specification.new do |s| 4 | s.name = 'rest-client' 5 | s.version = '1.7.0.alpha' 6 | s.authors = ['REST Client Team'] 7 | s.description = 'A simple HTTP and REST client for Ruby, inspired by the Sinatra microframework style of specifying actions: get, put, post, delete.' 8 | s.license = 'MIT' 9 | s.email = 'rest.client@librelist.com' 10 | s.executables = ['restclient'] 11 | s.extra_rdoc_files = ["README.rdoc", "history.md"] 12 | s.files = `git ls-files`.split("\n") 13 | s.test_files = `git ls-files -- spec/*`.split("\n") 14 | s.homepage = 'http://github.com/rest-client/rest-client' 15 | s.summary = 'Simple HTTP and REST client for Ruby, inspired by microframework syntax for specifying actions.' 16 | 17 | s.add_runtime_dependency(%q, [">= 1.16"]) 18 | s.add_development_dependency(%q, [">= 0.9.1"]) 19 | s.add_development_dependency(%q, [">= 2.0"]) 20 | s.add_dependency(%q, ["~> 0.7.7"]) 21 | end 22 | 23 | -------------------------------------------------------------------------------- /spec/integration/certs/equifax.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDIDCCAomgAwIBAgIENd70zzANBgkqhkiG9w0BAQUFADBOMQswCQYDVQQGEwJV 3 | UzEQMA4GA1UEChMHRXF1aWZheDEtMCsGA1UECxMkRXF1aWZheCBTZWN1cmUgQ2Vy 4 | dGlmaWNhdGUgQXV0aG9yaXR5MB4XDTk4MDgyMjE2NDE1MVoXDTE4MDgyMjE2NDE1 5 | MVowTjELMAkGA1UEBhMCVVMxEDAOBgNVBAoTB0VxdWlmYXgxLTArBgNVBAsTJEVx 6 | dWlmYXggU2VjdXJlIENlcnRpZmljYXRlIEF1dGhvcml0eTCBnzANBgkqhkiG9w0B 7 | AQEFAAOBjQAwgYkCgYEAwV2xWGcIYu6gmi0fCG2RFGiYCh7+2gRvE4RiIcPRfM6f 8 | BeC4AfBONOziipUEZKzxa1NfBbPLZ4C/QgKO/t0BCezhABRP/PvwDN1Dulsr4R+A 9 | cJkVV5MW8Q+XarfCaCMczE1ZMKxRHjuvK9buY0V7xdlfUNLjUA86iOe/FP3gx7kC 10 | AwEAAaOCAQkwggEFMHAGA1UdHwRpMGcwZaBjoGGkXzBdMQswCQYDVQQGEwJVUzEQ 11 | MA4GA1UEChMHRXF1aWZheDEtMCsGA1UECxMkRXF1aWZheCBTZWN1cmUgQ2VydGlm 12 | aWNhdGUgQXV0aG9yaXR5MQ0wCwYDVQQDEwRDUkwxMBoGA1UdEAQTMBGBDzIwMTgw 13 | ODIyMTY0MTUxWjALBgNVHQ8EBAMCAQYwHwYDVR0jBBgwFoAUSOZo+SvSspXXR9gj 14 | IBBPM5iQn9QwHQYDVR0OBBYEFEjmaPkr0rKV10fYIyAQTzOYkJ/UMAwGA1UdEwQF 15 | MAMBAf8wGgYJKoZIhvZ9B0EABA0wCxsFVjMuMGMDAgbAMA0GCSqGSIb3DQEBBQUA 16 | A4GBAFjOKer89961zgK5F7WF0bnj4JXMJTENAKaSbn+2kmOeUJXRmm/kEd5jhW6Y 17 | 7qj/WsjTVbJmcVfewCHrPSqnI0kBBIZCe/zuf6IWUrVnZ9NA2zsmWLIodz2uFHdh 18 | 1voqZiegDfqnc1zqcPGUIWVEX/r87yloqaKHee9570+sB3c4 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | begin 2 | # optionally load `rake build/install/release tasks' 3 | require 'bundler/gem_tasks' 4 | rescue LoadError 5 | end 6 | 7 | require "rspec/core/rake_task" 8 | 9 | desc "Run all specs" 10 | task :spec => ["spec:unit", "spec:integration"] 11 | 12 | desc "Run unit specs" 13 | RSpec::Core::RakeTask.new('spec:unit') do |t| 14 | t.pattern = 'spec/*_spec.rb' 15 | end 16 | 17 | desc "Run integration specs" 18 | RSpec::Core::RakeTask.new('spec:integration') do |t| 19 | t.pattern = 'spec/integration/*_spec.rb' 20 | end 21 | 22 | desc "Print specdocs" 23 | RSpec::Core::RakeTask.new(:doc) do |t| 24 | t.rspec_opts = ["--format", "specdoc", "--dry-run"] 25 | t.pattern = 'spec/*_spec.rb' 26 | end 27 | 28 | desc "Run all examples with RCov" 29 | RSpec::Core::RakeTask.new('rcov') do |t| 30 | t.pattern = 'spec/*_spec.rb' 31 | t.rcov = true 32 | t.rcov_opts = ['--exclude', 'examples'] 33 | end 34 | 35 | task :default => :spec 36 | 37 | ############################ 38 | 39 | require 'rdoc/task' 40 | 41 | Rake::RDocTask.new do |t| 42 | t.rdoc_dir = 'rdoc' 43 | t.title = "rest-client, fetch RESTful resources effortlessly" 44 | t.options << '--line-numbers' << '--inline-source' << '-A cattr_accessor=object' 45 | t.options << '--charset' << 'utf-8' 46 | t.rdoc_files.include('README.rdoc') 47 | t.rdoc_files.include('lib/*.rb') 48 | end 49 | 50 | -------------------------------------------------------------------------------- /spec/integration_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join( File.dirname(File.expand_path(__FILE__)), 'base') 2 | 3 | require 'webmock/rspec' 4 | include WebMock::API 5 | 6 | describe RestClient do 7 | 8 | it "a simple request" do 9 | body = 'abc' 10 | stub_request(:get, "www.example.com").to_return(:body => body, :status => 200) 11 | response = RestClient.get "www.example.com" 12 | response.code.should == 200 13 | response.body.should == body 14 | end 15 | 16 | it "a simple request with gzipped content" do 17 | stub_request(:get, "www.example.com").with(:headers => { 'Accept-Encoding' => 'gzip, deflate' }).to_return(:body => "\037\213\b\b\006'\252H\000\003t\000\313T\317UH\257\312,HM\341\002\000G\242(\r\v\000\000\000", :status => 200, :headers => { 'Content-Encoding' => 'gzip' } ) 18 | response = RestClient.get "www.example.com" 19 | response.code.should == 200 20 | response.body.should == "i'm gziped\n" 21 | end 22 | 23 | it "a 404" do 24 | body = "Ho hai ! I'm not here !" 25 | stub_request(:get, "www.example.com").to_return(:body => body, :status => 404) 26 | begin 27 | RestClient.get "www.example.com" 28 | raise 29 | rescue RestClient::ResourceNotFound => e 30 | e.http_code.should == 404 31 | e.response.code.should == 404 32 | e.response.body.should == body 33 | e.http_body.should == body 34 | end 35 | end 36 | 37 | 38 | end -------------------------------------------------------------------------------- /spec/integration/request_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join( File.dirname(File.expand_path(__FILE__)), '../base') 2 | 3 | describe RestClient::Request do 4 | describe "ssl verification" do 5 | it "is successful with the correct ca_file" do 6 | request = RestClient::Request.new( 7 | :method => :get, 8 | :url => 'https://www.mozilla.com', 9 | :verify_ssl => OpenSSL::SSL::VERIFY_PEER, 10 | :ssl_ca_file => File.join(File.dirname(__FILE__), "certs", "equifax.crt") 11 | ) 12 | expect { request.execute }.to_not raise_error 13 | end 14 | 15 | # I don' think this feature is useful anymore (under 1.9.3 at the very least). 16 | # 17 | # Exceptions in verify_callback are ignored; RestClient has to catch OpenSSL::SSL::SSLError 18 | # and either re-throw it as is, or throw SSLCertificateNotVerified 19 | # based on the contents of the message field of the original exception 20 | #. 21 | # The client has to handle OpenSSL::SSL::SSLError exceptions anyway, 22 | # why make them handle both OpenSSL *AND* RestClient exceptions??? 23 | # 24 | # also see https://github.com/ruby/ruby/blob/trunk/ext/openssl/ossl.c#L237 25 | it "is unsuccessful with an incorrect ca_file" do 26 | request = RestClient::Request.new( 27 | :method => :get, 28 | :url => 'https://www.mozilla.com', 29 | :verify_ssl => OpenSSL::SSL::VERIFY_PEER, 30 | :ssl_ca_file => File.join(File.dirname(__FILE__), "certs", "verisign.crt") 31 | ) 32 | expect { request.execute }.to raise_error(RestClient::SSLCertificateNotVerified) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/restclient/net_http_ext.rb: -------------------------------------------------------------------------------- 1 | module Net 2 | class HTTP 3 | 4 | # Adding the patch method if it doesn't exist (rest-client issue: https://github.com/archiloque/rest-client/issues/79) 5 | if !defined?(Net::HTTP::Patch) 6 | # Code taken from this commit: https://github.com/ruby/ruby/commit/ab70e53ac3b5102d4ecbe8f38d4f76afad29d37d#lib/net/http.rb 7 | class Protocol 8 | # Sends a PATCH request to the +path+ and gets a response, 9 | # as an HTTPResponse object. 10 | def patch(path, data, initheader = nil, dest = nil, &block) # :yield: +body_segment+ 11 | send_entity(path, data, initheader, dest, Patch, &block) 12 | end 13 | 14 | # Executes a request which uses a representation 15 | # and returns its body. 16 | def send_entity(path, data, initheader, dest, type, &block) 17 | res = nil 18 | request(type.new(path, initheader), data) {|r| 19 | r.read_body dest, &block 20 | res = r 21 | } 22 | unless @newimpl 23 | res.value 24 | return res, res.body 25 | end 26 | res 27 | end 28 | end 29 | 30 | class Patch < HTTPRequest 31 | METHOD = 'PATCH' 32 | REQUEST_HAS_BODY = true 33 | RESPONSE_HAS_BODY = true 34 | end 35 | end 36 | 37 | # 38 | # Replace the request method in Net::HTTP to sniff the body type 39 | # and set the stream if appropriate 40 | # 41 | # Taken from: 42 | # http://www.missiondata.com/blog/ruby/29/streaming-data-to-s3-with-ruby/ 43 | 44 | alias __request__ request 45 | 46 | def request(req, body=nil, &block) 47 | if body != nil && body.respond_to?(:read) 48 | req.body_stream = body 49 | return __request__(req, nil, &block) 50 | else 51 | return __request__(req, body, &block) 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /bin/restclient: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | $:.unshift File.dirname(__FILE__) + "/../lib" 4 | 5 | require 'rubygems' 6 | require 'restclient' 7 | require 'yaml' 8 | 9 | def usage(why = nil) 10 | puts "failed for reason: #{why}" if why 11 | puts "usage: restclient [get|put|post|delete] url|name [username] [password]" 12 | puts " The verb is optional, if you leave it off you'll get an interactive shell." 13 | puts " put and post both take the input body on stdin." 14 | exit(1) 15 | end 16 | 17 | POSSIBLE_VERBS = ['get', 'put', 'post', 'delete'] 18 | 19 | if POSSIBLE_VERBS.include? ARGV.first 20 | @verb = ARGV.shift 21 | else 22 | @verb = nil 23 | end 24 | 25 | @url = ARGV.shift || 'http://localhost:4567' 26 | 27 | config = YAML.load(File.read(ENV['HOME'] + "/.restclient")) rescue {} 28 | 29 | @url, @username, @password = if c = config[@url] 30 | [c['url'], c['username'], c['password']] 31 | else 32 | [@url, * ARGV] 33 | end 34 | 35 | usage("invalid url '#{@url}") unless @url =~ /^https?/ 36 | usage("too few args") unless ARGV.size < 3 37 | 38 | def r 39 | @r ||= RestClient::Resource.new(@url, @username, @password) 40 | end 41 | 42 | r # force rc to load 43 | 44 | if @verb 45 | begin 46 | if %w( put post ).include? @verb 47 | puts r.send(@verb, STDIN.read) 48 | else 49 | puts r.send(@verb) 50 | end 51 | exit 0 52 | rescue RestClient::Exception => e 53 | puts e.response.body if e.respond_to? :response 54 | raise 55 | end 56 | end 57 | 58 | POSSIBLE_VERBS.each do |m| 59 | eval <<-end_eval 60 | def #{m}(path, *args, &b) 61 | r[path].#{m}(*args, &b) 62 | end 63 | end_eval 64 | end 65 | 66 | def method_missing(s, * args, & b) 67 | if POSSIBLE_VERBS.include? s 68 | begin 69 | r.send(s, *args, & b) 70 | rescue RestClient::RequestFailed => e 71 | print STDERR, e.response.body 72 | raise e 73 | end 74 | else 75 | super 76 | end 77 | end 78 | 79 | require 'irb' 80 | require 'irb/completion' 81 | 82 | if File.exists? ".irbrc" 83 | ENV['IRBRC'] = ".irbrc" 84 | end 85 | 86 | if File.exists?(File.expand_path(rcfile = "~/.restclientrc")) 87 | load(rcfile) 88 | end 89 | 90 | ARGV.clear 91 | 92 | IRB.start 93 | exit! 94 | -------------------------------------------------------------------------------- /spec/request2_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join( File.dirname(File.expand_path(__FILE__)), 'base') 2 | 3 | require 'webmock/rspec' 4 | include WebMock::API 5 | 6 | describe RestClient::Request do 7 | 8 | it "manage params for get requests" do 9 | stub_request(:get, 'http://some/resource?a=b&c=d').with(:headers => {'Accept'=>'*/*; q=0.5, application/xml', 'Accept-Encoding'=>'gzip, deflate', 'Foo'=>'bar'}).to_return(:body => 'foo', :status => 200) 10 | RestClient::Request.execute(:url => 'http://some/resource', :method => :get, :headers => {:foo => :bar, :params => {:a => :b, 'c' => 'd'}}).body.should == 'foo' 11 | 12 | stub_request(:get, 'http://some/resource').with(:headers => {'Accept'=>'*/*; q=0.5, application/xml', 'Accept-Encoding'=>'gzip, deflate', 'Foo'=>'bar', 'params' => 'a'}).to_return(:body => 'foo', :status => 200) 13 | RestClient::Request.execute(:url => 'http://some/resource', :method => :get, :headers => {:foo => :bar, :params => :a}).body.should == 'foo' 14 | end 15 | 16 | it "can use a block to process response" do 17 | response_value = nil 18 | block = Proc.new do |http_response| 19 | response_value = http_response.body 20 | end 21 | stub_request(:get, 'http://some/resource?a=b&c=d').with(:headers => {'Accept'=>'*/*; q=0.5, application/xml', 'Accept-Encoding'=>'gzip, deflate', 'Foo'=>'bar'}).to_return(:body => 'foo', :status => 200) 22 | RestClient::Request.execute(:url => 'http://some/resource', :method => :get, :headers => {:foo => :bar, :params => {:a => :b, 'c' => 'd'}}, :block_response => block) 23 | response_value.should == "foo" 24 | end 25 | 26 | it 'closes payload if not nil' do 27 | test_file = File.new(File.join( File.dirname(File.expand_path(__FILE__)), 'master_shake.jpg')) 28 | initial_count = tmp_count 29 | 30 | stub_request(:post, 'http://some/resource').with(:headers => {'Accept'=>'*/*; q=0.5, application/xml', 'Accept-Encoding'=>'gzip, deflate'}).to_return(:body => 'foo', :status => 200) 31 | RestClient::Request.execute(:url => 'http://some/resource', :method => :post, :payload => {:file => test_file}) 32 | 33 | tmp_count.should == initial_count 34 | end 35 | 36 | end 37 | 38 | def tmp_count 39 | Dir.glob(Dir::tmpdir + "/*").size 40 | end -------------------------------------------------------------------------------- /spec/restclient_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join( File.dirname(File.expand_path(__FILE__)), 'base') 2 | 3 | describe RestClient do 4 | describe "API" do 5 | it "GET" do 6 | RestClient::Request.should_receive(:execute).with(:method => :get, :url => 'http://some/resource', :headers => {}) 7 | RestClient.get('http://some/resource') 8 | end 9 | 10 | it "POST" do 11 | RestClient::Request.should_receive(:execute).with(:method => :post, :url => 'http://some/resource', :payload => 'payload', :headers => {}) 12 | RestClient.post('http://some/resource', 'payload') 13 | end 14 | 15 | it "PUT" do 16 | RestClient::Request.should_receive(:execute).with(:method => :put, :url => 'http://some/resource', :payload => 'payload', :headers => {}) 17 | RestClient.put('http://some/resource', 'payload') 18 | end 19 | 20 | it "PATCH" do 21 | RestClient::Request.should_receive(:execute).with(:method => :patch, :url => 'http://some/resource', :payload => 'payload', :headers => {}) 22 | RestClient.patch('http://some/resource', 'payload') 23 | end 24 | 25 | it "DELETE" do 26 | RestClient::Request.should_receive(:execute).with(:method => :delete, :url => 'http://some/resource', :headers => {}) 27 | RestClient.delete('http://some/resource') 28 | end 29 | 30 | it "HEAD" do 31 | RestClient::Request.should_receive(:execute).with(:method => :head, :url => 'http://some/resource', :headers => {}) 32 | RestClient.head('http://some/resource') 33 | end 34 | 35 | it "OPTIONS" do 36 | RestClient::Request.should_receive(:execute).with(:method => :options, :url => 'http://some/resource', :headers => {}) 37 | RestClient.options('http://some/resource') 38 | end 39 | end 40 | 41 | describe "logging" do 42 | after do 43 | RestClient.log = nil 44 | end 45 | 46 | it "uses << if the log is not a string" do 47 | log = RestClient.log = [] 48 | log.should_receive(:<<).with('xyz') 49 | RestClient.log << 'xyz' 50 | end 51 | 52 | it "displays the log to stdout" do 53 | RestClient.log = 'stdout' 54 | STDOUT.should_receive(:puts).with('xyz') 55 | RestClient.log << 'xyz' 56 | end 57 | 58 | it "displays the log to stderr" do 59 | RestClient.log = 'stderr' 60 | STDERR.should_receive(:puts).with('xyz') 61 | RestClient.log << 'xyz' 62 | end 63 | 64 | it "append the log to the requested filename" do 65 | RestClient.log = '/tmp/restclient.log' 66 | f = mock('file handle') 67 | File.should_receive(:open).with('/tmp/restclient.log', 'a').and_yield(f) 68 | f.should_receive(:puts).with('xyz') 69 | RestClient.log << 'xyz' 70 | end 71 | end 72 | 73 | end 74 | -------------------------------------------------------------------------------- /spec/abstract_response_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join( File.dirname(File.expand_path(__FILE__)), 'base') 2 | 3 | describe RestClient::AbstractResponse do 4 | 5 | class MyAbstractResponse 6 | 7 | include RestClient::AbstractResponse 8 | 9 | attr_accessor :size 10 | 11 | def initialize net_http_res, args 12 | @net_http_res = net_http_res 13 | @args = args 14 | end 15 | 16 | end 17 | 18 | before do 19 | @net_http_res = mock('net http response') 20 | @response = MyAbstractResponse.new(@net_http_res, {}) 21 | end 22 | 23 | it "fetches the numeric response code" do 24 | @net_http_res.should_receive(:code).and_return('200') 25 | @response.code.should == 200 26 | end 27 | 28 | it "has a nice description" do 29 | @net_http_res.should_receive(:to_hash).and_return({'Content-Type' => ['application/pdf']}) 30 | @net_http_res.should_receive(:code).and_return('200') 31 | @response.description == '200 OK | application/pdf bytes\n' 32 | end 33 | 34 | it "beautifies the headers by turning the keys to symbols" do 35 | h = RestClient::AbstractResponse.beautify_headers('content-type' => [ 'x' ]) 36 | h.keys.first.should == :content_type 37 | end 38 | 39 | it "beautifies the headers by turning the values to strings instead of one-element arrays" do 40 | h = RestClient::AbstractResponse.beautify_headers('x' => [ 'text/html' ] ) 41 | h.values.first.should == 'text/html' 42 | end 43 | 44 | it "fetches the headers" do 45 | @net_http_res.should_receive(:to_hash).and_return('content-type' => [ 'text/html' ]) 46 | @response.headers.should == { :content_type => 'text/html' } 47 | end 48 | 49 | it "extracts cookies from response headers" do 50 | @net_http_res.should_receive(:to_hash).and_return('set-cookie' => ['session_id=1; path=/']) 51 | @response.cookies.should == { 'session_id' => '1' } 52 | end 53 | 54 | it "extract strange cookies" do 55 | @net_http_res.should_receive(:to_hash).and_return('set-cookie' => ['session_id=ZJ/HQVH6YE+rVkTpn0zvTQ==; path=/']) 56 | @response.cookies.should == { 'session_id' => 'ZJ%2FHQVH6YE+rVkTpn0zvTQ%3D%3D' } 57 | end 58 | 59 | it "doesn't escape cookies" do 60 | @net_http_res.should_receive(:to_hash).and_return('set-cookie' => ['session_id=BAh7BzoNYXBwX25hbWUiEGFwcGxpY2F0aW9uOgpsb2dpbiIKYWRtaW4%3D%0A--08114ba654f17c04d20dcc5228ec672508f738ca; path=/']) 61 | @response.cookies.should == { 'session_id' => 'BAh7BzoNYXBwX25hbWUiEGFwcGxpY2F0aW9uOgpsb2dpbiIKYWRtaW4%3D%0A--08114ba654f17c04d20dcc5228ec672508f738ca' } 62 | end 63 | 64 | it "can access the net http result directly" do 65 | @response.net_http_res.should == @net_http_res 66 | end 67 | 68 | describe "#return!" do 69 | it "should return the response itself on 200-codes" do 70 | @net_http_res.should_receive(:code).and_return('200') 71 | @response.return!.should be_equal(@response) 72 | end 73 | 74 | it "should raise RequestFailed on unknown codes" do 75 | @net_http_res.should_receive(:code).and_return('1000') 76 | lambda { @response.return! }.should raise_error RestClient::RequestFailed 77 | end 78 | 79 | it "should raise an error on a redirection after non-GET/HEAD requests" do 80 | @net_http_res.should_receive(:code).and_return('301') 81 | @response.args.merge(:method => :put) 82 | lambda { @response.return! }.should raise_error RestClient::RequestFailed 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /spec/exceptions_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join( File.dirname(File.expand_path(__FILE__)), 'base') 2 | 3 | require 'webmock/rspec' 4 | include WebMock::API 5 | 6 | describe RestClient::Exception do 7 | it "returns a 'message' equal to the class name if the message is not set, because 'message' should not be nil" do 8 | e = RestClient::Exception.new 9 | e.message.should == "RestClient::Exception" 10 | end 11 | 12 | it "returns the 'message' that was set" do 13 | e = RestClient::Exception.new 14 | message = "An explicitly set message" 15 | e.message = message 16 | e.message.should == message 17 | end 18 | 19 | it "sets the exception message to ErrorMessage" do 20 | RestClient::ResourceNotFound.new.message.should == 'Resource Not Found' 21 | end 22 | 23 | it "contains exceptions in RestClient" do 24 | RestClient::Unauthorized.new.should be_a_kind_of(RestClient::Exception) 25 | RestClient::ServerBrokeConnection.new.should be_a_kind_of(RestClient::Exception) 26 | end 27 | end 28 | 29 | describe RestClient::ServerBrokeConnection do 30 | it "should have a default message of 'Server broke connection'" do 31 | e = RestClient::ServerBrokeConnection.new 32 | e.message.should == 'Server broke connection' 33 | end 34 | end 35 | 36 | describe RestClient::RequestFailed do 37 | before do 38 | @response = mock('HTTP Response', :code => '502') 39 | end 40 | 41 | it "stores the http response on the exception" do 42 | response = "response" 43 | begin 44 | raise RestClient::RequestFailed, response 45 | rescue RestClient::RequestFailed => e 46 | e.response.should == response 47 | end 48 | end 49 | 50 | it "http_code convenience method for fetching the code as an integer" do 51 | RestClient::RequestFailed.new(@response).http_code.should == 502 52 | end 53 | 54 | it "http_body convenience method for fetching the body (decoding when necessary)" do 55 | RestClient::RequestFailed.new(@response).http_code.should == 502 56 | RestClient::RequestFailed.new(@response).message.should == 'HTTP status code 502' 57 | end 58 | 59 | it "shows the status code in the message" do 60 | RestClient::RequestFailed.new(@response).to_s.should match(/502/) 61 | end 62 | end 63 | 64 | describe RestClient::ResourceNotFound do 65 | it "also has the http response attached" do 66 | response = "response" 67 | begin 68 | raise RestClient::ResourceNotFound, response 69 | rescue RestClient::ResourceNotFound => e 70 | e.response.should == response 71 | end 72 | end 73 | end 74 | 75 | describe "backwards compatibility" do 76 | it "alias RestClient::Request::Redirect to RestClient::Redirect" do 77 | RestClient::Request::Redirect.should == RestClient::Redirect 78 | end 79 | 80 | it "alias RestClient::Request::Unauthorized to RestClient::Unauthorized" do 81 | RestClient::Request::Unauthorized.should == RestClient::Unauthorized 82 | end 83 | 84 | it "alias RestClient::Request::RequestFailed to RestClient::RequestFailed" do 85 | RestClient::Request::RequestFailed.should == RestClient::RequestFailed 86 | end 87 | 88 | it "make the exception's response act like an Net::HTTPResponse" do 89 | body = "body" 90 | stub_request(:get, "www.example.com").to_return(:body => body, :status => 404) 91 | begin 92 | RestClient.get "www.example.com" 93 | raise 94 | rescue RestClient::ResourceNotFound => e 95 | e.response.body.should == body 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/restclient/abstract_response.rb: -------------------------------------------------------------------------------- 1 | require 'cgi' 2 | 3 | module RestClient 4 | 5 | module AbstractResponse 6 | 7 | attr_reader :net_http_res, :args 8 | 9 | # HTTP status code 10 | def code 11 | @code ||= @net_http_res.code.to_i 12 | end 13 | 14 | # A hash of the headers, beautified with symbols and underscores. 15 | # e.g. "Content-type" will become :content_type. 16 | def headers 17 | @headers ||= AbstractResponse.beautify_headers(@net_http_res.to_hash) 18 | end 19 | 20 | # The raw headers. 21 | def raw_headers 22 | @raw_headers ||= @net_http_res.to_hash 23 | end 24 | 25 | # Hash of cookies extracted from response headers 26 | def cookies 27 | @cookies ||= (self.headers[:set_cookie] || {}).inject({}) do |out, cookie_content| 28 | out.merge parse_cookie(cookie_content) 29 | end 30 | end 31 | 32 | # Return the default behavior corresponding to the response code: 33 | # the response itself for code in 200..206, redirection for 301, 302 and 307 in get and head cases, redirection for 303 and an exception in other cases 34 | def return! request = nil, result = nil, & block 35 | if (200..207).include? code 36 | self 37 | elsif [301, 302, 307].include? code 38 | unless [:get, :head].include? args[:method] 39 | raise Exceptions::EXCEPTIONS_MAP[code].new(self, code) 40 | else 41 | follow_redirection(request, result, & block) 42 | end 43 | elsif code == 303 44 | args[:method] = :get 45 | args.delete :payload 46 | follow_redirection(request, result, & block) 47 | elsif Exceptions::EXCEPTIONS_MAP[code] 48 | raise Exceptions::EXCEPTIONS_MAP[code].new(self, code) 49 | else 50 | raise RequestFailed.new(self, code) 51 | end 52 | end 53 | 54 | def to_i 55 | code 56 | end 57 | 58 | def description 59 | "#{code} #{STATUSES[code]} | #{(headers[:content_type] || '').gsub(/;.*$/, '')} #{size} bytes\n" 60 | end 61 | 62 | # Follow a redirection 63 | def follow_redirection request = nil, result = nil, & block 64 | url = headers[:location] 65 | if url !~ /^http/ 66 | url = URI.parse(args[:url]).merge(url).to_s 67 | end 68 | args[:url] = url 69 | if request 70 | if request.max_redirects == 0 71 | raise MaxRedirectsReached 72 | end 73 | args[:password] = request.password 74 | args[:user] = request.user 75 | args[:headers] = request.headers 76 | args[:max_redirects] = request.max_redirects - 1 77 | # pass any cookie set in the result 78 | if result && result['set-cookie'] 79 | args[:headers][:cookies] = (args[:headers][:cookies] || {}).merge(parse_cookie(result['set-cookie'])) 80 | end 81 | end 82 | Request.execute args, &block 83 | end 84 | 85 | def AbstractResponse.beautify_headers(headers) 86 | headers.inject({}) do |out, (key, value)| 87 | out[key.gsub(/-/, '_').downcase.to_sym] = %w{ set-cookie }.include?(key.downcase) ? value : value.first 88 | out 89 | end 90 | end 91 | 92 | private 93 | 94 | # Parse a cookie value and return its content in an Hash 95 | def parse_cookie cookie_content 96 | out = {} 97 | CGI::Cookie::parse(cookie_content).each do |key, cookie| 98 | unless ['expires', 'path'].include? key 99 | out[CGI::escape(key)] = cookie.value[0] ? (CGI::escape(cookie.value[0]) || '') : '' 100 | end 101 | end 102 | out 103 | end 104 | end 105 | 106 | end 107 | -------------------------------------------------------------------------------- /lib/restclient/resource.rb: -------------------------------------------------------------------------------- 1 | module RestClient 2 | # A class that can be instantiated for access to a RESTful resource, 3 | # including authentication. 4 | # 5 | # Example: 6 | # 7 | # resource = RestClient::Resource.new('http://some/resource') 8 | # jpg = resource.get(:accept => 'image/jpg') 9 | # 10 | # With HTTP basic authentication: 11 | # 12 | # resource = RestClient::Resource.new('http://protected/resource', :user => 'user', :password => 'password') 13 | # resource.delete 14 | # 15 | # With a timeout (seconds): 16 | # 17 | # RestClient::Resource.new('http://slow', :timeout => 10) 18 | # 19 | # With an open timeout (seconds): 20 | # 21 | # RestClient::Resource.new('http://behindfirewall', :open_timeout => 10) 22 | # 23 | # You can also use resources to share common headers. For headers keys, 24 | # symbols are converted to strings. Example: 25 | # 26 | # resource = RestClient::Resource.new('http://some/resource', :headers => { :client_version => 1 }) 27 | # 28 | # This header will be transported as X-Client-Version (notice the X prefix, 29 | # capitalization and hyphens) 30 | # 31 | # Use the [] syntax to allocate subresources: 32 | # 33 | # site = RestClient::Resource.new('http://example.com', :user => 'adam', :password => 'mypasswd') 34 | # site['posts/1/comments'].post 'Good article.', :content_type => 'text/plain' 35 | # 36 | class Resource 37 | attr_reader :url, :options, :block 38 | 39 | def initialize(url, options={}, backwards_compatibility=nil, &block) 40 | @url = url 41 | @block = block 42 | if options.class == Hash 43 | @options = options 44 | else # compatibility with previous versions 45 | @options = { :user => options, :password => backwards_compatibility } 46 | end 47 | end 48 | 49 | def get(additional_headers={}, &block) 50 | headers = (options[:headers] || {}).merge(additional_headers) 51 | Request.execute(options.merge( 52 | :method => :get, 53 | :url => url, 54 | :headers => headers), &(block || @block)) 55 | end 56 | 57 | def head(additional_headers={}, &block) 58 | headers = (options[:headers] || {}).merge(additional_headers) 59 | Request.execute(options.merge( 60 | :method => :head, 61 | :url => url, 62 | :headers => headers), &(block || @block)) 63 | end 64 | 65 | def post(payload, additional_headers={}, &block) 66 | headers = (options[:headers] || {}).merge(additional_headers) 67 | Request.execute(options.merge( 68 | :method => :post, 69 | :url => url, 70 | :payload => payload, 71 | :headers => headers), &(block || @block)) 72 | end 73 | 74 | def put(payload, additional_headers={}, &block) 75 | headers = (options[:headers] || {}).merge(additional_headers) 76 | Request.execute(options.merge( 77 | :method => :put, 78 | :url => url, 79 | :payload => payload, 80 | :headers => headers), &(block || @block)) 81 | end 82 | 83 | def patch(payload, additional_headers={}, &block) 84 | headers = (options[:headers] || {}).merge(additional_headers) 85 | Request.execute(options.merge( 86 | :method => :patch, 87 | :url => url, 88 | :payload => payload, 89 | :headers => headers), &(block || @block)) 90 | end 91 | 92 | def delete(additional_headers={}, &block) 93 | headers = (options[:headers] || {}).merge(additional_headers) 94 | Request.execute(options.merge( 95 | :method => :delete, 96 | :url => url, 97 | :headers => headers), &(block || @block)) 98 | end 99 | 100 | def to_s 101 | url 102 | end 103 | 104 | def user 105 | options[:user] 106 | end 107 | 108 | def password 109 | options[:password] 110 | end 111 | 112 | def headers 113 | options[:headers] || {} 114 | end 115 | 116 | def timeout 117 | options[:timeout] 118 | end 119 | 120 | def open_timeout 121 | options[:open_timeout] 122 | end 123 | 124 | # Construct a subresource, preserving authentication. 125 | # 126 | # Example: 127 | # 128 | # site = RestClient::Resource.new('http://example.com', 'adam', 'mypasswd') 129 | # site['posts/1/comments'].post 'Good article.', :content_type => 'text/plain' 130 | # 131 | # This is especially useful if you wish to define your site in one place and 132 | # call it in multiple locations: 133 | # 134 | # def orders 135 | # RestClient::Resource.new('http://example.com/orders', 'admin', 'mypasswd') 136 | # end 137 | # 138 | # orders.get # GET http://example.com/orders 139 | # orders['1'].get # GET http://example.com/orders/1 140 | # orders['1/items'].delete # DELETE http://example.com/orders/1/items 141 | # 142 | # Nest resources as far as you want: 143 | # 144 | # site = RestClient::Resource.new('http://example.com') 145 | # posts = site['posts'] 146 | # first_post = posts['1'] 147 | # comments = first_post['comments'] 148 | # comments.post 'Hello', :content_type => 'text/plain' 149 | # 150 | def [](suburl, &new_block) 151 | case 152 | when block_given? then self.class.new(concat_urls(url, suburl), options, &new_block) 153 | when block then self.class.new(concat_urls(url, suburl), options, &block) 154 | else 155 | self.class.new(concat_urls(url, suburl), options) 156 | end 157 | end 158 | 159 | def concat_urls(url, suburl) # :nodoc: 160 | url = url.to_s 161 | suburl = suburl.to_s 162 | if url.slice(-1, 1) == '/' or suburl.slice(0, 1) == '/' 163 | url + suburl 164 | else 165 | "#{url}/#{suburl}" 166 | end 167 | end 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /spec/resource_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join( File.dirname(File.expand_path(__FILE__)), 'base') 2 | 3 | require 'webmock/rspec' 4 | include WebMock::API 5 | 6 | describe RestClient::Resource do 7 | before do 8 | @resource = RestClient::Resource.new('http://some/resource', :user => 'jane', :password => 'mypass', :headers => {'X-Something' => '1'}) 9 | end 10 | 11 | context "Resource delegation" do 12 | it "GET" do 13 | RestClient::Request.should_receive(:execute).with(:method => :get, :url => 'http://some/resource', :headers => {'X-Something' => '1'}, :user => 'jane', :password => 'mypass') 14 | @resource.get 15 | end 16 | 17 | it "HEAD" do 18 | RestClient::Request.should_receive(:execute).with(:method => :head, :url => 'http://some/resource', :headers => {'X-Something' => '1'}, :user => 'jane', :password => 'mypass') 19 | @resource.head 20 | end 21 | 22 | it "POST" do 23 | RestClient::Request.should_receive(:execute).with(:method => :post, :url => 'http://some/resource', :payload => 'abc', :headers => {:content_type => 'image/jpg', 'X-Something' => '1'}, :user => 'jane', :password => 'mypass') 24 | @resource.post 'abc', :content_type => 'image/jpg' 25 | end 26 | 27 | it "PUT" do 28 | RestClient::Request.should_receive(:execute).with(:method => :put, :url => 'http://some/resource', :payload => 'abc', :headers => {:content_type => 'image/jpg', 'X-Something' => '1'}, :user => 'jane', :password => 'mypass') 29 | @resource.put 'abc', :content_type => 'image/jpg' 30 | end 31 | 32 | it "PATCH" do 33 | RestClient::Request.should_receive(:execute).with(:method => :patch, :url => 'http://some/resource', :payload => 'abc', :headers => {:content_type => 'image/jpg', 'X-Something' => '1'}, :user => 'jane', :password => 'mypass') 34 | @resource.patch 'abc', :content_type => 'image/jpg' 35 | end 36 | 37 | it "DELETE" do 38 | RestClient::Request.should_receive(:execute).with(:method => :delete, :url => 'http://some/resource', :headers => {'X-Something' => '1'}, :user => 'jane', :password => 'mypass') 39 | @resource.delete 40 | end 41 | 42 | it "overrides resource headers" do 43 | RestClient::Request.should_receive(:execute).with(:method => :get, :url => 'http://some/resource', :headers => {'X-Something' => '2'}, :user => 'jane', :password => 'mypass') 44 | @resource.get 'X-Something' => '2' 45 | end 46 | end 47 | 48 | it "can instantiate with no user/password" do 49 | @resource = RestClient::Resource.new('http://some/resource') 50 | end 51 | 52 | it "is backwards compatible with previous constructor" do 53 | @resource = RestClient::Resource.new('http://some/resource', 'user', 'pass') 54 | @resource.user.should == 'user' 55 | @resource.password.should == 'pass' 56 | end 57 | 58 | it "concatenates urls, inserting a slash when it needs one" do 59 | @resource.concat_urls('http://example.com', 'resource').should == 'http://example.com/resource' 60 | end 61 | 62 | it "concatenates urls, using no slash if the first url ends with a slash" do 63 | @resource.concat_urls('http://example.com/', 'resource').should == 'http://example.com/resource' 64 | end 65 | 66 | it "concatenates urls, using no slash if the second url starts with a slash" do 67 | @resource.concat_urls('http://example.com', '/resource').should == 'http://example.com/resource' 68 | end 69 | 70 | it "concatenates even non-string urls, :posts + 1 => 'posts/1'" do 71 | @resource.concat_urls(:posts, 1).should == 'posts/1' 72 | end 73 | 74 | it "offers subresources via []" do 75 | parent = RestClient::Resource.new('http://example.com') 76 | parent['posts'].url.should == 'http://example.com/posts' 77 | end 78 | 79 | it "transports options to subresources" do 80 | parent = RestClient::Resource.new('http://example.com', :user => 'user', :password => 'password') 81 | parent['posts'].user.should == 'user' 82 | parent['posts'].password.should == 'password' 83 | end 84 | 85 | it "passes a given block to subresources" do 86 | block = Proc.new{|r| r} 87 | parent = RestClient::Resource.new('http://example.com', &block) 88 | parent['posts'].block.should == block 89 | end 90 | 91 | it "the block should be overrideable" do 92 | block1 = Proc.new{|r| r} 93 | block2 = Proc.new{|r| r} 94 | parent = RestClient::Resource.new('http://example.com', &block1) 95 | # parent['posts', &block2].block.should == block2 # ruby 1.9 syntax 96 | parent.send(:[], 'posts', &block2).block.should == block2 97 | end 98 | 99 | it "the block should be overrideable in ruby 1.9 syntax" do 100 | block = Proc.new{|r| r} 101 | parent = RestClient::Resource.new('http://example.com', &block) 102 | r19_syntax = %q{ 103 | parent['posts', &->(r){r}].block.should_not == block 104 | } 105 | if is_ruby_19? 106 | eval(r19_syntax) 107 | end 108 | end 109 | 110 | it "prints its url with to_s" do 111 | RestClient::Resource.new('x').to_s.should == 'x' 112 | end 113 | 114 | describe 'block' do 115 | it 'can use block when creating the resource' do 116 | stub_request(:get, 'www.example.com').to_return(:body => '', :status => 404) 117 | resource = RestClient::Resource.new('www.example.com') { |response, request| 'foo' } 118 | resource.get.should == 'foo' 119 | end 120 | 121 | it 'can use block when executing the resource' do 122 | stub_request(:get, 'www.example.com').to_return(:body => '', :status => 404) 123 | resource = RestClient::Resource.new('www.example.com') 124 | resource.get { |response, request| 'foo' }.should == 'foo' 125 | end 126 | 127 | it 'execution block override resource block' do 128 | stub_request(:get, 'www.example.com').to_return(:body => '', :status => 404) 129 | resource = RestClient::Resource.new('www.example.com') { |response, request| 'foo' } 130 | resource.get { |response, request| 'bar' }.should == 'bar' 131 | end 132 | 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /lib/restclient.rb: -------------------------------------------------------------------------------- 1 | require 'uri' 2 | require 'zlib' 3 | require 'stringio' 4 | 5 | begin 6 | require 'net/https' 7 | rescue LoadError => e 8 | raise e unless RUBY_PLATFORM =~ /linux/ 9 | raise LoadError, "no such file to load -- net/https. Try running apt-get install libopenssl-ruby" 10 | end 11 | 12 | require File.dirname(__FILE__) + '/restclient/exceptions' 13 | require File.dirname(__FILE__) + '/restclient/request' 14 | require File.dirname(__FILE__) + '/restclient/abstract_response' 15 | require File.dirname(__FILE__) + '/restclient/response' 16 | require File.dirname(__FILE__) + '/restclient/raw_response' 17 | require File.dirname(__FILE__) + '/restclient/resource' 18 | require File.dirname(__FILE__) + '/restclient/payload' 19 | require File.dirname(__FILE__) + '/restclient/net_http_ext' 20 | 21 | # This module's static methods are the entry point for using the REST client. 22 | # 23 | # # GET 24 | # xml = RestClient.get 'http://example.com/resource' 25 | # jpg = RestClient.get 'http://example.com/resource', :accept => 'image/jpg' 26 | # 27 | # # authentication and SSL 28 | # RestClient.get 'https://user:password@example.com/private/resource' 29 | # 30 | # # POST or PUT with a hash sends parameters as a urlencoded form body 31 | # RestClient.post 'http://example.com/resource', :param1 => 'one' 32 | # 33 | # # nest hash parameters 34 | # RestClient.post 'http://example.com/resource', :nested => { :param1 => 'one' } 35 | # 36 | # # POST and PUT with raw payloads 37 | # RestClient.post 'http://example.com/resource', 'the post body', :content_type => 'text/plain' 38 | # RestClient.post 'http://example.com/resource.xml', xml_doc 39 | # RestClient.put 'http://example.com/resource.pdf', File.read('my.pdf'), :content_type => 'application/pdf' 40 | # 41 | # # DELETE 42 | # RestClient.delete 'http://example.com/resource' 43 | # 44 | # # retreive the response http code and headers 45 | # res = RestClient.get 'http://example.com/some.jpg' 46 | # res.code # => 200 47 | # res.headers[:content_type] # => 'image/jpg' 48 | # 49 | # # HEAD 50 | # RestClient.head('http://example.com').headers 51 | # 52 | # To use with a proxy, just set RestClient.proxy to the proper http proxy: 53 | # 54 | # RestClient.proxy = "http://proxy.example.com/" 55 | # 56 | # Or inherit the proxy from the environment: 57 | # 58 | # RestClient.proxy = ENV['http_proxy'] 59 | # 60 | # For live tests of RestClient, try using http://rest-test.heroku.com, which echoes back information about the rest call: 61 | # 62 | # >> RestClient.put 'http://rest-test.heroku.com/resource', :foo => 'baz' 63 | # => "PUT http://rest-test.heroku.com/resource with a 7 byte payload, content type application/x-www-form-urlencoded {\"foo\"=>\"baz\"}" 64 | # 65 | module RestClient 66 | 67 | def self.get(url, headers={}, &block) 68 | Request.execute(:method => :get, :url => url, :headers => headers, &block) 69 | end 70 | 71 | def self.post(url, payload, headers={}, &block) 72 | Request.execute(:method => :post, :url => url, :payload => payload, :headers => headers, &block) 73 | end 74 | 75 | def self.patch(url, payload, headers={}, &block) 76 | Request.execute(:method => :patch, :url => url, :payload => payload, :headers => headers, &block) 77 | end 78 | 79 | def self.put(url, payload, headers={}, &block) 80 | Request.execute(:method => :put, :url => url, :payload => payload, :headers => headers, &block) 81 | end 82 | 83 | def self.delete(url, headers={}, &block) 84 | Request.execute(:method => :delete, :url => url, :headers => headers, &block) 85 | end 86 | 87 | def self.head(url, headers={}, &block) 88 | Request.execute(:method => :head, :url => url, :headers => headers, &block) 89 | end 90 | 91 | def self.options(url, headers={}, &block) 92 | Request.execute(:method => :options, :url => url, :headers => headers, &block) 93 | end 94 | 95 | class << self 96 | attr_accessor :proxy 97 | end 98 | 99 | # Setup the log for RestClient calls. 100 | # Value should be a logger but can can be stdout, stderr, or a filename. 101 | # You can also configure logging by the environment variable RESTCLIENT_LOG. 102 | def self.log= log 103 | @@log = create_log log 104 | end 105 | 106 | def self.version 107 | version_path = File.dirname(__FILE__) + "/../VERSION" 108 | return File.read(version_path).chomp if File.file?(version_path) 109 | "0.0.0" 110 | end 111 | 112 | # Create a log that respond to << like a logger 113 | # param can be 'stdout', 'stderr', a string (then we will log to that file) or a logger (then we return it) 114 | def self.create_log param 115 | if param 116 | if param.is_a? String 117 | if param == 'stdout' 118 | stdout_logger = Class.new do 119 | def << obj 120 | STDOUT.puts obj 121 | end 122 | end 123 | stdout_logger.new 124 | elsif param == 'stderr' 125 | stderr_logger = Class.new do 126 | def << obj 127 | STDERR.puts obj 128 | end 129 | end 130 | stderr_logger.new 131 | else 132 | file_logger = Class.new do 133 | attr_writer :target_file 134 | 135 | def << obj 136 | File.open(@target_file, 'a') { |f| f.puts obj } 137 | end 138 | end 139 | logger = file_logger.new 140 | logger.target_file = param 141 | logger 142 | end 143 | else 144 | param 145 | end 146 | end 147 | end 148 | 149 | @@env_log = create_log ENV['RESTCLIENT_LOG'] 150 | 151 | @@log = nil 152 | 153 | def self.log # :nodoc: 154 | @@env_log || @@log 155 | end 156 | 157 | @@before_execution_procs = [] 158 | 159 | # Add a Proc to be called before each request in executed. 160 | # The proc parameters will be the http request and the request params. 161 | def self.add_before_execution_proc &proc 162 | @@before_execution_procs << proc 163 | end 164 | 165 | # Reset the procs to be called before each request is executed. 166 | def self.reset_before_execution_procs 167 | @@before_execution_procs = [] 168 | end 169 | 170 | def self.before_execution_procs # :nodoc: 171 | @@before_execution_procs 172 | end 173 | 174 | end 175 | -------------------------------------------------------------------------------- /lib/restclient/payload.rb: -------------------------------------------------------------------------------- 1 | require 'tempfile' 2 | require 'stringio' 3 | require 'mime/types' 4 | 5 | module RestClient 6 | module Payload 7 | extend self 8 | 9 | def generate(params) 10 | if params.is_a?(String) 11 | Base.new(params) 12 | elsif params.is_a?(Hash) 13 | if params.delete(:multipart) == true || has_file?(params) 14 | Multipart.new(params) 15 | else 16 | UrlEncoded.new(params) 17 | end 18 | elsif params.respond_to?(:read) 19 | Streamed.new(params) 20 | else 21 | nil 22 | end 23 | end 24 | 25 | def has_file?(params) 26 | params.any? do |_, v| 27 | case v 28 | when Hash 29 | has_file?(v) 30 | when Array 31 | has_file_array?(v) 32 | else 33 | v.respond_to?(:path) && v.respond_to?(:read) 34 | end 35 | end 36 | end 37 | 38 | def has_file_array?(params) 39 | params.any? do |v| 40 | case v 41 | when Hash 42 | has_file?(v) 43 | when Array 44 | has_file_array?(v) 45 | else 46 | v.respond_to?(:path) && v.respond_to?(:read) 47 | end 48 | end 49 | end 50 | 51 | class Base 52 | def initialize(params) 53 | build_stream(params) 54 | end 55 | 56 | def build_stream(params) 57 | @stream = StringIO.new(params) 58 | @stream.seek(0) 59 | end 60 | 61 | def read(bytes=nil) 62 | @stream.read(bytes) 63 | end 64 | 65 | alias :to_s :read 66 | 67 | # Flatten parameters by converting hashes of hashes to flat hashes 68 | # {keys1 => {keys2 => value}} will be transformed into [keys1[key2], value] 69 | def flatten_params(params, parent_key = nil) 70 | result = [] 71 | params.each do |key, value| 72 | calculated_key = parent_key ? "#{parent_key}[#{handle_key(key)}]" : handle_key(key) 73 | if value.is_a? Hash 74 | result += flatten_params(value, calculated_key) 75 | elsif value.is_a? Array 76 | result += flatten_params_array(value, calculated_key) 77 | else 78 | result << [calculated_key, value] 79 | end 80 | end 81 | result 82 | end 83 | 84 | def flatten_params_array value, calculated_key 85 | result = [] 86 | value.each do |elem| 87 | if elem.is_a? Hash 88 | result += flatten_params(elem, calculated_key) 89 | elsif elem.is_a? Array 90 | result += flatten_params_array(elem, calculated_key) 91 | else 92 | result << ["#{calculated_key}[]", elem] 93 | end 94 | end 95 | result 96 | end 97 | 98 | def headers 99 | {'Content-Length' => size.to_s} 100 | end 101 | 102 | def size 103 | @stream.size 104 | end 105 | 106 | alias :length :size 107 | 108 | def close 109 | @stream.close unless @stream.closed? 110 | end 111 | 112 | def inspect 113 | result = to_s.inspect 114 | @stream.seek(0) 115 | result 116 | end 117 | 118 | def short_inspect 119 | (size > 500 ? "#{size} byte(s) length" : inspect) 120 | end 121 | 122 | end 123 | 124 | class Streamed < Base 125 | def build_stream(params = nil) 126 | @stream = params 127 | end 128 | 129 | def size 130 | if @stream.respond_to?(:size) 131 | @stream.size 132 | elsif @stream.is_a?(IO) 133 | @stream.stat.size 134 | end 135 | end 136 | 137 | alias :length :size 138 | end 139 | 140 | class UrlEncoded < Base 141 | def build_stream(params = nil) 142 | @stream = StringIO.new(flatten_params(params).collect do |entry| 143 | "#{entry[0]}=#{handle_key(entry[1])}" 144 | end.join("&")) 145 | @stream.seek(0) 146 | end 147 | 148 | # for UrlEncoded escape the keys 149 | def handle_key key 150 | URI.escape(key.to_s, Regexp.new("[^#{URI::PATTERN::UNRESERVED}]")) 151 | end 152 | 153 | def headers 154 | super.merge({'Content-Type' => 'application/x-www-form-urlencoded'}) 155 | end 156 | end 157 | 158 | class Multipart < Base 159 | EOL = "\r\n" 160 | 161 | def build_stream(params) 162 | b = "--#{boundary}" 163 | 164 | @stream = Tempfile.new("RESTClient.Stream.#{rand(1000)}") 165 | @stream.binmode 166 | @stream.write(b + EOL) 167 | 168 | if params.is_a? Hash 169 | x = flatten_params(params) 170 | else 171 | x = params 172 | end 173 | 174 | last_index = x.length - 1 175 | x.each_with_index do |a, index| 176 | k, v = * a 177 | if v.respond_to?(:read) && v.respond_to?(:path) 178 | create_file_field(@stream, k, v) 179 | else 180 | create_regular_field(@stream, k, v) 181 | end 182 | @stream.write(EOL + b) 183 | @stream.write(EOL) unless last_index == index 184 | end 185 | @stream.write('--') 186 | @stream.write(EOL) 187 | @stream.seek(0) 188 | end 189 | 190 | def create_regular_field(s, k, v) 191 | s.write("Content-Disposition: form-data; name=\"#{k}\"") 192 | s.write(EOL) 193 | s.write(EOL) 194 | s.write(v) 195 | end 196 | 197 | def create_file_field(s, k, v) 198 | begin 199 | s.write("Content-Disposition: form-data;") 200 | s.write(" name=\"#{k}\";") unless (k.nil? || k=='') 201 | s.write(" filename=\"#{v.respond_to?(:original_filename) ? v.original_filename : File.basename(v.path)}\"#{EOL}") 202 | s.write("Content-Type: #{v.respond_to?(:content_type) ? v.content_type : mime_for(v.path)}#{EOL}") 203 | s.write(EOL) 204 | while data = v.read(8124) 205 | s.write(data) 206 | end 207 | ensure 208 | v.close if v.respond_to?(:close) 209 | end 210 | end 211 | 212 | def mime_for(path) 213 | mime = MIME::Types.type_for path 214 | mime.empty? ? 'text/plain' : mime[0].content_type 215 | end 216 | 217 | def boundary 218 | @boundary ||= rand(1_000_000).to_s 219 | end 220 | 221 | # for Multipart do not escape the keys 222 | def handle_key key 223 | key 224 | end 225 | 226 | def headers 227 | super.merge({'Content-Type' => %Q{multipart/form-data; boundary=#{boundary}}}) 228 | end 229 | 230 | def close 231 | @stream.close! 232 | end 233 | end 234 | end 235 | end 236 | -------------------------------------------------------------------------------- /lib/restclient/exceptions.rb: -------------------------------------------------------------------------------- 1 | module RestClient 2 | 3 | STATUSES = {100 => 'Continue', 4 | 101 => 'Switching Protocols', 5 | 102 => 'Processing', #WebDAV 6 | 7 | 200 => 'OK', 8 | 201 => 'Created', 9 | 202 => 'Accepted', 10 | 203 => 'Non-Authoritative Information', # http/1.1 11 | 204 => 'No Content', 12 | 205 => 'Reset Content', 13 | 206 => 'Partial Content', 14 | 207 => 'Multi-Status', #WebDAV 15 | 16 | 300 => 'Multiple Choices', 17 | 301 => 'Moved Permanently', 18 | 302 => 'Found', 19 | 303 => 'See Other', # http/1.1 20 | 304 => 'Not Modified', 21 | 305 => 'Use Proxy', # http/1.1 22 | 306 => 'Switch Proxy', # no longer used 23 | 307 => 'Temporary Redirect', # http/1.1 24 | 25 | 400 => 'Bad Request', 26 | 401 => 'Unauthorized', 27 | 402 => 'Payment Required', 28 | 403 => 'Forbidden', 29 | 404 => 'Resource Not Found', 30 | 405 => 'Method Not Allowed', 31 | 406 => 'Not Acceptable', 32 | 407 => 'Proxy Authentication Required', 33 | 408 => 'Request Timeout', 34 | 409 => 'Conflict', 35 | 410 => 'Gone', 36 | 411 => 'Length Required', 37 | 412 => 'Precondition Failed', 38 | 413 => 'Request Entity Too Large', 39 | 414 => 'Request-URI Too Long', 40 | 415 => 'Unsupported Media Type', 41 | 416 => 'Requested Range Not Satisfiable', 42 | 417 => 'Expectation Failed', 43 | 418 => 'I\'m A Teapot', 44 | 421 => 'Too Many Connections From This IP', 45 | 422 => 'Unprocessable Entity', #WebDAV 46 | 423 => 'Locked', #WebDAV 47 | 424 => 'Failed Dependency', #WebDAV 48 | 425 => 'Unordered Collection', #WebDAV 49 | 426 => 'Upgrade Required', 50 | 449 => 'Retry With', #Microsoft 51 | 450 => 'Blocked By Windows Parental Controls', #Microsoft 52 | 53 | 500 => 'Internal Server Error', 54 | 501 => 'Not Implemented', 55 | 502 => 'Bad Gateway', 56 | 503 => 'Service Unavailable', 57 | 504 => 'Gateway Timeout', 58 | 505 => 'HTTP Version Not Supported', 59 | 506 => 'Variant Also Negotiates', 60 | 507 => 'Insufficient Storage', #WebDAV 61 | 509 => 'Bandwidth Limit Exceeded', #Apache 62 | 510 => 'Not Extended'} 63 | 64 | # Compatibility : make the Response act like a Net::HTTPResponse when needed 65 | module ResponseForException 66 | def method_missing symbol, *args 67 | if net_http_res.respond_to? symbol 68 | warn "[warning] The response contained in an RestClient::Exception is now a RestClient::Response instead of a Net::HTTPResponse, please update your code" 69 | net_http_res.send symbol, *args 70 | else 71 | super 72 | end 73 | end 74 | end 75 | 76 | # This is the base RestClient exception class. Rescue it if you want to 77 | # catch any exception that your request might raise 78 | # You can get the status code by e.http_code, or see anything about the 79 | # response via e.response. 80 | # For example, the entire result body (which is 81 | # probably an HTML error page) is e.response. 82 | class Exception < RuntimeError 83 | attr_accessor :response 84 | attr_writer :message 85 | 86 | def initialize response = nil, initial_response_code = nil 87 | @response = response 88 | @message = nil 89 | @initial_response_code = initial_response_code 90 | 91 | # compatibility: this make the exception behave like a Net::HTTPResponse 92 | response.extend ResponseForException if response 93 | end 94 | 95 | def http_code 96 | # return integer for compatibility 97 | if @response 98 | @response.code.to_i 99 | else 100 | @initial_response_code 101 | end 102 | end 103 | 104 | def http_body 105 | @response.body if @response 106 | end 107 | 108 | def inspect 109 | "#{message}: #{http_body}" 110 | end 111 | 112 | def to_s 113 | inspect 114 | end 115 | 116 | def message 117 | @message || self.class.name 118 | end 119 | 120 | end 121 | 122 | # Compatibility 123 | class ExceptionWithResponse < Exception 124 | end 125 | 126 | # The request failed with an error code not managed by the code 127 | class RequestFailed < ExceptionWithResponse 128 | 129 | def message 130 | "HTTP status code #{http_code}" 131 | end 132 | 133 | def to_s 134 | message 135 | end 136 | end 137 | 138 | # We will a create an exception for each status code, see http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html 139 | module Exceptions 140 | # Map http status codes to the corresponding exception class 141 | EXCEPTIONS_MAP = {} 142 | end 143 | 144 | STATUSES.each_pair do |code, message| 145 | 146 | # Compatibility 147 | superclass = ([304, 401, 404].include? code) ? ExceptionWithResponse : RequestFailed 148 | klass = Class.new(superclass) do 149 | send(:define_method, :message) {"#{http_code ? "#{http_code} " : ''}#{message}"} 150 | end 151 | klass_constant = const_set message.delete(' \-\''), klass 152 | Exceptions::EXCEPTIONS_MAP[code] = klass_constant 153 | end 154 | 155 | # A redirect was encountered; caught by execute to retry with the new url. 156 | class Redirect < Exception 157 | 158 | message = 'Redirect' 159 | 160 | attr_accessor :url 161 | 162 | def initialize(url) 163 | @url = url 164 | end 165 | end 166 | 167 | class MaxRedirectsReached < Exception 168 | message = 'Maximum number of redirect reached' 169 | end 170 | 171 | # The server broke the connection prior to the request completing. Usually 172 | # this means it crashed, or sometimes that your network connection was 173 | # severed before it could complete. 174 | class ServerBrokeConnection < Exception 175 | def initialize(message = 'Server broke connection') 176 | super nil, nil 177 | self.message = message 178 | end 179 | end 180 | 181 | class SSLCertificateNotVerified < Exception 182 | def initialize(message) 183 | super nil, nil 184 | self.message = message 185 | end 186 | end 187 | end 188 | 189 | # backwards compatibility 190 | class RestClient::Request 191 | Redirect = RestClient::Redirect 192 | Unauthorized = RestClient::Unauthorized 193 | RequestFailed = RestClient::RequestFailed 194 | end 195 | -------------------------------------------------------------------------------- /history.md: -------------------------------------------------------------------------------- 1 | # 1.6.7 2 | 3 | - rebuild with 1.8.7 to avoid https://github.com/rubygems/rubygems/pull/57 4 | 5 | # 1.6.6 6 | 7 | - 1.6.5 was yanked 8 | 9 | # 1.6.5 10 | 11 | - RFC6265 requires single SP after ';' for separating parameters pairs in the 'Cookie:' header (patch provided by Hiroshi Nakamura) 12 | - enable url parameters for all actions 13 | - detect file parameters in arrays 14 | - allow disabling the timeouts by passing -1 (patch provided by Sven Böhm) 15 | 16 | # 1.6.4 17 | 18 | - fix restclient script compatibility with 1.9.2 19 | - fix unlinking temp file (patch provided by Evan Smith) 20 | - monkeypatching ruby for http patch method (patch provided by Syl Turner) 21 | 22 | # 1.6.3 23 | 24 | - 1.6.2 was yanked 25 | 26 | # 1.6.2 27 | 28 | - add support for HEAD in resources (patch provided by tpresa) 29 | - fix shell for 1.9.2 30 | - workaround when some gem monkeypatch net/http (patch provided by Ian Warshak) 31 | - DELETE requests should process parameters just like GET and HEAD 32 | - adding :block_response parameter for manual processing 33 | - limit number of redirections (patch provided by Chris Dinn) 34 | - close and unlink the temp file created by playload (patch provided by Chris Green) 35 | - make gemspec Rubygems 1.8 compatible (patch provided by David Backeus) 36 | - added RestClient.reset_before_execution_procs (patch provided by Cloudify) 37 | - added PATCH method (patch provided by Jeff Remer) 38 | - hack for HTTP servers that use raw DEFLATE compression, see http://www.ruby-forum.com/topic/136825 (path provided by James Reeves) 39 | 40 | # 1.6.1 41 | 42 | - add response body in Exception#inspect 43 | - add support for RestClient.options 44 | - fix tests for 1.9.2 (patch provided by Niko Dittmann) 45 | - block passing in Resource#[] (patch provided by Niko Dittmann) 46 | - cookies set in a response should be kept in a redirect 47 | - HEAD requests should process parameters just like GET (patch provided by Rob Eanes) 48 | - exception message should never be nil (patch provided by Michael Klett) 49 | 50 | # 1.6.0 51 | 52 | - forgot to include rest-client.rb in the gem 53 | - user, password and user-defined headers should survive a redirect 54 | - added all missing status codes 55 | - added parameter passing for get request using the :param key in header 56 | - the warning about the logger when using a string was a bad idea 57 | - multipart parameters names should not be escaped 58 | - remove the cookie escaping introduced by migrating to CGI cookie parsing in 1.5.1 59 | - add a streamed payload type (patch provided by Caleb Land) 60 | - Exception#http_body works even when no response 61 | 62 | # 1.5.1 63 | 64 | - only converts headers keys which are Symbols 65 | - use CGI for cookie parsing instead of custom code 66 | - unescape user and password before using them (patch provided by Lars Gierth) 67 | - expand ~ in ~/.restclientrc (patch provided by Mike Fletcher) 68 | - ssl verification raise an exception when the ca certificate is incorrect (patch provided by Braintree) 69 | 70 | # 1.5.0 71 | 72 | - the response is now a String with the Response module a.k.a. the change in 1.4.0 was a mistake (Response.body is returning self for compatability) 73 | - added AbstractResponse.to_i to improve semantic 74 | - multipart Payloads ignores the name attribute if it's not set (patch provided by Tekin Suleyman) 75 | - correctly takes into account user headers whose keys are strings (path provided by Cyril Rohr) 76 | - use binary mode for payload temp file 77 | - concatenate cookies with ';' 78 | - fixed deeper parameter handling 79 | - do not quote the boundary in the Content-Type header (patch provided by W. Andrew Loe III) 80 | 81 | # 1.4.2 82 | 83 | - fixed RestClient.add_before_execution_proc (patch provided by Nicholas Wieland) 84 | - fixed error when an exception is raised without a response (patch provided by Caleb Land) 85 | 86 | # 1.4.1 87 | 88 | - fixed parameters managment when using hash 89 | 90 | # 1.4.0 91 | 92 | - Response is no more a String, and the mixin is replaced by an abstract_response, existing calls are redirected to response body with a warning. 93 | - enable repeated parameters RestClient.post 'http://example.com/resource', :param1 => ['one', 'two', 'three'], => :param2 => 'foo' (patch provided by Rodrigo Panachi) 94 | - fixed the redirect code concerning relative path and query string combination (patch provided by Kevin Read) 95 | - redirection code moved to Response so redirection can be customized using the block syntax 96 | - only get and head redirections are now followed by default, as stated in the specification 97 | - added RestClient.add_before_execution_proc to hack the http request, like for oauth 98 | 99 | The response change may be breaking in rare cases. 100 | 101 | # 1.3.1 102 | 103 | - added compatibility to enable responses in exception to act like Net::HTTPResponse 104 | 105 | # 1.3.0 106 | 107 | - a block can be used to process a request's result, this enable to handle custom error codes or paththrought (design by Cyril Rohr) 108 | - cleaner log API, add a warning for some cases but should be compatible 109 | - accept multiple "Set-Cookie" headers, see http://www.ietf.org/rfc/rfc2109.txt (patch provided by Cyril Rohr) 110 | - remove "Content-Length" and "Content-Type" headers when following a redirection (patch provided by haarts) 111 | - all http error codes have now a corresponding exception class and all of them contain the Reponse -> this means that the raised exception can be different 112 | - changed "Content-Disposition: multipart/form-data" to "Content-Disposition: form-data" per RFC 2388 (patch provided by Kyle Crawford) 113 | 114 | The only breaking change should be the exception classes, but as the new classes inherits from the existing ones, the breaking cases should be rare. 115 | 116 | # 1.2.0 117 | 118 | - formatting changed from tabs to spaces 119 | - logged requests now include generated headers 120 | - accept and content-type headers can now be specified using extentions: RestClient.post "http://example.com/resource", { 'x' => 1 }.to_json, :content_type => :json, :accept => :json 121 | - should be 1.1.1 but renamed to 1.2.0 because 1.1.X versions has already been packaged on Debian 122 | 123 | # 1.1.0 124 | 125 | - new maintainer: Archiloque, the working repo is now at http://github.com/archiloque/rest-client 126 | - a mailing list has been created at rest.client@librelist.com and an freenode irc channel #rest-client 127 | - François Beausoleil' multipart code from http://github.com/francois/rest-client has been merged 128 | - ability to use hash in hash as payload 129 | - the mime-type code now rely on the mime-types gem http://mime-types.rubyforge.org/ instead of an internal partial list 130 | - 204 response returns a Response instead of nil (patch provided by Elliott Draper) 131 | 132 | All changes exept the last one should be fully compatible with the previous version. 133 | 134 | NOTE: due to a dependency problem and to the last change, heroku users should update their heroku gem to >= 1.5.3 to be able to use this version. -------------------------------------------------------------------------------- /spec/payload_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: binary 2 | require File.join(File.dirname(File.expand_path(__FILE__)), 'base') 3 | 4 | describe RestClient::Payload do 5 | context "A regular Payload" do 6 | it "should use standard enctype as default content-type" do 7 | RestClient::Payload::UrlEncoded.new({}).headers['Content-Type']. 8 | should == 'application/x-www-form-urlencoded' 9 | end 10 | 11 | it "should form properly encoded params" do 12 | RestClient::Payload::UrlEncoded.new({:foo => 'bar'}).to_s. 13 | should == "foo=bar" 14 | ["foo=bar&baz=qux", "baz=qux&foo=bar"].should include( 15 | RestClient::Payload::UrlEncoded.new({:foo => 'bar', :baz => 'qux'}).to_s) 16 | end 17 | 18 | it "should escape parameters" do 19 | RestClient::Payload::UrlEncoded.new({'foo ' => 'bar'}).to_s. 20 | should == "foo%20=bar" 21 | end 22 | 23 | it "should properly handle hashes as parameter" do 24 | RestClient::Payload::UrlEncoded.new({:foo => {:bar => 'baz'}}).to_s. 25 | should == "foo[bar]=baz" 26 | RestClient::Payload::UrlEncoded.new({:foo => {:bar => {:baz => 'qux'}}}).to_s. 27 | should == "foo[bar][baz]=qux" 28 | end 29 | 30 | it "should handle many attributes inside a hash" do 31 | parameters = RestClient::Payload::UrlEncoded.new({:foo => {:bar => 'baz', :baz => 'qux'}}).to_s 32 | parameters.should include("foo[bar]=baz", "foo[baz]=qux") 33 | end 34 | 35 | it "should handle attributes inside a an array inside an hash" do 36 | parameters = RestClient::Payload::UrlEncoded.new({"foo" => [{"bar" => 'baz'}, {"bar" => 'qux'}]}).to_s 37 | parameters.should include("foo[bar]=baz", "foo[bar]=qux") 38 | end 39 | 40 | it "should handle attributes inside a an array inside an array inside an hash" do 41 | parameters = RestClient::Payload::UrlEncoded.new({"foo" => [[{"bar" => 'baz'}, {"bar" => 'qux'}]]}).to_s 42 | parameters.should include("foo[bar]=baz", "foo[bar]=qux") 43 | end 44 | 45 | it "should form properly use symbols as parameters" do 46 | RestClient::Payload::UrlEncoded.new({:foo => :bar}).to_s. 47 | should == "foo=bar" 48 | RestClient::Payload::UrlEncoded.new({:foo => {:bar => :baz}}).to_s. 49 | should == "foo[bar]=baz" 50 | end 51 | 52 | it "should properly handle arrays as repeated parameters" do 53 | RestClient::Payload::UrlEncoded.new({:foo => ['bar']}).to_s. 54 | should == "foo[]=bar" 55 | RestClient::Payload::UrlEncoded.new({:foo => ['bar', 'baz']}).to_s. 56 | should == "foo[]=bar&foo[]=baz" 57 | end 58 | 59 | it 'should not close if stream already closed' do 60 | p = RestClient::Payload::UrlEncoded.new({'foo ' => 'bar'}) 61 | 3.times {p.close} 62 | end 63 | 64 | end 65 | 66 | context "A multipart Payload" do 67 | it "should use standard enctype as default content-type" do 68 | m = RestClient::Payload::Multipart.new({}) 69 | m.stub!(:boundary).and_return(123) 70 | m.headers['Content-Type'].should == 'multipart/form-data; boundary=123' 71 | end 72 | 73 | it 'should not error on close if stream already closed' do 74 | m = RestClient::Payload::Multipart.new(:file => File.new(File.join(File.dirname(File.expand_path(__FILE__)), 'master_shake.jpg'))) 75 | 3.times {m.close} 76 | end 77 | 78 | it "should form properly separated multipart data" do 79 | m = RestClient::Payload::Multipart.new([[:bar, "baz"], [:foo, "bar"]]) 80 | m.to_s.should == <<-EOS 81 | --#{m.boundary}\r 82 | Content-Disposition: form-data; name="bar"\r 83 | \r 84 | baz\r 85 | --#{m.boundary}\r 86 | Content-Disposition: form-data; name="foo"\r 87 | \r 88 | bar\r 89 | --#{m.boundary}--\r 90 | EOS 91 | end 92 | 93 | it "should not escape parameters names" do 94 | m = RestClient::Payload::Multipart.new([["bar ", "baz"]]) 95 | m.to_s.should == <<-EOS 96 | --#{m.boundary}\r 97 | Content-Disposition: form-data; name="bar "\r 98 | \r 99 | baz\r 100 | --#{m.boundary}--\r 101 | EOS 102 | end 103 | 104 | it "should form properly separated multipart data" do 105 | f = File.new(File.dirname(__FILE__) + "/master_shake.jpg") 106 | m = RestClient::Payload::Multipart.new({:foo => f}) 107 | m.to_s.should == <<-EOS 108 | --#{m.boundary}\r 109 | Content-Disposition: form-data; name="foo"; filename="master_shake.jpg"\r 110 | Content-Type: image/jpeg\r 111 | \r 112 | #{File.open(f.path, 'rb'){|bin| bin.read}}\r 113 | --#{m.boundary}--\r 114 | EOS 115 | end 116 | 117 | it "should ignore the name attribute when it's not set" do 118 | f = File.new(File.dirname(__FILE__) + "/master_shake.jpg") 119 | m = RestClient::Payload::Multipart.new({nil => f}) 120 | m.to_s.should == <<-EOS 121 | --#{m.boundary}\r 122 | Content-Disposition: form-data; filename="master_shake.jpg"\r 123 | Content-Type: image/jpeg\r 124 | \r 125 | #{File.open(f.path, 'rb'){|bin| bin.read}}\r 126 | --#{m.boundary}--\r 127 | EOS 128 | end 129 | 130 | it "should detect optional (original) content type and filename" do 131 | f = File.new(File.dirname(__FILE__) + "/master_shake.jpg") 132 | f.instance_eval "def content_type; 'text/plain'; end" 133 | f.instance_eval "def original_filename; 'foo.txt'; end" 134 | m = RestClient::Payload::Multipart.new({:foo => f}) 135 | m.to_s.should == <<-EOS 136 | --#{m.boundary}\r 137 | Content-Disposition: form-data; name="foo"; filename="foo.txt"\r 138 | Content-Type: text/plain\r 139 | \r 140 | #{File.open(f.path, 'rb'){|bin| bin.read}}\r 141 | --#{m.boundary}--\r 142 | EOS 143 | end 144 | 145 | it "should handle hash in hash parameters" do 146 | m = RestClient::Payload::Multipart.new({:bar => {:baz => "foo"}}) 147 | m.to_s.should == <<-EOS 148 | --#{m.boundary}\r 149 | Content-Disposition: form-data; name="bar[baz]"\r 150 | \r 151 | foo\r 152 | --#{m.boundary}--\r 153 | EOS 154 | 155 | f = File.new(File.dirname(__FILE__) + "/master_shake.jpg") 156 | f.instance_eval "def content_type; 'text/plain'; end" 157 | f.instance_eval "def original_filename; 'foo.txt'; end" 158 | m = RestClient::Payload::Multipart.new({:foo => {:bar => f}}) 159 | m.to_s.should == <<-EOS 160 | --#{m.boundary}\r 161 | Content-Disposition: form-data; name="foo[bar]"; filename="foo.txt"\r 162 | Content-Type: text/plain\r 163 | \r 164 | #{File.open(f.path, 'rb'){|bin| bin.read}}\r 165 | --#{m.boundary}--\r 166 | EOS 167 | end 168 | 169 | end 170 | 171 | context "streamed payloads" do 172 | it "should properly determine the size of file payloads" do 173 | f = File.new(File.dirname(__FILE__) + "/master_shake.jpg") 174 | payload = RestClient::Payload.generate(f) 175 | payload.size.should == 76_988 176 | payload.length.should == 76_988 177 | end 178 | 179 | it "should properly determine the size of other kinds of streaming payloads" do 180 | s = StringIO.new 'foo' 181 | payload = RestClient::Payload.generate(s) 182 | payload.size.should == 3 183 | payload.length.should == 3 184 | 185 | begin 186 | f = Tempfile.new "rest-client" 187 | f.write 'foo bar' 188 | 189 | payload = RestClient::Payload.generate(f) 190 | payload.size.should == 7 191 | payload.length.should == 7 192 | ensure 193 | f.close 194 | end 195 | end 196 | end 197 | 198 | context "Payload generation" do 199 | it "should recognize standard urlencoded params" do 200 | RestClient::Payload.generate({"foo" => 'bar'}).should be_kind_of(RestClient::Payload::UrlEncoded) 201 | end 202 | 203 | it "should recognize multipart params" do 204 | f = File.new(File.dirname(__FILE__) + "/master_shake.jpg") 205 | RestClient::Payload.generate({"foo" => f}).should be_kind_of(RestClient::Payload::Multipart) 206 | end 207 | 208 | it "should be multipart if forced" do 209 | RestClient::Payload.generate({"foo" => "bar", :multipart => true}).should be_kind_of(RestClient::Payload::Multipart) 210 | end 211 | 212 | it "should return data if no of the above" do 213 | RestClient::Payload.generate("data").should be_kind_of(RestClient::Payload::Base) 214 | end 215 | 216 | it "should recognize nested multipart payloads in hashes" do 217 | f = File.new(File.dirname(__FILE__) + "/master_shake.jpg") 218 | RestClient::Payload.generate({"foo" => {"file" => f}}).should be_kind_of(RestClient::Payload::Multipart) 219 | end 220 | 221 | it "should recognize nested multipart payloads in arrays" do 222 | f = File.new(File.dirname(__FILE__) + "/master_shake.jpg") 223 | RestClient::Payload.generate({"foo" => [f]}).should be_kind_of(RestClient::Payload::Multipart) 224 | end 225 | 226 | it "should recognize file payloads that can be streamed" do 227 | f = File.new(File.dirname(__FILE__) + "/master_shake.jpg") 228 | RestClient::Payload.generate(f).should be_kind_of(RestClient::Payload::Streamed) 229 | end 230 | 231 | it "should recognize other payloads that can be streamed" do 232 | RestClient::Payload.generate(StringIO.new('foo')).should be_kind_of(RestClient::Payload::Streamed) 233 | end 234 | 235 | # hashery gem introduces Hash#read convenience method. Existence of #read method used to determine of content is streameable :/ 236 | it "shouldn't treat hashes as streameable" do 237 | RestClient::Payload.generate({"foo" => 'bar'}).should be_kind_of(RestClient::Payload::UrlEncoded) 238 | end 239 | end 240 | 241 | class HashMapForTesting < Hash 242 | alias :read :[] 243 | end 244 | end 245 | -------------------------------------------------------------------------------- /spec/response_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join( File.dirname(File.expand_path(__FILE__)), 'base') 2 | 3 | require 'webmock/rspec' 4 | include WebMock::API 5 | 6 | describe RestClient::Response do 7 | before do 8 | @net_http_res = mock('net http response', :to_hash => {"Status" => ["200 OK"]}, :code => 200) 9 | @request = mock('http request', :user => nil, :password => nil) 10 | @response = RestClient::Response.create('abc', @net_http_res, {}) 11 | end 12 | 13 | it "behaves like string" do 14 | @response.should.to_s == 'abc' 15 | @response.to_str.should == 'abc' 16 | @response.to_i.should == 200 17 | end 18 | 19 | it "accepts nil strings and sets it to empty for the case of HEAD" do 20 | RestClient::Response.create(nil, @net_http_res, {}).should.to_s == "" 21 | end 22 | 23 | it "test headers and raw headers" do 24 | @response.raw_headers["Status"][0].should == "200 OK" 25 | @response.headers[:status].should == "200 OK" 26 | end 27 | 28 | describe "cookie processing" do 29 | it "should correctly deal with one Set-Cookie header with one cookie inside" do 30 | net_http_res = mock('net http response', :to_hash => {"etag" => ["\"e1ac1a2df945942ef4cac8116366baad\""], "set-cookie" => ["main_page=main_page_no_rewrite; path=/; expires=Tue, 20-Jan-2015 15:03:14 GMT"]}) 31 | response = RestClient::Response.create('abc', net_http_res, {}) 32 | response.headers[:set_cookie].should == ["main_page=main_page_no_rewrite; path=/; expires=Tue, 20-Jan-2015 15:03:14 GMT"] 33 | response.cookies.should == { "main_page" => "main_page_no_rewrite" } 34 | end 35 | 36 | it "should correctly deal with multiple cookies [multiple Set-Cookie headers]" do 37 | net_http_res = mock('net http response', :to_hash => {"etag" => ["\"e1ac1a2df945942ef4cac8116366baad\""], "set-cookie" => ["main_page=main_page_no_rewrite; path=/; expires=Tue, 20-Jan-2015 15:03:14 GMT", "remember_me=; path=/; expires=Thu, 01-Jan-1970 00:00:00 GMT", "user=somebody; path=/; expires=Thu, 01-Jan-1970 00:00:00 GMT"]}) 38 | response = RestClient::Response.create('abc', net_http_res, {}) 39 | response.headers[:set_cookie].should == ["main_page=main_page_no_rewrite; path=/; expires=Tue, 20-Jan-2015 15:03:14 GMT", "remember_me=; path=/; expires=Thu, 01-Jan-1970 00:00:00 GMT", "user=somebody; path=/; expires=Thu, 01-Jan-1970 00:00:00 GMT"] 40 | response.cookies.should == { 41 | "main_page" => "main_page_no_rewrite", 42 | "remember_me" => "", 43 | "user" => "somebody" 44 | } 45 | end 46 | 47 | it "should correctly deal with multiple cookies [one Set-Cookie header with multiple cookies]" do 48 | net_http_res = mock('net http response', :to_hash => {"etag" => ["\"e1ac1a2df945942ef4cac8116366baad\""], "set-cookie" => ["main_page=main_page_no_rewrite; path=/; expires=Tue, 20-Jan-2015 15:03:14 GMT, remember_me=; path=/; expires=Thu, 01-Jan-1970 00:00:00 GMT, user=somebody; path=/; expires=Thu, 01-Jan-1970 00:00:00 GMT"]}) 49 | response = RestClient::Response.create('abc', net_http_res, {}) 50 | response.cookies.should == { 51 | "main_page" => "main_page_no_rewrite", 52 | "remember_me" => "", 53 | "user" => "somebody" 54 | } 55 | end 56 | end 57 | 58 | describe "exceptions processing" do 59 | it "should return itself for normal codes" do 60 | (200..206).each do |code| 61 | net_http_res = mock('net http response', :code => '200') 62 | response = RestClient::Response.create('abc', net_http_res, {}) 63 | response.return! @request 64 | end 65 | end 66 | 67 | it "should throw an exception for other codes" do 68 | RestClient::Exceptions::EXCEPTIONS_MAP.each_key do |code| 69 | unless (200..207).include? code 70 | net_http_res = mock('net http response', :code => code.to_i) 71 | response = RestClient::Response.create('abc', net_http_res, {}) 72 | lambda { response.return!}.should raise_error 73 | end 74 | end 75 | end 76 | 77 | end 78 | 79 | describe "redirection" do 80 | 81 | it "follows a redirection when the request is a get" do 82 | stub_request(:get, 'http://some/resource').to_return(:body => '', :status => 301, :headers => {'Location' => 'http://new/resource'}) 83 | stub_request(:get, 'http://new/resource').to_return(:body => 'Foo') 84 | RestClient::Request.execute(:url => 'http://some/resource', :method => :get).body.should == 'Foo' 85 | end 86 | 87 | it "follows a redirection and keep the parameters" do 88 | stub_request(:get, 'http://foo:bar@some/resource').with(:headers => {'Accept' => 'application/json'}).to_return(:body => '', :status => 301, :headers => {'Location' => 'http://new/resource'}) 89 | stub_request(:get, 'http://foo:bar@new/resource').with(:headers => {'Accept' => 'application/json'}).to_return(:body => 'Foo') 90 | RestClient::Request.execute(:url => 'http://some/resource', :method => :get, :user => 'foo', :password => 'bar', :headers => {:accept => :json}).body.should == 'Foo' 91 | end 92 | 93 | it "follows a redirection and keep the cookies" do 94 | stub_request(:get, 'http://some/resource').to_return(:body => '', :status => 301, :headers => {'Set-Cookie' => 'Foo=Bar', 'Location' => 'http://new/resource', }) 95 | stub_request(:get, 'http://new/resource').with(:headers => {'Cookie' => 'Foo=Bar'}).to_return(:body => 'Qux') 96 | RestClient::Request.execute(:url => 'http://some/resource', :method => :get).body.should == 'Qux' 97 | end 98 | 99 | it "doesn't follow a 301 when the request is a post" do 100 | net_http_res = mock('net http response', :code => 301) 101 | response = RestClient::Response.create('abc', net_http_res, {:method => :post}) 102 | lambda { response.return!(@request)}.should raise_error(RestClient::MovedPermanently) 103 | end 104 | 105 | it "doesn't follow a 302 when the request is a post" do 106 | net_http_res = mock('net http response', :code => 302) 107 | response = RestClient::Response.create('abc', net_http_res, {:method => :post}) 108 | lambda { response.return!(@request)}.should raise_error(RestClient::Found) 109 | end 110 | 111 | it "doesn't follow a 307 when the request is a post" do 112 | net_http_res = mock('net http response', :code => 307) 113 | response = RestClient::Response.create('abc', net_http_res, {:method => :post}) 114 | lambda { response.return!(@request)}.should raise_error(RestClient::TemporaryRedirect) 115 | end 116 | 117 | it "doesn't follow a redirection when the request is a put" do 118 | net_http_res = mock('net http response', :code => 301) 119 | response = RestClient::Response.create('abc', net_http_res, {:method => :put}) 120 | lambda { response.return!(@request)}.should raise_error(RestClient::MovedPermanently) 121 | end 122 | 123 | it "follows a redirection when the request is a post and result is a 303" do 124 | stub_request(:put, 'http://some/resource').to_return(:body => '', :status => 303, :headers => {'Location' => 'http://new/resource'}) 125 | stub_request(:get, 'http://new/resource').to_return(:body => 'Foo') 126 | RestClient::Request.execute(:url => 'http://some/resource', :method => :put).body.should == 'Foo' 127 | end 128 | 129 | it "follows a redirection when the request is a head" do 130 | stub_request(:head, 'http://some/resource').to_return(:body => '', :status => 301, :headers => {'Location' => 'http://new/resource'}) 131 | stub_request(:head, 'http://new/resource').to_return(:body => 'Foo') 132 | RestClient::Request.execute(:url => 'http://some/resource', :method => :head).body.should == 'Foo' 133 | end 134 | 135 | it "handles redirects with relative paths" do 136 | stub_request(:get, 'http://some/resource').to_return(:body => '', :status => 301, :headers => {'Location' => 'index'}) 137 | stub_request(:get, 'http://some/index').to_return(:body => 'Foo') 138 | RestClient::Request.execute(:url => 'http://some/resource', :method => :get).body.should == 'Foo' 139 | end 140 | 141 | it "handles redirects with relative path and query string" do 142 | stub_request(:get, 'http://some/resource').to_return(:body => '', :status => 301, :headers => {'Location' => 'index?q=1'}) 143 | stub_request(:get, 'http://some/index?q=1').to_return(:body => 'Foo') 144 | RestClient::Request.execute(:url => 'http://some/resource', :method => :get).body.should == 'Foo' 145 | end 146 | 147 | it "follow a redirection when the request is a get and the response is in the 30x range" do 148 | stub_request(:get, 'http://some/resource').to_return(:body => '', :status => 301, :headers => {'Location' => 'http://new/resource'}) 149 | stub_request(:get, 'http://new/resource').to_return(:body => 'Foo') 150 | RestClient::Request.execute(:url => 'http://some/resource', :method => :get).body.should == 'Foo' 151 | end 152 | 153 | it "follows no more than 10 redirections before raising error" do 154 | stub_request(:get, 'http://some/redirect-1').to_return(:body => '', :status => 301, :headers => {'Location' => 'http://some/redirect-2'}) 155 | stub_request(:get, 'http://some/redirect-2').to_return(:body => '', :status => 301, :headers => {'Location' => 'http://some/redirect-2'}) 156 | lambda { RestClient::Request.execute(:url => 'http://some/redirect-1', :method => :get) }.should raise_error(RestClient::MaxRedirectsReached) 157 | WebMock.should have_requested(:get, 'http://some/redirect-2').times(10) 158 | end 159 | 160 | it "follows no more than max_redirects redirections, if specified" do 161 | stub_request(:get, 'http://some/redirect-1').to_return(:body => '', :status => 301, :headers => {'Location' => 'http://some/redirect-2'}) 162 | stub_request(:get, 'http://some/redirect-2').to_return(:body => '', :status => 301, :headers => {'Location' => 'http://some/redirect-2'}) 163 | lambda { RestClient::Request.execute(:url => 'http://some/redirect-1', :method => :get, :max_redirects => 5) }.should raise_error(RestClient::MaxRedirectsReached) 164 | WebMock.should have_requested(:get, 'http://some/redirect-2').times(5) 165 | end 166 | end 167 | 168 | 169 | end 170 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | = REST Client -- simple DSL for accessing HTTP and REST resources 2 | 3 | Build status: {}[https://travis-ci.org/rest-client/rest-client] 4 | 5 | 6 | A simple HTTP and REST client for Ruby, inspired by the Sinatra's microframework style 7 | of specifying actions: get, put, post, delete. 8 | 9 | * Main page: http://github.com/rest-client/rest-client 10 | * Mailing list: rest.client@librelist.com (send a mail to subscribe). 11 | 12 | == Usage: Raw URL 13 | 14 | require 'rest_client' 15 | 16 | RestClient.get 'http://example.com/resource' 17 | 18 | RestClient.get 'http://example.com/resource', {:params => {:id => 50, 'foo' => 'bar'}} 19 | 20 | RestClient.get 'https://user:password@example.com/private/resource', {:accept => :json} 21 | 22 | RestClient.post 'http://example.com/resource', :param1 => 'one', :nested => { :param2 => 'two' } 23 | 24 | RestClient.post "http://example.com/resource", { 'x' => 1 }.to_json, :content_type => :json, :accept => :json 25 | 26 | RestClient.delete 'http://example.com/resource' 27 | 28 | response = RestClient.get 'http://example.com/resource' 29 | response.code 30 | ➔ 200 31 | response.cookies 32 | ➔ {"Foo"=>"BAR", "QUUX"=>"QUUUUX"} 33 | response.headers 34 | ➔ {:content_type=>"text/html; charset=utf-8", :cache_control=>"private" ... 35 | response.to_str 36 | ➔ \n\n\n { 41 | :path => '/foo/bar', 42 | :owner => 'that_guy', 43 | :group => 'those_guys' 44 | }, 45 | :upload => { 46 | :file => File.new(path, 'rb') 47 | } 48 | }) 49 | 50 | == Multipart 51 | 52 | Yeah, that's right! This does multipart sends for you! 53 | 54 | RestClient.post '/data', :myfile => File.new("/path/to/image.jpg", 'rb') 55 | 56 | This does two things for you: 57 | 58 | * Auto-detects that you have a File value sends it as multipart 59 | * Auto-detects the mime of the file and sets it in the HEAD of the payload for each entry 60 | 61 | If you are sending params that do not contain a File object but the payload needs to be multipart then: 62 | 63 | RestClient.post '/data', {:foo => 'bar', :multipart => true} 64 | 65 | == Usage: ActiveResource-Style 66 | 67 | resource = RestClient::Resource.new 'http://example.com/resource' 68 | resource.get 69 | 70 | private_resource = RestClient::Resource.new 'https://example.com/private/resource', 'user', 'pass' 71 | private_resource.put File.read('pic.jpg'), :content_type => 'image/jpg' 72 | 73 | See RestClient::Resource module docs for details. 74 | 75 | == Usage: Resource Nesting 76 | 77 | site = RestClient::Resource.new('http://example.com') 78 | site['posts/1/comments'].post 'Good article.', :content_type => 'text/plain' 79 | 80 | See RestClient::Resource docs for details. 81 | 82 | == Exceptions (see http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html) 83 | 84 | * for results code between 200 and 207 a RestClient::Response will be returned 85 | * for results code 301, 302 or 307 the redirection will be followed if the request is a get or a head 86 | * for result code 303 the redirection will be followed and the request transformed into a get 87 | * for other cases a RestClient::Exception holding the Response will be raised, a specific exception class will be thrown for know error codes 88 | 89 | RestClient.get 'http://example.com/resource' 90 | ➔ RestClient::ResourceNotFound: RestClient::ResourceNotFound 91 | 92 | begin 93 | RestClient.get 'http://example.com/resource' 94 | rescue => e 95 | e.response 96 | end 97 | ➔ 404 Resource Not Found | text/html 282 bytes 98 | 99 | == Result handling 100 | 101 | A block can be passed to the RestClient method, this block will then be called with the Response. 102 | Response.return! can be called to invoke the default response's behavior. 103 | 104 | # Don't raise exceptions but return the response 105 | RestClient.get('http://example.com/resource'){|response, request, result| response } 106 | ➔ 404 Resource Not Found | text/html 282 bytes 107 | 108 | # Manage a specific error code 109 | RestClient.get('http://my-rest-service.com/resource'){ |response, request, result, &block| 110 | case response.code 111 | when 200 112 | p "It worked !" 113 | response 114 | when 423 115 | raise SomeCustomExceptionIfYouWant 116 | else 117 | response.return!(request, result, &block) 118 | end 119 | } 120 | 121 | # Follow redirections for all request types and not only for get and head 122 | # RFC : "If the 301, 302 or 307 status code is received in response to a request other than GET or HEAD, 123 | # the user agent MUST NOT automatically redirect the request unless it can be confirmed by the user, 124 | # since this might change the conditions under which the request was issued." 125 | RestClient.get('http://my-rest-service.com/resource'){ |response, request, result, &block| 126 | if [301, 302, 307].include? response.code 127 | response.follow_redirection(request, result, &block) 128 | else 129 | response.return!(request, result, &block) 130 | end 131 | } 132 | 133 | == Non-normalized URIs. 134 | 135 | If you want to use non-normalized URIs, you can normalize them with the addressable gem (http://addressable.rubyforge.org/api/). 136 | 137 | require 'addressable/uri' 138 | RestClient.get(Addressable::URI.parse("http://www.詹姆斯.com/").normalize.to_str) 139 | 140 | == Lower-level access 141 | 142 | For cases not covered by the general API, you can use the RestClient::Request class which provide a lower-level API. 143 | 144 | You can: 145 | 146 | * specify ssl parameters 147 | * override cookies 148 | * manually handle the response (so you can operate on the response stream than reading it fully in memory) 149 | 150 | see the class' rdoc for more information. 151 | 152 | == Shell 153 | 154 | The restclient shell command gives an IRB session with RestClient already loaded: 155 | 156 | $ restclient 157 | >> RestClient.get 'http://example.com' 158 | 159 | Specify a URL argument for get/post/put/delete on that resource: 160 | 161 | $ restclient http://example.com 162 | >> put '/resource', 'data' 163 | 164 | Add a user and password for authenticated resources: 165 | 166 | $ restclient https://example.com user pass 167 | >> delete '/private/resource' 168 | 169 | Create ~/.restclient for named sessions: 170 | 171 | sinatra: 172 | url: http://localhost:4567 173 | rack: 174 | url: http://localhost:9292 175 | private_site: 176 | url: http://example.com 177 | username: user 178 | password: pass 179 | 180 | Then invoke: 181 | 182 | $ restclient private_site 183 | 184 | Use as a one-off, curl-style: 185 | 186 | $ restclient get http://example.com/resource > output_body 187 | 188 | $ restclient put http://example.com/resource < input_body 189 | 190 | == Logging 191 | 192 | To enable logging you can 193 | 194 | * set RestClient.log with a ruby Logger 195 | * or set an environment variable to avoid modifying the code (in this case you can use a file name, "stdout" or "stderr"): 196 | 197 | $ RESTCLIENT_LOG=stdout path/to/my/program 198 | 199 | Either produces logs like this: 200 | 201 | RestClient.get "http://some/resource" 202 | # => 200 OK | text/html 250 bytes 203 | RestClient.put "http://some/resource", "payload" 204 | # => 401 Unauthorized | application/xml 340 bytes 205 | 206 | Note that these logs are valid Ruby, so you can paste them into the restclient 207 | shell or a script to replay your sequence of rest calls. 208 | 209 | == Proxy 210 | 211 | All calls to RestClient, including Resources, will use the proxy specified by 212 | RestClient.proxy: 213 | 214 | RestClient.proxy = "http://proxy.example.com/" 215 | RestClient.get "http://some/resource" 216 | # => response from some/resource as proxied through proxy.example.com 217 | 218 | Often the proxy url is set in an environment variable, so you can do this to 219 | use whatever proxy the system is configured to use: 220 | 221 | RestClient.proxy = ENV['http_proxy'] 222 | 223 | == Query parameters 224 | 225 | Request objects know about query parameters and will automatically add them to 226 | the url for GET, HEAD and DELETE requests and escape the keys and values as 227 | needed: 228 | 229 | RestClient.get 'http://example.com/resource', :params => {:foo => 'bar', :baz => 'qux'} 230 | # will GET http://example.com/resource?foo=bar&baz=qux 231 | 232 | == Cookies 233 | 234 | Request and Response objects know about HTTP cookies, and will automatically 235 | extract and set headers for them as needed: 236 | 237 | response = RestClient.get 'http://example.com/action_which_sets_session_id' 238 | response.cookies 239 | # => {"_applicatioN_session_id" => "1234"} 240 | 241 | response2 = RestClient.post( 242 | 'http://localhost:3000/', 243 | {:param1 => "foo"}, 244 | {:cookies => {:session_id => "1234"}} 245 | ) 246 | # ...response body 247 | 248 | == SSL Client Certificates 249 | 250 | RestClient::Resource.new( 251 | 'https://example.com', 252 | :ssl_client_cert => OpenSSL::X509::Certificate.new(File.read("cert.pem")), 253 | :ssl_client_key => OpenSSL::PKey::RSA.new(File.read("key.pem"), "passphrase, if any"), 254 | :ssl_ca_file => "ca_certificate.pem", 255 | :verify_ssl => OpenSSL::SSL::VERIFY_PEER 256 | ).get 257 | 258 | Self-signed certificates can be generated with the openssl command-line tool. 259 | 260 | == Hook 261 | 262 | RestClient.add_before_execution_proc add a Proc to be called before each execution, it's handy if you need a direct access to the http request. 263 | 264 | Example: 265 | 266 | # Add oath support using the oauth gem 267 | require 'oauth' 268 | access_token = ... 269 | 270 | RestClient.add_before_execution_proc do |req, params| 271 | access_token.sign! req 272 | end 273 | 274 | RestClient.get 'http://example.com' 275 | 276 | == More 277 | 278 | Need caching, more advanced logging or any ability provided by a rack middleware ? 279 | 280 | Have a look at rest-client-components http://github.com/crohr/rest-client-components 281 | 282 | == Credits 283 | 284 | REST Client Team:: Matthew Manning, Lawrence Leonard Gilbert 285 | 286 | Creator:: Adam Wiggins 287 | 288 | Maintainer Emeritus:: Julien Kirch 289 | 290 | Major contributions:: Blake Mizerany, Julien Kirch 291 | 292 | Patches contributed by many, including Chris Anderson, Greg Borenstein, Ardekantur, Pedro Belo, Rafael Souza, Rick Olson, Aman Gupta, François Beausoleil and Nick Plante. 293 | 294 | == Legal 295 | 296 | Released under the MIT License: http://www.opensource.org/licenses/mit-license.php 297 | 298 | "Master Shake" photo (http://www.flickr.com/photos/solgrundy/924205581/) by 299 | "SolGrundy"; used under terms of the Creative Commons Attribution-ShareAlike 2.0 300 | Generic license (http://creativecommons.org/licenses/by-sa/2.0/) 301 | -------------------------------------------------------------------------------- /lib/restclient/request.rb: -------------------------------------------------------------------------------- 1 | require 'tempfile' 2 | require 'mime/types' 3 | require 'cgi' 4 | require 'netrc' 5 | 6 | module RestClient 7 | # This class is used internally by RestClient to send the request, but you can also 8 | # call it directly if you'd like to use a method not supported by the 9 | # main API. For example: 10 | # 11 | # RestClient::Request.execute(:method => :head, :url => 'http://example.com') 12 | # 13 | # Mandatory parameters: 14 | # * :method 15 | # * :url 16 | # Optional parameters (have a look at ssl and/or uri for some explanations): 17 | # * :headers a hash containing the request headers 18 | # * :cookies will replace possible cookies in the :headers 19 | # * :user and :password for basic auth, will be replaced by a user/password available in the :url 20 | # * :block_response call the provided block with the HTTPResponse as parameter 21 | # * :raw_response return a low-level RawResponse instead of a Response 22 | # * :max_redirects maximum number of redirections (default to 10) 23 | # * :verify_ssl enable ssl verification, possible values are constants from OpenSSL::SSL 24 | # * :timeout and :open_timeout passing in -1 will disable the timeout by setting the corresponding net timeout values to nil 25 | # * :ssl_client_cert, :ssl_client_key, :ssl_ca_file 26 | # * :ssl_version specifies the SSL version for the underlying Net::HTTP connection (defaults to 'SSLv3') 27 | class Request 28 | 29 | attr_reader :method, :url, :headers, :cookies, 30 | :payload, :user, :password, :timeout, :max_redirects, 31 | :open_timeout, :raw_response, :verify_ssl, :ssl_client_cert, 32 | :ssl_client_key, :ssl_ca_file, :processed_headers, :args, 33 | :ssl_version 34 | 35 | def self.execute(args, & block) 36 | new(args).execute(& block) 37 | end 38 | 39 | def initialize args 40 | @method = args[:method] or raise ArgumentError, "must pass :method" 41 | @headers = args[:headers] || {} 42 | if args[:url] 43 | @url = process_url_params(args[:url], headers) 44 | else 45 | raise ArgumentError, "must pass :url" 46 | end 47 | @cookies = @headers.delete(:cookies) || args[:cookies] || {} 48 | @payload = Payload.generate(args[:payload]) 49 | @user = args[:user] 50 | @password = args[:password] 51 | @timeout = args[:timeout] 52 | @open_timeout = args[:open_timeout] 53 | @block_response = args[:block_response] 54 | @raw_response = args[:raw_response] || false 55 | @verify_ssl = args[:verify_ssl] || false 56 | @ssl_client_cert = args[:ssl_client_cert] || nil 57 | @ssl_client_key = args[:ssl_client_key] || nil 58 | @ssl_ca_file = args[:ssl_ca_file] || nil 59 | @ssl_version = args[:ssl_version] || 'SSLv3' 60 | @tf = nil # If you are a raw request, this is your tempfile 61 | @max_redirects = args[:max_redirects] || 10 62 | @processed_headers = make_headers headers 63 | @args = args 64 | end 65 | 66 | def execute & block 67 | uri = parse_url_with_auth(url) 68 | transmit uri, net_http_request_class(method).new(uri.request_uri, processed_headers), payload, & block 69 | ensure 70 | payload.close if payload 71 | end 72 | 73 | # Extract the query parameters and append them to the url 74 | def process_url_params url, headers 75 | url_params = {} 76 | headers.delete_if do |key, value| 77 | if 'params' == key.to_s.downcase && value.is_a?(Hash) 78 | url_params.merge! value 79 | true 80 | else 81 | false 82 | end 83 | end 84 | unless url_params.empty? 85 | query_string = url_params.collect { |k, v| "#{k.to_s}=#{CGI::escape(v.to_s)}" }.join('&') 86 | url + "?#{query_string}" 87 | else 88 | url 89 | end 90 | end 91 | 92 | def make_headers user_headers 93 | unless @cookies.empty? 94 | user_headers[:cookie] = @cookies.map { |(key, val)| "#{key.to_s}=#{CGI::unescape(val.to_s)}" }.sort.join('; ') 95 | end 96 | headers = stringify_headers(default_headers).merge(stringify_headers(user_headers)) 97 | headers.merge!(@payload.headers) if @payload 98 | headers 99 | end 100 | 101 | def net_http_class 102 | if RestClient.proxy 103 | proxy_uri = URI.parse(RestClient.proxy) 104 | Net::HTTP::Proxy(proxy_uri.host, proxy_uri.port, proxy_uri.user, proxy_uri.password) 105 | else 106 | Net::HTTP 107 | end 108 | end 109 | 110 | def net_http_request_class(method) 111 | Net::HTTP.const_get(method.to_s.capitalize) 112 | end 113 | 114 | def parse_url(url) 115 | url = "http://#{url}" unless url.match(/^http/) 116 | URI.parse(url) 117 | end 118 | 119 | def parse_url_with_auth(url) 120 | uri = parse_url(url) 121 | @user = CGI.unescape(uri.user) if uri.user 122 | @password = CGI.unescape(uri.password) if uri.password 123 | if !@user && !@password 124 | @user, @password = Netrc.read[uri.host] 125 | end 126 | uri 127 | end 128 | 129 | def process_payload(p=nil, parent_key=nil) 130 | unless p.is_a?(Hash) 131 | p 132 | else 133 | @headers[:content_type] ||= 'application/x-www-form-urlencoded' 134 | p.keys.map do |k| 135 | key = parent_key ? "#{parent_key}[#{k}]" : k 136 | if p[k].is_a? Hash 137 | process_payload(p[k], key) 138 | else 139 | value = URI.escape(p[k].to_s, Regexp.new("[^#{URI::PATTERN::UNRESERVED}]")) 140 | "#{key}=#{value}" 141 | end 142 | end.join("&") 143 | end 144 | end 145 | 146 | def transmit uri, req, payload, & block 147 | setup_credentials req 148 | 149 | net = net_http_class.new(uri.host, uri.port) 150 | net.use_ssl = uri.is_a?(URI::HTTPS) 151 | net.ssl_version = @ssl_version 152 | err_msg = nil 153 | if (@verify_ssl == false) || (@verify_ssl == OpenSSL::SSL::VERIFY_NONE) 154 | net.verify_mode = OpenSSL::SSL::VERIFY_NONE 155 | elsif @verify_ssl.is_a? Integer 156 | net.verify_mode = @verify_ssl 157 | net.verify_callback = lambda do |preverify_ok, ssl_context| 158 | if (!preverify_ok) || ssl_context.error != 0 159 | err_msg = "SSL Verification failed -- Preverify: #{preverify_ok}, Error: #{ssl_context.error_string} (#{ssl_context.error})" 160 | return false 161 | end 162 | true 163 | end 164 | end 165 | net.cert = @ssl_client_cert if @ssl_client_cert 166 | net.key = @ssl_client_key if @ssl_client_key 167 | net.ca_file = @ssl_ca_file if @ssl_ca_file 168 | net.read_timeout = @timeout if @timeout 169 | net.open_timeout = @open_timeout if @open_timeout 170 | 171 | # disable the timeout if the timeout value is -1 172 | net.read_timeout = nil if @timeout == -1 173 | net.open_timeout = nil if @open_timeout == -1 174 | 175 | RestClient.before_execution_procs.each do |before_proc| 176 | before_proc.call(req, args) 177 | end 178 | 179 | log_request 180 | 181 | net.start do |http| 182 | if @block_response 183 | http.request(req, payload ? payload.to_s : nil, & @block_response) 184 | else 185 | res = http.request(req, payload ? payload.to_s : nil) { |http_response| fetch_body(http_response) } 186 | log_response res 187 | process_result res, & block 188 | end 189 | end 190 | rescue OpenSSL::SSL::SSLError => e 191 | if err_msg 192 | raise SSLCertificateNotVerified.new(err_msg) 193 | else 194 | raise e 195 | end 196 | rescue EOFError 197 | raise RestClient::ServerBrokeConnection 198 | rescue Timeout::Error 199 | raise RestClient::RequestTimeout 200 | end 201 | 202 | def setup_credentials(req) 203 | req.basic_auth(user, password) if user 204 | end 205 | 206 | def fetch_body(http_response) 207 | if @raw_response 208 | # Taken from Chef, which as in turn... 209 | # Stolen from http://www.ruby-forum.com/topic/166423 210 | # Kudos to _why! 211 | @tf = Tempfile.new("rest-client") 212 | size, total = 0, http_response.header['Content-Length'].to_i 213 | http_response.read_body do |chunk| 214 | @tf.write chunk 215 | size += chunk.size 216 | if RestClient.log 217 | if size == 0 218 | RestClient.log << "#{@method} #{@url} done (0 length file\n)" 219 | elsif total == 0 220 | RestClient.log << "#{@method} #{@url} (zero content length)\n" 221 | else 222 | RestClient.log << "#{@method} #{@url} %d%% done (%d of %d)\n" % [(size * 100) / total, size, total] 223 | end 224 | end 225 | end 226 | @tf.close 227 | @tf 228 | else 229 | http_response.read_body 230 | end 231 | http_response 232 | end 233 | 234 | def process_result res, & block 235 | if @raw_response 236 | # We don't decode raw requests 237 | response = RawResponse.new(@tf, res, args) 238 | else 239 | response = Response.create(Request.decode(res['content-encoding'], res.body), res, args) 240 | end 241 | 242 | if block_given? 243 | block.call(response, self, res, & block) 244 | else 245 | response.return!(self, res, & block) 246 | end 247 | 248 | end 249 | 250 | def self.decode content_encoding, body 251 | if (!body) || body.empty? 252 | body 253 | elsif content_encoding == 'gzip' 254 | Zlib::GzipReader.new(StringIO.new(body)).read 255 | elsif content_encoding == 'deflate' 256 | begin 257 | Zlib::Inflate.new.inflate body 258 | rescue Zlib::DataError 259 | # No luck with Zlib decompression. Let's try with raw deflate, 260 | # like some broken web servers do. 261 | Zlib::Inflate.new(-Zlib::MAX_WBITS).inflate body 262 | end 263 | else 264 | body 265 | end 266 | end 267 | 268 | def log_request 269 | if RestClient.log 270 | out = [] 271 | out << "RestClient.#{method} #{url.inspect}" 272 | out << payload.short_inspect if payload 273 | out << processed_headers.to_a.sort.map { |(k, v)| [k.inspect, v.inspect].join("=>") }.join(", ") 274 | RestClient.log << out.join(', ') + "\n" 275 | end 276 | end 277 | 278 | def log_response res 279 | if RestClient.log 280 | size = @raw_response ? File.size(@tf.path) : (res.body.nil? ? 0 : res.body.size) 281 | RestClient.log << "# => #{res.code} #{res.class.to_s.gsub(/^Net::HTTP/, '')} | #{(res['Content-type'] || '').gsub(/;.*$/, '')} #{size} bytes\n" 282 | end 283 | end 284 | 285 | # Return a hash of headers whose keys are capitalized strings 286 | def stringify_headers headers 287 | headers.inject({}) do |result, (key, value)| 288 | if key.is_a? Symbol 289 | key = key.to_s.split(/_/).map { |w| w.capitalize }.join('-') 290 | end 291 | if 'CONTENT-TYPE' == key.upcase 292 | target_value = value.to_s 293 | result[key] = MIME::Types.type_for_extension target_value 294 | elsif 'ACCEPT' == key.upcase 295 | # Accept can be composed of several comma-separated values 296 | if value.is_a? Array 297 | target_values = value 298 | else 299 | target_values = value.to_s.split ',' 300 | end 301 | result[key] = target_values.map { |ext| MIME::Types.type_for_extension(ext.to_s.strip) }.join(', ') 302 | else 303 | result[key] = value.to_s 304 | end 305 | result 306 | end 307 | end 308 | 309 | def default_headers 310 | {:accept => '*/*; q=0.5, application/xml', :accept_encoding => 'gzip, deflate'} 311 | end 312 | 313 | end 314 | end 315 | 316 | module MIME 317 | class Types 318 | 319 | # Return the first found content-type for a value considered as an extension or the value itself 320 | def type_for_extension ext 321 | candidates = @extension_index[ext] 322 | candidates.empty? ? ext : candidates[0].content_type 323 | end 324 | 325 | class << self 326 | def type_for_extension ext 327 | @__types__.type_for_extension ext 328 | end 329 | end 330 | end 331 | end 332 | -------------------------------------------------------------------------------- /spec/request_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join( File.dirname(File.expand_path(__FILE__)), 'base') 2 | 3 | require 'webmock/rspec' 4 | include WebMock::API 5 | 6 | describe RestClient::Request do 7 | before do 8 | @request = RestClient::Request.new(:method => :put, :url => 'http://some/resource', :payload => 'payload') 9 | 10 | @uri = mock("uri") 11 | @uri.stub!(:request_uri).and_return('/resource') 12 | @uri.stub!(:host).and_return('some') 13 | @uri.stub!(:port).and_return(80) 14 | 15 | @net = mock("net::http base") 16 | @http = mock("net::http connection") 17 | Net::HTTP.stub!(:new).and_return(@net) 18 | @net.stub!(:start).and_yield(@http) 19 | @net.stub!(:use_ssl=) 20 | @net.stub!(:verify_mode=) 21 | RestClient.log = nil 22 | end 23 | 24 | it "accept */* mimetype, preferring xml" do 25 | @request.default_headers[:accept].should == '*/*; q=0.5, application/xml' 26 | end 27 | 28 | describe "compression" do 29 | 30 | it "decodes an uncompressed result body by passing it straight through" do 31 | RestClient::Request.decode(nil, 'xyz').should == 'xyz' 32 | end 33 | 34 | it "doesn't fail for nil bodies" do 35 | RestClient::Request.decode('gzip', nil).should be_nil 36 | end 37 | 38 | 39 | it "decodes a gzip body" do 40 | RestClient::Request.decode('gzip', "\037\213\b\b\006'\252H\000\003t\000\313T\317UH\257\312,HM\341\002\000G\242(\r\v\000\000\000").should == "i'm gziped\n" 41 | end 42 | 43 | it "ingores gzip for empty bodies" do 44 | RestClient::Request.decode('gzip', '').should be_empty 45 | end 46 | 47 | it "decodes a deflated body" do 48 | RestClient::Request.decode('deflate', "x\234+\316\317MUHIM\313I,IMQ(I\255(\001\000A\223\006\363").should == "some deflated text" 49 | end 50 | end 51 | 52 | it "processes a successful result" do 53 | res = mock("result") 54 | res.stub!(:code).and_return("200") 55 | res.stub!(:body).and_return('body') 56 | res.stub!(:[]).with('content-encoding').and_return(nil) 57 | @request.process_result(res).body.should == 'body' 58 | @request.process_result(res).to_s.should == 'body' 59 | end 60 | 61 | it "doesn't classify successful requests as failed" do 62 | 203.upto(207) do |code| 63 | res = mock("result") 64 | res.stub!(:code).and_return(code.to_s) 65 | res.stub!(:body).and_return("") 66 | res.stub!(:[]).with('content-encoding').and_return(nil) 67 | @request.process_result(res).should be_empty 68 | end 69 | end 70 | 71 | it "parses a url into a URI object" do 72 | URI.should_receive(:parse).with('http://example.com/resource') 73 | @request.parse_url('http://example.com/resource') 74 | end 75 | 76 | it "adds http:// to the front of resources specified in the syntax example.com/resource" do 77 | URI.should_receive(:parse).with('http://example.com/resource') 78 | @request.parse_url('example.com/resource') 79 | end 80 | 81 | describe "user - password" do 82 | it "extracts the username and password when parsing http://user:password@example.com/" do 83 | URI.stub!(:parse).and_return(mock('uri', :user => 'joe', :password => 'pass1')) 84 | @request.parse_url_with_auth('http://joe:pass1@example.com/resource') 85 | @request.user.should == 'joe' 86 | @request.password.should == 'pass1' 87 | end 88 | 89 | it "extracts with escaping the username and password when parsing http://user:password@example.com/" do 90 | URI.stub!(:parse).and_return(mock('uri', :user => 'joe%20', :password => 'pass1')) 91 | @request.parse_url_with_auth('http://joe%20:pass1@example.com/resource') 92 | @request.user.should == 'joe ' 93 | @request.password.should == 'pass1' 94 | end 95 | 96 | it "doesn't overwrite user and password (which may have already been set by the Resource constructor) if there is no user/password in the url" do 97 | URI.stub!(:parse).and_return(mock('uri', :user => nil, :password => nil)) 98 | @request = RestClient::Request.new(:method => 'get', :url => 'example.com', :user => 'beth', :password => 'pass2') 99 | @request.parse_url_with_auth('http://example.com/resource') 100 | @request.user.should == 'beth' 101 | @request.password.should == 'pass2' 102 | end 103 | end 104 | 105 | it "correctly formats cookies provided to the constructor" do 106 | URI.stub!(:parse).and_return(mock('uri', :user => nil, :password => nil)) 107 | @request = RestClient::Request.new(:method => 'get', :url => 'example.com', :cookies => {:session_id => '1', :user_id => "someone" }) 108 | @request.should_receive(:default_headers).and_return({'Foo' => 'bar'}) 109 | @request.make_headers({}).should == { 'Foo' => 'bar', 'Cookie' => 'session_id=1; user_id=someone'} 110 | end 111 | 112 | it "uses netrc credentials" do 113 | URI.stub!(:parse).and_return(mock('uri', :user => nil, :password => nil, :host => 'example.com')) 114 | Netrc.stub!(:read).and_return('example.com' => ['a', 'b']) 115 | @request.parse_url_with_auth('http://example.com/resource') 116 | @request.user.should == 'a' 117 | @request.password.should == 'b' 118 | end 119 | 120 | it "uses credentials in the url in preference to netrc" do 121 | URI.stub!(:parse).and_return(mock('uri', :user => 'joe%20', :password => 'pass1', :host => 'example.com')) 122 | Netrc.stub!(:read).and_return('example.com' => ['a', 'b']) 123 | @request.parse_url_with_auth('http://joe%20:pass1@example.com/resource') 124 | @request.user.should == 'joe ' 125 | @request.password.should == 'pass1' 126 | end 127 | 128 | it "determines the Net::HTTP class to instantiate by the method name" do 129 | @request.net_http_request_class(:put).should == Net::HTTP::Put 130 | end 131 | 132 | describe "user headers" do 133 | it "merges user headers with the default headers" do 134 | @request.should_receive(:default_headers).and_return({ :accept => '*/*; q=0.5, application/xml', :accept_encoding => 'gzip, deflate' }) 135 | headers = @request.make_headers("Accept" => "application/json", :accept_encoding => 'gzip') 136 | headers.should have_key "Accept-Encoding" 137 | headers["Accept-Encoding"].should == "gzip" 138 | headers.should have_key "Accept" 139 | headers["Accept"].should == "application/json" 140 | end 141 | 142 | it "prefers the user header when the same header exists in the defaults" do 143 | @request.should_receive(:default_headers).and_return({ '1' => '2' }) 144 | headers = @request.make_headers('1' => '3') 145 | headers.should have_key('1') 146 | headers['1'].should == '3' 147 | end 148 | 149 | it "converts user headers to string before calling CGI::unescape which fails on non string values" do 150 | @request.should_receive(:default_headers).and_return({ '1' => '2' }) 151 | headers = @request.make_headers('1' => 3) 152 | headers.should have_key('1') 153 | headers['1'].should == '3' 154 | end 155 | end 156 | 157 | describe "header symbols" do 158 | 159 | it "converts header symbols from :content_type to 'Content-Type'" do 160 | @request.should_receive(:default_headers).and_return({}) 161 | headers = @request.make_headers(:content_type => 'abc') 162 | headers.should have_key('Content-Type') 163 | headers['Content-Type'].should == 'abc' 164 | end 165 | 166 | it "converts content-type from extension to real content-type" do 167 | @request.should_receive(:default_headers).and_return({}) 168 | headers = @request.make_headers(:content_type => 'json') 169 | headers.should have_key('Content-Type') 170 | headers['Content-Type'].should == 'application/json' 171 | end 172 | 173 | it "converts accept from extension(s) to real content-type(s)" do 174 | @request.should_receive(:default_headers).and_return({}) 175 | headers = @request.make_headers(:accept => 'json, mp3') 176 | headers.should have_key('Accept') 177 | headers['Accept'].should == 'application/json, audio/mpeg' 178 | 179 | @request.should_receive(:default_headers).and_return({}) 180 | headers = @request.make_headers(:accept => :json) 181 | headers.should have_key('Accept') 182 | headers['Accept'].should == 'application/json' 183 | end 184 | 185 | it "only convert symbols in header" do 186 | @request.should_receive(:default_headers).and_return({}) 187 | headers = @request.make_headers({:foo_bar => 'value', "bar_bar" => 'value'}) 188 | headers['Foo-Bar'].should == 'value' 189 | headers['bar_bar'].should == 'value' 190 | end 191 | 192 | it "converts header values to strings" do 193 | @request.make_headers('A' => 1)['A'].should == '1' 194 | end 195 | end 196 | 197 | it "executes by constructing the Net::HTTP object, headers, and payload and calling transmit" do 198 | @request.should_receive(:parse_url_with_auth).with('http://some/resource').and_return(@uri) 199 | klass = mock("net:http class") 200 | @request.should_receive(:net_http_request_class).with(:put).and_return(klass) 201 | klass.should_receive(:new).and_return('result') 202 | @request.should_receive(:transmit).with(@uri, 'result', kind_of(RestClient::Payload::Base)) 203 | @request.execute 204 | end 205 | 206 | it "transmits the request with Net::HTTP" do 207 | @http.should_receive(:request).with('req', 'payload') 208 | @net.should_receive(:ssl_version=).with('SSLv3') 209 | @request.should_receive(:process_result) 210 | @request.transmit(@uri, 'req', 'payload') 211 | end 212 | 213 | describe "payload" do 214 | it "sends nil payloads" do 215 | @http.should_receive(:request).with('req', nil) 216 | @net.should_receive(:ssl_version=).with('SSLv3') 217 | @request.should_receive(:process_result) 218 | @request.stub!(:response_log) 219 | @request.transmit(@uri, 'req', nil) 220 | end 221 | 222 | it "passes non-hash payloads straight through" do 223 | @request.process_payload("x").should == "x" 224 | end 225 | 226 | it "converts a hash payload to urlencoded data" do 227 | @request.process_payload(:a => 'b c+d').should == "a=b%20c%2Bd" 228 | end 229 | 230 | it "accepts nested hashes in payload" do 231 | payload = @request.process_payload(:user => { :name => 'joe', :location => { :country => 'USA', :state => 'CA' }}) 232 | payload.should include('user[name]=joe') 233 | payload.should include('user[location][country]=USA') 234 | payload.should include('user[location][state]=CA') 235 | end 236 | end 237 | 238 | it "set urlencoded content_type header on hash payloads" do 239 | @request.process_payload(:a => 1) 240 | @request.headers[:content_type].should == 'application/x-www-form-urlencoded' 241 | end 242 | 243 | describe "credentials" do 244 | it "sets up the credentials prior to the request" do 245 | @http.stub!(:request) 246 | @net.should_receive(:ssl_version=).with('SSLv3') 247 | 248 | @request.stub!(:process_result) 249 | @request.stub!(:response_log) 250 | 251 | @request.stub!(:user).and_return('joe') 252 | @request.stub!(:password).and_return('mypass') 253 | @request.should_receive(:setup_credentials).with('req') 254 | 255 | @request.transmit(@uri, 'req', nil) 256 | end 257 | 258 | it "does not attempt to send any credentials if user is nil" do 259 | @request.stub!(:user).and_return(nil) 260 | req = mock("request") 261 | req.should_not_receive(:basic_auth) 262 | @request.setup_credentials(req) 263 | end 264 | 265 | it "setup credentials when there's a user" do 266 | @request.stub!(:user).and_return('joe') 267 | @request.stub!(:password).and_return('mypass') 268 | req = mock("request") 269 | req.should_receive(:basic_auth).with('joe', 'mypass') 270 | @request.setup_credentials(req) 271 | end 272 | end 273 | 274 | it "catches EOFError and shows the more informative ServerBrokeConnection" do 275 | @http.stub!(:request).and_raise(EOFError) 276 | @net.should_receive(:ssl_version=).with('SSLv3') 277 | lambda { @request.transmit(@uri, 'req', nil) }.should raise_error(RestClient::ServerBrokeConnection) 278 | end 279 | 280 | it "class method execute wraps constructor" do 281 | req = mock("rest request") 282 | RestClient::Request.should_receive(:new).with(1 => 2).and_return(req) 283 | req.should_receive(:execute) 284 | RestClient::Request.execute(1 => 2) 285 | end 286 | 287 | describe "exception" do 288 | it "raises Unauthorized when the response is 401" do 289 | res = mock('response', :code => '401', :[] => ['content-encoding' => ''], :body => '' ) 290 | lambda { @request.process_result(res) }.should raise_error(RestClient::Unauthorized) 291 | end 292 | 293 | it "raises ResourceNotFound when the response is 404" do 294 | res = mock('response', :code => '404', :[] => ['content-encoding' => ''], :body => '' ) 295 | lambda { @request.process_result(res) }.should raise_error(RestClient::ResourceNotFound) 296 | end 297 | 298 | it "raises RequestFailed otherwise" do 299 | res = mock('response', :code => '500', :[] => ['content-encoding' => ''], :body => '' ) 300 | lambda { @request.process_result(res) }.should raise_error(RestClient::InternalServerError) 301 | end 302 | end 303 | 304 | describe "block usage" do 305 | it "returns what asked to" do 306 | res = mock('response', :code => '401', :[] => ['content-encoding' => ''], :body => '' ) 307 | @request.process_result(res){|response, request| "foo"}.should == "foo" 308 | end 309 | end 310 | 311 | describe "proxy" do 312 | it "creates a proxy class if a proxy url is given" do 313 | RestClient.stub!(:proxy).and_return("http://example.com/") 314 | @request.net_http_class.proxy_class?.should be_true 315 | end 316 | 317 | it "creates a non-proxy class if a proxy url is not given" do 318 | @request.net_http_class.proxy_class?.should be_false 319 | end 320 | end 321 | 322 | 323 | describe "logging" do 324 | it "logs a get request" do 325 | log = RestClient.log = [] 326 | RestClient::Request.new(:method => :get, :url => 'http://url').log_request 327 | log[0].should == %Q{RestClient.get "http://url", "Accept"=>"*/*; q=0.5, application/xml", "Accept-Encoding"=>"gzip, deflate"\n} 328 | end 329 | 330 | it "logs a post request with a small payload" do 331 | log = RestClient.log = [] 332 | RestClient::Request.new(:method => :post, :url => 'http://url', :payload => 'foo').log_request 333 | log[0].should == %Q{RestClient.post "http://url", "foo", "Accept"=>"*/*; q=0.5, application/xml", "Accept-Encoding"=>"gzip, deflate", "Content-Length"=>"3"\n} 334 | end 335 | 336 | it "logs a post request with a large payload" do 337 | log = RestClient.log = [] 338 | RestClient::Request.new(:method => :post, :url => 'http://url', :payload => ('x' * 1000)).log_request 339 | log[0].should == %Q{RestClient.post "http://url", 1000 byte(s) length, "Accept"=>"*/*; q=0.5, application/xml", "Accept-Encoding"=>"gzip, deflate", "Content-Length"=>"1000"\n} 340 | end 341 | 342 | it "logs input headers as a hash" do 343 | log = RestClient.log = [] 344 | RestClient::Request.new(:method => :get, :url => 'http://url', :headers => { :accept => 'text/plain' }).log_request 345 | log[0].should == %Q{RestClient.get "http://url", "Accept"=>"text/plain", "Accept-Encoding"=>"gzip, deflate"\n} 346 | end 347 | 348 | it "logs a response including the status code, content type, and result body size in bytes" do 349 | log = RestClient.log = [] 350 | res = mock('result', :code => '200', :class => Net::HTTPOK, :body => 'abcd') 351 | res.stub!(:[]).with('Content-type').and_return('text/html') 352 | @request.log_response res 353 | log[0].should == "# => 200 OK | text/html 4 bytes\n" 354 | end 355 | 356 | it "logs a response with a nil Content-type" do 357 | log = RestClient.log = [] 358 | res = mock('result', :code => '200', :class => Net::HTTPOK, :body => 'abcd') 359 | res.stub!(:[]).with('Content-type').and_return(nil) 360 | @request.log_response res 361 | log[0].should == "# => 200 OK | 4 bytes\n" 362 | end 363 | 364 | it "logs a response with a nil body" do 365 | log = RestClient.log = [] 366 | res = mock('result', :code => '200', :class => Net::HTTPOK, :body => nil) 367 | res.stub!(:[]).with('Content-type').and_return('text/html; charset=utf-8') 368 | @request.log_response res 369 | log[0].should == "# => 200 OK | text/html 0 bytes\n" 370 | end 371 | end 372 | 373 | it "strips the charset from the response content type" do 374 | log = RestClient.log = [] 375 | res = mock('result', :code => '200', :class => Net::HTTPOK, :body => 'abcd') 376 | res.stub!(:[]).with('Content-type').and_return('text/html; charset=utf-8') 377 | @request.log_response res 378 | log[0].should == "# => 200 OK | text/html 4 bytes\n" 379 | end 380 | 381 | describe "timeout" do 382 | it "set read_timeout" do 383 | @request = RestClient::Request.new(:method => :put, :url => 'http://some/resource', :payload => 'payload', :timeout => 123, :ssl_version => 'SSLv3') 384 | @http.stub!(:request) 385 | @request.stub!(:process_result) 386 | @request.stub!(:response_log) 387 | 388 | @net.should_receive(:read_timeout=).with(123) 389 | @net.should_receive(:ssl_version=).with('SSLv3') 390 | 391 | @request.transmit(@uri, 'req', nil) 392 | end 393 | 394 | it "set open_timeout" do 395 | @request = RestClient::Request.new(:method => :put, :url => 'http://some/resource', :payload => 'payload', :open_timeout => 123, :ssl_version => 'SSLv3') 396 | @http.stub!(:request) 397 | @request.stub!(:process_result) 398 | @request.stub!(:response_log) 399 | 400 | @net.should_receive(:open_timeout=).with(123) 401 | @net.should_receive(:ssl_version=).with('SSLv3') 402 | 403 | @request.transmit(@uri, 'req', nil) 404 | end 405 | end 406 | 407 | describe "ssl" do 408 | it "uses SSL when the URI refers to a https address" do 409 | @uri.stub!(:is_a?).with(URI::HTTPS).and_return(true) 410 | @net.should_receive(:use_ssl=).with(true) 411 | @net.should_receive(:ssl_version=).with('SSLv3') 412 | @http.stub!(:request) 413 | @request.stub!(:process_result) 414 | @request.stub!(:response_log) 415 | @request.transmit(@uri, 'req', 'payload') 416 | end 417 | 418 | it "should default to not verifying ssl certificates" do 419 | @request.verify_ssl.should == false 420 | end 421 | 422 | it "should set net.verify_mode to OpenSSL::SSL::VERIFY_NONE if verify_ssl is false" do 423 | @net.should_receive(:verify_mode=).with(OpenSSL::SSL::VERIFY_NONE) 424 | @net.should_receive(:ssl_version=).with('SSLv3') 425 | @http.stub!(:request) 426 | @request.stub!(:process_result) 427 | @request.stub!(:response_log) 428 | @request.transmit(@uri, 'req', 'payload') 429 | end 430 | 431 | it "should not set net.verify_mode to OpenSSL::SSL::VERIFY_NONE if verify_ssl is true" do 432 | @request = RestClient::Request.new(:method => :put, :url => 'https://some/resource', :payload => 'payload', :verify_ssl => true, :ssl_version => 'SSLv3') 433 | @net.should_not_receive(:verify_mode=).with(OpenSSL::SSL::VERIFY_NONE) 434 | @net.should_receive(:ssl_version=).with('SSLv3') 435 | @http.stub!(:request) 436 | @request.stub!(:process_result) 437 | @request.stub!(:response_log) 438 | @request.transmit(@uri, 'req', 'payload') 439 | end 440 | 441 | it "should set net.verify_mode to the passed value if verify_ssl is an OpenSSL constant" do 442 | mode = OpenSSL::SSL::VERIFY_PEER | OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT 443 | @request = RestClient::Request.new( :method => :put, 444 | :url => 'https://some/resource', 445 | :payload => 'payload', 446 | :ssl_version => 'SSLv3', 447 | :verify_ssl => mode ) 448 | @net.should_receive(:verify_mode=).with(mode) 449 | @net.should_receive(:verify_callback=) 450 | @net.should_receive(:ssl_version=).with('SSLv3') 451 | @http.stub!(:request) 452 | @request.stub!(:process_result) 453 | @request.stub!(:response_log) 454 | @request.transmit(@uri, 'req', 'payload') 455 | end 456 | 457 | it "should default to not having an ssl_client_cert" do 458 | @request.ssl_client_cert.should be(nil) 459 | end 460 | 461 | it "should set the ssl_client_cert if provided" do 462 | @request = RestClient::Request.new( 463 | :method => :put, 464 | :url => 'https://some/resource', 465 | :payload => 'payload', 466 | :ssl_version => 'SSLv3', 467 | :ssl_client_cert => "whatsupdoc!" 468 | ) 469 | @net.should_receive(:cert=).with("whatsupdoc!") 470 | @net.should_receive(:ssl_version=).with('SSLv3') 471 | @http.stub!(:request) 472 | @request.stub!(:process_result) 473 | @request.stub!(:response_log) 474 | @request.transmit(@uri, 'req', 'payload') 475 | end 476 | 477 | it "should not set the ssl_client_cert if it is not provided" do 478 | @request = RestClient::Request.new( 479 | :method => :put, 480 | :url => 'https://some/resource', 481 | :ssl_version => 'SSLv3', 482 | :payload => 'payload' 483 | ) 484 | @net.should_not_receive(:cert=).with("whatsupdoc!") 485 | @net.should_receive(:ssl_version=).with('SSLv3') 486 | @http.stub!(:request) 487 | @request.stub!(:process_result) 488 | @request.stub!(:response_log) 489 | @request.transmit(@uri, 'req', 'payload') 490 | end 491 | 492 | it "should default to not having an ssl_client_key" do 493 | @request.ssl_client_key.should be(nil) 494 | end 495 | 496 | it "should set the ssl_client_key if provided" do 497 | @request = RestClient::Request.new( 498 | :method => :put, 499 | :url => 'https://some/resource', 500 | :payload => 'payload', 501 | :ssl_version => 'SSLv3', 502 | :ssl_client_key => "whatsupdoc!" 503 | ) 504 | @net.should_receive(:key=).with("whatsupdoc!") 505 | @net.should_receive(:ssl_version=).with('SSLv3') 506 | @http.stub!(:request) 507 | @request.stub!(:process_result) 508 | @request.stub!(:response_log) 509 | @request.transmit(@uri, 'req', 'payload') 510 | end 511 | 512 | it "should not set the ssl_client_key if it is not provided" do 513 | @request = RestClient::Request.new( 514 | :method => :put, 515 | :url => 'https://some/resource', 516 | :ssl_version => 'SSLv3', 517 | :payload => 'payload' 518 | ) 519 | @net.should_not_receive(:key=).with("whatsupdoc!") 520 | @net.should_receive(:ssl_version=).with('SSLv3') 521 | @http.stub!(:request) 522 | @request.stub!(:process_result) 523 | @request.stub!(:response_log) 524 | @request.transmit(@uri, 'req', 'payload') 525 | end 526 | 527 | it "should default to not having an ssl_ca_file" do 528 | @request.ssl_ca_file.should be(nil) 529 | end 530 | 531 | it "should set the ssl_ca_file if provided" do 532 | @request = RestClient::Request.new( 533 | :method => :put, 534 | :url => 'https://some/resource', 535 | :payload => 'payload', 536 | :ssl_version => 'SSLv3', 537 | :ssl_ca_file => "Certificate Authority File" 538 | ) 539 | @net.should_receive(:ca_file=).with("Certificate Authority File") 540 | @net.should_receive(:ssl_version=).with('SSLv3') 541 | @http.stub!(:request) 542 | @request.stub!(:process_result) 543 | @request.stub!(:response_log) 544 | @request.transmit(@uri, 'req', 'payload') 545 | end 546 | 547 | it "should not set the ssl_ca_file if it is not provided" do 548 | @request = RestClient::Request.new( 549 | :method => :put, 550 | :url => 'https://some/resource', 551 | :ssl_version => 'TSLv1', 552 | :payload => 'payload' 553 | ) 554 | @net.should_not_receive(:ca_file=).with("Certificate Authority File") 555 | @net.should_receive(:ssl_version=).with('TSLv1') 556 | @http.stub!(:request) 557 | @request.stub!(:process_result) 558 | @request.stub!(:response_log) 559 | @request.transmit(@uri, 'req', 'payload') 560 | end 561 | end 562 | 563 | it "should still return a response object for 204 No Content responses" do 564 | @request = RestClient::Request.new( 565 | :method => :put, 566 | :url => 'https://some/resource', 567 | :ssl_version => 'SSLv3', 568 | :payload => 'payload' 569 | ) 570 | net_http_res = Net::HTTPNoContent.new("", "204", "No Content") 571 | net_http_res.stub!(:read_body).and_return(nil) 572 | @http.should_receive(:request).and_return(@request.fetch_body(net_http_res)) 573 | @net.should_receive(:ssl_version=).with('SSLv3') 574 | response = @request.transmit(@uri, 'req', 'payload') 575 | response.should_not be_nil 576 | response.code.should == 204 577 | end 578 | end 579 | --------------------------------------------------------------------------------