koala 1.5.0 → 1.6.0.rc1

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.
@@ -19,35 +19,23 @@ module Koala
19
19
  end
20
20
 
21
21
  def graph_call_in_batch(path, args = {}, verb = "get", options = {}, &post_processing)
22
- # for batch APIs, we queue up the call details (incl. post-processing)
23
- batch_calls << BatchOperation.new(
24
- :url => path,
25
- :args => args,
26
- :method => verb,
27
- :access_token => options['access_token'] || access_token,
28
- :http_options => options,
29
- :post_processing => post_processing
30
- )
31
- nil # batch operations return nothing immediately
22
+ # for batch APIs, we queue up the call details (incl. post-processing)
23
+ batch_calls << BatchOperation.new(
24
+ :url => path,
25
+ :args => args,
26
+ :method => verb,
27
+ :access_token => options['access_token'] || access_token,
28
+ :http_options => options,
29
+ :post_processing => post_processing
30
+ )
31
+ nil # batch operations return nothing immediately
32
32
  end
33
33
 
34
- def check_graph_batch_api_response(response)
35
- if response.is_a?(Hash) && response["error"] && !response["error"].is_a?(Hash)
36
- # old error format -- see http://developers.facebook.com/blog/post/596/
37
- APIError.new({"type" => "Error #{response["error"]}", "message" => response["error_description"]}.merge(response))
38
- else
39
- check_graph_api_response(response)
40
- end
41
- end
42
-
43
- # redefine the graph_call and check_response methods
44
- # so we can use this API inside the batch block just like any regular Graph API
34
+ # redefine the graph_call method so we can use this API inside the batch block
35
+ # just like any regular Graph API
45
36
  alias_method :graph_call_outside_batch, :graph_call
46
37
  alias_method :graph_call, :graph_call_in_batch
47
38
 
48
- alias_method :check_graph_api_response, :check_response
49
- alias_method :check_response, :check_graph_batch_api_response
50
-
51
39
  # execute the queued batch calls
52
40
  def execute(http_options = {})
53
41
  return [] unless batch_calls.length > 0
@@ -62,7 +50,7 @@ module Koala
62
50
  unless response
63
51
  # Facebook sometimes reportedly returns an empty body at times
64
52
  # see https://github.com/arsduo/koala/issues/184
65
- raise APIError.new({"type" => "BadFacebookResponse", "message" => "Facebook returned invalid batch response: #{response.inspect}"})
53
+ raise BadFacebookResponse.new(200, '', "Facebook returned an empty body")
66
54
  end
67
55
 
68
56
  # map the results with post-processing included
@@ -72,13 +60,16 @@ module Koala
72
60
  batch_op = batch_calls[index]
73
61
  index += 1
74
62
 
63
+ raw_result = nil
75
64
  if call_result
76
- # (see note in regular api method about JSON parsing)
77
- body = MultiJson.load("[#{call_result['body'].to_s}]")[0]
65
+ if ( error = check_response(call_result['code'], call_result['body'].to_s) )
66
+ raw_result = error
67
+ else
68
+ # (see note in regular api method about JSON parsing)
69
+ body = MultiJson.load("[#{call_result['body'].to_s}]")[0]
78
70
 
79
- unless call_result["code"].to_i >= 500 || error = check_response(body)
80
71
  # Get the HTTP component they want
81
- data = case batch_op.http_options[:http_component]
72
+ raw_result = case batch_op.http_options[:http_component]
82
73
  when :status
83
74
  call_result["code"].to_i
84
75
  when :headers
@@ -87,20 +78,19 @@ module Koala
87
78
  else
88
79
  body
89
80
  end
90
-
91
- # process it if we are given a block to process with
92
- batch_op.post_processing ? batch_op.post_processing.call(data) : data
93
- else
94
- error || APIError.new({"type" => "HTTP #{call_result["code"].to_s}", "message" => "Response body: #{body}"})
95
81
  end
82
+ end
83
+
84
+ # turn any results that are pageable into GraphCollections
85
+ # and pass to post-processing callback if given
86
+ result = GraphCollection.evaluate(raw_result, @original_api)
87
+ if batch_op.post_processing
88
+ batch_op.post_processing.call(result)
96
89
  else
97
- nil
90
+ result
98
91
  end
99
92
  end
