koala 1.0.0 → 1.1.0rc

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,151 @@
1
+ module Koala
2
+ module Facebook
3
+ class BatchOperation
4
+ attr_reader :access_token, :http_options, :post_processing, :files
5
+
6
+ def initialize(options = {})
7
+ @args = (options[:args] || {}).dup # because we modify it below
8
+ @access_token = options[:access_token]
9
+ @http_options = (options[:http_options] || {}).dup # dup because we modify it below
10
+ @batch_args = @http_options.delete(:batch_args) || {}
11
+ @url = options[:url]
12
+ @method = options[:method].to_sym
13
+ @post_processing = options[:post_processing]
14
+
15
+ process_binary_args
16
+
17
+ raise Koala::KoalaError, "Batch operations require an access token, none provided." unless @access_token
18
+ end
19
+
20
+ def to_batch_params(main_access_token)
21
+ # set up the arguments
22
+ args_string = Koala.http_service.encode_params(@access_token == main_access_token ? @args : @args.merge(:access_token => @access_token))
23
+
24
+ response = {
25
+ :method => @method,
26
+ :relative_url => @url,
27
+ }
28
+
29
+ # handle batch-level arguments, such as name, depends_on, and attached_files
30
+ @batch_args[:attached_files] = @files.keys.join(",") if @files
31
+ response.merge!(@batch_args) if @batch_args
32
+
33
+ # for get and delete, we append args to the URL string
34
+ # otherwise, they go in the body
35
+ if args_string.length > 0
36
+ if args_in_url?
37
+ response[:relative_url] += (@url =~ /\?/ ? "&" : "?") + args_string if args_string.length > 0
38
+ else
39
+ response[:body] = args_string if args_string.length > 0
40
+ end
41
+ end
42
+
43
+ response
44
+ end
45
+
46
+ protected
47
+
48
+ def process_binary_args
49
+ # collect binary files
50
+ @args.each_pair do |key, value|
51
+ if UploadableIO.binary_content?(value)
52
+ @files ||= {}
53
+ # we use object_id to ensure unique file identifiers across multiple batch operations
54
+ # remove it from the original hash and add it to the file store
55
+ id = "file#{GraphAPI.batch_calls.length}_#{@files.keys.length}"
56
+ @files[id] = @args.delete(key).is_a?(UploadableIO) ? value : UploadableIO.new(value)
57
+ end
58
+ end
59
+ end
60
+
61
+ def args_in_url?
62
+ @method == :get || @method == :delete
63
+ end
64
+ end
65
+
66
+ module GraphAPIBatchMethods
67
+ def self.included(base)
68
+ base.class_eval do
69
+ # batch mode flags
70
+ def self.batch_mode?
71
+ !!@batch_mode
72
+ end
73
+
74
+ def self.batch_calls
75
+ raise KoalaError, "GraphAPI.batch_calls accessed when not in batch block!" unless batch_mode?
76
+ @batch_calls
77
+ end
78
+
79
+ def self.batch(http_options = {}, &block)
80
+ @batch_mode = true
81
+ @batch_http_options = http_options
82
+ @batch_calls = []
83
+ yield
84
+ begin
85
+ results = batch_api(@batch_calls)
86
+ ensure
87
+ @batch_mode = false
88
+ end
89
+ results
90
+ end
91
+
92
+ def self.batch_api(batch_calls)
93
+ return [] unless batch_calls.length > 0
94
+ # Facebook requires a top-level access token
95
+
96
+ # Get the access token for the user and start building a hash to store params
97
+ # Turn the call args collected into what facebook expects
98
+ args = {}
99
+ access_token = args["access_token"] = batch_calls.first.access_token
100
+ args['batch'] = batch_calls.map { |batch_op|
101
+ args.merge!(batch_op.files) if batch_op.files
102
+ batch_op.to_batch_params(access_token)
103
+ }.to_json
104
+
105
+ # Make the POST request for the batch call
106
+ # batch operations have to go over SSL, but since there's an access token, that secures that
107
+ result = Koala.make_request('/', args, 'post', @batch_http_options)
108
+ # Raise an error if we get a 500
109
+ raise APIError.new("type" => "HTTP #{result.status.to_s}", "message" => "Response body: #{result.body}") if result.status >= 500
110
+
111
+ response = JSON.parse(result.body.to_s)
112
+ # raise an error if we get a Batch API error message
113
+ raise APIError.new("type" => "Error #{response["error"]}", "message" => response["error_description"]) if response.is_a?(Hash) && response["error"]
114
+
115
+ # otherwise, map the results with post-processing included
116
+ index = 0 # keep compat with ruby 1.8 - no with_index for map
117
+ response.map do |call_result|
118
+ # Get the options hash
119
+ batch_op = batch_calls[index]
120
+ index += 1
121
+
122
+ if call_result
123
+ # (see note in regular api method about JSON parsing)
124
+ body = JSON.parse("[#{call_result['body'].to_s}]")[0]
125
+ unless call_result["code"].to_i >= 500 || error = GraphAPI.check_response(body)
126
+ # Get the HTTP component they want
127
+ data = case batch_op.http_options[:http_component]
128
+ when :status
129
+ call_result["code"].to_i
130
+ when :headers
131
+ # facebook returns the headers as an array of k/v pairs, but we want a regular hash
132
+ call_result['headers'].inject({}) { |headers, h| headers[h['name']] = h['value']; headers}
133
+ else
134
+ body
135
+ end
136
+
137
+ # process it if we are given a block to process with
138
+ batch_op.post_processing ? batch_op.post_processing.call(data) : data
139
+ else
140
+ error || APIError.new({"type" => "HTTP #{call_result["code"].to_s}", "message" => "Response body: #{body}"})
141
+ end
142
+ else
143
+ nil
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
@@ -13,134 +13,32 @@ module Koala
13
13
  def self.included(base)
