pietern-contacts 0.1.1

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/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2008 Mislav Marohnić
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
17
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
18
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
19
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
20
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.rdoc ADDED
@@ -0,0 +1,79 @@
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('example@gmail.com', params[:token])
16
+ contacts = gmail.contacts
17
+ contacts.select { |c| c.name }
18
+ #-> ['Fitzgerald', 'William Paginate', ... ]
19
+
20
+ Although this is quite nice, it won't use this library to it's fullest power.
21
+ Instead, ask for a session token, which remains valid after subsequent requests,
22
+ via this URL:
23
+
24
+ Contacts::Google.authentication_url('http://mysite.com/invite', :session => true)
25
+
26
+ The user will authenticate just like the previous example, but the token you get
27
+ back can be used to acquire a session token like this:
28
+
29
+ Contacts::Google.session_token(params[:token])
30
+
31
+ This token can be used to instantiate a new Contacts::Google object which you can
32
+ use like this:
33
+
34
+ gmail = Contacts::Google.new('example@gmail.com', my_session_token)
35
+
36
+ # Fetch all contacts (in chunks, so this will render really ALL the contacts)
37
+ contacts = gmail.all_contacts
38
+
39
+ # Set some parameters and update all contacts
40
+ contacts.each do |c|
41
+ c.name += " (appended string)"
42
+ c['my_custom_parameter'] = "something"
43
+ c.update!
44
+ end
45
+
46
+ # Add a new contact
47
+ new_contact = gmail.new_contact(:name => 'Pieter', :email => 'email@some.host.com')
48
+ new_contact.create!
49
+
50
+ # Russian roulette!
51
+ another_contact = gmail.all_contacts.choice # Pick a random contact
52
+ another_contact.delete!
53
+
54
+ The contact objects take the methods +name+ and +email+ for direct access to the
55
+ most important fields for your contact. The Google Data API provides a
56
+ <tt>gd:extendedProperty</tt> tag for custom parameters. You can use this tag via
57
+ the <tt>[]</tt> and <tt>[]=</tt> methods.
58
+
59
+ The example stated above will be extremely slow if you have a lot of contacts. To
60
+ provide easy manipulation of large sets of contacts, you can use the batch method:
61
+
62
+ gmail.batch_contacts do
63
+ # Create, update and delete contacts, as long as you don't execute
64
+ # more than one action per contact in one batch. This won't
65
+ # report errors, but doesn't succeed.
66
+ end
67
+
68
+ This will result in a single POST request for every 100 operations. Because you
69
+ cannot batch operations to both contacts and groups, the batch method <tt>batch_groups</tt>
70
+ is provided for handling a lot of groups.
71
+
72
+ Make sure you don't issue multiple operations per contact each batch! After one request
73
+ for a contact, it needs to be reloaded.
74
+
75
+ More can be read in the rdoc of this lib, although they are not complete
76
+
77
+ Authors:<br/>
78
+ <b>Mislav Marohnić</b> (mislav.marohnic@gmail.com) (initial codebase)<br/>
79
+ <b>Pieter Noordhuis</b> (pcnoordhuis@gmail.com) (further development)<br/>
data/Rakefile ADDED
@@ -0,0 +1,49 @@
1
+ require 'spec/rake/spectask'
2
+ require 'rake/rdoctask'
3
+
4
+ task :default => :spec
5
+
6
+ spec_opts = 'spec/spec.opts'
7
+ spec_glob = FileList['spec/**/*_spec.rb']
8
+
9
+ desc 'Run all specs in spec directory'
10
+ Spec::Rake::SpecTask.new(:spec) do |t|
11
+ t.spec_opts = ['--options', spec_opts]
12
+ t.spec_files = spec_glob
13
+ end
14
+
15
+ namespace :spec do
16
+ desc 'Run all specs in spec directory with RCov'
17
+ Spec::Rake::SpecTask.new(:rcov) do |t|
18
+ t.spec_opts = ['--options', spec_opts]
19
+ t.spec_files = spec_glob
20
+ t.rcov = true
21
+ # t.rcov_opts = lambda do
22
+ # IO.readlines('spec/rcov.opts').map {|l| l.chomp.split " "}.flatten
23
+ # end
24
+ end
25
+
26
+ desc 'Print Specdoc for all specs'
27
+ Spec::Rake::SpecTask.new(:doc) do |t|
28
+ t.spec_opts = ['--format', 'specdoc', '--dry-run']
29
+ t.spec_files = spec_glob
30
+ end
31
+
32
+ desc 'Generate HTML report'
33
+ Spec::Rake::SpecTask.new(:html) do |t|
34
+ t.spec_opts = ['--format', 'html:doc/spec_results.html', '--diff']
35
+ t.spec_files = spec_glob
36
+ t.fail_on_error = false
37
+ end
38
+ end
39
+
40
+ desc 'Generate RDoc documentation'
41
+ Rake::RDocTask.new(:rdoc) do |rdoc|
42
+ rdoc.rdoc_files.add ['README.rdoc', 'MIT-LICENSE', 'lib/**/*.rb']
43
+ rdoc.main = 'README.rdoc'
44
+ rdoc.title = 'Google Contacts API'
45
+
46
+ rdoc.rdoc_dir = 'doc'
47
+ rdoc.options << '--inline-source'
48
+ rdoc.options << '--charset=UTF-8'
49
+ end
data/contacts.gemspec ADDED
@@ -0,0 +1,30 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = "contacts"
3
+ s.version = "0.1.1"
4
+ s.date = "2008-08-02"
5
+ s.summary = "Interfacing with the Google Contacts API"
6
+ s.email = "tom@rubyisawesome.com"
7
+ s.homepage = "http://github.com/pietern/contacts"
8
+ s.description = "Provides easy access and manipulation with the Google Contacts API"
9
+ s.has_rdoc = true
10
+ s.authors = ["Mislav Marohnić", "Pieter Noordhuis"]
11
+ s.files = [
12
+ "lib/contacts",
13
+ "lib/contacts/google.rb",
14
+ "lib/contacts.rb",
15
+ "spec/feeds",
16
+ "spec/feeds/google-many.xml",
17
+ "spec/feeds/google-single.xml",
18
+ "spec/gmail",
19
+ "spec/gmail/auth_spec.rb",
20
+ "spec/gmail/fetching_spec.rb",
21
+ "spec/spec.opts",
22
+ "spec/spec_helper.rb",
23
+ "contacts.gemspec",
24
+ "Rakefile",
25
+ "README.rdoc",
26
+ "MIT-LICENSE"
27
+ ]
28
+ #s.rdoc_options = ["--main", "README.rdoc"]
29
+ s.add_dependency("hpricot", [">= 0.6"])
30
+ end
data/lib/contacts.rb ADDED
@@ -0,0 +1,10 @@
1
+ module Contacts
2
+ class FetchingError < RuntimeError
3
+ attr_reader :response
4
+
5
+ def initialize(response)
6
+ @response = response
7
+ super "expected HTTPSuccess, got #{response.class} (#{response.code} #{response.message})"
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,547 @@
1
+ require 'contacts'
2
+ require 'cgi'
3
+ require 'net/http'
4
+ require 'net/https'
5
+ require 'rubygems'
6
+ require 'hpricot'
7
+ require 'time'
8
+ require 'zlib'
9
+ require 'stringio'
10
+
11
+ module Contacts
12
+ # == Fetching Google Contacts
13
+ #
14
+ # Web applications should use
15
+ # AuthSub[http://code.google.com/apis/contacts/developers_guide_protocol.html#auth_sub]
16
+ # proxy authentication to get an authentication token for a Google account.
17
+ #
18
+ # First, get the user to follow the following URL:
19
+ #
20
+ # Contacts::Google.authentication_url('http://mysite.com/invite')
21
+ #
22
+ # After he authenticates successfully, Google will redirect him back to the target URL
23
+ # (specified as argument above) and provide the token GET parameter. Use it to create a
24
+ # new instance of this class and request the contact list:
25
+ #
26
+ # gmail = Contacts::Google.new('example@gmail.com', params[:token])
27
+ # contacts = gmail.contacts
28
+ # #-> [ ['Fitzgerald', 'fubar@gmail.com', 'fubar@example.com'],
29
+ # ['William Paginate', 'will.paginate@gmail.com'], ...
30
+ # ]
31
+ #
32
+ # == Storing a session token
33
+ #
34
+ # The basic token that you will get after the user has authenticated on Google is valid
35
+ # for only one request. However, you can specify that you want a session token which
36
+ # doesn't expire:
37
+ #
38
+ # Contacts::Google.authentication_url('http://mysite.com/invite', :session => true)
39
+ #
40
+ # When the user authenticates, he will be redirected back with a token which still isn't
41
+ # a session token, but can be exchanged for one!
42
+ #
43
+ # token = Contacts::Google.sesion_token(params[:token])
44
+ #
45
+ # Now you have a permanent token. Store it with other user data so you can query the API
46
+ # on his behalf without him having to authenticate on Google each time.
47
+ class Google
48
+ DOMAIN = 'www.google.com'
49
+ AuthSubPath = '/accounts/AuthSub' # all variants go over HTTPS
50
+ AuthScope = "http://#{DOMAIN}/m8/feeds/"
51
+
52
+ # URL to Google site where user authenticates. Afterwards, Google redirects to your
53
+ # site with the URL specified as +target+.
54
+ #
55
+ # Options are:
56
+ # * <tt>:scope</tt> -- the AuthSub scope in which the resulting token is valid
57
+ # (default: "http://www.google.com/m8/feeds/")
58
+ # * <tt>:secure</tt> -- boolean indicating whether the token will be secure
59
+ # (default: false)
60
+ # * <tt>:session</tt> -- boolean indicating if the token can be exchanged for a session token
61
+ # (default: false)
62
+ def self.authentication_url(target, options = {})
63
+ params = { :next => target,
64
+ :scope => AuthScope,
65
+ :secure => false,
66
+ :session => false
67
+ }.merge(options)
68
+
69
+ query = params.inject [] do |url, pair|
70
+ unless pair.last.nil?
71
+ value = case pair.last
72
+ when TrueClass; 1
73
+ when FalseClass; 0
74
+ else pair.last
75
+ end
76
+
77
+ url << "#{pair.first}=#{CGI.escape(value.to_s)}"
78
+ end
79
+ url
80
+ end.join('&')
81
+
82
+ "https://#{DOMAIN}#{AuthSubPath}Request?#{query}"
83
+ end
84
+
85
+ # Makes an HTTPS request to exchange the given token with a session one. Session
86
+ # tokens never expire, so you can store them in the database alongside user info.
87
+ #
88
+ # Returns the new token as string or nil if the parameter couln't be found in response
89
+ # body.
90
+ def self.session_token(token)
91
+ http = Net::HTTP.new(DOMAIN, 443)
92
+ http.use_ssl = true
93
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
94
+ response = http.request_get(AuthSubPath + 'SessionToken', auth_headers(token))
95
+
96
+ pair = response.body.split(/\s+/).detect {|p| p.index('Token') == 0 }
97
+ pair.split('=').last if pair
98
+ end
99
+
100
+ # User ID (email) and token are required here. By default, an AuthSub token from
101
+ # Google is one-time only, which means you can only make a single request with it.
102
+ def initialize(user_id, token)
103
+ @user = user_id.to_s
104
+ @headers = {
105
+ 'Accept-Encoding' => 'gzip',
106
+ 'User-Agent' => 'agent-that-accepts-gzip',
107
+ }.update(self.class.auth_headers(token))
108
+ @in_batch = false
109
+ end
110
+
111
+ PATH = {
112
+ 'contacts_full' => '/m8/feeds/contacts/default/full',
113
+ 'contacts_batch' => '/m8/feeds/contacts/default/full/batch',
114
+ 'groups_full' => '/m8/feeds/groups/default/full',
115
+ 'groups_batch' => '/m8/feeds/groups/default/full/batch',
116
+ }
117
+
118
+ def get(path, params) #:nodoc:
119
+ response = Net::HTTP.start(DOMAIN) do |google|
120
+ google.get(path + '?' + query_string(params), @headers)
121
+ end
122
+
123
+ raise FetchingError.new(response) unless response.is_a? Net::HTTPSuccess
124
+
125
+ response
126
+ end
127
+
128
+ # Timestamp of last update. This value is available only after the XML
129
+ # document has been parsed; for instance after fetching the contact list.
130
+ def updated_at
131
+ @updated_at ||= Time.parse @updated_string if @updated_string
132
+ end
133
+
134
+ # Timestamp of last update as it appeared in the XML document
135
+ def updated_at_string
136
+ @updated_string
137
+ end
138
+
139
+ def post(url, body, headers)
140
+ if @in_batch
141
+ @batch_request << [body, headers]
142
+ else
143
+ response = Net::HTTP.start(DOMAIN) do |google|
144
+ google.post(url, body.to_s, @headers.merge(headers))
145
+ end
146
+
147
+ raise FetchingError.new(response) unless response.is_a? Net::HTTPSuccess
148
+
149
+ response
150
+ end
151
+ end
152
+
153
+ # Fetches, parses and returns the contact list.
154
+ #
155
+ # ==== Options
156
+ # * <tt>:limit</tt> -- use a large number to fetch a bigger contact list (default: 200)
157
+ # * <tt>:offset</tt> -- 0-based value, can be used for pagination
158
+ # * <tt>:order</tt> -- currently the only value support by Google is "lastmodified"
159
+ # * <tt>:descending</tt> -- boolean
160
+ # * <tt>:updated_after</tt> -- string or time-like object, use to only fetch contacts
161
+ # that were updated after this date
162
+ def contacts(options = {})
163
+ params = { :limit => 200 }.update(options)
164
+ response = get(PATH['contacts_full'], params)
165
+ parse_contacts response_body(response)
166
+ end
167
+
168
+ # Fetches, parses and returns the group list.
169
+ #
170
+ # ==== Options
171
+ # see contacts
172
+ def groups(options = {})
173
+ params = { :limit => 200 }.update(options)
174
+ response = get(PATH['groups_full'], params)
175
+ parse_groups response_body(response)
176
+ end
177
+
178
+ # Fetches all contacts in chunks of 200.
179
+ #
180
+ # For example: if you have 1000 contacts, this will render in 5 GET requests
181
+ def all_contacts
182
+ ret = []
183
+ chunk_size = 200
184
+ offset = 0
185
+
186
+ while (chunk = contacts(:limit => chunk_size, :offset => offset)).size != 0
187
+ ret.push(*chunk)
188
+ offset += chunk_size
189
+ break if chunk.size < chunk_size
190
+ end
191
+ ret
192
+ end
193
+
194
+ def all_groups
195
+ ret = []
196
+ chunk_size = 200
197
+ offset = 0
198
+
199
+ while (chunk = groups(:limit => chunk_size, :offset => offset)).size != 0
200
+ ret.push(*chunk)
201
+ offset += chunk_size
202
+ end
203
+ ret
204
+ end
205
+
206
+ def new_contact(attr = {})
207
+ c = Contact.new(self)
208
+ c.load_attributes(attr)
209
+ end
210
+
211
+ def new_group(attr = {})
212
+ g = Group.new(self)
213
+ g.load_attributes(attr)
214
+ end
215
+
216
+ def batch_contacts(&blk)
217
+ batch(PATH['contacts_batch'], &blk)
218
+ end
219
+
220
+ def batch_groups(&blk)
221
+ batch(PATH['groups_batch'], &blk)
222
+ end
223
+
224
+ def batch(url, &blk)
225
+ # Init
226
+ limit = 512 * 1024
227
+ @batch_request = []
228
+ @in_batch = true
229
+
230
+ # Execute the block
231
+ yield
232
+
233
+ # Pack post-request in batch job(s)
234
+ while !@batch_request.empty?
235
+ doc = Hpricot("<?xml version='1.0' encoding='UTF-8'?>\n<feed/>", :xml => true)
236
+ root = doc.root
237
+ root['xmlns'] = 'http://www.w3.org/2005/Atom'
238
+ root['xmlns:gContact'] = 'http://schemas.google.com/contact/2008'
239
+ root['xmlns:gd'] = 'http://schemas.google.com/g/2005'
240
+ root['xmlns:batch'] = 'http://schemas.google.com/gdata/batch'
241
+
242
+ size = doc.to_s.size
243
+ 100.times do
244
+ break if size >= limit || @batch_request.empty?
245
+ r = @batch_request.shift
246
+
247
+ # Get stuff for request
248
+ headers = r[1]
249
+ xml = r[0]
250
+
251
+ # Delete all namespace attributes
252
+ xml.root.attributes.each { |a,v| xml.root.remove_attribute(a) if a =~ /^xmlns/ }
253
+
254
+ # Find out what to do
255
+ operation = case headers['X-HTTP-Method-Override']
256
+ when 'PUT'
257
+ 'update'
258
+ when 'DELETE'
259
+ 'delete'
260
+ else
261
+ 'insert'
262
+ end
263
+
264
+ xml.root.children << Hpricot.make("<batch:operation type='#{operation}'/>").first
265
+ root.children << xml.root
266
+ size += xml.root.to_s.size
267
+ end
268
+
269
+ #puts "Doing POST... (#{size} bytes)"
270
+ @in_batch = false
271
+ post(url, doc, 'Content-Type' => 'application/atom+xml')
272
+ @in_batch = true
273
+ end
274
+ @in_batch = false
275
+ end
276
+
277
+ class Base
278
+ attr_reader :gmail, :xml
279
+
280
+ BASE_XML = "<entry><category scheme='http://schemas.google.com/g/2005#kind' /><title type='text' /></entry>"
281
+
282
+ def initialize(gmail, xml = nil)
283
+ xml = BASE_XML if xml.nil?
284
+ @xml = Hpricot(xml.to_s, :xml => true)
285
+ @gmail = gmail
286
+ end
287
+
288
+ def load_attributes(attr)
289
+ attr.each do |k,v|
290
+ self.send((k.to_s+"=").to_sym, v)
291
+ end
292
+ self
293
+ end
294
+
295
+ def new?
296
+ @xml.at('id').nil?
297
+ end
298
+
299
+ def id
300
+ @xml.at('id').inner_html unless new?
301
+ end
302
+
303
+ def name
304
+ @xml.at('title').inner_html
305
+ end
306
+
307
+ def name=(str)
308
+ @xml.at('title').inner_html = str
309
+ end
310
+
311
+ def [](attr)
312
+ el = get_extended_property(attr)
313
+ return nil if el.nil?
314
+
315
+ if el.has_attribute?('value')
316
+ el['value']
317
+ else
318
+ Hpricot(el.inner_html)
319
+ end
320
+ end
321
+
322
+ def []=(attr, value)
323
+ el = get_extended_property(attr)
324
+
325
+ # Create element if it not already exists
326
+ if el.nil?
327
+ @xml.root.children.push *Hpricot.make("<gd:extendedProperty name='#{attr}' />")
328
+ el = get_extended_property(attr)
329
+ end
330
+
331
+ if value.kind_of?(Hpricot)
332
+ # If value is valid XML, set as element content
333
+ el.remove_attribute('value')
334
+ el.inner_html = value.to_s
335
+ else
336
+ # If value is not XML, set as value-attribute
337
+ el['value'] = value
338
+ el.inner_html = ''
339
+ end
340
+ value
341
+ end
342
+
343
+ def create_url
344
+ raise "Contacts::Google::Base must be subclassed!"
345
+ end
346
+
347
+ def edit_url
348
+ @xml.at("link[@rel='edit']")['href'].gsub(/^http:\/\/www.google.com(\/.*)$/, '\1') unless new?
349
+ end
350
+
351
+ def create!
352
+ raise "Cannot create existing entry" unless new?
353
+ response = gmail.post(create_url, document_for_request, {
354
+ 'Content-Type' => 'application/atom+xml'
355
+ })
356
+ end
357
+
358
+ def update!
359
+ raise "Cannot update new entry" if new?
360
+ response = gmail.post(edit_url, document_for_request,
361
+ 'Content-Type' => 'application/atom+xml',
362
+ 'X-HTTP-Method-Override' => 'PUT'
363
+ )
364
+ end
365
+
366
+ def delete!
367
+ raise "Cannot delete new entry" if new?
368
+ gmail.post(edit_url, document_for_request,
369
+ 'X-HTTP-Method-Override' => 'DELETE'
370
+ )
371
+ end
372
+
373
+ protected
374
+ def document_for_request
375
+ # Make a new document from this entry, specify :xml => true to make sure Hpricot
376
+ # doesn't downcase all tags, which results in bad input to Google
377
+ atom = Hpricot(@xml.to_s, { :xml => true })
378
+
379
+ # Remove <updated> tag (not necessary, but results in smaller XML)
380
+ # Make sure not to delete the <link> tags, they seem unnecessary
381
+ # but result in strange errors while making batch requests
382
+ (atom / 'updated').remove
383
+
384
+ # Set the right namespaces
385
+ root = atom.at('entry')
386
+ root['xmlns'] = 'http://www.w3.org/2005/Atom'
387
+ root['xmlns:gd'] = 'http://schemas.google.com/g/2005'
388
+ root['xmlns:gContact'] = 'http://schemas.google.com/contact/2008'
389
+
390
+ after_document_for_request_hook(atom)
391
+ end
392
+
393
+ def after_document_for_request_hook(xml)
394
+ xml
395
+ end
396
+
397
+ def get_extended_property(attr)
398
+ raise "Attribute naming error" if attr =~ /['\[\]]/
399
+ @xml.at("gd:extendedProperty[@name='#{attr}']")
400
+ end
401
+ end
402
+
403
+ class Contact < Base
404
+ attr_reader :groups
405
+
406
+ PRIMARY_EMAIL_TAG = "<gd:email rel='http://schemas.google.com/g/2005#home' primary='true' address='' />"
407
+
408
+ def initialize(gmail, xml = nil)
409
+ super(gmail, xml)
410
+
411
+ if xml.nil?
412
+ # Specific constructs for a contact
413
+ @xml.at('category')['term'] = "http://schemas.google.com/contact/2008#contact"
414
+ @xml.root.children.push *Hpricot.make(PRIMARY_EMAIL_TAG)
415
+ end
416
+
417
+ @groups = []
418
+ (@xml / 'gContact:groupMembershipInfo').each do |e|
419
+ @groups << e['href']
420
+ end
421
+
422
+ # All groups are saved in an array for easy access
423
+ (@xml / 'gContact:groupMembershipInfo').remove
424
+ end
425
+
426
+ def create_url
427
+ '/m8/feeds/contacts/default/full'
428
+ PATH['contacts_full']
429
+ end
430
+
431
+ def email
432
+ @xml.at('gd:email')['address']
433
+ end
434
+
435
+ def email=(str)
436
+ @xml.at('gd:email')['address'] = str
437
+ end
438
+
439
+ def clear_groups!
440
+ @groups = []
441
+ end
442
+
443
+ def add_group(group)
444
+ href = get_group_href(group)
445
+ return nil if @groups.include?(href)
446
+ @groups << href
447
+ end
448
+
449
+ def remove_group(group)
450
+ href = get_group_href(group)
451
+ @groups.delete(href)
452
+ end
453
+
454
+ protected
455
+ def get_group_href(group)
456
+ raise "Needs Group object" unless group.instance_of?(Group)
457
+ group.id
458
+ end
459
+
460
+ def after_document_for_request_hook(xml)
461
+ str = ""
462
+ @groups.each do |href|
463
+ str << "<gContact:groupMembershipInfo deleted='false' href='#{href}' />"
464
+ end
465
+ xml.root.children.push *Hpricot.make(str, :xml => true)
466
+ xml
467
+ end
468
+ end
469
+
470
+ class Group < Base
471
+ def initialize(gmail, xml = nil)
472
+ super(gmail, xml)
473
+ @xml.at('category')['term'] = 'http://schemas.google.com/contact/2008#group'
474
+ end
475
+
476
+ def create_url
477
+ '/m8/feeds/groups/default/full'
478
+ end
479
+ end
480
+
481
+ protected
482
+
483
+ def response_body(response)
484
+ unless response['Content-Encoding'] == 'gzip'
485
+ puts 'no gzip'
486
+ response.body
487
+ else
488
+ gzipped = StringIO.new(response.body)
489
+ Zlib::GzipReader.new(gzipped).read
490
+ end
491
+ end
492
+
493
+ def self.auth_headers(token)
494
+ { 'Authorization' => %(AuthSub token=#{token.to_s.inspect}) }
495
+ end
496
+
497
+ def parse_contacts(body)
498
+ parse_entries(body, lambda { |*args| Contact.new(*args) })
499
+ end
500
+
501
+ def parse_groups(body)
502
+ parse_entries(body, lambda { |*args| Group.new(*args) })
503
+ end
504
+
505
+ def parse_entries(body, lmbd)
506
+ doc = Hpricot::XML body
507
+ entries = []
508
+
509
+ if updated_node = doc.at('/feed/updated')
510
+ @updated_string = updated_node.inner_text
511
+ end
512
+
513
+ (doc / '/feed/entry').each do |entry|
514
+ entries << lmbd.call(self, entry)
515
+ end
516
+ entries
517
+ end
518
+
519
+ def query_string(params)
520
+ params.inject [] do |url, pair|
521
+ value = pair.last
522
+ unless value.nil?
523
+ key = case pair.first
524
+ when :limit
525
+ 'max-results'
526
+ when :offset
527
+ value = value.to_i + 1
528
+ 'start-index'
529
+ when :order
530
+ url << 'sortorder=descending' if params[:descending].nil?
531
+ 'orderby'
532
+ when :descending
533
+ value = value ? 'descending' : 'ascending'
534
+ 'sortorder'
535
+ when :updated_after
536
+ value = value.strftime("%Y-%m-%dT%H:%M:%S%Z") if value.respond_to? :strftime
537
+ 'updated-min'
538
+ else pair.first
539
+ end
540
+
541
+ url << "#{key}=#{CGI.escape(value.to_s)}"
542
+ end
543
+ url
544
+ end.join('&')
545
+ end
546
+ end
547
+ end
@@ -0,0 +1,48 @@
1
+ <feed>
2
+ <entry xmlns='http://www.w3.org/2005/Atom' xmlns:gd='http://schemas.google.com/g/2005'>
3
+ <category scheme='http://schemas.google.com/g/2005#kind' term='http://schemas.google.com/contact/2008#contact'/>
4
+ <title>Elizabeth Bennet</title>
5
+ <content>My good friend, Liz. A little quick to judge sometimes, but nice girl.</content>
6
+ <gd:email rel='http://schemas.google.com/g/2005#work' primary='true' address='liz@gmail.com'/>
7
+ <gd:email rel='http://schemas.google.com/g/2005#home' address='liz@example.org'/>
8
+ <gd:phoneNumber rel='http://schemas.google.com/g/2005#work' primary='true'>
9
+ (206)555-1212
10
+ </gd:phoneNumber>
11
+ <gd:phoneNumber rel='http://schemas.google.com/g/2005#home'>
12
+ (206)555-1213
13
+ </gd:phoneNumber>
14
+ <gd:phoneNumber rel='http://schemas.google.com/g/2005#mobile'>
15
+ (206) 555-1212
16
+ </gd:phoneNumber>
17
+ <gd:im rel='http://schemas.google.com/g/2005#home'
18
+ protocol='http://schemas.google.com/g/2005#GOOGLE_TALK'
19
+ address='liz@gmail.com'/>
20
+ <gd:postalAddress rel='http://schemas.google.com/g/2005#work' primary='true'>
21
+ 1600 Amphitheatre Pkwy
22
+ Mountain View, CA 94043
23
+ </gd:postalAddress>
24
+ <gd:postalAddress rel='http://schemas.google.com/g/2005#home'>
25
+ 800 Main Street
26
+ Mountain View, CA 94041
27
+ </gd:postalAddress>
28
+ <gd:organization>
29
+ <gd:orgName>Google, Inc.</gd:orgName>
30
+ <gd:orgTitle>Tech Writer</gd:orgTitle>
31
+ </gd:organization>
32
+ </entry>
33
+
34
+ <entry>
35
+ <title>Poor Jack</title>
36
+ <content>Poor Jack doesn't have an e-mail address</content>
37
+ </entry>
38
+
39
+ <entry>
40
+ <title>William Paginate</title>
41
+ <gd:email address='will_paginate@googlegroups.com' />
42
+ </entry>
43
+
44
+ <entry>
45
+ <content>This guy doesn't have a name</content>
46
+ <gd:email address='anonymous@example.com' />
47
+ </entry>
48
+ </feed>
@@ -0,0 +1,46 @@
1
+ <!-- source: http://code.google.com/apis/contacts/developers_guide_protocol.html -->
2
+ <feed xmlns='http://www.w3.org/2005/Atom'
3
+ xmlns:openSearch='http://a9.com/-/spec/opensearchrss/1.0/'
4
+ xmlns:gd='http://schemas.google.com/g/2005'>
5
+ <id>http://www.google.com/m8/feeds/contacts/liz%40gmail.com/base</id>
6
+ <updated>2008-03-05T12:36:38.836Z</updated>
7
+ <category scheme='http://schemas.google.com/g/2005#kind'
8
+ term='http://schemas.google.com/contact/2008#contact' />
9
+ <title type='text'>Contacts</title>
10
+ <link rel='http://schemas.google.com/g/2005#feed'
11
+ type='application/atom+xml'
12
+ href='http://www.google.com/m8/feeds/contacts/liz%40gmail.com/base' />
13
+ <link rel='http://schemas.google.com/g/2005#post'
14
+ type='application/atom+xml'
15
+ href='http://www.google.com/m8/feeds/contacts/liz%40gmail.com/base' />
16
+ <link rel='self' type='application/atom+xml'
17
+ href='http://www.google.com/m8/feeds/contacts/liz%40gmail.com/base?max-results=25' />
18
+ <author>
19
+ <name>Elizabeth Bennet</name>
20
+ <email>liz@gmail.com</email>
21
+ </author>
22
+ <generator version='1.0' uri='http://www.google.com/m8/feeds/contacts'>
23
+ Contacts
24
+ </generator>
25
+ <openSearch:totalResults>1</openSearch:totalResults>
26
+ <openSearch:startIndex>1</openSearch:startIndex>
27
+ <openSearch:itemsPerPage>25</openSearch:itemsPerPage>
28
+ <entry>
29
+ <id>
30
+ http://www.google.com/m8/feeds/contacts/liz%40gmail.com/base/c9012de
31
+ </id>
32
+ <updated>2008-03-05T12:36:38.835Z</updated>
33
+ <category scheme='http://schemas.google.com/g/2005#kind'
34
+ term='http://schemas.google.com/contact/2008#contact' />
35
+ <title type='text'>Fitzgerald</title>
36
+ <link rel='self' type='application/atom+xml'
37
+ href='http://www.google.com/m8/feeds/contacts/liz%40gmail.com/base/c9012de' />
38
+ <link rel='edit' type='application/atom+xml'
39
+ href='http://www.google.com/m8/feeds/contacts/liz%40gmail.com/base/c9012de/1204720598835000' />
40
+ <gd:phoneNumber rel='http://schemas.google.com/g/2005#home'
41
+ primary='true'>
42
+ 456
43
+ </gd:phoneNumber>
44
+ <gd:email label="Personal" rel="http://schemas.google.com/g/2005#home" address="fubar@gmail.com" primary="true" />
45
+ </entry>
46
+ </feed>
@@ -0,0 +1,48 @@
1
+ require File.dirname(__FILE__) + '/../spec_helper'
2
+ require 'contacts/google'
3
+ require 'uri'
4
+
5
+ describe Contacts::Google, '.authentication_url' do
6
+ it 'generates a URL for target with default parameters' do
7
+ uri = url('http://example.com/invite')
8
+
9
+ uri.host.should == 'www.google.com'
10
+ uri.scheme.should == 'https'
11
+ uri.query.split('&').sort.should == [
12
+ 'next=http%3A%2F%2Fexample.com%2Finvite',
13
+ 'scope=http%3A%2F%2Fwww.google.com%2Fm8%2Ffeeds%2F',
14
+ 'secure=0',
15
+ 'session=0'
16
+ ]
17
+ end
18
+
19
+ it 'should handle boolean parameters' do
20
+ pairs = url(nil, :secure => true, :session => true).query.split('&')
21
+
22
+ pairs.should include('secure=1')
23
+ pairs.should include('session=1')
24
+ end
25
+
26
+ it 'skips parameters that have nil value' do
27
+ query = url(nil, :secure => nil).query
28
+ query.should_not include('next')
29
+ query.should_not include('secure')
30
+ end
31
+
32
+ it 'should be able to exchange one-time for session token' do
33
+ connection = mock('HTTP connection')
34
+ response = mock('HTTP response')
35
+ Net::HTTP.expects(:start).with('www.google.com').yields(connection).returns(response)
36
+ connection.expects(:use_ssl)
37
+ connection.expects(:verify_mode=).with(OpenSSL::SSL::VERIFY_NONE)
38
+ connection.expects(:get).with('/accounts/AuthSubSessionToken', 'Authorization' => %(AuthSub token="dummytoken"))
39
+
40
+ response.expects(:body).returns("Token=G25aZ-v_8B\nExpiration=20061004T123456Z")
41
+
42
+ Contacts::Google.session_token('dummytoken').should == 'G25aZ-v_8B'
43
+ end
44
+
45
+ def url(*args)
46
+ URI.parse Contacts::Google.authentication_url(*args)
47
+ end
48
+ end
@@ -0,0 +1,156 @@
1
+ require File.dirname(__FILE__) + '/../spec_helper'
2
+ require 'contacts/google'
3
+
4
+ describe Contacts::Google do
5
+
6
+ before :each do
7
+ @gmail = create
8
+ end
9
+
10
+ it 'fetches contacts feed via HTTP GET' do
11
+ @gmail.expects(:query_string).returns('a=b')
12
+ connection = mock('HTTP connection')
13
+ response = mock('HTTP response')
14
+ response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true)
15
+ Net::HTTP.expects(:start).with('www.google.com').yields(connection).returns(response)
16
+ connection.expects(:get).with('/m8/feeds/contacts/example%40gmail.com/base?a=b', {
17
+ 'Authorization' => %(AuthSub token="dummytoken"),
18
+ 'Accept-Encoding' => 'gzip'
19
+ })
20
+
21
+ @gmail.get({})
22
+ end
23
+
24
+ it 'handles a normal response body' do
25
+ response = mock('HTTP response')
26
+ @gmail.expects(:get).returns(response)
27
+
28
+ response.expects(:'[]').with('Content-Encoding').returns(nil)
29
+ response.expects(:body).returns('<feed/>')
30
+
31
+ @gmail.expects(:parse_contacts).with('<feed/>')
32
+ @gmail.contacts
33
+ end
34
+
35
+ it 'handles gzipped response' do
36
+ response = mock('HTTP response')
37
+ @gmail.expects(:get).returns(response)
38
+
39
+ gzipped = StringIO.new
40
+ gzwriter = Zlib::GzipWriter.new gzipped
41
+ gzwriter.write(('a'..'z').to_a.join)
42
+ gzwriter.close
43
+
44
+ response.expects(:'[]').with('Content-Encoding').returns('gzip')
45
+ response.expects(:body).returns gzipped.string
46
+
47
+ @gmail.expects(:parse_contacts).with('abcdefghijklmnopqrstuvwxyz')
48
+ @gmail.contacts
49
+ end
50
+
51
+ it 'raises a FetchingError when something goes awry' do
52
+ response = mock('HTTP response', :code => 666, :class => Net::HTTPBadRequest, :message => 'oh my')
53
+ Net::HTTP.expects(:start).returns(response)
54
+
55
+ lambda {
56
+ @gmail.get({})
57
+ }.should raise_error(Contacts::FetchingError)
58
+ end
59
+
60
+ it 'parses the resulting feed into name/email pairs' do
61
+ @gmail.stubs(:get)
62
+ @gmail.expects(:response_body).returns(sample_xml('google-single'))
63
+
64
+ @gmail.contacts.should == [['Fitzgerald', 'fubar@gmail.com']]
65
+ end
66
+
67
+ it 'parses a complex feed into name/email pairs' do
68
+ @gmail.stubs(:get)
69
+ @gmail.expects(:response_body).returns(sample_xml('google-many'))
70
+
71
+ @gmail.contacts.should == [
72
+ ['Elizabeth Bennet', 'liz@gmail.com', 'liz@example.org'],
73
+ ['William Paginate', 'will_paginate@googlegroups.com'],
74
+ [nil, 'anonymous@example.com']
75
+ ]
76
+ end
77
+
78
+ it 'makes modification time available after parsing' do
79
+ @gmail.updated_at.should be_nil
80
+ @gmail.stubs(:get)
81
+ @gmail.expects(:response_body).returns(sample_xml('google-single'))
82
+
83
+ @gmail.contacts
84
+ u = @gmail.updated_at
85
+ u.year.should == 2008
86
+ u.day.should == 5
87
+ @gmail.updated_at_string.should == '2008-03-05T12:36:38.836Z'
88
+ end
89
+
90
+ describe 'GET query parameter handling' do
91
+
92
+ before :each do
93
+ @gmail = create
94
+ @gmail.stubs(:response_body)
95
+ @gmail.stubs(:parse_contacts)
96
+
97
+ @connection = mock('HTTP connection')
98
+ response = mock('HTTP response')
99
+ response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true)
100
+ Net::HTTP.stubs(:start).yields(@connection).returns(response)
101
+ end
102
+
103
+ it 'abstracts ugly parameters behind nicer ones' do
104
+ expect_params %w( max-results=25
105
+ orderby=lastmodified
106
+ sortorder=ascending
107
+ start-index=11
108
+ updated-min=datetime )
109
+
110
+ @gmail.contacts :limit => 25,
111
+ :offset => 10,
112
+ :order => 'lastmodified',
113
+ :descending => false,
114
+ :updated_after => 'datetime'
115
+ end
116
+
117
+ it 'should have implicit :descending with :order' do
118
+ expect_params %w( orderby=lastmodified
119
+ sortorder=descending ), true
120
+
121
+ @gmail.contacts :order => 'lastmodified'
122
+ end
123
+
124
+ it 'should have default :limit of 200' do
125
+ expect_params %w( max-results=200 )
126
+ @gmail.contacts
127
+ end
128
+
129
+ it 'should skip nil values in parameters' do
130
+ expect_params %w( start-index=1 )
131
+ @gmail.contacts :limit => nil, :offset => 0
132
+ end
133
+
134
+ def expect_params(params, some = false)
135
+ @connection.expects(:get).with() do |path, headers|
136
+ pairs = path.split('?').last.split('&').sort
137
+ unless some
138
+ pairs.should == params
139
+ pairs.size == params.size
140
+ else
141
+ params.each {|p| pairs.should include(p) }
142
+ pairs.size >= params.size
143
+ end
144
+ end
145
+ end
146
+
147
+ end
148
+
149
+ def create
150
+ Contacts::Google.new('example@gmail.com', 'dummytoken')
151
+ end
152
+
153
+ def sample_xml(name)
154
+ File.read File.dirname(__FILE__) + "/../feeds/#{name}.xml"
155
+ end
156
+ end
data/spec/spec.opts ADDED
@@ -0,0 +1,2 @@
1
+ --colour
2
+ --reverse
@@ -0,0 +1,13 @@
1
+ require 'rubygems'
2
+ gem 'rspec', '~> 1.1.3'
3
+ require 'spec'
4
+
5
+ # add library's lib directory
6
+ $:.unshift File.dirname(__FILE__) + '/../lib'
7
+
8
+ Spec::Runner.configure do |config|
9
+ # config.include My::Pony, My::Horse, :type => :farm
10
+ # config.predicate_matchers[:swim] = :can_swim?
11
+
12
+ config.mock_with :mocha
13
+ end
metadata ADDED
@@ -0,0 +1,76 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: pietern-contacts
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - "Mislav Marohni\xC4\x87"
8
+ - Pieter Noordhuis
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+
13
+ date: 2008-08-02 00:00:00 -07:00
14
+ default_executable:
15
+ dependencies:
16
+ - !ruby/object:Gem::Dependency
17
+ name: hpricot
18
+ version_requirement:
19
+ version_requirements: !ruby/object:Gem::Requirement
20
+ requirements:
21
+ - - ">="
22
+ - !ruby/object:Gem::Version
23
+ version: "0.6"
24
+ version:
25
+ description: Provides easy access and manipulation with the Google Contacts API
26
+ email: tom@rubyisawesome.com
27
+ executables: []
28
+
29
+ extensions: []
30
+
31
+ extra_rdoc_files: []
32
+
33
+ files:
34
+ - lib/contacts
35
+ - lib/contacts/google.rb
36
+ - lib/contacts.rb
37
+ - spec/feeds
38
+ - spec/feeds/google-many.xml
39
+ - spec/feeds/google-single.xml
40
+ - spec/gmail
41
+ - spec/gmail/auth_spec.rb
42
+ - spec/gmail/fetching_spec.rb
43
+ - spec/spec.opts
44
+ - spec/spec_helper.rb
45
+ - contacts.gemspec
46
+ - Rakefile
47
+ - README.rdoc
48
+ - MIT-LICENSE
49
+ has_rdoc: true
50
+ homepage: http://github.com/pietern/contacts
51
+ post_install_message:
52
+ rdoc_options: []
53
+
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: "0"
61
+ version:
62
+ required_rubygems_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: "0"
67
+ version:
68
+ requirements: []
69
+
70
+ rubyforge_project:
71
+ rubygems_version: 1.2.0
72
+ signing_key:
73
+ specification_version: 2
74
+ summary: Interfacing with the Google Contacts API
75
+ test_files: []
76
+