omnigroupcontacts 0.3.10 → 0.3.11

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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +6 -0
  3. data/Gemfile +3 -0
  4. data/Gemfile.lock +39 -0
  5. data/README.md +132 -0
  6. data/Rakefile +7 -0
  7. data/lib/omnigroupcontacts.rb +19 -0
  8. data/lib/omnigroupcontacts/authorization/oauth1.rb +122 -0
  9. data/lib/omnigroupcontacts/authorization/oauth2.rb +87 -0
  10. data/lib/omnigroupcontacts/builder.rb +30 -0
  11. data/lib/omnigroupcontacts/http_utils.rb +101 -0
  12. data/lib/omnigroupcontacts/importer.rb +5 -0
  13. data/lib/omnigroupcontacts/importer/gmailgroup.rb +238 -0
  14. data/lib/omnigroupcontacts/integration_test.rb +36 -0
  15. data/lib/omnigroupcontacts/middleware/base_oauth.rb +120 -0
  16. data/lib/omnigroupcontacts/middleware/oauth1.rb +70 -0
  17. data/lib/omnigroupcontacts/middleware/oauth2.rb +80 -0
  18. data/lib/omnigroupcontacts/parse_utils.rb +56 -0
  19. data/omnigroupcontacts-0.3.10.gem +0 -0
  20. data/omnigroupcontacts-0.3.8.gem +0 -0
  21. data/omnigroupcontacts-0.3.9.gem +0 -0
  22. data/omnigroupcontacts.gemspec +25 -0
  23. data/spec/omnicontacts/authorization/oauth1_spec.rb +82 -0
  24. data/spec/omnicontacts/authorization/oauth2_spec.rb +92 -0
  25. data/spec/omnicontacts/http_utils_spec.rb +79 -0
  26. data/spec/omnicontacts/importer/facebook_spec.rb +120 -0
  27. data/spec/omnicontacts/importer/gmail_spec.rb +194 -0
  28. data/spec/omnicontacts/importer/hotmail_spec.rb +106 -0
  29. data/spec/omnicontacts/importer/linkedin_spec.rb +67 -0
  30. data/spec/omnicontacts/importer/yahoo_spec.rb +124 -0
  31. data/spec/omnicontacts/integration_test_spec.rb +51 -0
  32. data/spec/omnicontacts/middleware/base_oauth_spec.rb +53 -0
  33. data/spec/omnicontacts/middleware/oauth1_spec.rb +78 -0
  34. data/spec/omnicontacts/middleware/oauth2_spec.rb +67 -0
  35. data/spec/omnicontacts/parse_utils_spec.rb +53 -0
  36. data/spec/spec_helper.rb +12 -0
  37. metadata +37 -2