14
14
  base.class_eval do
15
15
  class << self
16
- attr_accessor :always_use_ssl
16
+ attr_accessor :always_use_ssl, :proxy, :timeout, :ca_path, :ca_file
17
17
  end
18
-
18
+
19
19
  def self.server(options = {})
20
20
  "#{options[:beta] ? "beta." : ""}#{options[:rest_api] ? Facebook::REST_SERVER : Facebook::GRAPH_SERVER}"
21
21
  end
22
-
23
- protected
24
22
 
25
- def self.params_require_multipart?(param_hash)
26
- param_hash.any? { |key, value| value.kind_of?(Koala::UploadableIO) }
27
- end
28
-
29
- def self.multipart_requires_content_type?
30
- true
31
- end
32
- end
33
- end
34
- end
35
-
36
- module NetHTTPService
37
- # this service uses Net::HTTP to send requests to the graph
38
- def self.included(base)
39
- base.class_eval do
40
- require "net/http" unless defined?(Net::HTTP)
41
- require "net/https"
42
- require "net/http/post/multipart"
43
-
44
- include Koala::HTTPService
45
-
46
- def self.make_request(path, args, verb, options = {})
47
- # We translate args to a valid query string. If post is specified,
48
- # we send a POST request to the given path with the given arguments.
49
-
50
- # by default, we use SSL only for private requests
51
- # this makes public requests faster
52
- private_request = args["access_token"] || @always_use_ssl || options[:use_ssl]
53
-
54
- # if the verb isn't get or post, send it as a post argument
55
- args.merge!({:method => verb}) && verb = "post" if verb != "get" && verb != "post"
56
-
57
- http = create_http(server(options), private_request, options)
58
- http.use_ssl = true if private_request
59
-
60
- result = http.start do |http|
61
- response, body = if verb == "post"
62
- if params_require_multipart? args
63
- http.request Net::HTTP::Post::Multipart.new path, encode_multipart_params(args)
64
- else
65
- http.post(path, encode_params(args))
66
- end
67
- else
68
- http.get("#{path}?#{encode_params(args)}")
69
- end
70
-
71
- Koala::Response.new(response.code.to_i, body, response)
72
- end
73
- end
74
-
75
- protected
76
23
  def self.encode_params(param_hash)
77
24
  # unfortunately, we can't use to_query because that's Rails, not Ruby
78
25
  # if no hash (e.g. no auth token) return empty string
