tyler_koala 1.2.0beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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