koala 0.10.0 → 1.0.0.beta2

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 (34) hide show
  1. data/CHANGELOG +33 -7
  2. data/Manifest +8 -1
  3. data/Rakefile +4 -4
  4. data/koala.gemspec +10 -8
  5. data/lib/koala/graph_api.rb +188 -123
  6. data/lib/koala/http_services.rb +93 -17
  7. data/lib/koala/rest_api.rb +73 -6
  8. data/lib/koala/test_users.rb +18 -5
  9. data/lib/koala/uploadable_io.rb +115 -0
  10. data/lib/koala.rb +95 -72
  11. data/readme.md +18 -12
  12. data/spec/facebook_data.yml +9 -3
  13. data/spec/koala/assets/beach.jpg +0 -0
  14. data/spec/koala/graph_and_rest_api/graph_and_rest_api_no_token_tests.rb +5 -1
  15. data/spec/koala/graph_and_rest_api/graph_and_rest_api_with_token_tests.rb +8 -3
  16. data/spec/koala/graph_api/graph_api_no_access_token_tests.rb +12 -61
  17. data/spec/koala/graph_api/graph_api_tests.rb +85 -0
  18. data/spec/koala/graph_api/graph_api_with_access_token_tests.rb +167 -123
  19. data/spec/koala/http_services/http_service_tests.rb +51 -0
  20. data/spec/koala/http_services/net_http_service_tests.rb +339 -0
  21. data/spec/koala/http_services/typhoeus_service_tests.rb +162 -0
  22. data/spec/koala/live_testing_data_helper.rb +1 -1
  23. data/spec/koala/oauth/oauth_tests.rb +35 -64
  24. data/spec/koala/rest_api/rest_api_no_access_token_tests.rb +5 -74
  25. data/spec/koala/rest_api/rest_api_tests.rb +118 -0
  26. data/spec/koala/rest_api/rest_api_with_access_token_tests.rb +5 -3
  27. data/spec/koala/test_users/test_users_tests.rb +49 -48
  28. data/spec/koala/uploadable_io/uploadable_io_tests.rb +246 -0
  29. data/spec/koala_spec_helper.rb +30 -4
  30. data/spec/koala_spec_without_mocks.rb +3 -3
  31. data/spec/mock_facebook_responses.yml +41 -18
  32. data/spec/mock_http_service.rb +16 -3
  33. metadata +38 -8
  34. data/spec/koala/net_http_service_tests.rb +0 -186
@@ -8,63 +8,139 @@ module Koala
8
8
  end
9
9
  end
10
10
 
11
+ module HTTPService
12
+ # common functionality for all HTTP services
13
+ def self.included(base)
14
+ base.class_eval do
15
+ class << self
16
+ attr_accessor :always_use_ssl
17
+ end
18
+
19
+ def self.server(options = {})
20
+ "#{options[:beta] ? "beta." : ""}#{options[:rest_api] ? Facebook::REST_SERVER : Facebook::GRAPH_SERVER}"
21
+ end
22
+
23
+ protected
24
+
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
+
11
36
  module NetHTTPService
12
37
  # this service uses Net::HTTP to send requests to the graph
13
38
  def self.included(base)
14
39
  base.class_eval do
15
- require 'net/http' unless defined?(Net::HTTP)
16
- require 'net/https'
40
+ require "net/http" unless defined?(Net::HTTP)
41
+ require "net/https"
42
+ require "net/http/post/multipart"
43
+
44
+ include Koala::HTTPService
17
45
 
18
46
  def self.make_request(path, args, verb, options = {})
19
47
  # We translate args to a valid query string. If post is specified,
20
48
  # we send a POST request to the given path with the given arguments.
21
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
+
22
54
  # if the verb isn't get or post, send it as a post argument
23
55
  args.merge!({:method => verb}) && verb = "post" if verb != "get" && verb != "post"
24
56
 
