databasedotcom-ejholmes 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,547 @@
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 = encode_path_with_params('/services/oauth2/token', :grant_type => 'password', :client_id => self.client_id, :client_secret => self.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
@@ -0,0 +1,37 @@
1
+ module Databasedotcom
2
+ # A collection of Sobject or Record objects that holds a single page of results, and understands how to
3
+ # retrieve the next page, if any. Inherits from Array, thus, behaves as an Enumerable.
4
+
5
+ class Collection < Array
6
+ attr_reader :total_size, :next_page_url, :previous_page_url, :current_page_url, :client
7
+
8
+ # Creates a paginatable collection. You should never need to call this.
9
+ def initialize(client, total_size, next_page_url=nil, previous_page_url=nil, current_page_url=nil) #:nodoc:
10
+ @client = client
11
+ @total_size = total_size
12
+ @next_page_url = next_page_url
13
+ @previous_page_url = previous_page_url
14
+ @current_page_url = current_page_url
15
+ end
16
+
17
+ # Does this collection have a next page?
18
+ def next_page?
19
+ !!self.next_page_url
20
+ end
21
+
22
+ # Retrieve the next page of this collection. Returns the new collection, which is an empty collection if no next page exists
23
+ def next_page
24
+ self.next_page? ? @client.next_page(@next_page_url) : Databasedotcom::Collection.new(self.client, 0)
25
+ end
26
+
27
+ # Does this collection have a previous page?
28
+ def previous_page?
29
+ !!self.previous_page_url
30
+ end
31
+
32
+ # Retrieve the previous page of this collection. Returns the new collection, which is an empty collection if no previous page exists
33
+ def previous_page
34
+ self.previous_page? ? @client.previous_page(@previous_page_url) : Databasedotcom::Collection.new(self.client, 0)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,41 @@
1
+ # This extends Class to be able to use +cattr_accessor+ if active_support is not being used.
2
+ class Class
3
+ unless respond_to?(:cattr_reader)
4
+ def cattr_reader(sym)
5
+ class_eval(<<-EOS, __FILE__, __LINE__ + 1)
6
+ unless defined? @@#{sym}
7
+ @@#{sym} = nil
8
+ end
9
+
10
+ def self.#{sym}
11
+ @@#{sym}
12
+ end
13
+
14
+ def #{sym}
15
+ @@#{sym}
16
+ end
17
+ EOS
18
+ end
19
+
20
+ def cattr_writer(sym)
21
+ class_eval(<<-EOS, __FILE__, __LINE__ + 1)
22
+ unless defined? @@#{sym}
23
+ @@#{sym} = nil
24
+ end
25
+
26
+ def self.#{sym}=(obj)
27
+ @@#{sym} = obj
28
+ end
29
+
30
+ def #{sym}=(obj)
31
+ @@#{sym} = obj
32
+ end
33
+ EOS
34
+ end
35
+
36
+ def cattr_accessor(*syms, &blk)
37
+ cattr_reader(*syms)
38
+ cattr_writer(*syms, &blk)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,8 @@
1
+ class Hash
2
+ def symbolize_keys!
3
+ keys.each do |key|
4
+ self[(key.to_sym rescue key) || key] = delete(key)
5
+ end
6
+ self
7
+ end
8
+ end
@@ -0,0 +1,16 @@
1
+ # This extends String to add the +resourcerize+ method.
2
+ class String
3
+
4
+ # Dasherizes and downcases a camelcased string. Used for Feed types.
5
+ def resourcerize
6
+ self.gsub(/([a-z])([A-Z])/, '\1-\2').downcase
7
+ end
8
+
9
+ def constantize
10
+ unless /\A(?:::)?([A-Z]\w*(?:::[A-Z]\w*)*)\z/ =~ self
11
+ raise NameError, "#{self.inspect} is not a valid constant name!"
12
+ end
13
+ Object.module_eval("::#{$1}", __FILE__, __LINE__)
14
+ end
15
+
16
+ end
@@ -0,0 +1,3 @@
1
+ require 'databasedotcom/core_extensions/class_extensions'
2
+ require 'databasedotcom/core_extensions/string_extensions'
3
+ require 'databasedotcom/core_extensions/hash_extensions'
@@ -0,0 +1,26 @@
1
+ module Databasedotcom
2
+ # An exception raised when any non successful request is made to Force.com.
3
+ class SalesForceError < StandardError
4
+ # the Net::HTTPResponse from the API call
5
+ attr_accessor :response
6
+ # the +errorCode+ from the server response body
7
+ attr_accessor :error_code
8
+
9
+ def initialize(response)
10
+ self.response = response
11
+ parsed_body = JSON.parse(response.body) rescue nil
12
+ if parsed_body
13
+ if parsed_body.is_a?(Array)
14
+ message = parsed_body[0]["message"]
15
+ self.error_code = parsed_body[0]["errorCode"]
16
+ else
17
+ message = parsed_body["error_description"]
18
+ self.error_code = parsed_body["error"]
19
+ end
20
+ else
21
+ message = response.body
22
+ end
23
+ super(message)
24
+ end
25
+ end
26
+ end