100
93
  end
101
-
102
- # turn any results that are pageable into GraphCollections
103
- batch_result.inject([]) {|processed_results, raw| processed_results << GraphCollection.evaluate(raw, @original_api)}
104
94
  end
105
95
 
106
96
  end
@@ -1,3 +1,5 @@
1
+ require 'addressable/uri'
2
+
1
3
  module Koala
2
4
  module Facebook
3
5
  class API
@@ -87,7 +89,7 @@ module Koala
87
89
  #
88
90
  # @return an array of parameters that can be provided via graph_call(*parsed_params)
89
91
  def self.parse_page_url(url)
90
- uri = URI.parse(url)
92
+ uri = Addressable::URI.parse(url)
91
93
 
92
94
  base = uri.path.sub(/^\//, '')
93
95
  params = CGI.parse(uri.query)
@@ -21,7 +21,7 @@ module Koala
21
21
  #
22
22
  # @return true if successful, false if not. (This call currently doesn't give useful feedback on failure.)
23
23
  def set_app_properties(properties, args = {}, options = {})
24
- raise APIError.new({"type" => "KoalaMissingAccessToken", "message" => "setAppProperties requires an access token"}) unless @access_token
24
+ raise AuthenticationError.new(nil, nil, "setAppProperties requires an access token") unless @access_token
25
25
  rest_call("admin.setAppProperties", args.merge(:properties => MultiJson.dump(properties)), options, "post")
26
26
  end
27
27
 
@@ -44,8 +44,24 @@ module Koala
44
44
 
45
45
  api("method/#{fb_method}", args.merge('format' => 'json'), verb, options) do |response|
46
46
  # check for REST API-specific errors
47
- if response.is_a?(Hash) && response["error_code"]
48
- raise APIError.new("type" => response["error_code"], "message" => response["error_msg"])
47
+ if response.status >= 400
48
+ begin
49
+ response_hash = MultiJson.load(response.body)
50
+ rescue MultiJson::DecodeError
51
+ response_hash = {}
52
+ end
53
+
54
+ error_info = {
55
+ 'code' => response_hash['error_code'],
56
+ 'error_subcode' => response_hash['error_subcode'],
57
+ 'message' => response_hash['error_msg']
58
+ }
59
+
60
+ if response.status >= 500
61
+ raise ServerError.new(response.status, response.body, error_info)
62
+ else
63
+ raise ClientError.new(response.status, response.body, error_info)
64
+ end
49
65
  end
50
66
  end
51
67
  end
@@ -0,0 +1,86 @@
1
+ module Koala
2
+
3
+ class KoalaError < StandardError; end
4
+
5
+ module Facebook
6
+
7
+ # The OAuth signature is incomplete, invalid, or using an unsupported algorithm
8
+ class OAuthSignatureError < ::Koala::KoalaError; end
9
+
10
+ # Facebook responded with an error to an API request. If the exception contains a nil
11
+ # http_status, then the error was detected before making a call to Facebook. (e.g. missing access token)
12
+ class APIError < ::Koala::KoalaError
13
+ attr_accessor :fb_error_type, :fb_error_code, :fb_error_subcode, :fb_error_message,
14
+ :http_status, :response_body
15
+
16
+ # Create a new API Error
17
+ #
18
+ # @param http_status [Integer] The HTTP status code of the response
19
+ # @param response_body [String] The response body
20
+ # @param error_info One of the following:
21
+ # [Hash] The error information extracted from the request
22
+ # ("type", "code", "error_subcode", "message")
23
+ # [String] The error description
24
+ # If error_info is nil or not provided, the method will attempt to extract
25
+ # the error info from the response_body
26
+ #
27
+ # @return the newly created APIError
28
+ def initialize(http_status, response_body, error_info = nil)
29
+ if response_body
30
+ self.response_body = response_body.strip
31
+ else
32
+ self.response_body = ''
33
+ end
34
+ self.http_status = http_status
35
+
36
+ if error_info && error_info.is_a?(String)
37
+ message = error_info
38
+ else
39
+ unless error_info
40
+ begin
41
+ error_info = MultiJson.load(response_body)['error'] if response_body
42
+ rescue
43
+ end
44
+ error_info ||= {}
45
+ end
46
+
47
+ self.fb_error_type = error_info["type"]
48
+ self.fb_error_code = error_info["code"]
49
+ self.fb_error_subcode = error_info["error_subcode"]
50
+ self.fb_error_message = error_info["message"]
51
+
52
+ error_array = []
53
+ %w(type code error_subcode message).each do |key|
54
+ error_array << "#{key}: #{error_info[key]}" if error_info[key]
55
+ end
56
+
57
+ if error_array.empty?
58
+ message = self.response_body
59
+ else
60
+ message = error_array.join(', ')
61
+ end
62
+ end
63
+ message += " [HTTP #{http_status}]" if http_status
64
+
65
+ super(message)
66
+ end
67
+ end
68
+
69
+ # Facebook returned an invalid response body
70
+ class BadFacebookResponse < APIError; end
71
+
72
+ # Facebook responded with an error while attempting to request an access token
73
+ class OAuthTokenRequestError < APIError; end
74
+
75
+ # Any error with a 5xx HTTP status code
76
+ class ServerError < APIError; end
77
+
78
+ # Any error with a 4xx HTTP status code
79
+ class ClientError < APIError; end
80
+
81
+ # All graph API authentication failures.
82
+ class AuthenticationError < ClientError; end
83
+
84
+ end
85
+
86
+ end
data/lib/koala/oauth.rb CHANGED
@@ -139,7 +139,7 @@ module Koala
139
139
  # @param code (see #url_for_access_token)
140
140
  # @param options any additional parameters to send to Facebook when redeeming the token
141
141
  #
142
- # @raise Koala::Facebook::APIError if Facebook returns an error response
142
+ # @raise Koala::Facebook::OAuthTokenRequestError if Facebook returns an error response
143
143
  #
144
144
  # @return a hash of the access token info returned by Facebook (token, expiration, etc.)
145
145
  def get_access_token_info(code, options = {})
@@ -221,21 +221,21 @@ module Koala
221
221
  #
222
222
  # @param input the signed request from Facebook
223
223
  #
224
- # @raise RuntimeError if the signature is incomplete, invalid, or using an unsupported algorithm
224
+ # @raise OAuthSignatureError if the signature is incomplete, invalid, or using an unsupported algorithm
225
225
  #
226
226
  # @return a hash of the validated request information
227
227
  def parse_signed_request(input)
228
228
  encoded_sig, encoded_envelope = input.split('.', 2)
229
- raise 'SignedRequest: Invalid (incomplete) signature data' unless encoded_sig && encoded_envelope
229
+ raise OAuthSignatureError, 'Invalid (incomplete) signature data' unless encoded_sig && encoded_envelope
230
230
 
231
231
  signature = base64_url_decode(encoded_sig).unpack("H*").first
232
232
  envelope = MultiJson.load(base64_url_decode(encoded_envelope))
233
233
 
234
- raise "SignedRequest: Unsupported algorithm #{envelope['algorithm']}" if envelope['algorithm'] != 'HMAC-SHA256'
234
+ raise OAuthSignatureError, "Unsupported algorithm #{envelope['algorithm']}" if envelope['algorithm'] != 'HMAC-SHA256'
235
235
 
236
236
  # now see if the signature is valid (digest, key, data)
237
237
  hmac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, @app_secret, encoded_envelope)
