├── .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 |
--------------------------------------------------------------------------------