koala 1.0.0 → 1.1.0

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 (39) hide show
  1. data/.autotest +12 -0
  2. data/.gitignore +3 -1
  3. data/.travis.yml +8 -0
  4. data/CHANGELOG +26 -2
  5. data/Gemfile +4 -0
  6. data/autotest/discover.rb +1 -0
  7. data/koala.gemspec +8 -8
  8. data/lib/koala/batch_operation.rb +74 -0
  9. data/lib/koala/graph_api.rb +103 -102
  10. data/lib/koala/graph_batch_api.rb +87 -0
  11. data/lib/koala/graph_collection.rb +54 -0
  12. data/lib/koala/http_services/net_http_service.rb +92 -0
  13. data/lib/koala/http_services/typhoeus_service.rb +37 -0
  14. data/lib/koala/http_services.rb +13 -113
  15. data/lib/koala/oauth.rb +181 -0
  16. data/lib/koala/realtime_updates.rb +5 -14
  17. data/lib/koala/rest_api.rb +13 -8
  18. data/lib/koala/uploadable_io.rb +137 -77
  19. data/lib/koala.rb +36 -196
  20. data/readme.md +51 -32
  21. data/spec/cases/api_base_spec.rb +4 -4
  22. data/spec/cases/graph_api_batch_spec.rb +609 -0
  23. data/spec/cases/http_services/http_service_spec.rb +87 -12
  24. data/spec/cases/http_services/net_http_service_spec.rb +259 -77
  25. data/spec/cases/http_services/typhoeus_service_spec.rb +29 -21
  26. data/spec/cases/koala_spec.rb +55 -0
  27. data/spec/cases/oauth_spec.rb +1 -1
  28. data/spec/cases/realtime_updates_spec.rb +3 -3
  29. data/spec/cases/test_users_spec.rb +1 -1
  30. data/spec/cases/uploadable_io_spec.rb +56 -14
  31. data/spec/fixtures/cat.m4v +0 -0
  32. data/spec/fixtures/mock_facebook_responses.yml +100 -5
  33. data/spec/spec_helper.rb +2 -1
  34. data/spec/support/graph_api_shared_examples.rb +106 -35
  35. data/spec/support/json_testing_fix.rb +18 -0
  36. data/spec/support/mock_http_service.rb +57 -56
  37. data/spec/support/rest_api_shared_examples.rb +131 -7
  38. data/spec/support/setup_mocks_or_live.rb +3 -4
  39. metadata +34 -47
@@ -2,9 +2,9 @@ require 'koala'
2
2
 
3
3
  module Koala
4
4
  class UploadableIO
5
- attr_reader :io_or_path, :content_type
5
+ attr_reader :io_or_path, :content_type, :requires_base_http_service
6
6
 
7
- def initialize(io_or_path_or_mixed, content_type = nil)
7
+ def initialize(io_or_path_or_mixed, content_type = nil, filename = nil)
8
8
  # see if we got the right inputs
9
9
  if content_type.nil?
10
10
  parse_init_mixed_param io_or_path_or_mixed
@@ -13,103 +13,163 @@ module Koala
13
13
  @content_type = content_type
14
14
  end
15
15
 
16
+ # Probably a StringIO or similar object, which won't work with Typhoeus
17
+ @requires_base_http_service = @io_or_path.respond_to?(:read) && !@io_or_path.kind_of?(File)
18
+
19
+ # filename is used in the Ads API
20
+ @filename = filename || "koala-io-file.dum"
21
+
16
22
  raise KoalaError.new("Invalid arguments to initialize an UploadableIO") unless @io_or_path
17
23
  raise KoalaError.new("Unable to determine MIME type for UploadableIO") if !@content_type && Koala.multipart_requires_content_type?
18
24
  end
19
25
 
20
26
  def to_upload_io
21
- UploadIO.new(@io_or_path, @content_type, "koala-io-file.dum")
27
+ UploadIO.new(@io_or_path, @content_type, @filename)
22
28
  end
23
29
 
24
30
  def to_file
