liquidm/z-http-request

View on GitHub
lib/z-http/client.rb

Summary

Maintainability
C
1 day
Test Coverage
require 'cookiejar'

module ZMachine


  class HttpClient
    include Deferrable
    include HttpEncoding
    include HttpStatus

    TRANSFER_ENCODING="TRANSFER_ENCODING"
    CONTENT_ENCODING="CONTENT_ENCODING"
    CONTENT_LENGTH="CONTENT_LENGTH"
    CONTENT_TYPE="CONTENT_TYPE"
    LAST_MODIFIED="LAST_MODIFIED"
    KEEP_ALIVE="CONNECTION"
    SET_COOKIE="SET_COOKIE"
    LOCATION="LOCATION"
    HOST="HOST"
    ETAG="ETAG"

    CRLF="\r\n"

    attr_accessor :state, :response
    attr_reader   :response_header, :error, :content_charset, :req, :cookies

    def initialize(conn, options)
      @conn = conn
      @req  = options

      @stream    = nil
      @headers   = nil
      @cookies   = []
      @cookiejar = CookieJar.new

      reset!
    end

    def reset!
      @response_header = HttpResponseHeader.new
      @state = :response_header

      @response = ''
      @error = nil
      @content_decoder = nil
      @content_charset = nil
    end

    def last_effective_url; @req.uri; end
    def redirects; @req.followed; end
    def peer; @conn.peer; end

    def connection_completed
      @state = :response_header

      head, body = build_request, @req.body
      @conn.middleware.each do |m|
        head, body = m.request(self, head, body) if m.respond_to?(:request)
      end

      send_request(head, body)
    end

    def on_request_complete
      begin
        @content_decoder.finalize! if @content_decoder
      rescue HttpDecoders::DecoderError
        on_error "Content-decoder error"
      end

      unbind
    end

    def continue?
      @response_header.status == 100 && (@req.method == 'POST' || @req.method == 'PUT')
    end

    def finished?
      @state == :finished || (@state == :body && @response_header.content_length.nil?)
    end

    def redirect?
      @response_header.redirection? && @req.follow_redirect?
    end

    def unbind(reason = nil)
      if finished?
        if redirect?

          begin
            @conn.middleware.each do |m|
              m.response(self) if m.respond_to?(:response)
            end

            # one of the injected middlewares could have changed
            # our redirect settings, check if we still want to
            # follow the location header
            if redirect?
              @req.followed += 1

              @cookies.clear
              @cookies = @cookiejar.get(@response_header.location).map(&:to_s) if @req.pass_cookies
              @req.set_uri(@response_header.location)

              @conn.redirect(self)
            else
              succeed(self)
            end

          rescue Exception => e
            on_error(e.message)
          end
        else
          succeed(self)
        end

      else
        on_error(reason || 'connection closed by server')
      end
    end

    def on_error(msg = nil)
      @error = msg
      fail(self)
    end
    alias :close :on_error

    def stream(&blk); @stream = blk; end
    def headers(&blk); @headers = blk; end

    def normalize_body(body)
      body.is_a?(Hash) ? form_encode_body(body) : body
    end

    def build_request
      head    = @req.headers ? munge_header_keys(@req.headers) : {}

      if @conn.connopts.http_proxy?
        proxy = @conn.connopts.proxy
        head['proxy-authorization'] = proxy[:authorization] if proxy[:authorization]
      end

      # Set the cookie header if provided
      if cookie = head['cookie']
        @cookies << encode_cookie(cookie) if cookie
      end
      head['cookie'] = @cookies.compact.uniq.join("; ").squeeze(";") unless @cookies.empty?

      # Set connection close unless keepalive
      if !@req.keepalive
        head['connection'] = 'close'
      end

      # Set the Host header if it hasn't been specified already
      head['host'] ||= encode_host

      # Set the User-Agent if it hasn't been specified
      if !head.key?('user-agent')
        head['user-agent'] = "ZMachine HttpClient"
      elsif head['user-agent'].nil?
        head.delete('user-agent')
      end

      # Set the auth from the URI if given
      head['Authorization'] = @req.uri.userinfo.split(/:/, 2) if @req.uri.userinfo

      head
    end

    def send_request(head, body)
      body    = normalize_body(body)
      file    = @req.file
      query   = @req.query

      # Set the Content-Length if file is given
      head['content-length'] = File.size(file) if file

      # Set the Content-Length if body is given,
      # or we're doing an empty post or put
      if body
        head['content-length'] = body.bytesize
      elsif @req.method == 'POST' or @req.method == 'PUT'
        # wont happen if body is set and we already set content-length above
        head['content-length'] ||= 0
      end

      # Set content-type header if missing and body is a Ruby hash
      if !head['content-type'] and @req.body.is_a? Hash
        head['content-type'] = 'application/x-www-form-urlencoded'
      end

      request_header ||= encode_request(@req.method, @req.uri, query, @conn.connopts.proxy)
      request_header << encode_headers(head)
      request_header << CRLF
      @conn.send_data request_header

      if body
        @conn.send_data body
      elsif @req.file
        @conn.stream_file_data @req.file, :http_chunks => false
      end
    end

    def on_body_data(data)
      if @content_decoder
        begin
          @content_decoder << data
        rescue HttpDecoders::DecoderError
          on_error "Content-decoder error"
        end
      else
        on_decoded_body_data(data)
      end
    end

    def on_decoded_body_data(data)
      data.force_encoding @content_charset if @content_charset
      if @stream
        @stream.call(data)
      else
        @response << data
      end
    end

    def parse_response_header(header, version, status)
      @response_header.raw = header
      header.each do |key, val|
        @response_header[key.upcase.gsub('-','_')] = val
      end

      @response_header.http_version = version.join('.')
      @response_header.http_status  = status
      @response_header.http_reason  = CODE[status] || 'unknown'

      # invoke headers callback after full parse
      # if one is specified by the user
      @headers.call(@response_header) if @headers

      unless @response_header.http_status and @response_header.http_reason
        @state = :invalid
        on_error "no HTTP response"
        return
      end

      # add set-cookie's to cookie list
      if @response_header.cookie && @req.pass_cookies
        [@response_header.cookie].flatten.each {|cookie| @cookiejar.set(cookie, @req.uri)}
      end

      # correct location header - some servers will incorrectly give a relative URI
      if @response_header.location
        begin
          location = Addressable::URI.parse(@response_header.location)
          location.path = "/" if location.path.empty?

          if location.relative?
            location = @req.uri.join(location)
          else
            # if redirect is to an absolute url, check for correct URI structure
            raise if location.host.nil?
          end

          @response_header[LOCATION] = location.to_s

        rescue
          on_error "Location header format error"
          return
        end
      end

      # Fire callbacks immediately after recieving header requests
      # if the request method is HEAD. In case of a redirect, terminate
      # current connection and reinitialize the process.
      if @req.method == "HEAD"
        @state = :finished
        return
      end

      if @response_header.chunked_encoding?
        @state = :chunk_header
      elsif @response_header.content_length
        @state = :body
      else
        @state = :body
      end

      if @req.decoding && decoder_class = HttpDecoders.decoder_for_encoding(response_header[CONTENT_ENCODING])
        begin
          @content_decoder = decoder_class.new do |s| on_decoded_body_data(s) end
        rescue HttpDecoders::DecoderError
          on_error "Content-decoder error"
        end
      end

      # handle malformed header - Content-Type repetitions.
      content_type = [response_header[CONTENT_TYPE]].flatten.first

      if String.method_defined?(:force_encoding) && /;\s*charset=\s*(.+?)\s*(;|$)/.match(content_type)
        @content_charset = Encoding.find($1.gsub(/^\"|\"$/, '')) rescue Encoding.default_external
      end
    end

    class CookieJar
      def initialize
        @jar = ::CookieJar::Jar.new
      end

      def set string, uri
        @jar.set_cookie(uri, string) rescue nil # drop invalid cookies
      end

      def get uri
        uri = URI.parse(uri) rescue nil
        uri ? @jar.get_cookies(uri) : []
      end
    end # CookieJar
  end
end