├── .gitignore ├── .travis.yml ├── CHANGELOG.txt ├── CODE_OF_CONDUCT.md ├── Gemfile ├── Gemfile.ci ├── README.rdoc ├── Rakefile ├── examples ├── oauth_setup.rb ├── oauth_use.rb └── simple.rb ├── lib ├── rforce.rb ├── rforce │ ├── binding.rb │ ├── method_keys.rb │ ├── soap_pullable.rb │ ├── soap_response.rb │ ├── soap_response_nokogiri.rb │ ├── soap_response_rexml.rb │ └── version.rb └── tasks │ └── timing.rake ├── rforce.gemspec └── spec ├── rforce_spec.rb ├── soap-response.xml ├── spec.opts └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle 2 | Gemfile.lock 3 | pkg 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | sudo: false 3 | language: ruby 4 | cache: bundler 5 | rvm: 6 | - 2.6.0 7 | before_install: gem install bundler -v 2.0.2 8 | install: "bundle install" 9 | script: "bundle exec rake spec" 10 | rvm: 11 | - 2.3 12 | - 2.4 13 | - 2.5 14 | - 2.6 15 | - jruby 16 | -------------------------------------------------------------------------------- /CHANGELOG.txt: -------------------------------------------------------------------------------- 1 | == 0.15.0 2021-04-22 2 | 3 | * 1 security update 4 | * Upgraded away from vulnerable OAuth version 5 | * 1 compatibility update 6 | * Dropped expat/xmlbuilder for lack of compatibility 7 | 8 | == 0.14.0 2019-08-20 9 | 10 | * 1 security update 11 | * Upgraded away from vulnerable Nokogiri version 12 | * 1 enhancement 13 | * Slightly modernized project layout 14 | 15 | == 0.13.0 2015-01-05 16 | * 1 bug fix 17 | * Attempt server connection even if caller hasn't logged in yet 18 | (reported by desheikh) 19 | 20 | == 0.12.0 2014-07-27 21 | * 2 enhancements 22 | * Clearer errors when skipping login (Victor Stan) 23 | * Allow passing in an optional logger (Jeff Jolma) 24 | * 3 bug fixes 25 | * Fixed URL error in OAuth flow (reported by karthickbabuos) 26 | * Corrected UTF-8 handling error (Maxime Rety) 27 | * Fixed repeat login issue (Jeff Jolma) 28 | 29 | == 0.11.0 2013-06-08 30 | * 1 minor enhancement 31 | * Give the README an .rdoc extension for easy Github viewing 32 | (Brad Dunn) 33 | * 1 bug fix 34 | * Fixed malformed request (Jeff Jolma) 35 | 36 | == 0.10.0 2012-09-12 37 | * 1 bug fix 38 | * Integer auto-parsing mangles ZIP codes (reported by Jason 39 | Yanowitz) 40 | 41 | == 0.9.0 2012-09-03 42 | * 1 minor enhancement 43 | * Automatically correct duplicate ID fields (Tomas Svarovsky) 44 | * 1 bug fix 45 | * Removed QueryOptions SOAP header when no block passed to 46 | call_remote (clicrdv) 47 | 48 | == 0.8.1 2012-01-29 49 | * 1 minor enhancement 50 | * Updated history file 51 | 52 | == 0.8 2012-01-29 53 | 54 | * 3 minor enhancements 55 | * Modified RForce::Binding#method_missing to allow for no-arg API 56 | calls (Brandon Tilley) 57 | * Added a way to test logins without raising an exception (Leon 58 | Lukashevsky) 59 | * Added ability to use a proxy server 60 | * 2 bug fixes 61 | * Fixed invalid XML in RForce::Binding::Envelope (Brandon Tilley) 62 | * Changed response.fault to correct method response.Fault (Jason 63 | Rogers) 64 | 65 | == 0.7 2011-02-05 66 | 67 | * 1 minor enhancement 68 | * Update OAuth login to keep up with Salesforce (Aaron Qian) 69 | 70 | == 0.6 2011-01-18 71 | 72 | * 1 major enhancement 73 | * Added Nokogiri support (Rob Rasmussen) 74 | 75 | == 0.5.1 2010-12-21 76 | 77 | * 2 minor enhancements 78 | * Added missing file to manifest (reported by Tyler Jennings) 79 | * Added dependency on OAuth gem 80 | 81 | == 0.5.0 2010-12-11 82 | 83 | * 2 minor enhancements: 84 | * Increase batch size (Raymond Gao) 85 | * Removed Facets dependency (Justin Ramos, Aaron Qian) 86 | 87 | == 0.4.1 2010-03-21 88 | 89 | * 1 minor enhancement: 90 | * Experimental OAuth support 91 | 92 | == 0.4 2010-01-21 93 | 94 | * 1 minor enhancement: 95 | * Fixed bug in search for XML libraries 96 | 97 | == 0.4 2010-01-21 98 | 99 | * 1 minor enhancement: 100 | * Moved RubyGems dependencies into the gemspec where they belong 101 | 102 | == 0.3 2009-04-16 103 | 104 | * 1 minor enhancement: 105 | * Updated for Ruby 1.9 106 | 107 | == 0.2.1 2008-07-04 108 | 109 | * 1 minor enhancement: 110 | * Updated examples for SalesForce API v10 111 | 112 | == 0.2.0 2008-07-03 113 | 114 | * 1 major enhancement: 115 | * Incorporated fixes from ActiveSalesforce project 116 | 117 | == 0.1.0 2005-09-02 118 | 119 | * 1 major enhancement: 120 | * Initial release 121 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at erin.dees@stitchfix.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in rforce.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /Gemfile.ci: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'builder', '~> 3.0' 4 | gem 'oauth', '~> 0.5.5' 5 | gem 'rspec', '~> 3.0' 6 | gem 'nokogiri', '~> 1.10.4' 7 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | = rforce 2 | 3 | * http://github.com/undees/rforce 4 | 5 | == DESCRIPTION: 6 | 7 | RForce is a simple, usable binding to the Salesforce API. 8 | 9 | {}[https://travis-ci.org/undees/rforce] 10 | 11 | == FEATURES: 12 | 13 | Rather than enforcing adherence to the sforce.com schema, RForce assumes you are familiar with the API. Ruby method names become SOAP method names. Nested Ruby hashes become nested XML elements. 14 | 15 | == SYNOPSIS: 16 | 17 | === Logging in with a user name and password 18 | 19 | binding = RForce::Binding.new \ 20 | 'https://www.salesforce.com/services/Soap/u/20.0' 21 | 22 | binding.login \ 23 | 'email', 'password' + 'token' 24 | 25 | === Logging in with OAuth 26 | 27 | oauth = { 28 | :consumer_key => '...', # Tokens obtained from Salesforce 29 | :consumer_secret => '...', 30 | :access_token => '...', 31 | :access_secret => '...', 32 | :login_url => 'https://login.salesforce.com/services/OAuth/u/20.0' 33 | } 34 | 35 | binding = RForce::Binding.new \ 36 | 'https://www.salesforce.com/services/Soap/u/20.0', 37 | nil, 38 | oauth 39 | 40 | binding.login_with_oauth 41 | 42 | === Finding a record 43 | 44 | answer = binding.search \ 45 | :searchString => 46 | 'find {McFakerson Co} in name fields returning account(id)' 47 | 48 | account = answer.searchResponse.result.searchRecords.record 49 | account_id = account.Id 50 | 51 | === Creating a record 52 | 53 | opportunity = [ 54 | :type, 'Opportunity', 55 | :accountId, account_id, 56 | :amount, '10.00', 57 | :name, 'Fakey McFakerson', 58 | :closeDate, '2008-07-04', 59 | :stageName, 'Closed Won' 60 | ] 61 | 62 | binding.create :sObject => opportunity 63 | 64 | == REQUIREMENTS: 65 | 66 | * +builder+ and +oauth+ gems 67 | * A Salesforce Enterprise or Developer account 68 | 69 | == INSTALL: 70 | 71 | * gem install rforce 72 | 73 | == LICENSE: 74 | 75 | Copyright (c) 2005-2019 Erin Dees and contributors 76 | 77 | Permission is hereby granted, free of charge, to any person obtaining 78 | a copy of this software and associated documentation files (the 79 | 'Software'), to deal in the Software without restriction, including 80 | without limitation the rights to use, copy, modify, merge, publish, 81 | distribute, sublicense, and/or sell copies of the Software, and to 82 | permit persons to whom the Software is furnished to do so, subject to 83 | the following conditions: 84 | 85 | The above copyright notice and this permission notice shall be 86 | included in all copies or substantial portions of the Software. 87 | 88 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 89 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 90 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 91 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 92 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 93 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 94 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 95 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | 8 | Dir['lib/tasks/**/*.rake'].each { |rake| load rake } 9 | -------------------------------------------------------------------------------- /examples/oauth_setup.rb: -------------------------------------------------------------------------------- 1 | require 'oauth' 2 | 3 | consumer_key = ENV['SALESFORCE_CONSUMER_KEY'] 4 | consumer_secret = ENV['SALESFORCE_CONSUMER_SECRET'] 5 | 6 | oauth_options = { 7 | :site => 'https://login.salesforce.com', 8 | :scheme => :body, 9 | :request_token_path => '/_nc_external/system/security/oauth/RequestTokenHandler', 10 | :authorize_path => '/setup/secur/RemoteAccessAuthorizationPage.apexp', 11 | :access_token_path => '/_nc_external/system/security/oauth/AccessTokenHandler', 12 | } 13 | 14 | consumer = OAuth::Consumer.new consumer_key, consumer_secret, oauth_options 15 | # consumer.http.set_debug_output STDERR # if you're curious 16 | 17 | request = consumer.get_request_token 18 | authorize_url = request.authorize_url :oauth_consumer_key => consumer_key 19 | 20 | puts "Go to #{authorize_url} in your browser, then enter the verification code:" 21 | verification_code = gets.strip 22 | 23 | access = request.get_access_token :oauth_verifier => verification_code 24 | 25 | puts "Access Token: " + access.token 26 | puts "Access Secret: " + access.secret 27 | -------------------------------------------------------------------------------- /examples/oauth_use.rb: -------------------------------------------------------------------------------- 1 | require 'rforce' 2 | 3 | oauth = { 4 | :consumer_key => ENV['SALESFORCE_CONSUMER_KEY'], 5 | :consumer_secret => ENV['SALESFORCE_CONSUMER_SECRET'], 6 | :access_token => ENV['SALESFORCE_ACCESS_TOKEN'], 7 | :access_secret => ENV['SALESFORCE_ACCESS_SECRET'], 8 | :login_url => 'https://login.salesforce.com/services/OAuth/u/20.0' 9 | } 10 | 11 | binding = RForce::Binding.new \ 12 | 'https://www.salesforce.com/services/Soap/u/20.0', 13 | nil, 14 | oauth 15 | 16 | binding.login_with_oauth 17 | 18 | answer = binding.search \ 19 | :searchString => 20 | 'find {McFakerson Co} in name fields returning account(id)' 21 | 22 | account = answer.searchResponse.result.searchRecords.record 23 | account_id = account.Id 24 | 25 | puts account_id 26 | -------------------------------------------------------------------------------- /examples/simple.rb: -------------------------------------------------------------------------------- 1 | require 'rforce' 2 | 3 | email = ENV['SALESFORCE_USER'] 4 | password = ENV['SALESFORCE_PASS'] 5 | token = ENV['SALESFORCE_TOKEN'] 6 | 7 | binding = RForce::Binding.new \ 8 | 'https://www.salesforce.com/services/Soap/u/20.0' 9 | 10 | binding.login \ 11 | email, password + token 12 | 13 | answer = binding.search \ 14 | :searchString => 15 | 'find {McFakerson Co} in name fields returning account(id)' 16 | 17 | account = answer.searchResponse.result.searchRecords.record 18 | account_id = account.Id 19 | 20 | puts account_id 21 | -------------------------------------------------------------------------------- /lib/rforce.rb: -------------------------------------------------------------------------------- 1 | =begin 2 | Copyright (c) 2005-2019 Erin Dees and contributors 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | =end 22 | 23 | # RForce is a simple Ruby binding to the Salesforce CRM system. 24 | # Rather than enforcing adherence to the sforce.com schema, 25 | # RForce assumes you are familiar with the API. Ruby method names 26 | # become SOAP method names. Nested Ruby hashes become nested 27 | # XML elements. 28 | # 29 | # Example: 30 | # 31 | # binding = RForce::Binding.new 'https://www.salesforce.com/services/Soap/u/10.0' 32 | # binding.login 'username', 'password' 33 | # answer = binding.search( 34 | # :searchString => 35 | # 'find {Some Account Name} in name fields returning account(id)') 36 | # account_id = answer.searchResponse.result.searchRecords.record.Id 37 | # 38 | # opportunity = { 39 | # :accountId => account_id, 40 | # :amount => "10.00", 41 | # :name => "New sale", 42 | # :closeDate => "2005-09-01", 43 | # :stageName => "Closed Won" 44 | # } 45 | # 46 | # binding.create 'sObject {"xsi:type" => "Opportunity"}' => opportunity 47 | # 48 | 49 | require 'rforce/binding' 50 | require 'rforce/soap_response' 51 | 52 | module RForce 53 | # Expand Ruby data structures into XML. 54 | def expand(builder, args, xmlns = nil) 55 | # Nest arrays: [:a, 1, :b, 2] => [[:a, 1], [:b, 2]] 56 | if args.is_a?(Array) 57 | args = args.each_slice(2).to_a 58 | end 59 | 60 | args.each do |key, value| 61 | attributes = xmlns ? {:xmlns => xmlns} : {} 62 | 63 | # If the XML tag requires attributes, 64 | # the tag name will contain a space 65 | # followed by a string representation 66 | # of a hash of attributes. 67 | # 68 | # e.g. 'sObject {"xsi:type" => "Opportunity"}' 69 | # becomes 24 | 29 | 30 | 31 | %s 32 | 33 | %s 34 | 35 | 36 | %s 37 | 38 | 39 | HERE 40 | 41 | QueryOptions = '%d' 42 | AssignmentRuleHeaderUsingRuleId = '%s' 43 | AssignmentRuleHeaderUsingDefaultRule = 'true' 44 | MruHeader = 'true' 45 | ClientIdHeader = '%s' 46 | 47 | # Create a binding to the server (after which you can call login 48 | # or login_with_oauth to connect to it). If you pass an oauth 49 | # hash, it must contain the keys :consumer_key, :consumer_secret, 50 | # :access_token, :access_secret, and :login_url. 51 | # 52 | # proxy may be a URL of the form http://user:pass@example.com:port 53 | # 54 | # if a logger is specified, it will be used for very verbose SOAP logging 55 | # 56 | def initialize(url, sid = nil, oauth = nil, proxy = nil, logger = nil, timeout = 5) 57 | @session_id = sid 58 | @oauth = oauth 59 | @proxy = proxy 60 | @batch_size = DEFAULT_BATCH_SIZE 61 | @logger = logger 62 | @url = URI.parse(url) 63 | @timeout = timeout 64 | end 65 | 66 | def show_debug 67 | ENV['SHOWSOAP'] == 'true' 68 | end 69 | 70 | def create_server(url) 71 | server = Net::HTTP.Proxy(@proxy).new(url.host, url.port) 72 | server.use_ssl = (url.scheme == 'https') 73 | server.verify_mode = OpenSSL::SSL::VERIFY_NONE 74 | server.open_timeout = @timeout 75 | server.read_timeout = @timeout 76 | 77 | # run ruby with -d or env variable SHOWSOAP=true to see SOAP wiredumps. 78 | server.set_debug_output $stderr if show_debug 79 | 80 | return server 81 | end 82 | 83 | # Log in to the server with a user name and password, remembering 84 | # the session ID returned to us by Salesforce. 85 | def login(user, password) 86 | @user = user 87 | @password = password 88 | @server = create_server(@url) 89 | response = call_remote(:login, [:username, user, :password, password]) 90 | 91 | raise "Incorrect user name / password [#{response.Fault}]" unless response.loginResponse 92 | 93 | result = response[:loginResponse][:result] 94 | @session_id = result[:sessionId] 95 | @url = URI.parse(result[:serverUrl]) 96 | @server = create_server(@url) 97 | 98 | return response 99 | end 100 | 101 | # Log in to the server with OAuth, remembering 102 | # the session ID returned to us by Salesforce. 103 | def login_with_oauth 104 | consumer = OAuth::Consumer.new \ 105 | @oauth[:consumer_key], 106 | @oauth[:consumer_secret] 107 | 108 | access = OAuth::AccessToken.new \ 109 | consumer, @oauth[:access_token], 110 | @oauth[:access_secret] 111 | 112 | login_url = @oauth[:login_url] 113 | 114 | result = access.post \ 115 | login_url, 116 | '', 117 | {'content-type' => 'application/x-www-form-urlencoded'} 118 | 119 | case result 120 | when Net::HTTPSuccess 121 | doc = REXML::Document.new result.body 122 | @session_id = doc.elements['*/sessionId'].text 123 | @url = URI.parse(doc.elements['*/serverUrl'].text) 124 | @server = access 125 | 126 | class << @server 127 | alias_method :post2, :post 128 | end 129 | 130 | return {:sessionId => @session_id, :serverUrl => @url.to_s} 131 | when Net::HTTPUnauthorized 132 | raise 'Invalid OAuth tokens' 133 | else 134 | raise "Unexpected error: #{response.inspect}" 135 | end 136 | end 137 | 138 | # Call a method on the remote server. Arguments can be 139 | # a hash or (if order is important) an array of alternating 140 | # keys and values. 141 | def call_remote(method, args) 142 | @server ||= create_server(@url) 143 | 144 | # Different URI requirements for regular vs. OAuth. This is 145 | # *screaming* for a refactor. 146 | fallback_soap_url = @oauth ? @url.to_s : @url.path 147 | 148 | urn, soap_url = block_given? ? yield : ["urn:partner.soap.sforce.com", fallback_soap_url] 149 | 150 | # Create XML text from the arguments. 151 | expanded = '' 152 | @builder = Builder::XmlMarkup.new(:target => expanded) 153 | expand(@builder, {method => args}, urn) 154 | 155 | extra_headers = "" 156 | 157 | # QueryOptions is not valid when making an Apex Webservice SOAP call 158 | if !block_given? 159 | extra_headers << (QueryOptions % @batch_size) 160 | end 161 | 162 | extra_headers << (AssignmentRuleHeaderUsingRuleId % assignment_rule_id) if assignment_rule_id 163 | extra_headers << AssignmentRuleHeaderUsingDefaultRule if use_default_rule 164 | extra_headers << MruHeader if update_mru 165 | extra_headers << (ClientIdHeader % client_id) if client_id 166 | 167 | if trigger_user_email or trigger_other_email or trigger_auto_response_email 168 | extra_headers << '' 169 | 170 | extra_headers << 'true' if trigger_user_email 171 | extra_headers << 'true' if trigger_other_email 172 | extra_headers << 'true' if trigger_auto_response_email 173 | 174 | extra_headers << '' 175 | end 176 | 177 | # Fill in the blanks of the SOAP envelope with our 178 | # session ID and the expanded XML of our request. 179 | request = (Envelope % [@session_id, extra_headers, expanded]) 180 | @logger && @logger.info("RForce request: #{request}") 181 | 182 | # reset the batch size for the next request 183 | @batch_size = DEFAULT_BATCH_SIZE 184 | 185 | # gzip request 186 | request = encode(request) 187 | 188 | headers = { 189 | 'Connection' => 'Keep-Alive', 190 | 'Content-Type' => 'text/xml', 191 | 'SOAPAction' => '""', 192 | 'User-Agent' => 'activesalesforce rforce/1.0' 193 | } 194 | 195 | unless show_debug 196 | headers['Accept-Encoding'] = 'gzip' 197 | headers['Content-Encoding'] = 'gzip' 198 | end 199 | 200 | # Send the request to the server and read the response. 201 | @logger && @logger.info("RForce request to host #{@server} url #{soap_url} headers: #{headers}") 202 | response = @server.post2(soap_url, request.lstrip, headers) 203 | 204 | # decode if we have encoding 205 | content = decode(response) 206 | 207 | # Fix charset encoding. Needed because the "content" variable may contain a UTF-8 208 | # or ISO-8859-1 string, but is carrying the US-ASCII encoding. 209 | content = fix_encoding(content) 210 | 211 | # Check to see if INVALID_SESSION_ID was raised and try to relogin in 212 | if method != :login and @session_id and content =~ /sf:INVALID_SESSION_ID/ 213 | if @user 214 | login(@user, @password) 215 | else 216 | raise "INVALID_SESSION_ID" 217 | end 218 | 219 | # repackage and rencode request with the new session id 220 | request = (Envelope % [@session_id, extra_headers, expanded]) 221 | request = encode(request) 222 | 223 | # Send the request to the server and read the response. 224 | response = @server.post2(soap_url, request.lstrip, headers) 225 | 226 | content = decode(response) 227 | 228 | # Fix charset encoding. Needed because the "content" variable may contain a UTF-8 229 | # or ISO-8859-1 string, but is carrying the US-ASCII encoding. 230 | content = fix_encoding(content) 231 | end 232 | 233 | @logger && @logger.info("RForce response: #{content}") 234 | SoapResponse.new(content).parse 235 | end 236 | 237 | # decode gzip 238 | def decode(response) 239 | encoding = response['Content-Encoding'] 240 | 241 | # return body if no encoding 242 | if !encoding then return response.body end 243 | 244 | # decode gzip 245 | case encoding.strip 246 | when 'gzip' then 247 | begin 248 | gzr = Zlib::GzipReader.new(StringIO.new(response.body)) 249 | decoded = gzr.read 250 | ensure 251 | gzr.close 252 | end 253 | decoded 254 | else 255 | response.body 256 | end 257 | end 258 | 259 | # encode gzip 260 | def encode(request) 261 | return request if show_debug 262 | 263 | begin 264 | ostream = StringIO.new 265 | gzw = Zlib::GzipWriter.new(ostream) 266 | gzw.write(request) 267 | ostream.string 268 | ensure 269 | gzw.close 270 | end 271 | end 272 | 273 | # fix invalid US-ASCII strings by applying the correct encoding on ruby 1.9+ 274 | def fix_encoding(string) 275 | if [:valid_encoding?, :force_encoding].all? { |m| string.respond_to?(m) } 276 | if !string.valid_encoding? 277 | # The 2 possible encodings in responses are UTF-8 and ISO-8859-1 278 | # http://www.salesforce.com/us/developer/docs/api/Content/implementation_considerations.htm#topic-title_international 279 | # 280 | ["UTF-8", "ISO-8859-1"].each do |encoding_name| 281 | 282 | s = string.dup.force_encoding(encoding_name) 283 | 284 | if s.valid_encoding? 285 | return s 286 | end 287 | end 288 | 289 | raise "Invalid encoding in SOAP response: not in [US-ASCII, UTF-8, ISO-8859-1]" 290 | end 291 | end 292 | 293 | return string 294 | end 295 | 296 | # Turns method calls on this object into remote SOAP calls. 297 | def method_missing(method, *args) 298 | unless args.empty? || (args.size == 1 && [Hash, Array].include?(args[0].class)) 299 | raise 'Expected at most 1 Hash or Array argument' 300 | end 301 | 302 | call_remote method, args[0] || [] 303 | end 304 | end 305 | end 306 | -------------------------------------------------------------------------------- /lib/rforce/method_keys.rb: -------------------------------------------------------------------------------- 1 | module RForce 2 | # Allows indexing hashes like method calls: hash.key 3 | # to supplement the traditional way of indexing: hash[key] 4 | module MethodKeys 5 | def respond_to_missing?(*) 6 | return true if respond_to?(:[]) 7 | super 8 | end 9 | 10 | def method_missing(method, *args) 11 | return self[method] if respond_to?(:[]) 12 | super 13 | end 14 | end 15 | 16 | class MethodHash < Hash 17 | include MethodKeys 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/rforce/soap_pullable.rb: -------------------------------------------------------------------------------- 1 | require 'rforce/method_keys' 2 | 3 | module RForce 4 | module SoapPullable 5 | SOAP_ENVELOPE = 'soapenv:Envelope' 6 | 7 | # Split off the local name portion of an XML tag. 8 | def local(tag) 9 | first, second = tag.split ':' 10 | return first if second.nil? 11 | @namespaces.include?(first) ? second : tag 12 | end 13 | 14 | def tag_start(name, attrs) 15 | # For shorter hash keys, we can strip any namespaces of the SOAP 16 | # envelope tag from the tags inside it. 17 | if name == SOAP_ENVELOPE 18 | @namespaces = attrs.keys.grep(/xmlns:/).map {|k| k.split(':').last} 19 | return 20 | end 21 | 22 | @stack.push MethodHash.new 23 | end 24 | 25 | def text(data) 26 | adding = data.strip.empty? ? nil : data 27 | 28 | if adding 29 | @current_value = (@current_value || '') + adding 30 | end 31 | end 32 | 33 | def tag_end(name) 34 | return if @done || name == SOAP_ENVELOPE 35 | 36 | tag_name = local name 37 | working_hash = @stack.pop 38 | 39 | # We are either done or working on a certain depth in the current 40 | # stack. 41 | if @stack.empty? 42 | @parsed = working_hash 43 | @done = true 44 | return 45 | end 46 | 47 | index = @stack.size - 1 48 | 49 | # working_hash and @current_value have a mutually exclusive relationship. 50 | # If the current element doesn't have a value then it means that there 51 | # is a nested data structure. In this case then working_hash is populated 52 | # and @current_value is nil. Conversely, if @current_value has a value 53 | # then we do not have a nested data structure and working_hash will 54 | # be empty. 55 | raise 'Parser is confused' unless working_hash.empty? || @current_value.nil? 56 | 57 | use_value = working_hash.empty? ? convert(@current_value) : working_hash 58 | tag_sym = tag_name.to_sym 59 | element = @stack[index][tag_sym] 60 | 61 | if @stack[index].keys.include? tag_sym 62 | # This is here to handle the Id value being included twice and thus 63 | # producing an array. We skip the second instance so the array is 64 | # not created. 65 | # 66 | # We also need to clear out the current value, so that the next 67 | # tag doesn't erroneously pick up the value of the Id. 68 | if tag_name == 'Id' 69 | @current_value = nil 70 | return 71 | end 72 | 73 | # We are here because the name of our current element is one that 74 | # already exists in the hash. If this is the first encounter with 75 | # the duplicate tag_name then we convert the existing value to an 76 | # array otherwise we push the value we are working with and add it 77 | # to the existing array. 78 | if element.is_a?( Array ) 79 | element << use_value 80 | else 81 | @stack[index][tag_sym] = [element, use_value] 82 | end 83 | else 84 | # We are here because the name of our current element has not been 85 | # assigned yet; anything inside 'records' should be an array 86 | @stack[index][tag_sym] = (:records == tag_sym ? [use_value] : use_value) 87 | end 88 | 89 | # We are done with the current tag so reset the data for the next one 90 | @current_value = nil 91 | end 92 | 93 | def convert(string) 94 | return nil if string.nil? 95 | s = string.strip 96 | 97 | case s 98 | when '' then nil 99 | when 'true', 'false' then ('true' == s) 100 | else s 101 | end 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /lib/rforce/soap_response.rb: -------------------------------------------------------------------------------- 1 | begin; require 'rforce/soap_response_nokogiri'; rescue LoadError; end 2 | require 'rforce/soap_response_rexml' 3 | 4 | 5 | module RForce 6 | # Use the fastest XML parser available. 7 | SoapResponse = 8 | (RForce::const_get(:SoapResponseNokogiri) rescue nil) || 9 | SoapResponseRexml 10 | end 11 | -------------------------------------------------------------------------------- /lib/rforce/soap_response_nokogiri.rb: -------------------------------------------------------------------------------- 1 | require 'nokogiri' 2 | 3 | module RForce 4 | class SoapResponseNokogiri 5 | def initialize(content) 6 | @content = content 7 | end 8 | 9 | def parse 10 | doc = Nokogiri::XML(@content) 11 | body = doc.at_xpath("//soapenv:Body") 12 | to_hash(body) 13 | end 14 | 15 | private 16 | 17 | def to_hash(node) 18 | return parse_text(text) if node.text? 19 | 20 | children = node.children.reject {|c| c.text? && c.text.strip.empty? } 21 | 22 | return nil if children.empty? 23 | 24 | if (child = children.first).text? 25 | return parse_text(child.text) 26 | end 27 | 28 | elements = MethodHash.new 29 | 30 | children.each do |elem| 31 | name = elem.name.split(":").last.to_sym 32 | 33 | if !elements[name] 34 | # anything inside 'records' should be an array 35 | elements[name] = elem.name == 'records' ? [to_hash(elem)] : to_hash(elem) 36 | elsif Array === elements[name] 37 | elements[name] << to_hash(elem) 38 | else 39 | next if elem.name == "Id" # Id fields are duplicated 40 | elements[name] = [elements[name]] << to_hash(elem) 41 | end 42 | end 43 | 44 | return elements.empty? ? nil : elements 45 | end 46 | 47 | def parse_text(text) 48 | text.strip! 49 | 50 | return nil if text.empty? 51 | 52 | boolean?(text) ? boolean(text) : text 53 | end 54 | 55 | def boolean(string) 56 | string == "true" 57 | end 58 | 59 | def boolean?(string) 60 | %w{true false}.include?(string) 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/rforce/soap_response_rexml.rb: -------------------------------------------------------------------------------- 1 | require 'rexml/document' 2 | require 'rexml/xpath' 3 | require 'rforce/soap_pullable' 4 | 5 | 6 | module RForce 7 | # Turns an XML response from the server into a Ruby 8 | # object whose methods correspond to nested XML elements. 9 | class SoapResponseRexml 10 | include SoapPullable 11 | include MethodKeys 12 | 13 | %w(attlistdecl cdata comment doctype doctype_end elementdecl 14 | entity entitydecl instruction notationdecl xmldecl).each do |unused| 15 | define_method(unused) {|*args|} 16 | end 17 | 18 | def initialize(content) 19 | @content = content 20 | end 21 | 22 | # Parses an XML string into structured data. 23 | def parse 24 | @current_value = nil 25 | @stack = [] 26 | @parsed = MethodHash.new 27 | @done = false 28 | @namespaces = [] 29 | 30 | REXML::Document.parse_stream @content, self 31 | 32 | @parsed 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/rforce/version.rb: -------------------------------------------------------------------------------- 1 | module RForce 2 | VERSION = '0.15' 3 | end 4 | -------------------------------------------------------------------------------- /lib/tasks/timing.rake: -------------------------------------------------------------------------------- 1 | require 'rforce' 2 | 3 | desc 'Perform a crude comparison of the various response parsers' 4 | task :timing do 5 | fname = File.join(File.dirname(__FILE__), '../../spec/soap-response.xml') 6 | contents = File.open(fname) {|f| f.read} 7 | 8 | [:SoapResponseRexml, 9 | :SoapResponseNokogiri].each do |name| 10 | begin 11 | klass = RForce.const_get name 12 | started_at = Time.now 13 | klass.new(contents).parse 14 | elapsed = Time.now - started_at 15 | puts "#{klass}: #{elapsed}" 16 | rescue NameError 17 | puts $! 18 | # no-op 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /rforce.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path("lib", __dir__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require "rforce/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "rforce" 7 | spec.version = RForce::VERSION 8 | spec.authors = ["Erin Dees"] 9 | spec.email = ["undees@gmail.com"] 10 | 11 | spec.summary = %q{A simple, usable binding to the Salesforce API.} 12 | spec.homepage = "https://github.com/undees/rforce" 13 | spec.license = "MIT" 14 | 15 | spec.metadata["homepage_uri"] = spec.homepage 16 | spec.metadata["source_code_uri"] = "https://github.com/undees/rforce" 17 | spec.metadata["changelog_uri"] = "https://github.com/undees/rforce/" 18 | 19 | # Specify which files should be added to the gem when it is released. 20 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 21 | spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do 22 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features|lib/tasks)/}) } 23 | end 24 | spec.require_paths = ["lib"] 25 | 26 | spec.add_runtime_dependency "builder", "~> 3.0" 27 | spec.add_runtime_dependency "oauth", "~> 0.5.5" 28 | 29 | spec.add_development_dependency "bundler", "~> 2.0" 30 | spec.add_development_dependency "rake", "~> 12.3.3" 31 | spec.add_development_dependency "rspec", "~> 3.0" 32 | 33 | # Optional XML parsing engines 34 | spec.add_development_dependency "nokogiri", "~> 1.10.8" 35 | end 36 | -------------------------------------------------------------------------------- /spec/rforce_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe MethodKeys do 4 | it 'lets you access hash keys with methods' do 5 | h = {:foo => :bar} 6 | class << h; include MethodKeys; end 7 | 8 | h.foo.should == :bar 9 | h.nonexistent.should be_nil 10 | 11 | [:foo, :nonexistent].each do |method| 12 | h.respond_to?(method).should be true 13 | end 14 | end 15 | 16 | it 'provides a Hash-like class' do 17 | mh = MethodHash.new 18 | mh[:one] = 1 19 | mh[:ten] = 10 20 | 21 | mh.one.should == 1 22 | mh.ten.should == 10 23 | mh.nothing.should be_nil 24 | 25 | [:one, :ten, :nothing].each do |method| 26 | mh.respond_to?(method).should be true 27 | end 28 | end 29 | end 30 | 31 | describe 'expand' do 32 | it 'turns Ruby into XML' do 33 | xmlns = 'urn:partner.soap.sforce.com' 34 | 35 | expanded = '' 36 | builder = Builder::XmlMarkup.new(:target => expanded) 37 | 38 | data = 39 | ['partner:create', 40 | ['partner:sObjects', 41 | ['spartner:type', 'Contact', 42 | 'AccountId', '01234567890ABCD', 43 | 'FirstName', 'Jane', 44 | 'LastName', 'Doe'], 45 | 'partner:sObjects', 46 | ['spartner:type', 'Account', 47 | 'Name', 'Acme Rockets, Inc.']]] 48 | 49 | expand(builder, data) 50 | 51 | expanded.should == CreateXml 52 | end 53 | 54 | it 'handles duplicate objects without complaint' do 55 | expanded = '' 56 | builder = Builder::XmlMarkup.new(:target => expanded) 57 | account = [:type, 'Account', :name, 'ALPHA'] 58 | args = {:create=> [:sObjects, account, :sObjects, account]} 59 | urn = 'urn:partner.soap.sforce.com' 60 | 61 | # should not raise 62 | expand builder, args, urn 63 | end 64 | end 65 | 66 | describe 'a SoapResponse implementation' do 67 | before :all do 68 | fname = File.join(File.dirname(__FILE__), 'soap-response.xml') 69 | @contents = File.open(fname) {|f| f.read} 70 | 71 | [:rexml, :nokogiri].each do |processor| 72 | name = "SoapResponse#{processor.to_s.capitalize}".to_sym 73 | variable = "@#{processor}_recs".to_sym 74 | 75 | results = begin 76 | klass = RForce.const_get name 77 | klass.new(@contents).parse.queryResponse[:result][:records] 78 | rescue NameError 79 | nil 80 | end 81 | 82 | instance_variable_set(variable, results) 83 | end 84 | end 85 | 86 | it 'turns XML into objects' do 87 | @rexml_recs.size.should == 58 88 | @rexml_recs.first.keys.size.should == 99 89 | end 90 | 91 | it 'understands XML entities' do 92 | expected = "Bee's knees" 93 | @rexml_recs.first.Description.should == expected 94 | end 95 | end 96 | 97 | SOAP_WRAPPER = <<-XML 98 | 99 | 100 | 101 | %s 102 | 103 | 104 | XML 105 | 106 | shared_examples_for 'a SOAP response' do 107 | def wrap_in_salesforce_envelope(xml) 108 | SOAP_WRAPPER % xml 109 | end 110 | 111 | it 'parses nested elements into nested hashes' do 112 | xml = wrap_in_salesforce_envelope(""" 113 | 114 | Bin 115 | """) 116 | 117 | klass.new(xml).parse.should == {:foo => {:bar => "Bin"}} 118 | end 119 | 120 | it 'parses repeated elements into arrays' do 121 | xml = wrap_in_salesforce_envelope(""" 122 | 123 | Bin 124 | Bash 125 | """) 126 | 127 | klass.new(xml).parse.should == {:foo => {:bar => ["Bin", "Bash"]}} 128 | end 129 | 130 | it 'parses records with single record as an array' do 131 | xml = wrap_in_salesforce_envelope(""" 132 | 133 | Contact 134 | """) 135 | 136 | klass.new(xml).parse.should == {:records => [{:type => "Contact"}]} 137 | end 138 | 139 | it 'parses records with multiple records as an array' do 140 | xml = wrap_in_salesforce_envelope(""" 141 | 142 | Contact 143 | 144 | 145 | Contact 146 | """) 147 | 148 | klass.new(xml).parse.should == {:records => [{:type => "Contact"}, {:type => "Contact"}]} 149 | end 150 | 151 | it 'parses Id array as single string' do 152 | xml = wrap_in_salesforce_envelope(""" 153 | 154 | some_id 155 | some_id 156 | """) 157 | 158 | klass.new(xml).parse.should == {:foo => {:Id => "some_id"}} 159 | end 160 | 161 | it 'parses booleans' do 162 | xml = wrap_in_salesforce_envelope(""" 163 | 164 | 20 165 | true 166 | false 167 | normal string 168 | """) 169 | 170 | klass.new(xml).parse.should == {:foo => {:size => "20", :done => true, :more => false, :string => "normal string"}} 171 | end 172 | 173 | it 'disregards namespacing when determining hash keys' do 174 | xml = wrap_in_salesforce_envelope(""" 175 | 176 | Bin 177 | Bash 178 | """) 179 | 180 | klass.new(xml).parse.should == {:foo => {:bar => ["Bin", "Bash"]}} 181 | end 182 | 183 | it 'unescapes any HTML contained in text nodes' do 184 | xml = wrap_in_salesforce_envelope(""" 185 | 186 | Bin 187 | <tag attr="Bee's knees & toes"> 188 | """) 189 | 190 | klass.new(xml).parse()[:foo][:bar].last.should == %q() 191 | end 192 | 193 | it 'returns an object that can be navigated via methods in addition to keys' do 194 | xml = wrap_in_salesforce_envelope("bash") 195 | klass.new(xml).parse().foo.bar.bin.should == "bash" 196 | end 197 | end 198 | 199 | if RForce.const_defined? :SoapResponseNokogiri 200 | describe 'SoapResponseNokogiri' do 201 | it_behaves_like 'a SOAP response' do 202 | let(:klass) { SoapResponseNokogiri } 203 | end 204 | end 205 | end 206 | 207 | describe 'SoapResponseRexml' do 208 | it_behaves_like 'a SOAP response' do 209 | let(:klass) { SoapResponseRexml } 210 | end 211 | end 212 | 213 | CreateXml = < 215 | 216 | Contact 217 | 01234567890ABCD 218 | Jane 219 | Doe 220 | 221 | 222 | Account 223 | Acme Rockets, Inc. 224 | 225 | 226 | HERE 227 | -------------------------------------------------------------------------------- /spec/spec.opts: -------------------------------------------------------------------------------- 1 | --colour -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | gem 'builder' 2 | require 'rforce' 3 | 4 | include RForce 5 | 6 | RSpec.configure do |config| 7 | config.expect_with(:rspec) { |c| c.syntax = :should } 8 | end 9 | --------------------------------------------------------------------------------