25
31
  @io_or_path.is_a?(String) ? File.open(@io_or_path) : @io_or_path
26
32
  end
33
+
34
+ def self.binary_content?(content)
35
+ content.is_a?(UploadableIO) || DETECTION_STRATEGIES.detect {|method| send(method, content)}
36
+ end
27
37
 
28
38
  private
29
- PARSE_STRATEGIES = [
30
- :parse_rails_3_param,
31
- :parse_sinatra_param,
32
- :parse_file_object,
33
- :parse_string_path
34
- ]
35
-
36
- def parse_init_mixed_param(mixed)
37
- PARSE_STRATEGIES.each do |method|
38
- send(method, mixed)
39
- return if @io_or_path && @content_type
40
- end
39
+ DETECTION_STRATEGIES = [
40
+ :sinatra_param?,
41
+ :rails_3_param?,
42
+ :file_param?
43
+ ]
44
+
45
+ PARSE_STRATEGIES = [
46
+ :parse_rails_3_param,
47
+ :parse_sinatra_param,
48
+ :parse_file_object,
49
+ :parse_string_path
50
+ ]
51
+
52
+ def parse_init_mixed_param(mixed)
53
+ PARSE_STRATEGIES.each do |method|
54
+ send(method, mixed)
55
+ return if @io_or_path && @content_type
41
56
  end
42
-
43
- # Expects a parameter of type ActionDispatch::Http::UploadedFile
44
- def parse_rails_3_param(uploaded_file)
45
- if uploaded_file.respond_to?(:content_type) and uploaded_file.respond_to?(:tempfile) and uploaded_file.tempfile.respond_to?(:path)
46
- @io_or_path = uploaded_file.tempfile.path
47
- @content_type = uploaded_file.content_type
48
- end
57
+ end
58
+
59
+ # Expects a parameter of type ActionDispatch::Http::UploadedFile
60
+ def self.rails_3_param?(uploaded_file)
61
+ uploaded_file.respond_to?(:content_type) and uploaded_file.respond_to?(:tempfile) and uploaded_file.tempfile.respond_to?(:path)
62
+ end
63
+
64
+ def parse_rails_3_param(uploaded_file)
65
+ if UploadableIO.rails_3_param?(uploaded_file)
66
+ @io_or_path = uploaded_file.tempfile.path
67
+ @content_type = uploaded_file.content_type
49
68
  end
50
-
51
- # Expects a Sinatra hash of file info
52
- def parse_sinatra_param(file_hash)
53
- if file_hash.kind_of?(Hash) and file_hash.has_key?(:type) and file_hash.has_key?(:tempfile)
54
- @io_or_path = file_hash[:tempfile]
55
- @content_type = file_hash[:type] || detect_mime_type(tempfile)
56
- end
69
+ end
70
+
71
+ # Expects a Sinatra hash of file info
72
+ def self.sinatra_param?(file_hash)
73
+ file_hash.kind_of?(Hash) and file_hash.has_key?(:type) and file_hash.has_key?(:tempfile)
74
+ end
75
+
76
+ def parse_sinatra_param(file_hash)
77
+ if UploadableIO.sinatra_param?(file_hash)
78
+ @io_or_path = file_hash[:tempfile]
79
+ @content_type = file_hash[:type] || detect_mime_type(tempfile)
57
80
  end
58
-
59
- # takes a file object
60
- def parse_file_object(file)
61
- if file.kind_of?(File)
62
- @io_or_path = file
63
- @content_type = detect_mime_type(file.path)
64
- end
81
+ end
82
+
83
+ # takes a file object
84
+ def self.file_param?(file)
85
+ file.kind_of?(File)
86
+ end
87
+
88
+ def parse_file_object(file)
89
+ if UploadableIO.file_param?(file)
90
+ @io_or_path = file
91
+ @content_type = detect_mime_type(file.path)
65
92
  end
66
-
67
- def parse_string_path(path)
68
- if path.kind_of?(String)
69
- @io_or_path = path
70
- @content_type = detect_mime_type(path)
71
- end
93
+ end
94
+
95
+ def parse_string_path(path)
96
+ if path.kind_of?(String)
97
+ @io_or_path = path
98
+ @content_type = detect_mime_type(path)
72
99
  end
