koala 1.0.0 → 1.1.0rc

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.
@@ -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