mislav_contacts 0.2.7

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