238
- raise 'SignedRequest: Invalid signature' if (signature != hmac)
238
+ raise OAuthSignatureError, 'Invalid signature' if (signature != hmac)
239
239
 
240
240
  envelope
241
241
  end
@@ -254,10 +254,7 @@ module Koala
254
254
 
255
255
  # Facebook returns an empty body in certain error conditions
256
256
  if response == ""
257
- raise APIError.new({
258
- "type" => "ArgumentError",
259
- "message" => "get_token_from_session_key received an error (empty response body) for sessions #{sessions.inspect}!"
260
- })
257
+ raise BadFacebookResponse.new(200, '', "get_token_from_session_key received an error (empty response body) for sessions #{sessions.inspect}!")
261
258
  end
262
259
 
263
260
  MultiJson.load(response)
@@ -282,13 +279,8 @@ module Koala
282
279
 
283
280
  def get_token_from_server(args, post = false, options = {})
284
281
  # fetch the result from Facebook's servers
285
- result = fetch_token_string(args, post, "access_token", options)
286
-
287
- # if we have an error, parse the error JSON and raise an error
288
- raise APIError.new((MultiJson.load(result)["error"] rescue nil) || {}) if result =~ /error/
289
-
290
- # otherwise, parse the access token
291
- parse_access_token(result)
282
+ response = fetch_token_string(args, post, "access_token", options)
283
+ parse_access_token(response)
292
284
  end
