aurelian-contacts 0.3.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,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