73
-
74
- MIME_TYPE_STRATEGIES = [
75
- :use_mime_module,
76
- :use_simple_detection
77
- ]
78
-
79
- def detect_mime_type(filename)
80
- if filename
81
- MIME_TYPE_STRATEGIES.each do |method|
82
- result = send(method, filename)
83
- return result if result
84
- end
100
+ end
101
+
102
+ MIME_TYPE_STRATEGIES = [
103
+ :use_mime_module,
104
+ :use_simple_detection
105
+ ]
106
+
107
+ def detect_mime_type(filename)
108
+ if filename
109
+ MIME_TYPE_STRATEGIES.each do |method|
110
+ result = send(method, filename)
111
+ return result if result
85
112
  end
86
- nil # if we can't find anything
87
113
  end
88
-
89
- def use_mime_module(filename)
90
- # if the user has installed mime/types, we can use that
91
- # if not, rescue and return nil
92
- begin
93
- type = MIME::Types.type_for(filename).first
94
- type ? type.to_s : nil
95
- rescue
96
- nil
97
- end
114
+ nil # if we can't find anything
115
+ end
116
+
117
+ def use_mime_module(filename)
118
+ # if the user has installed mime/types, we can use that
119
+ # if not, rescue and return nil
120
+ begin
121
+ type = MIME::Types.type_for(filename).first
122
+ type ? type.to_s : nil
123
+ rescue
124
+ nil
98
125
  end
99
-
100
- def use_simple_detection(filename)
101
- # very rudimentary extension analysis for images
102
- # first, get the downcased extension, or an empty string if it doesn't exist
103
- extension = ((filename.match(/\.([a-zA-Z0-9]+)$/) || [])[1] || "").downcase
104
- if extension == ""
126
+ end
127
+
128
+ def use_simple_detection(filename)
129
+ # very rudimentary extension analysis for images
130
+ # first, get the downcased extension, or an empty string if it doesn't exist
131
+ extension = ((filename.match(/\.([a-zA-Z0-9]+)$/) || [])[1] || "").downcase
132
+ case extension
133
+ when ""
105
134
  nil
106
- elsif extension == "jpg" || extension == "jpeg"
135
+ # images
136
+ when "jpg", "jpeg"
107
137
  "image/jpeg"
108
- elsif extension == "png"
138
+ when "png"
109
139
  "image/png"
110
- elsif extension == "gif"
140
+ when "gif"
111
141
  "image/gif"
112
- end
113
- end
142
+
143
+ # video
144
+ when "3g2"
145
+ "video/3gpp2"
146
+ when "3gp", "3gpp"
147
+ "video/3gpp"
148
+ when "asf"
149
+ "video/x-ms-asf"
150
+ when "avi"
151
+ "video/x-msvideo"
152
+ when "flv"
153
+ "video/x-flv"
154
+ when "m4v"
155
+ "video/x-m4v"
156
+ when "mkv"
157
+ "video/x-matroska"
158
+ when "mod"
159
+ "video/mod"
160
+ when "mov", "qt"
161
+ "video/quicktime"
162
+ when "mp4", "mpeg4"
163
+ "video/mp4"
164
+ when "mpe", "mpeg", "mpg", "tod", "vob"
165
+ "video/mpeg"
166
+ when "nsv"
167
+ "application/x-winamp"
168
+ when "ogm", "ogv"
169
+ "video/ogg"
170
+ when "wmv"
171
+ "video/x-ms-wmv"
172
+ end
173
+ end
114
174
  end
115
- end
175
+ end
data/lib/koala.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  require 'cgi'
2
2
  require 'digest/md5'
3
3
 
4
- require 'json'
4
+ require 'multi_json'
5
5
 
6
6
  # OpenSSL and Base64 are required to support signed_request
7
7
  require 'openssl'
@@ -9,10 +9,16 @@ require 'base64'
9
9
 
10
10
  # include koala modules
