databasedotcom 1.0.1

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,9 @@
1
+ require 'databasedotcom/chatter/record'
2
+
3
+ module Databasedotcom
4
+ module Chatter
5
+ # A GroupMembership represents the membership of a certain User in a certain Group.
6
+ class GroupMembership < Record
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ require 'databasedotcom/chatter/record'
2
+
3
+ module Databasedotcom
4
+ module Chatter
5
+ # A like on a FeedItem
6
+ class Like < Record
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,29 @@
1
+ require 'databasedotcom/chatter/record'
2
+
3
+ module Databasedotcom
4
+ module Chatter
5
+ # A private message between two or more Users
6
+ class Message < Record
7
+
8
+ # Send a private message with the content _text_ to each user in the _recipients_ list.
9
+ def self.send_message(client, recipients, text)
10
+ url = "/services/data/v#{client.version}/chatter/users/me/messages"
11
+ recipients = recipients.is_a?(Array) ? recipients : [recipients]
12
+ response = client.http_post(url, nil, :text => text, :recipients => recipients.join(','))
13
+ Message.new(client, response.body)
14
+ end
15
+
16
+ # Send a reply to the message identified by _in_reply_to_message_id_ with content _text_.
17
+ def self.reply(client, in_reply_to_message_id, text)
18
+ url = "/services/data/v#{client.version}/chatter/users/me/messages"
19
+ response = client.http_post(url, nil, :text => text, :inReplyTo => in_reply_to_message_id)
20
+ Message.new(client, response.body)
21
+ end
22
+
23
+ # Send a reply to this Message with content _text_.
24
+ def reply(text)
25
+ self.class.reply(self.client, self.id, text)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,55 @@
1
+ module Databasedotcom
2
+ module Chatter
3
+ # Defines methods for entities that can have photos i.e. Users, Groups.
4
+ module PhotoMethods
5
+ def self.included(base)
6
+ base.extend ClassMethods
7
+ end
8
+
9
+ # Defines class methods for resources that can have photos.
10
+ module ClassMethods
11
+ # Returns a Hash with urls for the small and large versions of the photo for a resource.
12
+ def photo(client, resource_id)
13
+ url = "/services/data/v#{client.version}/chatter/#{self.resource_name}/#{resource_id}/photo"
14
+ result = client.http_get(url)
15
+ JSON.parse(result.body)
16
+ end
17
+
18
+ # Uploads a photo for a resource with id _resource_id_.
19
+ #
20
+ # User.upload_photo(@client, "me", File.open("SomePicture.png"), "image/png")
21
+ def upload_photo(client, resource_id, io, file_type)
22
+ url = "/services/data/v#{client.version}/chatter/#{self.resource_name}/#{resource_id}/photo"
23
+ result = client.http_multipart_post(url, {"fileUpload" => UploadIO.new(io, file_type)})
24
+ JSON.parse(result.body)
25
+ end
26
+
27
+ # Deletes the photo for the resource with id _resource_id_.
28
+ def delete_photo(client, resource_id)
29
+ client.http_delete "/services/data/v#{client.version}/chatter/#{self.resource_name}/#{resource_id}/photo"
30
+ end
31
+ end
32
+
33
+ # Returns a Hash with urls for the small and large versions of the photo for this resource.
34
+ #
35
+ # User.find(@client, "me").photo #=> {"smallPhotoUrl"=>"/small/photo/url", "largePhotoUrl"=>"/large/photo/url"}
36
+ def photo
37
+ self.raw_hash["photo"]
38
+ end
39
+
40
+ # Uploads a photo for this resource.
41
+ #
42
+ # me = User.find(@client)
43
+ # me.upload_photo(File.open("SomePicture.png"), "image/png")
44
+ def upload_photo(io, file_type)
45
+ self.class.upload_photo(self.client, self.id, io, file_type)
46
+ end
47
+
48
+ # Deletes the photo for this resource.
49
+ def delete_photo
50
+ self.class.delete_photo(self.client, self.id)
51
+ photo
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,122 @@
1
+ require 'json'
2
+
3
+ module Databasedotcom
4
+ module Chatter
5
+ # Superclasses all Chatter resources except feeds. Some methods may not be supported by the Force.com API for certain subclasses.
6
+ class Record
7
+ attr_reader :raw_hash, :name, :id, :url, :type, :client
8
+
9
+ # Create a new record from the returned JSON response of an API request. Sets the client, name, id, url, and type attributes. Saves the raw response as +raw_hash+.
10
+ def initialize(client, response)
11
+ @client = client
12
+ @raw_hash = response.is_a?(Hash) ? response : JSON.parse(response)
13
+ @name = @raw_hash["name"]
14
+ @id = @raw_hash["id"]
15
+ @url = @raw_hash["url"]
16
+ @type = @raw_hash["type"]
17
+ end
18
+
19
+ # Find a single Record or a Collection of records by id. _resource_id_ can be a single id or a list of ids.
20
+ def self.find(client, resource_id, parameters={})
21
+ if resource_id.is_a?(Array)
22
+ resource_ids = resource_id.join(',')
23
+ url = "/services/data/v#{client.version}/chatter/#{self.resource_name}/batch/#{resource_ids}"
24
+ response = JSON.parse(client.http_get(url, parameters).body)
25
+ good_results = response["results"].select { |r| r["statusCode"] == 200 }
26
+ collection = Databasedotcom::Collection.new(client, good_results.length)
27
+ good_results.each do |result|
28
+ collection << self.new(client, result["result"])
29
+ end
30
+ collection
31
+ else
32
+ path_components = ["/services/data/v#{client.version}/chatter"]
33
+ if parameters.has_key?(:user_id)
34
+ path_components << "users/#{parameters[:user_id]}"
35
+ parameters.delete(:user_id)
36
+ end
37
+ path_components << "#{self.resource_name}/#{resource_id}"
38
+ url = path_components.join('/')
39
+ response = JSON.parse(client.http_get(url, parameters).body)
40
+ self.new(client, response)
41
+ end
42
+ end
43
+
44
+ # Return a Collection of records that match the _query_.
45
+ def self.search(client, query, parameters={})
46
+ self.all(client, parameters.merge(self.search_parameter_name => query))
47
+ end
48
+
49
+ # Return a Collection of all records.
50
+ def self.all(client, parameters={})
51
+ path_components = ["/services/data/v#{client.version}/chatter"]
52
+ if parameters.has_key?(:user_id)
53
+ path_components << "users/#{parameters[:user_id]}"
54
+ parameters.delete(:user_id)
55
+ end
56
+ path_components << self.resource_name
57
+ url = path_components.join('/')
58
+ result = client.http_get(url, parameters)
59
+ response = JSON.parse(result.body)
60
+ collection = Databasedotcom::Collection.new(client, self.total_size_of_collection(response), response["nextPageUrl"], response["previousPageUrl"], response["currentPageUrl"])
61
+ self.collection_from_response(response).each do |resource|
62
+ collection << self.new(client, resource)
63
+ end
64
+ collection
65
+ end
66
+
67
+ # Delete the Record identified by _resource_id_.
68
+ def self.delete(client, resource_id, parameters={})
69
+ path_components = ["/services/data/v#{client.version}/chatter"]
70
+ if parameters.has_key?(:user_id)
71
+ path_components << "users/#{parameters[:user_id]}"
72
+ parameters.delete(:user_id)
73
+ end
74
+ path_components << self.resource_name
75
+ path_components << resource_id
76
+ path = path_components.join('/')
77
+ client.http_delete(path, parameters)
78
+ end
79
+
80
+ # A Hash representation of the User that created this Record.
81
+ def user
82
+ self.raw_hash["user"]
83
+ end
84
+
85
+ # A Hash representation of the entity that is the parent of this Record.
86
+ def parent
87
+ self.raw_hash["parent"]
88
+ end
89
+
90
+ # Delete this record.
91
+ def delete(parameters={})
92
+ self.class.delete(self.client, self.id, parameters)
93
+ end
94
+
95
+ # Reload this record.
96
+ def reload
97
+ self.class.find(self.client, self.id)
98
+ end
99
+
100
+ # The REST resource name of this Record.
101
+ #
102
+ # GroupMembership.resource_name #=> group-memberships
103
+ def self.resource_name
104
+ (self.name.split('::').last).resourcerize + "s"
105
+ end
106
+
107
+ protected
108
+
109
+ def self.total_size_of_collection(response)
110
+ response["total"] || response["totalMemberCount"]
111
+ end
112
+
113
+ def self.collection_from_response(response)
114
+ response[self.resource_name]
115
+ end
116
+
117
+ def self.search_parameter_name
118
+ :q
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,9 @@
1
+ require 'databasedotcom/chatter/record'
2
+
3
+ module Databasedotcom
4
+ module Chatter
5
+ # A representation of a user "following" some other entity.
6
+ class Subscription < Record
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,153 @@
1
+ require 'databasedotcom/chatter/record'
2
+ require 'databasedotcom/chatter/photo_methods'
3
+
4
+ module Databasedotcom
5
+ module Chatter
6
+ # Defines a User in your org.
7
+ class User < Record
8
+ include PhotoMethods
9
+
10
+ # Returns a Collection of Subscription objects that represents all followers of the User identified by _subject_id_.
11
+ def self.followers(client, subject_id="me")
12
+ url = "/services/data/v#{client.version}/chatter/users/#{subject_id}/followers"
13
+ result = client.http_get(url)
14
+ response = JSON.parse(result.body)
15
+ collection = Databasedotcom::Collection.new(client, response["total"], response["nextPageUrl"], response["previousPageUrl"], response["currentPageUrl"])
16
+ response["followers"].each do |subscription|
17
+ collection << Subscription.new(client, subscription)
18
+ end
19
+ collection
20
+ end
21
+
22
+ # Returns a Collection of Subscription objects that represent all entities that the User identified by _subject_id_ is following.
23
+ def self.following(client, subject_id="me")
24
+ url = "/services/data/v#{client.version}/chatter/users/#{subject_id}/following"
25
+ result = client.http_get(url)
26
+ response = JSON.parse(result.body)
27
+ collection = Databasedotcom::Collection.new(client, response["total"], response["nextPageUrl"], response["previousPageUrl"], response["currentPageUrl"])
28
+ response["following"].each do |subscription|
29
+ collection << Subscription.new(client, subscription)
30
+ end
31
+ collection
32
+ end
33
+
34
+ # Returns a Collection of Group objects that represent all the groups that the User identified by _subject_id_ is a part of.
35
+ def self.groups(client, subject_id="me")
36
+ url = "/services/data/v#{client.version}/chatter/users/#{subject_id}/groups"
37
+ result = client.http_get(url)
38
+ response = JSON.parse(result.body)
39
+ collection = Databasedotcom::Collection.new(client, response["total"], response["nextPageUrl"], response["previousPageUrl"], response["currentPageUrl"])
40
+ response["groups"].each do |group|
41
+ collection << Group.new(client, group)
42
+ end
43
+ collection
44
+ end
45
+
46
+ # Returns the current status of the User identified by _subject_id_.
47
+ def self.status(client, subject_id="me")
48
+ url = "/services/data/v#{client.version}/chatter/users/#{subject_id}/status"
49
+ result = client.http_get(url)
50
+ JSON.parse(result.body)
51
+ end
52
+
53
+ # Posts a status update as the User identified by _subject_id_ with content _text_.
54
+ def self.post_status(client, subject_id, text)
55
+ url = "/services/data/v#{client.version}/chatter/users/#{subject_id}/status"
56
+ result = client.http_post(url, nil, :text => text)
57
+ JSON.parse(result.body)
58
+ end
59
+
60
+ # Deletes the status of User identified by _subject_id_.
61
+ def self.delete_status(client, subject_id="me")
62
+ client.http_delete "/services/data/v#{client.version}/chatter/users/#{subject_id}/status"
63
+ end
64
+
65
+ # Creates and returns a new Subscription object that represents the User identified by _subject_id_ following the resource identified by _resource_id_.
66
+ def self.follow(client, subject_id, resource_id)
67
+ response = client.http_post("/services/data/v#{client.version}/chatter/users/#{subject_id}/following", nil, :subjectId => resource_id)
68
+ Subscription.new(client, response.body)
69
+ end
70
+
71
+ # Returns a Collection of conversations that belong to the User identified by _subject_id_.
72
+ def self.conversations(client, subject_id)
73
+ Conversation.all(client, :user_id => subject_id)
74
+ end
75
+
76
+ # Returns a Collection of private messages that belong to the User identified by _subject_id_.
77
+ def self.messages(client, subject_id)
78
+ Message.all(client, :user_id => subject_id)
79
+ end
80
+
81
+ # Get a Collection of Subscription objects for this User. Always makes a call to the server.
82
+ def followers!
83
+ self.class.followers(self.client, self.id)
84
+ end
85
+
86
+ # Get a Collection of Subscription objects for this User. Returns cached data if it has been called before.
87
+ def followers
88
+ @followers ||= followers!
89
+ end
90
+
91
+ # Get a Collection of Subscription objects that represents all resources that this User is following. Always makes a call to the server.
92
+ def following!
93
+ self.class.following(self.client, self.id)
94
+ end
95
+
96
+ # Get a Collection of Subscription objects that represents all resources that this User is following. Returns cached data if it has been called before.
97
+ def following
98
+ @following ||= following!
99
+ end
100
+
101
+ # Returns this current status of this User.
102
+ def status
103
+ self.raw_hash["currentStatus"]
104
+ end
105
+
106
+ # Posts a new status with content _text_ for this User.
107
+ def post_status(text)
108
+ self.class.post_status(self.client, self.id, text)
109
+ end
110
+
111
+ # Deletes the current status of this User. Returns the deleted status.
112
+ def delete_status
113
+ self.class.delete_status(self.client, self.id)
114
+ status
115
+ end
116
+
117
+ # Get a Collection of Group objects that represents all groups that this User is in. Always makes a call to the server.
118
+ def groups!
119
+ self.class.groups(self.client, self.id)
120
+ end
121
+
122
+ # Get a Collection of Group objects that represents all groups that this User is in. Returns cached data if it has been called before.
123
+ def groups
124
+ @groups ||= groups!
125
+ end
126
+
127
+ # Creates a new Subscription that represents this User following the resource with id _record_id_.
128
+ def follow(record_id)
129
+ self.class.follow(self.client, self.id, record_id)
130
+ end
131
+
132
+ # Get a Collection of Conversation objects that represents the conversations for this User. Always makes a call to the server.
133
+ def conversations!
134
+ self.class.conversations(self.client, self.id)
135
+ end
136
+
137
+ # Get a Collection of Conversation objects that represents the conversations for this User. Returns cached data if it has been called before.
138
+ def conversations
139
+ @conversations ||= conversations!
140
+ end
141
+
142
+ # Get a Collection of Message objects that represents the messages for this User. Always makes a call to the server.
143
+ def messages!
144
+ self.class.messages(self.client, self.id)
145
+ end
146
+
147
+ # Get a Collection of Message objects that represents the messages for this User. Returns cached data if it has been called before.
148
+ def messages
149
+ @messages ||= messages!
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,421 @@
1
+ require 'net/https'
2
+ require 'json'
3
+ require 'net/http/post/multipart'
4
+
5
+ module Databasedotcom
6
+ # Interface for operating the Force.com REST API
7
+ class Client
8
+ # The client id (aka "Consumer Key") to use for OAuth2 authentication
9
+ attr_accessor :client_id
10
+ # The client secret (aka "Consumer Secret" to use for OAuth2 authentication)
11
+ attr_accessor :client_secret
12
+ # The OAuth access token in use by the client
13
+ attr_accessor :oauth_token
14
+ # The base URL to the authenticated user's SalesForce instance
15
+ attr_accessor :instance_url
16
+ # If true, print API debugging information to stdout. Defaults to false.
17
+ attr_accessor :debugging
18
+ # The host to use for OAuth2 authentication. Defaults to +login.salesforce.com+
19
+ attr_accessor :host
20
+ # The API version the client is using. Defaults to 23.0
21
+ attr_accessor :version
22
+ # A Module in which to materialize Sobject classes. Defaults to the global module (Object)
23
+ attr_accessor :sobject_module
24
+ # The SalesForce user id of the authenticated user
25
+ attr_reader :user_id
26
+ # The SalesForce username
27
+ attr_accessor :username
28
+ # The SalesForce password
29
+ attr_accessor :password
30
+
31
+ # Returns a new client object. _options_ can be one of the following
32
+ #
33
+ # * A String containing the name of a YAML file formatted like:
34
+ # ---
35
+ # client_id: <your_salesforce_client_id>
36
+ # client_secret: <your_salesforce_client_secret>
37
+ # host: login.salesforce.com
38
+ # debugging: true
39
+ # version: 23.0
40
+ # sobject_module: My::Module
41
+ # * A Hash containing the following keys:
42
+ # client_id
43
+ # client_secret
44
+ # host
45
+ # debugging
46
+ # version
47
+ # sobject_module
48
+ # If the environment variables DATABASEDOTCOM_CLIENT_ID, DATABASEDOTCOM_CLIENT_SECRET, DATABASEDOTCOM_HOST,
49
+ # DATABASEDOTCOM_DEBUGGING, DATABASEDOTCOM_VERSION, and/or DATABASEDOTCOM_SOBJECT_MODULE are present, they
50
+ # override any other values provided
51
+ def initialize(options = {})
52
+ if options.is_a?(String)
53
+ @options = YAML.load_file(options)
54
+ else
55
+ @options = options
56
+ end
57
+ @options.symbolize_keys!
58
+
59
+ if ENV['DATABASE_COM_URL']
60
+ url = URI.parse(ENV['DATABASE_COM_URL'])
61
+ url_options = Hash[url.query.split("&").map{|q| q.split("=")}].symbolize_keys!
62
+ self.host = url.host
63
+ self.client_id = url_options[:oauth_key]
64
+ self.client_secret = url_options[:oauth_secret]
65
+ self.username = url_options[:user]
66
+ self.password = url_options[:password]
67
+ self.sobject_module = "Databasedotcom::Sobject"
68
+ else
69
+ self.client_id = ENV['DATABASEDOTCOM_CLIENT_ID'] || @options[:client_id]
70
+ self.client_secret = ENV['DATABASEDOTCOM_CLIENT_SECRET'] || @options[:client_secret]
71
+ self.host = ENV['DATABASEDOTCOM_HOST'] || @options[:host] || "login.salesforce.com"
72
+ self.debugging = ENV['DATABASEDOTCOM_DEBUGGING'] || @options[:debugging]
73
+ self.version = ENV['DATABASEDOTCOM_VERSION'] || @options[:version]
74
+ self.version = self.version.to_s if self.version
75
+ self.sobject_module = ENV['DATABASEDOTCOM_SOBJECT_MODULE'] || (@options && @options[:sobject_module])
76
+ end
77
+ end
78
+
79
+ # Authenticate to the Force.com API. _options_ is a Hash, interpreted as follows:
80
+ #
81
+ # * 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
82
+ # * 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
83
+ # * 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
84
+ #
85
+ # Raises SalesForceError if an error occurs
86
+ def authenticate(options = nil)
87
+ if (options[:username] && options[:password])
88
+ req = Net::HTTP.new(self.host, 443)
89
+ req.use_ssl=true
90
+ path = "/services/oauth2/token?grant_type=password&client_id=#{self.client_id}&client_secret=#{client_secret}&username=#{options[:username]}&password=#{options[:password]}"
91
+ log_request("https://#{self.host}/#{path}")
92
+ result = req.post(path, "")
93
+ log_response(result)
94
+ raise SalesForceError.new(result) unless result.is_a?(Net::HTTPOK)
95
+ json = JSON.parse(result.body)
96
+ @user_id = json["id"].match(/\/([^\/]+)$/)[1] rescue nil
97
+ self.instance_url = json["instance_url"]
98
+ self.oauth_token = json["access_token"]
99
+ elsif options.is_a?(Hash)
100
+ if options.has_key?("provider")
101
+ @user_id = options["extra"]["user_hash"]["user_id"] rescue nil
102
+ self.instance_url = options["credentials"]["instance_url"]
103
+ self.oauth_token = options["credentials"]["token"]
104
+ else
105
+ raise ArgumentError unless options.has_key?(:token) && options.has_key?(:instance_url)
106
+ self.instance_url = options[:instance_url]
107
+ self.oauth_token = options[:token]
108
+ end
109
+ end
110
+
111
+ self.version = "23.0" unless self.version
112
+
113
+ self.oauth_token
114
+ end
115
+
116
+ # Returns an Array of Strings listing the class names for every type of _Sobject_ in the database. Raises SalesForceError if an error occurs.
117
+ def list_sobjects
118
+ result = http_get("/services/data/v#{self.version}/sobjects")
119
+ if result.is_a?(Net::HTTPOK)
120
+ JSON.parse(result.body)["sobjects"].collect { |sobject| sobject["name"] }
121
+ elsif result.is_a?(Net::HTTPBadRequest)
122
+ raise SalesForceError.new(result)
123
+ end
124
+ end
125
+
126
+ # 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.
127
+ #
128
+ # client.materialize("Contact") #=> Contact
129
+ # client.materialize(%w(Contact Company)) #=> [Contact, Company]
130
+ #
131
+ # 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.
132
+ def materialize(classnames)
133
+ classes = (classnames.is_a?(Array) ? classnames : [classnames]).collect do |clazz|
134
+ original_classname = clazz
135
+ clazz = original_classname[0].capitalize + original_classname[1..-1]
136
+ unless module_namespace.const_defined?(clazz)
137
+ new_class = module_namespace.const_set(clazz, Class.new(Databasedotcom::Sobject::Sobject))
138
+ new_class.client = self
139
+ new_class.materialize(original_classname)
140
+ new_class
141
+ else
142
+ module_namespace.const_get(clazz)
143
+ end
144
+ end
145
+
146
+ classes.length == 1 ? classes.first : classes
147
+ end
148
+
149
+ # Returns a description of the Sobject specified by _class_name_. The description includes all fields and their properties for the Sobject.
150
+ def describe_sobject(class_name)
151
+ result = http_get("/services/data/v#{self.version}/sobjects/#{class_name}/describe")
152
+ JSON.parse(result.body)
153
+ end
154
+
155
+ # 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_.
156
+ # If given a Class that is not defined, it will attempt to materialize the class on demand.
157
+ #
158
+ # client.find(Account, "recordid") #=> #<Account @Id="recordid", ...>
159
+ def find(class_or_classname, record_id)
160
+ class_or_classname = find_or_materialize(class_or_classname)
161
+ result = http_get("/services/data/v#{self.version}/sobjects/#{class_or_classname.sobject_name}/#{record_id}")
162
+ response = JSON.parse(result.body)
163
+ new_record = class_or_classname.new
164
+ class_or_classname.description["fields"].each do |field|
165
+ set_value(new_record, field["name"], response[key_from_label(field["label"])] || response[field["name"]], field["type"])
166
+ end
167
+ new_record
168
+ end
169
+
170
+ # 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.
171
+ #
172
+ # client.query("SELECT Name FROM Account") #=> [#<Account @Id=nil, @Name="Foo", ...>, #<Account @Id=nil, @Name="Bar", ...> ...]
173
+ def query(soql_expr)
174
+ result = http_get("/services/data/v#{self.version}/query?q=#{soql_expr}")
175
+ collection_from(result.body)
176
+ end
177
+
178
+ # 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.
179
+ #
180
+ # client.search("FIND {bar}") #=> [#<Account @Name="foobar", ...>, #<Account @Name="barfoo", ...> ...]
181
+ def search(sosl_expr)
182
+ result = http_get("/services/data/v#{self.version}/search?q=#{sosl_expr}")
183
+ collection_from(result.body)
184
+ end
185
+
186
+ # Used by Collection objects. Returns a Collection of Sobjects from the specified URL path that represents the next page of paginated results.
187
+ def next_page(path)
188
+ result = http_get(path)
189
+ collection_from(result.body)
190
+ end
191
+
192
+ # Used by Collection objects. Returns a Collection of Sobjects from the specified URL path that represents the previous page of paginated results.
193
+ def previous_page(path)
194
+ result = http_get(path)
195
+ collection_from(result.body)
196
+ end
197
+
198
+ # Returns a new instance of _class_or_classname_ (which can be passed in as either a String or a Class) with the specified attributes.
199
+ #
200
+ # client.create("Car", {"Color" => "Blue", "Year" => "2011"}) #=> #<Car @Id="recordid", @Color="Blue", @Year="2011">
201
+ def create(class_or_classname, object_attrs)
202
+ class_or_classname = find_or_materialize(class_or_classname)
203
+ json_for_assignment = coerced_json(object_attrs, class_or_classname)
204
+ result = http_post("/services/data/v#{self.version}/sobjects/#{class_or_classname.sobject_name}", json_for_assignment)
205
+ new_object = class_or_classname.new
206
+ JSON.parse(json_for_assignment).each do |property, value|
207
+ set_value(new_object, property, value, class_or_classname.type_map[property][:type])
208
+ end
209
+ id = JSON.parse(result.body)["id"]
210
+ set_value(new_object, "Id", id, "id")
211
+ new_object
212
+ end
213
+
214
+ # 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
215
+ #
216
+ # client.update("Car", "rid", {"Color" => "Red"})
217
+ def update(class_or_classname, record_id, new_attrs)
218
+ class_or_classname = find_or_materialize(class_or_classname)
219
+ json_for_update = coerced_json(new_attrs, class_or_classname)
220
+ http_patch("/services/data/v#{self.version}/sobjects/#{class_or_classname.sobject_name}/#{record_id}", json_for_update)
221
+ end
222
+
223
+ # 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.
224
+ # If not found, it will create a new record with _attrs_.
225
+ #
226
+ # client.upsert(Car, "Color", "Blue", {"Year" => "2012"})
227
+ def upsert(class_or_classname, field, value, attrs)
228
+ clazz = find_or_materialize(class_or_classname)
229
+ json_for_update = coerced_json(attrs, clazz)
230
+ http_patch("/services/data/v#{self.version}/sobjects/#{clazz.sobject_name}/#{field}/#{value}", json_for_update)
231
+ end
232
+
233
+ # Deletes the record of type _class_or_classname_ with id of _record_id_. _class_or_classname_ can be a String or a Class.
234
+ #
235
+ # client.delete(Car, "rid")
236
+ def delete(class_or_classname, record_id)
237
+ clazz = find_or_materialize(class_or_classname)
238
+ http_delete("/services/data/v#{self.version}/sobjects/#{clazz.sobject_name}/#{record_id}")
239
+ end
240
+
241
+ # Returns a Collection of recently touched items. The Collection contains Sobject instances that are fully populated with their correct values.
242
+ def recent
243
+ result = http_get("/services/data/v#{self.version}/recent")
244
+ collection_from(result.body)
245
+ end
246
+
247
+ # Returns an array of trending topic names.
248
+ def trending_topics
249
+ result = http_get("/services/data/v#{self.version}/chatter/topics/trending")
250
+ result = JSON.parse(result.body)
251
+ result["topics"].collect { |topic| topic["name"] }
252
+ end
253
+
254
+ # Performs an HTTP GET request to the specified path (relative to self.instance_url). Query parameters are included from _parameters_. The required
255
+ # +Authorization+ header is automatically included, as are any additional headers specified in _headers_. Returns the HTTPResult if it is of type
256
+ # HTTPSuccess- raises SalesForceError otherwise.
257
+ def http_get(path, parameters={}, headers={})
258
+ req = Net::HTTP.new(URI.parse(self.instance_url).host, 443)
259
+ req.use_ssl = true
260
+ path_parameters = (parameters || {}).collect { |k, v| "#{URI.escape(k.to_s)}=#{URI.escape(v.to_s)}" }.join('&')
261
+ encoded_path = [URI.escape(path), path_parameters.empty? ? nil : path_parameters].compact.join('?')
262
+ log_request(encoded_path)
263
+ result = req.get(encoded_path, {"Authorization" => "OAuth #{self.oauth_token}"}.merge(headers))
264
+ log_response(result)
265
+ raise SalesForceError.new(result) unless result.is_a?(Net::HTTPSuccess)
266
+ result
267
+ end
268
+
269
+
270
+ # Performs an HTTP DELETE request to the specified path (relative to self.instance_url). Query parameters are included from _parameters_. The required
271
+ # +Authorization+ header is automatically included, as are any additional headers specified in _headers_. Returns the HTTPResult if it is of type
272
+ # HTTPSuccess- raises SalesForceError otherwise.
273
+ def http_delete(path, parameters={}, headers={})
274
+ req = Net::HTTP.new(URI.parse(self.instance_url).host, 443)
275
+ req.use_ssl = true
276
+ path_parameters = (parameters || {}).collect { |k, v| "#{URI.escape(k.to_s)}=#{URI.escape(v.to_s)}" }.join('&')
277
+ encoded_path = [URI.escape(path), path_parameters.empty? ? nil : path_parameters].compact.join('?')
278
+ log_request(encoded_path)
279
+ result = req.delete(encoded_path, {"Authorization" => "OAuth #{self.oauth_token}"}.merge(headers))
280
+ log_response(result)
281
+ raise SalesForceError.new(result) unless result.is_a?(Net::HTTPNoContent)
282
+ result
283
+ end
284
+
285
+ # Performs an HTTP POST request to the specified path (relative to self.instance_url). The body of the request is taken from _data_.
286
+ # Query parameters are included from _parameters_. The required +Authorization+ header is automatically included, as are any additional
287
+ # headers specified in _headers_. Returns the HTTPResult if it is of type HTTPSuccess- raises SalesForceError otherwise.
288
+ def http_post(path, data=nil, parameters={}, headers={})
289
+ req = Net::HTTP.new(URI.parse(self.instance_url).host, 443)
290
+ req.use_ssl = true
291
+ path_parameters = (parameters || {}).collect { |k, v| "#{URI.escape(k.to_s)}=#{URI.escape(v.to_s)}" }.join('&')
292
+ encoded_path = [URI.escape(path), path_parameters.empty? ? nil : path_parameters].compact.join('?')
293
+ log_request(encoded_path, data)
294
+ result = req.post(encoded_path, data, {"Content-Type" => data ? "application/json" : "text/plain", "Authorization" => "OAuth #{self.oauth_token}"}.merge(headers))
295
+ log_response(result)
296
+ raise SalesForceError.new(result) unless result.is_a?(Net::HTTPSuccess)
297
+ result
298
+ end
299
+
300
+ # Performs an HTTP PATCH request to the specified path (relative to self.instance_url). The body of the request is taken from _data_.
301
+ # Query parameters are included from _parameters_. The required +Authorization+ header is automatically included, as are any additional
302
+ # headers specified in _headers_. Returns the HTTPResult if it is of type HTTPSuccess- raises SalesForceError otherwise.
303
+ def http_patch(path, data=nil, parameters={}, headers={})
304
+ req = Net::HTTP.new(URI.parse(self.instance_url).host, 443)
305
+ req.use_ssl = true
306
+ path_parameters = (parameters || {}).collect { |k, v| "#{URI.escape(k.to_s)}=#{URI.escape(v.to_s)}" }.join('&')
307
+ encoded_path = [URI.escape(path), path_parameters.empty? ? nil : path_parameters].compact.join('?')
308
+ log_request(encoded_path, data)
309
+ result = req.send_request("PATCH", encoded_path, data, {"Content-Type" => data ? "application/json" : "text/plain", "Authorization" => "OAuth #{self.oauth_token}"}.merge(headers))
310
+ log_response(result)
311
+ raise SalesForceError.new(result) unless result.is_a?(Net::HTTPSuccess)
312
+ result
313
+ end
314
+
315
+ # Performs an HTTP POST request to the specified path (relative to self.instance_url), using Content-Type multiplart/form-data.
316
+ # The parts of the body of the request are taken from parts_. Query parameters are included from _parameters_. The required
317
+ # +Authorization+ header is automatically included, as are any additional headers specified in _headers_.
318
+ # Returns the HTTPResult if it is of type HTTPSuccess- raises SalesForceError otherwise.
319
+ def http_multipart_post(path, parts, parameters={}, headers={})
320
+ req = Net::HTTP.new(URI.parse(self.instance_url).host, 443)
321
+ req.use_ssl = true
322
+ path_parameters = (parameters || {}).collect { |k, v| "#{URI.escape(k.to_s)}=#{URI.escape(v.to_s)}" }.join('&')
323
+ encoded_path = [URI.escape(path), path_parameters.empty? ? nil : path_parameters].compact.join('?')
324
+ log_request(encoded_path)
325
+ result = req.request(Net::HTTP::Post::Multipart.new(encoded_path, parts, {"Authorization" => "OAuth #{self.oauth_token}"}.merge(headers)))
326
+ log_response(result)
327
+ raise SalesForceError.new(result) unless result.is_a?(Net::HTTPSuccess)
328
+ result
329
+ end
330
+
331
+ private
332
+
333
+ def log_request(path, data=nil)
334
+ puts "***** REQUEST: #{path.include?(':') ? path : URI.join(self.instance_url, path)}#{data ? " => #{data}" : ''}" if self.debugging
335
+ end
336
+
337
+ def log_response(result)
338
+ puts "***** RESPONSE: #{result.class.name} -> #{result.body}" if self.debugging
339
+ end
340
+
341
+ def find_or_materialize(class_or_classname)
342
+ if class_or_classname.is_a?(Class)
343
+ clazz = class_or_classname
344
+ else
345
+ match = class_or_classname.match(/(?:(.+)::)?(\w+)$/)
346
+ preceding_namespace = match[1]
347
+ classname = match[2]
348
+ raise ArgumentError if preceding_namespace && preceding_namespace != module_namespace.name
349
+ clazz = module_namespace.const_get(classname.to_sym) rescue nil
350
+ clazz ||= self.materialize(classname)
351
+ end
352
+ clazz
353
+ end
354
+
355
+ def module_namespace
356
+ self.sobject_module || Object
357
+ end
358
+
359
+ def collection_from(response)
360
+ response = JSON.parse(response)
361
+ array_response = response.is_a?(Array)
362
+ if array_response
363
+ records = response.collect { |rec| self.find(rec["attributes"]["type"], rec["Id"]) }
364
+ else
365
+ records = response["records"].collect do |record|
366
+ attributes = record.delete('attributes')
367
+ new_record = find_or_materialize(attributes["type"]).new
368
+ record.each do |name, value|
369
+ field = new_record.description['fields'].find do |field|
370
+ key_from_label(field["label"]) == name || field["name"] == name
371
+ end
372
+ set_value(new_record, field["name"], value, field["type"]) if field
373
+ end
374
+ new_record
375
+ end
376
+ end
377
+
378
+ Databasedotcom::Collection.new(self, array_response ? records.length : response["totalSize"], array_response ? nil : response["nextRecordsUrl"]).concat(records)
379
+ end
380
+
381
+ def set_value(record, attr, value, attr_type)
382
+ value_to_set = value
383
+
384
+ case attr_type
385
+ when "datetime"
386
+ value_to_set = DateTime.parse(value) rescue nil
387
+
388
+ when "date"
389
+ value_to_set = Date.parse(value) rescue nil
390
+
391
+ when "multipicklist"
392
+ value_to_set = value.split(";") rescue []
393
+ end
394
+
395
+ record.send("#{attr}=", value_to_set)
396
+ end
397
+
398
+ def coerced_json(attrs, clazz)
399
+ if attrs.is_a?(Hash)
400
+ coerced_attrs = {}
401
+ attrs.keys.each do |key|
402
+ case clazz.field_type(key)
403
+ when "multipicklist"
404
+ coerced_attrs[key] = (attrs[key] || []).join(';')
405
+ when "datetime"
406
+ coerced_attrs[key] = attrs[key] ? attrs[key].strftime("%Y-%m-%dT%H:%M:%S.%L%z") : nil
407
+ else
408
+ coerced_attrs[key] = attrs[key]
409
+ end
410
+ end
411
+ coerced_attrs.to_json
412
+ else
413
+ attrs
414
+ end
415
+ end
416
+
417
+ def key_from_label(label)
418
+ label.gsub(' ', '_')
419
+ end
420
+ end
421
+ end