1 require 'net/https'
2 require 'json'
3 require 'net/http/post/multipart'
4 require 'date'
5
6 module Databasedotcom
7 # Interface for operating the Force.com REST API
8 class Client
9 # The client id (aka "Consumer Key") to use for OAuth2 authentication
10 attr_accessor :client_id
11 # The client secret (aka "Consumer Secret" to use for OAuth2 authentication)
12 attr_accessor :client_secret
13 # The OAuth access token in use by the client
14 attr_accessor :oauth_token
15 # The OAuth refresh token in use by the client
16 attr_accessor :refresh_token
17 # The base URL to the authenticated user's SalesForce instance
18 attr_accessor :instance_url
19 # If true, print API debugging information to stdout. Defaults to false.
20 attr_accessor :debugging
21 # The host to use for OAuth2 authentication. Defaults to +login.salesforce.com+
22 attr_accessor :host
23 # The API version the client is using. Defaults to 23.0
24 attr_accessor :version
25 # A Module in which to materialize Sobject classes. Defaults to the global module (Object)
26 attr_accessor :sobject_module
27 # The SalesForce user id of the authenticated user
28 attr_reader :user_id
29 # The SalesForce username
30 attr_accessor :username
31 # The SalesForce password
32 attr_accessor :password
33 # The SalesForce organization id for the authenticated user's Salesforce instance
34 attr_reader :org_id
35 # The CA file configured for this instance, if any
36 attr_accessor :ca_file
37 # The SSL verify mode configured for this instance, if any
38 attr_accessor :verify_mode
39
40 # Returns a new client object. _options_ can be one of the following
41 #
42 # * A String containing the name of a YAML file formatted like:
43 # ---
44 # client_id: <your_salesforce_client_id>
45 # client_secret: <your_salesforce_client_secret>
46 # host: login.salesforce.com
47 # debugging: true
48 # version: 23.0
49 # sobject_module: My::Module
50 # ca_file: some/ca/file.cert
51 # verify_mode: OpenSSL::SSL::VERIFY_PEER
52 # * A Hash containing the following keys:
53 # client_id
54 # client_secret
55 # host
56 # debugging
57 # version
58 # sobject_module
59 # ca_file
60 # verify_mode
61 # If the environment variables DATABASEDOTCOM_CLIENT_ID, DATABASEDOTCOM_CLIENT_SECRET, DATABASEDOTCOM_HOST,
62 # DATABASEDOTCOM_DEBUGGING, DATABASEDOTCOM_VERSION, DATABASEDOTCOM_SOBJECT_MODULE, DATABASEDOTCOM_CA_FILE, and/or
63 # DATABASEDOTCOM_VERIFY_MODE are present, they override any other values provided
64 def initialize(options = {})
65 if options.is_a?(String)
66 @options = YAML.load_file(options)
67 @options["verify_mode"] = @options["verify_mode"].constantize if @options["verify_mode"] && @options["verify_mode"].is_a?(String)
68 else
69 @options = options
70 end
71 @options.symbolize_keys!
72
73 if ENV['DATABASE_COM_URL']
74 url = URI.parse(ENV['DATABASE_COM_URL'])
75 url_options = Hash[url.query.split("&").map{|q| q.split("=")}].symbolize_keys!
76 self.host = url.host
77 self.client_id = url_options[:oauth_key]
78 self.client_secret = url_options[:oauth_secret]
79 self.username = url_options[:user]
80 self.password = url_options[:password]
81 else
82 self.client_id = ENV['DATABASEDOTCOM_CLIENT_ID'] || @options[:client_id]
83 self.client_secret = ENV['DATABASEDOTCOM_CLIENT_SECRET'] || @options[:client_secret]
84 self.host = ENV['DATABASEDOTCOM_HOST'] || @options[:host] || "login.salesforce.com"
85 end
86
87 self.debugging = ENV['DATABASEDOTCOM_DEBUGGING'] || @options[:debugging]
88 self.version = ENV['DATABASEDOTCOM_VERSION'] || @options[:version]
89 self.version = self.version.to_s if self.version
90 self.sobject_module = ENV['DATABASEDOTCOM_SOBJECT_MODULE'] || @options[:sobject_module]
91 self.ca_file = ENV['DATABASEDOTCOM_CA_FILE'] || @options[:ca_file]
92 self.verify_mode = ENV['DATABASEDOTCOM_VERIFY_MODE'] || @options[:verify_mode]
93 self.verify_mode = self.verify_mode.to_i if self.verify_mode
94 end
95
96 # Authenticate to the Force.com API. _options_ is a Hash, interpreted as follows:
97 #
98 # * If _options_ contains the keys <tt>:username</tt> and <tt>:password</tt>, those credentials are used to authenticate. In this case, the value of <tt>:password</tt> may need to include a concatenated security token, if required by your Salesforce org
99 # * If _options_ contains the key <tt>:provider</tt>, it is assumed to be the hash returned by Omniauth from a successful web-based OAuth2 authentication
100 # * If _options_ contains the keys <tt>:token</tt> and <tt>:instance_url</tt>, those are assumed to be a valid OAuth2 token and instance URL for a Salesforce account, obtained from an external source. _options_ may also optionally contain the key <tt>:refresh_token</tt>
101 #
102 # Raises SalesForceError if an error occurs
103 def authenticate(options = nil)
104 if user_and_pass?(options)
105 req = https_request(self.host)
106 user = self.username || options[:username]
107 pass = self.password || options[:password]
108 path = "/services/oauth2/token?grant_type=password&client_id=#{self.client_id}&client_secret=#{client_secret}&username=#{user}&password=#{pass}"
109 log_request("https://#{self.host}/#{path}")
110 result = req.post(path, "")
111 log_response(result)
112 raise SalesForceError.new(result) unless result.is_a?(Net::HTTPOK)
113 self.username = user
114 self.password = pass
115 parse_auth_response(result.body)
116 elsif options.is_a?(Hash)
117 if options.has_key?("provider")
118 parse_user_id_and_org_id_from_identity_url(options["uid"])
119 self.instance_url = options["credentials"]["instance_url"]
120 self.oauth_token = options["credentials"]["token"]
121 self.refresh_token = options["credentials"]["refresh_token"]
122 else
123 raise ArgumentError unless options.has_key?(:token) && options.has_key?(:instance_url)
124 self.instance_url = options[:instance_url]
125 self.oauth_token = options[:token]
126 self.refresh_token = options[:refresh_token]
127 end
128 end
129
130 self.version = "22.0" unless self.version
131
132 self.oauth_token
133 end
134
135 # The SalesForce organization id for the authenticated user's Salesforce instance
136 def org_id
137 @org_id ||= query_org_id # lazy query org_id when not set by login response
138 end
139
140 # Returns an Array of Strings listing the class names for every type of _Sobject_ in the database. Raises SalesForceError if an error occurs.
141 def list_sobjects
142 result = http_get("/services/data/v#{self.version}/sobjects")
143 if result.is_a?(Net::HTTPOK)
144 JSON.parse(result.body)["sobjects"].collect { |sobject| sobject["name"] }
145 elsif result.is_a?(Net::HTTPBadRequest)
146 raise SalesForceError.new(result)
147 end
148 end
149
150 # Dynamically defines classes for Force.com class names. _classnames_ can be a single String or an Array of Strings. Returns the class or Array of classes defined.
151 #
152 # client.materialize("Contact") #=> Contact
153 # client.materialize(%w(Contact Company)) #=> [Contact, Company]
154 #
155 # The classes defined by materialize derive from Sobject, and have getters and setters defined for all the attributes defined by the associated Force.com Sobject.
156 def materialize(classnames)
157 classes = (classnames.is_a?(Array) ? classnames : [classnames]).collect do |clazz|
158 original_classname = clazz
159 clazz = original_classname[0,1].capitalize + original_classname[1..-1]
160 unless const_defined_in_module(module_namespace, clazz)
161 new_class = module_namespace.const_set(clazz, Class.new(Databasedotcom::Sobject::Sobject))
162 new_class.client = self
163 new_class.materialize(original_classname)
164 new_class
165 else
166 module_namespace.const_get(clazz)
167 end
168 end
169
170 classes.length == 1 ? classes.first : classes
171 end
172
173 # Returns an Array of Hashes listing the properties for every type of _Sobject_ in the database. Raises SalesForceError if an error occurs.
174 def describe_sobjects
175 result = http_get("/services/data/v#{self.version}/sobjects")
176 JSON.parse(result.body)["sobjects"]
177 end
178
179 # Returns a description of the Sobject specified by _class_name_. The description includes all fields and their properties for the Sobject.
180 def describe_sobject(class_name)
181 result = http_get("/services/data/v#{self.version}/sobjects/#{class_name}/describe")
182 JSON.parse(result.body)
183 end
184
185 # Returns an instance of the Sobject specified by _class_or_classname_ (which can be either a String or a Class) populated with the values of the Force.com record specified by _record_id_.
186 # If given a Class that is not defined, it will attempt to materialize the class on demand.
187 #
188 # client.find(Account, "recordid") #=> #<Account @Id="recordid", ...>
189 def find(class_or_classname, record_id)
190 class_or_classname = find_or_materialize(class_or_classname)
191 result = http_get("/services/data/v#{self.version}/sobjects/#{class_or_classname.sobject_name}/#{record_id}")
192 response = JSON.parse(result.body)
193 new_record = class_or_classname.new
194 class_or_classname.description["fields"].each do |field|
195 set_value(new_record, field["name"], response[key_from_label(field["label"])] || response[field["name"]], field["type"])
196 end
197 new_record
198 end
199
200 # Returns a Collection of Sobjects of the class specified in the _soql_expr_, which is a valid SOQL[http://www.salesforce.com/us/developer/docs/api/Content/sforce_api_calls_soql.htm] expression. The objects will only be populated with the values of attributes specified in the query.
201 #
202 # client.query("SELECT Name FROM Account") #=> [#<Account @Id=nil, @Name="Foo", ...>, #<Account @Id=nil, @Name="Bar", ...> ...]
203 def query(soql_expr)
204 result = http_get("/services/data/v#{self.version}/query", :q => soql_expr)
205 collection_from(result.body)
206 end
207
208 # Returns a Collection of Sobject instances form the results of the SOSL[http://www.salesforce.com/us/developer/docs/api/Content/sforce_api_calls_sosl.htm] search.
209 #
210 # client.search("FIND {bar}") #=> [#<Account @Name="foobar", ...>, #<Account @Name="barfoo", ...> ...]
211 def search(sosl_expr)
212 result = http_get("/services/data/v#{self.version}/search", :q => sosl_expr)
213 collection_from(result.body)
214 end
215
216 # Used by Collection objects. Returns a Collection of Sobjects from the specified URL path that represents the next page of paginated results.
217 def next_page(path)
218 result = http_get(path)
219 collection_from(result.body)
220 end
221
222 # Used by Collection objects. Returns a Collection of Sobjects from the specified URL path that represents the previous page of paginated results.
223 def previous_page(path)
224 result = http_get(path)
225 collection_from(result.body)
226 end
227
228 # Returns a new instance of _class_or_classname_ (which can be passed in as either a String or a Class) with the specified attributes.
229 #
230 # client.create("Car", {"Color" => "Blue", "Year" => "2011"}) #=> #<Car @Id="recordid", @Color="Blue", @Year="2011">
231 def create(class_or_classname, object_attrs)
232 class_or_classname = find_or_materialize(class_or_classname)
233 json_for_assignment = coerced_json(object_attrs, class_or_classname)
234 result = http_post("/services/data/v#{self.version}/sobjects/#{class_or_classname.sobject_name}", json_for_assignment)
235 new_object = class_or_classname.new
236 JSON.parse(json_for_assignment).each do |property, value|
237 set_value(new_object, property, value, class_or_classname.type_map[property][:type])
238 end
239 id = JSON.parse(result.body)["id"]
240 set_value(new_object, "Id", id, "id")
241 new_object
242 end
243
244 # Updates the attributes of the record of type _class_or_classname_ and specified by _record_id_ with the values of _new_attrs_ in the Force.com database. _new_attrs_ is a hash of attribute => value
245 #
246 # client.update("Car", "rid", {"Color" => "Red"})
247 def update(class_or_classname, record_id, new_attrs)
248 class_or_classname = find_or_materialize(class_or_classname)
249 json_for_update = coerced_json(new_attrs, class_or_classname)
250 http_patch("/services/data/v#{self.version}/sobjects/#{class_or_classname.sobject_name}/#{record_id}", json_for_update)
251 end
252
253 # Attempts to find the record on Force.com of type _class_or_classname_ with attribute _field_ set as _value_. If found, it will update the record with the _attrs_ hash.
254 # If not found, it will create a new record with _attrs_.
255 #
256 # client.upsert(Car, "Color", "Blue", {"Year" => "2012"})
257 def upsert(class_or_classname, field, value, attrs)
258 clazz = find_or_materialize(class_or_classname)
259 json_for_update = coerced_json(attrs, clazz)
260 http_patch("/services/data/v#{self.version}/sobjects/#{clazz.sobject_name}/#{field}/#{value}", json_for_update)
261 end
262
263 # Deletes the record of type _class_or_classname_ with id of _record_id_. _class_or_classname_ can be a String or a Class.
264 #
265 # client.delete(Car, "rid")
266 def delete(class_or_classname, record_id)
267 clazz = find_or_materialize(class_or_classname)
268 http_delete("/services/data/v#{self.version}/sobjects/#{clazz.sobject_name}/#{record_id}")
269 end
270
271 # Returns a Collection of recently touched items. The Collection contains Sobject instances that are fully populated with their correct values.
272 def recent
273 result = http_get("/services/data/v#{self.version}/recent")
274 collection_from(result.body)
275 end
276
277 # Returns an array of trending topic names.
278 def trending_topics
279 result = http_get("/services/data/v#{self.version}/chatter/topics/trending")
280 result = JSON.parse(result.body)
281 result["topics"].collect { |topic| topic["name"] }
282 end
283
284 # Performs an HTTP GET request to the specified path (relative to self.instance_url). Query parameters are included from _parameters_. The required
285 # +Authorization+ header is automatically included, as are any additional headers specified in _headers_. Returns the HTTPResult if it is of type
286 # HTTPSuccess- raises SalesForceError otherwise.
287 def http_get(path, parameters={}, headers={})
288 with_encoded_path_and_checked_response(path, parameters) do |encoded_path|
289 https_request.get(encoded_path, {"Authorization" => "OAuth #{self.oauth_token}"}.merge(headers))
290 end
291 end
292
293
294 # Performs an HTTP DELETE request to the specified path (relative to self.instance_url). Query parameters are included from _parameters_. The required
295 # +Authorization+ header is automatically included, as are any additional headers specified in _headers_. Returns the HTTPResult if it is of type
296 # HTTPSuccess- raises SalesForceError otherwise.
297 def http_delete(path, parameters={}, headers={})
298 with_encoded_path_and_checked_response(path, parameters, {:expected_result_class => Net::HTTPNoContent}) do |encoded_path|
299 https_request.delete(encoded_path, {"Authorization" => "OAuth #{self.oauth_token}"}.merge(headers))
300 end
301 end
302
303 # Performs an HTTP POST request to the specified path (relative to self.instance_url). The body of the request is taken from _data_.
304 # Query parameters are included from _parameters_. The required +Authorization+ header is automatically included, as are any additional
305 # headers specified in _headers_. Returns the HTTPResult if it is of type HTTPSuccess- raises SalesForceError otherwise.
306 def http_post(path, data=nil, parameters={}, headers={})
307 with_encoded_path_and_checked_response(path, parameters, {:data => data}) do |encoded_path|
308 https_request.post(encoded_path, data, {"Content-Type" => data ? "application/json" : "text/plain", "Authorization" => "OAuth #{self.oauth_token}"}.merge(headers))
309 end
310 end
311
312 # Performs an HTTP PATCH request to the specified path (relative to self.instance_url). The body of the request is taken from _data_.
313 # Query parameters are included from _parameters_. The required +Authorization+ header is automatically included, as are any additional
314 # headers specified in _headers_. Returns the HTTPResult if it is of type HTTPSuccess- raises SalesForceError otherwise.
315 def http_patch(path, data=nil, parameters={}, headers={})
316 with_encoded_path_and_checked_response(path, parameters, {:data => data}) do |encoded_path|
317 https_request.send_request("PATCH", encoded_path, data, {"Content-Type" => data ? "application/json" : "text/plain", "Authorization" => "OAuth #{self.oauth_token}"}.merge(headers))
318 end
319 end
320
321 # Performs an HTTP POST request to the specified path (relative to self.instance_url), using Content-Type multiplart/form-data.
322 # The parts of the body of the request are taken from parts_. Query parameters are included from _parameters_. The required
323 # +Authorization+ header is automatically included, as are any additional headers specified in _headers_.
324 # Returns the HTTPResult if it is of type HTTPSuccess- raises SalesForceError otherwise.
325 def http_multipart_post(path, parts, parameters={}, headers={})
326 with_encoded_path_and_checked_response(path, parameters) do |encoded_path|
327 https_request.request(Net::HTTP::Post::Multipart.new(encoded_path, parts, {"Authorization" => "OAuth #{self.oauth_token}"}.merge(headers)))
328 end
329 end
330
331 private
332
333 def with_encoded_path_and_checked_response(path, parameters, options = {})
334 ensure_expected_response(options[:expected_result_class]) do
335 with_logging(encode_path_with_params(path, parameters), options) do |encoded_path|
336 yield(encoded_path)
337 end
338 end
339 end
340
341 def with_logging(encoded_path, options)
342 log_request(encoded_path, options)
343 response = yield encoded_path
344 log_response(response)
345 response
346 end
347
348 def ensure_expected_response(expected_result_class)
349 response = yield
350
351 unless response.is_a?(expected_result_class || Net::HTTPSuccess)
352 if response.is_a?(Net::HTTPUnauthorized)
353 if self.refresh_token
354 response = with_encoded_path_and_checked_response("/services/oauth2/token", { :grant_type => "refresh_token", :refresh_token => self.refresh_token, :client_id => self.client_id, :client_secret => self.client_secret}, :host => self.host) do |encoded_path|
355 response = https_request(self.host).post(encoded_path, nil)
356 if response.is_a?(Net::HTTPOK)
357 parse_auth_response(response.body)
358 end
359 response
360 end
361 elsif self.username && self.password
362 response = with_encoded_path_and_checked_response("/services/oauth2/token", { :grant_type => "password", :username => self.username, :password => self.password, :client_id => self.client_id, :client_secret => self.client_secret}, :host => self.host) do |encoded_path|
363 response = https_request(self.host).post(encoded_path, nil)
364 if response.is_a?(Net::HTTPOK)
365 parse_auth_response(response.body)
366 end
367 response
368 end
369 end
370
371 if response.is_a?(Net::HTTPSuccess)
372 response = yield
373 end
374 end
375
376 raise SalesForceError.new(response) unless response.is_a?(expected_result_class || Net::HTTPSuccess)
377 end
378
379 response
380 end
381
382 def https_request(host=nil)
383 Net::HTTP.new(host || URI.parse(self.instance_url).host, 443).tap do |http|
384 http.use_ssl = true
385 http.ca_file = self.ca_file if self.ca_file
386 http.verify_mode = self.verify_mode if self.verify_mode
387 end
388 end
389
390 def encode_path_with_params(path, parameters={})
391 [URI.escape(path), encode_parameters(parameters)].reject{|el| el.empty?}.join('?')
392 end
393
394 def encode_parameters(parameters={})
395 (parameters || {}).collect { |k, v| "#{uri_escape(k)}=#{uri_escape(v)}" }.join('&')
396 end
397
398 def log_request(path, options={})
399 base_url = options[:host] ? "https://#{options[:host]}" : self.instance_url
400 puts "***** REQUEST: #{path.include?(':') ? path : URI.join(base_url, path)}#{options[:data] ? " => #{options[:data]}" : ''}" if self.debugging
401 end
402
403 def uri_escape(str)
404 URI.escape(str.to_s, Regexp.new("[^#{URI::PATTERN::UNRESERVED}]"))
405 end
406
407 def log_response(result)
408 puts "***** RESPONSE: #{result.class.name} -> #{result.body}" if self.debugging
409 end
410
411 def find_or_materialize(class_or_classname)
412 if class_or_classname.is_a?(Class)
413 clazz = class_or_classname
414 else
415 match = class_or_classname.match(/(?:(.+)::)?(\w+)$/)
416 preceding_namespace = match[1]
417 classname = match[2]
418 raise ArgumentError if preceding_namespace && preceding_namespace != module_namespace.name
419 clazz = module_namespace.const_get(classname.to_sym) rescue nil
420 clazz ||= self.materialize(classname)
421 end
422 clazz
423 end
424
425 def module_namespace
426 _module = self.sobject_module
427 _module = _module.constantize if _module.is_a? String
428 _module || Object
429 end
430
431 def collection_from(response)
432 response = JSON.parse(response)
433 collection_from_hash( response )
434 end
435
436 # Converts a Hash of object data into a concrete SObject
437 def record_from_hash(data)
438 attributes = data.delete('attributes')
439 new_record = find_or_materialize(attributes["type"]).new
440 data.each do |name, value|
441 field = new_record.description['fields'].find do |field|
442 key_from_label(field["label"]) == name || field["name"] == name || field["relationshipName"] == name
443 end
444
445 # Field not found
446 if field == nil
447 break
448 end
449
450 # If reference/lookup field data was fetched, recursively build the child record and apply
451 if value.is_a?(Hash) and field['type'] == 'reference' and field["relationshipName"]
452 relation = record_from_hash( value )
453 set_value( new_record, field["relationshipName"], relation, 'reference' )
454
455 # Apply the raw value for all other field types
456 else
457 set_value(new_record, field["name"], value, field["type"]) if field
458 end
459 end
460 new_record
461 end
462
463 def collection_from_hash(data)
464 array_response = data.is_a?(Array)
465 if array_response
466 records = data.collect { |rec| self.find(rec["attributes"]["type"], rec["Id"]) }
467 else
468 records = data["records"].collect do |record|
469 record_from_hash( record )
470 end
471 end
472
473 Databasedotcom::Collection.new(self, array_response ? records.length : data["totalSize"], array_response ? nil : data["nextRecordsUrl"]).concat(records)
474 end
475
476 def set_value(record, attr, value, attr_type)
477 value_to_set = value
478
479 case attr_type
480 when "datetime"
481 value_to_set = DateTime.parse(value) rescue nil
482
483 when "date"
484 value_to_set = Date.parse(value) rescue nil
485
486 when "multipicklist"
487 value_to_set = value.split(";") rescue []
488 end
489
490 record.send("#{attr}=", value_to_set)
491 end
492
493 def coerced_json(attrs, clazz)
494 if attrs.is_a?(Hash)
495 coerced_attrs = {}
496 attrs.keys.each do |key|
497 case clazz.field_type(key)
498 when "multipicklist"
499 coerced_attrs[key] = (attrs[key] || []).join(';')
500 when "datetime"
501 coerced_attrs[key] = attrs[key] ? attrs[key].strftime(RUBY_VERSION.match(/^1.8/) ? "%Y-%m-%dT%H:%M:%S.000%z" : "%Y-%m-%dT%H:%M:%S.%L%z") : nil
502 when "date"
503 if attrs[key]
504 coerced_attrs[key] = attrs[key].respond_to?(:strftime) ? attrs[key].strftime("%Y-%m-%d") : attrs[key]
505 else
506 coerced_attrs[key] = nil
507 end
508 else
509 coerced_attrs[key] = attrs[key]
510 end
511 end
512 coerced_attrs.to_json
513 else
514 attrs
515 end
516 end
517
518 def key_from_label(label)
519 label.gsub(' ', '_')
520 end
521
522 def user_and_pass?(options)
523 (self.username && self.password) || (options && options[:username] && options[:password])
524 end
525
526 def parse_user_id_and_org_id_from_identity_url(identity_url)
527 m = identity_url.match(/\/id\/([^\/]+)\/([^\/]+)$/)
528 @org_id = m[1] rescue nil
529 @user_id = m[2] rescue nil
530 end
531
532 def parse_auth_response(body)
533 json = JSON.parse(body)
534 parse_user_id_and_org_id_from_identity_url(json["id"])
535 self.instance_url = json["instance_url"]
536 self.oauth_token = json["access_token"]
537 end
538
539 def query_org_id
540 query("select id from Organization")[0]["Id"]
541 end
542
543 def const_defined_in_module(mod, const)
544 mod.method(:const_defined?).arity == 1 ? mod.const_defined?(const) : mod.const_defined?(const, false)
545 end
546 end
547 end