11
11
  require 'koala/http_services'
12
+ require 'koala/http_services/net_http_service'
13
+ require 'koala/oauth'
12
14
  require 'koala/graph_api'
15
+ require 'koala/graph_batch_api'
16
+ require 'koala/batch_operation'
17
+ require 'koala/graph_collection'
13
18
  require 'koala/rest_api'
14
19
  require 'koala/realtime_updates'
15
20
  require 'koala/test_users'
21
+ require 'koala/http_services'
16
22
 
17
23
  # add KoalaIO class
18
24
  require 'koala/uploadable_io'
@@ -47,27 +53,27 @@ module Koala
47
53
  # in the case of a server error
48
54
  raise APIError.new({"type" => "HTTP #{result.status.to_s}", "message" => "Response body: #{result.body}"}) if result.status >= 500
49
55
 
50
- # Parse the body as JSON and check for errors if provided a mechanism to do so
56
+ # parse the body as JSON and run it through the error checker (if provided)
51
57
  # Note: Facebook sometimes sends results like "true" and "false", which aren't strictly objects
52
- # and cause JSON.parse to fail -- so we account for that by wrapping the result in []
53
- body = response = JSON.parse("[#{result.body.to_s}]")[0]
54
- if error_checking_block
55
- yield(body)
56
- end
58
+ # and cause MultiJson.decode to fail -- so we account for that by wrapping the result in []
59
+ body = MultiJson.decode("[#{result.body.to_s}]")[0]
60
+ yield body if error_checking_block
57
61
 
58
- # now return the desired information
59
- if options[:http_component]
60
- result.send(options[:http_component])
61
- else
62
- body
63
- end
62
+ # if we want a component other than the body (e.g. redirect header for images), return that
63
+ options[:http_component] ? result.send(options[:http_component]) : body
64
64
  end
65
65
  end
66
66
 
67
+ # APIs
68
+
67
69
  class GraphAPI < API
68
70
  include GraphAPIMethods
69
71
  end
70
-
72
+
73
+ class GraphBatchAPI < GraphAPI
74
+ include GraphBatchAPIMethods
75
+ end
76
+
71
77
  class RestAPI < API
72
78
  include RestAPIMethods
73
79
  end
@@ -87,6 +93,8 @@ module Koala
87
93
  attr_reader :graph_api
88
94
  end
89
95
 
96
+ # Errors
97
+
90
98
  class APIError < StandardError
91
99
  attr_accessor :fb_error_type
92
100
  def initialize(details = {})
@@ -94,201 +102,33 @@ module Koala
94
102
  super("#{fb_error_type}: #{details["message"]}")
95
103
  end
96
104
  end
105
+ end
97
106
 
107
+ class KoalaError < StandardError; end
98
108
 