25
- server = options[:rest_api] ? Facebook::REST_SERVER : Facebook::GRAPH_SERVER
26
- http = Net::HTTP.new(server, 443)
27
- http.use_ssl = true
28
- # we turn off certificate validation to avoid the
57
+ http = Net::HTTP.new(server(options), private_request ? 443 : nil)
58
+ http.use_ssl = true if private_request
59
+
60
+ # we turn off certificate validation to avoid the
29
61
  # "warning: peer certificate won't be verified in this SSL session" warning
30
62
  # not sure if this is the right way to handle it
31
63
  # see http://redcorundum.blogspot.com/2008/03/ssl-certificates-and-nethttps.html
32
64
  http.verify_mode = OpenSSL::SSL::VERIFY_NONE
33
65
 
34
- result = http.start { |http|
35
- response, body = (verb == "post" ? http.post(path, encode_params(args)) : http.get("#{path}?#{encode_params(args)}"))
66
+ result = http.start do |http|
67
+ response, body = if verb == "post"
68
+ if params_require_multipart? args
69
+ http.request Net::HTTP::Post::Multipart.new path, encode_multipart_params(args)
70
+ else
71
+ http.post(path, encode_params(args))
72
+ end
73
+ else
74
+ http.get("#{path}?#{encode_params(args)}")
75
+ end
76
+
36
77
  Koala::Response.new(response.code.to_i, body, response)
37
- }
78
+ end
38
79
  end
39
80
 
40
81
  protected
41
82
  def self.encode_params(param_hash)
42
83
  # unfortunately, we can't use to_query because that's Rails, not Ruby
43
84
  # if no hash (e.g. no auth token) return empty string
