lib/patron/session.rb



require 'uri'
require 'patron/error'
require 'patron/request'
require 'patron/response_decoding'
require 'patron/response'
require 'patron/session_ext'
require 'patron/util'
require 'patron/header_parser'

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] 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

    # @return [Integer, nil] the time in number seconds that the transfer speed should be below the
    #     `low_speed_limit` for the library to consider it too slow and abort.
    # @see low_speed_limit
    attr_accessor :low_speed_time

    # @return [Integer, nil] the average transfer speed in bytes per second that the transfer should be below
    #    during `low_speed_time` seconds for libcurl to consider it to be too slow and abort.
    # @see low_speed_time
    attr_accessor :low_speed_limit

    private :handle_request, :add_cookie_file, :set_debug_file

    # @return [#call, nil] callable object that will be called with 4 arguments
    #    during request/response execution - `dltotal`, `dlnow`, `ultotal`, `ulnow`.
    #    All these arguments are in bytes.
    attr_accessor :progress_callback

    # 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.low_speed_time         = options.fetch :low_speed_time,        self.low_speed_time
        req.low_speed_limit        = options.fetch :low_speed_limit,       self.low_speed_limit
        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.progress_callback      = options.fetch :progress_callback,     self.progress_callback
        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