79
26
  ((param_hash || {}).collect do |key_and_value|
80
- key_and_value[1] = key_and_value[1].to_json if key_and_value[1].class != String
27
+ key_and_value[1] = key_and_value[1].to_json unless key_and_value[1].is_a? String
81
28
  "#{key_and_value[0].to_s}=#{CGI.escape key_and_value[1]}"
82
29
  end).join("&")
83
30
  end
31
+
32
+ protected
84
33
 
85
- def self.encode_multipart_params(param_hash)
86
- Hash[*param_hash.collect do |key, value|
87
- [key, value.kind_of?(Koala::UploadableIO) ? value.to_upload_io : value]
88
- end.flatten]
89
- end
90
-
91
- def self.create_http(server, private_request, options)
92
- if options[:proxy]
93
- proxy = URI.parse(options[:proxy])
94
- http = Net::HTTP.new(server, private_request ? 443 : nil,
95
- proxy.host, proxy.port, proxy.user, proxy.password)
96
- else
97
- http = Net::HTTP.new(server, private_request ? 443 : nil)
98
- end
99
- if options[:timeout]
100
- http.open_timeout = options[:timeout]
101
- http.read_timeout = options[:timeout]
102
- end
103
- http
104
- end
105
-
106
- end
107
- end
108
- end
109
-
110
- module TyphoeusService
111
- # this service uses Typhoeus to send requests to the graph
112
-
113
- def self.included(base)
114
- base.class_eval do
115
- require "typhoeus" unless defined?(Typhoeus)
116
- include Typhoeus
117
-
118
- include Koala::HTTPService
119
-
120
- def self.make_request(path, args, verb, options = {})
121
- # if the verb isn't get or post, send it as a post argument
122
- args.merge!({:method => verb}) && verb = "post" if verb != "get" && verb != "post"
123
-
124
- # switch any UploadableIOs to the files Typhoeus expects
125
- args.each_pair {|key, value| args[key] = value.to_file if value.is_a?(UploadableIO)}
126
-
127
- # you can pass arguments directly to Typhoeus using the :typhoeus_options key
128
- typhoeus_options = {:params => args}.merge(options[:typhoeus_options] || {})
129
-
130
- # by default, we use SSL only for private requests (e.g. with access token)
131
- # this makes public requests faster
132
- prefix = (args["access_token"] || @always_use_ssl || options[:use_ssl]) ? "https" : "http"
133
-
134
- response = self.send(verb, "#{prefix}://#{server(options)}#{path}", typhoeus_options)
135
- Koala::Response.new(response.code, response.body, response.headers_hash)
34
+ def self.params_require_multipart?(param_hash)
35
+ param_hash.any? { |key, value| value.kind_of?(Koala::UploadableIO) }
136
36
  end
137
-
138
- private
139
-
37
+
140
38
  def self.multipart_requires_content_type?
141
- false # Typhoeus handles multipart file types, we don't have to require it
39
+ true
142
40
  end
143
- end # class_eval
41
+ end
144
42
  end
145
43
  end
146
44
  end
