tyler_koala 1.2.0beta

Sign up to get free protection for your applications and to get access to all the features.
Files changed (49) hide show
  1. data/.autotest +12 -0
  2. data/.gitignore +5 -0
  3. data/.travis.yml +9 -0
  4. data/CHANGELOG +185 -0
  5. data/Gemfile +11 -0
  6. data/LICENSE +22 -0
  7. data/Manifest +39 -0
  8. data/Rakefile +16 -0
  9. data/autotest/discover.rb +1 -0
  10. data/koala.gemspec +50 -0
  11. data/lib/koala.rb +119 -0
  12. data/lib/koala/batch_operation.rb +74 -0
  13. data/lib/koala/graph_api.rb +281 -0
  14. data/lib/koala/graph_batch_api.rb +87 -0
  15. data/lib/koala/graph_collection.rb +54 -0
  16. data/lib/koala/http_service.rb +161 -0
  17. data/lib/koala/oauth.rb +181 -0
  18. data/lib/koala/realtime_updates.rb +89 -0
  19. data/lib/koala/rest_api.rb +95 -0
  20. data/lib/koala/test_users.rb +102 -0
  21. data/lib/koala/uploadable_io.rb +180 -0
  22. data/lib/koala/utils.rb +7 -0
  23. data/readme.md +160 -0
  24. data/spec/cases/api_base_spec.rb +101 -0
  25. data/spec/cases/error_spec.rb +30 -0
  26. data/spec/cases/graph_and_rest_api_spec.rb +48 -0
  27. data/spec/cases/graph_api_batch_spec.rb +600 -0
  28. data/spec/cases/graph_api_spec.rb +42 -0
  29. data/spec/cases/http_service_spec.rb +420 -0
  30. data/spec/cases/koala_spec.rb +21 -0
  31. data/spec/cases/oauth_spec.rb +428 -0
  32. data/spec/cases/realtime_updates_spec.rb +198 -0
  33. data/spec/cases/rest_api_spec.rb +41 -0
  34. data/spec/cases/test_users_spec.rb +281 -0
  35. data/spec/cases/uploadable_io_spec.rb +206 -0
  36. data/spec/cases/utils_spec.rb +8 -0
  37. data/spec/fixtures/beach.jpg +0 -0
  38. data/spec/fixtures/cat.m4v +0 -0
  39. data/spec/fixtures/facebook_data.yml +61 -0
  40. data/spec/fixtures/mock_facebook_responses.yml +439 -0
  41. data/spec/spec_helper.rb +43 -0
  42. data/spec/support/graph_api_shared_examples.rb +502 -0
  43. data/spec/support/json_testing_fix.rb +42 -0
  44. data/spec/support/koala_test.rb +163 -0
  45. data/spec/support/mock_http_service.rb +98 -0
  46. data/spec/support/ordered_hash.rb +205 -0
  47. data/spec/support/rest_api_shared_examples.rb +285 -0
  48. data/spec/support/uploadable_io_shared_examples.rb +70 -0
  49. metadata +221 -0
