require 'cgi'
require 'net/http'
require 'cookie_collection'

#
# == Synopsis
#
# ThinHTTP is a light-weight and user friendly HTTP library.  This class is
# useful for sending simple GET requests or uploading files via POST.
#
# By default, it sends URL encoded data (application/x-www-form-urlencoded).
# MIME multipart encoded data (multipart/form-data) can be sent by utilizing
# the MIME library or the MultipartFormData class.
#
# == Features
#
# * Implements GET and POST requests.
# * Follows redirects using GET requests and set instance's host, port, and
#   path attributes using the final destination URI.
# * Accepts either a params hash or an encoded string for POST and GET
#   requests.
# * User-defined headers are sent with each request.
# * Sends and receives cookies automatically.
# * HTTP support only (HTTPS will be implemented when needed).
#
# == Design Goals
#
# * Extremely simple implementation and easy to use interface.
# * Only implement what is absolutely necessary.
# * Adhere to the RFCs.
# * Utilize third party libraries for added functionality when appropriate
#   (i.e. MIME library for constructing multipart/form-data POSTs).
# * Build lightly on top of the standard Net::HTTP library.
# * Useful as a test tool in unit and integration testing (original design
#   goal).
#
# == Examples
#
# === GET Request
#
#   th = ThinHTTP.new('rubyforge.org', 80)
#   th.set_header('User-Agent', th.class.to_s)
#   response = th.get('/')
#
#
# === POST Request (URL encoding)
#
#   th = ThinHTTP.new('example.com')
#   response = th.post('/submit_form', :param1 => 'val1', :param2 => 'val2')
#
#
# === POST Request (multipart/form-data encoding using MIME library)
#
#   fd = MIME::MultipartMedia::FormData.new
#   fd.add_entity(MIME::TextMedia.new('Clint'), 'first_name')
#   fd.add_entity(MIME::DiscreteMediaFactory.create('/tmp/pic.jpg'), 'portrait')
#
#   th = ThinHTTP.new('example.com')
#   response = th.post('/upload_file', fd.body, fd.content_type)
#
#
# == More Examples
#
# * Check the MultipartFormData class examples
# * Check the ThinHTTPTest class source code
#
#--
# TODO
# * May want to create a Response object to encapsulate the HTTPResponse
#   returned from the request methods. See SOAP::Response.
# * May want to decompose the HTTP headers.
#++
#
class ThinHTTP

  attr_reader :host, :port
  attr_accessor :path
  attr_accessor :cookies
  attr_accessor :request_headers
  attr_accessor :response_headers

  #
  # Assign initial connection params for +host+, +port+, and +http_headers+. No
  # connection is established until an HTTP method is invoked.
  #
  def initialize host = 'localhost', port = 2000, http_headers = {}
    @host = host
    @port = port
    @path = ''
    self.cookies = CookieCollection.new
    self.request_headers = http_headers
    self.response_headers = Hash.new
  end

  #
  # Change the remote connection host.
  #
  def host= host
    if @host != host
      @host = host
      reset_connection
    end
    @host
  end

  #
  # Change the remote connection port.
  #
  def port= port
    if @port != port
      @port = port
      reset_connection
    end
    @port
  end

  #
  # Set the +name+ header and its +value+ in each request.
  #
  def set_header name, value
    request_headers.store(name, value)
  end

  #
  # Delete the +name+ header, thus not setting +name+ in subsequent requests.
  #
  def unset_header name
    request_headers.delete(name)
  end

  #
  # Send +params+ to +path+ as a GET request and return the response body.
  #
  # +params+ may be a URL encoded String or a Hash.
  #
  def get path, params = nil
    url_path =
      case params
      when Hash    ; path + '?' + url_encode(params)
      when String  ; path + '?' + params
      when NilClass; path
      else raise 'cannot process params'
      end

    send_request Net::HTTP::Get.new(url_path)
  end

  #
  # Send +params+ to +path+ as a POST request and return the response body.
  #
  # +params+ may be a String or a Hash. If +params+ is a String, it is
  # considered encoded data and +content_type+ must be set accordingly.
  # Otherwise, the +params+ Hash is URL encoded.
  #
  def post path, params, content_type = 'application/x-www-form-urlencoded'
    post_request = Net::HTTP::Post.new(path)
    post_request.content_type = content_type
    post_request.body = params.is_a?(Hash) ? url_encode(params) : params.to_s

    send_request post_request
  end


  private

  #
  # Return an existing connection, otherwise create a new one.
  #
  def connection
    @connection ||= Net::HTTP.new(host, port)
  end

  #
  # Force a new HTTP connection on next connection attempt.
  #
  def reset_connection
    @connection = nil
  end

  #
  # Send +request+ to <tt>self.host:self.port</tt>, following redirection
  # responses if necessary. Applicable cookies and user-defined headers are
  # attached to the +request+ before sending.
  #
  # NOTE Sends and receives cookies in unverifiable transactions (i.e. 3rd
  # party cookies).
  #
  # http://tools.ietf.org/html/rfc2965#section-3.3.6
  #
  def send_request request
    self.path = request.path
    request['cookie'] = cookies.cookie_header_data(host, request.path)
    request_headers.each {|name, value| request[name] = value}

    response = connection.request(request)
    save_response_headers(response)

    case response
    when Net::HTTPSuccess
      response.body
    when Net::HTTPRedirection
      uri = URI.parse(response['location'])
      self.host = uri.host
      self.port = uri.port
      send_request redirection_request(request, uri.path)
    else
      response.error!
    end
  end

  #
  # Create a request that is to be utilized in following a redirection to
  # +path+. The new request will inherit +prev_request+'s state tracking
  # redirection information if available. A maximum of five sequential
  # redirections will be followed.
  #--
  # TODO Adhere to RFC2616 redirection behavior when implementing new request
  # methods. See http://tools.ietf.org/html/rfc2616#section-10.3 for details.
  #++
  #
  def redirection_request prev_request, path
    max_redirects = 5

    class << (request = Net::HTTP::Get.new(path))
      attr_accessor :num_redirects
    end

    if prev_request.respond_to? :num_redirects
      raise 'exceeded redirection limit'  if prev_request.num_redirects >= max_redirects
      request.num_redirects = prev_request.num_redirects + 1
    else
      request.num_redirects = 1
    end
    request
  end

  #
  # Save +response+'s cookies and headers.
  #
  def save_response_headers response
    cookies.store response['set-cookie']
    self.response_headers = response.to_hash
  end

  #
  # URL encodes +params+ hash.
  #
  def url_encode params
    params.collect do |key,value|
      CGI::escape(key.to_s) + "=" + CGI::escape(value)
    end.join('&')
  end

end
