├── .gitignore ├── README.rdoc ├── Rakefile ├── VERSION ├── bin └── restclient ├── lib ├── rest_client.rb ├── restclient.rb └── restclient │ ├── exceptions.rb │ ├── mixin │ └── response.rb │ ├── raw_response.rb │ ├── request.rb │ ├── resource.rb │ └── response.rb ├── rest-client.gemspec └── spec ├── base.rb ├── exceptions_spec.rb ├── mixin └── response_spec.rb ├── raw_response_spec.rb ├── request_spec.rb ├── resource_spec.rb ├── response_spec.rb └── restclient_spec.rb /.gitignore: -------------------------------------------------------------------------------- 1 | rdoc 2 | pkg 3 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | 2 | == This fork is no longer maintained. Please see the new official fork at: http://github.com/archiloque/rest-client 3 | 4 | -------------------------------------------------------------------- 5 | 6 | = REST Client -- simple DSL for accessing REST resources 7 | 8 | A simple REST client for Ruby, inspired by the Sinatra's microframework style 9 | of specifying actions: get, put, post, delete. 10 | 11 | == Usage: Raw URL 12 | 13 | require 'rest_client' 14 | 15 | RestClient.get 'http://example.com/resource' 16 | RestClient.get 'https://user:password@example.com/private/resource' 17 | 18 | RestClient.post 'http://example.com/resource', :param1 => 'one', :nested => { :param2 => 'two' } 19 | 20 | RestClient.delete 'http://example.com/resource' 21 | 22 | See RestClient module docs for details. 23 | 24 | == Usage: ActiveResource-Style 25 | 26 | resource = RestClient::Resource.new 'http://example.com/resource' 27 | resource.get 28 | 29 | private_resource = RestClient::Resource.new 'https://example.com/private/resource', :user => 'adam', :password => 'secret', :timeout => 20, :open_timeout => 5 30 | private_resource.put File.read('pic.jpg'), :content_type => 'image/jpg' 31 | 32 | See RestClient::Resource module docs for details. 33 | 34 | == Usage: Resource Nesting 35 | 36 | site = RestClient::Resource.new('http://example.com') 37 | site['posts/1/comments'].post 'Good article.', :content_type => 'text/plain' 38 | 39 | See RestClient::Resource docs for details. 40 | 41 | == Shell 42 | 43 | The restclient shell command gives an IRB session with RestClient already loaded: 44 | 45 | $ restclient 46 | >> RestClient.get 'http://example.com' 47 | 48 | Specify a URL argument for get/post/put/delete on that resource: 49 | 50 | $ restclient http://example.com 51 | >> put '/resource', 'data' 52 | 53 | Add a user and password for authenticated resources: 54 | 55 | $ restclient https://example.com user pass 56 | >> delete '/private/resource' 57 | 58 | Create ~/.restclient for named sessions: 59 | 60 | sinatra: 61 | url: http://localhost:4567 62 | rack: 63 | url: http://localhost:9292 64 | private_site: 65 | url: http://example.com 66 | username: user 67 | password: pass 68 | 69 | Then invoke: 70 | 71 | $ restclient private_site 72 | 73 | Use as a one-off, curl-style: 74 | 75 | $ restclient get http://example.com/resource > output_body 76 | 77 | $ restclient put http://example.com/resource < input_body 78 | 79 | == Logging 80 | 81 | Write calls to a log filename (can also be "stdout" or "stderr"): 82 | 83 | RestClient.log = '/tmp/restclient.log' 84 | 85 | Or set an environment variable to avoid modifying the code: 86 | 87 | $ RESTCLIENT_LOG=stdout path/to/my/program 88 | 89 | Either produces logs like this: 90 | 91 | RestClient.get "http://some/resource" 92 | # => 200 OK | text/html 250 bytes 93 | RestClient.put "http://some/resource", "payload" 94 | # => 401 Unauthorized | application/xml 340 bytes 95 | 96 | Note that these logs are valid Ruby, so you can paste them into the restclient 97 | shell or a script to replay your sequence of rest calls. 98 | 99 | == Proxy 100 | 101 | All calls to RestClient, including Resources, will use the proxy specified by 102 | RestClient.proxy: 103 | 104 | RestClient.proxy = "http://proxy.example.com/" 105 | RestClient.get "http://some/resource" 106 | # => response from some/resource as proxied through proxy.example.com 107 | 108 | Often the proxy url is set in an environment variable, so you can do this to 109 | use whatever proxy the system is configured to use: 110 | 111 | RestClient.proxy = ENV['http_proxy'] 112 | 113 | == Cookies 114 | 115 | Request and Response objects know about HTTP cookies, and will automatically 116 | extract and set headers for them as needed: 117 | 118 | response = RestClient.get 'http://example.com/action_which_sets_session_id' 119 | response.cookies 120 | # => {"_applicatioN_session_id" => "1234"} 121 | 122 | response2 = RestClient.post( 123 | 'http://localhost:3000/', 124 | {:param1 => "foo"}, 125 | {:cookies => {:session_id => "1234"}} 126 | ) 127 | # ...response body 128 | 129 | == SSL Client Certificates 130 | 131 | RestClient::Resource.new( 132 | 'https://example.com', 133 | :ssl_client_cert => OpenSSL::X509::Certificate.new(File.read("cert.pem")), 134 | :ssl_client_key => OpenSSL::PKey::RSA.new(File.read("key.pem"), "passphrase, if any"), 135 | :ssl_ca_file => "ca_certificate.pem", 136 | :verify_ssl => OpenSSL::SSL::VERIFY_PEER 137 | ).get 138 | 139 | Self-signed certificates can be generated with the openssl command-line tool. 140 | 141 | == Meta 142 | 143 | Written by Adam Wiggins (adam at heroku dot com) 144 | 145 | Patches contributed by: Chris Anderson, Greg Borenstein, Ardekantur, Pedro 146 | Belo, Rafael Souza, Rick Olson, Aman Gupta, Blake Mizerany, Brian Donovan, Ivan 147 | Makfinsky, Marc-André Cournoyer, Coda Hale, Tetsuo Watanabe, Dusty Doris, 148 | Lennon Day-Reynolds, James Edward Gray II, Cyril Rohr, Juan Alvarez, and Adam 149 | Jacob, Paul Dlug, and Brad Ediger 150 | 151 | Released under the MIT License: http://www.opensource.org/licenses/mit-license.php 152 | 153 | http://rest-client.heroku.com 154 | 155 | http://github.com/adamwiggins/rest-client 156 | 157 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake' 2 | 3 | require 'jeweler' 4 | 5 | Jeweler::Tasks.new do |s| 6 | s.name = "rest-client" 7 | s.description = "A simple REST client for Ruby, inspired by the Sinatra microframework style of specifying actions: get, put, post, delete." 8 | s.summary = "Simple REST client for Ruby, inspired by microframework syntax for specifying actions." 9 | s.author = "Adam Wiggins" 10 | s.email = "adam@heroku.com" 11 | s.homepage = "http://rest-client.heroku.com/" 12 | s.rubyforge_project = "rest-client" 13 | s.has_rdoc = true 14 | s.files = FileList["[A-Z]*", "{bin,lib,spec}/**/*"] 15 | s.executables = %w(restclient) 16 | end 17 | 18 | Jeweler::RubyforgeTasks.new 19 | 20 | ############################ 21 | 22 | require 'spec/rake/spectask' 23 | 24 | desc "Run all specs" 25 | Spec::Rake::SpecTask.new('spec') do |t| 26 | t.spec_opts = ['--colour --format progress --loadby mtime --reverse'] 27 | t.spec_files = FileList['spec/*_spec.rb'] 28 | end 29 | 30 | desc "Print specdocs" 31 | Spec::Rake::SpecTask.new(:doc) do |t| 32 | t.spec_opts = ["--format", "specdoc", "--dry-run"] 33 | t.spec_files = FileList['spec/*_spec.rb'] 34 | end 35 | 36 | desc "Run all examples with RCov" 37 | Spec::Rake::SpecTask.new('rcov') do |t| 38 | t.spec_files = FileList['spec/*_spec.rb'] 39 | t.rcov = true 40 | t.rcov_opts = ['--exclude', 'examples'] 41 | end 42 | 43 | task :default => :spec 44 | 45 | ############################ 46 | 47 | require 'rake/rdoctask' 48 | 49 | Rake::RDocTask.new do |t| 50 | t.rdoc_dir = 'rdoc' 51 | t.title = "rest-client, fetch RESTful resources effortlessly" 52 | t.options << '--line-numbers' << '--inline-source' << '-A cattr_accessor=object' 53 | t.options << '--charset' << 'utf-8' 54 | t.rdoc_files.include('README.rdoc') 55 | t.rdoc_files.include('lib/restclient.rb') 56 | t.rdoc_files.include('lib/restclient/*.rb') 57 | end 58 | 59 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 1.0.4 2 | -------------------------------------------------------------------------------- /bin/restclient: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | $:.unshift File.dirname(__FILE__) + "/../lib" 4 | require 'restclient' 5 | 6 | require "yaml" 7 | 8 | def usage(why = nil) 9 | puts "failed for reason: #{why}" if why 10 | puts "usage: restclient [get|put|post|delete] url|name [username] [password]" 11 | puts " The verb is optional, if you leave it off you'll get an interactive shell." 12 | puts " put and post both take the input body on stdin." 13 | exit(1) 14 | end 15 | 16 | if %w(get put post delete).include? ARGV.first 17 | @verb = ARGV.shift 18 | else 19 | @verb = nil 20 | end 21 | 22 | @url = ARGV.shift || 'http://localhost:4567' 23 | 24 | config = YAML.load(File.read(ENV['HOME'] + "/.restclient")) rescue {} 25 | 26 | @url, @username, @password = if c = config[@url] 27 | [c['url'], c['username'], c['password']] 28 | else 29 | [@url, *ARGV] 30 | end 31 | 32 | usage("invalid url '#{@url}") unless @url =~ /^https?/ 33 | usage("too few args") unless ARGV.size < 3 34 | 35 | def r 36 | @r ||= RestClient::Resource.new(@url, @username, @password) 37 | end 38 | 39 | r # force rc to load 40 | 41 | if @verb 42 | begin 43 | if %w(put post).include? @verb 44 | puts r.send(@verb, STDIN.read) 45 | else 46 | puts r.send(@verb) 47 | end 48 | exit 0 49 | rescue RestClient::Exception => e 50 | puts e.response.body if e.respond_to? :response 51 | raise 52 | end 53 | end 54 | 55 | %w(get post put delete).each do |m| 56 | eval <<-end_eval 57 | def #{m}(path, *args, &b) 58 | r[path].#{m}(*args, &b) 59 | end 60 | end_eval 61 | end 62 | 63 | def method_missing(s, *args, &b) 64 | super unless r.respond_to?(s) 65 | begin 66 | r.send(s, *args, &b) 67 | rescue RestClient::RequestFailed => e 68 | print STDERR, e.response.body 69 | raise e 70 | end 71 | end 72 | 73 | require 'irb' 74 | require 'irb/completion' 75 | 76 | if File.exists? ".irbrc" 77 | ENV['IRBRC'] = ".irbrc" 78 | end 79 | 80 | if File.exists?(rcfile = "~/.restclientrc") 81 | load(rcfile) 82 | end 83 | 84 | ARGV.clear 85 | 86 | IRB.start 87 | exit! 88 | -------------------------------------------------------------------------------- /lib/rest_client.rb: -------------------------------------------------------------------------------- 1 | # This file exists for backward compatbility with require 'rest_client' 2 | require File.dirname(__FILE__) + '/restclient' 3 | -------------------------------------------------------------------------------- /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/request' 13 | require File.dirname(__FILE__) + '/restclient/mixin/response' 14 | require File.dirname(__FILE__) + '/restclient/response' 15 | require File.dirname(__FILE__) + '/restclient/raw_response' 16 | require File.dirname(__FILE__) + '/restclient/resource' 17 | require File.dirname(__FILE__) + '/restclient/exceptions' 18 | 19 | # This module's static methods are the entry point for using the REST client. 20 | # 21 | # # GET 22 | # xml = RestClient.get 'http://example.com/resource' 23 | # jpg = RestClient.get 'http://example.com/resource', :accept => 'image/jpg' 24 | # 25 | # # authentication and SSL 26 | # RestClient.get 'https://user:password@example.com/private/resource' 27 | # 28 | # # POST or PUT with a hash sends parameters as a urlencoded form body 29 | # RestClient.post 'http://example.com/resource', :param1 => 'one' 30 | # 31 | # # nest hash parameters 32 | # RestClient.post 'http://example.com/resource', :nested => { :param1 => 'one' } 33 | # 34 | # # POST and PUT with raw payloads 35 | # RestClient.post 'http://example.com/resource', 'the post body', :content_type => 'text/plain' 36 | # RestClient.post 'http://example.com/resource.xml', xml_doc 37 | # RestClient.put 'http://example.com/resource.pdf', File.read('my.pdf'), :content_type => 'application/pdf' 38 | # 39 | # # DELETE 40 | # RestClient.delete 'http://example.com/resource' 41 | # 42 | # # retreive the response http code and headers 43 | # res = RestClient.get 'http://example.com/some.jpg' 44 | # res.code # => 200 45 | # res.headers[:content_type] # => 'image/jpg' 46 | # 47 | # # HEAD 48 | # RestClient.head('http://example.com').headers 49 | # 50 | # To use with a proxy, just set RestClient.proxy to the proper http proxy: 51 | # 52 | # RestClient.proxy = "http://proxy.example.com/" 53 | # 54 | # Or inherit the proxy from the environment: 55 | # 56 | # RestClient.proxy = ENV['http_proxy'] 57 | # 58 | # For live tests of RestClient, try using http://rest-test.heroku.com, which echoes back information about the rest call: 59 | # 60 | # >> RestClient.put 'http://rest-test.heroku.com/resource', :foo => 'baz' 61 | # => "PUT http://rest-test.heroku.com/resource with a 7 byte payload, content type application/x-www-form-urlencoded {\"foo\"=>\"baz\"}" 62 | # 63 | module RestClient 64 | def self.get(url, headers={}) 65 | Request.execute(:method => :get, :url => url, :headers => headers) 66 | end 67 | 68 | def self.post(url, payload, headers={}) 69 | Request.execute(:method => :post, :url => url, :payload => payload, :headers => headers) 70 | end 71 | 72 | def self.put(url, payload, headers={}) 73 | Request.execute(:method => :put, :url => url, :payload => payload, :headers => headers) 74 | end 75 | 76 | def self.delete(url, headers={}) 77 | Request.execute(:method => :delete, :url => url, :headers => headers) 78 | end 79 | 80 | def self.head(url, headers={}) 81 | Request.execute(:method => :head, :url => url, :headers => headers) 82 | end 83 | 84 | class << self 85 | attr_accessor :proxy 86 | end 87 | 88 | # Print log of RestClient calls. Value can be stdout, stderr, or a filename. 89 | # You can also configure logging by the environment variable RESTCLIENT_LOG. 90 | def self.log=(log) 91 | @@log = log 92 | end 93 | 94 | def self.log # :nodoc: 95 | return ENV['RESTCLIENT_LOG'] if ENV['RESTCLIENT_LOG'] 96 | return @@log if defined? @@log 97 | nil 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /lib/restclient/exceptions.rb: -------------------------------------------------------------------------------- 1 | module RestClient 2 | # This is the base RestClient exception class. Rescue it if you want to 3 | # catch any exception that your request might raise 4 | class Exception < RuntimeError 5 | def message(default=nil) 6 | self.class::ErrorMessage 7 | end 8 | end 9 | 10 | # Base RestClient exception when there's a response available 11 | class ExceptionWithResponse < Exception 12 | attr_accessor :response 13 | 14 | def initialize(response=nil) 15 | @response = response 16 | end 17 | 18 | def http_code 19 | @response.code.to_i if @response 20 | end 21 | 22 | def http_body 23 | RestClient::Request.decode(@response['content-encoding'], @response.body) if @response 24 | end 25 | end 26 | 27 | # A redirect was encountered; caught by execute to retry with the new url. 28 | class Redirect < Exception 29 | ErrorMessage = "Redirect" 30 | 31 | attr_accessor :url 32 | def initialize(url) 33 | @url = url 34 | end 35 | end 36 | 37 | class NotModified < ExceptionWithResponse 38 | ErrorMessage = 'NotModified' 39 | end 40 | 41 | # Authorization is required to access the resource specified. 42 | class Unauthorized < ExceptionWithResponse 43 | ErrorMessage = 'Unauthorized' 44 | end 45 | 46 | # No resource was found at the given URL. 47 | class ResourceNotFound < ExceptionWithResponse 48 | ErrorMessage = 'Resource not found' 49 | end 50 | 51 | # The server broke the connection prior to the request completing. Usually 52 | # this means it crashed, or sometimes that your network connection was 53 | # severed before it could complete. 54 | class ServerBrokeConnection < Exception 55 | ErrorMessage = 'Server broke connection' 56 | end 57 | 58 | # The server took too long to respond. 59 | class RequestTimeout < Exception 60 | ErrorMessage = 'Request timed out' 61 | end 62 | 63 | # The request failed, meaning the remote HTTP server returned a code other 64 | # than success, unauthorized, or redirect. 65 | # 66 | # The exception message attempts to extract the error from the XML, using 67 | # format returned by Rails: some message 68 | # 69 | # You can get the status code by e.http_code, or see anything about the 70 | # response via e.response. For example, the entire result body (which is 71 | # probably an HTML error page) is e.response.body. 72 | class RequestFailed < ExceptionWithResponse 73 | def message 74 | "HTTP status code #{http_code}" 75 | end 76 | 77 | def to_s 78 | message 79 | end 80 | end 81 | end 82 | 83 | # backwards compatibility 84 | class RestClient::Request 85 | Redirect = RestClient::Redirect 86 | Unauthorized = RestClient::Unauthorized 87 | RequestFailed = RestClient::RequestFailed 88 | end 89 | -------------------------------------------------------------------------------- /lib/restclient/mixin/response.rb: -------------------------------------------------------------------------------- 1 | module RestClient 2 | module Mixin 3 | module Response 4 | attr_reader :net_http_res 5 | 6 | # HTTP status code, always 200 since RestClient throws exceptions for 7 | # other codes. 8 | def code 9 | @code ||= @net_http_res.code.to_i 10 | end 11 | 12 | # A hash of the headers, beautified with symbols and underscores. 13 | # e.g. "Content-type" will become :content_type. 14 | def headers 15 | @headers ||= self.class.beautify_headers(@net_http_res.to_hash) 16 | end 17 | 18 | # Hash of cookies extracted from response headers 19 | def cookies 20 | @cookies ||= (self.headers[:set_cookie] || "").split('; ').inject({}) do |out, raw_c| 21 | key, val = raw_c.split('=') 22 | unless %w(expires domain path secure).member?(key) 23 | out[key] = val 24 | end 25 | out 26 | end 27 | end 28 | 29 | def self.included(receiver) 30 | receiver.extend(RestClient::Mixin::Response::ClassMethods) 31 | end 32 | 33 | module ClassMethods 34 | def beautify_headers(headers) 35 | headers.inject({}) do |out, (key, value)| 36 | out[key.gsub(/-/, '_').to_sym] = value.first 37 | out 38 | end 39 | end 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/restclient/raw_response.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/mixin/response' 2 | 3 | module RestClient 4 | # The response from RestClient on a raw request looks like a string, but is 5 | # actually one of these. 99% of the time you're making a rest call all you 6 | # care about is the body, but on the occassion you want to fetch the 7 | # headers you can: 8 | # 9 | # RestClient.get('http://example.com').headers[:content_type] 10 | # 11 | # In addition, if you do not use the response as a string, you can access 12 | # a Tempfile object at res.file, which contains the path to the raw 13 | # downloaded request body. 14 | class RawResponse 15 | include RestClient::Mixin::Response 16 | 17 | attr_reader :file 18 | 19 | def initialize(tempfile, net_http_res) 20 | @net_http_res = net_http_res 21 | @file = tempfile 22 | end 23 | 24 | def to_s 25 | @file.open 26 | @file.read 27 | end 28 | 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/restclient/request.rb: -------------------------------------------------------------------------------- 1 | require 'tempfile' 2 | 3 | module RestClient 4 | # This class is used internally by RestClient to send the request, but you can also 5 | # access it internally if you'd like to use a method not directly supported by the 6 | # main API. For example: 7 | # 8 | # RestClient::Request.execute(:method => :head, :url => 'http://example.com') 9 | # 10 | class Request 11 | attr_reader :method, :url, :payload, :headers, 12 | :cookies, :user, :password, :timeout, :open_timeout, 13 | :verify_ssl, :ssl_client_cert, :ssl_client_key, :ssl_ca_file, 14 | :raw_response 15 | 16 | def self.execute(args) 17 | new(args).execute 18 | end 19 | 20 | def initialize(args) 21 | @method = args[:method] or raise ArgumentError, "must pass :method" 22 | @url = args[:url] or raise ArgumentError, "must pass :url" 23 | @headers = args[:headers] || {} 24 | @cookies = @headers.delete(:cookies) || args[:cookies] || {} 25 | @payload = process_payload(args[:payload]) 26 | @user = args[:user] 27 | @password = args[:password] 28 | @timeout = args[:timeout] 29 | @open_timeout = args[:open_timeout] 30 | @raw_response = args[:raw_response] || false 31 | @verify_ssl = args[:verify_ssl] || false 32 | @ssl_client_cert = args[:ssl_client_cert] || nil 33 | @ssl_client_key = args[:ssl_client_key] || nil 34 | @ssl_ca_file = args[:ssl_ca_file] || nil 35 | @tf = nil # If you are a raw request, this is your tempfile 36 | end 37 | 38 | def execute 39 | execute_inner 40 | rescue Redirect => e 41 | @url = e.url 42 | @method = :get 43 | @payload = nil 44 | execute 45 | end 46 | 47 | def execute_inner 48 | uri = parse_url_with_auth(url) 49 | transmit uri, net_http_request_class(method).new(uri.request_uri, make_headers(headers)), payload 50 | end 51 | 52 | def make_headers(user_headers) 53 | unless @cookies.empty? 54 | user_headers[:cookie] = @cookies.map {|key, val| "#{key.to_s}=#{val}" }.join('; ') 55 | end 56 | 57 | default_headers.merge(user_headers).inject({}) do |final, (key, value)| 58 | final[key.to_s.gsub(/_/, '-').capitalize] = value.to_s 59 | final 60 | end 61 | end 62 | 63 | def net_http_class 64 | if RestClient.proxy 65 | proxy_uri = URI.parse(RestClient.proxy) 66 | Net::HTTP::Proxy(proxy_uri.host, proxy_uri.port, proxy_uri.user, proxy_uri.password) 67 | else 68 | Net::HTTP 69 | end 70 | end 71 | 72 | def net_http_request_class(method) 73 | Net::HTTP.const_get(method.to_s.capitalize) 74 | end 75 | 76 | def parse_url(url) 77 | url = "http://#{url}" unless url.match(/^http/) 78 | URI.parse(url) 79 | end 80 | 81 | def parse_url_with_auth(url) 82 | uri = parse_url(url) 83 | @user = uri.user if uri.user 84 | @password = uri.password if uri.password 85 | uri 86 | end 87 | 88 | def process_payload(p=nil, parent_key=nil) 89 | unless p.is_a?(Hash) 90 | p 91 | else 92 | @headers[:content_type] ||= 'application/x-www-form-urlencoded' 93 | p.keys.map do |k| 94 | key = parent_key ? "#{parent_key}[#{k}]" : k 95 | if p[k].is_a? Hash 96 | process_payload(p[k], key) 97 | else 98 | value = URI.escape(p[k].to_s, Regexp.new("[^#{URI::PATTERN::UNRESERVED}]")) 99 | "#{key}=#{value}" 100 | end 101 | end.join("&") 102 | end 103 | end 104 | 105 | def transmit(uri, req, payload) 106 | setup_credentials(req) 107 | 108 | net = net_http_class.new(uri.host, uri.port) 109 | net.use_ssl = uri.is_a?(URI::HTTPS) 110 | if @verify_ssl == false 111 | net.verify_mode = OpenSSL::SSL::VERIFY_NONE 112 | elsif @verify_ssl.is_a? Integer 113 | net.verify_mode = @verify_ssl 114 | end 115 | net.cert = @ssl_client_cert if @ssl_client_cert 116 | net.key = @ssl_client_key if @ssl_client_key 117 | net.ca_file = @ssl_ca_file if @ssl_ca_file 118 | net.read_timeout = @timeout if @timeout 119 | net.open_timeout = @open_timeout if @open_timeout 120 | 121 | display_log request_log 122 | 123 | net.start do |http| 124 | res = http.request(req, payload) { |http_response| fetch_body(http_response) } 125 | result = process_result(res) 126 | display_log response_log(res) 127 | 128 | if result.kind_of?(String) or @method == :head 129 | Response.new(result, res) 130 | elsif @raw_response 131 | RawResponse.new(@tf, res) 132 | else 133 | nil 134 | end 135 | end 136 | rescue EOFError 137 | raise RestClient::ServerBrokeConnection 138 | rescue Timeout::Error 139 | raise RestClient::RequestTimeout 140 | end 141 | 142 | def setup_credentials(req) 143 | req.basic_auth(user, password) if user 144 | end 145 | 146 | def fetch_body(http_response) 147 | if @raw_response 148 | # Taken from Chef, which as in turn... 149 | # Stolen from http://www.ruby-forum.com/topic/166423 150 | # Kudos to _why! 151 | @tf = Tempfile.new("rest-client") 152 | size, total = 0, http_response.header['Content-Length'].to_i 153 | http_response.read_body do |chunk| 154 | @tf.write(chunk) 155 | size += chunk.size 156 | if size == 0 157 | display_log("#{@method} #{@url} done (0 length file)") 158 | elsif total == 0 159 | display_log("#{@method} #{@url} (zero content length)") 160 | else 161 | display_log("#{@method} #{@url} %d%% done (%d of %d)" % [(size * 100) / total, size, total]) 162 | end 163 | end 164 | @tf.close 165 | @tf 166 | else 167 | http_response.read_body 168 | end 169 | http_response 170 | end 171 | 172 | def process_result(res) 173 | if res.code =~ /\A2\d{2}\z/ 174 | # We don't decode raw requests 175 | unless @raw_response 176 | self.class.decode res['content-encoding'], res.body if res.body 177 | end 178 | elsif %w(301 302 303).include? res.code 179 | url = res.header['Location'] 180 | 181 | if url !~ /^http/ 182 | uri = URI.parse(@url) 183 | uri.path = "/#{url}".squeeze('/') 184 | url = uri.to_s 185 | end 186 | 187 | raise Redirect, url 188 | elsif res.code == "304" 189 | raise NotModified, res 190 | elsif res.code == "401" 191 | raise Unauthorized, res 192 | elsif res.code == "404" 193 | raise ResourceNotFound, res 194 | else 195 | raise RequestFailed, res 196 | end 197 | end 198 | 199 | def self.decode(content_encoding, body) 200 | if content_encoding == 'gzip' and not body.empty? 201 | Zlib::GzipReader.new(StringIO.new(body)).read 202 | elsif content_encoding == 'deflate' 203 | Zlib::Inflate.new.inflate(body) 204 | else 205 | body 206 | end 207 | end 208 | 209 | def request_log 210 | out = [] 211 | out << "RestClient.#{method} #{url.inspect}" 212 | out << (payload.size > 100 ? "(#{payload.size} byte payload)".inspect : payload.inspect) if payload 213 | out << headers.inspect.gsub(/^\{/, '').gsub(/\}$/, '') unless headers.empty? 214 | out.join(', ') 215 | end 216 | 217 | def response_log(res) 218 | size = @raw_response ? File.size(@tf.path) : (res.body.nil? ? 0 : res.body.size) 219 | "# => #{res.code} #{res.class.to_s.gsub(/^Net::HTTP/, '')} | #{(res['Content-type'] || '').gsub(/;.*$/, '')} #{size} bytes" 220 | end 221 | 222 | def display_log(msg) 223 | return unless log_to = RestClient.log 224 | 225 | if log_to == 'stdout' 226 | STDOUT.puts msg 227 | elsif log_to == 'stderr' 228 | STDERR.puts msg 229 | else 230 | File.open(log_to, 'a') { |f| f.puts msg } 231 | end 232 | end 233 | 234 | def default_headers 235 | { :accept => '*/*; q=0.5, application/xml', :accept_encoding => 'gzip, deflate' } 236 | end 237 | end 238 | end 239 | -------------------------------------------------------------------------------- /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 38 | 39 | def initialize(url, options={}, backwards_compatibility=nil) 40 | @url = url 41 | if options.class == Hash 42 | @options = options 43 | else # compatibility with previous versions 44 | @options = { :user => options, :password => backwards_compatibility } 45 | end 46 | end 47 | 48 | def get(additional_headers={}) 49 | Request.execute(options.merge( 50 | :method => :get, 51 | :url => url, 52 | :headers => headers.merge(additional_headers) 53 | )) 54 | end 55 | 56 | def post(payload, additional_headers={}) 57 | Request.execute(options.merge( 58 | :method => :post, 59 | :url => url, 60 | :payload => payload, 61 | :headers => headers.merge(additional_headers) 62 | )) 63 | end 64 | 65 | def put(payload, additional_headers={}) 66 | Request.execute(options.merge( 67 | :method => :put, 68 | :url => url, 69 | :payload => payload, 70 | :headers => headers.merge(additional_headers) 71 | )) 72 | end 73 | 74 | def delete(additional_headers={}) 75 | Request.execute(options.merge( 76 | :method => :delete, 77 | :url => url, 78 | :headers => headers.merge(additional_headers) 79 | )) 80 | end 81 | 82 | def to_s 83 | url 84 | end 85 | 86 | def user 87 | options[:user] 88 | end 89 | 90 | def password 91 | options[:password] 92 | end 93 | 94 | def headers 95 | options[:headers] || {} 96 | end 97 | 98 | def timeout 99 | options[:timeout] 100 | end 101 | 102 | def open_timeout 103 | options[:open_timeout] 104 | end 105 | 106 | # Construct a subresource, preserving authentication. 107 | # 108 | # Example: 109 | # 110 | # site = RestClient::Resource.new('http://example.com', 'adam', 'mypasswd') 111 | # site['posts/1/comments'].post 'Good article.', :content_type => 'text/plain' 112 | # 113 | # This is especially useful if you wish to define your site in one place and 114 | # call it in multiple locations: 115 | # 116 | # def orders 117 | # RestClient::Resource.new('http://example.com/orders', 'admin', 'mypasswd') 118 | # end 119 | # 120 | # orders.get # GET http://example.com/orders 121 | # orders['1'].get # GET http://example.com/orders/1 122 | # orders['1/items'].delete # DELETE http://example.com/orders/1/items 123 | # 124 | # Nest resources as far as you want: 125 | # 126 | # site = RestClient::Resource.new('http://example.com') 127 | # posts = site['posts'] 128 | # first_post = posts['1'] 129 | # comments = first_post['comments'] 130 | # comments.post 'Hello', :content_type => 'text/plain' 131 | # 132 | def [](suburl) 133 | self.class.new(concat_urls(url, suburl), options) 134 | end 135 | 136 | def concat_urls(url, suburl) # :nodoc: 137 | url = url.to_s 138 | suburl = suburl.to_s 139 | if url.slice(-1, 1) == '/' or suburl.slice(0, 1) == '/' 140 | url + suburl 141 | else 142 | "#{url}/#{suburl}" 143 | end 144 | end 145 | end 146 | end 147 | -------------------------------------------------------------------------------- /lib/restclient/response.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/mixin/response' 2 | 3 | module RestClient 4 | # The response from RestClient looks like a string, but is actually one of 5 | # these. 99% of the time you're making a rest call all you care about is 6 | # the body, but on the occassion you want to fetch the headers you can: 7 | # 8 | # RestClient.get('http://example.com').headers[:content_type] 9 | # 10 | class Response < String 11 | 12 | include RestClient::Mixin::Response 13 | 14 | def initialize(string, net_http_res) 15 | @net_http_res = net_http_res 16 | super(string || "") 17 | end 18 | 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /rest-client.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | Gem::Specification.new do |s| 4 | s.name = %q{rest-client} 5 | s.version = "1.0.4" 6 | 7 | s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= 8 | s.authors = ["Adam Wiggins"] 9 | s.date = %q{2009-07-29} 10 | s.default_executable = %q{restclient} 11 | s.description = %q{A simple REST client for Ruby, inspired by the Sinatra microframework style of specifying actions: get, put, post, delete.} 12 | s.email = %q{adam@heroku.com} 13 | s.executables = ["restclient"] 14 | s.extra_rdoc_files = [ 15 | "README.rdoc" 16 | ] 17 | s.files = [ 18 | "README.rdoc", 19 | "Rakefile", 20 | "VERSION", 21 | "bin/restclient", 22 | "lib/rest_client.rb", 23 | "lib/restclient.rb", 24 | "lib/restclient/exceptions.rb", 25 | "lib/restclient/mixin/response.rb", 26 | "lib/restclient/raw_response.rb", 27 | "lib/restclient/request.rb", 28 | "lib/restclient/resource.rb", 29 | "lib/restclient/response.rb", 30 | "spec/base.rb", 31 | "spec/exceptions_spec.rb", 32 | "spec/mixin/response_spec.rb", 33 | "spec/raw_response_spec.rb", 34 | "spec/request_spec.rb", 35 | "spec/resource_spec.rb", 36 | "spec/response_spec.rb", 37 | "spec/restclient_spec.rb" 38 | ] 39 | s.has_rdoc = true 40 | s.homepage = %q{http://rest-client.heroku.com/} 41 | s.rdoc_options = ["--charset=UTF-8"] 42 | s.require_paths = ["lib"] 43 | s.rubyforge_project = %q{rest-client} 44 | s.rubygems_version = %q{1.3.1} 45 | s.summary = %q{Simple REST client for Ruby, inspired by microframework syntax for specifying actions.} 46 | s.test_files = [ 47 | "spec/base.rb", 48 | "spec/exceptions_spec.rb", 49 | "spec/mixin/response_spec.rb", 50 | "spec/raw_response_spec.rb", 51 | "spec/request_spec.rb", 52 | "spec/resource_spec.rb", 53 | "spec/response_spec.rb", 54 | "spec/restclient_spec.rb" 55 | ] 56 | 57 | if s.respond_to? :specification_version then 58 | current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION 59 | s.specification_version = 2 60 | 61 | if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then 62 | else 63 | end 64 | else 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /spec/base.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'spec' 3 | 4 | require File.dirname(__FILE__) + '/../lib/restclient' 5 | -------------------------------------------------------------------------------- /spec/exceptions_spec.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/base' 2 | 3 | describe RestClient::Exception do 4 | it "sets the exception message to ErrorMessage" do 5 | RestClient::ResourceNotFound.new.message.should == 'Resource not found' 6 | end 7 | 8 | it "contains exceptions in RestClient" do 9 | RestClient::Unauthorized.new.should be_a_kind_of(RestClient::Exception) 10 | RestClient::ServerBrokeConnection.new.should be_a_kind_of(RestClient::Exception) 11 | end 12 | end 13 | 14 | describe RestClient::RequestFailed do 15 | before do 16 | @response = mock('HTTP Response', :code => '502') 17 | end 18 | 19 | it "stores the http response on the exception" do 20 | begin 21 | raise RestClient::RequestFailed, :response 22 | rescue RestClient::RequestFailed => e 23 | e.response.should == :response 24 | end 25 | end 26 | 27 | it "http_code convenience method for fetching the code as an integer" do 28 | RestClient::RequestFailed.new(@response).http_code.should == 502 29 | end 30 | 31 | it "http_body convenience method for fetching the body (decoding when necessary)" do 32 | @response.stub!(:[]).with('content-encoding').and_return('gzip') 33 | @response.stub!(:body).and_return('compressed body') 34 | RestClient::Request.should_receive(:decode).with('gzip', 'compressed body').and_return('plain body') 35 | RestClient::RequestFailed.new(@response).http_body.should == 'plain body' 36 | end 37 | 38 | it "shows the status code in the message" do 39 | RestClient::RequestFailed.new(@response).to_s.should match(/502/) 40 | end 41 | end 42 | 43 | describe RestClient::ResourceNotFound do 44 | it "also has the http response attached" do 45 | begin 46 | raise RestClient::ResourceNotFound, :response 47 | rescue RestClient::ResourceNotFound => e 48 | e.response.should == :response 49 | end 50 | end 51 | end 52 | 53 | describe "backwards compatibility" do 54 | it "alias RestClient::Request::Redirect to RestClient::Redirect" do 55 | RestClient::Request::Redirect.should == RestClient::Redirect 56 | end 57 | 58 | it "alias RestClient::Request::Unauthorized to RestClient::Unauthorized" do 59 | RestClient::Request::Unauthorized.should == RestClient::Unauthorized 60 | end 61 | 62 | it "alias RestClient::Request::RequestFailed to RestClient::RequestFailed" do 63 | RestClient::Request::RequestFailed.should == RestClient::RequestFailed 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /spec/mixin/response_spec.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/../base' 2 | 3 | class MockResponse 4 | include RestClient::Mixin::Response 5 | 6 | def initialize(body, res) 7 | @net_http_res = res 8 | @body = @body 9 | end 10 | end 11 | 12 | describe RestClient::Mixin::Response do 13 | before do 14 | @net_http_res = mock('net http response') 15 | @response = MockResponse.new('abc', @net_http_res) 16 | end 17 | 18 | it "fetches the numeric response code" do 19 | @net_http_res.should_receive(:code).and_return('200') 20 | @response.code.should == 200 21 | end 22 | 23 | it "beautifies the headers by turning the keys to symbols" do 24 | h = RestClient::Response.beautify_headers('content-type' => [ 'x' ]) 25 | h.keys.first.should == :content_type 26 | end 27 | 28 | it "beautifies the headers by turning the values to strings instead of one-element arrays" do 29 | h = RestClient::Response.beautify_headers('x' => [ 'text/html' ] ) 30 | h.values.first.should == 'text/html' 31 | end 32 | 33 | it "fetches the headers" do 34 | @net_http_res.should_receive(:to_hash).and_return('content-type' => [ 'text/html' ]) 35 | @response.headers.should == { :content_type => 'text/html' } 36 | end 37 | 38 | it "extracts cookies from response headers" do 39 | @net_http_res.should_receive(:to_hash).and_return('set-cookie' => ['session_id=1; path=/']) 40 | @response.cookies.should == { 'session_id' => '1' } 41 | end 42 | 43 | it "can access the net http result directly" do 44 | @response.net_http_res.should == @net_http_res 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/raw_response_spec.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__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 | -------------------------------------------------------------------------------- /spec/request_spec.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/base' 2 | 3 | describe RestClient::Request do 4 | before do 5 | @request = RestClient::Request.new(:method => :put, :url => 'http://some/resource', :payload => 'payload') 6 | 7 | @uri = mock("uri") 8 | @uri.stub!(:request_uri).and_return('/resource') 9 | @uri.stub!(:host).and_return('some') 10 | @uri.stub!(:port).and_return(80) 11 | 12 | @net = mock("net::http base") 13 | @http = mock("net::http connection") 14 | Net::HTTP.stub!(:new).and_return(@net) 15 | @net.stub!(:start).and_yield(@http) 16 | @net.stub!(:use_ssl=) 17 | @net.stub!(:verify_mode=) 18 | end 19 | 20 | it "accept */* mimetype, preferring xml" do 21 | @request.default_headers[:accept].should == '*/*; q=0.5, application/xml' 22 | end 23 | 24 | it "decodes an uncompressed result body by passing it straight through" do 25 | RestClient::Request.decode(nil, 'xyz').should == 'xyz' 26 | end 27 | 28 | it "decodes a gzip body" do 29 | 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" 30 | end 31 | 32 | it "ingores gzip for empty bodies" do 33 | RestClient::Request.decode('gzip', '').should be_empty 34 | end 35 | 36 | it "decodes a deflated body" do 37 | RestClient::Request.decode('deflate', "x\234+\316\317MUHIM\313I,IMQ(I\255(\001\000A\223\006\363").should == "some deflated text" 38 | end 39 | 40 | it "processes a successful result" do 41 | res = mock("result") 42 | res.stub!(:code).and_return("200") 43 | res.stub!(:body).and_return('body') 44 | res.stub!(:[]).with('content-encoding').and_return(nil) 45 | @request.process_result(res).should == 'body' 46 | end 47 | 48 | it "doesn't classify successful requests as failed" do 49 | 203.upto(206) do |code| 50 | res = mock("result") 51 | res.stub!(:code).and_return(code.to_s) 52 | res.stub!(:body).and_return("") 53 | res.stub!(:[]).with('content-encoding').and_return(nil) 54 | @request.process_result(res).should be_empty 55 | end 56 | end 57 | 58 | it "parses a url into a URI object" do 59 | URI.should_receive(:parse).with('http://example.com/resource') 60 | @request.parse_url('http://example.com/resource') 61 | end 62 | 63 | it "adds http:// to the front of resources specified in the syntax example.com/resource" do 64 | URI.should_receive(:parse).with('http://example.com/resource') 65 | @request.parse_url('example.com/resource') 66 | end 67 | 68 | it "extracts the username and password when parsing http://user:password@example.com/" do 69 | URI.stub!(:parse).and_return(mock('uri', :user => 'joe', :password => 'pass1')) 70 | @request.parse_url_with_auth('http://joe:pass1@example.com/resource') 71 | @request.user.should == 'joe' 72 | @request.password.should == 'pass1' 73 | end 74 | 75 | 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 76 | URI.stub!(:parse).and_return(mock('uri', :user => nil, :password => nil)) 77 | @request = RestClient::Request.new(:method => 'get', :url => 'example.com', :user => 'beth', :password => 'pass2') 78 | @request.parse_url_with_auth('http://example.com/resource') 79 | @request.user.should == 'beth' 80 | @request.password.should == 'pass2' 81 | end 82 | 83 | it "correctly formats cookies provided to the constructor" do 84 | URI.stub!(:parse).and_return(mock('uri', :user => nil, :password => nil)) 85 | @request = RestClient::Request.new(:method => 'get', :url => 'example.com', :cookies => {:session_id => '1' }) 86 | @request.should_receive(:default_headers).and_return({'foo' => 'bar'}) 87 | headers = @request.make_headers({}).should == { 'Foo' => 'bar', 'Cookie' => 'session_id=1'} 88 | end 89 | 90 | it "determines the Net::HTTP class to instantiate by the method name" do 91 | @request.net_http_request_class(:put).should == Net::HTTP::Put 92 | end 93 | 94 | it "merges user headers with the default headers" do 95 | @request.should_receive(:default_headers).and_return({ '1' => '2' }) 96 | @request.make_headers('3' => '4').should == { '1' => '2', '3' => '4' } 97 | end 98 | 99 | it "prefers the user header when the same header exists in the defaults" do 100 | @request.should_receive(:default_headers).and_return({ '1' => '2' }) 101 | @request.make_headers('1' => '3').should == { '1' => '3' } 102 | end 103 | 104 | it "converts header symbols from :content_type to 'Content-type'" do 105 | @request.should_receive(:default_headers).and_return({}) 106 | @request.make_headers(:content_type => 'abc').should == { 'Content-type' => 'abc' } 107 | end 108 | 109 | it "converts header values to strings" do 110 | @request.make_headers('A' => 1)['A'].should == '1' 111 | end 112 | 113 | it "executes by constructing the Net::HTTP object, headers, and payload and calling transmit" do 114 | @request.should_receive(:parse_url_with_auth).with('http://some/resource').and_return(@uri) 115 | klass = mock("net:http class") 116 | @request.should_receive(:net_http_request_class).with(:put).and_return(klass) 117 | klass.should_receive(:new).and_return('result') 118 | @request.should_receive(:transmit).with(@uri, 'result', 'payload') 119 | @request.execute_inner 120 | end 121 | 122 | it "transmits the request with Net::HTTP" do 123 | @http.should_receive(:request).with('req', 'payload') 124 | @request.should_receive(:process_result) 125 | @request.should_receive(:response_log) 126 | @request.transmit(@uri, 'req', 'payload') 127 | end 128 | 129 | it "uses SSL when the URI refers to a https address" do 130 | @uri.stub!(:is_a?).with(URI::HTTPS).and_return(true) 131 | @net.should_receive(:use_ssl=).with(true) 132 | @http.stub!(:request) 133 | @request.stub!(:process_result) 134 | @request.stub!(:response_log) 135 | @request.transmit(@uri, 'req', 'payload') 136 | end 137 | 138 | it "sends nil payloads" do 139 | @http.should_receive(:request).with('req', nil) 140 | @request.should_receive(:process_result) 141 | @request.stub!(:response_log) 142 | @request.transmit(@uri, 'req', nil) 143 | end 144 | 145 | it "passes non-hash payloads straight through" do 146 | @request.process_payload("x").should == "x" 147 | end 148 | 149 | it "converts a hash payload to urlencoded data" do 150 | @request.process_payload(:a => 'b c+d').should == "a=b%20c%2Bd" 151 | end 152 | 153 | it "accepts nested hashes in payload" do 154 | payload = @request.process_payload(:user => { :name => 'joe', :location => { :country => 'USA', :state => 'CA' }}) 155 | payload.should include('user[name]=joe') 156 | payload.should include('user[location][country]=USA') 157 | payload.should include('user[location][state]=CA') 158 | end 159 | 160 | it "set urlencoded content_type header on hash payloads" do 161 | @request.process_payload(:a => 1) 162 | @request.headers[:content_type].should == 'application/x-www-form-urlencoded' 163 | end 164 | 165 | it "sets up the credentials prior to the request" do 166 | @http.stub!(:request) 167 | @request.stub!(:process_result) 168 | @request.stub!(:response_log) 169 | 170 | @request.stub!(:user).and_return('joe') 171 | @request.stub!(:password).and_return('mypass') 172 | @request.should_receive(:setup_credentials).with('req') 173 | 174 | @request.transmit(@uri, 'req', nil) 175 | end 176 | 177 | it "does not attempt to send any credentials if user is nil" do 178 | @request.stub!(:user).and_return(nil) 179 | req = mock("request") 180 | req.should_not_receive(:basic_auth) 181 | @request.setup_credentials(req) 182 | end 183 | 184 | it "setup credentials when there's a user" do 185 | @request.stub!(:user).and_return('joe') 186 | @request.stub!(:password).and_return('mypass') 187 | req = mock("request") 188 | req.should_receive(:basic_auth).with('joe', 'mypass') 189 | @request.setup_credentials(req) 190 | end 191 | 192 | it "catches EOFError and shows the more informative ServerBrokeConnection" do 193 | @http.stub!(:request).and_raise(EOFError) 194 | lambda { @request.transmit(@uri, 'req', nil) }.should raise_error(RestClient::ServerBrokeConnection) 195 | end 196 | 197 | it "execute calls execute_inner" do 198 | @request.should_receive(:execute_inner) 199 | @request.execute 200 | end 201 | 202 | it "class method execute wraps constructor" do 203 | req = mock("rest request") 204 | RestClient::Request.should_receive(:new).with(1 => 2).and_return(req) 205 | req.should_receive(:execute) 206 | RestClient::Request.execute(1 => 2) 207 | end 208 | 209 | it "raises a Redirect with the new location when the response is in the 30x range" do 210 | res = mock('response', :code => '301', :header => { 'Location' => 'http://new/resource' }) 211 | lambda { @request.process_result(res) }.should raise_error(RestClient::Redirect) { |e| e.url.should == 'http://new/resource'} 212 | end 213 | 214 | it "handles redirects with relative paths" do 215 | res = mock('response', :code => '301', :header => { 'Location' => 'index' }) 216 | lambda { @request.process_result(res) }.should raise_error(RestClient::Redirect) { |e| e.url.should == 'http://some/index' } 217 | end 218 | 219 | it "handles redirects with absolute paths" do 220 | @request.instance_variable_set('@url', 'http://some/place/else') 221 | res = mock('response', :code => '301', :header => { 'Location' => '/index' }) 222 | lambda { @request.process_result(res) }.should raise_error(RestClient::Redirect) { |e| e.url.should == 'http://some/index' } 223 | end 224 | 225 | it "uses GET and clears payload when following 30x redirects" do 226 | url = "http://example.com/redirected" 227 | 228 | @request.should_receive(:execute_inner).once.ordered.and_raise(RestClient::Redirect.new(url)) 229 | 230 | @request.should_receive(:execute_inner).once.ordered do 231 | @request.url.should == url 232 | @request.method.should == :get 233 | @request.payload.should be_nil 234 | end 235 | 236 | @request.execute 237 | end 238 | 239 | it "raises Unauthorized when the response is 401" do 240 | res = mock('response', :code => '401') 241 | lambda { @request.process_result(res) }.should raise_error(RestClient::Unauthorized) 242 | end 243 | 244 | it "raises ResourceNotFound when the response is 404" do 245 | res = mock('response', :code => '404') 246 | lambda { @request.process_result(res) }.should raise_error(RestClient::ResourceNotFound) 247 | end 248 | 249 | it "raises RequestFailed otherwise" do 250 | res = mock('response', :code => '500') 251 | lambda { @request.process_result(res) }.should raise_error(RestClient::RequestFailed) 252 | end 253 | 254 | it "creates a proxy class if a proxy url is given" do 255 | RestClient.stub!(:proxy).and_return("http://example.com/") 256 | @request.net_http_class.should include(Net::HTTP::ProxyDelta) 257 | end 258 | 259 | it "creates a non-proxy class if a proxy url is not given" do 260 | @request.net_http_class.should_not include(Net::HTTP::ProxyDelta) 261 | end 262 | 263 | it "logs a get request" do 264 | RestClient::Request.new(:method => :get, :url => 'http://url').request_log.should == 265 | 'RestClient.get "http://url"' 266 | end 267 | 268 | it "logs a post request with a small payload" do 269 | RestClient::Request.new(:method => :post, :url => 'http://url', :payload => 'foo').request_log.should == 270 | 'RestClient.post "http://url", "foo"' 271 | end 272 | 273 | it "logs a post request with a large payload" do 274 | RestClient::Request.new(:method => :post, :url => 'http://url', :payload => ('x' * 1000)).request_log.should == 275 | 'RestClient.post "http://url", "(1000 byte payload)"' 276 | end 277 | 278 | it "logs input headers as a hash" do 279 | RestClient::Request.new(:method => :get, :url => 'http://url', :headers => { :accept => 'text/plain' }).request_log.should == 280 | 'RestClient.get "http://url", :accept=>"text/plain"' 281 | end 282 | 283 | it "logs a response including the status code, content type, and result body size in bytes" do 284 | res = mock('result', :code => '200', :class => Net::HTTPOK, :body => 'abcd') 285 | res.stub!(:[]).with('Content-type').and_return('text/html') 286 | @request.response_log(res).should == "# => 200 OK | text/html 4 bytes" 287 | end 288 | 289 | it "logs a response with a nil Content-type" do 290 | res = mock('result', :code => '200', :class => Net::HTTPOK, :body => 'abcd') 291 | res.stub!(:[]).with('Content-type').and_return(nil) 292 | @request.response_log(res).should == "# => 200 OK | 4 bytes" 293 | end 294 | 295 | it "logs a response with a nil body" do 296 | res = mock('result', :code => '200', :class => Net::HTTPOK, :body => nil) 297 | res.stub!(:[]).with('Content-type').and_return('text/html; charset=utf-8') 298 | @request.response_log(res).should == "# => 200 OK | text/html 0 bytes" 299 | end 300 | 301 | it "strips the charset from the response content type" do 302 | res = mock('result', :code => '200', :class => Net::HTTPOK, :body => 'abcd') 303 | res.stub!(:[]).with('Content-type').and_return('text/html; charset=utf-8') 304 | @request.response_log(res).should == "# => 200 OK | text/html 4 bytes" 305 | end 306 | 307 | it "displays the log to stdout" do 308 | RestClient.stub!(:log).and_return('stdout') 309 | STDOUT.should_receive(:puts).with('xyz') 310 | @request.display_log('xyz') 311 | end 312 | 313 | it "displays the log to stderr" do 314 | RestClient.stub!(:log).and_return('stderr') 315 | STDERR.should_receive(:puts).with('xyz') 316 | @request.display_log('xyz') 317 | end 318 | 319 | it "append the log to the requested filename" do 320 | RestClient.stub!(:log).and_return('/tmp/restclient.log') 321 | f = mock('file handle') 322 | File.should_receive(:open).with('/tmp/restclient.log', 'a').and_yield(f) 323 | f.should_receive(:puts).with('xyz') 324 | @request.display_log('xyz') 325 | end 326 | 327 | it "set read_timeout" do 328 | @request = RestClient::Request.new(:method => :put, :url => 'http://some/resource', :payload => 'payload', :timeout => 123) 329 | @http.stub!(:request) 330 | @request.stub!(:process_result) 331 | @request.stub!(:response_log) 332 | 333 | @net.should_receive(:read_timeout=).with(123) 334 | 335 | @request.transmit(@uri, 'req', nil) 336 | end 337 | 338 | it "set open_timeout" do 339 | @request = RestClient::Request.new(:method => :put, :url => 'http://some/resource', :payload => 'payload', :open_timeout => 123) 340 | @http.stub!(:request) 341 | @request.stub!(:process_result) 342 | @request.stub!(:response_log) 343 | 344 | @net.should_receive(:open_timeout=).with(123) 345 | 346 | @request.transmit(@uri, 'req', nil) 347 | end 348 | 349 | it "should default to not verifying ssl certificates" do 350 | @request.verify_ssl.should == false 351 | end 352 | 353 | it "should set net.verify_mode to OpenSSL::SSL::VERIFY_NONE if verify_ssl is false" do 354 | @net.should_receive(:verify_mode=).with(OpenSSL::SSL::VERIFY_NONE) 355 | @http.stub!(:request) 356 | @request.stub!(:process_result) 357 | @request.stub!(:response_log) 358 | @request.transmit(@uri, 'req', 'payload') 359 | end 360 | 361 | it "should not set net.verify_mode to OpenSSL::SSL::VERIFY_NONE if verify_ssl is true" do 362 | @request = RestClient::Request.new(:method => :put, :url => 'https://some/resource', :payload => 'payload', :verify_ssl => true) 363 | @net.should_not_receive(:verify_mode=).with(OpenSSL::SSL::VERIFY_NONE) 364 | @http.stub!(:request) 365 | @request.stub!(:process_result) 366 | @request.stub!(:response_log) 367 | @request.transmit(@uri, 'req', 'payload') 368 | end 369 | 370 | it "should set net.verify_mode to the passed value if verify_ssl is an OpenSSL constant" do 371 | mode = OpenSSL::SSL::VERIFY_PEER | 372 | OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT 373 | @request = RestClient::Request.new( :method => :put, 374 | :url => 'https://some/resource', 375 | :payload => 'payload', 376 | :verify_ssl => mode ) 377 | @net.should_receive(:verify_mode=).with(mode) 378 | @http.stub!(:request) 379 | @request.stub!(:process_result) 380 | @request.stub!(:response_log) 381 | @request.transmit(@uri, 'req', 'payload') 382 | end 383 | 384 | it "should default to not having an ssl_client_cert" do 385 | @request.ssl_client_cert.should be(nil) 386 | end 387 | 388 | it "should set the ssl_client_cert if provided" do 389 | @request = RestClient::Request.new( 390 | :method => :put, 391 | :url => 'https://some/resource', 392 | :payload => 'payload', 393 | :ssl_client_cert => "whatsupdoc!" 394 | ) 395 | @net.should_receive(:cert=).with("whatsupdoc!") 396 | @http.stub!(:request) 397 | @request.stub!(:process_result) 398 | @request.stub!(:response_log) 399 | @request.transmit(@uri, 'req', 'payload') 400 | end 401 | 402 | it "should not set the ssl_client_cert if it is not provided" do 403 | @request = RestClient::Request.new( 404 | :method => :put, 405 | :url => 'https://some/resource', 406 | :payload => 'payload' 407 | ) 408 | @net.should_not_receive(:cert=).with("whatsupdoc!") 409 | @http.stub!(:request) 410 | @request.stub!(:process_result) 411 | @request.stub!(:response_log) 412 | @request.transmit(@uri, 'req', 'payload') 413 | end 414 | 415 | it "should default to not having an ssl_client_key" do 416 | @request.ssl_client_key.should be(nil) 417 | end 418 | 419 | it "should set the ssl_client_key if provided" do 420 | @request = RestClient::Request.new( 421 | :method => :put, 422 | :url => 'https://some/resource', 423 | :payload => 'payload', 424 | :ssl_client_key => "whatsupdoc!" 425 | ) 426 | @net.should_receive(:key=).with("whatsupdoc!") 427 | @http.stub!(:request) 428 | @request.stub!(:process_result) 429 | @request.stub!(:response_log) 430 | @request.transmit(@uri, 'req', 'payload') 431 | end 432 | 433 | it "should not set the ssl_client_key if it is not provided" do 434 | @request = RestClient::Request.new( 435 | :method => :put, 436 | :url => 'https://some/resource', 437 | :payload => 'payload' 438 | ) 439 | @net.should_not_receive(:key=).with("whatsupdoc!") 440 | @http.stub!(:request) 441 | @request.stub!(:process_result) 442 | @request.stub!(:response_log) 443 | @request.transmit(@uri, 'req', 'payload') 444 | end 445 | 446 | it "should default to not having an ssl_ca_file" do 447 | @request.ssl_ca_file.should be(nil) 448 | end 449 | 450 | it "should set the ssl_ca_file if provided" do 451 | @request = RestClient::Request.new( 452 | :method => :put, 453 | :url => 'https://some/resource', 454 | :payload => 'payload', 455 | :ssl_ca_file => "Certificate Authority File" 456 | ) 457 | @net.should_receive(:ca_file=).with("Certificate Authority File") 458 | @http.stub!(:request) 459 | @request.stub!(:process_result) 460 | @request.stub!(:response_log) 461 | @request.transmit(@uri, 'req', 'payload') 462 | end 463 | 464 | it "should not set the ssl_ca_file if it is not provided" do 465 | @request = RestClient::Request.new( 466 | :method => :put, 467 | :url => 'https://some/resource', 468 | :payload => 'payload' 469 | ) 470 | @net.should_not_receive(:ca_file=).with("Certificate Authority File") 471 | @http.stub!(:request) 472 | @request.stub!(:process_result) 473 | @request.stub!(:response_log) 474 | @request.transmit(@uri, 'req', 'payload') 475 | end 476 | end 477 | -------------------------------------------------------------------------------- /spec/resource_spec.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/base' 2 | 3 | describe RestClient::Resource do 4 | before do 5 | @resource = RestClient::Resource.new('http://some/resource', :user => 'jane', :password => 'mypass', :headers => { 'X-Something' => '1'}) 6 | end 7 | 8 | context "Resource delegation" do 9 | it "GET" do 10 | RestClient::Request.should_receive(:execute).with(:method => :get, :url => 'http://some/resource', :headers => { 'X-Something' => '1'}, :user => 'jane', :password => 'mypass') 11 | @resource.get 12 | end 13 | 14 | it "POST" do 15 | 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') 16 | @resource.post 'abc', :content_type => 'image/jpg' 17 | end 18 | 19 | it "PUT" do 20 | 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') 21 | @resource.put 'abc', :content_type => 'image/jpg' 22 | end 23 | 24 | it "DELETE" do 25 | RestClient::Request.should_receive(:execute).with(:method => :delete, :url => 'http://some/resource', :headers => { 'X-Something' => '1'}, :user => 'jane', :password => 'mypass') 26 | @resource.delete 27 | end 28 | 29 | it "overrides resource headers" do 30 | RestClient::Request.should_receive(:execute).with(:method => :get, :url => 'http://some/resource', :headers => { 'X-Something' => '2'}, :user => 'jane', :password => 'mypass') 31 | @resource.get 'X-Something' => '2' 32 | end 33 | end 34 | 35 | it "can instantiate with no user/password" do 36 | @resource = RestClient::Resource.new('http://some/resource') 37 | end 38 | 39 | it "is backwards compatible with previous constructor" do 40 | @resource = RestClient::Resource.new('http://some/resource', 'user', 'pass') 41 | @resource.user.should == 'user' 42 | @resource.password.should == 'pass' 43 | end 44 | 45 | it "concatinates urls, inserting a slash when it needs one" do 46 | @resource.concat_urls('http://example.com', 'resource').should == 'http://example.com/resource' 47 | end 48 | 49 | it "concatinates urls, using no slash if the first url ends with a slash" do 50 | @resource.concat_urls('http://example.com/', 'resource').should == 'http://example.com/resource' 51 | end 52 | 53 | it "concatinates urls, using no slash if the second url starts with a slash" do 54 | @resource.concat_urls('http://example.com', '/resource').should == 'http://example.com/resource' 55 | end 56 | 57 | it "concatinates even non-string urls, :posts + 1 => 'posts/1'" do 58 | @resource.concat_urls(:posts, 1).should == 'posts/1' 59 | end 60 | 61 | it "offers subresources via []" do 62 | parent = RestClient::Resource.new('http://example.com') 63 | parent['posts'].url.should == 'http://example.com/posts' 64 | end 65 | 66 | it "transports options to subresources" do 67 | parent = RestClient::Resource.new('http://example.com', :user => 'user', :password => 'password') 68 | parent['posts'].user.should == 'user' 69 | parent['posts'].password.should == 'password' 70 | end 71 | 72 | it "prints its url with to_s" do 73 | RestClient::Resource.new('x').to_s.should == 'x' 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /spec/response_spec.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/base' 2 | 3 | describe RestClient::Response do 4 | before do 5 | @net_http_res = mock('net http response') 6 | @response = RestClient::Response.new('abc', @net_http_res) 7 | end 8 | 9 | it "behaves like string" do 10 | @response.should == 'abc' 11 | end 12 | 13 | it "accepts nil strings and sets it to empty for the case of HEAD" do 14 | RestClient::Response.new(nil, @net_http_res).should == "" 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/restclient_spec.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__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 "DELETE" do 21 | RestClient::Request.should_receive(:execute).with(:method => :delete, :url => 'http://some/resource', :headers => {}) 22 | RestClient.delete('http://some/resource') 23 | end 24 | 25 | it "HEAD" do 26 | RestClient::Request.should_receive(:execute).with(:method => :head, :url => 'http://some/resource', :headers => {}) 27 | RestClient.head('http://some/resource') 28 | end 29 | end 30 | 31 | describe "logging" do 32 | after do 33 | RestClient.log = nil 34 | end 35 | 36 | it "gets the log source from the RESTCLIENT_LOG environment variable" do 37 | ENV.stub!(:[]).with('RESTCLIENT_LOG').and_return('from env') 38 | RestClient.log = 'from class method' 39 | RestClient.log.should == 'from env' 40 | end 41 | 42 | it "sets a destination for log output, used if no environment variable is set" do 43 | ENV.stub!(:[]).with('RESTCLIENT_LOG').and_return(nil) 44 | RestClient.log = 'from class method' 45 | RestClient.log.should == 'from class method' 46 | end 47 | 48 | it "returns nil (no logging) if neither are set (default)" do 49 | ENV.stub!(:[]).with('RESTCLIENT_LOG').and_return(nil) 50 | RestClient.log.should == nil 51 | end 52 | end 53 | end 54 | --------------------------------------------------------------------------------