293
285
 
294
286
  def parse_access_token(response_text)
@@ -318,9 +310,12 @@ module Koala
318
310
  if code = components["code"]
319
311
  begin
320
312
  token_info = get_access_token_info(code, :redirect_uri => '')
321
- rescue Koala::Facebook::APIError => err
322
- return nil if err.message =~ /Code was invalid or expired/
323
- raise
313
+ rescue Koala::Facebook::OAuthTokenRequestError => err
314
+ if err.fb_error_type == 'OAuthException' && err.fb_error_message =~ /Code was invalid or expired/
315
+ return nil
316
+ else
317
+ raise
318
+ end
324
319
  end
325
320
 
326
321
  components.merge(token_info) if token_info
@@ -331,10 +326,15 @@ module Koala
331
326
  end
332
327
 
333
328
  def fetch_token_string(args, post = false, endpoint = "access_token", options = {})
334
- Koala.make_request("/oauth/#{endpoint}", {
329
+ response = Koala.make_request("/oauth/#{endpoint}", {
335
330
  :client_id => @app_id,
336
331
  :client_secret => @app_secret
337
- }.merge!(args), post ? "post" : "get", {:use_ssl => true}.merge!(options)).body
332
+ }.merge!(args), post ? "post" : "get", {:use_ssl => true}.merge!(options))
333
+
334
+ raise ServerError.new(response.status, response.body) if response.status >= 500
335
+ raise OAuthTokenRequestError.new(response.status, response.body) if response.status >= 400
336
+
337
+ response.body
338
338
  end
339
339
 
340
340
  # base 64
@@ -7,19 +7,19 @@ module Koala
7
7
  # @note: to subscribe to real-time updates, you must have an application access token
8
8
  # or provide the app secret when initializing your RealtimeUpdates object.
9
9
 
10
- # The application API interface used to communicate with Facebook.
11
- # @return [Koala::Facebook::API]
10
+ # The application API interface used to communicate with Facebook.
11
+ # @return [Koala::Facebook::API]
12
12
  attr_reader :api
13
13
  attr_reader :app_id, :app_access_token, :secret
14
14
 
15
- # Create a new RealtimeUpdates instance.
16
- # If you don't have your app's access token, provide the app's secret and
15
+ # Create a new RealtimeUpdates instance.
16
+ # If you don't have your app's access token, provide the app's secret and
17
17
  # Koala will make a request to Facebook for the appropriate token.
18
- #
18
+ #
19
19
  # @param options initialization options.
20
20
  # @option options :app_id the application's ID.
21
21
  # @option options :app_access_token an application access token, if known.
22
- # @option options :secret the application's secret.
22
+ # @option options :secret the application's secret.
23
23
  #
24
24
  # @raise ArgumentError if the application ID and one of the app access token or the secret are not provided.
25
25
  def initialize(options = {})
@@ -39,7 +39,7 @@ module Koala
39
39
  @api = API.new(@app_access_token)
40
40
  end
41
41
 
42
- # Subscribe to realtime updates for certain fields on a given object (user, page, etc.).
42
+ # Subscribe to realtime updates for certain fields on a given object (user, page, etc.).
43
43
  # See {http://developers.facebook.com/docs/reference/api/realtime the realtime updates documentation}
44
44
  # for more information on what objects and fields you can register for.
45
45
  #
@@ -48,11 +48,11 @@ module Koala
48
48
  # @param object a Facebook ID (name or number)
49
49
  # @param fields the fields you want your app to be updated about
50
50
  # @param callback_url the URL Facebook should ping when an update is available
51
- # @param verify_token a token included in the verification request, allowing you to ensure the call is genuine
51
+ # @param verify_token a token included in the verification request, allowing you to ensure the call is genuine
52
52
  # (see the docs for more information)
53
53
  # @param options (see Koala::HTTPService.make_request)
54
54
  #
