## ------------------------------------------------------------------- ## ## Patron HTTP Client: Session class ## Copyright (c) 2008 The Hive http://www.thehive.com/ ## ## Permission is hereby granted, free of charge, to any person obtaining a copy ## of this software and associated documentation files (the "Software"), to deal ## in the Software without restriction, including without limitation the rights ## to use, copy, modify, merge, publish, distribute, sublicense, and/or sell ## copies of the Software, and to permit persons to whom the Software is ## furnished to do so, subject to the following conditions: ## ## The above copyright notice and this permission notice shall be included in ## all copies or substantial portions of the Software. ## ## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR ## IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, ## FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE ## AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER ## LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, ## OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN ## THE SOFTWARE. ## ## ------------------------------------------------------------------- require 'uri' require 'patron/error' require 'patron/request' require 'patron/response_decoding' require 'patron/response' require 'patron/session_ext' require 'patron/util' module Patron # This class represents multiple request/response transactions with an HTTP # server. This is the primary API for Patron. class Session # @return [Integer] HTTP connection timeout in seconds. Defaults to 1 second. attr_accessor :connect_timeout # @return [Integer] HTTP transaction timeout in seconds. Defaults to 5 seconds. attr_accessor :timeout # Maximum number of redirects to follow # Set to 0 to disable and -1 to follow all redirects. Defaults to 5. # @return [Integer] attr_accessor :max_redirects # @return [String] The URL to prepend to all requests. attr_accessor :base_url # Username for http authentication # @return [String,nil] the HTTP basic auth username attr_accessor :username # Password for http authentication # @return [String,nil] the HTTP basic auth password attr_accessor :password # @return [String] Proxy URL in cURL format ('hostname:8080') attr_accessor :proxy # @return [Integer] Proxy type (default is HTTP) # @see Patron::ProxyType attr_accessor :proxy_type # @return [Hash] headers used in all requests. attr_accessor :headers # @return [Symbol] the authentication type for the request (`:basic`, `:digest` or `:token`). # @see Patron::Request#auth_type attr_accessor :auth_type # @return [Boolean] `true` if SSL certificate verification is disabled. # Please consider twice before using this option.. attr_accessor :insecure # @return [String] the SSL version for the requests, or nil if all versions are permitted # The supported values are nil, "SSLv2", "SSLv3", and "TLSv1". attr_accessor :ssl_version # @return [String] the HTTP version for the requests, or "None" if libcurl is to choose permitted versions # The supported values are "None", "HTTPv1_0", "HTTPv1_1", "HTTPv2_0", "HTTPv2_TLS", and "HTTPv2_PRIOR". attr_accessor :http_version # @return [String] path to the CA file used for certificate verification, or `nil` if CURL default is used attr_accessor :cacert # @return [Boolean] whether Content-Range and Content-Length headers should be ignored attr_accessor :ignore_content_length # @return [Integer, nil] # Set the buffer size for this request. This option will # only be set if buffer_size is non-nil attr_accessor :buffer_size # @return [String, nil] # Sets the name of the charset to assume for the response. The argument should be a String that # is an acceptable argument for `Encoding.find()` in Ruby. The variable will only be used if the # response does not specify a charset in it's `Content-Type` header already, if it does that charset # will take precedence. attr_accessor :default_response_charset # @return [Boolean] Force curl to use IPv4 attr_accessor :force_ipv4 # @return [Boolean] Support automatic Content-Encoding decompression and set liberal Accept-Encoding headers attr_accessor :automatic_content_encoding # @return [Fixnum, nil] Limit the amount of bytes downloaded. If it is set to nil # (default) no limit will be applied. # **Note that this only works on libCURL 7.34 and newer** attr_accessor :download_byte_limit private :handle_request, :add_cookie_file, :set_debug_file # Create a new Session object for performing requests. # # @param args[Hash] options for the Session (same names as the writable attributes of the Session) # @yield self def initialize(args = {}, &block) # Allows accessors to be set via constructor hash. Ex: {:base_url => 'www.home.com'} args.each do |attribute, value| send("#{attribute}=", value) end # Allows accessors to be set via block. if block_given? yield self end @headers ||= {} @headers['User-Agent'] ||= Patron.user_agent_string @timeout ||= 5 @connect_timeout ||= 1 @max_redirects ||= 5 @auth_type ||= :basic @force_ipv4 ||= false end # Turn on cookie handling for this session, storing them in memory by # default or in +file+ if specified. The `file` must be readable and # writable. Calling multiple times will add more files. # # @todo the cookie jar and cookie file path options should be split # @param file_path[String] path to an existing cookie jar file, or nil to store cookies in memory # @return self def handle_cookies(file_path = nil) if file_path path = Pathname(file_path).expand_path if !File.exists?(file_path) && !File.writable?(path.dirname) raise ArgumentError, "Can't create file #{path} (permission error)" elsif File.exists?(file_path) && !File.writable?(file_path) raise ArgumentError, "Can't read or write file #{path} (permission error)" end else path = nil end # Apparently calling this with an empty string sets the cookie file, # but calling it with a path to a writable file sets that file to be # the cookie jar (new cookies are written there) add_cookie_file(path.to_s) self end # Enable debug output to stderr or to specified `file`. # # @todo Change to an assignment of an IO object # @param file[String, nil] path to the file to write debug data to, or `nil` to print to `STDERR` # @return self def enable_debug(file = nil) set_debug_file(file.to_s) self end # Retrieve the contents of the specified `url` optionally sending the # specified headers. If the +base_url+ varaible is set then it is prepended # to the +url+ parameter. Any custom headers are merged with the contents # of the +headers+ instance variable. The results are returned in a # Response object. # Notice: this method doesn't accept any `data` argument: if you need to send a request body # with a GET request, when using ElasticSearch for example, please, use the #request method. # # @param url[String] the URL to fetch # @param headers[Hash] the hash of header keys to values # @return [Patron::Response] def get(url, headers = {}) request(:get, url, headers) end # Retrieve the contents of the specified +url+ as with #get, but the # content at the URL is downloaded directly into the specified file. The file will be accessed # by libCURL bypassing the Ruby runtime entirely. # # Note that when using this option, the Response object will have ++nil++ as the body, and you # will need to read your target file for access to the body string). # # @param url[String] the URL to fetch # @param filename[String] path to the file to save the response body in # @return [Patron::Response] def get_file(url, filename, headers = {}) request(:get, url, headers, :file => filename) end # Same as #get but performs a HEAD request. # # @see #get # @param url[String] the URL to fetch # @param headers[Hash] the hash of header keys to values # @return [Patron::Response] def head(url, headers = {}) request(:head, url, headers) end # Same as #get but performs a DELETE request. # # Notice: this method doesn't accept any `data` argument: if you need to send data with # a delete request (as might be needed for ElasticSearch), please, use the #request method. # # @param url[String] the URL to fetch # @param headers[Hash] the hash of header keys to values # @return [Patron::Response] def delete(url, headers = {}) request(:delete, url, headers) end # Uploads the passed `data` to the specified `url` using an HTTP PUT. Note that # unline ++post++, a Hash is not accepted as the ++data++ argument. # # @todo inconsistency with "post" - Hash not accepted # @param url[String] the URL to fetch # @param data[#to_s, #to_path] an object that can be converted to a String # to create the request body, or that responds to #to_path to upload the # entire request body from that file # @param headers[Hash] the hash of header keys to values # @return [Patron::Response] def put(url, data, headers = {}) request(:put, url, headers, :data => data) end # Uploads the passed `data` to the specified `url` using an HTTP PATCH. Note that # unline ++post++, a Hash is not accepted as the ++data++ argument. # # @todo inconsistency with "post" - Hash not accepted # @param url[String] the URL to fetch # @param data[#to_s, #to_path] an object that can be converted to a String # to create the request body, or that responds to #to_path to upload the # entire request body from that file # @param headers[Hash] the hash of header keys to values # @return [Patron::Response] def patch(url, data, headers = {}) request(:patch, url, headers, :data => data) end # Uploads the contents of `file` to the specified `url` using an HTTP PUT. The file will be # sent "as-is" without any multipart encoding. # # @param url[String] the URL to fetch # @param filename[String] path to the file to be uploaded # @param headers[Hash] the hash of header keys to values # @return [Patron::Response] def put_file(url, filename, headers = {}) request(:put, url, headers, :file => filename) end # Uploads the passed `data` to the specified `url` using an HTTP POST. # # @param url[String] the URL to fetch # @param data[Hash, #to_s, #to_path] a Hash of form fields/values, # or an object that can be converted to a String # to create the request body, or an object that responds to #to_path to upload the # entire request body from that file # @param headers[Hash] the hash of header keys to values # @return [Patron::Response] def post(url, data, headers = {}) if data.is_a?(Hash) data = data.map {|k,v| urlencode(k.to_s) + '=' + urlencode(v.to_s) }.join('&') headers['Content-Type'] = 'application/x-www-form-urlencoded' end request(:post, url, headers, :data => data) end # Uploads the contents of `file` to the specified `url` using an HTTP POST. # The file will be sent "as-is" without any multipart encoding. # # @param url[String] the URL to fetch # @param filename[String] path to the file to be uploaded # @param headers[Hash] the hash of header keys to values # @return [Patron::Response] def post_file(url, filename, headers = {}) request(:post, url, headers, :file => filename) end # Uploads the contents of `filename` to the specified `url` using an HTTP POST, # in combination with given form fields passed in `data`. # # @param url[String] the URL to fetch # @param data[Hash] hash of the form fields # @param filename[String] path to the file to be uploaded # @param headers[Hash] the hash of header keys to values # @return [Patron::Response] def post_multipart(url, data, filename, headers = {}) request(:post, url, headers, {:data => data, :file => filename, :multipart => true}) end # @!group WebDAV methods # Sends a WebDAV COPY request to the specified +url+. # # @param url[String] the URL to copy # @param dest[String] the URL of the COPY destination # @param headers[Hash] the hash of header keys to values # @return [Patron::Response] def copy(url, dest, headers = {}) headers['Destination'] = dest request(:copy, url, headers) end # @!endgroup # @!group Basic API methods # Send an HTTP request to the specified `url`. # # @param action[#to_s] the HTTP verb # @param url[String] the URL for the request # @param headers[Hash] headers to send along with the request # @param options[Hash] any additonal setters to call on the Request # @see Patron::Request # @return [Patron::Response] def request(action, url, headers, options = {}) req = build_request(action, url, headers, options) handle_request(req) end # Returns the class that will be used to build a Response # from a Curl call. # # Primarily useful if you need a very lightweight Response # object that does not have to perform all the parsing of # various headers/status codes. The method must return # a module that supports the same interface for +new+ # as ++Patron::Response++ # # @return [#new] Returns any object that responds to `.new` with 6 arguments # @see Patron::Response#initialize def response_class ::Patron::Response end # Builds a request object that can be used by ++handle_request++ # Note that internally, ++handle_request++ uses instance variables of # the Request object, and not it's public methods. # # @param action[String] the HTTP verb # @param url[#to_s] the addition to the base url component, or a complete URL # @param headers[Hash] a hash of headers, "Accept" will be automatically set to an empty string if not provided # @param options[Hash] any overriding options (will shadow the options from the Session object) # @return [Patron::Request] the request that will be passed to ++handle_request++ def build_request(action, url, headers, options = {}) # If the Expect header isn't set uploads are really slow headers['Expect'] ||= '' Request.new.tap do |req| req.action = action req.headers = self.headers.merge headers req.automatic_content_encoding = options.fetch :automatic_content_encoding, self.automatic_content_encoding req.timeout = options.fetch :timeout, self.timeout req.connect_timeout = options.fetch :connect_timeout, self.connect_timeout req.force_ipv4 = options.fetch :force_ipv4, self.force_ipv4 req.max_redirects = options.fetch :max_redirects, self.max_redirects req.username = options.fetch :username, self.username req.password = options.fetch :password, self.password req.proxy = options.fetch :proxy, self.proxy req.proxy_type = options.fetch :proxy_type, self.proxy_type req.auth_type = options.fetch :auth_type, self.auth_type req.insecure = options.fetch :insecure, self.insecure req.ssl_version = options.fetch :ssl_version, self.ssl_version req.http_version = options.fetch :http_version, self.http_version req.cacert = options.fetch :cacert, self.cacert req.ignore_content_length = options.fetch :ignore_content_length, self.ignore_content_length req.buffer_size = options.fetch :buffer_size, self.buffer_size req.download_byte_limit = options.fetch :download_byte_limit, self.download_byte_limit req.multipart = options[:multipart] req.upload_data = options[:data] req.file_name = options[:file] base_url = self.base_url.to_s url = url.to_s raise ArgumentError, "Empty URL" if base_url.empty? && url.empty? uri = URI.parse(base_url.empty? ? url : File.join(base_url, url)) query = uri.query.to_s.split('&') query += options[:query].is_a?(Hash) ? Util.build_query_pairs_from_hash(options[:query]) : options[:query].to_s.split('&') uri.query = query.join('&') uri.query = nil if uri.query.empty? url = uri.to_s req.url = url end end # @!endgroup end end