44
- ((param_hash || {}).collect do |key_and_value|
85
+ ((param_hash || {}).collect do |key_and_value|
45
86
  key_and_value[1] = key_and_value[1].to_json if key_and_value[1].class != String
46
87
  "#{key_and_value[0].to_s}=#{CGI.escape key_and_value[1]}"
47
88
  end).join("&")
48
89
  end
90
+
91
+ def self.encode_multipart_params(param_hash)
92
+ Hash[*param_hash.collect do |key, value|
93
+ [key, value.kind_of?(Koala::UploadableIO) ? value.to_upload_io : value]
94
+ end.flatten]
95
+ end
49
96
  end
50
97
  end
51
98
  end
52
99
 
53
100
  module TyphoeusService
54
101
  # this service uses Typhoeus to send requests to the graph
102
+
103
+ # used for multipart file uploads (see below)
104
+ class NetHTTPInterface
105
+ include NetHTTPService
106
+ end
107
+
55
108
  def self.included(base)
56
109
  base.class_eval do
57
- require 'typhoeus' unless defined?(Typhoeus)
110
+ require "typhoeus" unless defined?(Typhoeus)
58
111
  include Typhoeus
112
+
113
+ include Koala::HTTPService
59
114
 
60
115
  def self.make_request(path, args, verb, options = {})
61
- # if the verb isn't get or post, send it as a post argument
62
- args.merge!({:method => verb}) && verb = "post" if verb != "get" && verb != "post"
63
- server = options[:rest_api] ? Facebook::REST_SERVER : Facebook::GRAPH_SERVER
64
- typhoeus_options = {:params => args}.merge(options[:typhoeus_options] || {})
65
- response = self.send(verb, "https://#{server}#{path}", typhoeus_options)
66
- Koala::Response.new(response.code, response.body, response.headers_hash)
116
+ unless params_require_multipart?(args)
117
+ # if the verb isn't get or post, send it as a post argument
118
+ args.merge!({:method => verb}) && verb = "post" if verb != "get" && verb != "post"
119
+
120
+ # switch any UploadableIOs to the files Typhoeus expects
121
+ # args.each_pair {|key, value| args[key] = value.to_file if value.is_a?(UploadableIO)}
122
+
123
+ # you can pass arguments directly to Typhoeus using the :typhoeus_options key
124
+ typhoeus_options = {:params => args}.merge(options[:typhoeus_options] || {})
125
+
126
+ # by default, we use SSL only for private requests (e.g. with access token)
127
+ # this makes public requests faster
128
+ prefix = (args["access_token"] || @always_use_ssl || options[:use_ssl]) ? "https" : "http"
129
+
130
+ response = self.send(verb, "#{prefix}://#{server(options)}#{path}", typhoeus_options)
131
+ Koala::Response.new(response.code, response.body, response.headers_hash)
132
+ else
133
+ # we have to use NetHTTPService for multipart for file uploads
134
+ # until Typhoeus integrates support
135
+ Koala::TyphoeusService::NetHTTPInterface.make_request(path, args, verb, options)
136
+ end
67
137
  end
138
+
139
+ private
140
+ # Typhoeus file uploads are not currently supported; this will be added when we can get them working
141
+ #def self.multipart_requires_content_type?
142
+ # false # Typhoeus handles multipart file types, we don't have to require it
143
+ #end
68
144
  end # class_eval
69
145
  end
70
146
  end
@@ -1,23 +1,90 @@
1
1
  module Koala
2
2
  module Facebook
3
3
  REST_SERVER = "api.facebook.com"
4
-
4
+
5
5
  module RestAPIMethods
6
6
  def fql_query(fql)
7
7
  rest_call('fql.query', 'query' => fql)
8
8
  end
9
-
10
- def rest_call(method, args = {})
11
- response = api("method/#{method}", args.merge('format' => 'json'), 'get', :rest_api => true) do |response|
9
+
10
+ def rest_call(method, args = {}, options = {})
11
+ options = options.merge!(:rest_api => true, :read_only => READ_ONLY_METHODS.include?(method))
12
+
13
+ response = api("method/#{method}", args.merge('format' => 'json'), 'get', options) do |response|
12
14
  # check for REST API-specific errors
13
15
  if response.is_a?(Hash) && response["error_code"]
14
16
  raise APIError.new("type" => response["error_code"], "message" => response["error_msg"])
15
17
  end
16
18
  end
17
-
19
+
18
20
  response
19
21
  end
22
+
23
+ # read-only methods for which we can use API-read
24
+ # taken directly from the FB PHP library (https://github.com/facebook/php-sdk/blob/master/src/facebook.php)
25
+ READ_ONLY_METHODS = [
26
+ 'admin.getallocation',
27
+ 'admin.getappproperties',
28
+ 'admin.getbannedusers',
29
+ 'admin.getlivestreamvialink',
30
+ 'admin.getmetrics',
31
+ 'admin.getrestrictioninfo',
32
+ 'application.getpublicinfo',
33
+ 'auth.getapppublickey',
34
+ 'auth.getsession',
35
+ 'auth.getsignedpublicsessiondata',
36
+ 'comments.get',
37
+ 'connect.getunconnectedfriendscount',
38
+ 'dashboard.getactivity',
39
+ 'dashboard.getcount',
40
+ 'dashboard.getglobalnews',
41
+ 'dashboard.getnews',
42
+ 'dashboard.multigetcount',
43
+ 'dashboard.multigetnews',
44
+ 'data.getcookies',
45
+ 'events.get',
46
+ 'events.getmembers',
47
+ 'fbml.getcustomtags',
48
+ 'feed.getappfriendstories',
49
+ 'feed.getregisteredtemplatebundlebyid',
50
+ 'feed.getregisteredtemplatebundles',
51
+ 'fql.multiquery',
52
+ 'fql.query',
53
+ 'friends.arefriends',
54
+ 'friends.get',
55
+ 'friends.getappusers',
56
+ 'friends.getlists',
57
+ 'friends.getmutualfriends',
58
+ 'gifts.get',
59
+ 'groups.get',
60
+ 'groups.getmembers',
61
+ 'intl.gettranslations',
62
+ 'links.get',
63
+ 'notes.get',
64
+ 'notifications.get',
65
+ 'pages.getinfo',
66
+ 'pages.isadmin',
67
+ 'pages.isappadded',
68
+ 'pages.isfan',
69
+ 'permissions.checkavailableapiaccess',
70
+ 'permissions.checkgrantedapiaccess',
71
+ 'photos.get',
72
+ 'photos.getalbums',
73
+ 'photos.gettags',
74
+ 'profile.getinfo',
75
+ 'profile.getinfooptions',
76
+ 'stream.get',
77
+ 'stream.getcomments',
78
+ 'stream.getfilters',
79
+ 'users.getinfo',
80
+ 'users.getloggedinuser',
81
+ 'users.getstandardinfo',
82
+ 'users.hasapppermission',
83
+ 'users.isappuser',
84
+ 'users.isverified',
85
+ 'video.getuploadlimits'
86
+ ]
20
87
  end
21
-
88
+
22
89
  end # module Facebook
23
90
  end # module Koala
@@ -40,12 +40,25 @@ module Koala
40
40
  list.each {|u| delete u }
41
41
  end
42
42
 
43
- def befriend(user1, user2)
44
- user1 = user1["id"] if user1.is_a?(Hash)
45
- user2 = user2["id"] if user2.is_a?(Hash)
46
- @graph_api.graph_call("/#{user1}/friends/#{user2}") && @graph_api.graph_call("/#{user2}/friends/#{user1}")
43
+ def befriend(user1_hash, user2_hash)
44
+ user1_id = user1_hash["id"] || user1_hash[:id]
45
+ user2_id = user2_hash["id"] || user2_hash[:id]
46
+ user1_token = user1_hash["access_token"] || user1_hash[:access_token]
47
+ user2_token = user2_hash["access_token"] || user2_hash[:access_token]
48
+ unless user1_id && user2_id && user1_token && user2_token
49
+ # we explicitly raise an error here to minimize the risk of confusing output
50
+ # if you pass in a string (as was previously supported) no local exception would be raised
51
+ # but the Facebook call would fail
52
+ raise ArgumentError, "TestUsers#befriend requires hash arguments with id and access_token"
53
+ end
54
+
55
+ u1_graph_api = GraphAPI.new(user1_token)
56
+ u2_graph_api = GraphAPI.new(user2_token)
57
+
58
+ u1_graph_api.graph_call("#{user1_id}/friends/#{user2_id}", {}, "post") &&
59
+ u2_graph_api.graph_call("#{user2_id}/friends/#{user1_id}", {}, "post")
47
60
  end
48
-
61
+
49
62
  def create_network(network_size, installed = true, permissions = '')
50
63
  network_size = 50 if network_size > 50 # FB's max is 50
51
64
  users = (0...network_size).collect { create(installed, permissions) }
@@ -0,0 +1,115 @@
1
+ require 'koala'
2
+
3
+ module Koala
4
+ class UploadableIO
5
+ attr_reader :io_or_path, :content_type
6
+
7
+ def initialize(io_or_path_or_mixed, content_type = nil)
8
+ # see if we got the right inputs
9
+ if content_type.nil?
10
+ parse_init_mixed_param io_or_path_or_mixed
11
+ elsif !content_type.nil? && (io_or_path_or_mixed.respond_to?(:read) or io_or_path_or_mixed.kind_of?(String))
12
+ @io_or_path = io_or_path_or_mixed
13
+ @content_type = content_type
14
+ end
15
+
16
+ raise KoalaError.new("Invalid arguments to initialize an UploadableIO") unless @io_or_path
17
+ raise KoalaError.new("Unable to determine MIME type for UploadableIO") if !@content_type && Koala.multipart_requires_content_type?
18
+ end
19
+
20
+ def to_upload_io
21
+ UploadIO.new(@io_or_path, @content_type, "koala-io-file.dum")
22
+ end
23
+
24
+ def to_file
25
+ @io_or_path.is_a?(String) ? File.open(@io_or_path) : @io_or_path
26
+ end
27
+
28
+ 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
41
+ 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.respond_to?(:path)
46
+ @io_or_path = uploaded_file.tempfile.path
47
+ @content_type = uploaded_file.content_type
48
+ end
49
+ 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
57
+ 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
65
+ 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
72
+ 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
85
+ end
86
+ nil # if we can't find anything
87
+ 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
98
+ 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 == ""
105
+ nil
106
+ elsif extension == "jpg" || extension == "jpeg"
107
+ "image/jpeg"
108
+ elsif extension == "png"
109
+ "image/png"
110
+ elsif extension == "gif"
111
+ "image/gif"
112
+ end
113
+ end
114
+ end
115
+ end