benSlaughter/segregate

View on GitHub
lib/segregate.rb

Summary

Maintainability
C
1 day
Test Coverage
require 'juncture'
require 'hashie'
require 'uri'
require 'segregate/version'
require 'segregate/http_methods'
require 'segregate/http_headers'
require 'segregate/http_regular_expressions'

class Segregate
  attr_reader :uri, :type, :state, :http_version, :headers
  attr_accessor :request_method, :status_code, :status_phrase, :body

  def method_missing meth, *args, &block
    if @uri.respond_to? meth
      @uri.send meth, *args, &block
    else
      super
    end
  end

  def respond_to?(meth, include_private = false)
    @uri.respond_to?(meth, include_private) || super
  end

  def debug message
    if @debug
      puts "DEBUG: " + message.to_s
    end
  end

  def initialize callback = nil, *args, **kwargs
    @debug = kwargs[:debug] ? true : false
    @callback = callback
    @http_version = [nil, nil]

    # :request, :response
    @type = Juncture.new :request, :response
    @state = Juncture.new :waiting, :headers, :body, :done, default: :waiting

    @headers = Hashie::Mash.new
    @body = ""

    @stashed_data = ""
    @stashed_body = ""

    @header_order = []
  end

  def request?
    @type == :request
  end

  def response?
    @type == :response
  end

  def headers_complete?
    @state > :headers
  end

  def done?
    @state >= :done
  end

  def request_line
    request? ? "%s %s HTTP/%d.%d" % [request_method, request_url.to_s, *http_version] : nil
  end

  def status_line
    response? ? "HTTP/%d.%d %d %s" % [*http_version, status_code, status_phrase] : nil
  end

  def request_url
    uri ? uri.to_s : nil
  end

  def major_http_version
    http_version[0]
  end

  def major_http_version= value
    http_version[0] = value
  end

  def minor_http_version
    http_version[1]
  end

  def minor_http_version= value
    http_version[1] = value
  end

  def update_content_length
    unless @body.empty?
      if @headers['content-length']
        @headers['content-length'] = @body.length.to_s
      end
    end
  end

  def raw_data
    raw_message = ""
    update_content_length

    build_headers raw_message
    build_body raw_message

    return raw_message
  end

  def build_headers raw_message
    request? ? raw_message << request_line + "\r\n" : raw_message << status_line + "\r\n"
    @header_order.each do |header|
      values = @headers[header.downcase]
      if values.kind_of?(Array)
        values.each do |value|
          raw_message << "%s: %s\r\n" % [header, value]
        end
      else
        raw_message << "%s: %s\r\n" % [header, values]
      end
    end
    raw_message << "\r\n"
  end

  def build_body raw_message
    new_body, size = deflate_if_needed(@body)
    if @headers['content-length']
      raw_message << new_body
      raw_message << "\r\n\r\n"
    elsif @headers['transfer-encoding'] == 'chunked'
      raw_message << "%s\r\n" % (size.to_s(16))
      raw_message << new_body + "\r\n"
      raw_message << "0\r\n\r\n"
    end
  end

  def deflate_if_needed(body)
    return [body, body.size] unless gzip_encoded_body?

    str_io = StringIO.new('w')
    w_gz = Zlib::GzipWriter.new(str_io)
    w_gz.write(body)
    w_gz.close

    # To specify the number of bytes for a gzip string
    # we should check for the buffer size instead of the string size
    [str_io.string, str_io.size]
  end

  def parse_data data
    data = StringIO.new(@stashed_data + data)
    @stashed_data = ""

    while !data.eof? && @state < :done
      line, complete_line = get_next_line data
      complete_line ? parse_line(line) : @stashed_data = line
    end

    data.close
  end

  def parse_line line
    case @state.state
    when :waiting
      read_in_first_line line
    when :headers
      read_in_headers line
    when :body
      read_in_body line
    end

    @callback.on_message_complete self if @callback.respond_to?(:on_message_complete) && done?
  end

  private

  def get_next_line data
    if @headers['content-length'] && @state >= :body
      line = data.readline("\r\n")
      @inital_line = line
      [line, true]
    else
      line = data.readline("\r\n")
      @inital_line = line
      result = line.end_with?("\r\n")
      line = line[0..-3] if result
      [line, result]
    end
  end

  def read_in_first_line line
    @callback.on_message_begin self if @callback.respond_to?(:on_message_begin)

    if line =~ REQUEST_LINE
      parse_request_line line
    elsif line =~ STATUS_LINE
      parse_status_line line
    else
      raise "ERROR: Unknown first line: %s" % line
    end

    @state.next
  end

  def parse_request_line line
    @type.set :request
    @request_method, url, @http_version[0], @http_version[1] = line.scan(REQUEST_LINE).flatten
    @http_version.map! {|v| v.to_i}
    @uri = URI.parse url

    @callback.on_request_line self if @callback.respond_to?(:on_request_line)
  end

  def parse_status_line line
    @type.set :response
    @http_version[0], @http_version[1], code, @status_phrase = line.scan(STATUS_LINE).flatten
    @http_version.map! {|v| v.to_i}
    @status_code = code.to_i

    @callback.on_status_line self if @callback.respond_to?(:on_status_line)
  end

  def read_in_headers line
    if line.empty?
      @state.next
    else
      key, value = line.split(": ",2)
      @header_order << key unless @header_order.include?(key)
      save_value(@headers, key, value)
    end

    if headers_complete?
      @callback.on_headers_complete self if @callback.respond_to?(:on_headers_complete)
      if headers['transfer-encoding'].nil? && (headers['content-length'] == "0" || headers['content-length'].nil?)
        @state.set :done
      end
    end
  end

  def read_in_body line
    if headers['content-length']
      parse_body line
    elsif headers['transfer-encoding'] == 'chunked'
      parse_chunked_data line
    end
  end

  def parse_body line
    line = @stashed_body + line
    @stashed_body = ""

    if line.length >= headers['content-length'].to_i
      # Removing delimiters characters from the HTTP protocol
      line = line[0..-3]
      @body = inflate_body_if_needed(line)
      @callback.on_body @body if @callback.respond_to?(:on_body)
      @state.next
    else
      @stashed_body = line
    end
  end

  def inflate_body_if_needed(body)
    return body unless gzip_encoded_body?

    gzip_str  = StringIO.new(body)
    gzip_body = Zlib::GzipReader.new( gzip_str )
    gzip_body.read()
  end

  def gzip_encoded_body?
    headers.key?('content-encoding') && headers['content-encoding'].eql?('gzip')
  end

  def parse_chunked_data line
    @chunked_body_state ||= Juncture.new :size, :chunk, default: :size

    case @chunked_body_state.state
    when :size
      parse_chunked_data_size line.to_i(16)
    when :chunk
      parse_chunked_data_chunk line
    end
  end

  def parse_chunked_data_size chunk_size
    if chunk_size == 0
      @state.next
    else
      @chunk_size = chunk_size
    end
    @chunked_body_state.next
  end

  def parse_chunked_data_chunk line
    line = @stashed_body + line
    @stashed_body = ""

    if line.length >= @chunk_size
      @body << line
      @original_body = @body
      @body = inflate_body_if_needed(@body)
      @callback.on_body line if @callback.respond_to?(:on_body)
      @chunked_body_state.next
    else
      @stashed_body = @inital_line.end_with?("\r\n") ? (line + "\r\n") : line
    end
  end

  def save_value(store_hash, key, value)
    key = key.downcase
    if store_hash.key?(key)
      store_hash[key] = [] << store_hash[key] unless store_hash[key].kind_of?(Array)
      store_hash[key] << value
    else
      store_hash[key] = value
    end
  end
end