├── spec ├── fixtures │ ├── sobject │ │ ├── upsert_updated_success_response.json │ │ ├── upsert_created_success_response.json │ │ ├── query_empty_response.json │ │ ├── create_success_response.json │ │ ├── list_sobjects_error_response.json │ │ ├── query_error_response.json │ │ ├── describe_sobjects_error_response.json │ │ ├── upsert_multiple_error_response.json │ │ ├── upsert_error_response.json │ │ ├── delete_error_response.json │ │ ├── sobject_describe_error_response.json │ │ ├── write_error_response.json │ │ ├── search_error_response.json │ │ ├── sobject_find_error_response.json │ │ ├── org_query_response.json │ │ ├── query_paginated_last_page_response.json │ │ ├── query_paginated_first_page_response.json │ │ ├── search_success_response.json │ │ ├── recent_success_response.json │ │ ├── query_success_response.json │ │ ├── describe_sobjects_success_response.json │ │ ├── list_sobjects_success_response.json │ │ └── sobject_find_success_response.json │ ├── refresh_error_response.json │ ├── auth_error_response.json │ ├── chatter │ │ ├── trending_topics_get_success_response.json │ │ ├── groups_photo_get_id_success_response.json │ │ ├── users_photo_get_id_success_response.json │ │ ├── records_get_id_error_response.json │ │ ├── filter_feed_get_all_success_response.json │ │ ├── records_get_id_success_response.json │ │ ├── users_status_get_id_success_response.json │ │ ├── likes_get_id_success_response.json │ │ ├── groups_get_id_success_response.json │ │ ├── group-memberships_get_id_success_response.json │ │ ├── users_groups_get_id_success_response.json │ │ ├── subscriptions_get_id_success_response.json │ │ ├── groups_get_success_response.json │ │ ├── groups_members_get_id_success_response.json │ │ ├── users_following_get_id_success_response.json │ │ ├── users_get_success_response.json │ │ ├── messages_get_id_success_response.json │ │ ├── comments_get_id_success_response.json │ │ ├── users_get_id_success_response.json │ │ ├── users_followers_get_id_success_response.json │ │ ├── users_batch_get_mixed_response.json │ │ ├── conversations_get_success_response.json │ │ ├── messages_get_success_response.json │ │ ├── users_batch_get_success_response.json │ │ ├── conversations_get_id_success_response.json │ │ ├── feed-items_get_id_success_response.json │ │ └── news_feed_items_get_success_response.json │ ├── reauth_success_response.json │ ├── databasedotcom.yml │ ├── refresh_success_response.json │ ├── services_data_success_response.json │ ├── omniauth_response.json │ └── auth_success_response.json ├── lib │ ├── chatter │ │ ├── like_spec.rb │ │ ├── comment_spec.rb │ │ ├── subscription_spec.rb │ │ ├── group_membership_spec.rb │ │ ├── filter_feed_spec.rb │ │ ├── record_spec.rb │ │ ├── feed_item_spec.rb │ │ ├── feeds_spec.rb │ │ ├── message_spec.rb │ │ ├── group_spec.rb │ │ ├── conversation_spec.rb │ │ └── user_spec.rb │ ├── core_extensions │ │ └── string_extensions_spec.rb │ ├── sales_force_error_spec.rb │ ├── collection_spec.rb │ └── shared_behaviors │ │ ├── resource_with_photo.rb │ │ ├── token_refreshing_request.rb │ │ └── restful_resource.rb └── spec_helper.rb ├── lib ├── databasedotcom │ ├── sobject.rb │ ├── version.rb │ ├── core_extensions.rb │ ├── chatter │ │ ├── like.rb │ │ ├── comment.rb │ │ ├── feeds.rb │ │ ├── subscription.rb │ │ ├── group_membership.rb │ │ ├── filter_feed.rb │ │ ├── message.rb │ │ ├── feed_item.rb │ │ ├── group.rb │ │ ├── photo_methods.rb │ │ ├── feed.rb │ │ ├── conversation.rb │ │ ├── record.rb │ │ └── user.rb │ ├── core_extensions │ │ └── string_extensions.rb │ ├── chatter.rb │ ├── sales_force_error.rb │ ├── collection.rb │ ├── sobject │ │ └── sobject.rb │ └── client.rb └── databasedotcom.rb ├── .travis.yml ├── .gitignore ├── Gemfile ├── Rakefile ├── databasedotcom.gemspec ├── MIT-LICENSE └── README.md /spec/fixtures/sobject/upsert_updated_success_response.json: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/databasedotcom/sobject.rb: -------------------------------------------------------------------------------- 1 | require 'databasedotcom/sobject/sobject' 2 | 3 | -------------------------------------------------------------------------------- /lib/databasedotcom/version.rb: -------------------------------------------------------------------------------- 1 | module Databasedotcom 2 | VERSION = "1.3.2" 3 | end 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | rvm: 2 | - 1.8.7 3 | - 1.9.2 4 | - 1.9.3 5 | - 2.0.0 6 | - ree 7 | - jruby 8 | -------------------------------------------------------------------------------- /lib/databasedotcom/core_extensions.rb: -------------------------------------------------------------------------------- 1 | require 'databasedotcom/core_extensions/string_extensions' 2 | -------------------------------------------------------------------------------- /spec/fixtures/sobject/upsert_created_success_response.json: -------------------------------------------------------------------------------- 1 | {"id":"foo" + 2 | "","errors":[],"success":true} -------------------------------------------------------------------------------- /spec/fixtures/refresh_error_response.json: -------------------------------------------------------------------------------- 1 | {"error":"invalid_grant","error_description":"expired access/refresh token"} -------------------------------------------------------------------------------- /spec/fixtures/sobject/query_empty_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "totalSize" : 0, 3 | "done" : true, 4 | "records" : [ ] 5 | } -------------------------------------------------------------------------------- /spec/fixtures/auth_error_response.json: -------------------------------------------------------------------------------- 1 | {"error":"invalid_grant","error_description":"authentication failure - Invalid Password"} -------------------------------------------------------------------------------- /spec/fixtures/sobject/create_success_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "id" : "some_id", 3 | "errors" : [ ], 4 | "success" : true 5 | } -------------------------------------------------------------------------------- /spec/fixtures/sobject/list_sobjects_error_response.json: -------------------------------------------------------------------------------- 1 | [{"message":"Session expired or invalid","errorCode":"INVALID_SESSION_ID"}] -------------------------------------------------------------------------------- /spec/fixtures/sobject/query_error_response.json: -------------------------------------------------------------------------------- 1 | [ { 2 | "message" : "error_message", 3 | "errorCode" : "INVALID_FIELD" 4 | } ] -------------------------------------------------------------------------------- /spec/fixtures/sobject/describe_sobjects_error_response.json: -------------------------------------------------------------------------------- 1 | [{"message":"Session expired or invalid","errorCode":"INVALID_SESSION_ID"}] -------------------------------------------------------------------------------- /spec/fixtures/chatter/trending_topics_get_success_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "topics": [ 3 | { 4 | "name": "dude" 5 | } 6 | ] 7 | } -------------------------------------------------------------------------------- /spec/fixtures/chatter/groups_photo_get_id_success_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "smallPhotoUrl": "/small/photo", 3 | "largePhotoUrl": "/large/photo" 4 | } -------------------------------------------------------------------------------- /spec/fixtures/sobject/upsert_multiple_error_response.json: -------------------------------------------------------------------------------- 1 | ["/services/data/v23.0/sobjects/Whizbang/foo","/services/data/v23.0/sobjects/Whizbang/bar"] -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .rvmrc 2 | *.gem 3 | .bundle 4 | Gemfile.lock 5 | pkg/* 6 | .idea/* 7 | bin/* 8 | doc/* 9 | .DS_Store 10 | **/.DS_Store 11 | .yardoc 12 | -------------------------------------------------------------------------------- /spec/fixtures/sobject/upsert_error_response.json: -------------------------------------------------------------------------------- 1 | [{"message":"Provided external ID field does not exist or is not accessible: Namez","errorCode":"NOT_FOUND"}] -------------------------------------------------------------------------------- /spec/fixtures/chatter/users_photo_get_id_success_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "smallPhotoUrl": "/profilephoto/005/T", 3 | "largePhotoUrl": "/profilephoto/005/F" 4 | } -------------------------------------------------------------------------------- /spec/fixtures/sobject/delete_error_response.json: -------------------------------------------------------------------------------- 1 | [{"message":"Provided external ID field does not exist or is not accessible: rid","errorCode":"NOT_FOUND"}] 2 | -------------------------------------------------------------------------------- /spec/fixtures/sobject/sobject_describe_error_response.json: -------------------------------------------------------------------------------- 1 | [ { 2 | "message" : "The requested resource does not exist", 3 | "errorCode" : "NOT_FOUND" 4 | } ] -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | # Specify your gem's dependencies in databasedotcom.gemspec 4 | gemspec 5 | 6 | gem "jruby-openssl", :platforms => :jruby 7 | -------------------------------------------------------------------------------- /spec/fixtures/chatter/records_get_id_error_response.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "message": "The requested resource does not exist", 4 | "errorCode": "NOT_FOUND" 5 | } 6 | ] -------------------------------------------------------------------------------- /spec/fixtures/sobject/write_error_response.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "message":"No such column 'foo' on sobject of type Bar", 4 | "errorCode":"INVALID_FIELD" 5 | } 6 | ] -------------------------------------------------------------------------------- /spec/fixtures/sobject/search_error_response.json: -------------------------------------------------------------------------------- 1 | [ { 2 | "message" : "No search term found. The search term must be enclosed in braces.", 3 | "errorCode" : "MALFORMED_SEARCH" 4 | } ] -------------------------------------------------------------------------------- /spec/fixtures/sobject/sobject_find_error_response.json: -------------------------------------------------------------------------------- 1 | [ { 2 | "message" : "Provided external ID field does not exist or is not accessible: 23foo", 3 | "errorCode" : "NOT_FOUND" 4 | } ] -------------------------------------------------------------------------------- /spec/lib/chatter/like_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rspec' 2 | require 'spec_helper' 3 | require 'databasedotcom' 4 | 5 | describe Databasedotcom::Chatter::Like do 6 | it_should_behave_like("a restful resource") 7 | end -------------------------------------------------------------------------------- /spec/lib/chatter/comment_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rspec' 2 | require 'spec_helper' 3 | require 'databasedotcom' 4 | 5 | describe Databasedotcom::Chatter::Comment do 6 | it_should_behave_like "a restful resource" 7 | end -------------------------------------------------------------------------------- /lib/databasedotcom/chatter/like.rb: -------------------------------------------------------------------------------- 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 -------------------------------------------------------------------------------- /spec/fixtures/reauth_success_response.json: -------------------------------------------------------------------------------- 1 | {"id":"https://login.salesforce.com/id/foo/bar","issued_at":"1309974610026","instance_url":"https://na1.salesforce.com","signature":"sig=","access_token":"new_access_token"} -------------------------------------------------------------------------------- /spec/fixtures/databasedotcom.yml: -------------------------------------------------------------------------------- 1 | --- 2 | client_secret: client_secret 3 | client_id: client_id 4 | debugging: true 5 | host: bro.baz 6 | version: 88 7 | ca_file: other/ca/file.cert 8 | verify_mode: OpenSSL::SSL::VERIFY_PEER -------------------------------------------------------------------------------- /spec/fixtures/chatter/filter_feed_get_all_success_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "feeds": [{ 3 | "label": "Contacts", 4 | "feedUrl": "/services/data/v23.0/chatter/feeds/filter/005x0000000JbFuAAK/003" 5 | }] 6 | } -------------------------------------------------------------------------------- /spec/fixtures/chatter/records_get_id_success_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Admin User", 3 | "id": "005x0000000JbFuAAK", 4 | "type": "User", 5 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 6 | } -------------------------------------------------------------------------------- /spec/lib/chatter/subscription_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rspec' 2 | require 'spec_helper' 3 | require 'databasedotcom' 4 | 5 | describe Databasedotcom::Chatter::Subscription do 6 | it_should_behave_like("a restful resource") 7 | end -------------------------------------------------------------------------------- /spec/lib/chatter/group_membership_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rspec' 2 | require 'spec_helper' 3 | require 'databasedotcom' 4 | 5 | describe Databasedotcom::Chatter::GroupMembership do 6 | it_should_behave_like("a restful resource") 7 | end -------------------------------------------------------------------------------- /lib/databasedotcom/chatter/comment.rb: -------------------------------------------------------------------------------- 1 | require 'databasedotcom/chatter/record' 2 | 3 | module Databasedotcom 4 | module Chatter 5 | 6 | # A comment posted on a FeedItem. 7 | class Comment < Record 8 | end 9 | end 10 | end -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'webmock/rspec' 2 | Encoding.default_external = Encoding::UTF_8 if defined?(Encoding) 3 | Dir.glob(File.join(File.dirname(__FILE__), 'lib', 'shared_behaviors', '**', '*.rb')).each do |f| 4 | require f 5 | end 6 | -------------------------------------------------------------------------------- /lib/databasedotcom/chatter/feeds.rb: -------------------------------------------------------------------------------- 1 | require 'databasedotcom/chatter/feed' 2 | 3 | Databasedotcom::Chatter::FEED_TYPES.each do |feed_type| 4 | Databasedotcom::Chatter.const_set("#{feed_type}Feed", Class.new(Databasedotcom::Chatter::Feed)) 5 | end 6 | -------------------------------------------------------------------------------- /lib/databasedotcom/chatter/subscription.rb: -------------------------------------------------------------------------------- 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 -------------------------------------------------------------------------------- /spec/fixtures/refresh_success_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "https://login.salesforce.com/id/foo/bar", 3 | "issued_at": "1309974610026", 4 | "instance_url": "https://na1.salesforce.com", 5 | "signature": "sig=", 6 | "access_token": "refreshed_access_token" 7 | } -------------------------------------------------------------------------------- /spec/fixtures/services_data_success_response.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "version": "20.0", 4 | "url": "/services/data/v20.0", 5 | "label": "Winter '11" 6 | }, 7 | { 8 | "version": "100", 9 | "url": "/services/data/v100", 10 | "label": "Spring '86" 11 | } 12 | ] -------------------------------------------------------------------------------- /lib/databasedotcom/chatter/group_membership.rb: -------------------------------------------------------------------------------- 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 -------------------------------------------------------------------------------- /lib/databasedotcom/core_extensions/string_extensions.rb: -------------------------------------------------------------------------------- 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 | end 10 | -------------------------------------------------------------------------------- /spec/fixtures/sobject/org_query_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "totalSize" : 1, 3 | "done" : true, 4 | "records" : [ { 5 | "attributes" : { 6 | "type" : "Organization", 7 | "url" : "/services/data/v20.0/sobjects/Organization/00Dx0000000BV7z" 8 | }, 9 | "Id" : "00Dx0000000BV7z" 10 | } ] 11 | } -------------------------------------------------------------------------------- /spec/fixtures/sobject/query_paginated_last_page_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "totalSize" : 2, 3 | "done" : true, 4 | "records" : [ { 5 | "attributes" : { 6 | "type" : "Whizbang", 7 | "url" : "/services/data/v20.0/sobjects/Whizbang/foo" 8 | }, 9 | "Text_Label" : "Last Page" 10 | } ] 11 | } -------------------------------------------------------------------------------- /spec/fixtures/omniauth_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "provider":"salesforce", 3 | "uid":"https://login.salesforce.com/id/00Dx0000000BV7z/005x00000012Q9P", 4 | "credentials":{ 5 | "token":"access_token", 6 | "instance_url":"https://na1.salesforce.com", 7 | "refresh_token":"refresh_token" 8 | } 9 | } -------------------------------------------------------------------------------- /lib/databasedotcom.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext' 2 | 3 | require 'databasedotcom/version' 4 | require 'databasedotcom/core_extensions' 5 | require 'databasedotcom/client' 6 | require 'databasedotcom/sales_force_error' 7 | require 'databasedotcom/collection' 8 | require 'databasedotcom/sobject' 9 | require 'databasedotcom/chatter' 10 | -------------------------------------------------------------------------------- /spec/fixtures/sobject/query_paginated_first_page_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "totalSize" : 2, 3 | "done" : false, 4 | "nextRecordsUrl" : "/next/page/url", 5 | "records" : [ { 6 | "attributes" : { 7 | "type" : "Whizbang", 8 | "url" : "/services/data/v20.0/sobjects/Whizbang/foo" 9 | }, 10 | "Text_Label" : "First Page" 11 | } ] 12 | } -------------------------------------------------------------------------------- /spec/fixtures/auth_success_response.json: -------------------------------------------------------------------------------- 1 | {"id":"https://login.salesforce.com/id/00Dx0000000BV7z/005x00000012Q9P","issued_at":"1278448832702","instance_url":"https://na1.salesforce.com","signature":"0CmxinZir53Yex7nE0TD+zMpvIWYGb/bdJh6XfOH6EQ=","access_token":"00Dx0000000BV7z!AR8AQAxo9UfVkh8AlV0Gomt9Czx9LjHnSSpwBMmbRcgKFmxOtvxjTrKW19ye6PE3Ds1eQz3z8jr3W7_VbWmEu4Q8TVGSTHxs"} -------------------------------------------------------------------------------- /spec/fixtures/sobject/search_success_response.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "attributes" : { 4 | "type" : "Whizbang", 5 | "url" : "/services/data/v23.0/sobjects/Whizbang/foo" 6 | }, 7 | "Id" : "foo" 8 | }, 9 | { 10 | "attributes" : { 11 | "type" : "Whizbang", 12 | "url" : "/services/data/v23.0/sobjects/Whizbang/bar" 13 | }, 14 | "Id" : "bar" 15 | } 16 | ] -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler' 2 | require 'rspec/core/rake_task' 3 | Bundler::GemHelper.install_tasks 4 | 5 | RSpec::Core::RakeTask.new(:spec) 6 | 7 | desc "runs specs on all supported rubies" 8 | task :spec_rubies do 9 | system "rvm ruby-1.9.2-p180@databasedotcom,ruby-1.8.7-p357@databasedotcom,ruby-1.9.3@databasedotcom,ree@databasedotcom --create do rake spec" 10 | end 11 | 12 | task :default => :spec 13 | -------------------------------------------------------------------------------- /spec/fixtures/sobject/recent_success_response.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "attributes" : { 4 | "type" : "Whizbang", 5 | "url" : "/services/data/v23.0/sobjects/Whizbang/foo" 6 | }, 7 | "Id" : "foo", 8 | "Name" : "whatever" 9 | }, 10 | { 11 | "attributes" : { 12 | "type" : "Whizbang", 13 | "url" : "/services/data/v23.0/sobjects/Whizbang/bar" 14 | }, 15 | "Id" : "bar", 16 | "Name" : "whatever" 17 | } 18 | ] -------------------------------------------------------------------------------- /lib/databasedotcom/chatter.rb: -------------------------------------------------------------------------------- 1 | require 'databasedotcom/chatter/feeds' 2 | require 'databasedotcom/chatter/filter_feed' 3 | require 'databasedotcom/chatter/feed_item' 4 | require 'databasedotcom/chatter/comment' 5 | require 'databasedotcom/chatter/like' 6 | require 'databasedotcom/chatter/user' 7 | require 'databasedotcom/chatter/group' 8 | require 'databasedotcom/chatter/group_membership' 9 | require 'databasedotcom/chatter/subscription' 10 | require 'databasedotcom/chatter/conversation' 11 | require 'databasedotcom/chatter/message' -------------------------------------------------------------------------------- /lib/databasedotcom/chatter/filter_feed.rb: -------------------------------------------------------------------------------- 1 | module Databasedotcom 2 | module Chatter 3 | # Filter feeds contain items pertaining to both a user and another specified resource. 4 | class FilterFeed < Feed 5 | 6 | # Lists all FilterFeeds for the user with id _user_id_. 7 | def self.feeds(client, user_id="me") 8 | url = "/services/data/v#{client.version}/chatter/feeds/filter/#{user_id}" 9 | result = client.http_get(url) 10 | JSON.parse(result.body) 11 | end 12 | end 13 | end 14 | end -------------------------------------------------------------------------------- /spec/fixtures/chatter/users_status_get_id_success_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK/status", 3 | 4 | "body": { 5 | "text": "I love #frogs", 6 | "messageSegments": [ 7 | { 8 | "type": "Text", 9 | "text": "I love " 10 | }, 11 | { 12 | "url": "/services/data/v23.0/chatter/feed-items?q=%23frogs", 13 | "tag": "frogs", 14 | "type": "Hashtag", 15 | "text": "#frogs" 16 | } 17 | ] 18 | }, 19 | "parentId": "005x0000000JbFuAAK" 20 | } -------------------------------------------------------------------------------- /spec/fixtures/chatter/likes_get_id_success_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "0I0x000000001XiCAI", 3 | "user": { 4 | "name": "Admin User", 5 | "title": null, 6 | "firstName": "Admin", 7 | "lastName": "User", 8 | "companyName": "Pivotal", 9 | "mySubscription": null, 10 | "photo": { 11 | "smallPhotoUrl": "/profilephoto/005/T", 12 | "largePhotoUrl": "/profilephoto/005/F" 13 | }, 14 | "isChatterGuest": false, 15 | "id": "005x0000000JbFuAAK", 16 | "type": "User", 17 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 18 | }, 19 | "url": "/services/data/v23.0/chatter/likes/0I0x000000001XiCAI" 20 | } -------------------------------------------------------------------------------- /spec/lib/core_extensions/string_extensions_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rspec' 2 | require 'spec_helper' 3 | require 'databasedotcom' 4 | 5 | describe "string extensions" do 6 | describe "#resourcerize" do 7 | it "resourcerizes class names" do 8 | "FooBar".resourcerize.should == "foo-bar" 9 | "User".resourcerize.should == "user" 10 | end 11 | end 12 | 13 | describe "#constantize" do 14 | context "when string is empty" do 15 | it "returns Object" do 16 | "".constantize.should eq(Object) 17 | end 18 | end 19 | 20 | context "when string ends with ::" do 21 | it "returns Object" do 22 | "Object::".constantize.should eq(Object) 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/fixtures/sobject/query_success_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "totalSize" : 1, 3 | "done" : true, 4 | "records" : [ { 5 | "attributes" : { 6 | "type" : "Whizbang", 7 | "url" : "/services/data/v20.0/sobjects/Whizbang/foo" 8 | }, 9 | "Checkbox_Label" : false, 10 | "Text_Label" : "Hi there!", 11 | "Date_Label" : "2010-01-01", 12 | "DateTime_Label" : "2011-07-07T00:37:00.000+0000", 13 | "Picklist_Multiselect_Label" : "four;six", 14 | 15 | "ParentWhizbang__r" : { 16 | "Name": "Parent Whizbang", 17 | "Text_Label": "Hello", 18 | "attributes" : { 19 | "type" : "Whizbang", 20 | "url" : "/services/data/v20.0/sobjects/Whizbang/bar" 21 | } 22 | } 23 | } ] 24 | } 25 | -------------------------------------------------------------------------------- /spec/lib/chatter/filter_feed_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rspec' 2 | require 'spec_helper' 3 | require 'databasedotcom' 4 | 5 | describe Databasedotcom::Chatter::FilterFeed do 6 | describe ".feeds" do 7 | before do 8 | @response = File.read(File.join(File.dirname(__FILE__), "../../fixtures/chatter/filter_feed_get_all_success_response.json")) 9 | @client_mock = double("client", :version => "23") 10 | @client_mock.should_receive(:http_get).with("/services/data/v23/chatter/feeds/filter/uid").and_return(double("response", :body => @response)) 11 | end 12 | 13 | it "returns a hash describing the filter feeds" do 14 | Databasedotcom::Chatter::FilterFeed.feeds(@client_mock, "uid").should be_instance_of(Hash) 15 | end 16 | end 17 | end -------------------------------------------------------------------------------- /spec/fixtures/chatter/groups_get_id_success_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "All Pivotal", 3 | "description": "Get company announcements and other important updates", 4 | "visibility": "PublicAccess", 5 | "memberCount": 1, 6 | "canHaveChatterGuests": false, 7 | "mySubscription": { 8 | "id": "0FBx000000006TrGAI", 9 | "url": "/services/data/v23.0/chatter/group-memberships/0FBx000000006TrGAI" 10 | }, 11 | "myRole": "GroupOwner", 12 | "photo": { 13 | "smallPhotoUrl": "/profilephoto/729x000000005HB/T", 14 | "largePhotoUrl": "/profilephoto/729x000000005HB/F" 15 | }, 16 | "id": "0F9x000000002HVCAY", 17 | "type": "CollaborationGroup", 18 | "url": "/services/data/v23.0/chatter/groups/0F9x000000002HVCAY" 19 | } -------------------------------------------------------------------------------- /spec/fixtures/chatter/group-memberships_get_id_success_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "0FBx000000006TrGAI", 3 | "user": { 4 | "name": "Admin User", 5 | "title": null, 6 | "firstName": "Admin", 7 | "lastName": "User", 8 | "companyName": "Pivotal", 9 | "mySubscription": null, 10 | "photo": { 11 | "smallPhotoUrl": "/profilephoto/005/T", 12 | "largePhotoUrl": "/profilephoto/005/F" 13 | }, 14 | "isChatterGuest": false, 15 | "id": "005x0000000JbFuAAK", 16 | "type": "User", 17 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 18 | }, 19 | "url": "/services/data/v23.0/chatter/group-memberships/0FBx000000006TrGAI", 20 | "role": "GROUP_OWNER" 21 | } -------------------------------------------------------------------------------- /spec/fixtures/chatter/users_groups_get_id_success_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "total": 1, 3 | "groups": [{ 4 | "name": "All Pivotal", 5 | "description": "Get company announcements and other important updates", 6 | "visibility": "PublicAccess", 7 | "memberCount": 1, 8 | "photo": { 9 | "smallPhotoUrl": "/profilephoto/729x000000005HB/T", 10 | "largePhotoUrl": "/profilephoto/729x000000005HB/F" 11 | }, 12 | "canHaveChatterGuests": false, 13 | "mySubscription": null, 14 | "myRole": "GroupOwner", 15 | "id": "0F9x000000002HVCAY", 16 | "type": "CollaborationGroup", 17 | "url": "/services/data/v23.0/chatter/groups/0F9x000000002HVCAY" 18 | }], 19 | "previousPageUrl": null, 20 | "nextPageUrl": null, 21 | "currentPageUrl": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK/groups" 22 | } -------------------------------------------------------------------------------- /lib/databasedotcom/sales_force_error.rb: -------------------------------------------------------------------------------- 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 -------------------------------------------------------------------------------- /spec/fixtures/chatter/subscriptions_get_id_success_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "0E8x00000000aenCAA", 3 | "url": "/services/data/v23.0/chatter/subscriptions/0E8x00000000aenCAA", 4 | "subject": { 5 | "name": "Richard Zhao", 6 | "id": "003x0000001X6w9AAC", 7 | "type": "Contact", 8 | "url": "/services/data/v23.0/chatter/records/003x0000001X6w9AAC" 9 | }, 10 | "subscriber": { 11 | "name": "Admin User", 12 | "title": null, 13 | "firstName": "Admin", 14 | "lastName": "User", 15 | "companyName": "Pivotal", 16 | "mySubscription": null, 17 | "photo": { 18 | "smallPhotoUrl": "/profilephoto/005/T", 19 | "largePhotoUrl": "/profilephoto/005/F" 20 | }, 21 | "isChatterGuest": false, 22 | "id": "005x0000000JbFuAAK", 23 | "type": "User", 24 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 25 | } 26 | } -------------------------------------------------------------------------------- /spec/fixtures/chatter/groups_get_success_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "groups": [{ 3 | "name": "All Pivotal", 4 | "description": "Get company announcements and other important updates", 5 | "visibility": "PublicAccess", 6 | "memberCount": 1, 7 | "photo": { 8 | "smallPhotoUrl": "/profilephoto/729x000000005HB/T", 9 | "largePhotoUrl": "/profilephoto/729x000000005HB/F" 10 | }, 11 | "canHaveChatterGuests": false, 12 | "mySubscription": { 13 | "id": "0FBx000000006TrGAI", 14 | "url": "/services/data/v23.0/chatter/group-memberships/0FBx000000006TrGAI" 15 | }, 16 | "myRole": "GroupOwner", 17 | "id": "0F9x000000002HVCAY", 18 | "type": "CollaborationGroup", 19 | "url": "/services/data/v23.0/chatter/groups/0F9x000000002HVCAY" 20 | }], 21 | "previousPageUrl": null, 22 | "nextPageUrl": null, 23 | "currentPageUrl": "/services/data/v23.0/chatter/groups" 24 | } -------------------------------------------------------------------------------- /spec/fixtures/sobject/describe_sobjects_success_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "encoding" : "UTF-8", 3 | "maxBatchSize" : 200, 4 | "sobjects" : [ { 5 | "name" : "Account", 6 | "label" : "Account", 7 | "custom" : false, 8 | "keyPrefix" : "001", 9 | "labelPlural" : "Accounts", 10 | "layoutable" : true, 11 | "activateable" : false, 12 | "updateable" : true, 13 | "urls" : { 14 | "sobject" : "/services/data/v23.0/sobjects/Account", 15 | "describe" : "/services/data/v23.0/sobjects/Account/describe", 16 | "rowTemplate" : "/services/data/v23.0/sobjects/Account/{ID}" 17 | }, 18 | "searchable" : true, 19 | "createable" : true, 20 | "customSetting" : false, 21 | "deletable" : true, 22 | "deprecatedAndHidden" : false, 23 | "feedEnabled" : true, 24 | "mergeable" : true, 25 | "queryable" : true, 26 | "replicateable" : true, 27 | "retrieveable" : true, 28 | "undeletable" : true, 29 | "triggerable" : true 30 | }] 31 | } -------------------------------------------------------------------------------- /spec/fixtures/sobject/list_sobjects_success_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "encoding" : "UTF-8", 3 | "maxBatchSize" : 200, 4 | "sobjects" : [ { 5 | "name" : "Account", 6 | "label" : "Account", 7 | "custom" : false, 8 | "keyPrefix" : "001", 9 | "labelPlural" : "Accounts", 10 | "layoutable" : true, 11 | "activateable" : false, 12 | "updateable" : true, 13 | "urls" : { 14 | "sobject" : "/services/data/v23.0/sobjects/Account", 15 | "describe" : "/services/data/v23.0/sobjects/Account/describe", 16 | "rowTemplate" : "/services/data/v23.0/sobjects/Account/{ID}" 17 | }, 18 | "searchable" : true, 19 | "createable" : true, 20 | "customSetting" : false, 21 | "deletable" : true, 22 | "deprecatedAndHidden" : false, 23 | "feedEnabled" : true, 24 | "mergeable" : true, 25 | "queryable" : true, 26 | "replicateable" : true, 27 | "retrieveable" : true, 28 | "undeletable" : true, 29 | "triggerable" : true 30 | }] 31 | } -------------------------------------------------------------------------------- /databasedotcom.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path('../lib', __FILE__) 3 | require 'databasedotcom/version' 4 | 5 | Gem::Specification.new do |s| 6 | s.name = 'databasedotcom' 7 | s.version = Databasedotcom::VERSION 8 | s.platform = Gem::Platform::RUBY 9 | s.authors = ['Glenn Gillen, Danny Burkes & Richard Zhao'] 10 | s.email = ['me@glenngillen.com'] 11 | s.homepage = 'https://github.com/heroku/databasedotcom' 12 | s.summary = %q{A ruby wrapper for the Force.com REST API} 13 | s.description = %q{A ruby wrapper for the Force.com REST API} 14 | 15 | s.rubyforge_project = 'databasedotcom' 16 | 17 | s.files = Dir['README.md', 'MIT-LICENSE', 'lib/**/*'] 18 | s.require_paths = ['lib'] 19 | 20 | s.add_dependency 'multipart-post', '~> 1.1' 21 | s.add_dependency 'json' 22 | s.add_dependency 'activesupport' 23 | 24 | s.add_development_dependency 'rspec', '~> 2.6' 25 | s.add_development_dependency 'webmock' 26 | s.add_development_dependency 'rake', '>= 0.8.6' 27 | end 28 | -------------------------------------------------------------------------------- /spec/fixtures/sobject/sobject_find_success_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "attributes" : { 3 | "type" : "Whizbang", 4 | "url" : "/services/data/v20.0/sobjects/Whizbang/23foo" 5 | }, 6 | "Id" : "23foo", 7 | "OwnerId" : "owner_id", 8 | "IsDeleted" : false, 9 | "Name" : "My First Whizbang", 10 | "CreatedById" : "created_by_id", 11 | "LastModifiedById" : "last_modified_by_id", 12 | "Auto_Number" : "A-1", 13 | "Checkbox_Label" : true, 14 | "Currency_Label" : 23.0, 15 | "Date_Label" : "2010-01-01", 16 | "DateTime_Label" : "2011-07-07T00:37:00.000+0000", 17 | "OtherDateTime_Label" : null, 18 | "Email_Label" : "danny@example.com", 19 | "Number_Label" : 23.0, 20 | "Percent_Label" : 33.0, 21 | "Phone_Label" : "(415) 555-1212", 22 | "Picklist_Label" : "one", 23 | "Picklist_Multiselect_Label" : "four;six", 24 | "Text_Label" : "some text", 25 | "TextArea_Label" : "a text area", 26 | "TextAreaLong_Label" : "a loooooooooooooong text area", 27 | "TextAreaRich_Label" : "Rich text", 28 | "URL_Label" : "http://pivotallabs.com" 29 | } -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /spec/fixtures/chatter/groups_members_get_id_success_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "members": [{ 3 | "id": "0FBx000000006TrGAI", 4 | "user": { 5 | "name": "Admin User", 6 | "title": null, 7 | "firstName": "Admin", 8 | "lastName": "User", 9 | "companyName": "Pivotal", 10 | "mySubscription": null, 11 | "photo": { 12 | "smallPhotoUrl": "/profilephoto/005/T", 13 | "largePhotoUrl": "/profilephoto/005/F" 14 | }, 15 | "isChatterGuest": false, 16 | "id": "005x0000000JbFuAAK", 17 | "type": "User", 18 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 19 | }, 20 | "url": "/services/data/v23.0/chatter/group-memberships/0FBx000000006TrGAI", 21 | "role": "GROUP_OWNER" 22 | }], 23 | "previousPageUrl": null, 24 | "nextPageUrl": null, 25 | "currentPageUrl": "/services/data/v23.0/chatter/groups/0F9x000000002HVCAY/members", 26 | "totalMemberCount": 1, 27 | "myMembership": { 28 | "id": "0FBx000000006TrGAI", 29 | "url": "/services/data/v23.0/chatter/group-memberships/0FBx000000006TrGAI" 30 | } 31 | } -------------------------------------------------------------------------------- /spec/fixtures/chatter/users_following_get_id_success_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "total": 1, 3 | "previousPageUrl": null, 4 | "nextPageUrl": null, 5 | "currentPageUrl": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK/following", 6 | "following": [{ 7 | "id": "0E8x00000000aenCAA", 8 | "url": "/services/data/v23.0/chatter/subscriptions/0E8x00000000aenCAA", 9 | "subject": { 10 | "name": "Richard Zhao", 11 | "id": "003x0000001X6w9AAC", 12 | "type": "Contact", 13 | "url": "/services/data/v23.0/chatter/records/003x0000001X6w9AAC" 14 | }, 15 | "subscriber": { 16 | "name": "Admin User", 17 | "title": null, 18 | "firstName": "Admin", 19 | "lastName": "User", 20 | "companyName": "Pivotal", 21 | "mySubscription": null, 22 | "photo": { 23 | "smallPhotoUrl": "/profilephoto/005/T", 24 | "largePhotoUrl": "/profilephoto/005/F" 25 | }, 26 | "isChatterGuest": false, 27 | "id": "005x0000000JbFuAAK", 28 | "type": "User", 29 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 30 | } 31 | }] 32 | } -------------------------------------------------------------------------------- /lib/databasedotcom/chatter/message.rb: -------------------------------------------------------------------------------- 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 -------------------------------------------------------------------------------- /spec/fixtures/chatter/users_get_success_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "users": [{ 3 | "name": "Admin User", 4 | "title": null, 5 | "firstName": "Admin", 6 | "lastName": "User", 7 | "companyName": "Pivotal", 8 | "mySubscription": null, 9 | "photo": { 10 | "smallPhotoUrl": "/profilephoto/005/T", 11 | "largePhotoUrl": "/profilephoto/005/F" 12 | }, 13 | "isChatterGuest": false, 14 | "id": "005x0000000JbFuAAK", 15 | "type": "User", 16 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 17 | }, 18 | { 19 | "name": "Chatter Expert", 20 | "title": null, 21 | "firstName": null, 22 | "lastName": "Chatter Expert", 23 | "companyName": null, 24 | "mySubscription": { 25 | "id": "005x0000000JbGVAA0", 26 | "url": "/services/data/v23.0/chatter/subscriptions/0E8x00000000aesCAA" 27 | }, 28 | "photo": { 29 | "smallPhotoUrl": "/profilephoto/729x000000005Gw/T", 30 | "largePhotoUrl": "/profilephoto/729x000000005Gw/F" 31 | }, 32 | "isChatterGuest": false, 33 | "id": "005x0000000JbGVAA0", 34 | "type": "User", 35 | "url": "/services/data/v23.0/chatter/users/005x0000000JbGVAA0" 36 | }], 37 | "previousPageUrl": null, 38 | "nextPageUrl": null, 39 | "currentPageUrl": "/services/data/v23.0/chatter/users" 40 | } -------------------------------------------------------------------------------- /spec/fixtures/chatter/messages_get_id_success_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "03Jx000000000FYEAY", 3 | "sender": { 4 | "name": "Admin User", 5 | "title": null, 6 | "firstName": "Admin", 7 | "lastName": "User", 8 | "companyName": "Pivotal", 9 | "photo": { 10 | "smallPhotoUrl": "/profilephoto/005/T", 11 | "largePhotoUrl": "/profilephoto/005/F" 12 | }, 13 | "mySubscription": null, 14 | "isChatterGuest": false, 15 | "id": "005x0000000JbFuAAK", 16 | "type": "User", 17 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 18 | }, 19 | "url": "/services/data/v23.0/chatter/users/me/messages/03Jx000000000FYEAY", 20 | "body": { 21 | "text": "Yes", 22 | "messageSegments": [] 23 | }, 24 | "recipients": [{ 25 | "name": "Chatter Expert", 26 | "title": null, 27 | "firstName": null, 28 | "lastName": "Chatter Expert", 29 | "companyName": null, 30 | "photo": { 31 | "smallPhotoUrl": "/profilephoto/729x000000005Gw/T", 32 | "largePhotoUrl": "/profilephoto/729x000000005Gw/F" 33 | }, 34 | "mySubscription": null, 35 | "isChatterGuest": false, 36 | "id": "005x0000000JbGVAA0", 37 | "type": "User", 38 | "url": "/services/data/v23.0/chatter/users/005x0000000JbGVAA0" 39 | }], 40 | "sentDate": "2011-08-08T21:36:05.000Z", 41 | "conversationId": "03Mx0000000009XEAQ", 42 | "conversationUrl": "/services/data/v23.0/chatter/users/me/conversations/03Mx0000000009XEAQ" 43 | } -------------------------------------------------------------------------------- /spec/fixtures/chatter/comments_get_id_success_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "parent": { 3 | "id": "005x0000000JbFuAAK", 4 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 5 | }, 6 | "id": "0D7x000000005dRCAQ", 7 | "user": { 8 | "name": "Admin User", 9 | "title": null, 10 | "firstName": "Admin", 11 | "lastName": "User", 12 | "companyName": "Pivotal", 13 | "mySubscription": null, 14 | "photo": { 15 | "smallPhotoUrl": "/profilephoto/005/T", 16 | "largePhotoUrl": "/profilephoto/005/F" 17 | }, 18 | "isChatterGuest": false, 19 | "id": "005x0000000JbFuAAK", 20 | "type": "User", 21 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 22 | }, 23 | "clientInfo": { 24 | "applicationName": "localhost", 25 | "applicationUrl": "null" 26 | }, 27 | "url": "/services/data/v23.0/chatter/comments/0D7x000000005dRCAQ", 28 | "body": { 29 | "text": "So do I like #frogs", 30 | "messageSegments": [{ 31 | "type": "Text", 32 | "text": "So do I like " 33 | }, 34 | { 35 | "url": "/services/data/v23.0/chatter/feed-items?q=%23frogs", 36 | "tag": "frogs", 37 | "type": "Hashtag", 38 | "text": "#frogs" 39 | }] 40 | }, 41 | "createdDate": "2011-08-08T21:39:23.000+0000", 42 | "deletable": true, 43 | "feedItem": { 44 | "id": "0D5x00000001g6tCAA", 45 | "url": "/services/data/v23.0/chatter/feed-items/0D5x00000001g6tCAA" 46 | } 47 | } -------------------------------------------------------------------------------- /lib/databasedotcom/collection.rb: -------------------------------------------------------------------------------- 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 -------------------------------------------------------------------------------- /lib/databasedotcom/chatter/feed_item.rb: -------------------------------------------------------------------------------- 1 | require 'databasedotcom/chatter/record' 2 | 3 | module Databasedotcom 4 | module Chatter 5 | 6 | # An item in a Feed. 7 | class FeedItem < Record 8 | 9 | # Returns a Collection of comments that were posted on this FeedItem instance. 10 | def comments 11 | collection = Databasedotcom::Collection.new(self.client, self.raw_hash["comments"]["total"], self.raw_hash["comments"]["nextPageUrl"], nil, self.raw_hash["comments"]["currentPageUrl"]) 12 | collection.concat(self.raw_hash["comments"]["comments"]) 13 | end 14 | 15 | # Returns a Collection of likes for this FeedItem instance. 16 | def likes 17 | collection = Databasedotcom::Collection.new(self.client, self.raw_hash["likes"]["total"], self.raw_hash["likes"]["nextPageUrl"], self.raw_hash["likes"]["previousPageUrl"], self.raw_hash["likes"]["currentPageUrl"]) 18 | collection.concat(self.raw_hash["likes"]["likes"]) 19 | end 20 | 21 | # Like this FeedItem. 22 | def like 23 | result = self.client.http_post("/services/data/v#{self.client.version}/chatter/feed-items/#{self.id}/likes") 24 | Like.new(self.client, result.body) 25 | end 26 | 27 | # Post a Comment on this FeedItem with content _text_. 28 | def comment(text) 29 | result = self.client.http_post("/services/data/v#{self.client.version}/chatter/feed-items/#{self.id}/comments", nil, :text => text) 30 | Comment.new(self.client, result.body) 31 | end 32 | 33 | protected 34 | 35 | def self.collection_from_response(response) 36 | response["items"] 37 | end 38 | end 39 | end 40 | end -------------------------------------------------------------------------------- /spec/fixtures/chatter/users_get_id_success_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "address": { 3 | "state": "CA", 4 | "country": "US", 5 | "street": "1 Market St", 6 | "city": "San Francisco", 7 | "zip": "94105" 8 | }, 9 | "email": "danny@pivotallabs.com", 10 | "currentStatus": { 11 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK/status", 12 | "body": { 13 | "text": "I love #frogs", 14 | "messageSegments": [{ 15 | "type": "Text", 16 | "text": "I love " 17 | }, 18 | { 19 | "url": "/services/data/v23.0/chatter/feed-items?q=%23frogs", 20 | "tag": "frogs", 21 | "type": "Hashtag", 22 | "text": "#frogs" 23 | }] 24 | }, 25 | "parentId": "005x0000000JbFuAAK" 26 | }, 27 | "managerId": null, 28 | "followingCounts": { 29 | "total": 2, 30 | "records": 1, 31 | "people": 1 32 | }, 33 | "groupCount": 1, 34 | "followersCount": 0, 35 | "managerName": null, 36 | "aboutMe": null, 37 | "isActive": true, 38 | "phoneNumbers": [], 39 | "chatterActivity": { 40 | "commentCount": 2, 41 | "commentReceivedCount": 0, 42 | "likeReceivedCount": 0, 43 | "postCount": 3 44 | }, 45 | "name": "Admin User", 46 | "title": null, 47 | "firstName": "Admin", 48 | "lastName": "User", 49 | "companyName": "Pivotal", 50 | "mySubscription": null, 51 | "photo": { 52 | "smallPhotoUrl": "/profilephoto/005/T", 53 | "largePhotoUrl": "/profilephoto/005/F" 54 | }, 55 | "isChatterGuest": false, 56 | "id": "005x0000000JbFuAAK", 57 | "type": "User", 58 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 59 | } -------------------------------------------------------------------------------- /lib/databasedotcom/chatter/group.rb: -------------------------------------------------------------------------------- 1 | require 'databasedotcom/chatter/record' 2 | require 'databasedotcom/chatter/photo_methods' 3 | 4 | module Databasedotcom 5 | module Chatter 6 | # A group of Users 7 | class Group < Record 8 | include PhotoMethods 9 | 10 | # Returns a Collection of GroupMembership instances for the Group identified by _group_id_. 11 | def self.members(client, group_id) 12 | url = "/services/data/v#{client.version}/chatter/groups/#{group_id}/members" 13 | result = client.http_get(url) 14 | response = JSON.parse(result.body) 15 | collection = Databasedotcom::Collection.new(client, response["totalMemberCount"], response["nextPageUrl"], response["previousPageUrl"], response["currentPageUrl"]) 16 | response["members"].each do |member| 17 | collection << GroupMembership.new(client, member) 18 | end 19 | collection 20 | end 21 | 22 | # Join the group identified by _group_id_ as the user identified by _user_id_. 23 | def self.join(client, group_id, user_id="me") 24 | url = "/services/data/v#{client.version}/chatter/groups/#{group_id}/members" 25 | response = client.http_post(url, nil, :userId => user_id) 26 | GroupMembership.new(client, response.body) 27 | end 28 | 29 | # Get a Collection of GroupMembership objects for this Group. Always makes a call to the server. 30 | def members! 31 | self.class.members(self.client, self.id) 32 | end 33 | 34 | # Get a Collection of GroupMembership objects for this Group. Returns cached data if it has been called before. 35 | def members 36 | @members ||= members! 37 | end 38 | 39 | # Join this Group as the user identified by _user_id_. 40 | def join(user_id="me") 41 | self.class.join(self.client, self.id, user_id) 42 | end 43 | end 44 | end 45 | end -------------------------------------------------------------------------------- /spec/lib/chatter/record_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rspec' 2 | require 'spec_helper' 3 | require 'databasedotcom' 4 | 5 | describe Databasedotcom::Chatter::Record do 6 | it_should_behave_like("a restful resource") 7 | 8 | describe "with a constructed record" do 9 | before do 10 | @response = File.read(File.join(File.dirname(__FILE__), "../../fixtures/chatter/records_get_id_success_response.json")) 11 | @mock_client = double("client", :version => "23") 12 | @record = Databasedotcom::Chatter::Record.new(@mock_client, @response) 13 | end 14 | 15 | describe "#client" do 16 | it "returns the client of the standard record" do 17 | @record.client.should == @mock_client 18 | end 19 | end 20 | 21 | describe "#parent" do 22 | it "returns the parent of the record" do 23 | @record.parent.should == @record.raw_hash["parent"] 24 | end 25 | end 26 | 27 | describe "#user" do 28 | it "returns the user of the record" do 29 | @record.user.should == @record.raw_hash["user"] 30 | end 31 | end 32 | 33 | describe "#delete" do 34 | it "deletes the resource" do 35 | @mock_client.should_receive(:http_delete).with("/services/data/v23/chatter/records/#{@record.id}", {}) 36 | @record.delete 37 | end 38 | end 39 | 40 | describe "#reload" do 41 | it "reloads the record" do 42 | @mock_client.should_receive(:http_get).with("/services/data/v23/chatter/records/#{@record.id}", {}).and_return(double("response", :body => @response)) 43 | @record.reload 44 | end 45 | end 46 | 47 | it "provides getters for common attributes" do 48 | @record.name.should == "Admin User" 49 | @record.id.should == "005x0000000JbFuAAK" 50 | @record.url.should == "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 51 | @record.type.should == "User" 52 | end 53 | end 54 | end 55 | 56 | -------------------------------------------------------------------------------- /spec/fixtures/chatter/users_followers_get_id_success_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "total": 1, 3 | "followers": [{ 4 | "id": "0E8x00000000aesCAA", 5 | "url": "/services/data/v23.0/chatter/subscriptions/0E8x00000000aesCAA", 6 | "subject": { 7 | "name": "Chatter Expert", 8 | "title": null, 9 | "firstName": null, 10 | "lastName": "Chatter Expert", 11 | "companyName": null, 12 | "mySubscription": { 13 | "id": "005x0000000JbGVAA0", 14 | "url": "/services/data/v23.0/chatter/subscriptions/0E8x00000000aesCAA" 15 | }, 16 | "photo": { 17 | "smallPhotoUrl": "/profilephoto/729x000000005Gw/T", 18 | "largePhotoUrl": "/profilephoto/729x000000005Gw/F" 19 | }, 20 | "isChatterGuest": false, 21 | "id": "005x0000000JbGVAA0", 22 | "type": "User", 23 | "url": "/services/data/v23.0/chatter/users/005x0000000JbGVAA0" 24 | }, 25 | "subscriber": { 26 | "name": "Admin User", 27 | "title": null, 28 | "firstName": "Admin", 29 | "lastName": "User", 30 | "companyName": "Pivotal", 31 | "mySubscription": null, 32 | "photo": { 33 | "smallPhotoUrl": "/profilephoto/005/T", 34 | "largePhotoUrl": "/profilephoto/005/F" 35 | }, 36 | "isChatterGuest": false, 37 | "id": "005x0000000JbFuAAK", 38 | "type": "User", 39 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 40 | } 41 | }], 42 | "previousPageUrl": null, 43 | "nextPageUrl": null, 44 | "currentPageUrl": "/services/data/v23.0/chatter/records/005x0000000JbGVAA0/followers", 45 | "mySubscription": { 46 | "id": "0E8x00000000aesCAA", 47 | "url": "/services/data/v23.0/chatter/subscriptions/0E8x00000000aesCAA" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /spec/lib/sales_force_error_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rspec' 2 | require 'spec_helper' 3 | require 'databasedotcom' 4 | 5 | describe Databasedotcom::SalesForceError do 6 | context "with a non-array body" do 7 | before do 8 | @response_body = File.read(File.join(File.dirname(__FILE__), "../fixtures/auth_error_response.json")) 9 | @response_json = JSON.parse(@response_body) 10 | @response = double("result", :body => @response_body) 11 | @exception = Databasedotcom::SalesForceError.new(@response) 12 | end 13 | 14 | describe "#message" do 15 | it "returns the message from the server response" do 16 | @exception.message.should == @response_json["error_description"] 17 | end 18 | end 19 | 20 | describe "#error_code" do 21 | it "returns the error code from the server response" do 22 | @exception.error_code.should == @response_json["error"] 23 | end 24 | end 25 | 26 | describe "#response" do 27 | it "returns the HTTPResponse" do 28 | @exception.response.should == @response 29 | end 30 | end 31 | end 32 | 33 | context "with a array body" do 34 | before do 35 | @response_body = File.read(File.join(File.dirname(__FILE__), "../fixtures/sobject/search_error_response.json")) 36 | @response_json = JSON.parse(@response_body) 37 | @response = double("result", :body => @response_body) 38 | @exception = Databasedotcom::SalesForceError.new(@response) 39 | end 40 | 41 | describe "#message" do 42 | it "returns the message from the server response" do 43 | @exception.message.should == @response_json[0]["message"] 44 | end 45 | end 46 | 47 | describe "#error_code" do 48 | it "returns the error code from the server response" do 49 | @exception.error_code.should == @response_json[0]["errorCode"] 50 | end 51 | end 52 | 53 | describe "#response" do 54 | it "returns the HTTPResponse" do 55 | @exception.response.should == @response 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/fixtures/chatter/users_batch_get_mixed_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "results": [{ 3 | "result": { 4 | "address": { 5 | "state": "CA", 6 | "country": "US", 7 | "street": "1 Market St", 8 | "city": "San Francisco", 9 | "zip": "94105" 10 | }, 11 | "email": "danny@pivotallabs.com", 12 | "currentStatus": { 13 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK/status", 14 | "body": null, 15 | "parentId": "005x0000000JbFuAAK" 16 | }, 17 | "managerId": null, 18 | "followingCounts": { 19 | "total": 1, 20 | "records": 1, 21 | "people": 0 22 | }, 23 | "groupCount": 1, 24 | "followersCount": 0, 25 | "managerName": null, 26 | "aboutMe": null, 27 | "isActive": true, 28 | "phoneNumbers": [], 29 | "chatterActivity": { 30 | "commentCount": 2, 31 | "commentReceivedCount": 0, 32 | "likeReceivedCount": 0, 33 | "postCount": 3 34 | }, 35 | "name": "Admin User", 36 | "title": null, 37 | "firstName": "Admin", 38 | "lastName": "User", 39 | "companyName": "Pivotal", 40 | "mySubscription": null, 41 | "photo": { 42 | "smallPhotoUrl": "/profilephoto/005/T", 43 | "largePhotoUrl": "/profilephoto/005/F" 44 | }, 45 | "isChatterGuest": false, 46 | "id": "005x0000000JbFuAAK", 47 | "type": "User", 48 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 49 | }, 50 | "statusCode": 200 51 | }, 52 | { 53 | "result": [{ 54 | "message": "The requested resource does not exist", 55 | "errorCode": "NOT_FOUND" 56 | }], 57 | "statusCode": 404 58 | }] 59 | } -------------------------------------------------------------------------------- /spec/lib/chatter/feed_item_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rspec' 2 | require 'spec_helper' 3 | require 'databasedotcom' 4 | 5 | describe Databasedotcom::Chatter::FeedItem do 6 | it_should_behave_like("a restful resource") 7 | 8 | context "with a FeedItem object" do 9 | before do 10 | @response = JSON.parse(File.read(File.join(File.dirname(__FILE__), "../../fixtures/chatter/feed-items_get_id_success_response.json"))) 11 | @client_mock = double("client", :version => "23") 12 | @record = Databasedotcom::Chatter::FeedItem.new(@client_mock, @response) 13 | end 14 | 15 | describe "#comments" do 16 | it "returns a collection of comment records" do 17 | @record.comments.should be_instance_of(Databasedotcom::Collection) 18 | end 19 | end 20 | 21 | describe "#likes" do 22 | it "returns a collection of like records" do 23 | @record.likes.should be_instance_of(Databasedotcom::Collection) 24 | end 25 | end 26 | 27 | describe "#like" do 28 | it "likes the feed item" do 29 | body = JSON.parse(File.read(File.join(File.dirname(__FILE__), "../../fixtures/chatter/likes_get_id_success_response.json"))) 30 | @response = double("response") 31 | @response.should_receive(:body).any_number_of_times.and_return(body) 32 | @client_mock.should_receive(:http_post).with("/services/data/v23/chatter/feed-items/#{@record.id}/likes").and_return(@response) 33 | like = @record.like 34 | like.should be_instance_of(Databasedotcom::Chatter::Like) 35 | end 36 | end 37 | 38 | describe "#comment" do 39 | it "comments on the feed item" do 40 | body = JSON.parse(File.read(File.join(File.dirname(__FILE__), "../../fixtures/chatter/comments_get_id_success_response.json"))) 41 | @response = double("response") 42 | @response.should_receive(:body).any_number_of_times.and_return(body) 43 | @client_mock.should_receive(:http_post).with("/services/data/v23/chatter/feed-items/#{@record.id}/comments", nil, :text => "whatever").and_return(@response) 44 | comment = @record.comment("whatever") 45 | comment.should be_instance_of(Databasedotcom::Chatter::Comment) 46 | end 47 | end 48 | end 49 | end -------------------------------------------------------------------------------- /spec/lib/chatter/feeds_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rspec' 2 | require 'spec_helper' 3 | require 'databasedotcom' 4 | 5 | Databasedotcom::Chatter::FEED_TYPES.each do |feed_type| 6 | describe "Databasedotcom::Chatter::#{feed_type}Feed" do 7 | before do 8 | @client_mock = double("client", :version => "23") 9 | end 10 | 11 | describe ".find" do 12 | before do 13 | expected_path = feed_type == "Company" ? "/services/data/v23/chatter/feeds/#{feed_type.resourcerize}/feed-items" : "/services/data/v23/chatter/feeds/#{feed_type.resourcerize}/fid/feed-items" 14 | @client_mock.should_receive(:http_get).with(expected_path).and_return(double "response", :body => {"items" => []}.to_json) 15 | end 16 | 17 | it "retrieves a #{feed_type}feed" do 18 | Databasedotcom::Chatter.const_get("#{feed_type}Feed").send(:find, @client_mock, "fid") 19 | end 20 | end 21 | 22 | describe ".post" do 23 | before do 24 | @client_mock.should_receive(:http_post).with("/services/data/v23/chatter/feeds/#{feed_type.resourcerize}/me/feed-items", nil, { :text => "text" }).and_return(double "response", :body => {"id" => "rid", "name" => "name", "type" => "posted_item", "url" => "some/url"}) 25 | end 26 | 27 | it "posts text passed as a parameter and returns a new FeedItem" do 28 | result = Databasedotcom::Chatter.const_get("#{feed_type}Feed").send(:post, @client_mock, "me", :text => "text") 29 | result.should be_instance_of(Databasedotcom::Chatter::FeedItem) 30 | end 31 | end 32 | 33 | describe ".post_file" do 34 | before do 35 | @client_mock.should_receive(:http_multipart_post).with("/services/data/v23/chatter/feeds/#{feed_type.resourcerize}/me/feed-items", an_instance_of(Hash), an_instance_of(Hash)).and_return(double("response", :body => {"id" => "rid", "name" => "name", "type" => "posted_item", "url" => "some/url"})) 36 | end 37 | 38 | it "posts the file and returns a new FeedItem" do 39 | result = Databasedotcom::Chatter.const_get("#{feed_type}Feed").send(:post_file, @client_mock, "me", StringIO.new("foo"), "text/plain", "filename.txt") 40 | result.should be_instance_of(Databasedotcom::Chatter::FeedItem) 41 | end 42 | end 43 | end 44 | end 45 | 46 | -------------------------------------------------------------------------------- /lib/databasedotcom/chatter/photo_methods.rb: -------------------------------------------------------------------------------- 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 -------------------------------------------------------------------------------- /spec/lib/chatter/message_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rspec' 2 | require 'spec_helper' 3 | require 'databasedotcom' 4 | 5 | describe Databasedotcom::Chatter::Message do 6 | it_should_behave_like("a restful resource") 7 | 8 | describe ".send_message" do 9 | before do 10 | @client_mock = double("client", :version => "23") 11 | @response = double("response", :body => File.read(File.join(File.dirname(__FILE__), "../../fixtures/chatter/messages_get_id_success_response.json"))) 12 | end 13 | 14 | it "sends a private message" do 15 | @client_mock.should_receive(:http_post).with("/services/data/v23/chatter/users/me/messages", nil, :recipients => "rid", :text => "text").and_return(@response) 16 | Databasedotcom::Chatter::Message.send_message(@client_mock, "rid", "text").should be_instance_of(Databasedotcom::Chatter::Message) 17 | end 18 | 19 | it "supports multiple recipients" do 20 | @client_mock.should_receive(:http_post).with("/services/data/v23/chatter/users/me/messages", nil, :recipients => "rid1,rid2", :text => "text").and_return(@response) 21 | Databasedotcom::Chatter::Message.send_message(@client_mock, %w(rid1 rid2), "text").should be_instance_of(Databasedotcom::Chatter::Message) 22 | end 23 | end 24 | 25 | describe ".reply" do 26 | before do 27 | @client_mock = double("client", :version => "23") 28 | @response = double("response", :body => File.read(File.join(File.dirname(__FILE__), "../../fixtures/chatter/messages_get_id_success_response.json"))) 29 | end 30 | 31 | it "replies to a private message" do 32 | @client_mock.should_receive(:http_post).with("/services/data/v23/chatter/users/me/messages", nil, :inReplyTo => "mid", :text => "text").and_return(@response) 33 | Databasedotcom::Chatter::Message.reply(@client_mock, "mid", "text").should be_instance_of(Databasedotcom::Chatter::Message) 34 | end 35 | end 36 | 37 | context "with an instantiated Message" do 38 | before do 39 | msg = File.read(File.join(File.dirname(__FILE__), "../../fixtures/chatter/messages_get_id_success_response.json")) 40 | @client_mock = double("client", :version => "23") 41 | @response = double("response") 42 | @message = Databasedotcom::Chatter::Message.new(@client_mock, msg) 43 | end 44 | 45 | describe "#reply" do 46 | it "replies to a message" do 47 | Databasedotcom::Chatter::Message.should_receive(:reply).with(@client_mock, @message.id, "text") 48 | @message.reply("text") 49 | end 50 | end 51 | end 52 | end -------------------------------------------------------------------------------- /spec/lib/chatter/group_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rspec' 2 | require 'spec_helper' 3 | require 'databasedotcom' 4 | 5 | describe Databasedotcom::Chatter::Group do 6 | it_should_behave_like("a restful resource") 7 | it_should_behave_like("a resource with a photo") 8 | 9 | describe ".members" do 10 | before do 11 | @client_mock = double("client", :version => "23") 12 | @response = double("response", :body => File.read(File.join(File.dirname(__FILE__), "../../fixtures/chatter/groups_members_get_id_success_response.json"))) 13 | end 14 | 15 | it "gets a Collection of GroupMemberships" do 16 | @client_mock.should_receive(:http_get).with("/services/data/v23/chatter/groups/gid/members").and_return(@response) 17 | Databasedotcom::Chatter::Group.members(@client_mock, "gid").should be_instance_of(Databasedotcom::Collection) 18 | end 19 | end 20 | 21 | describe ".join" do 22 | before do 23 | @client_mock = double("client", :version => "23") 24 | @response = double("response", :body => File.read(File.join(File.dirname(__FILE__), "../../fixtures/chatter/group-memberships_get_id_success_response.json"))) 25 | end 26 | 27 | it "joins the group" do 28 | @client_mock.should_receive(:http_post).with("/services/data/v23/chatter/groups/gid/members", nil, :userId => "me").and_return(@response) 29 | Databasedotcom::Chatter::Group.join(@client_mock, "gid", "me").should be_instance_of(Databasedotcom::Chatter::GroupMembership) 30 | end 31 | end 32 | 33 | context "with an instantiated Group" do 34 | before do 35 | grp = File.read(File.join(File.dirname(__FILE__), "../../fixtures/chatter/groups_get_id_success_response.json")) 36 | @client_mock = double("client", :version => "23") 37 | @response = double("response") 38 | @group = Databasedotcom::Chatter::Group.new(@client_mock, grp) 39 | end 40 | 41 | describe "#members!" do 42 | it "gets the members for the group" do 43 | Databasedotcom::Chatter::Group.should_receive(:members).with(@group.client, @group.id) 44 | @group.members! 45 | end 46 | end 47 | 48 | describe "#members" do 49 | it "gets the memoized members for the group" do 50 | Databasedotcom::Chatter::Group.should_receive(:members).with(@group.client, @group.id).once.and_return("foo") 51 | @group.members 52 | @group.members 53 | end 54 | end 55 | 56 | describe "#join" do 57 | it "joins the group" do 58 | Databasedotcom::Chatter::Group.should_receive(:join).with(@client_mock, @group.id, "me") 59 | @group.join 60 | end 61 | end 62 | end 63 | end -------------------------------------------------------------------------------- /spec/fixtures/chatter/conversations_get_success_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "nextPageUrl": null, 3 | "currentPageUrl": "/services/data/v23.0/chatter/users/me/conversations", 4 | "conversations": [{ 5 | "id": "03Mx0000000009XEAQ", 6 | "url": "/services/data/v23.0/chatter/users/me/conversations/03Mx0000000009XEAQ", 7 | "members": [{ 8 | "name": "Chatter Expert", 9 | "title": null, 10 | "firstName": null, 11 | "lastName": "Chatter Expert", 12 | "companyName": null, 13 | "mySubscription": null, 14 | "photo": { 15 | "smallPhotoUrl": "/profilephoto/729x000000005Gw/T", 16 | "largePhotoUrl": "/profilephoto/729x000000005Gw/F" 17 | }, 18 | "isChatterGuest": false, 19 | "id": "005x0000000JbGVAA0", 20 | "type": "User", 21 | "url": "/services/data/v23.0/chatter/users/005x0000000JbGVAA0" 22 | }, 23 | { 24 | "name": "Admin User", 25 | "title": null, 26 | "firstName": "Admin", 27 | "lastName": "User", 28 | "companyName": "Pivotal", 29 | "mySubscription": null, 30 | "photo": { 31 | "smallPhotoUrl": "/profilephoto/005/T", 32 | "largePhotoUrl": "/profilephoto/005/F" 33 | }, 34 | "isChatterGuest": false, 35 | "id": "005x0000000JbFuAAK", 36 | "type": "User", 37 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 38 | }], 39 | "read": false, 40 | "latestMessage": { 41 | "id": "03Jx000000000FYEAY", 42 | "sender": { 43 | "name": "Admin User", 44 | "title": null, 45 | "firstName": "Admin", 46 | "lastName": "User", 47 | "companyName": "Pivotal", 48 | "mySubscription": null, 49 | "photo": { 50 | "smallPhotoUrl": "/profilephoto/005/T", 51 | "largePhotoUrl": "/profilephoto/005/F" 52 | }, 53 | "isChatterGuest": false, 54 | "id": "005x0000000JbFuAAK", 55 | "type": "User", 56 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 57 | }, 58 | "url": "/services/data/v23.0/chatter/users/me/messages/03Jx000000000FYEAY", 59 | "body": { 60 | "text": "Yes", 61 | "messageSegments": [] 62 | }, 63 | "recipients": [], 64 | "sentDate": "2011-08-08T21:36:05.000Z", 65 | "conversationId": "03Mx0000000009XEAQ", 66 | "conversationUrl": "/services/data/v23.0/chatter/users/me/conversations/03Mx0000000009XEAQ" 67 | } 68 | }] 69 | } -------------------------------------------------------------------------------- /spec/lib/collection_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rspec' 2 | require 'spec_helper' 3 | require 'databasedotcom' 4 | 5 | describe Databasedotcom::Collection do 6 | before do 7 | @client_mock = double("client") 8 | @collection = Databasedotcom::Collection.new(@client_mock, 10, "http://next.page.url", "http://previous.page.url", "http://current.page.url") 9 | end 10 | 11 | it "is an Array" do 12 | @collection.should be_a_kind_of(Array) 13 | end 14 | 15 | it "has a total size" do 16 | @collection.total_size.should == 10 17 | end 18 | 19 | it "has a client" do 20 | @collection.client.should == @client_mock 21 | end 22 | 23 | it "has a current page url" do 24 | @collection.current_page_url.should == "http://current.page.url" 25 | end 26 | 27 | describe "#next_page?" do 28 | it "returns true if a next page url exists" do 29 | @collection.next_page?.should be_true 30 | end 31 | 32 | it "returns false if no next page url exists" do 33 | Databasedotcom::Collection.new(@client_mock, 10).next_page?.should be_false 34 | end 35 | end 36 | 37 | describe "#next_page" do 38 | context "when a next page url exists" do 39 | it "creates a new collection" do 40 | @client_mock.should_receive(:next_page).with(@collection.next_page_url).and_return("foo") 41 | new_collection = @collection.next_page 42 | new_collection.should == "foo" 43 | end 44 | end 45 | 46 | context "when no next page url exists" do 47 | it "returns an empty collection" do 48 | @collection.stub(:next_page_url).and_return(nil) 49 | @client_mock.should_not_receive(:next_page) 50 | new_collection = @collection.next_page 51 | new_collection.should be_an_instance_of(Databasedotcom::Collection) 52 | new_collection.length.should be_zero 53 | end 54 | end 55 | end 56 | 57 | describe "#previous_page?" do 58 | it "returns true if a previous page url exists" do 59 | @collection.previous_page?.should be_true 60 | end 61 | 62 | it "returns false if no previous page url exists" do 63 | Databasedotcom::Collection.new(@client_mock, 10).previous_page?.should be_false 64 | end 65 | end 66 | 67 | describe "#previous_page" do 68 | context "when a previous page url exists" do 69 | it "creates a new collection" do 70 | @client_mock.should_receive(:previous_page).with(@collection.previous_page_url).and_return("foo") 71 | new_collection = @collection.previous_page 72 | new_collection.should == "foo" 73 | end 74 | end 75 | 76 | context "when no previous page url exists" do 77 | it "returns an empty collection" do 78 | @collection.stub(:previous_page_url).and_return(nil) 79 | @client_mock.should_not_receive(:previous_page) 80 | new_collection = @collection.previous_page 81 | new_collection.should be_an_instance_of(Databasedotcom::Collection) 82 | new_collection.length.should be_zero 83 | end 84 | end 85 | end 86 | end -------------------------------------------------------------------------------- /spec/lib/shared_behaviors/resource_with_photo.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for("a resource with a photo") do 2 | describe ".photo" do 3 | before do 4 | @response = double("response", :body => File.read(File.join(File.dirname(__FILE__), "../../fixtures/chatter/#{described_class.resource_name}_photo_get_id_success_response.json"))) 5 | @client_mock = double("client", :version => "23") 6 | end 7 | 8 | it "returns a hash containing photo info" do 9 | @client_mock.should_receive(:http_get).with("/services/data/v23/chatter/#{described_class.resource_name}/foo/photo").and_return(@response) 10 | photo = described_class.photo(@client_mock, "foo") 11 | photo.should be_instance_of(Hash) 12 | end 13 | end 14 | 15 | describe ".upload_photo" do 16 | before do 17 | @response = double("response", :body => File.read(File.join(File.dirname(__FILE__), "../../fixtures/chatter/#{described_class.resource_name}_photo_get_id_success_response.json"))) 18 | @client_mock = double("client", :version => "23") 19 | end 20 | 21 | it "uploads the photo and returns a hash containing photo info" do 22 | @client_mock.should_receive(:http_multipart_post).with("/services/data/v23/chatter/#{described_class.resource_name}/foo/photo", an_instance_of(Hash)).and_return(@response) 23 | file_mock = double("file", :read => "foo", :path => "filename") 24 | photo = described_class.upload_photo(@client_mock, "foo", file_mock, "image/gif") 25 | photo.should be_instance_of(Hash) 26 | end 27 | end 28 | 29 | describe ".delete_photo" do 30 | before do 31 | @client_mock = double("client", :version => "23") 32 | end 33 | 34 | it "deletes the photo of the specified user" do 35 | @client_mock.should_receive(:http_delete).with("/services/data/v23/chatter/#{described_class.resource_name}/foo/photo").and_return(true) 36 | described_class.delete_photo(@client_mock, "foo") 37 | end 38 | end 39 | 40 | context "with an instantiated object" do 41 | before do 42 | @response = File.read(File.join(File.dirname(__FILE__), "../../fixtures/chatter/#{described_class.resource_name}_get_id_success_response.json")) 43 | @client_mock = double("client", :version => "23") 44 | @resource = described_class.new(@client_mock, @response) 45 | end 46 | 47 | describe "#photo" do 48 | it "returns a hash with the resource's photo" do 49 | @resource.photo.should == @resource.raw_hash["photo"] 50 | end 51 | end 52 | 53 | describe "#upload_photo" do 54 | it "uploads a photo for the resource" do 55 | described_class.should_receive(:upload_photo).with(@client_mock, @resource.id, "io", "image/gif") 56 | @resource.upload_photo("io", "image/gif") 57 | end 58 | end 59 | 60 | describe "#delete_photo" do 61 | it "deletes the resource's current photo" do 62 | @client_mock.should_receive(:http_delete).with("/services/data/v23/chatter/#{described_class.resource_name}/#{@resource.id}/photo").and_return(true) 63 | @resource.delete_photo 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/databasedotcom/chatter/feed.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | 3 | module Databasedotcom 4 | module Chatter 5 | # Parent class of all feeds and inherits from Collection. This class is not intended to be instantiated. Methods should be called on subclasses, which are all are dynamically defined (except for FilterFeed). Defined feeds are *NewsFeed*, *UserProfileFeed*, *RecordFeed*, *ToFeed*, *PeopleFeed*, *GroupsFeed*, *FilesFeed*, *CompanyFeed*, and *FilterFeed*. 6 | class Feed < Collection 7 | 8 | # Returns an enumerable Feed of FeedItem objects that make up the feed with the specified _id_. Should not be called as a class method on Feed, but as a method on subclasses. 9 | # 10 | # NewsFeed.find(@client) #=> [#, #, ...] 11 | # PeopleFeed.find(@client, "userid") #=> [#, #, ...] 12 | # FilterFeed.find(@client, "me", "000") #=> [#, #, ...] 13 | # 14 | # _id_prefix_ is only applicable for FilterFeed. 15 | def self.find(client, id="me", id_prefix=nil) 16 | path_components = %w(services data) 17 | path_components << "v#{client.version}" 18 | path_components.concat(%w(chatter feeds)) 19 | path_components << feed_type 20 | path_components << id unless feed_type == "company" 21 | path_components << id_prefix 22 | path_components << "feed-items" 23 | path = "/" + path_components.compact.join('/') 24 | result = client.http_get(path) 25 | response = JSON.parse(result.body) 26 | collection = self.new(client, nil, response["nextPageUrl"], response["previousPageUrl"], response["currentPageUrl"]) 27 | response["items"].each do |item| 28 | collection << FeedItem.new(client, item) 29 | end 30 | collection 31 | end 32 | 33 | # Posts a FeedItem to a Feed specified by _user_id_. Should not be called as a class method on Feed, but as a method on subclasses. 34 | # 35 | # UserProfileFeed.post(@client, "me", :text => "This is a status update about Salesforce.", :url => "http://www.salesforce.com") 36 | # 37 | # Returns the newly created FeedItem. 38 | def self.post(client, user_id, parameters) 39 | url = "/services/data/v#{client.version}/chatter/feeds/#{feed_type}/#{user_id}/feed-items" 40 | response = client.http_post(url, nil, parameters) 41 | Databasedotcom::Chatter::FeedItem.new(client, response.body) 42 | end 43 | 44 | # Posts a file to a Feed specified by _user_id_. Should not be called as a class method on Feed, but as a method on subclasses. 45 | # 46 | # UserProfileFeed.post_file(@client, "me", File.open("MyFile"), "text/plain", "MyFile", :desc => "This is an uploaded text file.") 47 | # 48 | # Returns the newly created FeedItem. 49 | def self.post_file(client, user_id, io, file_type, file_name, parameters={}) 50 | url = "/services/data/v#{client.version}/chatter/feeds/#{feed_type}/#{user_id}/feed-items" 51 | response = client.http_multipart_post(url, {"feedItemFileUpload" => UploadIO.new(io, file_type, file_name), "fileName" => file_name}, parameters) 52 | Databasedotcom::Chatter::FeedItem.new(client, response.body) 53 | end 54 | 55 | private 56 | 57 | def self.feed_type 58 | self.name.match(/.+::(.+)Feed$/)[1].resourcerize 59 | end 60 | end 61 | 62 | FEED_TYPES = %w(News UserProfile Record To People Groups Files Company) 63 | end 64 | end -------------------------------------------------------------------------------- /spec/fixtures/chatter/messages_get_success_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "messages": [{ 3 | "id": "03Jx000000000FYEAY", 4 | "sender": { 5 | "name": "Admin User", 6 | "title": null, 7 | "firstName": "Admin", 8 | "lastName": "User", 9 | "companyName": "Pivotal", 10 | "mySubscription": null, 11 | "photo": { 12 | "smallPhotoUrl": "/profilephoto/005/T", 13 | "largePhotoUrl": "/profilephoto/005/F" 14 | }, 15 | "isChatterGuest": false, 16 | "id": "005x0000000JbFuAAK", 17 | "type": "User", 18 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 19 | }, 20 | "url": "/services/data/v23.0/chatter/users/me/messages/03Jx000000000FYEAY", 21 | "body": { 22 | "text": "Yes", 23 | "messageSegments": [] 24 | }, 25 | "recipients": [{ 26 | "name": "Chatter Expert", 27 | "title": null, 28 | "firstName": null, 29 | "lastName": "Chatter Expert", 30 | "companyName": null, 31 | "mySubscription": null, 32 | "photo": { 33 | "smallPhotoUrl": "/profilephoto/729x000000005Gw/T", 34 | "largePhotoUrl": "/profilephoto/729x000000005Gw/F" 35 | }, 36 | "isChatterGuest": false, 37 | "id": "005x0000000JbGVAA0", 38 | "type": "User", 39 | "url": "/services/data/v23.0/chatter/users/005x0000000JbGVAA0" 40 | }], 41 | "sentDate": "2011-08-08T21:36:05.000Z", 42 | "conversationId": "03Mx0000000009XEAQ", 43 | "conversationUrl": "/services/data/v23.0/chatter/users/me/conversations/03Mx0000000009XEAQ" 44 | }, 45 | { 46 | "id": "03Jx000000000FTEAY", 47 | "sender": { 48 | "name": "Chatter Expert", 49 | "title": null, 50 | "firstName": null, 51 | "lastName": "Chatter Expert", 52 | "companyName": null, 53 | "mySubscription": null, 54 | "photo": { 55 | "smallPhotoUrl": "/profilephoto/729x000000005Gw/T", 56 | "largePhotoUrl": "/profilephoto/729x000000005Gw/F" 57 | }, 58 | "isChatterGuest": false, 59 | "id": "005x0000000JbGVAA0", 60 | "type": "User", 61 | "url": "/services/data/v23.0/chatter/users/005x0000000JbGVAA0" 62 | }, 63 | "url": "/services/data/v23.0/chatter/users/me/messages/03Jx000000000FTEAY", 64 | "body": { 65 | "text": "Did you know that you can now send private messages that don’t show up in your Chatter feed? You can keep a conversation private between you and the people you send a message to.\n\nMessages are a great way to:\n- Ask someone a question privately\n- Talk about confidential subjects like hiring or compensation\n- Work on a top-secret project with a few select people\n\nGo to My Messages and try sending a message now!\n\nP.S. This message was automatically generated. If you reply, Chatter Expert isn’t able to reply back.", 66 | "messageSegments": [] 67 | }, 68 | "recipients": [{ 69 | "name": "Admin User", 70 | "title": null, 71 | "firstName": "Admin", 72 | "lastName": "User", 73 | "companyName": "Pivotal", 74 | "mySubscription": null, 75 | "photo": { 76 | "smallPhotoUrl": "/profilephoto/005/T", 77 | "largePhotoUrl": "/profilephoto/005/F" 78 | }, 79 | "isChatterGuest": false, 80 | "id": "005x0000000JbFuAAK", 81 | "type": "User", 82 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 83 | }], 84 | "sentDate": "2011-08-08T21:20:18.000Z", 85 | "conversationId": "03Mx0000000009XEAQ", 86 | "conversationUrl": "/services/data/v23.0/chatter/users/me/conversations/03Mx0000000009XEAQ" 87 | }], 88 | "nextPageUrl": null, 89 | "currentPageUrl": "/services/data/v23.0/chatter/users/me/messages" 90 | } -------------------------------------------------------------------------------- /spec/fixtures/chatter/users_batch_get_success_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "results": [{ 3 | "result": { 4 | "address": { 5 | "state": "CA", 6 | "country": "US", 7 | "street": "1 Market St", 8 | "city": "San Francisco", 9 | "zip": "94105" 10 | }, 11 | "email": "danny@pivotallabs.com", 12 | "currentStatus": { 13 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK/status", 14 | "body": null, 15 | "parentId": "005x0000000JbFuAAK" 16 | }, 17 | "managerId": null, 18 | "followingCounts": { 19 | "total": 1, 20 | "records": 1, 21 | "people": 0 22 | }, 23 | "groupCount": 1, 24 | "followersCount": 0, 25 | "managerName": null, 26 | "aboutMe": null, 27 | "isActive": true, 28 | "phoneNumbers": [], 29 | "chatterActivity": { 30 | "commentCount": 2, 31 | "commentReceivedCount": 0, 32 | "likeReceivedCount": 0, 33 | "postCount": 3 34 | }, 35 | "name": "Admin User", 36 | "title": null, 37 | "firstName": "Admin", 38 | "lastName": "User", 39 | "companyName": "Pivotal", 40 | "mySubscription": null, 41 | "photo": { 42 | "smallPhotoUrl": "/profilephoto/005/T", 43 | "largePhotoUrl": "/profilephoto/005/F" 44 | }, 45 | "isChatterGuest": false, 46 | "id": "005x0000000JbFuAAK", 47 | "type": "User", 48 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 49 | }, 50 | "statusCode": 200 51 | }, 52 | { 53 | "result": { 54 | "address": { 55 | "state": "CA", 56 | "country": "USA", 57 | "street": "Landmark @ One Market", 58 | "city": "San Francisco", 59 | "zip": "94105" 60 | }, 61 | "email": "noreply@chatter.salesforce.com", 62 | "currentStatus": { 63 | "url": "/services/data/v23.0/chatter/users/005x0000000JbGVAA0/status", 64 | "body": null, 65 | "parentId": "005x0000000JbGVAA0" 66 | }, 67 | "managerId": null, 68 | "followingCounts": { 69 | "total": 0, 70 | "records": 0, 71 | "people": 0 72 | }, 73 | "groupCount": 0, 74 | "followersCount": 0, 75 | "managerName": null, 76 | "aboutMe": "I’m here to help you get started with Chatter. I’ll introduce you to features, create sample posts, and suggest tips and best practices.\n\nI'm an automated user so you don't need to worry about privacy! I can't see any of your posts or files.\n\nIf I'm too noisy, have your admin or moderator deactivate me and I'll stop posting.", 77 | "isActive": true, 78 | "phoneNumbers": [], 79 | "chatterActivity": { 80 | "commentCount": 0, 81 | "commentReceivedCount": 0, 82 | "likeReceivedCount": 0, 83 | "postCount": 0 84 | }, 85 | "name": "Chatter Expert", 86 | "title": null, 87 | "firstName": null, 88 | "lastName": "Chatter Expert", 89 | "companyName": null, 90 | "mySubscription": null, 91 | "photo": { 92 | "smallPhotoUrl": "/profilephoto/729x000000005Gw/T", 93 | "largePhotoUrl": "/profilephoto/729x000000005Gw/F" 94 | }, 95 | "isChatterGuest": false, 96 | "id": "005x0000000JbGVAA0", 97 | "type": "User", 98 | "url": "/services/data/v23.0/chatter/users/005x0000000JbGVAA0" 99 | }, 100 | "statusCode": 200 101 | }] 102 | } -------------------------------------------------------------------------------- /lib/databasedotcom/chatter/conversation.rb: -------------------------------------------------------------------------------- 1 | require 'databasedotcom/chatter/record' 2 | 3 | module Databasedotcom 4 | module Chatter 5 | # A thread of private messages. When calling +Conversation.find+ or +Conversation.all+, you must pass +:user_id => + in the _parameters_ 6 | # 7 | # Conversation.all(@client, :user_id => "me") 8 | # Conversation.find(@client, "conversationId", :user_id => "f80ad89f9d98d89dfd89") 9 | class Conversation < Record 10 | 11 | # Creates a new Conversation and sets its +id+ and +url+ to values obtained from the server response. 12 | def initialize(client, response) 13 | super 14 | @id ||= @raw_hash["conversationId"] 15 | @url ||= @raw_hash["conversationUrl"] 16 | end 17 | 18 | # Find the Conversation identified by _cid_ and archive it. Returns the updated Conversation. 19 | # 20 | # Conversation.archive(@client, "fakeid") 21 | def self.archive(client, cid) 22 | url = "/services/data/v#{client.version}/chatter/users/me/conversations/#{cid}" 23 | response = client.http_patch(url, nil, :archived => "true") 24 | Conversation.new(client, response.body) 25 | end 26 | 27 | # Find the Conversation identified by _cid_ and unarchive it. Returns the updated Conversation. 28 | # 29 | # Conversation.unarchive(@client, "fakeid") 30 | def self.unarchive(client, cid) 31 | url = "/services/data/v#{client.version}/chatter/users/me/conversations/#{cid}" 32 | response = client.http_patch(url, nil, :archived => "false") 33 | Conversation.new(client, response.body) 34 | end 35 | 36 | # Find the Conversation identified by _cid_ and mark it as read. Returns the updated Conversation. 37 | # 38 | # Conversation.mark_read(@client, "fakeid") 39 | def self.mark_read(client, cid) 40 | url = "/services/data/v#{client.version}/chatter/users/me/conversations/#{cid}" 41 | response = client.http_patch(url, nil, :read => "true") 42 | Conversation.new(client, response.body) 43 | end 44 | 45 | # Find the Conversation identified by _cid_ and mark it as unread. Returns the updated Conversation. 46 | # 47 | # Conversation.mark_unread(@client, "fakeid") 48 | def self.mark_unread(client, cid) 49 | url = "/services/data/v#{client.version}/chatter/users/me/conversations/#{cid}" 50 | response = client.http_patch(url, nil, :read => "false") 51 | Conversation.new(client, response.body) 52 | end 53 | 54 | # Gets all messages for the Conversation specified by _cid_ and the User specified by _uid_. Returns a Collection of Message objects. 55 | def self.messages(client, uid, cid) 56 | conversation = self.find(client, cid, :user_id => uid) 57 | collection = Databasedotcom::Collection.new(client, nil, conversation.raw_hash["messages"]["nextPageUrl"], conversation.raw_hash["messages"]["previousPageUrl"], conversation.raw_hash["messages"]["currentPageUrl"]) 58 | conversation.raw_hash["messages"]["messages"].each do |item| 59 | collection << Message.new(client, item) 60 | end 61 | collection 62 | end 63 | 64 | # Archive this Conversation. 65 | def archive 66 | self.class.archive(self.client, self.id) 67 | end 68 | 69 | # Unarchive this Conversation. 70 | def unarchive 71 | self.class.unarchive(self.client, self.id) 72 | end 73 | 74 | # Mark this Conversation as read. 75 | def mark_read 76 | self.class.mark_read(self.client, self.id) 77 | end 78 | 79 | # Mark this Conversation as unread. 80 | def mark_unread 81 | self.class.mark_unread(self.client, self.id) 82 | end 83 | 84 | # Return a Collection of messages from this Conversation. 85 | def messages 86 | collection = Databasedotcom::Collection.new(client, nil, self.raw_hash["messages"]["nextPageUrl"], self.raw_hash["messages"]["previousPageUrl"], self.raw_hash["messages"]["currentPageUrl"]) 87 | self.raw_hash["messages"]["messages"].each do |item| 88 | collection << Message.new(client, item) 89 | end 90 | collection 91 | end 92 | 93 | protected 94 | 95 | def self.search_parameter_name 96 | :Q 97 | end 98 | end 99 | end 100 | end -------------------------------------------------------------------------------- /spec/fixtures/chatter/conversations_get_id_success_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "messages": { 3 | "messages": [{ 4 | "id": "03Jx000000000FYEAY", 5 | "sender": { 6 | "name": "Admin User", 7 | "title": null, 8 | "firstName": "Admin", 9 | "lastName": "User", 10 | "companyName": "Pivotal", 11 | "mySubscription": null, 12 | "photo": { 13 | "smallPhotoUrl": "/profilephoto/005/T", 14 | "largePhotoUrl": "/profilephoto/005/F" 15 | }, 16 | "isChatterGuest": false, 17 | "id": "005x0000000JbFuAAK", 18 | "type": "User", 19 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 20 | }, 21 | "url": "/services/data/v23.0/chatter/users/me/messages/03Jx000000000FYEAY", 22 | "body": { 23 | "text": "Yes", 24 | "messageSegments": [] 25 | }, 26 | "recipients": [], 27 | "sentDate": "2011-08-08T21:36:05.000Z", 28 | "conversationId": "03Mx0000000009XEAQ", 29 | "conversationUrl": "/services/data/v23.0/chatter/users/me/conversations/03Mx0000000009XEAQ" 30 | }, 31 | { 32 | "id": "03Jx000000000FTEAY", 33 | "sender": { 34 | "name": "Chatter Expert", 35 | "title": null, 36 | "firstName": null, 37 | "lastName": "Chatter Expert", 38 | "companyName": null, 39 | "mySubscription": null, 40 | "photo": { 41 | "smallPhotoUrl": "/profilephoto/729x000000005Gw/T", 42 | "largePhotoUrl": "/profilephoto/729x000000005Gw/F" 43 | }, 44 | "isChatterGuest": false, 45 | "id": "005x0000000JbGVAA0", 46 | "type": "User", 47 | "url": "/services/data/v23.0/chatter/users/005x0000000JbGVAA0" 48 | }, 49 | "url": "/services/data/v23.0/chatter/users/me/messages/03Jx000000000FTEAY", 50 | "body": { 51 | "text": "Did you know that you can now send private messages that don’t show up in your Chatter feed? You can keep a conversation private between you and the people you send a message to.\n\nMessages are a great way to:\n- Ask someone a question privately\n- Talk about confidential subjects like hiring or compensation\n- Work on a top-secret project with a few select people\n\nGo to My Messages and try sending a message now!\n\nP.S. This message was automatically generated. If you reply, Chatter Expert isn’t able to reply back.", 52 | "messageSegments": [] 53 | }, 54 | "recipients": [], 55 | "sentDate": "2011-08-08T21:20:18.000Z", 56 | "conversationId": "03Mx0000000009XEAQ", 57 | "conversationUrl": "/services/data/v23.0/chatter/users/me/conversations/03Mx0000000009XEAQ" 58 | }], 59 | "nextPageUrl": null, 60 | "currentPageUrl": "/services/data/v23.0/chatter/users/me/conversations/03Mx0000000009XEAQ" 61 | }, 62 | "members": [{ 63 | "name": "Chatter Expert", 64 | "title": null, 65 | "firstName": null, 66 | "lastName": "Chatter Expert", 67 | "companyName": null, 68 | "mySubscription": null, 69 | "photo": { 70 | "smallPhotoUrl": "/profilephoto/729x000000005Gw/T", 71 | "largePhotoUrl": "/profilephoto/729x000000005Gw/F" 72 | }, 73 | "isChatterGuest": false, 74 | "id": "005x0000000JbGVAA0", 75 | "type": "User", 76 | "url": "/services/data/v23.0/chatter/users/005x0000000JbGVAA0" 77 | }, 78 | { 79 | "name": "Admin User", 80 | "title": null, 81 | "firstName": "Admin", 82 | "lastName": "User", 83 | "companyName": "Pivotal", 84 | "mySubscription": null, 85 | "photo": { 86 | "smallPhotoUrl": "/profilephoto/005/T", 87 | "largePhotoUrl": "/profilephoto/005/F" 88 | }, 89 | "isChatterGuest": false, 90 | "id": "005x0000000JbFuAAK", 91 | "type": "User", 92 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 93 | }], 94 | "read": false, 95 | "conversationId": "03Mx0000000009XEAQ", 96 | "conversationUrl": "/services/data/v23.0/chatter/users/me/conversations/03Mx0000000009XEAQ" 97 | } 98 | -------------------------------------------------------------------------------- /spec/lib/shared_behaviors/token_refreshing_request.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for("a request that can refresh the oauth token") do |request_method, request_method_name, request_url, success_status_code| 2 | describe "when receiving a 401 response" do 3 | before do 4 | stub_request(request_method, request_url).to_return(:body => "", :status => 401).then.to_return(:body => "", :status => success_status_code) 5 | end 6 | 7 | context "with a refresh token" do 8 | before do 9 | @client.refresh_token = "refresh" 10 | end 11 | 12 | after do 13 | @client.refresh_token = nil 14 | end 15 | 16 | context "when the refresh token flow succeeds" do 17 | before do 18 | response_body = File.read(File.join(File.dirname(__FILE__), "../../fixtures/refresh_success_response.json")) 19 | stub_request(:post, "https://bro.baz/services/oauth2/token?client_id=client_id&client_secret=client_secret&grant_type=refresh_token&refresh_token=refresh").to_return(:body => response_body, :status => 200) 20 | end 21 | 22 | it "stores the new access token" do 23 | @client.send("http_#{request_method_name}", URI.parse(request_url).path, {}) 24 | @client.oauth_token.should == "refreshed_access_token" 25 | end 26 | 27 | it "retries the request" do 28 | @client.send("http_#{request_method_name}", URI.parse(request_url).path, {}) 29 | WebMock.should have_requested(request_method, request_url).twice 30 | end 31 | end 32 | 33 | context "when the refresh token flow fails" do 34 | before do 35 | response_body = File.read(File.join(File.dirname(__FILE__), "../../fixtures/refresh_error_response.json")) 36 | stub_request(:post, "https://bro.baz/services/oauth2/token?client_id=client_id&client_secret=client_secret&grant_type=refresh_token&refresh_token=refresh").to_return(:body => response_body, :status => 400) 37 | end 38 | 39 | it "raises SalesForceError" do 40 | lambda { 41 | @client.send("http_#{request_method_name}", URI.parse(request_url).path, {}) 42 | }.should raise_error(Databasedotcom::SalesForceError) 43 | end 44 | end 45 | end 46 | 47 | context "with a username and password" do 48 | before do 49 | @client.username = "username" 50 | @client.password = "password" 51 | end 52 | 53 | after do 54 | @client.username = @client.password = nil 55 | end 56 | 57 | context "when reauthentication succeeds" do 58 | before do 59 | response_body = File.read(File.join(File.dirname(__FILE__), "../../fixtures/reauth_success_response.json")) 60 | stub_request(:post, "https://bro.baz/services/oauth2/token?client_id=client_id&client_secret=client_secret&grant_type=password&username=username&password=password").to_return(:body => response_body, :status => 200) 61 | end 62 | 63 | it "stores the new access token" do 64 | @client.send("http_#{request_method_name}", URI.parse(request_url).path, {}) 65 | @client.oauth_token.should == "new_access_token" 66 | end 67 | 68 | it "retries the request" do 69 | @client.send("http_#{request_method_name}", URI.parse(request_url).path, {}) 70 | WebMock.should have_requested(request_method, request_url).twice 71 | end 72 | end 73 | 74 | context "when reauthentication fails" do 75 | before do 76 | response_body = File.read(File.join(File.dirname(__FILE__), "../../fixtures/auth_error_response.json")) 77 | stub_request(:post, "https://bro.baz/services/oauth2/token?client_id=client_id&client_secret=client_secret&grant_type=password&username=username&password=password").to_return(:body => response_body, :status => 400) 78 | end 79 | 80 | it "raises SalesForceError" do 81 | lambda { 82 | @client.send("http_#{request_method_name}", URI.parse(request_url).path, {}) 83 | }.should raise_error(Databasedotcom::SalesForceError) 84 | end 85 | end 86 | end 87 | 88 | context "without a refresh token or username/password" do 89 | it "raises SalesForceError" do 90 | lambda { 91 | @client.send("http_#{request_method_name}", URI.parse(request_url).path, {}) 92 | }.should raise_error(Databasedotcom::SalesForceError) 93 | end 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/databasedotcom/chatter/record.rb: -------------------------------------------------------------------------------- 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 -------------------------------------------------------------------------------- /spec/fixtures/chatter/feed-items_get_id_success_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "parent": { 3 | "name": "Admin User", 4 | "title": null, 5 | "firstName": "Admin", 6 | "lastName": "User", 7 | "companyName": "Pivotal", 8 | "photo": { 9 | "smallPhotoUrl": "/profilephoto/005/T", 10 | "largePhotoUrl": "/profilephoto/005/F" 11 | }, 12 | "mySubscription": null, 13 | "isChatterGuest": false, 14 | "id": "005x0000000JbFuAAK", 15 | "type": "User", 16 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 17 | }, 18 | "id": "0D5x00000001g6tCAA", 19 | "type": "UserStatus", 20 | "clientInfo": { 21 | "applicationName": "localhost", 22 | "applicationUrl": "null" 23 | }, 24 | "url": "/services/data/v23.0/chatter/feed-items/0D5x00000001g6tCAA", 25 | "body": { 26 | "text": "I love #frogs", 27 | "messageSegments": [{ 28 | "type": "Text", 29 | "text": "I love " 30 | }, 31 | { 32 | "url": "/services/data/v23.0/chatter/feed-items?q=%23frogs", 33 | "tag": "frogs", 34 | "type": "Hashtag", 35 | "text": "#frogs" 36 | }] 37 | }, 38 | "createdDate": "2011-08-08T21:37:08.000Z", 39 | "modifiedDate": "2011-08-08T21:39:23.000Z", 40 | "photoUrl": "/profilephoto/005/T", 41 | "comments": { 42 | "total": 1, 43 | "comments": [{ 44 | "parent": { 45 | "id": "005x0000000JbFuAAK", 46 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 47 | }, 48 | "id": "0D7x000000005dRCAQ", 49 | "user": { 50 | "name": "Admin User", 51 | "title": null, 52 | "firstName": "Admin", 53 | "lastName": "User", 54 | "companyName": "Pivotal", 55 | "photo": { 56 | "smallPhotoUrl": "/profilephoto/005/T", 57 | "largePhotoUrl": "/profilephoto/005/F" 58 | }, 59 | "mySubscription": null, 60 | "isChatterGuest": false, 61 | "id": "005x0000000JbFuAAK", 62 | "type": "User", 63 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 64 | }, 65 | "clientInfo": { 66 | "applicationName": "localhost", 67 | "applicationUrl": "null" 68 | }, 69 | "url": "/services/data/v23.0/chatter/comments/0D7x000000005dRCAQ", 70 | "body": { 71 | "text": "So do I like #frogs", 72 | "messageSegments": [{ 73 | "type": "Text", 74 | "text": "So do I like " 75 | }, 76 | { 77 | "url": "/services/data/v23.0/chatter/feed-items?q=%23frogs", 78 | "tag": "frogs", 79 | "type": "Hashtag", 80 | "text": "#frogs" 81 | }] 82 | }, 83 | "createdDate": "2011-08-08T21:39:23.000+0000", 84 | "deletable": true, 85 | "feedItem": { 86 | "id": "0D5x00000001g6tCAA", 87 | "url": "/services/data/v23.0/chatter/feed-items/0D5x00000001g6tCAA" 88 | } 89 | }], 90 | "nextPageUrl": null, 91 | "currentPageUrl": "/services/data/v23.0/chatter/feed-items/0D5x00000001g6tCAA/comments" 92 | }, 93 | "likes": { 94 | "total": 1, 95 | "likes": [{ 96 | "id": "0I0x000000001XiCAI", 97 | "user": { 98 | "name": "Admin User", 99 | "title": null, 100 | "firstName": "Admin", 101 | "lastName": "User", 102 | "companyName": "Pivotal", 103 | "photo": { 104 | "smallPhotoUrl": "/profilephoto/005/T", 105 | "largePhotoUrl": "/profilephoto/005/F" 106 | }, 107 | "mySubscription": null, 108 | "isChatterGuest": false, 109 | "id": "005x0000000JbFuAAK", 110 | "type": "User", 111 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 112 | }, 113 | "url": "/services/data/v23.0/chatter/likes/0I0x000000001XiCAI" 114 | }], 115 | "previousPageUrl": null, 116 | "nextPageUrl": null, 117 | "currentPageUrl": "/services/data/v23.0/chatter/feed-items/0D5x00000001g6tCAA/likes", 118 | "myLike": { 119 | "id": "0I0x000000001XiCAI", 120 | "url": "/services/data/v23.0/chatter/likes/0I0x000000001XiCAI" 121 | } 122 | }, 123 | "isLikedByCurrentUser": true, 124 | "currentUserLike": { 125 | "id": "0I0x000000001XiCAI", 126 | "url": "/services/data/v23.0/chatter/likes/0I0x000000001XiCAI" 127 | }, 128 | "actor": { 129 | "name": "Admin User", 130 | "title": null, 131 | "firstName": "Admin", 132 | "lastName": "User", 133 | "companyName": "Pivotal", 134 | "photo": { 135 | "smallPhotoUrl": "/profilephoto/005/T", 136 | "largePhotoUrl": "/profilephoto/005/F" 137 | }, 138 | "mySubscription": null, 139 | "isChatterGuest": false, 140 | "id": "005x0000000JbFuAAK", 141 | "type": "User", 142 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 143 | }, 144 | "event": false, 145 | "attachment": null 146 | } -------------------------------------------------------------------------------- /spec/lib/chatter/conversation_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rspec' 2 | require 'spec_helper' 3 | require 'databasedotcom' 4 | 5 | describe Databasedotcom::Chatter::Conversation do 6 | it_should_behave_like("a restful resource") 7 | 8 | describe ".archive" do 9 | before do 10 | @client_mock = double("client", :version => 23) 11 | end 12 | 13 | it "archives the conversation" do 14 | response = double("response", :body => File.read(File.join(File.dirname(__FILE__), "../../fixtures/chatter/conversations_get_id_success_response.json"))) 15 | @client_mock.should_receive(:http_patch).with("/services/data/v23/chatter/users/me/conversations/cid", nil, :archived => "true").and_return(response) 16 | Databasedotcom::Chatter::Conversation.archive(@client_mock, "cid").should be_instance_of(Databasedotcom::Chatter::Conversation) 17 | end 18 | end 19 | 20 | describe ".unarchive" do 21 | before do 22 | @client_mock = double("client", :version => 23) 23 | end 24 | 25 | it "unarchives the conversation" do 26 | response = double("response", :body => File.read(File.join(File.dirname(__FILE__), "../../fixtures/chatter/conversations_get_id_success_response.json"))) 27 | @client_mock.should_receive(:http_patch).with("/services/data/v23/chatter/users/me/conversations/cid", nil, :archived => "false").and_return(response) 28 | Databasedotcom::Chatter::Conversation.unarchive(@client_mock, "cid").should be_instance_of(Databasedotcom::Chatter::Conversation) 29 | end 30 | end 31 | 32 | describe ".mark_read" do 33 | before do 34 | @client_mock = double("client", :version => 23) 35 | end 36 | 37 | it "marks the conversation as read" do 38 | response = double("response", :body => File.read(File.join(File.dirname(__FILE__), "../../fixtures/chatter/conversations_get_id_success_response.json"))) 39 | @client_mock.should_receive(:http_patch).with("/services/data/v23/chatter/users/me/conversations/cid", nil, :read => "true").and_return(response) 40 | Databasedotcom::Chatter::Conversation.mark_read(@client_mock, "cid").should be_instance_of(Databasedotcom::Chatter::Conversation) 41 | end 42 | end 43 | 44 | describe ".mark_unread" do 45 | before do 46 | @client_mock = double("client", :version => 23) 47 | end 48 | 49 | it "marks the conversation as unread" do 50 | response = double("response", :body => File.read(File.join(File.dirname(__FILE__), "../../fixtures/chatter/conversations_get_id_success_response.json"))) 51 | @client_mock.should_receive(:http_patch).with("/services/data/v23/chatter/users/me/conversations/cid", nil, :read => "false").and_return(response) 52 | Databasedotcom::Chatter::Conversation.mark_unread(@client_mock, "cid").should be_instance_of(Databasedotcom::Chatter::Conversation) 53 | end 54 | end 55 | 56 | describe ".messages" do 57 | before do 58 | @client_mock = double("client", :version => 23) 59 | end 60 | 61 | it "gets the messages for the conversation" do 62 | response = double("response", :body => File.read(File.join(File.dirname(__FILE__), "../../fixtures/chatter/conversations_get_id_success_response.json"))) 63 | @client_mock.should_receive(:http_get).with("/services/data/v23/chatter/users/uid/conversations/cid", an_instance_of(Hash)).and_return(response) 64 | messages = Databasedotcom::Chatter::Conversation.messages(@client_mock, "uid", "cid") 65 | messages.should be_instance_of(Databasedotcom::Collection) 66 | messages.each do |message| 67 | message.should be_instance_of(Databasedotcom::Chatter::Message) 68 | end 69 | end 70 | end 71 | 72 | context "with a Conversation object" do 73 | before do 74 | @client_mock = double("client", :version => 23) 75 | @conversation = Databasedotcom::Chatter::Conversation.new(@client_mock, File.read(File.join(File.dirname(__FILE__), "../../fixtures/chatter/conversations_get_id_success_response.json"))) 76 | end 77 | 78 | describe "#initialize" do 79 | it "initializes id and url" do 80 | @conversation.id.should == @conversation.raw_hash["conversationId"] 81 | @conversation.url.should == @conversation.raw_hash["conversationUrl"] 82 | end 83 | end 84 | 85 | describe "#archive" do 86 | it "archives the conversation" do 87 | Databasedotcom::Chatter::Conversation.should_receive(:archive).with(@client_mock, @conversation.id) 88 | @conversation.archive 89 | end 90 | end 91 | 92 | describe "#unarchive" do 93 | it "unarchives the conversation" do 94 | Databasedotcom::Chatter::Conversation.should_receive(:unarchive).with(@client_mock, @conversation.id) 95 | @conversation.unarchive 96 | end 97 | end 98 | 99 | describe "#mark_read" do 100 | it "marks the conversation as read" do 101 | Databasedotcom::Chatter::Conversation.should_receive(:mark_read).with(@client_mock, @conversation.id) 102 | @conversation.mark_read 103 | end 104 | end 105 | 106 | describe "#mark_unread" do 107 | it "marks the conversation unread" do 108 | Databasedotcom::Chatter::Conversation.should_receive(:mark_unread).with(@client_mock, @conversation.id) 109 | @conversation.mark_unread 110 | end 111 | end 112 | 113 | describe "#messages" do 114 | it "gets the messages for the conversation" do 115 | messages = @conversation.messages 116 | messages.should be_an_instance_of(Databasedotcom::Collection) 117 | messages.each do |message| 118 | message.should be_an_instance_of(Databasedotcom::Chatter::Message) 119 | end 120 | end 121 | end 122 | end 123 | end -------------------------------------------------------------------------------- /spec/lib/shared_behaviors/restful_resource.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for("a restful resource") do 2 | describe "#initialize" do 3 | describe "from a JSON response" do 4 | before do 5 | @response = File.read(File.join(File.dirname(__FILE__), "../../fixtures/chatter/#{described_class.resource_name}_get_id_success_response.json")) 6 | @client_mock = double("client") 7 | @record = described_class.new(@client_mock, @response) 8 | end 9 | 10 | it "is a Record" do 11 | @record.should be_a_kind_of(Databasedotcom::Chatter::Record) 12 | end 13 | end 14 | end 15 | 16 | describe ".find" do 17 | context "with a single id" do 18 | before do 19 | body = File.read(File.join(File.dirname(__FILE__), "../../fixtures/chatter/#{described_class.resource_name}_get_id_success_response.json")) 20 | @client_mock = double("client", :version => "23") 21 | @response = double("response") 22 | @response.should_receive(:body).any_number_of_times.and_return(body) 23 | end 24 | 25 | it "gets a resource by id" do 26 | @client_mock.should_receive(:http_get).with("/services/data/v23/chatter/#{described_class.resource_name}/rid", {}).and_return(@response) 27 | resource = described_class.find(@client_mock, "rid") 28 | resource.should be_instance_of(described_class) 29 | end 30 | 31 | it "passes through parameters" do 32 | @client_mock.should_receive(:http_get).with("/services/data/v23/chatter/#{described_class.resource_name}/rid", {:page => "foo", :pageSize => 10}).and_return(@response) 33 | described_class.find(@client_mock, "rid", :page => "foo", :pageSize => 10) 34 | end 35 | 36 | context "with a parent user id" do 37 | it "constructs the path appropriately" do 38 | @client_mock.should_receive(:http_get).with("/services/data/v23/chatter/users/me/#{described_class.resource_name}/rid", {:page => "foo", :pageSize => 10}).and_return(@response) 39 | described_class.find(@client_mock, "rid", :page => "foo", :pageSize => 10, :user_id => "me") 40 | end 41 | end 42 | end 43 | 44 | if File.exists?(File.join(File.dirname(__FILE__), "../../fixtures/chatter/#{described_class.resource_name}_batch_get_success_response.json")) 45 | context "with an array of ids" do 46 | context "when all the ids are valid" do 47 | before do 48 | body = File.read(File.join(File.dirname(__FILE__), "../../fixtures/chatter/#{described_class.resource_name}_batch_get_success_response.json")) 49 | @client_mock = double("client", :version => "23") 50 | @response = double("response") 51 | @response.should_receive(:body).any_number_of_times.and_return(body) 52 | end 53 | 54 | it "gets all the resources" do 55 | @client_mock.should_receive(:http_get).with("/services/data/v23/chatter/#{described_class.resource_name}/batch/fid,gid", {}).and_return(@response) 56 | resources = described_class.find(@client_mock, %w(fid gid)) 57 | resources.should be_instance_of(Databasedotcom::Collection) 58 | resources.each do |resource| 59 | resource.should be_instance_of(described_class) 60 | end 61 | end 62 | end 63 | 64 | context "when some of the ids are not valid" do 65 | before do 66 | body = File.read(File.join(File.dirname(__FILE__), "../../fixtures/chatter/#{described_class.resource_name}_batch_get_mixed_response.json")) 67 | @client_mock = double("client", :version => "23") 68 | @response = double("response") 69 | @response.should_receive(:body).any_number_of_times.and_return(body) 70 | end 71 | 72 | it "gets all the users" do 73 | @client_mock.should_receive(:http_get).with("/services/data/v23/chatter/#{described_class.resource_name}/batch/fid,gid", {}).and_return(@response) 74 | resources = described_class.find(@client_mock, %w(fid gid)) 75 | resources.should be_instance_of(Databasedotcom::Collection) 76 | resources.length.should == 1 77 | end 78 | end 79 | end 80 | end 81 | end 82 | 83 | if File.exists?(File.join(File.dirname(__FILE__), "../../fixtures/chatter/#{described_class.resource_name}_get_success_response.json")) 84 | describe ".all" do 85 | before do 86 | body = File.read(File.join(File.dirname(__FILE__), "../../fixtures/chatter/#{described_class.resource_name}_get_success_response.json")) 87 | @client_mock = double("client", :version => "23") 88 | @response = double("response") 89 | @response.should_receive(:body).any_number_of_times.and_return(body) 90 | end 91 | 92 | it "gets a collection of resources" do 93 | @client_mock.should_receive(:http_get).with("/services/data/v23/chatter/#{described_class.resource_name}", {}).and_return(@response) 94 | resources = described_class.all(@client_mock) 95 | resources.should be_instance_of(Databasedotcom::Collection) 96 | resources.each do |resource| 97 | resource.should be_instance_of(described_class) 98 | end 99 | end 100 | 101 | it "passes through parameters" do 102 | @client_mock.should_receive(:http_get).with("/services/data/v23/chatter/#{described_class.resource_name}", {:page => "foo", :pageSize => 10}).and_return(@response) 103 | described_class.all(@client_mock, :page => "foo", :pageSize => 10) 104 | end 105 | 106 | context "with a parent user id" do 107 | it "constructs the path appropriately" do 108 | @client_mock.should_receive(:http_get).with("/services/data/v23/chatter/users/me/#{described_class.resource_name}", {:page => "foo", :pageSize => 10}).and_return(@response) 109 | described_class.all(@client_mock, :page => "foo", :pageSize => 10, :user_id => "me") 110 | end 111 | end 112 | end 113 | end 114 | 115 | describe ".search" do 116 | it "calls .find with the appropriate parameters" do 117 | described_class.should_receive(:all).with("client", {:foo => "bar", described_class.search_parameter_name => "query"}) 118 | described_class.search("client", "query", {:foo => "bar"}) 119 | end 120 | end 121 | 122 | describe ".delete" do 123 | before do 124 | @client_mock = double("client", :version => "23") 125 | end 126 | 127 | it "deletes the resource specified by id" do 128 | @client_mock.should_receive(:http_delete).with("/services/data/v23/chatter/#{described_class.resource_name}/rid", {}).and_return(true) 129 | described_class.delete(@client_mock, "rid") 130 | end 131 | 132 | context "with a parent user id" do 133 | it "constructs the path appropriately" do 134 | @client_mock.should_receive(:http_delete).with("/services/data/v23/chatter/users/me/#{described_class.resource_name}/rid", {}).and_return(@response) 135 | described_class.delete(@client_mock, "rid", :user_id => "me") 136 | end 137 | end 138 | end 139 | end -------------------------------------------------------------------------------- /lib/databasedotcom/chatter/user.rb: -------------------------------------------------------------------------------- 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 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # databasedotcom 2 | 3 | [![Build Status](https://travis-ci.org/heroku/databasedotcom.png?branch=master)](https://travis-ci.org/heroku/databasedotcom) [![Code Climate](https://codeclimate.com/badge.png)](https://codeclimate.com/github/heroku/databasedotcom) [![Dependency Status](https://gemnasium.com/heroku/databasedotcom.png)](https://gemnasium.com/heroku/databasedotcom) 4 | 5 | databasedotcom is a gem to enable ruby applications to access the SalesForce REST API. 6 | If you use bundler, simply list it in your Gemfile, like so: 7 | 8 | ``` 9 | gem 'databasedotcom' 10 | ``` 11 | 12 | If you don't use bundler, install it by hand: 13 | 14 | ``` 15 | gem install databasedotcom 16 | ``` 17 | 18 | ## Documentation 19 | 20 | Reference documentation is available at [rubydoc.info](http://rubydoc.info/github/heroku/databasedotcom/master/frames) 21 | 22 | ## Source 23 | 24 | Source is available at [github](http://github.com/heroku/databasedotcom) 25 | 26 | ## Contributions 27 | 28 | To contribute, fork this repo, make changes in your fork, then send a pull request. 29 | No pull requests without accompanying tests will be accepted. To run tests in your 30 | fork, just do 31 | 32 | ``` 33 | bundle install 34 | rake 35 | ``` 36 | 37 | # Usage 38 | ## Initialization 39 | When you create a Databasedotcom::Client object, you need to configure it with a client 40 | id and client secret that corresponds to one of the Remote Access Applications configured 41 | within your Salesforce instance. The Salesforce UI refers to the client id as "Consumer Key", 42 | and to the client secret as "Consumer Secret". 43 | 44 | You can configure your Client object with a client id and client secret in one of several 45 | different ways: 46 | 47 | ### Configuration from the environment 48 | 49 | If configuration information is present in the environment, the new Client will take configuration 50 | information from there. 51 | 52 | ```bash 53 | export DATABASEDOTCOM_CLIENT_ID=foo 54 | export DATABASEDOTCOM_CLIENT_SECRET=bar 55 | ``` 56 | 57 | Then 58 | 59 | ```ruby 60 | client = Databasedotcom::Client.new 61 | client.client_id #=> foo 62 | client.client_secret #=> bar 63 | ``` 64 | 65 | ### Configuration from a YAML file 66 | 67 | If you pass the name of a YAML file when you create a Client, the new Client will read the YAML 68 | file and take the client id and client secret values from there. 69 | 70 | ```yaml 71 | # databasedotcom.yml 72 | # 73 | --- 74 | client_secret: bro 75 | client_id: baz 76 | ``` 77 | 78 | Then 79 | 80 | ```ruby 81 | client = Databasedotcom::Client.new("databasedotcom.yml") 82 | client.client_id #=> bro 83 | client.client_secret #=> baz 84 | ``` 85 | 86 | ### Configuration from a Hash 87 | 88 | If you pass a hash when you create a Client, the new Client will take configuration information 89 | from that Hash. 90 | 91 | ```ruby 92 | client = Databasedotcom::Client.new :client_id => "sponge", :client_secret => "bob" 93 | client.client_id #=> sponge 94 | client.client_secret #=> bob 95 | ``` 96 | 97 | ### Configuration precedence 98 | 99 | Configuration information present in the environment always takes precedence over that passed in 100 | via a YAML file or a Hash. 101 | 102 | ```bash 103 | export DATABASEDOTCOM_CLIENT_ID=foo 104 | export DATABASEDOTCOM_CLIENT_SECRET=bar 105 | ``` 106 | 107 | Then 108 | 109 | ```ruby 110 | client = Databasedotcom::Client.new :client_id => "sponge", :client_secret => "bob" 111 | client.client_id #=> foo 112 | client.client_secret #=> bar 113 | ``` 114 | 115 | ### Usage in an application deployed on Heroku 116 | 117 | You can use the `heroku config:add` command to set environment variables: 118 | 119 | ```bash 120 | heroku config:add DATABASEDOTCOM_CLIENT_ID=foo 121 | heroku config:add DATABASEDOTCOM_CLIENT_SECRET=bar 122 | ``` 123 | 124 | Then, when you create your client like: 125 | 126 | ```ruby 127 | client = Databasedotcom::Client.new 128 | ``` 129 | 130 | it will use the configuration information that you set with `heroku config:add`. 131 | 132 | ### Connect to a SalesForce sandbox account 133 | 134 | Specify the `:host` option when creating your Client, e.g, 135 | 136 | ```ruby 137 | Databasedotcom::Client.new :host => "test.salesforce.com", ... 138 | ``` 139 | 140 | ## Authentication 141 | 142 | The first thing you need to do with the new Client is to authenticate with Salesforce. 143 | You can do this in one of several ways: 144 | 145 | ### Authentication via an externally-acquired OAuth access token 146 | 147 | If you have acquired an OAuth access token for your Salesforce instance through some external 148 | means, you can use it. Note that you have to pass both the token and your Salesforce instance 149 | URL to the `authenticate` method: 150 | 151 | ```ruby 152 | client.authenticate :token => "my-oauth-token", :instance_url => "http://na1.salesforce.com" #=> "my-oauth-token" 153 | ``` 154 | 155 | ### Authentication via Omniauth 156 | 157 | If you are using the gem within the context of a web application, and your web app is using Omniauth 158 | to do OAuth with Salesforce, you can authentication the Client direction via the Hash that Omniauth 159 | passes to your OAuth callback method, like so: 160 | 161 | ```ruby 162 | client.authenticate request.env['omniauth.auth'] #=> "the-oauth-token" 163 | ``` 164 | 165 | ### Authentication via username and password 166 | 167 | You can authenticate your Client directly with Salesforce with a valid username and password for 168 | a user in your Salesforce instance. Note that, if access to your Salesforce instance requires a 169 | [security token](http://www.salesforce.com/us/developer/docs/api/Content/sforce_api_concepts_security.htm), 170 | the value that you pass for :password must be the password for the user concatenated with 171 | her security token. 172 | 173 | ```ruby 174 | client.authenticate :username => "foo@bar.com", :password => "ThePasswordTheSecurityToken" #=> "the-oauth-token" 175 | ``` 176 | 177 | ## Accessing the Sobject API 178 | 179 | You can retrieve a list of Sobject defined in your Salesforce instance like so: 180 | 181 | ```ruby 182 | client.list_sobjects #=> ['User', 'Group', 'Contact'] 183 | ``` 184 | 185 | Once you have the name of an Sobject, the easiest way to interact with it is to first materialize it: 186 | 187 | ```ruby 188 | contact_class = client.materialize("Contact") #=> Contact 189 | ``` 190 | 191 | By default, Sobject classes are materialized into the global namespace- if you want materialize into 192 | another module, you can easily do configure this: 193 | 194 | ```ruby 195 | client.sobject_module = My::Module 196 | client.materialize("Contact") #=> My::Module::Contact 197 | ``` 198 | 199 | Materialized Sobject classes behave much like ActiveRecord classes: 200 | 201 | ```ruby 202 | contact = Contact.find("contact_id") #=> # 203 | contact = Contact.find_by_Name("John Smith") #=> dynamic finders! 204 | contacts = Contact.all #=> a Databasedotcom::Collection of Contact instances 205 | contacts = Contact.find_all_by_Company("IBM") #=> a Databasedotcom::Collection of matching Contacts 206 | contact.Name #=> the contact's Name attribute 207 | contact["Name"] #=> same thing 208 | contact.Name = "new name" #=> change the contact's Name attribute, in memory 209 | contact["Name"] = "new name" #=> same thing 210 | contact.save #=> save the changes to the database 211 | contact.update_attributes "Name" => "newer name", 212 | "Phone" => "4156543210" #=> change several attributes at once and save them 213 | contact.delete #=> delete the contact from the database 214 | ``` 215 | 216 | See the [documentation](http://rubydoc.info/github/heroku/databasedotcom/master/frames) for full details. 217 | 218 | ## Accessing the Chatter API 219 | 220 | You can easily access Chatter feeds, group, conversations, etc.: 221 | 222 | ```ruby 223 | my_feed_items = Databasedotcom::Chatter::UserProfileFeed.find(client) #=> a Databasedotcom::Collection of FeedItems 224 | 225 | my_feed_items.each do |feed_item| 226 | feed_item.likes #=> a Databasedotcom::Collection of Like instances 227 | feed_item.comments #=> a Databasedotcom::Collection of Comment instances 228 | feed_item.raw_hash #=> the hash returned from the Chatter API describing this FeedItem 229 | feed_item.comment("This is cool") #=> create a new comment on the FeedItem 230 | feed_item.like #=> the authenticating user likes the FeedItem 231 | end 232 | 233 | me = Databasedotcom::Chatter::User.find(client, "me") #=> a User for the authenticating user 234 | me.followers #=> a Databasedotcom::Collection of Users 235 | me.post_status("what I'm doing now") #=> post a new status 236 | 237 | you = Databasedotcom::Chatter::User.find(client, "your-user-id") 238 | me.follow(you) #=> start following a user 239 | ``` 240 | 241 | See the [documentation](http://rubydoc.info/github/heroku/databasedotcom/master/frames) for full details. 242 | 243 | # License 244 | 245 | This gem is licensed under the MIT License. 246 | -------------------------------------------------------------------------------- /spec/lib/chatter/user_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rspec' 2 | require 'spec_helper' 3 | require 'databasedotcom' 4 | 5 | describe Databasedotcom::Chatter::User do 6 | it_should_behave_like("a restful resource") 7 | it_should_behave_like("a resource with a photo") 8 | 9 | describe ".followers" do 10 | before do 11 | @response = double("response", :body => File.read(File.join(File.dirname(__FILE__), "../../fixtures/chatter/users_followers_get_id_success_response.json"))) 12 | @client_mock = double("client", :version => "23") 13 | end 14 | 15 | it "returns a collection of followers subscriptions for a user" do 16 | @client_mock.should_receive(:http_get).with("/services/data/v23/chatter/users/foo/followers").and_return(@response) 17 | followers = Databasedotcom::Chatter::User.followers(@client_mock, "foo") 18 | followers.should be_instance_of(Databasedotcom::Collection) 19 | followers.each do |follower| 20 | follower.should be_instance_of(Databasedotcom::Chatter::Subscription) 21 | end 22 | end 23 | 24 | it "defaults user id to 'me'" do 25 | @client_mock.should_receive(:http_get).with("/services/data/v23/chatter/users/me/followers").and_return(@response) 26 | Databasedotcom::Chatter::User.followers(@client_mock) 27 | end 28 | end 29 | 30 | describe ".following" do 31 | before do 32 | @response = double("response", :body => File.read(File.join(File.dirname(__FILE__), "../../fixtures/chatter/users_following_get_id_success_response.json"))) 33 | @client_mock = double("client", :version => "23") 34 | end 35 | 36 | it "returns a collection of following subscriptions for a user" do 37 | @client_mock.should_receive(:http_get).with("/services/data/v23/chatter/users/foo/following").and_return(@response) 38 | followings = Databasedotcom::Chatter::User.following(@client_mock, "foo") 39 | followings.should be_instance_of(Databasedotcom::Collection) 40 | followings.each do |following| 41 | following.should be_instance_of(Databasedotcom::Chatter::Subscription) 42 | end 43 | end 44 | 45 | it "defaults user id to 'me'" do 46 | @client_mock.should_receive(:http_get).with("/services/data/v23/chatter/users/me/following").and_return(@response) 47 | Databasedotcom::Chatter::User.following(@client_mock) 48 | end 49 | end 50 | 51 | describe ".follow" do 52 | before do 53 | @response = double("response", :body => File.read(File.join(File.dirname(__FILE__), "../../fixtures/chatter/subscriptions_get_id_success_response.json"))) 54 | @client_mock = double("client", :version => "23") 55 | end 56 | 57 | it "follows the record specified by id and returns a new subscription" do 58 | @client_mock.should_receive(:http_post).with("/services/data/v23/chatter/users/me/following", nil, :subjectId => "rid").and_return(@response) 59 | Databasedotcom::Chatter::User.follow(@client_mock, "me", "rid").should be_instance_of(Databasedotcom::Chatter::Subscription) 60 | end 61 | end 62 | 63 | describe ".groups" do 64 | before do 65 | @response = double("response", :body => File.read(File.join(File.dirname(__FILE__), "../../fixtures/chatter/users_groups_get_id_success_response.json"))) 66 | @client_mock = double("client", :version => "23") 67 | end 68 | 69 | it "returns a collection of following subscriptions for a user" do 70 | @client_mock.should_receive(:http_get).with("/services/data/v23/chatter/users/foo/groups").and_return(@response) 71 | groups = Databasedotcom::Chatter::User.groups(@client_mock, "foo") 72 | groups.should be_instance_of(Databasedotcom::Collection) 73 | groups.each do |group| 74 | group.should be_instance_of(Databasedotcom::Chatter::Group) 75 | end 76 | end 77 | 78 | it "defaults user id to 'me'" do 79 | @client_mock.should_receive(:http_get).with("/services/data/v23/chatter/users/me/groups").and_return(@response) 80 | Databasedotcom::Chatter::User.groups(@client_mock) 81 | end 82 | end 83 | 84 | describe ".status" do 85 | before do 86 | @response = double("response", :body => File.read(File.join(File.dirname(__FILE__), "../../fixtures/chatter/users_status_get_id_success_response.json"))) 87 | @client_mock = double("client", :version => "23") 88 | end 89 | 90 | it "returns a hash containing status info" do 91 | @client_mock.should_receive(:http_get).with("/services/data/v23/chatter/users/foo/status").and_return(@response) 92 | groups = Databasedotcom::Chatter::User.status(@client_mock, "foo") 93 | groups.should be_instance_of(Hash) 94 | end 95 | 96 | it "defaults user id to 'me'" do 97 | @client_mock.should_receive(:http_get).with("/services/data/v23/chatter/users/me/status").and_return(@response) 98 | Databasedotcom::Chatter::User.status(@client_mock) 99 | end 100 | end 101 | 102 | describe ".delete_status" do 103 | before do 104 | @client_mock = double("client", :version => "23") 105 | end 106 | 107 | it "deletes the status of the specified user" do 108 | @client_mock.should_receive(:http_delete).with("/services/data/v23/chatter/users/foo/status").and_return(true) 109 | Databasedotcom::Chatter::User.delete_status(@client_mock, "foo") 110 | end 111 | 112 | it "defaults the user id to 'me'" do 113 | @client_mock.should_receive(:http_delete).with("/services/data/v23/chatter/users/me/status").and_return(true) 114 | Databasedotcom::Chatter::User.delete_status(@client_mock) 115 | end 116 | end 117 | 118 | describe ".post_status" do 119 | before do 120 | @client_mock = double("client", :version => "23") 121 | end 122 | 123 | it "post a status update for the specified user" do 124 | @client_mock.should_receive(:http_post).with("/services/data/v23/chatter/users/foo/status", nil, :text => "new status").and_return(double("response", :body => "{}")) 125 | Databasedotcom::Chatter::User.post_status(@client_mock, "foo", "new status").should be_instance_of(Hash) 126 | end 127 | end 128 | 129 | describe ".conversations" do 130 | before do 131 | @client_mock = double("client", :version => "23") 132 | end 133 | 134 | it "gets the conversations for the specified user" do 135 | Databasedotcom::Chatter::Conversation.should_receive(:all).with(@client_mock, :user_id => "foo").and_return(double("response", :body => "{}")) 136 | Databasedotcom::Chatter::User.conversations(@client_mock, "foo") 137 | end 138 | end 139 | 140 | describe ".messages" do 141 | before do 142 | @client_mock = double("client", :version => "23") 143 | end 144 | 145 | it "gets the messages for the specified user" do 146 | Databasedotcom::Chatter::Message.should_receive(:all).with(@client_mock, :user_id => "foo").and_return(double("response", :body => "{}")) 147 | Databasedotcom::Chatter::User.messages(@client_mock, "foo") 148 | end 149 | end 150 | 151 | context "with an instantiated object" do 152 | before do 153 | @response = File.read(File.join(File.dirname(__FILE__), "../../fixtures/chatter/users_get_id_success_response.json")) 154 | @client_mock = double("client", :version => "23") 155 | @user = Databasedotcom::Chatter::User.new(@client_mock, @response) 156 | end 157 | 158 | describe "#status" do 159 | it "returns the user's status" do 160 | @user.status.should == @user.raw_hash["currentStatus"] 161 | end 162 | end 163 | 164 | describe "#post_status" do 165 | it "post a status for the user" do 166 | Databasedotcom::Chatter::User.should_receive(:post_status).with(@client_mock, @user.id, "new status") 167 | @user.post_status("new status") 168 | end 169 | end 170 | 171 | describe "#delete_status" do 172 | it "deletes the user's current status and returns what was deleted" do 173 | @client_mock.should_receive(:http_delete).with("/services/data/v23/chatter/users/#{@user.id}/status").and_return(true) 174 | @user.delete_status 175 | end 176 | end 177 | 178 | describe "#followers!" do 179 | it "gets the followers" do 180 | Databasedotcom::Chatter::User.should_receive(:followers).with(@client_mock, @user.id) 181 | @user.followers! 182 | end 183 | end 184 | 185 | describe "#followers" do 186 | it "gets the memoized followers" do 187 | Databasedotcom::Chatter::User.should_receive(:followers).with(@client_mock, @user.id).once.and_return("foo") 188 | @user.followers 189 | @user.followers 190 | end 191 | end 192 | 193 | describe "#following!" do 194 | it "gets the Following subscriptions" do 195 | Databasedotcom::Chatter::User.should_receive(:following).with(@client_mock, @user.id) 196 | @user.following! 197 | end 198 | end 199 | 200 | describe "#following" do 201 | it "gets the memoized following subscriptions" do 202 | Databasedotcom::Chatter::User.should_receive(:following).with(@client_mock, @user.id).once.and_return("foo") 203 | @user.following 204 | @user.following 205 | end 206 | end 207 | 208 | describe "#groups!" do 209 | it "gets the groups" do 210 | Databasedotcom::Chatter::User.should_receive(:groups).with(@client_mock, @user.id) 211 | @user.groups! 212 | end 213 | end 214 | 215 | describe "#groups" do 216 | it "gets the memoized groups" do 217 | Databasedotcom::Chatter::User.should_receive(:groups).with(@client_mock, @user.id).once.and_return("foo") 218 | @user.groups 219 | @user.groups 220 | end 221 | end 222 | 223 | describe "#follow" do 224 | it "follows the subject" do 225 | Databasedotcom::Chatter::User.should_receive(:follow).with(@client_mock, @user.id, "rid") 226 | @user.follow("rid") 227 | end 228 | end 229 | 230 | describe "#conversations!" do 231 | it "gets the conversations" do 232 | Databasedotcom::Chatter::User.should_receive(:conversations).with(@client_mock, @user.id) 233 | @user.conversations! 234 | end 235 | end 236 | 237 | describe "#conversations" do 238 | it "gets the memoized conversations" do 239 | Databasedotcom::Chatter::User.should_receive(:conversations).with(@client_mock, @user.id).once.and_return("foo") 240 | @user.conversations 241 | @user.conversations 242 | end 243 | end 244 | 245 | describe "#messages!" do 246 | it "gets the messages" do 247 | Databasedotcom::Chatter::User.should_receive(:messages).with(@client_mock, @user.id) 248 | @user.messages! 249 | end 250 | end 251 | 252 | describe "#messages" do 253 | it "gets the memoized messages" do 254 | Databasedotcom::Chatter::User.should_receive(:messages).with(@client_mock, @user.id).once.and_return("foo") 255 | @user.messages 256 | @user.messages 257 | end 258 | end 259 | end 260 | end -------------------------------------------------------------------------------- /lib/databasedotcom/sobject/sobject.rb: -------------------------------------------------------------------------------- 1 | module Databasedotcom 2 | module Sobject 3 | # Parent class of dynamically created sobject types. Interacts with Force.com through a Client object that is passed in during materialization. 4 | class Sobject 5 | cattr_accessor :client 6 | extend ActiveModel::Naming if defined?(ActiveModel::Naming) 7 | 8 | def ==(other) 9 | return false unless other.is_a?(self.class) 10 | self.Id == other.Id 11 | end 12 | 13 | # Returns a new Sobject. The default values for all attributes are set based on its description. 14 | def initialize(attrs = {}) 15 | super() 16 | self.class.description["fields"].each do |field| 17 | if field['type'] =~ /(picklist|multipicklist)/ && picklist_option = field['picklistValues'].find { |p| p['defaultValue'] } 18 | self.send("#{field["name"]}=", picklist_option["value"]) 19 | elsif field['type'] =~ /boolean/ 20 | self.send("#{field["name"]}=", field["defaultValue"]) 21 | else 22 | self.send("#{field["name"]}=", field["defaultValueFormula"]) 23 | end 24 | end 25 | self.attributes=(attrs) 26 | end 27 | 28 | # Returns a hash representing the state of this object 29 | def attributes 30 | self.class.attributes.inject({}) do |hash, attr| 31 | hash[attr] = self.send(attr.to_sym) if self.respond_to?(attr.to_sym) 32 | hash 33 | end 34 | end 35 | 36 | # Set attributes of this object, from a hash, in bulk 37 | def attributes=(attrs) 38 | attrs.each do |key, value| 39 | self.send("#{key}=", value) 40 | end 41 | end 42 | 43 | # Returns true if the object has been persisted in the Force.com database. 44 | def persisted? 45 | !self.Id.nil? 46 | end 47 | 48 | # Returns true if this record has not been persisted in the Force.com database. 49 | def new_record? 50 | !self.persisted? 51 | end 52 | 53 | # Returns self. 54 | def to_model 55 | self 56 | end 57 | 58 | # Returns a unique object id for self. 59 | def to_key 60 | [object_id] 61 | end 62 | 63 | # Returns the Force.com Id for this instance. 64 | def to_param 65 | self.Id 66 | end 67 | 68 | # Updates the corresponding record on Force.com by setting the attribute +attr_name+ to +attr_value+. 69 | # 70 | # client.materialize("Car") 71 | # c = Car.new 72 | # c.update_attribute("Color", "Blue") 73 | def update_attribute(attr_name, attr_value) 74 | update_attributes(attr_name => attr_value) 75 | end 76 | 77 | # Updates the corresponding record on Force.com with the attributes specified by the +new_attrs+ hash. 78 | # 79 | # client.materialize("Car") 80 | # c = Car.new 81 | # c.update_attributes {"Color" => "Blue", "Year" => "2012"} 82 | def update_attributes(new_attrs) 83 | if self.client.update(self.class, self.Id, new_attrs) 84 | new_attrs = new_attrs.is_a?(Hash) ? new_attrs : JSON.parse(new_attrs) 85 | new_attrs.each do |attr, value| 86 | self.send("#{attr}=", value) 87 | end 88 | end 89 | self 90 | end 91 | 92 | # Updates the corresponding record on Force.com with the attributes of self. 93 | # 94 | # client.materialize("Car") 95 | # c = Car.find_by_Color("Yellow") 96 | # c.Color = "Green" 97 | # c.save 98 | # 99 | # _options_ can contain the following keys: 100 | # 101 | # exclusions # an array of field names (case sensitive) to exclude from save 102 | def save(options={}) 103 | attr_hash = {} 104 | selection_attr = self.Id.nil? ? "createable" : "updateable" 105 | self.class.description["fields"].select { |f| f[selection_attr] }.collect { |f| f["name"] }.each { |attr| attr_hash[attr] = self.send(attr) } 106 | 107 | # allow fields to be removed on a case by case basis as some data is not allowed to be saved 108 | # (e.g. Name field on Account with record type of Person Account) despite the API listing 109 | # some fields as editable 110 | if options[:exclusions] and options[:exclusions].respond_to?(:include?) then 111 | attr_hash.delete_if { |key, value| options[:exclusions].include?(key.to_s) } 112 | end 113 | 114 | if self.Id.nil? 115 | self.Id = self.client.create(self.class, attr_hash).Id 116 | else 117 | self.client.update(self.class, self.Id, attr_hash) 118 | end 119 | end 120 | 121 | # Deletes the corresponding record from the Force.com database. Returns self. 122 | # 123 | # client.materialize("Car") 124 | # c = Car.find_by_Color("Yellow") 125 | # c.delete 126 | def delete 127 | if self.client.delete(self.class, self.Id) 128 | self 129 | end 130 | end 131 | 132 | # Reloads the record from the Force.com database. Returns self. 133 | # 134 | # client.materialize("Car") 135 | # c = Car.find_by_Color("Yellow") 136 | # c.reload 137 | def reload 138 | self.attributes = self.class.find(self.Id).attributes 139 | self 140 | end 141 | 142 | # Get a named attribute on this object 143 | def [](attr_name) 144 | self.send(attr_name) rescue nil 145 | end 146 | 147 | # Set a named attribute on this object 148 | def []=(attr_name, value) 149 | raise ArgumentError.new("No attribute named #{attr_name}") unless self.class.attributes.include?(attr_name) 150 | self.send("#{attr_name}=", value) 151 | end 152 | 153 | # Returns an Array of attribute names that this Sobject has. 154 | # 155 | # client.materialize("Car") 156 | # Car.attributes #=> ["Id", "Name", "Color", "Year"] 157 | def self.attributes 158 | self.description["fields"].collect { |f| [f["name"], f["relationshipName"]] }.flatten.compact 159 | end 160 | 161 | # Materializes the dynamically created Sobject class by adding all attribute accessors for each field as described in the description of the object on Force.com 162 | def self.materialize(sobject_name) 163 | self.cattr_accessor :description 164 | self.cattr_accessor :type_map 165 | self.cattr_accessor :sobject_name 166 | 167 | self.sobject_name = sobject_name 168 | self.description = self.client.describe_sobject(self.sobject_name) 169 | self.type_map = {} 170 | 171 | self.description["fields"].each do |field| 172 | 173 | # Register normal fields 174 | name = field["name"] 175 | register_field( field["name"], field ) 176 | 177 | # Register relationship fields. 178 | if( field["type"] == "reference" and field["relationshipName"] ) 179 | register_field( field["relationshipName"], field ) 180 | end 181 | 182 | end 183 | end 184 | 185 | # Returns the Force.com type of the attribute +attr_name+. Raises ArgumentError if attribute does not exist. 186 | # 187 | # client.materialize("Car") 188 | # Car.field_type("Color") #=> "string" 189 | def self.field_type(attr_name) 190 | self.type_map_attr(attr_name, :type) 191 | end 192 | 193 | # Returns the label for the attribute +attr_name+. Raises ArgumentError if attribute does not exist. 194 | def self.label_for(attr_name) 195 | self.type_map_attr(attr_name, :label) 196 | end 197 | 198 | # Returns the possible picklist options for the attribute +attr_name+. If +attr_name+ is not of type picklist or multipicklist, [] is returned. Raises ArgumentError if attribute does not exist. 199 | def self.picklist_values(attr_name) 200 | self.type_map_attr(attr_name, :picklist_values) 201 | end 202 | 203 | # Returns true if the attribute +attr_name+ can be updated. Raises ArgumentError if attribute does not exist. 204 | def self.updateable?(attr_name) 205 | self.type_map_attr(attr_name, :updateable?) 206 | end 207 | 208 | # Returns true if the attribute +attr_name+ can be created. Raises ArgumentError if attribute does not exist. 209 | def self.createable?(attr_name) 210 | self.type_map_attr(attr_name, :createable?) 211 | end 212 | 213 | # Delegates to Client.find with arguments +record_id+ and self 214 | # 215 | # client.materialize("Car") 216 | # Car.find("rid") #=> # 217 | def self.find(record_id) 218 | self.client.find(self, record_id) 219 | end 220 | 221 | # Returns all records of type self as instances. 222 | # 223 | # client.materialize("Car") 224 | # Car.all #=> [#, #, #, ...] 225 | def self.all 226 | self.client.query("SELECT #{self.field_list} FROM #{self.sobject_name}") 227 | end 228 | 229 | # Returns a collection of instances of self that match the conditional +where_expr+, which is the WHERE part of a SOQL query. 230 | # 231 | # client.materialize("Car") 232 | # Car.query("Color = 'Blue'") #=> [#, #, ...] 233 | def self.query(where_expr) 234 | self.client.query("SELECT #{self.field_list} FROM #{self.sobject_name} WHERE #{where_expr}") 235 | end 236 | 237 | # Delegates to Client.search 238 | def self.search(sosl_expr) 239 | self.client.search(sosl_expr) 240 | end 241 | 242 | # Find the first record. If the +where_expr+ argument is present, it must be the WHERE part of a SOQL query 243 | def self.first(where_expr=nil) 244 | where = where_expr ? "WHERE #{where_expr} " : "" 245 | self.client.query("SELECT #{self.field_list} FROM #{self.sobject_name} #{where}ORDER BY Id ASC LIMIT 1").first 246 | end 247 | 248 | # Find the last record. If the +where_expr+ argument is present, it must be the WHERE part of a SOQL query 249 | def self.last(where_expr=nil) 250 | where = where_expr ? "WHERE #{where_expr} " : "" 251 | self.client.query("SELECT #{self.field_list} FROM #{self.sobject_name} #{where}ORDER BY Id DESC LIMIT 1").first 252 | end 253 | 254 | #Delegates to Client.upsert with arguments self, +field+, +values+, and +attrs+ 255 | def self.upsert(field, value, attrs) 256 | self.client.upsert(self.sobject_name, field, value, attrs) 257 | end 258 | 259 | # Delegates to Client.delete with arguments +record_id+ and self 260 | def self.delete(record_id) 261 | self.client.delete(self.sobject_name, record_id) 262 | end 263 | 264 | # Get the total number of records 265 | def self.count 266 | self.client.query("SELECT COUNT() FROM #{self.sobject_name}").total_size 267 | end 268 | 269 | # Sobject objects support dynamic finders similar to ActiveRecord. 270 | # 271 | # client.materialize("Car") 272 | # Car.find_by_Color("Blue") 273 | # Car.find_all_by_Year("2011") 274 | # Car.find_by_Color_and_Year("Blue", "2011") 275 | # Car.find_or_create_by_Year("2011") 276 | # Car.find_or_initialize_by_Name("Foo") 277 | def self.method_missing(method_name, *args, &block) 278 | if method_name.to_s =~ /^find_(or_create_|or_initialize_)?by_(.+)$/ || method_name.to_s =~ /^find_(all_)by_(.+)$/ 279 | named_attrs = $2.split('_and_') 280 | attrs_and_values_for_find = {} 281 | hash_args = args.length == 1 && args[0].is_a?(Hash) 282 | attrs_and_values_for_write = hash_args ? args[0] : {} 283 | 284 | named_attrs.each_with_index do |attr, index| 285 | value = hash_args ? args[0][attr] : args[index] 286 | attrs_and_values_for_find[attr] = value 287 | attrs_and_values_for_write[attr] = value unless hash_args 288 | end 289 | 290 | limit_clause = method_name.to_s.include?('_all_by_') ? "" : " LIMIT 1" 291 | 292 | results = self.client.query("SELECT #{self.field_list} FROM #{self.sobject_name} WHERE #{soql_conditions_for(attrs_and_values_for_find)}#{limit_clause}") 293 | results = limit_clause == "" ? results : results.first rescue nil 294 | 295 | if results.nil? 296 | if method_name.to_s =~ /^find_or_create_by_(.+)$/ 297 | results = self.client.create(self, attrs_and_values_for_write) 298 | elsif method_name.to_s =~ /^find_or_initialize_by_(.+)$/ 299 | results = self.new 300 | attrs_and_values_for_write.each { |attr, val| results.send("#{attr}=", val) } 301 | end 302 | end 303 | 304 | results 305 | else 306 | super 307 | end 308 | end 309 | 310 | # Delegates to Client.create with arguments +object_attributes+ and self 311 | def self.create(object_attributes) 312 | self.client.create(self, object_attributes) 313 | end 314 | 315 | # Coerce values submitted from a Rails form to the values expected by the database 316 | # returns a new hash with updated values 317 | def self.coerce_params(params) 318 | params.each do |attr, value| 319 | case self.field_type(attr) 320 | when "boolean" 321 | params[attr] = value.is_a?(String) ? value.to_i != 0 : value 322 | when "currency", "percent", "double" 323 | value = value.gsub(/[^-0-9.0-9]/, '').to_f if value.respond_to?(:gsub) 324 | params[attr] = value.to_f 325 | when "date" 326 | params[attr] = Date.parse(value) rescue Date.today 327 | when "datetime" 328 | params[attr] = DateTime.parse(value) rescue DateTime.now 329 | end 330 | end 331 | end 332 | 333 | private 334 | 335 | def self.register_field( name, field ) 336 | public 337 | attr_accessor name.to_sym 338 | private 339 | self.type_map[name] = { 340 | :type => field["type"], 341 | :label => field["label"], 342 | :picklist_values => field["picklistValues"], 343 | :updateable? => field["updateable"], 344 | :createable? => field["createable"] 345 | } 346 | end 347 | 348 | def self.field_list 349 | self.description['fields'].collect { |f| f['name'] }.join(',') 350 | end 351 | 352 | def self.type_map_attr(attr_name, key) 353 | raise ArgumentError.new("No attribute named #{attr_name}") unless self.type_map.has_key?(attr_name) 354 | self.type_map[attr_name][key] 355 | end 356 | 357 | def self.soql_conditions_for(params) 358 | params.inject([]) do |arr, av| 359 | case av[1] 360 | when String 361 | value_str = "'#{av[1].gsub("'", "\\\\'")}'" 362 | when DateTime, Time 363 | value_str = av[1].strftime(RUBY_VERSION.match(/^1.8/) ? "%Y-%m-%dT%H:%M:%S.000%z" : "%Y-%m-%dT%H:%M:%S.%L%z").insert(-3, ":") 364 | when Date 365 | value_str = av[1].strftime("%Y-%m-%d") 366 | else 367 | value_str = av[1].to_s 368 | end 369 | 370 | arr << "#{av[0]} = #{value_str}" 371 | arr 372 | end.join(" AND ") 373 | end 374 | end 375 | end 376 | end 377 | -------------------------------------------------------------------------------- /spec/fixtures/chatter/news_feed_items_get_success_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "items": [{ 3 | "parent": { 4 | "name": "Admin User", 5 | "title": null, 6 | "firstName": "Admin", 7 | "lastName": "User", 8 | "companyName": "Pivotal", 9 | "mySubscription": null, 10 | "photo": { 11 | "smallPhotoUrl": "/profilephoto/005/T", 12 | "largePhotoUrl": "/profilephoto/005/F" 13 | }, 14 | "isChatterGuest": false, 15 | "id": "005x0000000JbFuAAK", 16 | "type": "User", 17 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 18 | }, 19 | "id": "0D5x00000001g6tCAA", 20 | "type": "UserStatus", 21 | "clientInfo": { 22 | "applicationName": "localhost", 23 | "applicationUrl": "null" 24 | }, 25 | "url": "/services/data/v23.0/chatter/feed-items/0D5x00000001g6tCAA", 26 | "body": { 27 | "text": "I love #frogs", 28 | "messageSegments": [{ 29 | "type": "Text", 30 | "text": "I love " 31 | }, 32 | { 33 | "url": "/services/data/v23.0/chatter/feed-items?q=%23frogs", 34 | "tag": "frogs", 35 | "type": "Hashtag", 36 | "text": "#frogs" 37 | }] 38 | }, 39 | "createdDate": "2011-08-08T21:37:08.000Z", 40 | "modifiedDate": "2011-08-08T21:39:23.000Z", 41 | "photoUrl": "/profilephoto/005/T", 42 | "comments": { 43 | "total": 1, 44 | "comments": [{ 45 | "parent": { 46 | "id": "005x0000000JbFuAAK", 47 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 48 | }, 49 | "id": "0D7x000000005dRCAQ", 50 | "user": { 51 | "name": "Admin User", 52 | "title": null, 53 | "firstName": "Admin", 54 | "lastName": "User", 55 | "companyName": "Pivotal", 56 | "mySubscription": null, 57 | "photo": { 58 | "smallPhotoUrl": "/profilephoto/005/T", 59 | "largePhotoUrl": "/profilephoto/005/F" 60 | }, 61 | "isChatterGuest": false, 62 | "id": "005x0000000JbFuAAK", 63 | "type": "User", 64 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 65 | }, 66 | "clientInfo": { 67 | "applicationName": "localhost", 68 | "applicationUrl": "null" 69 | }, 70 | "url": "/services/data/v23.0/chatter/comments/0D7x000000005dRCAQ", 71 | "body": { 72 | "text": "So do I like #frogs", 73 | "messageSegments": [{ 74 | "type": "Text", 75 | "text": "So do I like " 76 | }, 77 | { 78 | "url": "/services/data/v23.0/chatter/feed-items?q=%23frogs", 79 | "tag": "frogs", 80 | "type": "Hashtag", 81 | "text": "#frogs" 82 | }] 83 | }, 84 | "createdDate": "2011-08-08T21:39:23.000+0000", 85 | "deletable": true, 86 | "feedItem": { 87 | "id": "0D5x00000001g6tCAA", 88 | "url": "/services/data/v23.0/chatter/feed-items/0D5x00000001g6tCAA" 89 | } 90 | }], 91 | "nextPageUrl": null, 92 | "currentPageUrl": "/services/data/v23.0/chatter/feed-items/0D5x00000001g6tCAA/comments" 93 | }, 94 | "likes": { 95 | "total": 1, 96 | "likes": [{ 97 | "id": "0I0x000000001XiCAI", 98 | "user": { 99 | "name": "Admin User", 100 | "title": null, 101 | "firstName": "Admin", 102 | "lastName": "User", 103 | "companyName": "Pivotal", 104 | "mySubscription": null, 105 | "photo": { 106 | "smallPhotoUrl": "/profilephoto/005/T", 107 | "largePhotoUrl": "/profilephoto/005/F" 108 | }, 109 | "isChatterGuest": false, 110 | "id": "005x0000000JbFuAAK", 111 | "type": "User", 112 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 113 | }, 114 | "url": "/services/data/v23.0/chatter/likes/0I0x000000001XiCAI" 115 | }], 116 | "previousPageUrl": null, 117 | "nextPageUrl": null, 118 | "currentPageUrl": "/services/data/v23.0/chatter/feed-items/0D5x00000001g6tCAA/likes", 119 | "myLike": { 120 | "id": "0I0x000000001XiCAI", 121 | "url": "/services/data/v23.0/chatter/likes/0I0x000000001XiCAI" 122 | } 123 | }, 124 | "isLikedByCurrentUser": true, 125 | "currentUserLike": { 126 | "id": "0I0x000000001XiCAI", 127 | "url": "/services/data/v23.0/chatter/likes/0I0x000000001XiCAI" 128 | }, 129 | "actor": { 130 | "name": "Admin User", 131 | "title": null, 132 | "firstName": "Admin", 133 | "lastName": "User", 134 | "companyName": "Pivotal", 135 | "mySubscription": null, 136 | "photo": { 137 | "smallPhotoUrl": "/profilephoto/005/T", 138 | "largePhotoUrl": "/profilephoto/005/F" 139 | }, 140 | "isChatterGuest": false, 141 | "id": "005x0000000JbFuAAK", 142 | "type": "User", 143 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 144 | }, 145 | "event": false, 146 | "attachment": null 147 | }, 148 | { 149 | "parent": { 150 | "name": "Admin User", 151 | "title": null, 152 | "firstName": "Admin", 153 | "lastName": "User", 154 | "companyName": "Pivotal", 155 | "mySubscription": null, 156 | "photo": { 157 | "smallPhotoUrl": "/profilephoto/005/T", 158 | "largePhotoUrl": "/profilephoto/005/F" 159 | }, 160 | "isChatterGuest": false, 161 | "id": "005x0000000JbFuAAK", 162 | "type": "User", 163 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 164 | }, 165 | "id": "0D5x00000001g7CCAQ", 166 | "type": "UserStatus", 167 | "clientInfo": { 168 | "applicationName": "localhost", 169 | "applicationUrl": "null" 170 | }, 171 | "url": "/services/data/v23.0/chatter/feed-items/0D5x00000001g7CCAQ", 172 | "body": { 173 | "text": "Posting", 174 | "messageSegments": [{ 175 | "type": "Text", 176 | "text": "Posting" 177 | }] 178 | }, 179 | "createdDate": "2011-08-08T21:36:13.000Z", 180 | "modifiedDate": "2011-08-08T21:36:13.000Z", 181 | "photoUrl": "/profilephoto/005/T", 182 | "comments": { 183 | "total": 0, 184 | "comments": [], 185 | "nextPageUrl": null, 186 | "currentPageUrl": "/services/data/v23.0/chatter/feed-items/0D5x00000001g7CCAQ/comments" 187 | }, 188 | "likes": { 189 | "total": 0, 190 | "likes": [], 191 | "previousPageUrl": null, 192 | "nextPageUrl": null, 193 | "currentPageUrl": "/services/data/v23.0/chatter/feed-items/0D5x00000001g7CCAQ/likes", 194 | "myLike": null 195 | }, 196 | "isLikedByCurrentUser": false, 197 | "currentUserLike": null, 198 | "actor": { 199 | "name": "Admin User", 200 | "title": null, 201 | "firstName": "Admin", 202 | "lastName": "User", 203 | "companyName": "Pivotal", 204 | "mySubscription": null, 205 | "photo": { 206 | "smallPhotoUrl": "/profilephoto/005/T", 207 | "largePhotoUrl": "/profilephoto/005/F" 208 | }, 209 | "isChatterGuest": false, 210 | "id": "005x0000000JbFuAAK", 211 | "type": "User", 212 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 213 | }, 214 | "event": false, 215 | "attachment": null 216 | }, 217 | { 218 | "parent": { 219 | "name": "Admin User", 220 | "title": null, 221 | "firstName": "Admin", 222 | "lastName": "User", 223 | "companyName": "Pivotal", 224 | "mySubscription": null, 225 | "photo": { 226 | "smallPhotoUrl": "/profilephoto/005/T", 227 | "largePhotoUrl": "/profilephoto/005/F" 228 | }, 229 | "isChatterGuest": false, 230 | "id": "005x0000000JbFuAAK", 231 | "type": "User", 232 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 233 | }, 234 | "id": "0D5x00000001g78CAA", 235 | "type": "UserStatus", 236 | "clientInfo": null, 237 | "url": "/services/data/v23.0/chatter/feed-items/0D5x00000001g78CAA", 238 | "body": { 239 | "text": "This is so great", 240 | "messageSegments": [{ 241 | "type": "Text", 242 | "text": "This is so great" 243 | }] 244 | }, 245 | "createdDate": "2011-08-08T21:07:48.000Z", 246 | "modifiedDate": "2011-08-08T21:07:48.000Z", 247 | "photoUrl": "/profilephoto/005/T", 248 | "comments": { 249 | "total": 0, 250 | "comments": [], 251 | "nextPageUrl": null, 252 | "currentPageUrl": "/services/data/v23.0/chatter/feed-items/0D5x00000001g78CAA/comments" 253 | }, 254 | "likes": { 255 | "total": 0, 256 | "likes": [], 257 | "previousPageUrl": null, 258 | "nextPageUrl": null, 259 | "currentPageUrl": "/services/data/v23.0/chatter/feed-items/0D5x00000001g78CAA/likes", 260 | "myLike": null 261 | }, 262 | "isLikedByCurrentUser": false, 263 | "currentUserLike": null, 264 | "actor": { 265 | "name": "Admin User", 266 | "title": null, 267 | "firstName": "Admin", 268 | "lastName": "User", 269 | "companyName": "Pivotal", 270 | "mySubscription": null, 271 | "photo": { 272 | "smallPhotoUrl": "/profilephoto/005/T", 273 | "largePhotoUrl": "/profilephoto/005/F" 274 | }, 275 | "isChatterGuest": false, 276 | "id": "005x0000000JbFuAAK", 277 | "type": "User", 278 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 279 | }, 280 | "event": false, 281 | "attachment": null 282 | }, 283 | { 284 | "parent": { 285 | "name": "All Pivotal", 286 | "description": "Get company announcements and other important updates", 287 | "visibility": "PublicAccess", 288 | "memberCount": 1, 289 | "canHaveChatterGuests": false, 290 | "mySubscription": { 291 | "id": "0FBx000000006TrGAI", 292 | "url": "/services/data/v23.0/chatter/group-memberships/0FBx000000006TrGAI" 293 | }, 294 | "myRole": "GroupOwner", 295 | "photo": { 296 | "smallPhotoUrl": "/profilephoto/729x000000005HB/T", 297 | "largePhotoUrl": "/profilephoto/729x000000005HB/F" 298 | }, 299 | "id": "0F9x000000002HVCAY", 300 | "type": "CollaborationGroup", 301 | "url": "/services/data/v23.0/chatter/groups/0F9x000000002HVCAY" 302 | }, 303 | "id": "0D5x00000001g77CAA", 304 | "type": "TrackedChange", 305 | "clientInfo": null, 306 | "url": "/services/data/v23.0/chatter/feed-items/0D5x00000001g77CAA", 307 | "body": { 308 | "text": "created this group.", 309 | "messageSegments": [{ 310 | "type": "Text", 311 | "text": "created this group." 312 | }] 313 | }, 314 | "createdDate": "2011-08-08T21:07:31.000Z", 315 | "modifiedDate": "2011-08-08T21:08:06.000Z", 316 | "photoUrl": "/profilephoto/005/T", 317 | "comments": { 318 | "total": 1, 319 | "comments": [{ 320 | "parent": { 321 | "id": "0F9x000000002HVCAY", 322 | "url": "/services/data/v23.0/chatter/groups/0F9x000000002HVCAY" 323 | }, 324 | "id": "0D7x000000005dMCAQ", 325 | "user": { 326 | "name": "Admin User", 327 | "title": null, 328 | "firstName": "Admin", 329 | "lastName": "User", 330 | "companyName": "Pivotal", 331 | "mySubscription": null, 332 | "photo": { 333 | "smallPhotoUrl": "/profilephoto/005/T", 334 | "largePhotoUrl": "/profilephoto/005/F" 335 | }, 336 | "isChatterGuest": false, 337 | "id": "005x0000000JbFuAAK", 338 | "type": "User", 339 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 340 | }, 341 | "clientInfo": { 342 | "applicationName": "localhost", 343 | "applicationUrl": "null" 344 | }, 345 | "url": "/services/data/v23.0/chatter/comments/0D7x000000005dMCAQ", 346 | "body": { 347 | "text": "yes, it is", 348 | "messageSegments": [{ 349 | "type": "Text", 350 | "text": "yes, it is" 351 | }] 352 | }, 353 | "createdDate": "2011-08-08T21:08:06.000+0000", 354 | "deletable": true, 355 | "feedItem": { 356 | "id": "0D5x00000001g77CAA", 357 | "url": "/services/data/v23.0/chatter/feed-items/0D5x00000001g77CAA" 358 | } 359 | }], 360 | "nextPageUrl": null, 361 | "currentPageUrl": "/services/data/v23.0/chatter/feed-items/0D5x00000001g77CAA/comments" 362 | }, 363 | "likes": { 364 | "total": 0, 365 | "likes": [], 366 | "previousPageUrl": null, 367 | "nextPageUrl": null, 368 | "currentPageUrl": "/services/data/v23.0/chatter/feed-items/0D5x00000001g77CAA/likes", 369 | "myLike": null 370 | }, 371 | "isLikedByCurrentUser": false, 372 | "currentUserLike": null, 373 | "actor": { 374 | "name": "Admin User", 375 | "title": null, 376 | "firstName": "Admin", 377 | "lastName": "User", 378 | "companyName": "Pivotal", 379 | "mySubscription": null, 380 | "photo": { 381 | "smallPhotoUrl": "/profilephoto/005/T", 382 | "largePhotoUrl": "/profilephoto/005/F" 383 | }, 384 | "isChatterGuest": false, 385 | "id": "005x0000000JbFuAAK", 386 | "type": "User", 387 | "url": "/services/data/v23.0/chatter/users/005x0000000JbFuAAK" 388 | }, 389 | "event": true, 390 | "attachment": null 391 | }], 392 | "nextPageUrl": null, 393 | "currentPageUrl": "/services/data/v23.0/chatter/feeds/company/feed-items", 394 | "isModifiedUrl": null 395 | } -------------------------------------------------------------------------------- /lib/databasedotcom/client.rb: -------------------------------------------------------------------------------- 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: 45 | # 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 :username and :password, those credentials are used to authenticate. In this case, the value of :password may need to include a concatenated security token, if required by your Salesforce org 99 | # * If _options_ contains the key :provider, it is assumed to be the hash returned by Omniauth from a successful web-based OAuth2 authentication 100 | # * If _options_ contains the keys :token and :instance_url, 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 :refresh_token 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") #=> # 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") #=> [#, # ...] 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}") #=> [#, # ...] 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"}) #=> # 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.to_s) 498 | when "multipicklist" 499 | coerced_attrs[key] = (attrs[key] || []).join(';') 500 | when "datetime" 501 | begin 502 | attrs[key] = DateTime.parse(attrs[key]) if attrs[key].is_a?(String) 503 | coerced_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") 504 | rescue 505 | nil 506 | end 507 | when "date" 508 | if attrs[key] 509 | coerced_attrs[key] = attrs[key].respond_to?(:strftime) ? attrs[key].strftime("%Y-%m-%d") : attrs[key] 510 | else 511 | coerced_attrs[key] = nil 512 | end 513 | else 514 | coerced_attrs[key] = attrs[key] 515 | end 516 | end 517 | coerced_attrs.to_json 518 | else 519 | attrs 520 | end 521 | end 522 | 523 | def key_from_label(label) 524 | label.gsub(' ', '_') 525 | end 526 | 527 | def user_and_pass?(options) 528 | (self.username && self.password) || (options && options[:username] && options[:password]) 529 | end 530 | 531 | def parse_user_id_and_org_id_from_identity_url(identity_url) 532 | m = identity_url.match(/\/id\/([^\/]+)\/([^\/]+)$/) 533 | @org_id = m[1] rescue nil 534 | @user_id = m[2] rescue nil 535 | end 536 | 537 | def parse_auth_response(body) 538 | json = JSON.parse(body) 539 | parse_user_id_and_org_id_from_identity_url(json["id"]) 540 | self.instance_url = json["instance_url"] 541 | self.oauth_token = json["access_token"] 542 | end 543 | 544 | def query_org_id 545 | query("select id from Organization")[0]["Id"] 546 | end 547 | 548 | def const_defined_in_module(mod, const) 549 | mod.method(:const_defined?).arity == 1 ? mod.const_defined?(const) : mod.const_defined?(const, false) 550 | end 551 | end 552 | end 553 | --------------------------------------------------------------------------------