lib/copy_tuner_client/client.rb



require 'net/http'
require 'net/https'
require 'json'
require 'copy_tuner_client/errors'

module CopyTunerClient
  # Communicates with the CopyTuner server. This class is used to actually
  # download and upload blurbs, as well as issuing deploys.
  #
  # A client is usually instantiated when {Configuration#apply} is called, and
  # the application will not need to interact with it directly.
  class Client
    # These errors will be rescued when connecting CopyTuner.
    HTTP_ERRORS = [Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, EOFError,
                   Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError,
                   Net::ProtocolError, SocketError, OpenSSL::SSL::SSLError,
                   Errno::ECONNREFUSED]

    USER_AGENT = "copy_tuner_client #{CopyTunerClient::VERSION}"

    # Usually instantiated from {Configuration#apply}. Copies options.
    # @param options [Hash]
    # @option options [String] :api_key API key of the project to connect to
    # @option options [Fixnum] :port the port to connect to
    # @option options [Boolean] :public whether to download draft or published content
    # @option options [Fixnum] :http_read_timeout how long to wait before timing out when reading data from the socket
    # @option options [Fixnum] :http_open_timeout how long to wait before timing out when opening the socket
    # @option options [Boolean] :secure whether to use SSL
    # @option options [Logger] :logger where to log transactions
    # @option options [String] :ca_file path to root certificate file for ssl verification
    def initialize(options)
      [:api_key, :host, :port, :public, :http_read_timeout,
        :http_open_timeout, :secure, :logger, :ca_file, :s3_host].each do |option|
        instance_variable_set "@#{option}", options[option]
      end
    end

    # Downloads all blurbs for the given api_key.
    #
    # If the +public+ option was set to +true+, this will use published blurbs.
    # Otherwise, draft content is fetched.
    #
    # The client tracks ETags between download requests, and will return
    # without yielding anything if the server returns a not modified response.
    #
    # @yield [Hash] downloaded blurbs
    # @raise [ConnectionError] if the connection fails
    def download
      connect(s3_host) do |http|
        request = Net::HTTP::Get.new(uri(download_resource))
        request['If-None-Match'] = @etag
        log 'Start downloading translations'
        t = Time.now
        response = http.request(request)
        t_ms = ((Time.now - t) * 1000).to_i
        if check response
          log "Downloaded translations (#{t_ms}ms)"
          yield JSON.parse(response.body)
        else
          log "No new translations (#{t_ms}ms)"
        end

        @etag = response['ETag']
      end
    end

    # Uploads the given hash of blurbs as draft content.
    # @param data [Hash] the blurbs to upload
    # @raise [ConnectionError] if the connection fails
    def upload(data)
      connect(host) do |http|
        response = http.post(uri('draft_blurbs'), data.to_json, 'Content-Type' => 'application/json', 'User-Agent' => USER_AGENT)
        check response
        log 'Uploaded missing translations'
      end
    end

    # Issues a deploy, marking all draft content as published for this project.
    # @raise [ConnectionError] if the connection fails
    def deploy
      connect(host) do |http|
        response = http.post(uri('deploys'), '', 'User-Agent' => USER_AGENT)
        check response
        log 'Deployed'
      end
    end

    private

    attr_reader :host, :port, :api_key, :http_read_timeout,
      :http_open_timeout, :secure, :logger, :ca_file, :s3_host

    def public?
      @public
    end

    def uri(resource)
      "/api/v2/projects/#{api_key}/#{resource}"
    end

    def download_resource
      if public?
        'published_blurbs.json'
      else
        'draft_blurbs.json'
      end
    end

    def connect(host)
      http = Net::HTTP.new(host, port)
      http.open_timeout = http_open_timeout
      http.read_timeout = http_read_timeout
      http.use_ssl = secure
      http.verify_mode = OpenSSL::SSL::VERIFY_PEER
      http.ca_file = ca_file

      begin
        yield http
      rescue *HTTP_ERRORS => exception
        raise ConnectionError, "#{exception.class.name}: #{exception.message}"
      end
    end

    def check(response)
      case response
      when Net::HTTPNotFound
        raise InvalidApiKey, "Invalid API key: #{api_key}"
      when Net::HTTPNotModified
        false
      when Net::HTTPSuccess
        true
      else
        raise ConnectionError, "#{response.code}: #{response.body}"
      end
    end

    def log(message)
      logger.info message
    end
  end
end