mislav_contacts 0.2.7

Sign up to get free protection for your applications and to get access to all the features.
data/LICENSE ADDED
@@ -0,0 +1,18 @@
1
+ Copyright (c) 2009 Mislav Marohnić
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
4
+ this software and associated documentation files (the "Software"), to deal in
5
+ the Software without restriction, including without limitation the rights to
6
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
7
+ the Software, and to permit persons to whom the Software is furnished to do so,
8
+ subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
15
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
16
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
17
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
18
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.rdoc ADDED
@@ -0,0 +1,51 @@
1
+ == Basic usage instructions
2
+
3
+ Fetch users' contact lists from your web application without asking them to
4
+ provide their passwords.
5
+
6
+ First, register[http://code.google.com/apis/accounts/docs/RegistrationForWebAppsAuto.html]
7
+ your application's domain. Then make users follow this URL:
8
+
9
+ Contacts::Google.authentication_url('http://mysite.com/invite')
10
+
11
+ They will authenticate on Google and it will send them back to the URL
12
+ provided. Google will add a token GET parameter to the query part of the URL.
13
+ Use that token in the next step:
14
+
15
+ gmail = Contacts::Google.new(params[:token])
16
+ gmail.contacts
17
+ # => [#<Contact 1>, #<Contact 2>, ...]
18
+
19
+ The resulting Contacts::Contact objects have `name` and `email` properties.
20
+
21
+ Read more in Contacts::Google. I plan to support more APIs (Microsoft Live, for
22
+ starters); feel free to contribute.
23
+
24
+ Author: <b>Mislav Marohnić</b> (mislav.marohnic@gmail.com)
25
+
26
+ == Dependencies
27
+
28
+ Please note that you will need to install the json gem if you are not using this gem as part of a Rails project.
29
+
30
+ == Documentation auto-generated from specifications
31
+
32
+ Contacts::Google.authentication_url
33
+ - generates a URL for target with default parameters
34
+ - should handle boolean parameters
35
+ - skips parameters that have nil value
36
+ - should be able to exchange one-time for session token
37
+
38
+ Contacts::Google
39
+ - fetches contacts feed via HTTP GET
40
+ - handles a normal response body
41
+ - handles gzipped response
42
+ - raises a FetchingError when something goes awry
43
+ - parses the resulting feed into name/email pairs
44
+ - parses a complex feed into name/email pairs
45
+ - makes modification time available after parsing
46
+
47
+ Contacts::Google GET query parameter handling
48
+ - abstracts ugly parameters behind nicer ones
49
+ - should have implicit :descending with :order
50
+ - should have default :limit of 200
51
+ - should skip nil values in parameters
@@ -0,0 +1,10 @@
1
+ windows_live:
2
+ appid: your_app_id
3
+ secret: your_app_secret_key
4
+ security_algorithm: wsignin1.0
5
+ return_url: http://yourserver.com/your_return_url
6
+ policy_url: http://yourserver.com/you_policy_url
7
+
8
+ yahoo:
9
+ appid: your_app_id
10
+ secret: your_shared_secret
data/lib/contacts.rb ADDED
@@ -0,0 +1,51 @@
1
+ require 'contacts/version'
2
+
3
+ module Contacts
4
+
5
+ Identifier = 'Ruby Contacts v' + VERSION::STRING
6
+
7
+ # An object that represents a single contact
8
+ class Contact
9
+ attr_reader :name, :username, :emails
10
+
11
+ def initialize(email, name = nil, username = nil)
12
+ @emails = []
13
+ @emails << email if email
14
+ @name = name
15
+ @username = username
16
+ end
17
+
18
+ def email
19
+ @emails.first
20
+ end
21
+
22
+ def inspect
23
+ %!#<Contacts::Contact "#{name}"#{email ? " (#{email})" : ''}>!
24
+ end
25
+ end
26
+
27
+ def self.verbose?
28
+ 'irb' == $0
29
+ end
30
+
31
+ class Error < StandardError
32
+ end
33
+
34
+ class TooManyRedirects < Error
35
+ attr_reader :response, :location
36
+
37
+ MAX_REDIRECTS = 2
38
+
39
+ def initialize(response)
40
+ @response = response
41
+ @location = @response['Location']
42
+ super "exceeded maximum of #{MAX_REDIRECTS} redirects (Location: #{location})"
43
+ end
44
+ end
45
+ end
46
+
47
+ require 'contacts/flickr'
48
+ require 'contacts/google'
49
+ require 'contacts/windows_live'
50
+ require 'contacts/yahoo'
51
+
@@ -0,0 +1,131 @@
1
+ require 'rubygems'
2
+ require 'hpricot'
3
+ require 'md5'
4
+ require 'cgi'
5
+ require 'time'
6
+ require 'zlib'
7
+ require 'stringio'
8
+ require 'net/http'
9
+
10
+ module Contacts
11
+
12
+ class Flickr
13
+ DOMAIN = 'api.flickr.com'
14
+ ServicesPath = '/services/rest/'
15
+
16
+ def self.frob_url(key, secret)
17
+ url_for(:api_key => key, :secret => secret, :method => 'flickr.auth.getFrob')
18
+ end
19
+
20
+ def self.frob_from_response(response)
21
+ doc = Hpricot::XML response.body
22
+ doc.at('frob').inner_text
23
+ end
24
+
25
+ def self.authentication_url_for_frob(frob, key, secret)
26
+ params = { :api_key => key, :secret => secret, :perms => 'read', :frob => frob }
27
+ 'http://www.flickr.com/services/auth/?' + query_string(params)
28
+ end
29
+
30
+ def self.authentication_url(key, secret)
31
+ response = http_start do |flickr|
32
+ flickr.get(frob_url(key, secret))
33
+ end
34
+ authentication_url_for_frob(frob_from_response(response), key, secret)
35
+ end
36
+
37
+ def self.token_url(key, secret, frob)
38
+ params = { :api_key => key, :secret => secret, :frob => frob, :method => 'flickr.auth.getToken' }
39
+ url_for(params)
40
+ end
41
+
42
+ def self.get_token_from_frob(key, secret, frob)
43
+ response = http_start do |flickr|
44
+ flickr.get(token_url(key, secret, frob))
45
+ end
46
+ doc = Hpricot::XML response.body
47
+ doc.at('token').inner_text
48
+ end
49
+
50
+ private
51
+ # Use the key-sorted version of the parameters to construct
52
+ # a string, to which the secret is prepended.
53
+
54
+ def self.sort_params(params)
55
+ params.sort do |a,b|
56
+ a.to_s <=> b.to_s
57
+ end
58
+ end
59
+
60
+ def self.string_to_sign(params, secret)
61
+ string_to_sign = secret + sort_params(params).inject('') do |str, pair|
62
+ key, value = pair
63
+ str + key.to_s + value.to_s
64
+ end
65
+ end
66
+
67
+ # Get the MD5 digest of the string to sign
68
+ def self.get_signature(params, secret)
69
+ ::Digest::MD5.hexdigest(string_to_sign(params, secret))
70
+ end
71
+
72
+ def self.query_string(params)
73
+ secret = params.delete(:secret)
74
+ params[:api_sig] = get_signature(params, secret)
75
+
76
+ params.inject([]) do |arr, pair|
77
+ key, value = pair
78
+ arr << "#{key}=#{value}"
79
+ end.join('&')
80
+ end
81
+
82
+ def self.url_for(params)
83
+ ServicesPath + '?' + query_string(params)
84
+ end
85
+
86
+ def self.http_start(ssl = false)
87
+ port = ssl ? Net::HTTP::https_default_port : Net::HTTP::http_default_port
88
+ http = Net::HTTP.new(DOMAIN, port)
89
+ redirects = 0
90
+ if ssl
91
+ http.use_ssl = true
92
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
93
+ end
94
+ http.start
95
+
96
+ begin
97
+ response = yield(http)
98
+
99
+ loop do
100
+ inspect_response(response) if Contacts::verbose?
101
+
102
+ case response
103
+ when Net::HTTPSuccess
104
+ break response
105
+ when Net::HTTPRedirection
106
+ if redirects == TooManyRedirects::MAX_REDIRECTS
107
+ raise TooManyRedirects.new(response)
108
+ end
109
+ location = URI.parse response['Location']
110
+ puts "Redirected to #{location}"
111
+ response = http.get(location.path)
112
+ redirects += 1
113
+ else
114
+ response.error!
115
+ end
116
+ end
117
+ ensure
118
+ http.finish
119
+ end
120
+ end
121
+
122
+ def self.inspect_response(response, out = $stderr)
123
+ out.puts response.inspect
124
+ for name, value in response
125
+ out.puts "#{name}: #{value}"
126
+ end
127
+ out.puts "----\n#{response.body}\n----" unless response.body.empty?
128
+ end
129
+ end
130
+
131
+ end
@@ -0,0 +1,304 @@
1
+ require 'rubygems'
2
+ require 'hpricot'
3
+ require 'cgi'
4
+ require 'time'
5
+ require 'zlib'
6
+ require 'stringio'
7
+ require 'net/http'
8
+ require 'net/https'
9
+
10
+ module Contacts
11
+ # == Fetching Google Contacts
12
+ #
13
+ # First, get the user to follow the following URL:
14
+ #
15
+ # Contacts::Google.authentication_url('http://mysite.com/invite')
16
+ #
17
+ # After he authenticates successfully to Google, it will redirect him back to the target URL
18
+ # (specified as argument above) and provide the token GET parameter. Use it to create a
19
+ # new instance of this class and request the contact list:
20
+ #
21
+ # gmail = Contacts::Google.new(params[:token])
22
+ # gmail.contacts
23
+ # # => [#<Contact 1>, #<Contact 2>, ...]
24
+ #
25
+ # == Storing a session token
26
+ #
27
+ # The basic token that you will get after the user has authenticated on Google is valid
28
+ # for <b>only one request</b>. However, you can specify that you want a session token which
29
+ # doesn't expire:
30
+ #
31
+ # Contacts::Google.authentication_url('http://mysite.com/invite', :session => true)
32
+ #
33
+ # When the user authenticates, he will be redirected back with a token that can be exchanged
34
+ # for a session token with the following method:
35
+ #
36
+ # token = Contacts::Google.sesion_token(params[:token])
37
+ #
38
+ # Now you have a permanent token. Store it with other user data so you can query the API
39
+ # on his behalf without him having to authenticate on Google each time.
40
+ class Google
41
+ DOMAIN = 'www.google.com'
42
+ AuthSubPath = '/accounts/AuthSub' # all variants go over HTTPS
43
+ ClientLogin = '/accounts/ClientLogin'
44
+ FeedsPath = '/m8/feeds/contacts/'
45
+
46
+ # default options for #authentication_url
47
+ def self.authentication_url_options
48
+ @authentication_url_options ||= {
49
+ :scope => "http://#{DOMAIN}#{FeedsPath}",
50
+ :secure => false,
51
+ :session => false
52
+ }
53
+ end
54
+
55
+ # default options for #client_login
56
+ def self.client_login_options
57
+ @client_login_options ||= {
58
+ :accountType => 'GOOGLE',
59
+ :service => 'cp',
60
+ :source => 'Contacts-Ruby'
61
+ }
62
+ end
63
+
64
+ # URL to Google site where user authenticates. Afterwards, Google redirects to your
65
+ # site with the URL specified as +target+.
66
+ #
67
+ # Options are:
68
+ # * <tt>:scope</tt> -- the AuthSub scope in which the resulting token is valid
69
+ # (default: "http://www.google.com/m8/feeds/contacts/")
70
+ # * <tt>:secure</tt> -- boolean indicating whether the token will be secure. Only available
71
+ # for registered domains.
72
+ # (default: false)
73
+ # * <tt>:session</tt> -- boolean indicating if the token can be exchanged for a session token
74
+ # (default: false)
75
+ def self.authentication_url(target, options = {})
76
+ params = authentication_url_options.merge(options)
77
+ params[:next] = target
78
+ query = query_string(params)
79
+ "https://#{DOMAIN}#{AuthSubPath}Request?#{query}"
80
+ end
81
+
82
+ # Makes an HTTPS request to exchange the given token with a session one. Session
83
+ # tokens never expire, so you can store them in the database alongside user info.
84
+ #
85
+ # Returns the new token as string or nil if the parameter couldn't be found in response
86
+ # body.
87
+ def self.session_token(token)
88
+ response = http_start do |google|
89
+ google.get(AuthSubPath + 'SessionToken', authorization_header(token))
90
+ end
91
+
92
+ pair = response.body.split(/\n/).detect { |p| p.index('Token=') == 0 }
93
+ pair.split('=').last if pair
94
+ end
95
+
96
+ # Alternative to AuthSub: using email and password.
97
+ def self.client_login(email, password)
98
+ response = http_start do |google|
99
+ query = query_string(client_login_options.merge(:Email => email, :Passwd => password))
100
+ puts "posting #{query} to #{ClientLogin}" if Contacts::verbose?
101
+ google.post(ClientLogin, query)
102
+ end
103
+
104
+ pair = response.body.split(/\n/).detect { |p| p.index('Auth=') == 0 }
105
+ pair.split('=').last if pair
106
+ end
107
+
108
+ attr_reader :user, :token, :headers
109
+ attr_accessor :projection
110
+
111
+ # A token is required here. By default, an AuthSub token from
112
+ # Google is one-time only, which means you can only make a single request with it.
113
+ def initialize(token, user_id = 'default', client = false)
114
+ @user = user_id.to_s
115
+ @token = token.to_s
116
+ @headers = {
117
+ 'Accept-Encoding' => 'gzip',
118
+ 'User-Agent' => Identifier + ' (gzip)'
119
+ }.update(self.class.authorization_header(@token, client))
120
+ @projection = 'thin'
121
+ end
122
+
123
+ def get(params) # :nodoc:
124
+ self.class.http_start(false) do |google|
125
+ path = FeedsPath + CGI.escape(@user)
126
+ google_params = translate_parameters(params)
127
+ query = self.class.query_string(google_params)
128
+ google.get("#{path}/#{@projection}?#{query}", @headers)
129
+ end
130
+ end
131
+
132
+ # Timestamp of last update. This value is available only after the XML
133
+ # document has been parsed; for instance after fetching the contact list.
134
+ def updated_at
135
+ @updated_at ||= Time.parse @updated_string if @updated_string
136
+ end
137
+
138
+ # Timestamp of last update as it appeared in the XML document
139
+ def updated_at_string
140
+ @updated_string
141
+ end
142
+
143
+ # Fetches, parses and returns the contact list.
144
+ #
145
+ # ==== Options
146
+ # * <tt>:limit</tt> -- use a large number to fetch a bigger contact list (default: 200)
147
+ # * <tt>:offset</tt> -- 0-based value, can be used for pagination
148
+ # * <tt>:order</tt> -- currently the only value support by Google is "lastmodified"
149
+ # * <tt>:descending</tt> -- boolean
150
+ # * <tt>:updated_after</tt> -- string or time-like object, use to only fetch contacts
151
+ # that were updated after this date
152
+ def contacts(options = {})
153
+ params = { :limit => 200 }.update(options)
154
+ response = get(params)
155
+ parse_contacts response_body(response)
156
+ end
157
+
158
+ # Fetches contacts using multiple API calls when necessary
159
+ def all_contacts(options = {}, chunk_size = 200)
160
+ in_chunks(options, :contacts, chunk_size)
161
+ end
162
+
163
+ protected
164
+
165
+ def in_chunks(options, what, chunk_size)
166
+ returns = []
167
+ offset = 0
168
+
169
+ begin
170
+ chunk = send(what, options.merge(:offset => offset, :limit => chunk_size))
171
+ returns.push(*chunk)
172
+ offset += chunk_size
173
+ end while chunk.size == chunk_size
174
+
175
+ returns
176
+ end
177
+
178
+ def response_body(response)
179
+ unless response['Content-Encoding'] == 'gzip'
180
+ response.body
181
+ else
182
+ gzipped = StringIO.new(response.body)
183
+ Zlib::GzipReader.new(gzipped).read
184
+ end
185
+ end
186
+
187
+ def parse_contacts(body)
188
+ doc = Hpricot::XML body
189
+ contacts_found = []
190
+
191
+ if updated_node = doc.at('/feed/updated')
192
+ @updated_string = updated_node.inner_text
193
+ end
194
+
195
+ (doc / '/feed/entry').each do |entry|
196
+ email_nodes = entry / 'gd:email[@address]'
197
+
198
+ unless email_nodes.empty?
199
+ title_node = entry.at('/title')
200
+ name = title_node ? title_node.inner_text : nil
201
+ contact = Contact.new(nil, name)
202
+ contact.emails.concat email_nodes.map { |e| e['address'].to_s }
203
+ contacts_found << contact
204
+ end
205
+ end
206
+
207
+ contacts_found
208
+ end
209
+
210
+ # Constructs a query string from a Hash object
211
+ def self.query_string(params)
212
+ params.inject([]) do |all, pair|
213
+ key, value = pair
214
+ unless value.nil?
215
+ value = case value
216
+ when TrueClass; '1'
217
+ when FalseClass; '0'
218
+ else value
219
+ end
220
+
221
+ all << "#{CGI.escape(key.to_s)}=#{CGI.escape(value.to_s)}"
222
+ end
223
+ all
224
+ end.join('&')
225
+ end
226
+
227
+ def translate_parameters(params)
228
+ params.inject({}) do |all, pair|
229
+ key, value = pair
230
+ unless value.nil?
231
+ key = case key
232
+ when :limit
233
+ 'max-results'
234
+ when :offset
235
+ value = value.to_i + 1
236
+ 'start-index'
237
+ when :order
238
+ all['sortorder'] = 'descending' if params[:descending].nil?
239
+ 'orderby'
240
+ when :descending
241
+ value = value ? 'descending' : 'ascending'
242
+ 'sortorder'
243
+ when :updated_after
244
+ value = value.strftime("%Y-%m-%dT%H:%M:%S%Z") if value.respond_to? :strftime
245
+ 'updated-min'
246
+ else key
247
+ end
248
+
249
+ all[key] = value
250
+ end
251
+ all
252
+ end
253
+ end
254
+
255
+ def self.authorization_header(token, client = false)
256
+ type = client ? 'GoogleLogin auth' : 'AuthSub token'
257
+ { 'Authorization' => %(#{type}="#{token}") }
258
+ end
259
+
260
+ def self.http_start(ssl = true)
261
+ port = ssl ? Net::HTTP::https_default_port : Net::HTTP::http_default_port
262
+ http = Net::HTTP.new(DOMAIN, port)
263
+ redirects = 0
264
+ if ssl
265
+ http.use_ssl = true
266
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
267
+ end
268
+ http.start
269
+
270
+ begin
271
+ response = yield(http)
272
+
273
+ loop do
274
+ inspect_response(response) if Contacts::verbose?
275
+
276
+ case response
277
+ when Net::HTTPSuccess
278
+ break response
279
+ when Net::HTTPRedirection
280
+ if redirects == TooManyRedirects::MAX_REDIRECTS
281
+ raise TooManyRedirects.new(response)
282
+ end
283
+ location = URI.parse response['Location']
284
+ puts "Redirected to #{location}"
285
+ response = http.get(location.path)
286
+ redirects += 1
287
+ else
288
+ response.error!
289
+ end
290
+ end
291
+ ensure
292
+ http.finish
293
+ end
294
+ end
295
+
296
+ def self.inspect_response(response, out = $stderr)
297
+ out.puts response.inspect
298
+ for name, value in response
299
+ out.puts "#{name}: #{value}"
300
+ end
301
+ out.puts "----\n#{response_body response}\n----" unless response.body.empty?
302
+ end
303
+ end
304
+ end