@@ -0,0 +1,101 @@
1
+ require "net/http"
2
+ require "net/https"
3
+ require "cgi"
4
+ require "openssl"
5
+
6
+ # This module contains a set of utility methods related to the HTTP protocol.
7
+ module OmniGroupContacts
8
+ module HTTPUtils
9
+
10
+ SSL_PORT = 443
11
+
12
+ module_function
13
+
14
+ def query_string_to_map query_string
15
+ query_string.split('&').reduce({}) do |memo, key_value|
16
+ (key, value) = key_value.split('=')
17
+ memo[key]= value
18
+ memo
19
+ end
20
+ end
21
+
22
+ def to_query_string map
23
+ map.collect do |key, value|
24
+ key.to_s + "=" + value.to_s
25
+ end.join("&")
26
+ end
27
+
28
+ # Encodes the given input according to RFC 3986
29
+ def encode to_encode
30
+ CGI.escape(to_encode)
31
+ end
32
+
33
+ # Calculates the url of the host from a Rack environment.
34
+ # The result is in the form scheme://host:port
35
+ # If port is 80 the result is scheme://host
36
+ # According to Rack specification the HTTP_HOST variable is preferred over SERVER_NAME.
37
+ def host_url_from_rack_env env
38
+ port = ((env["SERVER_PORT"] == 80) && "") || ":#{env['SERVER_PORT']}"
39
+ host = (env["HTTP_HOST"]) || (env["SERVER_NAME"] + port)
40
+ "#{scheme(env)}://#{host}"
41
+ end
42
+
43
+ def scheme env
44
+ if env['HTTPS'] == 'on'
45
+ 'https'
46
+ elsif env['HTTP_X_FORWARDED_SSL'] == 'on'
47
+ 'https'
48
+ elsif env['HTTP_X_FORWARDED_PROTO']
49
+ env['HTTP_X_FORWARDED_PROTO'].split(',').first
50
+ else
51
+ env["rack.url_scheme"]
52
+ end
53
+ end
54
+
55
+ # Classes including the module must respond to the ssl_ca_file message in order to use the following methods.
56
+ # The response will be the path to the CA file to use when making https requests.
57
+ # If the result of ssl_ca_file is nil no file is used. In this case a warn message is logged.
58
+ private
59
+
60
+ # Executes an HTTP GET request.
61
+ # It raises a RuntimeError if the response code is not equal to 200
62
+ def http_get host, path, params
63
+ connection = Net::HTTP.new(host)
64
+ process_http_response connection.request_get(path + "?" + to_query_string(params))
65
+ end
66
+
67
+ # Executes an HTTP POST request over SSL
68
+ # It raises a RuntimeError if the response code is not equal to 200
69
+ def https_post host, path, params
70
+ https_connection host do |connection|
71
+ connection.request_post(path, to_query_string(params))
72
+ end
73
+ end
74
+
75
+ # Executes an HTTP GET request over SSL
76
+ # It raises a RuntimeError if the response code is not equal to 200
77
+ def https_get host, path, params, headers = nil
78
+ https_connection host do |connection|
79
+ connection.request_get(path + "?" + to_query_string(params), headers)
80
+ end
81
+ end
82
+
83
+ def https_connection (host)
84
+ connection = Net::HTTP.new(host, SSL_PORT)
85
+ connection.use_ssl = true
86
+ if ssl_ca_file
87
+ connection.ca_file = ssl_ca_file
88
+ else
89
+ logger << "No SSL ca file provided. It is highly reccomended to use one in production envinronments" if respond_to?(:logger) && logger
90
+ connection.verify_mode = OpenSSL::SSL::VERIFY_NONE
91
+ end
92
+ process_http_response(yield(connection))
93
+ end
94
+
95
+ def process_http_response response
96
+ raise response.body if response.code != "200"
97
+ response.body
98
+ end
99
+
100
+ end
101
+ end
@@ -0,0 +1,5 @@
1
+ module OmniGroupContacts
2
+ module Importer
3
+ autoload :Gmailgroup, "omnigroupcontacts/importer/gmailgroup"
4
+ end
5
+ end
@@ -0,0 +1,238 @@
1
+ require "omnigroupcontacts/parse_utils"
2
+ require "omnigroupcontacts/middleware/oauth2"
3
+
4
+ module OmniGroupContacts
5
+ module Importer
6
+ class Gmailgroup < Middleware::OAuth2
7
+ include ParseUtils
8
+
9
+ attr_reader :auth_host, :authorize_path, :auth_token_path, :scope
10
+
11
+ def initialize *args
12
+ super *args
13
+ @auth_host = "accounts.google.com"
14
+ @authorize_path = "/o/oauth2/auth"
15
+ @auth_token_path = "/o/oauth2/token"
16
+ @scope = (args[3] && args[3][:scope]) || "https://www.google.com/m8/feeds https://www.googleapis.com/auth/userinfo#email https://www.googleapis.com/auth/userinfo.profile"
17
+ @contacts_host = "www.google.com"
18
+ @contacts_path = "/m8/feeds/groups/default/full"
19
+ @max_results = (args[3] && args[3][:max_results]) || 100
20
+ @self_host = "www.googleapis.com"
21
+ @profile_path = "/oauth2/v1/userinfo"
22
+ end
23
+
24
+ def fetch_groups_using_access_token access_token, token_type
25
+ fetch_current_user(access_token, token_type)
26
+ groups_response = https_get(@contacts_host, @contacts_path, contacts_req_params, contacts_req_headers(access_token, token_type))
27
+ groups_from_response groups_response
28
+ end
29
+
30
+ def fetch_contacts_using_access_token access_token, token_type, group_id
31
+ fetch_current_user(access_token, token_type)
32
+ @contacts_path = "/m8/feeds/contacts/default/full?&group=#{group_id}"
33
+ contacts_response = https_get(@contacts_host, @contacts_path, contacts_req_params, contacts_req_headers(access_token, token_type))
34
+ contacts_from_response contacts_response
35
+ end
36
+
37
+ def fetch_current_user access_token, token_type
38
+ self_response = https_get(@self_host, @profile_path, contacts_req_params, contacts_req_headers(access_token, token_type))
39
+ user = current_user(self_response, access_token, token_type)
40
+ set_current_user user
41
+ end
42
+
43
+ private
44
+
45
+ def contacts_req_params
46
+ {'max-results' => @max_results.to_s, 'alt' => 'json'}
47
+ end
48
+
49
+ def contacts_req_headers token, token_type
50
+ {"GData-Version" => "3.0", "Authorization" => "#{token_type} #{token}"}
51
+ end
52
+
53
+ def groups_from_response response_as_json
54
+ response = JSON.parse(response_as_json)
55
+
56
+ return [] if response['feed'].nil? || response['feed']['entry'].nil?
57
+ groups = []
58
+ return groups if response.nil?
59
+ response['feed']['entry'].each do |entry|
60
+ group = {
61
+ :id => nil,
62
+ :title => nil
63
+ }
64
+ group[:id] = entry['id']["$t"]
65
+ group[:title] = entry['title']["$t"]
66
+
67
+ groups << group
68
+ end
69
+ groups
70
+
71
+ end
72
+
73
+ def contacts_from_response response_as_json
74
+ response = JSON.parse(response_as_json)
75
+ return [] if response['feed'].nil? || response['feed']['entry'].nil?
76
+ contacts = []
77
+ return contacts if response.nil?
78
+ response['feed']['entry'].each do |entry|
79
+ # creating nil fields to keep the fields consistent across other networks
80
+
81
+ contact = { :id => nil,
82
+ :first_name => nil,
83
+ :last_name => nil,
84
+ :name => nil,
85
+ :emails => nil,
86
+ :gender => nil,
87
+ :birthday => nil,
88
+ :profile_picture=> nil,
89
+ :relation => nil,
90
+ :addresses => nil,
91
+ :phone_numbers => nil,
92
+ :dates => nil,
93
+ :company => nil,
94
+ :position => nil,
95
+ :groups => nil
96
+ }
97
+ contact[:id] = entry['id']['$t'] if entry['id']
98
+ if entry['gd$name']
99
+ gd_name = entry['gd$name']
100
+ contact[:first_name] = normalize_name(entry['gd$name']['gd$givenName']['$t']) if gd_name['gd$givenName']
101
+ contact[:last_name] = normalize_name(entry['gd$name']['gd$familyName']['$t']) if gd_name['gd$familyName']
102
+ contact[:name] = normalize_name(entry['gd$name']['gd$fullName']['$t']) if gd_name['gd$fullName']
103
+ contact[:name] = full_name(contact[:first_name],contact[:last_name]) if contact[:name].nil?
104
+ end
105
+
106
+ contact[:emails] = []
107
+ entry['gd$email'].each do |email|
108
+ if email['rel']
109
+ split_index = email['rel'].index('#')
110
+ contact[:emails] << {:name => email['rel'][split_index + 1, email['rel'].length - 1], :email => email['address']}
111
+ elsif email['label']
112
+ contact[:emails] << {:name => email['label'], :email => email['address']}
113
+ end
114
+ end if entry['gd$email']
115
+
116
+ # Support older versions of the gem by keeping singular entries around
117
+ contact[:email] = contact[:emails][0][:email] if contact[:emails][0]
118
+ contact[:first_name], contact[:last_name], contact[:name] = email_to_name(contact[:name]) if !contact[:name].nil? && contact[:name].include?('@')
119
+ contact[:first_name], contact[:last_name], contact[:name] = email_to_name(contact[:emails][0][:email]) if (contact[:name].nil? && contact[:emails][0] && contact[:emails][0][:email])
120
+ #format - year-month-date
121
+ contact[:birthday] = birthday(entry['gContact$birthday']['when']) if entry['gContact$birthday']
122
+
123
+ # value is either "male" or "female"
124
+ contact[:gender] = entry['gContact$gender']['value'] if entry['gContact$gender']
125
+
126
+ if entry['gContact$relation']
127
+ if entry['gContact$relation'].is_a?(Hash)
128
+ contact[:relation] = entry['gContact$relation']['rel']
129
+ elsif entry['gContact$relation'].is_a?(Array)
130
+ contact[:relation] = entry['gContact$relation'].first['rel']
131
+ end
132
+ end
133
+
134
+ contact[:addresses] = []
135
+ entry['gd$structuredPostalAddress'].each do |address|
136
+ if address['rel']
137
+ split_index = address['rel'].index('#')
138
+ new_address = {:name => address['rel'][split_index + 1, address['rel'].length - 1]}
139
+ elsif address['label']
140
+ new_address = {:name => address['label']}
141
+ end
142
+
143
+ new_address[:address_1] = address['gd$street']['$t'] if address['gd$street']
144
+ new_address[:address_1] = address['gd$formattedAddress']['$t'] if new_address[:address_1].nil? && address['gd$formattedAddress']
145
+ if new_address[:address_1].index("\n")
146
+ parts = new_address[:address_1].split("\n")
147
+ new_address[:address_1] = parts.first
148
+ # this may contain city/state/zip if user jammed it all into one string.... :-(
149
+ new_address[:address_2] = parts[1..-1].join(', ')
150
+ end
151
+ new_address[:city] = address['gd$city']['$t'] if address['gd$city']
152
+ new_address[:region] = address['gd$region']['$t'] if address['gd$region'] # like state or province
153
+ new_address[:country] = address['gd$country']['code'] if address['gd$country']
154
+ new_address[:postcode] = address['gd$postcode']['$t'] if address['gd$postcode']
155
+ contact[:addresses] << new_address
156
+ end if entry['gd$structuredPostalAddress']
157
+
158
+ # Support older versions of the gem by keeping singular entries around
159
+ if contact[:addresses][0]
160
+ contact[:address_1] = contact[:addresses][0][:address_1]
161
+ contact[:address_2] = contact[:addresses][0][:address_2]
162
+ contact[:city] = contact[:addresses][0][:city]
163
+ contact[:region] = contact[:addresses][0][:region]
164
+ contact[:country] = contact[:addresses][0][:country]
165
+ contact[:postcode] = contact[:addresses][0][:postcode]
166
+ end
167
+
168
+ contact[:phone_numbers] = []
169
+ entry['gd$phoneNumber'].each do |phone_number|
170
+ if phone_number['rel']
171
+ split_index = phone_number['rel'].index('#')
172
+ contact[:phone_numbers] << {:name => phone_number['rel'][split_index + 1, phone_number['rel'].length - 1], :number => phone_number['$t']}
173
+ elsif phone_number['label']
174
+ contact[:phone_numbers] << {:name => phone_number['label'], :number => phone_number['$t']}
175
+ end
176
+ end if entry['gd$phoneNumber']
177
+
178
+ # Support older versions of the gem by keeping singular entries around
179
+ contact[:phone_number] = contact[:phone_numbers][0][:number] if contact[:phone_numbers][0]
180
+
181
+ if entry['gContact$website'] && entry['gContact$website'][0]["rel"] == "profile"
182
+ contact[:id] = contact_id(entry['gContact$website'][0]["href"])
183
+ contact[:profile_picture] = image_url(contact[:id])
184
+ else
185
+ contact[:profile_picture] = image_url_from_email(contact[:email])
186
+ end
187
+
188
+ if entry['gContact$event']
189
+ contact[:dates] = []
190
+ entry['gContact$event'].each do |event|
191
+ if event['rel']
192
+ contact[:dates] << {:name => event['rel'], :date => birthday(event['gd$when']['startTime'])}
193
+ elsif event['label']
194
+ contact[:dates] << {:name => event['label'], :date => birthday(event['gd$when']['startTime'])}
195
+ end
196
+ end
197
+ end
198
+
199
+ if entry['gd$organization']
200
+ contact[:company] = entry['gd$organization'][0]['gd$orgName']['$t'] if entry['gd$organization'][0]['gd$orgName']
201
+ contact[:position] = entry['gd$organization'][0]['gd$orgTitle']['$t'] if entry['gd$organization'][0]['gd$orgTitle']
202
+ end
203
+
204
+ contacts << contact if contact[:name]
205
+ end
206
+ contacts.uniq! {|c| c[:email] || c[:profile_picture] || c[:name]}
207
+ contacts
208
+ end
209
+
210
+ def image_url gmail_id
211
+ return "https://profiles.google.com/s2/photos/profile/" + gmail_id if gmail_id
212
+ end
213
+
214
+ def current_user me, access_token, token_type
215
+ return nil if me.nil?
216
+ me = JSON.parse(me)
217
+ user = {:id => me['id'], :email => me['email'], :name => me['name'], :first_name => me['given_name'],
218
+ :last_name => me['family_name'], :gender => me['gender'], :birthday => birthday(me['birthday']), :profile_picture => image_url(me['id']),
219
+ :access_token => access_token, :token_type => token_type
220
+ }
221
+ user
222
+ end
223
+
224
+ def birthday dob
225
+ return nil if dob.nil?
226
+ birthday = dob.split('-')
227
+ return birthday_format(birthday[2], birthday[3], nil) if birthday.size == 4
228
+ return birthday_format(birthday[1], birthday[2], birthday[0]) if birthday.size == 3
229
+ end
230
+
231
+ def contact_id(profile_url)
232
+ id = (profile_url.present?) ? File.basename(profile_url) : nil
233
+ id
234
+ end
235
+
236
+ end
237
+ end
238
+ end
@@ -0,0 +1,36 @@
1
+ require 'singleton'
2
+
3
+ class IntegrationTest
4
+ include Singleton
5
+
6
+ attr_accessor :enabled
7
+
8
+ def initialize
9
+ enabled = false
10
+ clear_mocks
11
+ end
12
+
13
+ def clear_mocks
14
+ @mock = {}
15
+ end
16
+
17
+ def mock provider, mock
18
+ @mock[provider.to_sym] = mock
19
+ end
20
+
21
+ def mock_authorization_from_user provider
22
+ [302, {"Content-Type" => "application/x-www-form-urlencoded", "location" => provider.redirect_path}, []]
23
+ end
24
+
25
+ def mock_fetch_contacts provider
26
+ result = @mock[provider.class_name.to_sym] || []
27
+ if result.is_a? Array
28
+ result
29
+ elsif result.is_a? Hash
30
+ [result]
31
+ else
32
+ raise result.to_s
33
+ end
34
+ end
35
+
36
+ end
@@ -0,0 +1,120 @@
1
+ # This class contains the common behavior for middlewares
2
+ # implementing either versions of OAuth.
3
+ #
4
+ # Extending classes are required to implement
5
+ # the following methods:
6
+ # * request_authorization_from_user
7
+ # * fetch_contatcs
8
+ module OmniGroupContacts
9
+ module Middleware
10
+ class BaseOAuth
11
+
12
+ attr_reader :ssl_ca_file
13
+
14
+ def initialize app, options
15
+ @app = app
16
+ @listening_path = MOUNT_PATH + class_name
17
+ @ssl_ca_file = options[:ssl_ca_file]
18
+ end
19
+
20
+ def class_name
21
+ self.class.name.split('::').last.downcase
22
+ end
23
+
24
+ # Rack callback. It handles three cases:
25
+ # * user visit middleware entry point.
26
+ # In this case request_authorization_from_user is called
27
+ # * user is redirected back to the application
28
+ # from the authorization site. In this case the list
29
+ # of contacts is fetched and stored in the variables
30
+ # omnigroupcontacts.contacts within the Rack env variable.
31
+ # Once that is done the next middleware component is called.
32
+ # * user visits any other resource. In this case the request
33
+ # is simply forwarded to the next middleware component.
34
+ def call env
35
+ @env = env
36
+ if env["PATH_INFO"] =~ /^#{@listening_path}\/?$/
37
+ handle_initial_request
38
+ elsif env["PATH_INFO"] =~ /^#{redirect_path}/
39
+ handle_callback
40
+ else
41
+ @app.call(env)
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def test_mode?
48
+ IntegrationTest.instance.enabled
49
+ end
50
+
51
+ def handle_initial_request
52
+ execute_and_rescue_exceptions do
53
+ if test_mode?
54
+ IntegrationTest.instance.mock_authorization_from_user(self)
55
+ else
56
+ request_authorization_from_user
57
+ end
58
+ end
59
+ end
60
+
61
+ def handle_callback
62
+ execute_and_rescue_exceptions do
63
+ @env["omnicontacts.contacts"] = if test_mode?
64
+ IntegrationTest.instance.mock_fetch_contacts(self)
65
+ else
66
+ fetch_groups
67
+ end
68
+ @app.call(@env)
69
+ end
70
+ end
71
+
72
+ def set_current_user user
73
+ @env["omnigroupcontacts.user"] = user
74
+ end
75
+
76
+ # This method rescues executes a block of code and
77
+ # rescue all exceptions. In case of an exception the
78
+ # user is redirected to the failure endpoint.
79
+ def execute_and_rescue_exceptions
80
+ yield
81
+ rescue AuthorizationError => e
82
+ handle_error :not_authorized, e
83
+ rescue ::Timeout::Error, ::Errno::ETIMEDOUT => e
84
+ handle_error :timeout, e
85
+ rescue ::RuntimeError => e
86
+ handle_error :internal_error, e
87
+ end
88
+
89
+ def handle_error error_type, exception
90
+ logger.puts("Error #{error_type} while processing #{@env["PATH_INFO"]}: #{exception.message}") if logger
91
+ failure_url = "#{ MOUNT_PATH }failure?error_message=#{error_type}&importer=#{class_name}"
92
+ target_url = append_state_query(failure_url)
93
+ [302, {"Content-Type" => "text/html", "location" => target_url}, []]
94
+ end
95
+
96
+ def session
97
+ raise "You must provide a session to use omnigroupcontacts" unless @env["rack.session"]
98
+ @env["rack.session"]
99
+ end
100
+
101
+ def logger
102
+ @env["rack.errors"] if @env
103
+ end
104
+
105
+ def base_prop_name
106
+ "omnigroupcontacts." + class_name
107
+ end
108
+
109
+ def append_state_query(target_url)
110
+ state = Rack::Utils.parse_query(@env['QUERY_STRING'])['state']
111
+
112
+ unless state.nil?
113
+ target_url = target_url + (target_url.include?("?")?"&":"?") + 'state=' + state
114
+ end
115
+
116
+ return target_url
117
+ end
118
+ end
119
+ end
120
+ end