55
- # @return true if successful, false (or an APIError) otherwise.
55
+ # @raise A subclass of Koala::Facebook::APIError if the subscription request failed.
56
56
  def subscribe(object, fields, callback_url, verify_token, options = {})
57
57
  args = {
58
58
  :object => object,
@@ -60,23 +60,23 @@ module Koala
60
60
  :callback_url => callback_url,
61
61
  }.merge(verify_token ? {:verify_token => verify_token} : {})
62
62
  # a subscription is a success if Facebook returns a 200 (after hitting your server for verification)
63
- @api.graph_call(subscription_path, args, 'post', options.merge(:http_component => :status)) == 200
63
+ @api.graph_call(subscription_path, args, 'post', options)
64
64
  end
65
65
 
66
- # Unsubscribe from updates for a particular object or from updates.
66
+ # Unsubscribe from updates for a particular object or from updates.
67
67
  #
68
- # @param object the object whose subscriptions to delete.
68
+ # @param object the object whose subscriptions to delete.
69
69
  # If no object is provided, all subscriptions will be removed.
70
70
  # @param options (see Koala::HTTPService.make_request)
71
71
  #
72
- # @return true if the unsubscription is successful, false (or an APIError) otherwise.
72
+ # @raise A subclass of Koala::Facebook::APIError if the subscription request failed.
73
73
  def unsubscribe(object = nil, options = {})
74
- @api.graph_call(subscription_path, object ? {:object => object} : {}, "delete", options.merge(:http_component => :status)) == 200
74
+ @api.graph_call(subscription_path, object ? {:object => object} : {}, "delete", options)
75
75
  end
76
76
 
77
77
  # List all active subscriptions for this application.
78
- #
79
- # @param options (see Koala::HTTPService.make_request)
78
+ #
79
+ # @param options (see Koala::HTTPService.make_request)
80
80
  #
81
81
  # @return [Array] a list of active subscriptions
82
82
  def list_subscriptions(options = {})
@@ -89,12 +89,12 @@ module Koala
89
89
  #
90
90
  # @param params the request parameters sent by Facebook. (You can pass in a Rails params hash.)
91
91
  # @param verify_token the verify token sent in the {#subscribe subscription request}, if you provided one
92
- #
92
+ #
93
93
  # @yield verify_token if you need to compute the verification token
94
94
  # (for instance, if your callback URL includes a record ID, which you look up
95
- # and use to calculate a hash), you can pass meet_challenge a block, which
95
+ # and use to calculate a hash), you can pass meet_challenge a block, which
96
96
  # will receive the verify_token received back from Facebook.
97
- #
97
+ #
98
98
  # @return the challenge string to be sent back to Facebook, or false if the request is invalid.
99
99
  def self.meet_challenge(params, verify_token = nil, &verification_block)
100
100
  if params["hub.mode"] == "subscribe" &&
@@ -111,12 +111,33 @@ module Koala
111
111
  false
112
112
  end
113
113
  end
114
-
114
+
115
+ # Public: As a security measure, all updates from facebook are signed using
116
+ # X-Hub-Signature: sha1=XXXX where XXX is the sha1 of the json payload
117
+ # using your application secret as the key.
118
+ #
119
+ # Example:
120
+ # # in Rails controller
121
+ # # @oauth being a previously defined Koala::Facebook::OAuth instance
122
+ # def receive_update
123
+ # if @oauth.validate_update(request.body, headers)
124
+ # ...
125
+ # end
126
+ # end
127
+ def validate_update(body, headers)
128
+ if request_signature = headers['X-Hub-Signature'] || headers['HTTP_X_HUB_SIGNATURE'] and
129
+ signature_parts = request_signature.split("sha1=")
130
+ request_signature = signature_parts[1]
131
+ calculated_signature = OpenSSL::HMAC.hexdigest('sha1', @secret, body)
132
+ calculated_signature == request_signature
133
+ end
134
+ end
135
+
115
136
  # The Facebook subscription management URL for your application.
116
137
  def subscription_path
117
138
  @subscription_path ||= "#{@app_id}/subscriptions"
118
139
  end
119
-
140
+
120
141
  # @private
121
142
  def graph_api
122
143
  Koala::Utils.deprecate("the TestUsers.graph_api accessor is deprecated and will be removed in a future version; please use .api instead.")