@@ -0,0 +1,87 @@
1
+ require "net/http" unless defined?(Net::HTTP)
2
+ require "net/https"
3
+ require "net/http/post/multipart"
4
+
5
+ module Koala
6
+ module NetHTTPService
7
+ # this service uses Net::HTTP to send requests to the graph
8
+ include Koala::HTTPService
9
+
10
+ def self.make_request(path, args, verb, options = {})
11
+ # We translate args to a valid query string. If post is specified,
12
+ # we send a POST request to the given path with the given arguments.
13
+
14
+ # by default, we use SSL only for private requests
15
+ # this makes public requests faster
16
+ private_request = args["access_token"] || @always_use_ssl || options[:use_ssl]
17
+
18
+ # if proxy/timeout options aren't passed, check if defaults are set
19
+ options[:proxy] ||= proxy
20
+ options[:timeout] ||= timeout
21
+
22
+ # if the verb isn't get or post, send it as a post argument
23
+ args.merge!({:method => verb}) && verb = "post" if verb != "get" && verb != "post"
24
+
25
+ http = create_http(server(options), private_request, options)
26
+
27
+ result = http.start do |http|
28
+ response, body = if verb == "post"
29
+ if params_require_multipart? args
30
+ http.request Net::HTTP::Post::Multipart.new path, encode_multipart_params(args)
31
+ else
32
+ http.post(path, encode_params(args))
33
+ end
34
+ else
35
+ http.get("#{path}?#{encode_params(args)}")
36
+ end
37
+
38
+ Koala::Response.new(response.code.to_i, body, response)
39
+ end
40
+ end
41
+
42
+ protected
43
+ def self.encode_params(param_hash)
44
+ # unfortunately, we can't use to_query because that's Rails, not Ruby
45
+ # if no hash (e.g. no auth token) return empty string
46
+ ((param_hash || {}).collect do |key_and_value|
47
+ key_and_value[1] = key_and_value[1].to_json if key_and_value[1].class != String
48
+ "#{key_and_value[0].to_s}=#{CGI.escape key_and_value[1]}"
49
+ end).join("&")
50
+ end
51
+
52
+ def self.encode_multipart_params(param_hash)
53
+ Hash[*param_hash.collect do |key, value|
54
+ [key, value.kind_of?(Koala::UploadableIO) ? value.to_upload_io : value]
55
+ end.flatten]
56
+ end
57
+
58
+ def self.create_http(server, private_request, options)
59
+ if options[:proxy]
60
+ proxy = URI.parse(options[:proxy])
61
+ http = Net::HTTP.new(server, private_request ? 443 : nil,
62
+ proxy.host, proxy.port, proxy.user, proxy.password)
63
+ else
64
+ http = Net::HTTP.new(server, private_request ? 443 : nil)
65
+ end
66
+
67
+ if options[:timeout]
68
+ http.open_timeout = options[:timeout]
69
+ http.read_timeout = options[:timeout]
70
+ end
71
+
72
+ # For HTTPS requests, set the proper CA certificates
73
+ if private_request
74
+ http.use_ssl = true
75
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
76
+
77
+ options[:ca_file] ||= ca_file
78
+ http.ca_file = options[:ca_file] if options[:ca_file] && File.exists?(options[:ca_file])
79
+
80
+ options[:ca_path] ||= ca_path
81
+ http.ca_path = options[:ca_path] if options[:ca_path] && Dir.exists?(options[:ca_path])
82
+ end
83
+
84
+ http
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,37 @@
1
+ require "typhoeus" unless defined?(Typhoeus)
2
+
3
+ module Koala
4
+ module TyphoeusService
5
+ # this service uses Typhoeus to send requests to the graph
6
+ include Typhoeus
7
+ include Koala::HTTPService
8
+
9
+ def self.make_request(path, args, verb, options = {})
10
+ # if the verb isn't get or post, send it as a post argument
11
+ args.merge!({:method => verb}) && verb = "post" if verb != "get" && verb != "post"
12
+
13
+ # switch any UploadableIOs to the files Typhoeus expects
14
+ args.each_pair {|key, value| args[key] = value.to_file if value.is_a?(UploadableIO)}
15
+
16
+ # you can pass arguments directly to Typhoeus using the :typhoeus_options key
17
+ typhoeus_options = {:params => args}.merge(options[:typhoeus_options] || {})
18
+
19
+ # if proxy/timeout options aren't passed, check if defaults are set
20
+ typhoeus_options[:proxy] ||= proxy
21
+ typhoeus_options[:timeout] ||= timeout
22
+
23
+ # by default, we use SSL only for private requests (e.g. with access token)
24
+ # this makes public requests faster
25
+ prefix = (args["access_token"] || @always_use_ssl || options[:use_ssl]) ? "https" : "http"
26
+
27
+ response = Typhoeus::Request.send(verb, "#{prefix}://#{server(options)}#{path}", typhoeus_options)
28
+ Koala::Response.new(response.code, response.body, response.headers_hash)
29
+ end
30
+
31
+ protected
32
+
33
+ def self.multipart_requires_content_type?
34
+ false # Typhoeus handles multipart file types, we don't have to require it
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,181 @@
1
+ module Koala
2
+ module Facebook
3
+ class OAuth
4
+ attr_reader :app_id, :app_secret, :oauth_callback_url
5
+ def initialize(app_id, app_secret, oauth_callback_url = nil)
6
+ @app_id = app_id
7
+ @app_secret = app_secret
8
+ @oauth_callback_url = oauth_callback_url
9
+ end
10
+
11
+ def get_user_info_from_cookie(cookie_hash)
12
+ # Parses the cookie set by the official Facebook JavaScript SDK.
13
+ #
14
+ # cookies should be a Hash, like the one Rails provides
15
+ #
16
+ # If the user is logged in via Facebook, we return a dictionary with the
17
+ # keys "uid" and "access_token". The former is the user's Facebook ID,
18
+ # and the latter can be used to make authenticated requests to the Graph API.
19
+ # If the user is not logged in, we return None.
20
+ #
21
+ # Download the official Facebook JavaScript SDK at
22
+ # http://github.com/facebook/connect-js/. Read more about Facebook
23
+ # authentication at http://developers.facebook.com/docs/authentication/.
24
+
25
+ if fb_cookie = cookie_hash["fbs_" + @app_id.to_s]
26
+ # remove the opening/closing quote
27
+ fb_cookie = fb_cookie.gsub(/\"/, "")
28
+
29
+ # since we no longer get individual cookies, we have to separate out the components ourselves
30
+ components = {}
31
+ fb_cookie.split("&").map {|param| param = param.split("="); components[param[0]] = param[1]}
32
+
33
+ # generate the signature and make sure it matches what we expect
34
+ auth_string = components.keys.sort.collect {|a| a == "sig" ? nil : "#{a}=#{components[a]}"}.reject {|a| a.nil?}.join("")
35
+ sig = Digest::MD5.hexdigest(auth_string + @app_secret)
36
+ sig == components["sig"] && (components["expires"] == "0" || Time.now.to_i < components["expires"].to_i) ? components : nil
37
+ end
38
+ end
39
+ alias_method :get_user_info_from_cookies, :get_user_info_from_cookie
40
+
41
+ def get_user_from_cookie(cookies)
42
+ if info = get_user_info_from_cookies(cookies)
43
+ string = info["uid"]
44
+ end
45
+ end
46
+ alias_method :get_user_from_cookies, :get_user_from_cookie
47
+
48
+ # URLs
49
+
50
+ def url_for_oauth_code(options = {})
51
+ # for permissions, see http://developers.facebook.com/docs/authentication/permissions
52
+ permissions = options[:permissions]
53
+ scope = permissions ? "&scope=#{permissions.is_a?(Array) ? permissions.join(",") : permissions}" : ""
54
+ display = options.has_key?(:display) ? "&display=#{options[:display]}" : ""
55
+
56
+ callback = options[:callback] || @oauth_callback_url
57
+ raise ArgumentError, "url_for_oauth_code must get a callback either from the OAuth object or in the options!" unless callback
58
+
59
+ # Creates the URL for oauth authorization for a given callback and optional set of permissions
60
+ "https://#{GRAPH_SERVER}/oauth/authorize?client_id=#{@app_id}&redirect_uri=#{callback}#{scope}#{display}"
61
+ end
62
+
63
+ def url_for_access_token(code, options = {})
64
+ # Creates the URL for the token corresponding to a given code generated by Facebook
65
+ callback = options[:callback] || @oauth_callback_url
66
+ raise ArgumentError, "url_for_access_token must get a callback either from the OAuth object or in the parameters!" unless callback
67
+ "https://#{GRAPH_SERVER}/oauth/access_token?client_id=#{@app_id}&redirect_uri=#{callback}&client_secret=#{@app_secret}&code=#{code}"
68
+ end
69
+
70
+ def get_access_token_info(code, options = {})
71
+ # convenience method to get a parsed token from Facebook for a given code
72
+ # should this require an OAuth callback URL?
73
+ get_token_from_server({:code => code, :redirect_uri => @oauth_callback_url}, false, options)
74
+ end
75
+
76
+ def get_access_token(code, options = {})
77
+ # upstream methods will throw errors if needed
78
+ if info = get_access_token_info(code, options)
79
+ string = info["access_token"]
80
+ end
81
+ end
82
+
83
+ def get_app_access_token_info(options = {})
84
+ # convenience method to get a the application's sessionless access token
85
+ get_token_from_server({:type => 'client_cred'}, true, options)
86
+ end
87
+
88
+ def get_app_access_token(options = {})
89
+ if info = get_app_access_token_info(options)
90
+ string = info["access_token"]
91
+ end
92
+ end
93
+
94
+ # Originally provided directly by Facebook, however this has changed
95
+ # as their concept of crypto changed. For historic purposes, this is their proposal:
96
+ # https://developers.facebook.com/docs/authentication/canvas/encryption_proposal/
97
+ # Currently see https://github.com/facebook/php-sdk/blob/master/src/facebook.php#L758
98
+ # for a more accurate reference implementation strategy.
99
+ def parse_signed_request(input)
100
+ encoded_sig, encoded_envelope = input.split('.', 2)
101
+ signature = base64_url_decode(encoded_sig).unpack("H*").first
102
+ envelope = JSON.parse(base64_url_decode(encoded_envelope))
103
+
104
+ raise "SignedRequest: Unsupported algorithm #{envelope['algorithm']}" if envelope['algorithm'] != 'HMAC-SHA256'
105
+
106
+ # now see if the signature is valid (digest, key, data)
107
+ hmac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, @app_secret, encoded_envelope.tr("-_", "+/"))
108
+ raise 'SignedRequest: Invalid signature' if (signature != hmac)
109
+
110
+ return envelope
111
+ end
112
+
113
+ # from session keys
114
+ def get_token_info_from_session_keys(sessions, options = {})
115
+ # fetch the OAuth tokens from Facebook
116
+ response = fetch_token_string({
117
+ :type => 'client_cred',
118
+ :sessions => sessions.join(",")
119
+ }, true, "exchange_sessions", options)
120
+
121
+ # Facebook returns an empty body in certain error conditions
122
+ if response == ""
123
+ raise APIError.new({
124
+ "type" => "ArgumentError",
125
+ "message" => "get_token_from_session_key received an error (empty response body) for sessions #{sessions.inspect}!"
126
+ })
127
+ end
128
+
129
+ JSON.parse(response)
130
+ end
131
+
132
+ def get_tokens_from_session_keys(sessions, options = {})
133
+ # get the original hash results
134
+ results = get_token_info_from_session_keys(sessions, options)
135
+ # now recollect them as just the access tokens
136
+ results.collect { |r| r ? r["access_token"] : nil }
137
+ end
138
+
139
+ def get_token_from_session_key(session, options = {})
140
+ # convenience method for a single key
141
+ # gets the overlaoded strings automatically
142
+ get_tokens_from_session_keys([session], options)[0]
143
+ end
144
+
145
+ protected
146
+
147
+ def get_token_from_server(args, post = false, options = {})
148
+ # fetch the result from Facebook's servers
149
+ result = fetch_token_string(args, post, "access_token", options)
150
+
151
+ # if we have an error, parse the error JSON and raise an error
152
+ raise APIError.new((JSON.parse(result)["error"] rescue nil) || {}) if result =~ /error/
153
+
154
+ # otherwise, parse the access token
155
+ parse_access_token(result)
156
+ end
157
+
158
+ def parse_access_token(response_text)
159
+ components = response_text.split("&").inject({}) do |hash, bit|
160
+ key, value = bit.split("=")
161
+ hash.merge!(key => value)
162
+ end
163
+ components
164
+ end
165
+
166
+ def fetch_token_string(args, post = false, endpoint = "access_token", options = {})
167
+ Koala.make_request("/oauth/#{endpoint}", {
168
+ :client_id => @app_id,
169
+ :client_secret => @app_secret
170
+ }.merge!(args), post ? "post" : "get", {:use_ssl => true}.merge!(options)).body
171
+ end
172
+
173
+ # base 64
174
+ # directly from https://github.com/facebook/crypto-request-examples/raw/master/sample.rb
175
+ def base64_url_decode(str)
176
+ str += '=' * (4 - str.length.modulo(4))
177
+ Base64.decode64(str.tr('-_', '+/'))
178
+ end
179
+ end
180
+ end
181
+ end