lib/quickbooks/service/base_service.rb



module Quickbooks
  module Service
    class BaseService
      include Quickbooks::Util::Logging
      include ServiceCrud

      attr_accessor :company_id
      attr_accessor :oauth
      attr_reader :base_uri
      attr_reader :last_response_xml
      attr_reader :last_response_intuit_tid

      XML_NS = %{xmlns="http://schema.intuit.com/finance/v3"}
      HTTP_CONTENT_TYPE = 'application/xml'
      HTTP_ACCEPT = 'application/xml'
      HTTP_ACCEPT_ENCODING = 'gzip, deflate'
      BASE_DOMAIN = 'quickbooks.api.intuit.com'
      SANDBOX_DOMAIN = 'sandbox-quickbooks.api.intuit.com'

      def initialize(attributes = {})
        domain = Quickbooks.sandbox_mode ? SANDBOX_DOMAIN : BASE_DOMAIN
        @base_uri = "https://#{domain}/v3/company"
        attributes.each {|key, value| public_send("#{key}=", value) }
      end

      def access_token=(token)
        @oauth = token
        rebuild_connection!
      end

      def company_id=(company_id)
        @company_id = company_id
      end

      # realm & company are synonymous
      def realm_id=(company_id)
        @company_id = company_id
      end

      # def oauth_v2?
      #   @oauth.is_a? OAuth2::AccessToken
      # end

      # [OAuth2] The default Faraday connection does not have gzip or multipart support.
      # We need to reset the existing connection and build a new one.
      def rebuild_connection!
        @oauth.client.connection = nil
        @oauth.client.connection.build do |builder|
          builder.use :gzip
          builder.request :multipart
          builder.request :url_encoded
          builder.adapter :net_http
        end
      end

      def url_for_resource(resource)
        "#{url_for_base}/#{resource}"
      end

      def url_for_base
        raise MissingRealmError.new unless @company_id
        "#{@base_uri}/#{@company_id}"
      end

      def is_json?
        self.class::HTTP_CONTENT_TYPE == "application/json"
      end

      def is_pdf?
        self.class::HTTP_CONTENT_TYPE == "application/pdf"
      end

      def default_model_query
        "SELECT * FROM #{self.class.name.split("::").last}"
      end

      def url_for_query(query = nil, start_position = 1, max_results = 20, options = {})
        query ||= default_model_query
        query = "#{query} STARTPOSITION #{start_position} MAXRESULTS #{max_results}"

        "#{url_for_base}/query?query=#{CGI.escape(query)}"
      end

      private

      def parse_xml(xml)
        @last_response_xml = Nokogiri::XML(xml)
      end

      def valid_xml_document(xml)
        %Q{<?xml version="1.0" encoding="utf-8"?>\n#{xml.strip}}
      end

      # A single object response is the same as a collection response except
      # it just has a single main element
      def fetch_object(model, url, params = {})
        raise ArgumentError, "missing model to instantiate" if model.nil?
        response = do_http_get(url, params)
        collection = parse_collection(response, model)
        if collection.is_a?(Quickbooks::Collection)
          collection.entries.first
        else
          nil
        end
      end

      def fetch_collection(query, model, options = {})
        page = options.fetch(:page, 1)
        per_page = options.fetch(:per_page, 20)

        start_position = ((page - 1) * per_page) + 1 # page=2, per_page=10 then we want to start at 11
        max_results = per_page

        response = do_http_get(url_for_query(query, start_position, max_results))

        parse_collection(response, model)
      end

      def parse_collection(response, model)
        if response
          collection = Quickbooks::Collection.new
          xml = @last_response_xml
          begin
            results = []

            query_response = xml.xpath("//xmlns:IntuitResponse/xmlns:QueryResponse")[0]
            if query_response

              start_pos_attr = query_response.attributes['startPosition']
              if start_pos_attr
                collection.start_position = start_pos_attr.value.to_i
              end

              max_results_attr = query_response.attributes['maxResults']
              if max_results_attr
                collection.max_results = max_results_attr.value.to_i
              end

              total_count_attr = query_response.attributes['totalCount']
              if total_count_attr
                collection.total_count = total_count_attr.value.to_i
              end
            end

            path_to_nodes = "//xmlns:IntuitResponse//xmlns:#{model::XML_NODE}"
            collection.count = xml.xpath(path_to_nodes).count
            if collection.count > 0
              xml.xpath(path_to_nodes).each do |xa|
                results << model.from_xml(xa)
              end
            end

            collection.entries = results
          rescue => ex
            raise Quickbooks::IntuitRequestException.new("Error parsing XML: #{ex.message}")
          end
          collection
        else
          nil
        end
      end

      # Given an IntuitResponse which is expected to wrap a single
      # Entity node, e.g.
      # <IntuitResponse xmlns="http://schema.intuit.com/finance/v3" time="2013-11-16T10:26:42.762-08:00">
      #   <Customer domain="QBO" sparse="false">
      #     <Id>1</Id>
      #     ...
      #   </Customer>
      # </IntuitResponse>
      def parse_singular_entity_response(model, xml, node_xpath_prefix = nil)
        xmldoc = Nokogiri(xml)
        prefix = node_xpath_prefix || model::XML_NODE
        xmldoc.xpath("//xmlns:IntuitResponse/xmlns:#{prefix}")[0]
      end

      # A successful delete request returns a XML packet like:
      # <IntuitResponse xmlns="http://schema.intuit.com/finance/v3" time="2013-04-23T08:30:33.626-07:00">
      #   <Payment domain="QBO" status="Deleted">
      #   <Id>8748</Id>
      #   </Payment>
      # </IntuitResponse>
      def parse_singular_entity_response_for_delete(model, xml)
        xmldoc = Nokogiri(xml)
        xmldoc.xpath("//xmlns:IntuitResponse/xmlns:#{model::XML_NODE}[@status='Deleted']").length == 1
      end

      def do_http_post(url, body = "", params = {}, headers = {}) # throws IntuitRequestException
        url = add_query_string_to_url(url, params)
        do_http(:post, url, body, headers)
      end

      def do_http_get(url, params = {}, headers = {}) # throws IntuitRequestException
        url = add_query_string_to_url(url, params)
        do_http(:get, url, {}, headers)
      end

      def do_http_raw_get(url, params = {}, headers = {})
        url = add_query_string_to_url(url, params)
        unless headers.has_key?('Content-Type')
          headers['Content-Type'] = self.class::HTTP_CONTENT_TYPE
        end
        unless headers.has_key?('Accept')
          headers['Accept'] = self.class::HTTP_ACCEPT
        end
        unless headers.has_key?('Accept-Encoding')
          headers['Accept-Encoding'] = HTTP_ACCEPT_ENCODING
        end
        raw_response = oauth_get(url, headers)
        Quickbooks::Service::Responses::OAuthHttpResponse.wrap(raw_response)
      end

      def do_http_file_upload(uploadIO, url, metadata = nil)
        headers = {
          'Content-Type' => 'multipart/form-data'
        }
        body = {}
        body['file_content_0'] = uploadIO

        if metadata
          standalone_prefix = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
          meta_data_xml = "#{standalone_prefix}\n#{metadata.to_xml_ns.to_s}"
          param_part = UploadIO.new(StringIO.new(meta_data_xml), "application/xml")
          body['file_metadata_0'] = param_part
        end

        do_http(:upload, url, body, headers)
      end

      def do_http(method, url, body, headers) # throws IntuitRequestException
        if @oauth.nil?
          raise "OAuth client has not been initialized. Initialize with setter access_token="
        end
        unless headers.has_key?('Content-Type')
          headers['Content-Type'] = self.class::HTTP_CONTENT_TYPE
        end
        unless headers.has_key?('Accept')
          headers['Accept'] = self.class::HTTP_ACCEPT
        end
        unless headers.has_key?('Accept-Encoding')
          headers['Accept-Encoding'] = HTTP_ACCEPT_ENCODING
        end

        log "------ QUICKBOOKS-RUBY REQUEST ------"
        log "METHOD = #{method}"
        log "RESOURCE = #{url}"
        log_request_body(body)
        log "REQUEST HEADERS = #{headers.inspect}"

        raw_response = case method
        when :get
          oauth_get(url, headers)
        when :post
          oauth_post(url, body, headers)
        when :upload
          oauth_post_with_multipart(url, body, headers)
        else
          raise "Do not know how to perform that HTTP operation"
        end

        response = Quickbooks::Service::Responses::OAuthHttpResponse.wrap(raw_response)
        log "------ QUICKBOOKS-RUBY RESPONSE ------"
        log "RESPONSE CODE = #{response.code}"
        log_response_body(response)
        if response.respond_to?(:headers)
          log "RESPONSE HEADERS = #{response.headers}"
        end
        check_response(response, request: body)
      end

      def oauth_get(url, headers)
        @oauth.get(url, headers: headers, raise_errors: false)
      end

      def oauth_post(url, body, headers)
        @oauth.post(url, headers: headers, body: body, raise_errors: false)
      end

      def oauth_post_with_multipart(url, body, headers)
        @oauth.post_with_multipart(url, headers: headers, body: body, raise_errors: false)
      end

      def add_query_string_to_url(url, params)
        if params.is_a?(Hash) && !params.empty?
          keyvalues = params.collect { |k| "#{k.first}=#{k.last}" }.join("&")
          delim = url.index("?") != nil ? "&" : "?"
          url + delim + keyvalues
        else
          url
        end
      end

      def check_response(response, options = {})
        if is_json?
          parse_json(response.plain_body)
        elsif !is_pdf?
          parse_xml(response.plain_body)
        end

        @last_response_intuit_tid = if response.respond_to?(:headers) && response.headers
          response.headers['intuit_tid']
        else
          nil
        end

        status = response.code.to_i
        case status
        when 200
          # even HTTP 200 can contain an error, so we always have to peek for an Error
          if response_is_error?
            parse_and_raise_exception(options)
          else
            response
          end
        when 302
          raise "Unhandled HTTP Redirect"
        when 401
          raise Quickbooks::AuthorizationFailure, parse_intuit_error
        when 403
          message = parse_intuit_error[:message]
          if message.include?('ThrottleExceeded')
            raise Quickbooks::ThrottleExceeded, message
          end
          raise Quickbooks::Forbidden, message
        when 404
          raise Quickbooks::NotFound
        when 413
          raise Quickbooks::RequestTooLarge
        when 400, 500
          parse_and_raise_exception(options)
        when 429
          message = parse_intuit_error[:message]
          raise Quickbooks::TooManyRequests, message
        when 502, 503, 504
          raise Quickbooks::ServiceUnavailable
        else
          raise "HTTP Error Code: #{status}, Msg: #{response.plain_body}"
        end
      end

      def log_response_body(response)
        log "RESPONSE BODY:"
        if is_json?
          log ">>>>#{response.plain_body.inspect}"
        elsif is_pdf?
          log("BODY is a PDF : not dumping")
        else
          log(log_xml(response.plain_body))
        end
      end

      def log_request_body(body)
        log "REQUEST BODY:"
        if is_json?
          log(body.inspect)
        elsif is_pdf?
          log("BODY is a PDF : not dumping")
        else
          #multipart request for uploads arrive here in a Hash with UploadIO vals
          if body.is_a?(Hash)
            body.each do |k,v|
              log('BODY PART:')
              val_content = v.inspect
              if v.is_a?(UploadIO)
                if v.content_type == 'application/xml'
                  if v.io.is_a?(StringIO)
                    val_content = log_xml(v.io.string)
                  end
                end
              end
              log("#{k}: #{val_content}")
            end
          else
            log(log_xml(body))
          end
        end
      end

      def parse_and_raise_exception(options = {})
        err = parse_intuit_error
        ex = Quickbooks::IntuitRequestException.new("#{err[:message]}:\n\t#{err[:detail]}")
        ex.code = err[:code]
        ex.detail = err[:detail]
        ex.type = err[:type]
        if is_json?
          ex.request_json = options[:request]
        else
          ex.request_xml = options[:request]
        end
        ex.intuit_tid = err[:intuit_tid]
        raise ex
      end

      def response_is_error?
        begin
          @last_response_xml.xpath("//xmlns:IntuitResponse/xmlns:Fault")[0] != nil
        rescue Nokogiri::XML::XPath::SyntaxError => exception
          #puts @last_response_xml.to_xml.to_s
          #puts "WTF: #{exception.inspect}:#{exception.backtrace.join("\n")}"
          true
        end
      end

      def parse_intuit_error
        error = {:message => "", :detail => "", :type => nil, :code => 0, :intuit_tid => @last_response_intuit_tid}
        fault = @last_response_xml.xpath("//xmlns:IntuitResponse/xmlns:Fault")[0]
        if fault
          error[:type] = fault.attributes['type'].value

          error_element = fault.xpath("//xmlns:Error")[0]
          if error_element
            code_attr = error_element.attributes['code']
            if code_attr
              error[:code] = code_attr.value
            end
            element_attr = error_element.attributes['element']
            if element_attr
              error[:element] = code_attr.value
            end
            error[:message] = error_element.xpath("//xmlns:Message").text
            error[:detail] = error_element.xpath("//xmlns:Detail").text
          end
        end

        error
      rescue Nokogiri::XML::XPath::SyntaxError => exception
        error[:detail] = @last_response_xml.to_s

        error
      end

    end
  end
end