require 'net/https'
require 'uri'
require 'zlib'
require 'stringio'
require 'rexml/document'
require 'builder'
require 'oauth'
module RForce
# Implements the connection to the Salesforce server.
class Binding
include RForce
# Increase the maximum fetch size to 2000, as allowed by Salesforce
# Added by Raymond Gao
DEFAULT_BATCH_SIZE = 2000
attr_accessor :batch_size, :url, :assignment_rule_id, :use_default_rule, :update_mru, :client_id, :trigger_user_email,
:trigger_other_email, :trigger_auto_response_email
# Fill in the guts of this typical SOAP envelope
# with the session ID and the body of the SOAP request.
Envelope = <<-HERE
%s
%s
%s
HERE
QueryOptions = '%d'
AssignmentRuleHeaderUsingRuleId = '%s'
AssignmentRuleHeaderUsingDefaultRule = 'true'
MruHeader = 'true'
ClientIdHeader = '%s'
# Create a binding to the server (after which you can call login
# or login_with_oauth to connect to it). If you pass an oauth
# hash, it must contain the keys :consumer_key, :consumer_secret,
# :access_token, :access_secret, and :login_url.
#
# proxy may be a URL of the form http://user:pass@example.com:port
#
# if a logger is specified, it will be used for very verbose SOAP logging
#
def initialize(url, sid = nil, oauth = nil, proxy = nil, logger = nil)
@session_id = sid
@oauth = oauth
@proxy = proxy
@batch_size = DEFAULT_BATCH_SIZE
@logger = logger
@url = URI.parse(url)
end
def show_debug
ENV['SHOWSOAP'] == 'true'
end
def create_server(url)
server = Net::HTTP.Proxy(@proxy).new(url.host, url.port)
server.use_ssl = (url.scheme == 'https')
server.verify_mode = OpenSSL::SSL::VERIFY_NONE
# run ruby with -d or env variable SHOWSOAP=true to see SOAP wiredumps.
server.set_debug_output $stderr if show_debug
return server
end
# Log in to the server with a user name and password, remembering
# the session ID returned to us by Salesforce.
def login(user, password)
@user = user
@password = password
@server = create_server(@url)
response = call_remote(:login, [:username, user, :password, password])
raise "Incorrect user name / password [#{response.Fault}]" unless response.loginResponse
result = response[:loginResponse][:result]
@session_id = result[:sessionId]
@url = URI.parse(result[:serverUrl])
@server = create_server(@url)
return response
end
# Log in to the server with OAuth, remembering
# the session ID returned to us by Salesforce.
def login_with_oauth
consumer = OAuth::Consumer.new \
@oauth[:consumer_key],
@oauth[:consumer_secret]
access = OAuth::AccessToken.new \
consumer, @oauth[:access_token],
@oauth[:access_secret]
login_url = @oauth[:login_url]
result = access.post \
login_url,
'',
{'content-type' => 'application/x-www-form-urlencoded'}
case result
when Net::HTTPSuccess
doc = REXML::Document.new result.body
@session_id = doc.elements['*/sessionId'].text
@url = URI.parse(doc.elements['*/serverUrl'].text)
@server = access
class << @server
alias_method :post2, :post
end
return {:sessionId => @session_id, :serverUrl => @url.to_s}
when Net::HTTPUnauthorized
raise 'Invalid OAuth tokens'
else
raise "Unexpected error: #{response.inspect}"
end
end
# Call a method on the remote server. Arguments can be
# a hash or (if order is important) an array of alternating
# keys and values.
def call_remote(method, args)
# Different URI requirements for regular vs. OAuth. This is
# *screaming* for a refactor.
fallback_soap_url = @oauth ? @url.to_s : @url.path
urn, soap_url = block_given? ? yield : ["urn:partner.soap.sforce.com", fallback_soap_url]
# Create XML text from the arguments.
expanded = ''
@builder = Builder::XmlMarkup.new(:target => expanded)
expand(@builder, {method => args}, urn)
extra_headers = ""
# QueryOptions is not valid when making an Apex Webservice SOAP call
if !block_given?
extra_headers << (QueryOptions % @batch_size)
end
extra_headers << (AssignmentRuleHeaderUsingRuleId % assignment_rule_id) if assignment_rule_id
extra_headers << AssignmentRuleHeaderUsingDefaultRule if use_default_rule
extra_headers << MruHeader if update_mru
extra_headers << (ClientIdHeader % client_id) if client_id
if trigger_user_email or trigger_other_email or trigger_auto_response_email
extra_headers << ''
extra_headers << 'true' if trigger_user_email
extra_headers << 'true' if trigger_other_email
extra_headers << 'true' if trigger_auto_response_email
extra_headers << ''
end
# Fill in the blanks of the SOAP envelope with our
# session ID and the expanded XML of our request.
request = (Envelope % [@session_id, extra_headers, expanded])
@logger && @logger.info("RForce request: #{request}")
# reset the batch size for the next request
@batch_size = DEFAULT_BATCH_SIZE
# gzip request
request = encode(request)
headers = {
'Connection' => 'Keep-Alive',
'Content-Type' => 'text/xml',
'SOAPAction' => '""',
'User-Agent' => 'activesalesforce rforce/1.0'
}
unless show_debug
headers['Accept-Encoding'] = 'gzip'
headers['Content-Encoding'] = 'gzip'
end
# Send the request to the server and read the response.
@logger && @logger.info("RForce request to host #{@server} url #{soap_url} headers: #{headers}")
response = @server.post2(soap_url, request.lstrip, headers)
# decode if we have encoding
content = decode(response)
# Fix charset encoding. Needed because the "content" variable may contain a UTF-8
# or ISO-8859-1 string, but is carrying the US-ASCII encoding.
content = fix_encoding(content)
# Check to see if INVALID_SESSION_ID was raised and try to relogin in
if method != :login and @session_id and content =~ /sf:INVALID_SESSION_ID/
if @user
login(@user, @password)
else
raise "INVALID_SESSION_ID"
end
# repackage and rencode request with the new session id
request = (Envelope % [@session_id, extra_headers, expanded])
request = encode(request)
# Send the request to the server and read the response.
response = @server.post2(soap_url, request.lstrip, headers)
content = decode(response)
# Fix charset encoding. Needed because the "content" variable may contain a UTF-8
# or ISO-8859-1 string, but is carrying the US-ASCII encoding.
content = fix_encoding(content)
end
@logger && @logger.info("RForce response: #{content}")
SoapResponse.new(content).parse
end
# decode gzip
def decode(response)
encoding = response['Content-Encoding']
# return body if no encoding
if !encoding then return response.body end
# decode gzip
case encoding.strip
when 'gzip' then
begin
gzr = Zlib::GzipReader.new(StringIO.new(response.body))
decoded = gzr.read
ensure
gzr.close
end
decoded
else
response.body
end
end
# encode gzip
def encode(request)
return request if show_debug
begin
ostream = StringIO.new
gzw = Zlib::GzipWriter.new(ostream)
gzw.write(request)
ostream.string
ensure
gzw.close
end
end
# fix invalid US-ASCII strings by applying the correct encoding on ruby 1.9+
def fix_encoding(string)
if [:valid_encoding?, :force_encoding].all? { |m| string.respond_to?(m) }
if !string.valid_encoding?
# The 2 possible encodings in responses are UTF-8 and ISO-8859-1
# http://www.salesforce.com/us/developer/docs/api/Content/implementation_considerations.htm#topic-title_international
#
["UTF-8", "ISO-8859-1"].each do |encoding_name|
s = string.dup.force_encoding(encoding_name)
if s.valid_encoding?
return s
end
end
raise "Invalid encoding in SOAP response: not in [US-ASCII, UTF-8, ISO-8859-1]"
end
end
return string
end
# Turns method calls on this object into remote SOAP calls.
def method_missing(method, *args)
unless args.empty? || (args.size == 1 && [Hash, Array].include?(args[0].class))
raise 'Expected at most 1 Hash or Array argument'
end
call_remote method, args[0] || []
end
end
end