99
- class OAuth
100
- attr_reader :app_id, :app_secret, :oauth_callback_url
101
- def initialize(app_id, app_secret, oauth_callback_url = nil)
102
- @app_id = app_id
103
- @app_secret = app_secret
104
- @oauth_callback_url = oauth_callback_url
105
- end
106
-
107
- def get_user_info_from_cookie(cookie_hash)
108
- # Parses the cookie set by the official Facebook JavaScript SDK.
109
- #
110
- # cookies should be a Hash, like the one Rails provides
111
- #
112
- # If the user is logged in via Facebook, we return a dictionary with the
113
- # keys "uid" and "access_token". The former is the user's Facebook ID,
114
- # and the latter can be used to make authenticated requests to the Graph API.
115
- # If the user is not logged in, we return None.
116
- #
117
- # Download the official Facebook JavaScript SDK at
118
- # http://github.com/facebook/connect-js/. Read more about Facebook
119
- # authentication at http://developers.facebook.com/docs/authentication/.
120
-
121
- if fb_cookie = cookie_hash["fbs_" + @app_id.to_s]
122
- # remove the opening/closing quote
123
- fb_cookie = fb_cookie.gsub(/\"/, "")
124
-
125
- # since we no longer get individual cookies, we have to separate out the components ourselves
126
- components = {}
127
- fb_cookie.split("&").map {|param| param = param.split("="); components[param[0]] = param[1]}
128
-
129
- # generate the signature and make sure it matches what we expect
130
- auth_string = components.keys.sort.collect {|a| a == "sig" ? nil : "#{a}=#{components[a]}"}.reject {|a| a.nil?}.join("")
131
- sig = Digest::MD5.hexdigest(auth_string + @app_secret)
132
- sig == components["sig"] && (components["expires"] == "0" || Time.now.to_i < components["expires"].to_i) ? components : nil
133
- end
134
- end
135
- alias_method :get_user_info_from_cookies, :get_user_info_from_cookie
136
-
137
- def get_user_from_cookie(cookies)
138
- if info = get_user_info_from_cookies(cookies)
139
- string = info["uid"]
140
- end
141
- end
142
- alias_method :get_user_from_cookies, :get_user_from_cookie
143
-
144
- # URLs
145
-
146
- def url_for_oauth_code(options = {})
147
- # for permissions, see http://developers.facebook.com/docs/authentication/permissions
148
- permissions = options[:permissions]
149
- scope = permissions ? "&scope=#{permissions.is_a?(Array) ? permissions.join(",") : permissions}" : ""
150
- display = options.has_key?(:display) ? "&display=#{options[:display]}" : ""
151
-
152
- callback = options[:callback] || @oauth_callback_url
153
- raise ArgumentError, "url_for_oauth_code must get a callback either from the OAuth object or in the options!" unless callback
154
-
155
- # Creates the URL for oauth authorization for a given callback and optional set of permissions
156
- "https://#{GRAPH_SERVER}/oauth/authorize?client_id=#{@app_id}&redirect_uri=#{callback}#{scope}#{display}"
157
- end
158
-
159
- def url_for_access_token(code, options = {})
160
- # Creates the URL for the token corresponding to a given code generated by Facebook
161
- callback = options[:callback] || @oauth_callback_url
162
- raise ArgumentError, "url_for_access_token must get a callback either from the OAuth object or in the parameters!" unless callback
163
- "https://#{GRAPH_SERVER}/oauth/access_token?client_id=#{@app_id}&redirect_uri=#{callback}&client_secret=#{@app_secret}&code=#{code}"
164
- end
165
-
166
- def get_access_token_info(code, options = {})
167
- # convenience method to get a parsed token from Facebook for a given code
168
- # should this require an OAuth callback URL?
169
- get_token_from_server({:code => code, :redirect_uri => @oauth_callback_url}, false, options)
170
- end
171
-
172
- def get_access_token(code, options = {})
173
- # upstream methods will throw errors if needed
174
- if info = get_access_token_info(code, options)
175
- string = info["access_token"]
176
- end
177
- end
178
-
179
- def get_app_access_token_info(options = {})
180
- # convenience method to get a the application's sessionless access token
181
- get_token_from_server({:type => 'client_cred'}, true, options)
182
- end
183
-
184
- def get_app_access_token(options = {})
185
- if info = get_app_access_token_info(options)
186
- string = info["access_token"]
187
- end
188
- end
189
-
190
- # Originally provided directly by Facebook, however this has changed
191
- # as their concept of crypto changed. For historic purposes, this is their proposal:
192
- # https://developers.facebook.com/docs/authentication/canvas/encryption_proposal/
193
- # Currently see https://github.com/facebook/php-sdk/blob/master/src/facebook.php#L758
194
- # for a more accurate reference implementation strategy.
195
- def parse_signed_request(input)
196
- encoded_sig, encoded_envelope = input.split('.', 2)
197
- signature = base64_url_decode(encoded_sig).unpack("H*").first
198
- envelope = JSON.parse(base64_url_decode(encoded_envelope))
199
-
200
- raise "SignedRequest: Unsupported algorithm #{envelope['algorithm']}" if envelope['algorithm'] != 'HMAC-SHA256'
201
-
202
- # now see if the signature is valid (digest, key, data)
203
- hmac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, @app_secret, encoded_envelope.tr("-_", "+/"))
204
- raise 'SignedRequest: Invalid signature' if (signature != hmac)
205
-
206
- return envelope
207
- end
208
-
209
- # from session keys
210
- def get_token_info_from_session_keys(sessions, options = {})
211
- # fetch the OAuth tokens from Facebook
212
- response = fetch_token_string({
213
- :type => 'client_cred',
214
- :sessions => sessions.join(",")
215
- }, true, "exchange_sessions", options)
216
-
217
- # Facebook returns an empty body in certain error conditions
218
- if response == ""
219
- raise APIError.new({
220
- "type" => "ArgumentError",
221
- "message" => "get_token_from_session_key received an error (empty response body) for sessions #{sessions.inspect}!"
222
- })
223
- end
224
-
225
- JSON.parse(response)
226
- end
227
-
228
- def get_tokens_from_session_keys(sessions, options = {})
229
- # get the original hash results
230
- results = get_token_info_from_session_keys(sessions, options)
231
- # now recollect them as just the access tokens
232
- results.collect { |r| r ? r["access_token"] : nil }
233
- end
234
-
235
- def get_token_from_session_key(session, options = {})
236
- # convenience method for a single key
237
- # gets the overlaoded strings automatically
238
- get_tokens_from_session_keys([session], options)[0]
239
- end
240
-
241
- protected
242
-
243
- def get_token_from_server(args, post = false, options = {})
244
- # fetch the result from Facebook's servers
245
- result = fetch_token_string(args, post, "access_token", options)
246
-
247
- # if we have an error, parse the error JSON and raise an error
248
- raise APIError.new((JSON.parse(result)["error"] rescue nil) || {}) if result =~ /error/
249
-
250
- # otherwise, parse the access token
251
- parse_access_token(result)
252
- end
253
-
254
- def parse_access_token(response_text)
255
- components = response_text.split("&").inject({}) do |hash, bit|
256
- key, value = bit.split("=")
257
- hash.merge!(key => value)
258
- end
259
- components
260
- end
261
-
262
- def fetch_token_string(args, post = false, endpoint = "access_token", options = {})
263
- Koala.make_request("/oauth/#{endpoint}", {
264
- :client_id => @app_id,
265
- :client_secret => @app_secret
266
- }.merge!(args), post ? "post" : "get", {:use_ssl => true}.merge!(options)).body
267
- end
268
-
269
- # base 64
270
- # directly from https://github.com/facebook/crypto-request-examples/raw/master/sample.rb
271
- def base64_url_decode(str)
272
- str += '=' * (4 - str.length.modulo(4))
273
- Base64.decode64(str.tr('-_', '+/'))
274
- end
275
- end
109
+ # Make an api request using the provided api service or one passed by the caller
110
+ def self.make_request(path, args, verb, options = {})
111
+ http_service = options.delete(:http_service) || Koala.http_service
112
+ options = options.merge(:use_ssl => true) if @always_use_ssl
113
+ http_service.make_request(path, args, verb, options)
276
114
  end
277
115
 
278
- class KoalaError< StandardError; end
279
-
280
116
  # finally, set up the http service Koala methods used to make requests
281
117
  # you can use your own (for HTTParty, etc.) by calling Koala.http_service = YourModule
282
- def self.http_service=(service)
283
- self.send(:include, service)
118
+ class << self
119
+ attr_accessor :http_service
120
+ attr_accessor :always_use_ssl
121
+ attr_accessor :base_http_service
284
122
  end
123
+ Koala.base_http_service = NetHTTPService
285
124
 
286
125
  # by default, try requiring Typhoeus -- if that works, use it
287
126
  # if you have Typheous and don't want to use it (or want another service),
288
127
  # you can run Koala.http_service = NetHTTPService (or MyHTTPService)
289
128
  begin
129
+ require 'koala/http_services/typhoeus_service'
290
130
  Koala.http_service = TyphoeusService
291
131
  rescue LoadError
292
- Koala.http_service = NetHTTPService
132
+ Koala.http_service = Koala.base_http_service
293
133
  end
294
134
  end