require "httparty" require "nokogiri" require "uri" require 'zuora_api/exceptions' module ZuoraAPI class Login ENVIRONMENTS = [SANDBOX = 'Sandbox', PRODUCTION = 'Production', PREFORMANCE = 'Preformance', SERVICES = 'Services', UNKNOWN = 'Unknown', STAGING = 'Staging' ] REGIONS = [EU = 'EU', US = 'US', NA = 'NA' ] MIN_Endpoint = '96.0' XML_SAVE_OPTIONS = Nokogiri::XML::Node::SaveOptions::AS_XML | Nokogiri::XML::Node::SaveOptions::NO_DECLARATION CONNECTION_EXCEPTIONS = [Net::OpenTimeout, OpenSSL::SSL::SSLError, Errno::ECONNREFUSED, SocketError, Errno::EHOSTUNREACH] CONNECTION_READ_EXCEPTIONS = [Net::ReadTimeout, Errno::ECONNRESET, Errno::EPIPE] ZUORA_API_ERRORS = [ZuoraAPI::Exceptions::ZuoraAPIError, ZuoraAPI::Exceptions::ZuoraAPIRequestLimit, ZuoraAPI::Exceptions::ZuoraAPILockCompetition, ZuoraAPI::Exceptions::ZuoraAPITemporaryError, ZuoraAPI::Exceptions::ZuoraDataIntegrity] attr_accessor :region, :url, :wsdl_number, :current_session, :bearer_token, :oauth_session_expires_at, :environment, :status, :errors, :current_error, :user_info, :tenant_id, :tenant_name, :entity_id, :timeout_sleep, :hostname, :zconnect_provider def initialize(url: nil, entity_id: nil, session: nil, status: nil, bearer_token: nil, oauth_session_expires_at: nil, **keyword_args) raise "URL is nil or empty, but URL is required" if url.nil? || url.empty? # raise "URL is improper. URL must contain zuora.com, zuora.eu, or zuora.na" if /zuora.com|zuora.eu|zuora.na/ === url self.hostname = /(?<=https:\/\/|http:\/\/)(.*?)(?=\/|$)/.match(url)[0] if !/(?<=https:\/\/|http:\/\/)(.*?)(?=\/|$)/.match(url).nil? if !/apps\/services\/a\/\d{2}\.\d$/.match(url.strip) self.url = "https://#{hostname}/apps/services/a/#{MIN_Endpoint}" elsif MIN_Endpoint.to_f > url.scan(/(\d{2}\.\d)$/).dig(0,0).to_f self.url = url.gsub(/(\d{2}\.\d)$/, MIN_Endpoint) else self.url = url end self.entity_id = get_entity_id(entity_id: entity_id) self.errors = Hash.new self.current_session = session self.bearer_token = bearer_token self.oauth_session_expires_at = oauth_session_expires_at self.status = status.blank? ? "Active" : status self.user_info = Hash.new self.update_region self.update_environment self.update_zconnect_provider @timeout_sleep = 5 end def get_identity(cookies) zsession = cookies["ZSession"] zconnect_accesstoken = get_zconnect_accesstoken(cookies) begin if !zsession.blank? response = HTTParty.get("https://#{self.hostname}/apps/v1/identity", :headers => {'Cookie' => "ZSession=#{zsession}", 'Content-Type' => 'application/json'}) output_json = JSON.parse(response.body) elsif zconnect_accesstoken.present? begin code = zconnect_accesstoken.split("#!").last encrypted_token, tenant_id = Base64.decode64(code).split(":") body = {'token' => encrypted_token}.to_json rescue => ex raise ZuoraAPI::Exceptions::ZuoraAPIError.new("Invalid ZConnect Cookie") end response = HTTParty.post("https://#{self.hostname}/apps/zconnectsession/identity", :body => body, :headers => { 'Content-Type' => 'application/json' }) output_json = JSON.parse(response.body) else if zconnect_accesstoken.blank? && cookies.keys.any? { |x| x.include? "ZConnect"} raise ZuoraAPI::Exceptions::ZuoraAPIError.new("No ZConnect cookie present matching #{self.hostname}") else raise ZuoraAPI::Exceptions::ZuoraAPIError.new("No ZSession cookie present") end end rescue JSON::ParserError => ex output_json = {} end raise_errors(type: :JSON, body: output_json, response: response) return output_json end def get_full_nav(cookies) zsession = cookies["ZSession"] zconnect_accesstoken = get_zconnect_accesstoken(cookies) begin if zsession.present? response = HTTParty.get("https://#{self.hostname}/apps/v1/navigation", :headers => {'Cookie' => "ZSession=#{zsession}", 'Content-Type' => 'application/json'}) output_json = JSON.parse(response.body) elsif zconnect_accesstoken.present? response = HTTParty.get("https://#{self.hostname}/apps/zconnectsession/navigation", :headers => {'Cookie' => "#{self.zconnect_provider}=#{zconnect_accesstoken}",'Content-Type' => 'application/json'}) output_json = JSON.parse(response.body) else if zconnect_accesstoken.blank? && cookies.keys.any? { |x| x.include? "ZConnect"} raise ZuoraAPI::Exceptions::ZuoraAPIError.new("No ZConnect cookie present matching #{self.hostname}") else raise ZuoraAPI::Exceptions::ZuoraAPIError.new("No ZSession cookie present") end end rescue JSON::ParserError => ex output_json = {} end raise_errors(type: :JSON, body: output_json, response: response) return output_json end def set_nav(state, cookies) zsession = cookies["ZSession"] zconnect_accesstoken = get_zconnect_accesstoken(cookies) begin if !zsession.blank? response = HTTParty.put("https://#{self.hostname}/apps/v1/preference/navigation", :body => state.to_json, :headers => {'Cookie' => "ZSession=#{zsession}", 'Content-Type' => 'application/json'}) output_json = JSON.parse(response.body) elsif !zconnect_accesstoken.blank? response = HTTParty.post("https://#{self.hostname}/apps/zconnectsession/navigationstate", :body => state.to_json, :headers => {'Cookie' => "#{self.zconnect_provider}=#{zconnect_accesstoken}", 'Content-Type' => 'application/json'}) output_json = JSON.parse(response.body) else if zconnect_accesstoken.blank? && cookies.keys.any? { |x| x.include? "ZConnect"} raise ZuoraAPI::Exceptions::ZuoraAPIError.new("No ZConnect cookie present matching #{self.hostname}") else raise ZuoraAPI::Exceptions::ZuoraAPIError.new("No ZSession cookie present") end end rescue JSON::ParserError => ex output_json = {} end raise_errors(type: :JSON, body: output_json, response: response) return output_json end def refresh_nav(cookies) zsession = cookies["ZSession"] zconnect_accesstoken = get_zconnect_accesstoken(cookies) begin if !zsession.blank? response = HTTParty.post("https://#{self.hostname}/apps/v1/navigation/fetch", :headers => {'Cookie' => "ZSession=#{zsession}", 'Content-Type' => 'application/json'}) output_json = JSON.parse(response.body) elsif !zconnect_accesstoken.blank? response = HTTParty.post("https://#{self.hostname}/apps/zconnectsession/refresh-navbarcache", :headers => {'Cookie' => "#{self.zconnect_provider}=#{zconnect_accesstoken}", 'Content-Type' => 'application/json'}) output_json = JSON.parse(response.body) else if zconnect_accesstoken.blank? && cookies.keys.any? { |x| x.include? "ZConnect"} raise ZuoraAPI::Exceptions::ZuoraAPIError.new("No ZConnect cookie present matching #{self.hostname}") else raise ZuoraAPI::Exceptions::ZuoraAPIError.new("No ZSession cookie present") end end rescue JSON::ParserError => ex output_json = {} end raise_errors(type: :JSON, body: output_json, response: response) return output_json end def get_zconnect_accesstoken(cookies) accesstoken = nil self.update_zconnect_provider if !cookies[self.zconnect_provider].nil? && !cookies[self.zconnect_provider].empty? accesstoken = cookies[self.zconnect_provider] end return accesstoken end def reporting_url(path) map = {"US" => {"Sandbox" => "https://zconnectsandbox.zuora.com/api/rest/v1/", "Production" => "https://zconnect.zuora.com/api/rest/v1/", "Services"=> ""}, "EU" => {"Sandbox" => "https://zconnect.sandbox.eu.zuora.com/api/rest/v1/", "Production" => "https://zconnect.eu.zuora.com/api/rest/v1/", "Services"=> ""}, "NA" => {"Sandbox" => "https://zconnect.sandbox.na.zuora.com/api/rest/v1/", "Production" => "https://zconnect.na.zuora.com/api/rest/v1/", "Services"=> ""} } return map[zuora_client.region][zuora_client.environment].insert(-1, path) end # There are two ways to call this method. The first way is best. # 1. Pass in cookies and optionally custom_authorities, name, and description # 2. Pass in user_id, entity_ids, client_id, client_secret, and optionally custom_authorities, name, and description # https://intranet.zuora.com/confluence/display/Sunburst/Create+an+OAuth+Client+through+API+Gateway#CreateanOAuthClientthroughAPIGateway-ZSession def get_oauth_client (custom_authorities = [], info_name: "No Name", info_desc: "This client was created without a description.", user_id: nil, entity_ids: nil, client_id: nil, client_secret: nil, new_client_id: nil, new_client_secret: nil, cookies: nil) authorization = "" new_client_id = SecureRandom.uuid if new_client_id.blank? new_client_secret = SecureRandom.hex(10) if new_client_secret.blank? if !cookies.nil? authorization = cookies["ZSession"] authorization = "ZSession-a3N2w #{authorization}" if entity_ids.blank? && cookies["ZuoraCurrentEntity"].present? entity_ids = Array(cookies["ZuoraCurrentEntity"].unpack("a8a4a4a4a12").join('-')) else raise ZuoraAPI::Exceptions::ZuoraAPIError.new("Zuora Entity ID not provided") end if user_id.blank? && cookies["Zuora-User-Id"].present? user_id = cookies["Zuora-User-Id"] else raise ZuoraAPI::Exceptions::ZuoraAPIError.new("Zuora User ID not provided") end elsif !client_id.nil? && !client_secret.nil? bearer_response = HTTParty.post("https://#{self.hostname}/oauth/token", :headers => {'Content-Type' => 'application/x-www-form-urlencoded', 'Accept' => 'application/json'}, :body => {'client_id' => client_id, 'client_secret' => URI::encode(client_secret), 'grant_type' => 'client_credentials'}) bearer_hash = JSON.parse(bearer_response.body) bearer_token = bearer_hash["access_token"] authorization = "Bearer #{bearer_token}" end if !authorization.blank? && !user_id.blank? && !entity_ids.blank? endpoint = self.rest_endpoint("genesis/clients") oauth_response = HTTParty.post(endpoint, :headers => {'authorization' => authorization, 'Content-Type' => 'application/json'}, :body => {'clientId' => new_client_id, 'clientSecret' => new_client_secret, 'userId' => user_id, 'entityIds' => entity_ids, 'customAuthorities' => custom_authorities, 'additionalInformation' => {'description' => info_desc, 'name' => info_name}}.to_json) output_json = JSON.parse(oauth_response.body) if oauth_response.code == 201 output_json["clientSecret"] = new_client_secret return output_json elsif oauth_response.code == 401 && !oauth_response.message.blank? raise ZuoraAPI::Exceptions::ZuoraAPIError.new(output_json["message"], oauth_response) else raise ZuoraAPI::Exceptions::ZuoraAPIError.new(output_json["error"], oauth_response) end else raise ZuoraAPI::Exceptions::ZuoraAPIError.new("Insufficient credentials provided") end end def self.environments %w(Sandbox Production Services Performance Staging) end def self.regions %w(US EU NA) end def self.endpoints return {"US" => {"Sandbox" => "https://apisandbox.zuora.com/apps/services/a/", "Production" => "https://www.zuora.com/apps/services/a/", "Performance" => "https://pt1.zuora.com/apps/services/a/", "Services" => "https://services347.zuora.com/apps/services/a/", "Staging" => "https://staging2.zuora.com/apps/services/a/"}, "EU" => {"Sandbox" => "https://sandbox.eu.zuora.com/apps/services/a/", "Production" => "https://eu.zuora.com/apps/services/a/", "Performance" => "https://pt1.eu.zuora.com/apps/services/a/", "Services" => "https://services347.eu.zuora.com/apps/services/a/"}, "NA" => {"Sandbox" => "https://sandbox.na.zuora.com/apps/services/a/", "Production" => "https://na.zuora.com/apps/services/a/", "Performance" => "https://pt1.na.zuora.com/apps/services/a/", "Services" => "https://services347.na.zuora.com/apps/services/a/"} } end def get_entity_id(entity_id: nil) if entity_id.present? entity_match = /[a-zA-Z0-9]{8}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{12}$/.match(entity_id) if entity_match.blank? raise "Entity length is wrong." if entity_id.length != 32 part_one, part_two, part_three, part_four, part_five = [entity_id[0..7], entity_id[8..11], entity_id[12..15], entity_id[16..19], entity_id[20..31]] entity_id = "#{part_one}-#{part_two}-#{part_three}-#{part_four}-#{part_five}" end end return entity_id end def update_region if !self.hostname.blank? if /(?<=\.|\/|^)(eu)(?=\.|\/|$)/ === self.hostname self.region = "EU" elsif /(?<=\.|\/|^)(na)(?=\.|\/|$)/ === self.hostname self.region = "NA" else self.region = "US" end else # This will never happen # raise "Can't update region because URL is blank" self.region = "Unknown" end end def update_environment if !self.url.blank? if /(?<=\.|\/|-|^)(apisandbox|sandbox)(?=\.|\/|-|$)/ === self.hostname self.environment = 'Sandbox' elsif /(?<=\.|\/|^)(service[\d]*|services[\d]*|ep-edge)(?=\.|\/|$)/ === self.hostname self.environment = 'Services' elsif /(?<=\.|\/|-|^)(pt[\d]*)(?=\.|\/|-|$)/ === self.hostname self.environment = 'Performance' elsif /(?<=\.|\/|^)(staging1|staging2|stg)(?=\.|\/|$)/ === self.hostname self.environment = 'Staging' elsif is_prod_env self.environment = 'Production' else self.environment = 'Unknown' end else # this will never happen raise "Can't determine environment from blank URL" end end def is_prod_env is_prod = false www_or_api = /(?<=\.|\/|^)(www|api)(?=\.|\/|$)/ === self.hostname host_prefix_match = /(^|tls10\.|origin-www\.|zforsf\.|eu\.|na\.)(zuora\.com)/ === self.hostname if www_or_api || host_prefix_match is_prod = true end return is_prod end def update_zconnect_provider region = update_region environment = update_environment mappings = {"US" => {"Sandbox" => "ZConnectSbx", "KubeSTG" => "ZConnectDev", "KubeDEV" => "ZConnectDev", "KubePROD" => "ZConnectDev", "Services" => "ZConnectQA", "Production" => "ZConnectProd", "Performance" => "ZConnectPT1", "Staging" => "ZConnectQA"}, "NA" => {"Sandbox" => "ZConnectSbxNA", "Services" => "ZConnectQANA", "Production" => "ZConnectProdNA", "Performance" => "ZConnectPT1NA"}, "EU" => {"Sandbox" => "ZConnectSbxEU", "Services" => "ZConnectQAEU", "Production" => "ZConnectProdEU", "Performance" => "ZConnectPT1EU"}, "Unknown" => {"Unknown" => "Unknown"}} self.zconnect_provider = mappings[region][environment] # raise "Can't find ZConnect Provider for #{region} region and #{environment} environment" if self.zconnect_provider.nil? end def aqua_endpoint(url="") match = /.*(\/apps\/)/.match(self.url) if !match.nil? url_slash_apps_slash = match[0] else raise "self.url has no /apps in it" end return "#{url_slash_apps_slash}api/#{url}" end def rest_endpoint(url="") update_environment endpoint = url case self.environment when 'Sandbox' case self.region when 'US' endpoint = "https://rest.apisandbox.zuora.com/v1/".concat(url) when 'EU' endpoint = "https://rest.sandbox.eu.zuora.com/v1/".concat(url) when 'NA' endpoint = "https://rest.sandbox.na.zuora.com/v1/".concat(url) end when 'Production' case self.region when 'US' endpoint = "https://rest.zuora.com/v1/".concat(url) when 'EU' endpoint = "https://rest.eu.zuora.com/v1/".concat(url) when 'NA' endpoint = "https://rest.na.zuora.com/v1/".concat(url) end when 'Performance' endpoint = "https://rest.pt1.zuora.com/v1/".concat(url) when 'Services' https = /https:\/\/|http:\/\//.match(self.url)[0] host = self.hostname endpoint = "#{https}rest#{host}/v1/#{url}" when 'Staging' endpoint = "https://rest-staging2.zuora.com/v1/".concat(url) when 'Unknown' raise "Environment unknown, returning passed in parameter unaltered" end return endpoint end def rest_domain return Addressable::URI.parse(self.rest_endpoint).host end def fileURL(url="") return self.rest_endpoint("file/").concat(url) end def dateFormat return self.wsdl_number > 68 ? '%Y-%m-%d' : '%Y-%m-%dT%H:%M:%S' end def new_session(auth_type: :basic, debug: false) end def get_session(prefix: false, auth_type: :basic) Rails.logger.debug("Get session for #{auth_type} - #{self.class.to_s}") if Rails.env.to_s == 'development' case auth_type when :basic if self.current_session.blank? case self.class.to_s when 'ZuoraAPI::Oauth' if self.bearer_token.blank? || self.oauth_expired? self.new_session(auth_type: :bearer) end self.get_z_session when 'ZuoraAPI::Basic' self.new_session(auth_type: :basic) else raise "No Zuora Login Specified" end end raise ZuoraAPI::Exceptions::ZuoraAPISessionError.new(self.current_error) if self.status != 'Active' return prefix ? "ZSession #{self.current_session}" : self.current_session.to_s when :bearer case self.class.to_s when 'ZuoraAPI::Oauth' if self.bearer_token.blank? || self.oauth_expired? self.new_session(auth_type: :bearer) end when 'ZuoraAPI::Basic' raise ZuoraAPI::Exceptions::ZuoraAPIAuthenticationTypeError.new("Basic Login, does not support Authentication of Type: #{auth_type}") end raise ZuoraAPI::Exceptions::ZuoraAPISessionError.new(self.current_error) if self.status != 'Active' return prefix ? "Bearer #{self.bearer_token}" : self.bearer_token.to_s end end def soap_call(ns1: 'ns1', ns2: 'ns2', batch_size: nil, single_transaction: false, debug: false, errors: [ZuoraAPI::Exceptions::ZuoraAPISessionError].concat(ZUORA_API_ERRORS), z_session: true, timeout_retry: false, timeout: 120,**keyword_args) tries ||= 2 xml = Nokogiri::XML::Builder.new do |xml| xml['SOAP-ENV'].Envelope('xmlns:SOAP-ENV' => "http://schemas.xmlsoap.org/soap/envelope/", "xmlns:#{ns2}" => "http://object.api.zuora.com/", 'xmlns:xsi' => "http://www.w3.org/2001/XMLSchema-instance", 'xmlns:api' => "http://api.zuora.com/", "xmlns:#{ns1}" => "http://api.zuora.com/") do xml['SOAP-ENV'].Header do xml["#{ns1}"].SessionHeader do xml["#{ns1}"].session self.get_session(prefix: false, auth_type: :basic) end if single_transaction xml["#{ns1}"].CallOptions do xml.useSingleTransaction single_transaction end end if batch_size xml["#{ns1}"].QueryOptions do xml.batchSize batch_size end end end xml['SOAP-ENV'].Body do yield xml, keyword_args end end end input_xml = Nokogiri::XML(xml.to_xml(:save_with => XML_SAVE_OPTIONS).strip) input_xml.xpath('//ns1:session', 'ns1' =>'http://api.zuora.com/').children.remove Rails.logger.debug("Request SOAP XML: #{input_xml.to_xml(:save_with => XML_SAVE_OPTIONS).strip}") if debug response = HTTParty.post(self.url,:body => xml.doc.to_xml(:save_with => XML_SAVE_OPTIONS).strip, :headers => {'Content-Type' => "text/xml; charset=utf-8"}, :timeout => timeout) output_xml = Nokogiri::XML(response.body) Rails.logger.debug("Response SOAP XML: #{output_xml.to_xml(:save_with => XML_SAVE_OPTIONS).strip}") if debug raise_errors(type: :SOAP, body: output_xml, response: response) rescue ZuoraAPI::Exceptions::ZuoraAPISessionError => ex if !(tries -= 1).zero? && z_session Rails.logger.debug("SOAP Call - Session Invalid") self.new_session(auth_type: :basic) retry else if errors.include?(ex.class) raise ex else return output_xml, input_xml, response end end rescue *ZUORA_API_ERRORS => ex if errors.include?(ex.class) raise ex else return output_xml, input_xml, response end rescue *CONNECTION_EXCEPTIONS => ex if !(tries -= 1).zero? Rails.logger.info("SOAP Call - #{ex.class} Timed out will retry after 5 seconds") sleep(self.timeout_sleep) retry else raise ex end rescue *CONNECTION_READ_EXCEPTIONS => ex if !(tries -= 1).zero? && timeout_retry Rails.logger.info("SOAP Call - #{ex.class} Timed out will retry after 5 seconds") sleep(self.timeout_sleep) retry else raise ex end rescue Errno::ECONNRESET => ex if !(tries -= 1).zero? && ex.message.include?('SSL_connect') retry else raise ex end rescue => ex raise ex else return output_xml, input_xml, response end def raise_errors(type: :SOAP, body: nil, response: nil) case type when :SOAP error = body.xpath('//fns:FaultCode', 'fns' =>'http://fault.api.zuora.com/').text message = body.xpath('//fns:FaultMessage', 'fns' =>'http://fault.api.zuora.com/').text if error.blank? || message.blank? error = body.xpath('//faultcode').text message = body.xpath('//faultstring').text end if error.blank? || message.blank? error = body.xpath('//ns1:Code', 'ns1' =>'http://api.zuora.com/').text message = body.xpath('//ns1:Message', 'ns1' =>'http://api.zuora.com/').text end #Update/Create/Delete Calls with multiple requests and responses if body.xpath('//ns1:result', 'ns1' =>'http://api.zuora.com/').size > 0 && body.xpath('//ns1:Errors', 'ns1' =>'http://api.zuora.com/').size > 0 error = [] success = [] body.xpath('//ns1:result', 'ns1' =>'http://api.zuora.com/').each_with_index do |call, object_index| if call.xpath('./ns1:Success', 'ns1' =>'http://api.zuora.com/').text == 'false' && call.xpath('./ns1:Errors', 'ns1' =>'http://api.zuora.com/').size > 0 message = "#{call.xpath('./*/ns1:Code', 'ns1' =>'http://api.zuora.com/').text}::#{call.xpath('./*/ns1:Message', 'ns1' =>'http://api.zuora.com/').text}" error.push(message) else success.push(call.xpath('./ns1:Id', 'ns1' =>'http://api.zuora.com/').text) end end end #By default response if not passed in for SOAP as all SOAP is 200 if error.present? if error.class == String case error when "INVALID_SESSION" raise ZuoraAPI::Exceptions::ZuoraAPISessionError.new("#{error}::#{message}", response) when "REQUEST_EXCEEDED_LIMIT" raise ZuoraAPI::Exceptions::ZuoraAPIRequestLimit.new("#{error}::#{message}", response) when "LOCK_COMPETITION" raise ZuoraAPI::Exceptions::ZuoraAPILockCompetition.new("#{error}::#{message}", response) when "BATCH_FAIL_ERROR" if message.include?("optimistic locking failed") raise ZuoraAPI::Exceptions::ZuoraAPILockCompetition.new("#{error}::#{message}", response) else raise ZuoraAPI::Exceptions::ZuoraAPIError.new("#{error}::#{message}", response) end when "TEMPORARY_ERROR" raise ZuoraAPI::Exceptions::ZuoraAPITemporaryError.new("#{error}::#{message}", response) when "INVALID_VALUE" if message.include?("data integrity violation") raise ZuoraAPI::Exceptions::ZuoraDataIntegrity.new("Data Integrity Violation", response) else raise ZuoraAPI::Exceptions::ZuoraAPIError.new("#{error}::#{message}", response) end else raise ZuoraAPI::Exceptions::ZuoraAPIError.new("#{error}::#{message}", response) if error.present? end elsif error.class == Array if error[0].include?("LOCK_COMPETITION") && error.count == 1 raise ZuoraAPI::Exceptions::ZuoraAPILockCompetition.new(error.group_by {|v| v}.map {|k,v| "(#{v.size}x) - #{k == "::" ? 'UNKNOWN::No error provided' : k}"}.join(', '), response) else raise ZuoraAPI::Exceptions::ZuoraAPIError.new(error.group_by {|v| v}.map {|k,v| "(#{v.size}x) - #{k == "::" ? 'UNKNOWN::No error provided' : k}"}.join(', '), response, error, success) end end end if response.code == 429 raise ZuoraAPI::Exceptions::ZuoraAPIRequestLimit.new("The total number of concurrent requests has exceeded the limit allowed by the system. Please resubmit your request later.", response) end when :JSON body = body.dig("results").present? ? body["results"] : body if body.class == Hash if body.class == Hash && (!body["success"] || !body["Success"] || response.code != 200) messages_array = (body["reasons"] || []).map {|error| error['message']}.compact codes_array = (body["reasons"] || []).map {|error| error['code']}.compact if body['message'] == "No bearer token" && response.code == 400 raise ZuoraAPI::Exceptions::ZuoraAPIAuthenticationTypeError.new("Authentication type is not supported by this Login", response) end if body['errorMessage'] raise ZuoraAPI::Exceptions::ZuoraAPIError.new(body['errorMessage'], response) end if body.dig("reasons").nil? ? false : body.dig("reasons")[0].dig("code") == 90000020 raise ZuoraAPI::Exceptions::BadEntityError.new("#{messages_array.join(', ')}", response) end if body['error'] == 'Unauthorized' && body['status'] = 401 raise ZuoraAPI::Exceptions::ZuoraAPISessionError.new("#{messages_array.join(', ')}", response) end #Authentication failed if codes_array.map{|code| code.to_s.slice(6,7).to_i}.include?(11) || response.code == 401 raise ZuoraAPI::Exceptions::ZuoraAPISessionError.new("#{messages_array.join(', ')}", response) end #Zuora REST Create Amendment error #Authentication failed if body["faultcode"].present? && body["faultcode"] == "fns:INVALID_SESSION" raise ZuoraAPI::Exceptions::ZuoraAPISessionError.new("#{body['faultstring']}", response) end #Request exceeded limit if codes_array.map{|code| code.to_s.slice(6,7).to_i}.include?(70) raise ZuoraAPI::Exceptions::ZuoraAPIRequestLimit.new("#{messages_array.join(', ')}", response) end #Locking contention if codes_array.map{|code| code.to_s.slice(6,7).to_i}.include?(50) raise ZuoraAPI::Exceptions::ZuoraAPILockCompetition.new("#{messages_array.join(', ')}", response) end #All Errors catch if codes_array.size > 0 raise ZuoraAPI::Exceptions::ZuoraAPIError.new("#{messages_array.join(', ')}", response) end #Zuora REST Query Errors if body["faultcode"].present? case body["faultcode"] when "fns:MALFORMED_QUERY" raise ZuoraAPI::Exceptions::ZuoraAPIError.new("#{body["faultcode"]}::#{body["faultstring"]}", response) when "fns:REQUEST_EXCEEDED_LIMIT" raise ZuoraAPI::Exceptions::ZuoraAPIRequestLimit.new("#{body["faultcode"]}::#{body["faultstring"]}", response) when "fns:LOCK_COMPETITION" raise ZuoraAPI::Exceptions::ZuoraAPILockCompetition.new("#{body["faultcode"]}::#{body["faultstring"]}", response) when "INVALID_SESSION" raise ZuoraAPI::Exceptions::ZuoraAPISessionError.new("#{body["faultcode"]}::#{body["faultstring"]}", response) else raise ZuoraAPI::Exceptions::ZuoraAPIError.new("#{body["faultcode"]}::#{body["faultstring"]}", response) end end if body["Errors"].present? || body["errors"].present? errors = [] (body["Errors"] || []).select { |obj| errors.push(obj["Message"]) }.compact (body["errors"] || []).select { |obj| errors.push(obj["Message"]) }.compact if errors.size > 0 raise ZuoraAPI::Exceptions::ZuoraAPIError.new("#{errors.join(", ")}", response, errors) end end end #Zuora REST Actions error (Create, Update, Delete, Amend) if body.class == Array all_errors = body.select {|obj| !obj['Success'] || !obj['success'] }.map {|obj| obj['Errors'] || obj['errors'] }.compact all_success = body.select {|obj| obj['Success'] || obj['success']}.compact if all_success.blank? && all_errors.present? error_codes = all_errors.flatten.group_by {|error| error['Code']}.keys.uniq error_messages = all_errors.flatten.group_by {|error| error['Message']}.keys.uniq if error_codes.size == 1 || error_messages.size == 1 if error_codes.first == "LOCK_COMPETITION" raise ZuoraAPI::Exceptions::ZuoraAPILockCompetition.new("Retry Lock Competition", response) elsif error_messages.first.include?("data integrity violation") raise ZuoraAPI::Exceptions::ZuoraDataIntegrity.new("Data Integrity Violation", response) end end end if all_errors.size > 0 raise ZuoraAPI::Exceptions::ZuoraAPIError.new("#{all_errors.flatten.group_by {|error| error['Message']}.keys.uniq.join(' ')}", response, all_errors, all_success) end end #All other errors if ![200,201].include?(response.code) if response.code == 429 raise ZuoraAPI::Exceptions::ZuoraAPIRequestLimit.new("The total number of concurrent requests has exceeded the limit allowed by the system. Please resubmit your request later.", response) else raise ZuoraAPI::Exceptions::ZuoraAPIError.new("#{response.message}", response) end end end end def aqua_query(queryName: '', query: '', version: '1.2', jobName: 'Aqua',partner: '', project: '') params = { "format" => 'csv', "version" => version, "name" => jobName, "encrypted" => 'none', "useQueryLabels" => 'true', "partner" => partner, "project" => project, "queries" => [{ "name" => queryName, "query" => query, "type" => 'zoqlexport' }] } response = self.rest_call(method: :post, body: params.to_json, url: self.aqua_endpoint("batch-query/")) if(response[0]["id"].nil?) raise ZuoraAPI::Exceptions::ZuoraAPIError.new("Error in AQuA Process.", response) end return getFileById(id: response[0]["id"]) end def getFileById(id: "2c92c0f85e7f88ff015e86b8f8f4517f") response = nil result = "new" while result != "completed" do sleep(2)#sleep 2 seconds response, fullResponse = self.rest_call(method: :get, body: {}, url: self.aqua_endpoint("batch-query/jobs/#{id}")) result = response["batches"][0]["status"] if result == "error" raise ZuoraAPI::Exceptions::ZuoraAPIError.new("Aqua Error", response) break end end fileId = response["batches"][0]["fileId"] return self.get_file(url: self.aqua_endpoint("file/#{fileId}")) end def describe_call(object = nil) tries ||= 2 base = self.url.include?(".com") ? self.url.split(".com")[0].concat(".com") : self.url.split(".eu")[0].concat(".eu") url = object ? "#{base}/apps/api/describe/#{object}" : "#{base}/apps/api/describe/" headers = self.entity_id.present? ? {"Zuora-Entity-Ids" => self.entity_id, 'Content-Type' => "text/xml; charset=utf-8"} : {'Content-Type' => "text/xml; charset=utf-8"} response = HTTParty.get(url, headers: {"Authorization" => self.get_session(prefix: true, auth_type: :basic)}.merge(headers), :timeout => 120) raise ZuoraAPI::Exceptions::ZuoraAPISessionError.new(self.current_error.present? ? self.current_error : 'Describe call 401', response) if response.code == 401 output_xml = Nokogiri::XML(response.body) des_hash = Hash.new if object == nil output_xml.xpath("//object").each do |object| temp = {:label => object.xpath(".//label").text, :url => object.attributes["href"].value } des_hash[object.xpath(".//name").text] = temp end else output_xml.xpath("//field").each do |object| temp = {:label => object.xpath(".//label").text,:selectable => object.xpath(".//selectable").text, :createable => object.xpath(".//label").text == "ID" ? "false" : object.xpath(".//createable").text, :filterable => object.xpath(".//filterable").text, :updateable => object.xpath(".//label").text == "ID" ? "false" : object.xpath(".//updateable").text, :custom => object.xpath(".//custom").text,:maxlength => object.xpath(".//maxlength").text, :required => object.xpath(".//required").text, :type => object.xpath(".//type").text, :context => object.xpath(".//context").collect{ |x| x.text } } temp[:options] = object.xpath(".//option").collect{ |x| x.text } if object.xpath(".//option").size > 0 des_hash[object.xpath(".//name").text.to_sym] = temp des_hash[:fieldsToNull] = {:label => "FieldsToNull",:selectable => "false", :createable => "false",:filterable => "false", :updateable => "true",:custom => "false", :required => "false",:type => "picklist", :maxlength => "" ,:context => ["soap"], :options => des_hash.map {|k,v| k if v[:updateable] == "true" && v[:required] == "false"}.compact.uniq } end des_hash[:related_objects] = output_xml.xpath(".//related-objects").xpath(".//object").map{ |x| [x.xpath(".//name").text.to_sym, [ [:url, x.attributes["href"].value], [:label, x.xpath(".//name").text ] ].to_h] }.to_h end rescue *(CONNECTION_EXCEPTIONS).concat(CONNECTION_READ_EXCEPTIONS) => ex if !(tries -= 1).zero? Rails.logger.info("Describe - #{ex.class} Timed out will retry after 5 seconds") sleep(self.timeout_sleep) retry else raise ex end rescue ZuoraAPI::Exceptions::ZuoraAPISessionError => ex if !(tries -= 1).zero? Rails.logger.info("Session expired. Starting new session.") self.new_session retry else raise ex end rescue => ex raise ex else return des_hash end def rest_call(method: :get, body: nil, headers: {}, url: rest_endpoint("catalog/products?pageSize=4"), debug: false, errors: [ZuoraAPI::Exceptions::ZuoraAPISessionError].concat(ZUORA_API_ERRORS), z_session: true, session_type: :basic, timeout_retry: false, timeout: 120, **keyword_args) tries ||= 2 if self.entity_id.present? headers["Zuora-Entity-Ids"] = self.entity_id if headers.dig("Zuora-Entity-Ids").nil? headers.delete_if { |key, value| ["entityId", "entityName"].include?(key.to_s) } end raise "Method not supported, supported methods include: :get, :post, :put, :delete, :patch, :head, :options" if ![:get, :post, :put, :delete, :patch, :head, :options].include?(method) authentication_headers = z_session ? {"Authorization" => self.get_session(prefix: true, auth_type: session_type) } : {} modified_headers = {'Content-Type' => "application/json; charset=utf-8"}.merge(authentication_headers).merge(headers) response = HTTParty::Request.new("Net::HTTP::#{method.to_s.capitalize}".constantize, url, body: body, headers: modified_headers, timeout: timeout).perform Rails.logger.debug("Response Code: #{response.code}") if debug begin output_json = JSON.parse(response.body) rescue JSON::ParserError => ex output_json = {} end Rails.logger.debug("Response JSON: #{output_json}") if debug && output_json.present? raise_errors(type: :JSON, body: output_json, response: response) rescue ZuoraAPI::Exceptions::ZuoraAPIAuthenticationTypeError => ex if self.class.to_s == 'ZuoraAPI::Oauth' && ex.message.include?("Authentication type is not supported by this Login") self.rest_call(method: method.to_sym, url: url, body: body, debug: debug, errors: errors, z_session: z_session, session_type: :bearer, timeout_retry: timeout_retry, timeout: timeout) else Rails.logger.debug("Rest Call - Session Bad Auth type") raise ex end rescue ZuoraAPI::Exceptions::ZuoraAPISessionError => ex if !(tries -= 1).zero? && z_session Rails.logger.debug("Rest Call - Session Invalid #{session_type}") self.new_session(auth_type: session_type) retry else if errors.include?(ex.class) raise ex else return [output_json, response] end end rescue *ZUORA_API_ERRORS => ex if errors.include?(ex.class) raise ex else return [output_json, response] end rescue ZuoraAPI::Exceptions::BadEntityError => ex raise ex rescue *CONNECTION_EXCEPTIONS => ex if !(tries -= 1).zero? Rails.logger.info("Rest Call - #{ex.class} Timed out will retry after 5 seconds") sleep(self.timeout_sleep) retry else raise ex end rescue *CONNECTION_READ_EXCEPTIONS => ex if !(tries -= 1).zero? && timeout_retry Rails.logger.info("Rest Call - #{ex.class} Timed out will retry after 5 seconds") sleep(self.timeout_sleep) retry else raise ex end rescue Errno::ECONNRESET => ex if !(tries -= 1).zero? && ex.message.include?('SSL_connect') retry else raise ex end rescue => ex raise ex else return [output_json, response] end def update_create_tenant Rails.logger.debug("Update and/or Create Tenant") output_xml, input_xml = soap_call() do |xml| xml['api'].getUserInfo end user_info = output_xml.xpath('//ns1:getUserInfoResponse', 'ns1' =>'http://api.zuora.com/') output_hash = Hash[user_info.children.map {|x| [x.name.to_sym, x.text] }] self.user_info = output_hash self.user_info['entities'] = self.rest_call(:url => self.rest_endpoint("user-access/user-profile/#{self.user_info['UserId']}/accessible-entities"))['entities'] self.tenant_name = output_hash[:TenantName] self.tenant_id = output_hash[:TenantId] return self end def get_catalog(page_size: 40) products, catalog_map, response = [{}, {}, {'nextPage' => self.rest_endpoint("catalog/products?pageSize=#{page_size}") }] while !response["nextPage"].blank? url = self.rest_endpoint(response["nextPage"].split('/v1/').last) Rails.logger.debug("Fetch Catalog URL #{url}") output_json, response = self.rest_call(:debug => false, :url => url, :errors => [ZuoraAPI::Exceptions::ZuoraAPISessionError], :timeout_retry => true ) if !output_json['success'] =~ (/(true|t|yes|y|1)$/i) || output_json['success'].class != TrueClass raise ZuoraAPI::Exceptions::ZuoraAPIError.new("Error Getting Catalog: #{output_json}", response) end output_json["products"].each do |product| catalog_map[product["id"]] = {"productId" => product["id"]} rateplans = {} product["productRatePlans"].each do |rateplan| catalog_map[rateplan["id"]] = {"productId" => product["id"], "productRatePlanId" => rateplan["id"]} charges = {} rateplan["productRatePlanCharges"].each do |charge| catalog_map[charge["id"]] = {"productId" => product["id"], "productRatePlanId" => rateplan["id"], "productRatePlanChargeId" => charge["id"]} charges[charge["id"]] = charge.merge({"productId" => product["id"], "productName" => product["name"], "productRatePlanId" => rateplan["id"], "productRatePlanName" => rateplan["name"] }) end rateplan["productRatePlanCharges"] = charges rateplans[rateplan["id"]] = rateplan.merge({"productId" => product["id"], "productName" => product["name"]}) end product["productRatePlans"] = rateplans products[product['id']] = product end end return products, catalog_map end def get_file(url: nil, headers: {}, count: 3, z_session: true, tempfile: true, output_file_name: nil, add_timestamp: true, file_path: defined?(Rails.root.join('tmp')) ? Rails.root.join('tmp') : Pathname.new(Dir.pwd), timeout_retries: 2, timeout: 120, session_type: :basic, **execute_params) raise "file_path must be of class Pathname" if file_path.class != Pathname #Make sure directory exists require 'fileutils' FileUtils.mkdir_p(file_path) unless File.exists?(file_path) begin status_code = nil uri = URI.parse(url) http = Net::HTTP.new(uri.host, uri.port) http.read_timeout = timeout #Seconds http.use_ssl = true if !uri.scheme.nil? && uri.scheme.downcase == 'https' if z_session headers = headers.merge({"Authorization" => self.get_session(prefix: true)}) headers = headers.merge({"Zuora-Entity-Ids" => self.entity_id}) if !self.entity_id.blank? end response_save = nil begin http.request_get(uri.request_uri, headers) do |response| response_save = response status_code = response.code if response case response when Net::HTTPNotFound raise when Net::HTTPUnauthorized if count <= 0 if z_session raise ZuoraAPI::Exceptions::ZuoraAPISessionError.new(self.current_error) else raise end end self.new_session if z_session return get_file(:url => url, :headers => headers, :count => count - 1, :z_session => z_session, :tempfile => tempfile, :file_path => file_path, :timeout_retries => timeout_retries, :timeout => timeout) when Net::HTTPClientError raise when Net::HTTPOK headers = {} response.each_header do |k,v| headers[k] = v end Rails.logger.debug("Headers: #{headers.to_s}") if output_file_name.present? file_ending ||= output_file_name.end_with?(".csv.zip") ? ".csv.zip" : File.extname(output_file_name) filename ||= File.basename(output_file_name, file_ending) end size, export_progress = [0, 0] encoding, type, full_filename = [nil, nil, nil] if response.header["Content-Disposition"].present? case response.header["Content-Disposition"] when /.*; filename\*=.*/ full_filename ||= /.*; filename\*=(.*)''(.*)/.match(response.header["Content-Disposition"])[2].strip encoding = /.*; filename\*=(.*)''(.*)/.match(response.header["Content-Disposition"])[1].strip when /.*; filename=/ full_filename ||= /.*; filename=(.*)/.match(response.header["Content-Disposition"])[1].strip else raise "Can't parse Content-Disposition header: #{response.header["Content-Disposition"]}" end file_ending ||= full_filename.end_with?(".csv.zip") ? ".csv.zip" : File.extname(full_filename) filename ||= File.basename(full_filename, file_ending) end #If user supplied a filename use it, else default to content header filename, else default to uri pattern file_ending ||= uri.path.end_with?(".csv.zip") ? ".csv.zip" : File.extname(uri.path) filename ||= File.basename(uri.path, file_ending) if response.header["Content-Type"].present? case response.header["Content-Type"] when /.*;charset=.*/ type = /(.*);charset=(.*)/.match(response.header["Content-Type"])[1] encoding = /(.*);charset=(.*)/.match(response.header["Content-Type"])[2] else type = response.header["Content-Type"] encoding ||= 'UTF-8' end end if response.header["Content-Length"].present? export_size = response.header["Content-Length"].to_i elsif response.header["ContentLength"].present? export_size = response.header["ContentLength"].to_i end Rails.logger.info("File: #{filename}#{file_ending} #{encoding} #{type} #{export_size}") file_prefix = add_timestamp ? "#{filename}_#{Time.now.to_i}" : filename if tempfile require 'tempfile' file_handle = ::Tempfile.new([file_prefix, "#{file_ending}"], file_path) else file_handle = File.new(file_path.join("#{file_prefix}#{file_ending}"), "w+") end file_handle.binmode response.read_body do |chunk| file_handle << chunk if defined?(export_size) && export_size != 0 && export_size.class == Integer size += chunk.size new_progress = (size * 100) / export_size unless new_progress == export_progress Rails.logger.debug("Login: Export Downloading %s (%3d%%)" % [filename, new_progress]) end export_progress = new_progress end end file_handle.close Rails.logger.debug("Filepath: #{file_handle.path} Size: #{File.size(file_handle.path).to_f/1000000} mb") return file_handle end end rescue *(CONNECTION_EXCEPTIONS).concat(CONNECTION_READ_EXCEPTIONS).concat([Net::HTTPBadResponse]) => e sleep(5) if (timeout_retries -= 1) >= 0 Rails.logger.warn("Download Failed: #{e.class} : #{e.message}") retry else raise end end rescue => ex Rails.logger.fatal(ex) raise end end def getDataSourceExport(query, extract: true, encrypted: false, zip: true) request = Nokogiri::XML::Builder.new do |xml| xml['SOAP-ENV'].Envelope('xmlns:SOAP-ENV' => "http://schemas.xmlsoap.org/soap/envelope/", 'xmlns:ns2' => "http://object.api.zuora.com/", 'xmlns:xsi' => "http://www.w3.org/2001/XMLSchema-instance", 'xmlns:ns1' => "http://api.zuora.com/") do xml['SOAP-ENV'].Header do xml['ns1'].SessionHeader do xml['ns1'].session self.get_session(prefix: false, auth_type: :basic) end end xml['SOAP-ENV'].Body do xml['ns1'].create do xml['ns1'].zObjects('xsi:type' => "ns2:Export") do xml['ns2'].Format 'csv' xml['ns2'].Zip zip xml['ns2'].Name 'googman' xml['ns2'].Query query xml['ns2'].Encrypted encrypted end end end end end response_query = HTTParty.post(self.url, body: request.to_xml(:save_with => XML_SAVE_OPTIONS).strip, headers: {'Content-Type' => "application/json; charset=utf-8"}, :timeout => 120) output_xml = Nokogiri::XML(response_query.body) raise 'Export Creation Unsuccessful : ' + output_xml.xpath('//ns1:Message', 'ns1' =>'http://api.zuora.com/').text if output_xml.xpath('//ns1:Success', 'ns1' =>'http://api.zuora.com/').text != "true" id = output_xml.xpath('//ns1:Id', 'ns1' =>'http://api.zuora.com/').text confirmRequest = Nokogiri::XML::Builder.new do |xml| xml['SOAP-ENV'].Envelope('xmlns:SOAP-ENV' => "http://schemas.xmlsoap.org/soap/envelope/", 'xmlns:ns2' => "http://object.api.zuora.com/", 'xmlns:xsi' => "http://www.w3.org/2001/XMLSchema-instance", 'xmlns:ns1' => "http://api.zuora.com/") do xml['SOAP-ENV'].Header do xml['ns1'].SessionHeader do xml['ns1'].session self.get_session(prefix: false, auth_type: :basic) end end xml['SOAP-ENV'].Body do xml['ns1'].query do xml['ns1'].queryString "SELECT Id, CreatedById, CreatedDate, Encrypted, FileId, Format, Name, Query, Size, Status, StatusReason, UpdatedById, UpdatedDate, Zip From Export where Id = '#{id}'" end end end end result = 'Waiting' while result != "Completed" sleep 3 response_query = HTTParty.post(self.url, body: confirmRequest.to_xml(:save_with => XML_SAVE_OPTIONS).strip, headers: {'Content-Type' => "application/json; charset=utf-8"}, :timeout => 120) output_xml = Nokogiri::XML(response_query.body) result = output_xml.xpath('//ns2:Status', 'ns2' =>'http://object.api.zuora.com/').text status_code = response_query.code if response_query raise "Export Creation Unsuccessful : #{output_xml.xpath('//ns1:Message', 'ns1' =>'http://api.zuora.com/').text}" if result.blank? || result == "Failed" end file_id = output_xml.xpath('//ns2:FileId', 'ns2' =>'http://object.api.zuora.com/').text export_file = get_file(:url => self.fileURL(file_id)) export_file_path = export_file.path Rails.logger.debug("=====> Export path #{export_file.path}") if extract && zip require "zip" new_path = export_file_path.partition('.zip').first zipped = Zip::File.open(export_file_path) file_handle = zipped.entries.first file_handle.extract(new_path) File.delete(export_file_path) return new_path else return export_file_path end end def query(query, parse = false) output_xml, input_xml = self.soap_call({:debug => false, :timeout_retry => true}) do |xml| xml['ns1'].query do xml['ns1'].queryString query end end if parse return [] if output_xml.xpath('//ns1:size', 'ns1' =>'http://api.zuora.com/').text == '0' data = output_xml.xpath('//ns1:records', 'ns1' =>'http://api.zuora.com/').map {|record| record.children.map {|element| [element.name, element.text]}.to_h} return data else return output_xml end end def createJournalRun(call) url = rest_endpoint("/journal-runs") uri = URI(url) req = Net::HTTP::Post.new(uri,initheader = {'Content-Type' =>'application/json'}) req["Authorization"] = self.get_session(prefix: true) req.body = call response = Net::HTTP.start(uri.host, uri.port, :use_ssl => true) do |http| http.request req end Rails.logger.debug("Response #{response.code} #{response.message}: #{response.body}") result = JSON.parse(response.body) if result["success"] jrNumber = result["journalRunNumber"] return jrNumber else message = result["reasons"][0]["message"] Rails.logger.error("Journal Run failed with message #{message}") return result end end def checkJRStatus(jrNumber) Rails.logger.info("Check for completion") url = rest_endpoint("/journal-runs/#{jrNumber}") uri = URI(url) req = Net::HTTP::Get.new(uri,initheader = {'Content-Type' =>'application/json'}) req["Authorization"] = self.get_session(prefix: true) response = Net::HTTP.start(uri.host, uri.port, :use_ssl => true) do |http| http.request req end result = JSON.parse(response.body) if result["success"] if !(result["status"].eql? "Completed") sleep(20.seconds) end return result["status"] else message = result["reasons"][0]["message"] Rails.logger.info("Checking status of journal run failed with message #{message}") end return "failure" end end end