aurelian-contacts 0.3.1

Sign up to get free protection for your applications and to get access to all the features.
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,29 @@
1
+ == Note
2
+
3
+ This is a fork of aeden/contacts[http://github.com/aeden/contacts/tree] to add:
4
+ - appdata parameter to Contacts::Yahoo.get_authentication_url
5
+ - context parameter to Contacts::WindowsLive.get_authentication_url
6
+ - Contacts::Flickr to use FlickrFu
7
+
8
+ == Basic usage instructions
9
+
10
+ Fetch users' contact lists from your web application without asking them to
11
+ provide their passwords.
12
+
13
+ First, register[http://code.google.com/apis/accounts/docs/RegistrationForWebAppsAuto.html]
14
+ your application's domain. Then make users follow this URL:
15
+
16
+ Contacts::Google.authentication_url('http://mysite.com/invite')
17
+
18
+ They will authenticate on Google and it will send them back to the URL
19
+ provided. Google will add a token GET parameter to the query part of the URL.
20
+ Use that token in the next step:
21
+
22
+ gmail = Contacts::Google.new('example@gmail.com', params[:token])
23
+ contacts = gmail.contacts
24
+ #-> [ ['Fitzgerald', 'fubar@gmail.com', 'fubar@example.com'],
25
+ ['William Paginate', 'will.paginate@gmail.com'], ...
26
+ ]
27
+
28
+ Author: <b>Mislav Marohnić</b> (mislav.marohnic@gmail.com)
29
+
data/Rakefile ADDED
@@ -0,0 +1,71 @@
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
+ libs = ['lib', 'spec']
9
+
10
+ desc 'Run all specs in spec directory'
11
+ Spec::Rake::SpecTask.new(:spec) do |t|
12
+ t.libs = libs
13
+ t.spec_opts = ['--options', spec_opts]
14
+ t.spec_files = spec_glob
15
+ # t.warning = true
16
+ end
17
+
18
+ namespace :spec do
19
+ desc 'Analyze spec coverage with RCov'
20
+ Spec::Rake::SpecTask.new(:rcov) do |t|
21
+ t.libs = libs
22
+ t.spec_files = spec_glob
23
+ t.spec_opts = ['--options', spec_opts]
24
+ t.rcov = true
25
+ t.rcov_opts = lambda do
26
+ IO.readlines('spec/rcov.opts').map { |l| l.chomp.split(" ") }.flatten
27
+ end
28
+ end
29
+
30
+ desc 'Print Specdoc for all specs'
31
+ Spec::Rake::SpecTask.new(:doc) do |t|
32
+ t.libs = libs
33
+ t.spec_opts = ['--format', 'specdoc', '--dry-run']
34
+ t.spec_files = spec_glob
35
+ end
36
+
37
+ desc 'Generate HTML report'
38
+ Spec::Rake::SpecTask.new(:html) do |t|
39
+ t.libs = libs
40
+ t.spec_opts = ['--format', 'html:doc/spec.html', '--diff']
41
+ t.spec_files = spec_glob
42
+ t.fail_on_error = false
43
+ end
44
+ end
45
+
46
+ desc 'Generate RDoc documentation'
47
+ Rake::RDocTask.new(:rdoc) do |rdoc|
48
+ rdoc.rdoc_files.add ['README.rdoc', 'MIT-LICENSE', 'lib/**/*.rb']
49
+ rdoc.main = 'README.rdoc'
50
+ rdoc.title = 'Ruby Contacts library'
51
+
52
+ rdoc.rdoc_dir = 'doc'
53
+ rdoc.options << '--inline-source'
54
+ rdoc.options << '--charset=UTF-8'
55
+ end
56
+
57
+ begin
58
+ require 'jeweler'
59
+ Jeweler::Tasks.new do |s|
60
+ s.name = "contacts"
61
+ s.summary = "Ruby library for consuming Google, Yahoo!, Flickr and Windows Live contact APIs"
62
+ s.email = "oancea@gmail.com"
63
+ s.homepage = "http://github.com/aurelian/contacts"
64
+ s.description = "Ruby library for consuming Google, Yahoo!, Flickr and Windows Live contacts APIs."
65
+ s.authors = ["Mislav Marohnić", "Lukas Fittl", "Keavy Miller", "Aurelian Oancea"]
66
+ s.files = FileList["[A-Z]*", "{lib,vendor}/**/*"]
67
+ end
68
+ rescue LoadError
69
+ puts "Jeweler not available. Install it with: sudo gem install technicalpickles-jeweler -s http://gems.github.com"
70
+ end
71
+
data/VERSION.yml ADDED
@@ -0,0 +1,4 @@
1
+ ---
2
+ :minor: 3
3
+ :patch: 1
4
+ :major: 0
data/lib/contacts.rb ADDED
@@ -0,0 +1,59 @@
1
+ require 'contacts/version'
2
+
3
+ require 'yaml' # required to parse yaml config file
4
+
5
+ module Contacts
6
+
7
+ Identifier = 'Ruby Contacts v' + VERSION::STRING
8
+
9
+ # An object that represents a single contact
10
+ class Contact
11
+ attr_reader :organizations, :firstname, :lastname
12
+ attr_accessor :name, :username, :service_id, :note, :emails, :ims,:phones, :addresses
13
+
14
+ def initialize(email, name = nil, username = nil, firstname = nil, lastname = nil)
15
+ @emails = []
16
+ @emails << email if email
17
+ @ims = []
18
+ @phones = []
19
+ @addresses = []
20
+ @organizations = []
21
+ @name = name
22
+ @username = username
23
+ @firstname = firstname
24
+ @lastname = lastname
25
+ end
26
+
27
+ def email
28
+ @emails.first
29
+ end
30
+
31
+ def inspect
32
+ %!#<Contacts::Contact "#{name}"#{email ? " (#{email})" : ''}>!
33
+ end
34
+ end
35
+
36
+ def self.verbose=(verbose)
37
+ @verbose = verbose
38
+ end
39
+
40
+ def self.verbose?
41
+ @verbose || 'irb' == $0
42
+ end
43
+
44
+ class Error < StandardError
45
+ end
46
+
47
+ class TooManyRedirects < Error
48
+ attr_reader :response, :location
49
+
50
+ MAX_REDIRECTS = 2
51
+
52
+ def initialize(response)
53
+ @response = response
54
+ @location = @response['Location']
55
+ super "exceeded maximum of #{MAX_REDIRECTS} redirects (Location: #{location})"
56
+ end
57
+ end
58
+
59
+ end
@@ -0,0 +1,40 @@
1
+ require 'contacts'
2
+
3
+ begin
4
+ require 'flickr_fu'
5
+ rescue LoadError => error
6
+ puts "~> contacts/flickr: Could not load flickr_fu gem."
7
+ puts "~> contacts/flickr: Install it with `gem install flickr_fu'."
8
+ exit -1
9
+ end
10
+
11
+ module Contacts
12
+
13
+ class Flickr
14
+
15
+ attr_accessor :token
16
+
17
+ def initialize(config_file)
18
+ confs = YAML.load_file(config_file)['flickr']
19
+ @appid= confs['appid']
20
+ @secret= confs['secret']
21
+ end
22
+
23
+ def get_authentication_url
24
+ ::Flickr.new({:key => @appid, :secret => @secret}).auth.url
25
+ end
26
+
27
+ def contacts(frob= nil)
28
+ @token ||= get_token(frob) unless frob.nil?
29
+ ::Flickr.new({:key => @appid, :secret => @secret, :token => @token.token}).contacts.get_list
30
+ end
31
+
32
+ # returns a Flickr::Token
33
+ def get_token(frob)
34
+ client= ::Flickr.new({:key => @appid, :secret => @secret})
35
+ client.auth.frob= frob
36
+ client.auth.token
37
+ end
38
+
39
+ end
40
+ end
@@ -0,0 +1,427 @@
1
+ require 'contacts'
2
+
3
+ require 'hpricot'
4
+ require 'cgi'
5
+ require 'time'
6
+ require 'zlib'
7
+ require 'stringio'
8
+ require 'net/http'
9
+ require 'net/https'
10
+
11
+ module Contacts
12
+ # == Fetching Google Contacts
13
+ #
14
+ # First, get the user to follow the following URL:
15
+ #
16
+ # Contacts::Google.authentication_url('http://mysite.com/invite')
17
+ #
18
+ # After he authenticates successfully to Google, it will redirect him back to the target URL
19
+ # (specified as argument above) and provide the token GET parameter. Use it to create a
20
+ # new instance of this class and request the contact list:
21
+ #
22
+ # gmail = Contacts::Google.new(params[:token])
23
+ # contacts = gmail.contacts
24
+ # #-> [ ['Fitzgerald', 'fubar@gmail.com', 'fubar@example.com'],
25
+ # ['William Paginate', 'will.paginate@gmail.com'], ...
26
+ # ]
27
+ #
28
+ # == Storing a session token
29
+ #
30
+ # The basic token that you will get after the user has authenticated on Google is valid
31
+ # for <b>only one request</b>. However, you can specify that you want a session token which
32
+ # doesn't expire:
33
+ #
34
+ # Contacts::Google.authentication_url('http://mysite.com/invite', :session => true)
35
+ #
36
+ # When the user authenticates, he will be redirected back with a token that can be exchanged
37
+ # for a session token with the following method:
38
+ #
39
+ # token = Contacts::Google.sesion_token(params[:token])
40
+ #
41
+ # Now you have a permanent token. Store it with other user data so you can query the API
42
+ # on his behalf without him having to authenticate on Google each time.
43
+ class Google
44
+ DOMAIN = 'www.google.com'
45
+ AuthSubPath = '/accounts/AuthSub' # all variants go over HTTPS
46
+ ClientLogin = '/accounts/ClientLogin'
47
+ FeedsPath = '/m8/feeds/contacts/'
48
+ GroupsPath = '/m8/feeds/groups/'
49
+
50
+ # default options for #authentication_url
51
+ def self.authentication_url_options
52
+ @authentication_url_options ||= {
53
+ :scope => "http://#{DOMAIN}#{FeedsPath}",
54
+ :secure => false,
55
+ :session => false
56
+ }
57
+ end
58
+
59
+ # default options for #client_login
60
+ def self.client_login_options
61
+ @client_login_options ||= {
62
+ :accountType => 'GOOGLE',
63
+ :service => 'cp',
64
+ :source => 'Contacts-Ruby'
65
+ }
66
+ end
67
+
68
+ # URL to Google site where user authenticates. Afterwards, Google redirects to your
69
+ # site with the URL specified as +target+.
70
+ #
71
+ # Options are:
72
+ # * <tt>:scope</tt> -- the AuthSub scope in which the resulting token is valid
73
+ # (default: "http://www.google.com/m8/feeds/contacts/")
74
+ # * <tt>:secure</tt> -- boolean indicating whether the token will be secure. Only available
75
+ # for registered domains.
76
+ # (default: false)
77
+ # * <tt>:session</tt> -- boolean indicating if the token can be exchanged for a session token
78
+ # (default: false)
79
+ def self.authentication_url(target, options = {})
80
+ params = authentication_url_options.merge(options)
81
+ params[:next] = target
82
+ query = query_string(params)
83
+ "https://#{DOMAIN}#{AuthSubPath}Request?#{query}"
84
+ end
85
+
86
+ # Makes an HTTPS request to exchange the given token with a session one. Session
87
+ # tokens never expire, so you can store them in the database alongside user info.
88
+ #
89
+ # Returns the new token as string or nil if the parameter couldn't be found in response
90
+ # body.
91
+ def self.session_token(token)
92
+ response = http_start do |google|
93
+ google.get(AuthSubPath + 'SessionToken', authorization_header(token))
94
+ end
95
+
96
+ pair = response.body.split(/\n/).detect { |p| p.index('Token=') == 0 }
97
+ pair.split('=').last if pair
98
+ end
99
+
100
+ # Alternative to AuthSub: using email and password.
101
+ def self.client_login(email, password)
102
+ response = http_start do |google|
103
+ query = query_string(client_login_options.merge(:Email => email, :Passwd => password))
104
+ puts "posting #{query} to #{ClientLogin}" if Contacts::verbose?
105
+ google.post(ClientLogin, query)
106
+ end
107
+
108
+ pair = response.body.split(/\n/).detect { |p| p.index('Auth=') == 0 }
109
+ pair.split('=').last if pair
110
+ end
111
+
112
+ attr_reader :user, :token, :headers
113
+ attr_accessor :projection
114
+
115
+ # A token is required here. By default, an AuthSub token from
116
+ # Google is one-time only, which means you can only make a single request with it.
117
+ def initialize(token, user_id = 'default', client = false)
118
+ @user = user_id.to_s
119
+ @token = token.to_s
120
+ @headers = {
121
+ 'Accept-Encoding' => 'gzip',
122
+ 'User-Agent' => Identifier + ' (gzip)',
123
+ 'GData-Version' => '3.0'
124
+ }.update(self.class.authorization_header(@token, client))
125
+ @projection = 'thin'
126
+ end
127
+
128
+ def get(params={}) # :nodoc:
129
+ self.class.http_start(false) do |google|
130
+ path = FeedsPath + CGI.escape(@user)
131
+ google_params = translate_parameters(params)
132
+ query = self.class.query_string(google_params)
133
+ #puts "get query: #{query}"
134
+ google.get("#{path}/#{@projection}?#{query}", @headers)
135
+ end
136
+ end
137
+
138
+ def get_groups(params={})
139
+ self.class.http_start(false) do |google|
140
+ path = GroupsPath + CGI.escape(@user)
141
+ google_params = translate_parameters(params)
142
+ query = self.class.query_string(google_params)
143
+ url = "#{path}/full?#{query}"
144
+ #puts "get_groups url: #{url}"
145
+ google.get(url, @headers)
146
+ end
147
+ end
148
+
149
+ # Timestamp of last update. This value is available only after the XML
150
+ # document has been parsed; for instance after fetching the contact list.
151
+ def updated_at
152
+ @updated_at ||= Time.parse @updated_string if @updated_string
153
+ end
154
+
155
+ # Timestamp of last update as it appeared in the XML document
156
+ def updated_at_string
157
+ @updated_string
158
+ end
159
+
160
+ # Fetches, parses and returns the contact list.
161
+ #
162
+ # ==== Options
163
+ # * <tt>:limit</tt> -- use a large number to fetch a bigger contact list (default: 200)
164
+ # * <tt>:offset</tt> -- 0-based value, can be used for pagination
165
+ # * <tt>:order</tt> -- currently the only value support by Google is "lastmodified"
166
+ # * <tt>:descending</tt> -- boolean
167
+ # * <tt>:updated_after</tt> -- string or time-like object, use to only fetch contacts
168
+ # that were updated after this date
169
+ def contacts(options = {})
170
+ params = { :limit => 200 }.update(options)
171
+ response = get(params)
172
+ parse_contacts response_body(response)
173
+ end
174
+
175
+ # Fetches contacts using multiple API calls when necessary
176
+ def all_contacts(options = {}, chunk_size = 200)
177
+ in_chunks(options, :contacts, chunk_size)
178
+ end
179
+
180
+ def groups(options = {})
181
+ params = {}.update(options)
182
+ response = get_groups(params)
183
+ parse_groups response_body(response)
184
+ end
185
+
186
+ def response_body(response)
187
+ self.class.response_body(response)
188
+ end
189
+
190
+ def self.response_body(response)
191
+ unless response['Content-Encoding'] == 'gzip'
192
+ response.body
193
+ else
194
+ gzipped = StringIO.new(response.body)
195
+ Zlib::GzipReader.new(gzipped).read
196
+ end
197
+ end
198
+
199
+ protected
200
+
201
+ def in_chunks(options, what, chunk_size)
202
+ returns = []
203
+ offset = 0
204
+
205
+ begin
206
+ chunk = send(what, options.merge(:offset => offset, :limit => chunk_size))
207
+ returns.push(*chunk)
208
+ offset += chunk_size
209
+ end while chunk.size == chunk_size
210
+
211
+ returns
212
+ end
213
+
214
+ def parse_groups(body)
215
+ doc = Hpricot::XML body
216
+ groups_found = {}
217
+
218
+ (doc / '/feed/entry').each do |entry|
219
+ id_node = entry.at('id')
220
+ title_node = entry.at('/title')
221
+
222
+ entry_id = id_node.inner_text
223
+ title = title_node ? title_node.inner_text : ''
224
+
225
+ puts "#{title}: #{entry_id}"
226
+ groups_found[title] = entry_id
227
+ end
228
+
229
+ groups_found
230
+ end
231
+
232
+ def parse_contacts(body)
233
+ doc = Hpricot::XML body
234
+ contacts_found = []
235
+
236
+ if updated_node = doc.at('/feed/updated')
237
+ @updated_string = updated_node.inner_text
238
+ end
239
+
240
+ (doc / '/feed/entry').each do |entry|
241
+ title_node = entry.at('/title')
242
+ id_node = entry.at('/id')
243
+ email_nodes = entry / 'gd:email'
244
+ im_nodes = entry / 'gd:im'
245
+ phone_nodes = entry / 'gd:phoneNumber'
246
+ address_nodes = entry / 'gd:postalAddress'
247
+ organization_nodes = entry / 'gd:organization'
248
+ content_node = entry / 'atom:content'
249
+
250
+ service_id = id_node ? id_node.inner_text : nil
251
+ name = title_node ? title_node.inner_html : nil
252
+
253
+ contact = Contact.new(nil, name)
254
+ contact.service_id = service_id
255
+ email_nodes.each do |n|
256
+ contact.emails << {
257
+ 'value' => n['address'].to_s,
258
+ 'type' => (type_map[n['rel']] || 'other').to_s,
259
+ 'primary' => (n['primary'] == 'true').to_s
260
+ }
261
+ end
262
+ im_nodes.each do |n|
263
+ contact.ims << {
264
+ 'value' => n['address'].to_s,
265
+ 'type' => (im_protocols[n['protocol']] || 'unknown').to_s
266
+ }
267
+ end
268
+ phone_nodes.each do |n|
269
+ contact.phones << {
270
+ 'value' => n.inner_html,
271
+ 'type' => (type_map[n['rel']] || 'other').to_s
272
+ }
273
+ end
274
+ address_nodes.each do |n|
275
+ contact.addresses << {
276
+ 'formatted' => n.inner_html,
277
+ 'type' => (type_map[n['rel']] || 'other').to_s
278
+ }
279
+ end
280
+ organization_nodes.each do |n|
281
+ if n['rel'] == 'http://schemas.google.com/g/2005#work'
282
+ org_name = n / 'gd:orgName'
283
+ org_title = n / 'gd:orgTitle'
284
+ org_department = n / 'gd:orgDepartment'
285
+ org_description = n / 'gd:orgJobDescription'
286
+
287
+ contact.organizations << {
288
+ 'type' => 'job',
289
+ 'name' => org_name ? org_name.inner_html : '',
290
+ 'title' => org_title ? org_title.inner_html : '',
291
+ 'department' => org_department ? org_department.inner_html : '',
292
+ 'description' => org_description ? org_description.inner_html : ''
293
+ }
294
+ end
295
+ end
296
+ contact.note = content_node ? content_node.inner_html : ''
297
+
298
+ contacts_found << contact
299
+ end
300
+
301
+ contacts_found
302
+ end
303
+
304
+ def type_map
305
+ @type_map ||= {
306
+ 'http://schemas.google.com/g/2005#other' => 'other',
307
+ 'http://schemas.google.com/g/2005#home' => 'home',
308
+ 'http://schemas.google.com/g/2005#work' => 'work',
309
+ 'http://schemas.google.com/g/2005#mobile' => 'mobile',
310
+ 'http://schemas.google.com/g/2005#pager' => 'pager',
311
+ 'http://schemas.google.com/g/2005#fax' => 'fax',
312
+ 'http://schemas.google.com/g/2005#work_fax' => 'fax',
313
+ 'http://schemas.google.com/g/2005#home_fax' => 'fax'
314
+ }
315
+ end
316
+
317
+ def im_protocols
318
+ @im_protocols ||= {
319
+ 'http://schemas.google.com/g/2005#GOOGLE_TALK' => 'google',
320
+ 'http://schemas.google.com/g/2005#YAHOO' => 'yahoo',
321
+ 'http://schemas.google.com/g/2005#SKYPE' => 'skype',
322
+ 'http://schemas.google.com/g/2005#JABBER' => 'jabber',
323
+ 'http://schemas.google.com/g/2005#MSN' => 'msn',
324
+ 'http://schemas.google.com/g/2005#QQ' => 'qq',
325
+ 'http://schemas.google.com/g/2005#ICQ' => 'icq',
326
+ 'http://schemas.google.com/g/2005#AIM' => 'aim'
327
+ }
328
+ end
329
+
330
+ # Constructs a query string from a Hash object
331
+ def self.query_string(params)
332
+ params.inject([]) do |all, pair|
333
+ key, value = pair
334
+ unless value.nil?
335
+ value = case value
336
+ when TrueClass; '1'
337
+ when FalseClass; '0'
338
+ else value
339
+ end
340
+
341
+ all << "#{CGI.escape(key.to_s)}=#{CGI.escape(value.to_s)}"
342
+ end
343
+ all
344
+ end.join('&')
345
+ end
346
+
347
+ def translate_parameters(params)
348
+ params.inject({}) do |all, pair|
349
+ key, value = pair
350
+ unless value.nil?
351
+ key = case key
352
+ when :limit
353
+ 'max-results'
354
+ when :offset
355
+ value = value.to_i + 1
356
+ 'start-index'
357
+ when :order
358
+ all['sortorder'] = 'descending' if params[:descending].nil?
359
+ 'orderby'
360
+ when :descending
361
+ value = value ? 'descending' : 'ascending'
362
+ 'sortorder'
363
+ when :updated_after
364
+ value = value.strftime("%Y-%m-%dT%H:%M:%S%Z") if value.respond_to? :strftime
365
+ 'updated-min'
366
+ when :group_name
367
+ value = groups[value]
368
+ 'group'
369
+ else key
370
+ end
371
+
372
+ all[key] = value
373
+ end
374
+ all
375
+ end
376
+ end
377
+
378
+ def self.authorization_header(token, client = false)
379
+ type = client ? 'GoogleLogin auth' : 'AuthSub token'
380
+ { 'Authorization' => %(#{type}="#{token}") }
381
+ end
382
+
383
+ def self.http_start(ssl = true)
384
+ port = ssl ? Net::HTTP::https_default_port : Net::HTTP::http_default_port
385
+ http = Net::HTTP.new(DOMAIN, port)
386
+ redirects = 0
387
+ if ssl
388
+ http.use_ssl = true
389
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
390
+ end
391
+ http.start
392
+
393
+ begin
394
+ response = yield(http)
395
+
396
+ loop do
397
+ inspect_response(response) if Contacts::verbose?
398
+
399
+ case response
400
+ when Net::HTTPSuccess
401
+ break response
402
+ when Net::HTTPRedirection
403
+ if redirects == TooManyRedirects::MAX_REDIRECTS
404
+ raise TooManyRedirects.new(response)
405
+ end
406
+ location = URI.parse response['Location']
407
+ puts "Redirected to #{location}"
408
+ response = http.get(location.path)
409
+ redirects += 1
410
+ else
411
+ response.error!
412
+ end
413
+ end
414
+ ensure
415
+ http.finish
416
+ end
417
+ end
418
+
419
+ def self.inspect_response(response, out = $stderr)
420
+ out.puts response.inspect
421
+ for name, value in response
422
+ out.puts "#{name}: #{value}"
423
+ end
424
+ out.puts "----\n#{response_body(response)}\n----" unless response.body.empty?
425
+ end
426
+ end
427
+ end