@@ -0,0 +1,74 @@
1
+ module Koala
2
+ module Facebook
3
+ class BatchOperation
4
+ attr_reader :access_token, :http_options, :post_processing, :files, :batch_api, :identifier
5
+
6
+ @identifier = 0
7
+
8
+ def self.next_identifier
9
+ @identifier += 1
10
+ end
11
+
12
+ def initialize(options = {})
13
+ @identifier = self.class.next_identifier
14
+ @args = (options[:args] || {}).dup # because we modify it below
15
+ @access_token = options[:access_token]
16
+ @http_options = (options[:http_options] || {}).dup # dup because we modify it below
17
+ @batch_args = @http_options.delete(:batch_args) || {}
18
+ @url = options[:url]
19
+ @method = options[:method].to_sym
20
+ @post_processing = options[:post_processing]
21
+
22
+ process_binary_args
23
+
24
+ raise Koala::KoalaError, "Batch operations require an access token, none provided." unless @access_token
25
+ end
26
+
27
+ def to_batch_params(main_access_token)
28
+ # set up the arguments
29
+ args_string = Koala.http_service.encode_params(@access_token == main_access_token ? @args : @args.merge(:access_token => @access_token))
30
+
31
+ response = {
32
+ :method => @method.to_s,
33
+ :relative_url => @url,
34
+ }
35
+
36
+ # handle batch-level arguments, such as name, depends_on, and attached_files
37
+ @batch_args[:attached_files] = @files.keys.join(",") if @files
38
+ response.merge!(@batch_args) if @batch_args
39
+
40
+ # for get and delete, we append args to the URL string
41
+ # otherwise, they go in the body
42
+ if args_string.length > 0
43
+ if args_in_url?
44
+ response[:relative_url] += (@url =~ /\?/ ? "&" : "?") + args_string if args_string.length > 0
45
+ else
46
+ response[:body] = args_string if args_string.length > 0
47
+ end
48
+ end
49
+
50
+ response
51
+ end
52
+
53
+ protected
54
+
55
+ def process_binary_args
56
+ # collect binary files
57
+ @args.each_pair do |key, value|
58
+ if UploadableIO.binary_content?(value)
59
+ @files ||= {}
60
+ # we use a class-level counter to ensure unique file identifiers across multiple batch operations
61
+ # (this is thread safe, since we just care about uniqueness)
62
+ # so remove the file from the original hash and add it to the file store
63
+ id = "op#{identifier}_file#{@files.keys.length}"
64
+ @files[id] = @args.delete(key).is_a?(UploadableIO) ? value : UploadableIO.new(value)
65
+ end
66
+ end
67
+ end
68
+
69
+ def args_in_url?
70
+ @method == :get || @method == :delete
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,281 @@
1
+ module Koala
2
+ module Facebook
3
+ GRAPH_SERVER = "graph.facebook.com"
4
+
5
+ module GraphAPIMethods
6
+ # A client for the Facebook Graph API.
7
+ #
8
+ # See http://github.com/arsduo/koala for Ruby/Koala documentation
9
+ # and http://developers.facebook.com/docs/api for Facebook API documentation
10
+ #
11
+ # The Graph API is made up of the objects in Facebook (e.g., people, pages,
12
+ # events, photos) and the connections between them (e.g., friends,
13
+ # photo tags, and event RSVPs). This client provides access to those
14
+ # primitive types in a generic way. For example, given an OAuth access
15
+ # token, this will fetch the profile of the active user and the list
16
+ # of the user's friends:
17
+ #
18
+ # graph = Koala::Facebook::API.new(access_token)
19
+ # user = graph.get_object("me")
20
+ # friends = graph.get_connections(user["id"], "friends")
21
+ #
22
+ # You can see a list of all of the objects and connections supported
23
+ # by the API at http://developers.facebook.com/docs/reference/api/.
24
+ #
25
+ # You can obtain an access token via OAuth or by using the Facebook
26
+ # JavaScript SDK. See the Koala and Facebook documentation for more information.
27
+ #
28
+ # If you are using the JavaScript SDK, you can use the
29
+ # Koala::Facebook::OAuth.get_user_from_cookie() method below to get the OAuth access token
30
+ # for the active user from the cookie saved by the SDK.
31
+
32
+ # Objects
33
+
34
+ def get_object(id, args = {}, options = {})
35
+ # Fetchs the given object from the graph.
36
+ graph_call(id, args, "get", options)
37
+ end
38
+
39
+ def get_objects(ids, args = {}, options = {})
40
+ # Fetchs all of the given objects from the graph.
41
+ # If any of the IDs are invalid, they'll raise an exception.
42
+ return [] if ids.empty?
43
+ graph_call("", args.merge("ids" => ids.respond_to?(:join) ? ids.join(",") : ids), "get", options)
44
+ end
45
+
46
+ def put_object(parent_object, connection_name, args = {}, options = {})
47
+ # Writes the given object to the graph, connected to the given parent.
48
+ # See http://developers.facebook.com/docs/api#publishing for all of
49
+ # the supported writeable objects.
50
+ #
51
+ # For example,
52
+ # graph.put_object("me", "feed", :message => "Hello, world")
53
+ # writes "Hello, world" to the active user's wall.
54
+ #
55
+ # Most write operations require extended permissions. For example,
56
+ # publishing wall posts requires the "publish_stream" permission. See
57
+ # http://developers.facebook.com/docs/authentication/ for details about
58
+ # extended permissions.
59
+
60
+ raise APIError.new({"type" => "KoalaMissingAccessToken", "message" => "Write operations require an access token"}) unless @access_token
61
+ graph_call("#{parent_object}/#{connection_name}", args, "post", options)
62
+ end
63
+
64
+ def delete_object(id, options = {})
65
+ # Deletes the object with the given ID from the graph.
66
+ raise APIError.new({"type" => "KoalaMissingAccessToken", "message" => "Delete requires an access token"}) unless @access_token
67
+ graph_call(id, {}, "delete", options)
68
+ end
69
+
70
+ # Connections
71
+
72
+ def get_connections(id, connection_name, args = {}, options = {})
73
+ # Fetchs the connections for given object.
74
+ graph_call("#{id}/#{connection_name}", args, "get", options) do |result|
75
+ result ? GraphCollection.new(result, self) : nil # when facebook is down nil can be returned
76
+ end
77
+ end
78
+
79
+ def put_connections(id, connection_name, args = {}, options = {})
80
+ # Posts a certain connection
81
+ raise APIError.new({"type" => "KoalaMissingAccessToken", "message" => "Write operations require an access token"}) unless @access_token
82
+ graph_call("#{id}/#{connection_name}", args, "post", options)
83
+ end
84
+
85
+ def delete_connections(id, connection_name, args = {}, options = {})
86
+ # Deletes a given connection
87
+ raise APIError.new({"type" => "KoalaMissingAccessToken", "message" => "Delete requires an access token"}) unless @access_token
88
+ graph_call("#{id}/#{connection_name}", args, "delete", options)
89
+ end
90
+
91
+ # Media (photos and videos)
92
+ # to delete photos or videos, use delete_object(object_id)
93
+ # note: you'll need the user_photos or user_videos permissions to actually access media after upload
94
+
95
+ def get_picture(object, args = {}, options = {})
96
+ # Gets a picture object, returning the URL (which Facebook sends as a header)
97
+ graph_call("#{object}/picture", args, "get", options.merge(:http_component => :headers)) do |result|
98
+ result["Location"]
99
+ end
100
+ end
101
+
102
+ # Can be called in multiple ways:
103
+ #
104
+ # put_picture(file, [content_type], ...)
105
+ # put_picture(path_to_file, [content_type], ...)
106
+ # put_picture(picture_url, ...)
107
+ #
108
+ # You can pass in uploaded files directly from Rails or Sinatra.
109
+ # (See lib/koala/uploadable_io.rb for supported frameworks)
110
+ #
111
+ # Optional parameters can be added to the end of the argument list:
112
+ # - args: a hash of request parameters (default: {})
113
+ # - target_id: ID of the target where to post the picture (default: "me")
114
+ # - options: a hash of http options passed to the HTTPService module
115
+ #
116
+ # put_picture(file, content_type, {:message => "Message"}, 01234560)
117
+ # put_picture(params[:file], {:message => "Message"})
118
+ #
119
+ # (Note that with URLs, there's no optional content type field)
120
+ # put_picture(picture_url, {:message => "Message"}, my_page_id)
121
+
122
+ def put_picture(*picture_args)
123
+ put_object(*parse_media_args(picture_args, "photos"))
124
+ end
125
+
126
+ def put_video(*video_args)
127
+ args = parse_media_args(video_args, "videos")
128
+ args.last[:video] = true
129
+ put_object(*args)
130
+ end
131
+
132
+ # Wall posts
133
+ # To get wall posts, use get_connections(user, "feed")
134
+ # To delete a wall post, just use delete_object(post_id)
135
+
136
+ def put_wall_post(message, attachment = {}, profile_id = "me", options = {})
137
+ # attachment is a hash describing the wall post
138
+ # (see X for more details)
139
+ # For instance,
140
+ #
141
+ # {"name" => "Link name"
142
+ # "link" => "http://www.example.com/",
143
+ # "caption" => "{*actor*} posted a new review",
144
+ # "description" => "This is a longer description of the attachment",
145
+ # "picture" => "http://www.example.com/thumbnail.jpg"}
146
+
147
+ self.put_object(profile_id, "feed", attachment.merge({:message => message}), options)
148
+ end
149
+
150
+ # Comments
151
+ # to delete comments, use delete_object(comment_id)
152
+ # to get comments, use get_connections(object, "likes")
153
+
154
+ def put_comment(object_id, message, options = {})
155
+ # Writes the given comment on the given post.
156
+ self.put_object(object_id, "comments", {:message => message}, options)
157
+ end
158
+
159
+ # Likes
160
+ # to get likes, use get_connections(user, "likes")
161
+
162
+ def put_like(object_id, options = {})
163
+ # Likes the given post.
164
+ self.put_object(object_id, "likes", {}, options)
165
+ end
166
+
167
+ def delete_like(object_id, options = {})
168
+ # Unlikes a given object for the logged-in user
169
+ raise APIError.new({"type" => "KoalaMissingAccessToken", "message" => "Unliking requires an access token"}) unless @access_token
170
+ graph_call("#{object_id}/likes", {}, "delete", options)
171
+ end
172
+
173
+ # Search
174
+
175
+ def search(search_terms, args = {}, options = {})
176
+ args.merge!({:q => search_terms}) unless search_terms.nil?
177
+ graph_call("search", args, "get", options) do |result|
178
+ result ? GraphCollection.new(result, self) : nil # when facebook is down nil can be returned
179
+ end
180
+ end
181
+
182
+ # Convenience Methods
183
+
184
+ def get_page_access_token(object_id)
185
+ result = get_object(object_id, :fields => "access_token") do
186
+ result ? result["access_token"] : nil
187
+ end
188
+ end
189
+
190
+ def get_comments_for_urls(urls = [], args = {}, options = {})
191
+ # Fetchs the comments for given URLs (array or comma-separated string)
192
+ # see https://developers.facebook.com/blog/post/490
193
+ return [] if urls.empty?
194
+ args.merge!(:ids => urls.respond_to?(:join) ? urls.join(",") : urls)
195
+ get_object("comments", args, options)
196
+ end
197
+
198
+ # GraphCollection support
199
+ def get_page(params)
200
+ # Pages through a set of results stored in a GraphCollection
201
+ # Used for connections and search results
202
+ graph_call(*params) do |result|
203
+ result ? GraphCollection.new(result, self) : nil # when facebook is down nil can be returned
204
+ end
205
+ end
206
+
207
+ # Batch API
208
+ def batch(http_options = {}, &block)
209
+ batch_client = GraphBatchAPI.new(access_token)
210
+ if block
211
+ yield batch_client
212
+ batch_client.execute(http_options)
213
+ else
214
+ batch_client
215
+ end
216
+ end
217
+
218
+ def self.included(base)
219
+ base.class_eval do
220
+ def self.batch
221
+ raise NoMethodError, "The BatchAPI signature has changed (the original implementation was not thread-safe). Please see https://github.com/arsduo/koala/wiki/Batch-requests. (This message will be removed in the final 1.1 release.)"
222
+ end
223
+ end
224
+ end
225
+
226
+ # Direct access to the Facebook API
227
+ # see any of the above methods for example invocations
228
+ def graph_call(path, args = {}, verb = "get", options = {}, &post_processing)
229
+ result = api(path, args, verb, options) do |response|
230
+ error = check_response(response)
231
+ raise error if error
232
+ end
233
+
234
+ # now process as appropriate (get picture header, make GraphCollection, etc.)
235
+ post_processing ? post_processing.call(result) : result
236
+ end
237
+
238
+ def check_response(response)
239
+ # check for Graph API-specific errors
240
+ # this returns an error, which is immediately raised (non-batch)
241
+ # or added to the list of batch results (batch)
242
+ if response.is_a?(Hash) && error_details = response["error"]
243
+ APIError.new(error_details)
244
+ end
245
+ end
246
+
247
+ private
248
+
249
+ def parse_media_args(media_args, method)
250
+ # photo and video uploads can accept different types of arguments (see above)
251
+ # so here, we parse the arguments into a form directly usable in put_object
252
+ raise KoalaError.new("Wrong number of arguments for put_#{method == "photos" ? "picture" : "video"}") unless media_args.size.between?(1, 5)
253
+
254
+ args_offset = media_args[1].kind_of?(Hash) || media_args.size == 1 ? 0 : 1
255
+
256
+ args = media_args[1 + args_offset] || {}
257
+ target_id = media_args[2 + args_offset] || "me"
258
+ options = media_args[3 + args_offset] || {}
259
+
260
+ if url?(media_args.first)
261
+ # If media_args is a URL, we can upload without UploadableIO
262
+ args.merge!(:url => media_args.first)
263
+ else
264
+ args["source"] = Koala::UploadableIO.new(*media_args.slice(0, 1 + args_offset))
265
+ end
266
+
267
+ [target_id, method, args, options]
268
+ end
269
+
270
+ def url?(data)
271
+ return false unless data.is_a? String
272
+ begin
273
+ uri = URI.parse(data)
274
+ %w( http https ).include?(uri.scheme)
275
+ rescue URI::BadURIError
276
+ false
277
+ end
278
+ end
279
+ end
280
+ end
281
+ end
@@ -0,0 +1,87 @@
1
+ module Koala
2
+ module Facebook
3
+ module GraphBatchAPIMethods
4
+
5
+ def self.included(base)
6
+ base.class_eval do
7
+ alias_method :graph_call_outside_batch, :graph_call
8
+ alias_method :graph_call, :graph_call_in_batch
9
+
10
+ alias_method :check_graph_api_response, :check_response
11
+ alias_method :check_response, :check_graph_batch_api_response
12
+ end
13
+ end
14
+
15
+ def batch_calls
16
+ @batch_calls ||= []
17
+ end
18
+
19
+ def graph_call_in_batch(path, args = {}, verb = "get", options = {}, &post_processing)
20
+ # for batch APIs, we queue up the call details (incl. post-processing)
21
+ batch_calls << BatchOperation.new(
22
+ :url => path,
23
+ :args => args,
24
+ :method => verb,
25
+ :access_token => options['access_token'] || access_token,
26
+ :http_options => options,
27
+ :post_processing => post_processing
28
+ )
29
+ nil # batch operations return nothing immediately
30
+ end
31
+
32
+ def check_graph_batch_api_response(response)
33
+ if response.is_a?(Hash) && response["error"] && !response["error"].is_a?(Hash)
34
+ APIError.new("type" => "Error #{response["error"]}", "message" => response["error_description"])
35
+ else
36
+ check_graph_api_response(response)
37
+ end
38
+ end
39
+
40
+ def execute(http_options = {})
41
+ return [] unless batch_calls.length > 0
42
+ # Turn the call args collected into what facebook expects
43
+ args = {}
44
+ args["batch"] = MultiJson.encode(batch_calls.map { |batch_op|
45
+ args.merge!(batch_op.files) if batch_op.files
46
+ batch_op.to_batch_params(access_token)
47
+ })
48
+
49
+ graph_call_outside_batch('/', args, 'post', http_options) do |response|
50
+ # map the results with post-processing included
51
+ index = 0 # keep compat with ruby 1.8 - no with_index for map
52
+ response.map do |call_result|
53
+ # Get the options hash
54
+ batch_op = batch_calls[index]
55
+ index += 1
56
+
57
+ if call_result
58
+ # (see note in regular api method about JSON parsing)
59
+ body = MultiJson.decode("[#{call_result['body'].to_s}]")[0]
60
+
61
+ unless call_result["code"].to_i >= 500 || error = check_response(body)
62
+ # Get the HTTP component they want
63
+ data = case batch_op.http_options[:http_component]
64
+ when :status
65
+ call_result["code"].to_i
66
+ when :headers
67
+ # facebook returns the headers as an array of k/v pairs, but we want a regular hash
68
+ call_result['headers'].inject({}) { |headers, h| headers[h['name']] = h['value']; headers}
69
+ else
70
+ body
71
+ end
72
+
73
+ # process it if we are given a block to process with
74
+ batch_op.post_processing ? batch_op.post_processing.call(data) : data
75
+ else
76
+ error || APIError.new({"type" => "HTTP #{call_result["code"].to_s}", "message" => "Response body: #{body}"})
77
+ end
78
+ else
79
+ nil
80
+ end
81
+ end
82
+ end
83
+ end
84
+
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,54 @@
1
+ module Koala
2
+ module Facebook
3
+ class GraphCollection < Array
4
+ # This class is a light wrapper for collections returned
5
+ # from the Graph API.
6
+ #
7
+ # It extends Array to allow direct access to the data colleciton
8
+ # which should allow it to drop in seamlessly.
9
+ #
10
+ # It also allows access to paging information and the
11
+ # ability to get the next/previous page in the collection
12
+ # by calling next_page or previous_page.
13
+ attr_reader :paging
14
+ attr_reader :api
15
+
16
+ def initialize(response, api)
17
+ super response["data"]
18
+ @paging = response["paging"]
19
+ @api = api
20
+ end
21
+
22
+ # defines methods for NEXT and PREVIOUS pages
23
+ %w{next previous}.each do |this|
24
+
25
+ # def next_page
26
+ # def previous_page
27
+ define_method "#{this.to_sym}_page" do
28
+ base, args = send("#{this}_page_params")
29
+ base ? @api.get_page([base, args]) : nil
30
+ end
31
+
32
+ # def next_page_params
33
+ # def previous_page_params
34
+ define_method "#{this.to_sym}_page_params" do
35
+ return nil unless @paging and @paging[this]
36
+ parse_page_url(@paging[this])
37
+ end
38
+ end
39
+
40
+ def parse_page_url(url)
41
+ match = url.match(/.com\/(.*)\?(.*)/)
42
+ base = match[1]
43
+ args = match[2]
44
+ params = CGI.parse(args)
45
+ new_params = {}
46
+ params.each_pair do |key,value|
47
+ new_params[key] = value.join ","
48
+ end
49
+ [base,new_params]
50
+ end
51
+
52
+ end
53
+ end
54
+ end