atom-tools 0.9.0 → 2.0.5

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.
Files changed (93) hide show
  1. data/COPYING +3 -3
  2. data/README +9 -49
  3. data/Rakefile +65 -62
  4. data/bin/atom-cp +159 -0
  5. data/bin/atom-grep +78 -0
  6. data/bin/atom-post +73 -0
  7. data/bin/atom-purge +82 -0
  8. data/doc/classes/Atom/AttrEl.html +163 -0
  9. data/doc/classes/Atom/Author.html +2 -19
  10. data/doc/classes/Atom/AutodiscoveryFailure.html +111 -0
  11. data/doc/classes/Atom/Categories.html +168 -0
  12. data/doc/classes/Atom/Category.html +10 -1
  13. data/doc/classes/Atom/Collection.html +194 -91
  14. data/doc/classes/Atom/Content.html +42 -1
  15. data/doc/classes/Atom/Contributor.html +2 -8
  16. data/doc/classes/Atom/Control.html +126 -0
  17. data/doc/classes/Atom/Converters.html +571 -0
  18. data/doc/classes/Atom/DigestAuth.html +285 -0
  19. data/doc/classes/Atom/Element.html +673 -106
  20. data/doc/classes/Atom/FileCache.html +287 -0
  21. data/doc/classes/Atom/HTTP.html +211 -95
  22. data/doc/classes/Atom/HTTPResponse.html +190 -0
  23. data/doc/classes/Atom/HasCategories.html +187 -0
  24. data/doc/classes/Atom/HasLinks.html +168 -0
  25. data/doc/classes/Atom/Link.html +80 -3
  26. data/doc/classes/Atom/NilCache.html +202 -0
  27. data/doc/classes/Atom/ParseError.html +111 -0
  28. data/doc/classes/Atom/Parsers.html +311 -0
  29. data/doc/classes/Atom/Person.html +130 -0
  30. data/doc/classes/Atom/Rights.html +113 -0
  31. data/doc/classes/Atom/Service.html +298 -0
  32. data/doc/{files/lib/atom/yaml_rb.html → classes/Atom/Source.html} +26 -24
  33. data/doc/classes/Atom/Subtitle.html +113 -0
  34. data/doc/classes/Atom/Summary.html +113 -0
  35. data/doc/classes/Atom/Text.html +196 -53
  36. data/doc/classes/Atom/Title.html +113 -0
  37. data/doc/classes/Atom/Tools.html +525 -0
  38. data/doc/classes/Atom/Workspace.html +121 -0
  39. data/doc/classes/Object.html +204 -0
  40. data/doc/created.rid +1 -1
  41. data/doc/files/README.html +9 -49
  42. data/doc/files/lib/atom/cache_rb.html +302 -0
  43. data/doc/files/lib/atom/collection_rb.html +1 -2
  44. data/doc/files/lib/atom/element_rb.html +2 -1
  45. data/doc/files/lib/atom/entry_rb.html +2 -2
  46. data/doc/files/lib/atom/feed_rb.html +1 -2
  47. data/doc/files/lib/atom/http_rb.html +6 -1
  48. data/doc/files/lib/atom/{xml_rb.html → service_rb.html} +8 -6
  49. data/doc/files/lib/atom/text_rb.html +2 -1
  50. data/doc/files/lib/atom/{app_rb.html → tools_rb.html} +4 -6
  51. data/doc/fr_class_index.html +23 -3
  52. data/doc/fr_file_index.html +3 -3
  53. data/doc/fr_method_index.html +111 -36
  54. data/lib/atom/cache.rb +178 -0
  55. data/lib/atom/collection.rb +90 -40
  56. data/lib/atom/element.rb +569 -161
  57. data/lib/atom/entry.rb +63 -89
  58. data/lib/atom/feed.rb +65 -67
  59. data/lib/atom/http.rb +421 -49
  60. data/lib/atom/service.rb +106 -0
  61. data/lib/atom/text.rb +138 -61
  62. data/lib/atom/tools.rb +163 -0
  63. data/spec/entry_spec.rb +364 -0
  64. data/spec/ext_spec.rb +42 -0
  65. data/spec/feed_spec.rb +39 -0
  66. data/spec/fixtures/contacts-feed.xml +34 -0
  67. data/spec/fixtures/entry-w-ext.xml +15 -0
  68. data/spec/fixtures/entry-w-xml.xml +24 -0
  69. data/spec/fixtures/entry.xml +42 -0
  70. data/spec/fixtures/feed-w-ext.xml +33 -0
  71. data/spec/fixtures/service-w-xhtml-ns.xml +21 -0
  72. data/spec/fixtures/service.xml +36 -0
  73. data/spec/service_spec.rb +108 -0
  74. data/spec/spec_helper.rb +9 -0
  75. data/test/conformance/order.rb +11 -10
  76. data/test/conformance/title.rb +9 -9
  77. data/test/conformance/updated.rb +2 -1
  78. data/test/runtests.rb +0 -0
  79. data/test/test_constructs.rb +78 -8
  80. data/test/test_feed.rb +35 -29
  81. data/test/test_general.rb +3 -30
  82. data/test/test_http.rb +248 -34
  83. data/test/test_protocol.rb +143 -24
  84. data/test/test_xml.rb +145 -53
  85. metadata +116 -62
  86. data/bin/atom-client.rb +0 -246
  87. data/bin/atom-server.rb~ +0 -71
  88. data/doc/classes/Atom/App.html +0 -217
  89. data/doc/classes/Atom/Entry.html +0 -365
  90. data/doc/classes/Atom/Feed.html +0 -585
  91. data/lib/atom/app.rb +0 -87
  92. data/lib/atom/xml.rb +0 -200
  93. data/lib/atom/yaml.rb +0 -101
data/lib/atom/http.rb CHANGED
@@ -1,5 +1,12 @@
1
1
  require "net/http"
2
- require 'uri'
2
+ require "net/https"
3
+ require "uri"
4
+ require "cgi"
5
+
6
+ require "atom/cache"
7
+
8
+ require "digest/sha1"
9
+ require "digest/md5"
3
10
 
4
11
  module URI # :nodoc: all
5
12
  class Generic; def to_uri; self; end; end
@@ -10,22 +17,143 @@ class String # :nodoc:
10
17
  end
11
18
 
12
19
  module Atom
13
- UA = "atom-tools 0.9.0"
14
- class Unauthorized < RuntimeError # :nodoc:
20
+ TOOLS_VERSION = '2.0.5'
21
+ UA = "atom-tools " + TOOLS_VERSION
22
+
23
+ module DigestAuth
24
+ CNONCE = Digest::MD5.hexdigest("%x" % (Time.now.to_i + rand(65535)))
25
+
26
+ @@nonce_count = -1
27
+
28
+ # quoted-strings plus a few special cases for Digest
29
+ def parse_wwwauth_digest param_string
30
+ params = parse_quoted_wwwauth param_string
31
+ qop = params[:qop] ? params[:qop].split(",") : nil
32
+
33
+ param_string.gsub(/stale=([^,]*)/) do
34
+ params[:stale] = ($1.downcase == "true")
35
+ end
36
+
37
+ params[:algorithm] = "MD5"
38
+ param_string.gsub(/algorithm=([^,]*)/) { params[:algorithm] = $1 }
39
+
40
+ params
41
+ end
42
+
43
+ def h(data); Digest::MD5.hexdigest(data); end
44
+ def kd(secret, data); h(secret + ":" + data); end
45
+
46
+ # HTTP Digest authentication (RFC 2617)
47
+ def digest_authenticate(req, url, param_string = "")
48
+ raise "Digest authentication requires a WWW-Authenticate header" if param_string.empty?
49
+
50
+ params = parse_wwwauth_digest(param_string)
51
+ qop = params[:qop]
52
+
53
+ user, pass = username_and_password_for_realm(url, params[:realm])
54
+
55
+ if params[:algorithm] == "MD5"
56
+ a1 = user + ":" + params[:realm] + ":" + pass
57
+ else
58
+ # XXX MD5-sess
59
+ raise "I only support MD5 digest authentication (not #{params[:algorithm].inspect})"
60
+ end
61
+
62
+ if qop.nil? or qop.member? "auth"
63
+ a2 = req.method + ":" + req.path
64
+ else
65
+ # XXX auth-int
66
+ raise "only 'auth' qop supported (none of: #{qop.inspect})"
67
+ end
68
+
69
+ if qop.nil?
70
+ response = kd(h(a1), params[:nonce] + ":" + h(a2))
71
+ else
72
+ @@nonce_count += 1
73
+ nc = ('%08x' % @@nonce_count)
74
+
75
+ # XXX auth-int
76
+ data = "#{params[:nonce]}:#{nc}:#{CNONCE}:#{"auth"}:#{h(a2)}"
77
+
78
+ response = kd(h(a1), data)
79
+ end
80
+
81
+ header = %Q<Digest username="#{user}", uri="#{req.path}", realm="#{params[:realm]}", response="#{response}", nonce="#{params[:nonce]}">
82
+
83
+ if params[:opaque]
84
+ header += %Q<, opaque="#{params[:opaque]}">
85
+ end
86
+
87
+ if params[:algorithm] != "MD5"
88
+ header += ", algorithm=#{algo}"
89
+ end
90
+
91
+ if qop
92
+ # XXX auth-int
93
+ header += %Q<, nc=#{nc}, cnonce="#{CNONCE}", qop=auth>
94
+ end
95
+
96
+ req["Authorization"] = header
97
+ end
98
+ end
99
+
100
+ class HTTPException < RuntimeError # :nodoc:
101
+ end
102
+ class Unauthorized < Atom::HTTPException # :nodoc:
103
+ end
104
+ class WrongMimetype < Atom::HTTPException # :nodoc:
15
105
  end
16
106
 
17
107
  # An object which handles the details of HTTP - particularly
18
108
  # authentication and caching (neither of which are fully implemented).
19
109
  #
20
- # This object can be used on its own, or passed to an Atom::App,
110
+ # This object can be used on its own, or passed to an Atom::Service,
21
111
  # Atom::Collection or Atom::Feed, where it will be used for requests.
22
- #
112
+ #
23
113
  # All its HTTP methods return a Net::HTTPResponse
24
114
  class HTTP
115
+ include DigestAuth
116
+
25
117
  # used by the default #when_auth
26
118
  attr_accessor :user, :pass
27
119
 
28
- def initialize # :nodoc:
120
+ # the token used for Google's AuthSub authentication
121
+ attr_accessor :token
122
+
123
+ # when set to :basic, :wsse or :authsub, this will send an
124
+ # Authentication header with every request instead of waiting for a
125
+ # challenge from the server.
126
+ #
127
+ # be careful; always_auth :basic will send your username and
128
+ # password in plain text to every URL this object requests.
129
+ #
130
+ # :digest won't work, since Digest authentication requires an
131
+ # initial challenge to generate a response
132
+ #
133
+ # defaults to nil
134
+ attr_accessor :always_auth
135
+ # if this is true, we tell Net::HTTP to die if it can't verify the SSL when doing https
136
+ attr_accessor :strict_ssl
137
+
138
+ # automatically handle redirects, even for POST/PUT/DELETE requests?
139
+ #
140
+ # defaults to false, which will transparently redirect GET requests
141
+ # but return a Net::HTTPRedirection object when the server
142
+ # indicates to redirect a POST/PUT/DELETE
143
+ attr_accessor :allow_all_redirects
144
+
145
+ # if set, 'cache' should be a directory for a disk cache, or an object
146
+ # with the same interface as Atom::FileCache
147
+ def initialize cache = nil
148
+ if cache.is_a? String
149
+ @cache = FileCache.new(cache)
150
+ elsif cache
151
+ @cache = cache
152
+ else
153
+ @cache = NilCache.new
154
+ end
155
+
156
+ # initialize default #when_auth
29
157
  @get_auth_details = lambda do |abs_url, realm|
30
158
  if @user and @pass
31
159
  [@user, @pass]
@@ -39,7 +167,7 @@ module Atom
39
167
  def get url, headers = {}
40
168
  http_request(url, Net::HTTP::Get, nil, headers)
41
169
  end
42
-
170
+
43
171
  # POSTs body to an url
44
172
  def post url, body, headers = {}
45
173
  http_request(url, Net::HTTP::Post, body, headers)
@@ -57,76 +185,320 @@ module Atom
57
185
 
58
186
  # a block that will be called when a remote server responds with
59
187
  # 401 Unauthorized, so that your application can prompt for
60
- # authentication details
188
+ # authentication details.
61
189
  #
62
- # it will be called with the base URL of the requested URL, and the realm used in the WWW-Authenticate header.
63
- #
64
- # it should return a value of the form [username, password]
65
- def when_auth &block
190
+ # the default is to use the values of @user and @pass.
191
+ #
192
+ # your block will be called with two parameters:
193
+ # abs_url:: the base URL of the request URL
194
+ # realm:: the realm used in the WWW-Authenticate header (maybe nil)
195
+ #
196
+ # your block should return [username, password], or nil
197
+ def when_auth &block # :yields: abs_url, realm
66
198
  @get_auth_details = block
67
199
  end
68
200
 
201
+ # GET a URL and turn it into an Atom::Entry
202
+ def get_atom_entry(url)
203
+ res = get(url, "Accept" => "application/atom+xml")
204
+
205
+ # XXX handle other HTTP codes
206
+ if res.code != "200"
207
+ raise Atom::HTTPException, "failed to fetch entry: expected 200 OK, got #{res.code}"
208
+ end
209
+
210
+ # be picky for atom:entrys
211
+ res.validate_content_type( [ "application/atom+xml" ] )
212
+
213
+ Atom::Entry.parse(res.body, url)
214
+ end
215
+
216
+ # PUT an Atom::Entry to a URL
217
+ def put_atom_entry(entry, url = entry.edit_url)
218
+ raise "Cowardly refusing to PUT a non-Atom::Entry (#{entry.class})" unless entry.is_a? Atom::Entry
219
+ headers = {"Content-Type" => "application/atom+xml" }
220
+
221
+ put(url, entry.to_s, headers)
222
+ end
223
+
69
224
  private
70
- def parse_wwwauth www_authenticate
71
- auth_type = www_authenticate.split[0] # "Digest" or "Basic"
72
- auth_params = {}
73
-
74
- www_authenticate =~ /^(\w+) (.*)/
225
+ # parses plain quoted-strings
226
+ def parse_quoted_wwwauth param_string
227
+ params = {}
228
+
229
+ param_string.gsub(/(\w+)="(.*?)"/) { params[$1.to_sym] = $2 }
230
+
231
+ params
232
+ end
233
+
234
+ # HTTP Basic authentication (RFC 2617)
235
+ def basic_authenticate(req, url, param_string = "")
236
+ params = parse_quoted_wwwauth(param_string)
237
+
238
+ user, pass = username_and_password_for_realm(url, params[:realm])
239
+
240
+ req.basic_auth user, pass
241
+ end
242
+
243
+ # is this the right way to do it? who knows, there's no
244
+ # spec!
245
+ # <http://necronomicorp.com/lab/atom-authentication-sucks>
246
+ #
247
+ # thanks to H. Miyamoto for clearing things up.
248
+ def wsse_authenticate(req, url, params = {})
249
+ user, pass = username_and_password_for_realm(url, params["realm"])
250
+
251
+ nonce = rand(16**32).to_s(16)
252
+ nonce_enc = [nonce].pack('m').chomp
253
+ now = Time.now.gmtime.iso8601
254
+
255
+ digest = [Digest::SHA1.digest(nonce + now + pass)].pack("m").chomp
75
256
 
76
- $2.gsub(/(\w+)="(.*?)"/) { auth_params[$1] = $2 }
257
+ req['X-WSSE'] = %Q<UsernameToken Username="#{user}", PasswordDigest="#{digest}", Nonce="#{nonce_enc}", Created="#{now}">
258
+ req["Authorization"] = 'WSSE profile="UsernameToken"'
259
+ end
77
260
 
78
- [ auth_type, auth_params ]
261
+ def authsub_authenticate req, url, param_string = ""
262
+ req["Authorization"] = %{AuthSub token="#{@token}"}
79
263
  end
80
264
 
81
- # performs an authenticated http request
82
- def authenticated_request(url_string, method, wwwauth, body = nil, init_headers = {})
265
+ # GoogleLogin support thanks to Adrian Hosey
266
+ def googlelogin_authenticate(req, url, param_string)
267
+ params_h = Hash.new
268
+ param_string.split(',').each do |p|
269
+ k, v = p.split('=')
270
+ # No whitespace in the key
271
+ k.delete!(' ')
272
+ # Values come wrapped in doublequotes - remove
273
+ v.gsub!(/^"|"$/, '')
274
+ params_h[k] = v
275
+ end
83
276
 
84
- auth_type, params = parse_wwwauth(wwwauth)
85
- req, url = new_request(url_string, method, init_headers)
86
-
87
- realm = params["realm"]
88
277
  abs_url = (url + "/").to_s
278
+ user, pass = @get_auth_details.call(abs_url, params_h["realm"])
279
+ token = fetch_googlelogin_token(user, pass, params_h["realm"], params_h["service"])
280
+ if !token.nil?
281
+ req["Authorization"] = "GoogleLogin auth=#{token}"
282
+ end
283
+ end
89
284
 
90
- user, pass = @get_auth_details.call(abs_url, realm)
91
-
92
- raise Unauthorized unless user and pass
93
-
94
- if auth_type == "Basic"
95
- req.basic_auth user, pass
285
+ def fetch_googlelogin_token(user, pass, url_s, service)
286
+ req, url = new_request(url_s, Net::HTTP::Post)
287
+ http_obj = Net::HTTP.new(url.host, url.port)
288
+ if url.scheme == "https"
289
+ http_obj.use_ssl = true
290
+ probe_for_cafile(http_obj)
291
+ end
292
+
293
+ body = "Email=#{CGI.escape(user)}&Passwd=#{CGI.escape(pass)}&service=#{CGI.escape(service)}"
294
+ body += "&accountType=GOOGLE&source=ruby-atom-tools-#{CGI.escape(TOOLS_VERSION)}"
295
+ res = http_obj.start do |h|
296
+ h.request(req, body)
297
+ end
298
+
299
+ retval = nil
300
+ case res
301
+ when Net::HTTPUnauthorized
302
+ raise Unauthorized, "Your authorization was rejected"
303
+ when Net::HTTPOK, Net::HTTPNonAuthoritativeInformation
304
+ res.body.each_line do |l|
305
+ k, v = l.split('=')
306
+ if k == "Auth"
307
+ retval = v.chomp
308
+ end
309
+ end
310
+ end
311
+
312
+ retval
313
+ end
314
+
315
+ # Look for a root CA file and set the relevant options on the passed-in Net::HTTP object.
316
+ def probe_for_cafile(http_obj)
317
+ ca_possibles = [
318
+ '/usr/share/curl/curl-ca-bundle.crt', # OS X
319
+ '/etc/pki/tls/certs/ca-bundle.crt', # newer Redhat
320
+ '/usr/share/ssl/certs/ca-bundle.crt', # older Redhat
321
+ '/etc/ssl/certs/ca-certificates.crt', # Ubuntu (I think)
322
+ # <irony>Dear LSB: Thank you for standardizing Linux</irony>
323
+ ]
324
+ cafile = nil
325
+ ca_possibles.each do |ca|
326
+ if File.exist? ca
327
+ cafile = ca
328
+ break
329
+ end
330
+ end
331
+ if cafile.nil?
332
+ if @strict_ssl
333
+ # set this knowing it will die, since we didn't find a good cafile
334
+ http_obj.verify_mode = OpenSSL::SSL::VERIFY_PEER
335
+ else
336
+ http_obj.verify_mode = OpenSSL::SSL::VERIFY_NONE
337
+ end
96
338
  else
97
- # TODO: implement Digest auth
98
- raise "atom-tools only supports Basic authentication"
339
+ http_obj.ca_file = cafile
340
+ http_obj.verify_mode = OpenSSL::SSL::VERIFY_PEER
341
+ http_obj.verify_depth = 5
99
342
  end
100
-
101
- res = Net::HTTP.start(url.host, url.port) { |h| h.request(req, body) }
102
-
103
- raise Unauthorized if res.kind_of? Net::HTTPUnauthorized
104
- res
105
343
  end
106
344
 
107
- # performs a regular http request. if it responds 401
108
- # then it retries using @user and @pass for authentication
109
- def http_request(url_string, method, body = nil, init_headers = {})
110
- req, url = new_request(url_string, method, init_headers)
111
-
112
- res = Net::HTTP.start(url.host, url.port) { |h| h.request(req, body) }
345
+ def username_and_password_for_realm(url, realm)
346
+ abs_url = (url + "/").to_s
347
+ user, pass = @get_auth_details.call(abs_url, realm)
348
+
349
+ unless user and pass
350
+ raise Unauthorized, "You must provide a username and password"
351
+ end
352
+
353
+ [ user, pass ]
354
+ end
355
+
356
+ # performs a generic HTTP request.
357
+ def http_request(url_s, method, body = nil, headers = {}, www_authenticate = nil, redirect_limit = 5)
358
+ cachekey = url_s.to_s
359
+
360
+ cached_value = @cache[cachekey]
361
+ if cached_value
362
+ sock = Net::BufferedIO.new(StringIO.new(cached_value))
363
+ info = Net::HTTPResponse.read_new(sock)
364
+ info.reading_body(sock, true) {}
365
+
366
+ if method == Net::HTTP::Put and info.key? 'etag' and not headers['If-Match']
367
+ headers['If-Match'] = info['etag']
368
+ end
369
+ end
370
+
371
+ if cached_value and not [Net::HTTP::Get, Net::HTTP::Head].member? method
372
+ @cache.delete(cachekey)
373
+ elsif cached_value
374
+ entry_disposition = _entry_disposition(info, headers)
113
375
 
114
- if res.kind_of? Net::HTTPUnauthorized
115
- res = authenticated_request(url, method, res["WWW-Authenticate"], body, init_headers)
376
+ if entry_disposition == :FRESH
377
+ info.extend Atom::HTTPResponse
378
+
379
+ return info
380
+ elsif entry_disposition == :STALE
381
+ if info.key? 'etag' and not headers['If-None-Match']
382
+ headers['If-None-Match'] = info['etag']
383
+ end
384
+ if info.key? 'last-modified' and not headers['Last-Modified']
385
+ headers['If-Modified-Since'] = info['last-modified']
386
+ end
387
+ end
388
+ end
389
+
390
+ req, url = new_request(url_s, method, headers)
391
+
392
+ # two reasons to authenticate;
393
+ if @always_auth
394
+ self.send("#{@always_auth}_authenticate", req, url)
395
+ elsif www_authenticate
396
+ dispatch_authorization www_authenticate, req, url
397
+ end
398
+
399
+ http_obj = Net::HTTP.new(url.host, url.port)
400
+ if url.scheme == "https"
401
+ http_obj.use_ssl = true
402
+ probe_for_cafile(http_obj)
403
+ end
404
+
405
+ res = http_obj.start do |h|
406
+ h.request(req, body)
407
+ end
408
+
409
+ # a bit of added convenience
410
+ res.extend Atom::HTTPResponse
411
+
412
+ case res
413
+ when Net::HTTPUnauthorized
414
+ if @always_auth or www_authenticate or not res["WWW-Authenticate"] # XXX and not stale (Digest only)
415
+ # we've tried the credentials you gave us once
416
+ # and failed, or the server gave us no way to fix it
417
+ raise Unauthorized, "Your authorization was rejected"
418
+ else
419
+ # once more, with authentication
420
+ res = http_request(url_s, method, body, headers, res["WWW-Authenticate"])
421
+
422
+ if res.kind_of? Net::HTTPUnauthorized
423
+ raise Unauthorized, "Your authorization was rejected"
424
+ end
425
+ end
426
+ when Net::HTTPRedirection
427
+ if res.code == "304" and method == Net::HTTP::Get
428
+ res.end2end_headers.each { |k| info[k] = res[k] }
429
+
430
+ res = info
431
+
432
+ res["Content-Length"] = res.body.length
433
+
434
+ res.extend Atom::HTTPResponse
435
+
436
+ _updateCache(headers, res, @cache, cachekey)
437
+ elsif res["Location"] and (allow_all_redirects or [Net::HTTP::Get, Net::HTTP::Head].member? method)
438
+ raise HTTPException, "Too many redirects" if redirect_limit.zero?
439
+
440
+ res = http_request res["Location"], method, body, headers, nil, (redirect_limit - 1)
441
+ end
442
+ when Net::HTTPOK, Net::HTTPNonAuthoritativeInformation
443
+ unless res.key? 'Content-Location'
444
+ res['Content-Location'] = url_s
445
+ end
446
+ _updateCache(headers, res, @cache, cachekey)
116
447
  end
117
448
 
118
449
  res
119
450
  end
120
-
451
+
121
452
  def new_request(url_string, method, init_headers = {})
122
453
  headers = { "User-Agent" => UA }.merge(init_headers)
123
-
454
+
124
455
  url = url_string.to_uri
125
-
456
+
126
457
  rel = url.path
127
458
  rel += "?" + url.query if url.query
128
459
 
129
460
  [method.new(rel, headers), url]
130
461
  end
462
+
463
+ def dispatch_authorization www_authenticate, req, url
464
+ param_string = www_authenticate.sub(/^(\w+) /, "")
465
+ auth_method = ($~[1].downcase + "_authenticate").to_sym
466
+
467
+ if self.respond_to? auth_method, true # includes private methods
468
+ self.send(auth_method, req, url, param_string)
469
+ else
470
+ # didn't support the first offered, find the next header
471
+ next_to_try = www_authenticate.sub(/.* ([\w]+ )/, '\1')
472
+ if next_to_try == www_authenticate
473
+ # this was the last WWW-Authenticate header
474
+ raise Atom::Unauthorized, "No support for offered authentication types"
475
+ else
476
+ dispatch_authorization next_to_try, req, url
477
+ end
478
+ end
479
+ end
480
+ end
481
+
482
+ module HTTPResponse
483
+ HOP_BY_HOP = ['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailers', 'transfer-encoding', 'upgrade']
484
+
485
+ # this should probably support ranges (eg. text/*)
486
+ def validate_content_type( valid )
487
+ raise Atom::HTTPException, "HTTP response contains no Content-Type!" if not self.content_type or self.content_type.empty?
488
+
489
+ media_type = self.content_type.split(";").first
490
+
491
+ unless valid.member? media_type.downcase
492
+ raise Atom::WrongMimetype, "unexpected response Content-Type: #{media_type.inspect}. should be one of: #{valid.inspect}"
493
+ end
494
+ end
495
+
496
+ def end2end_headers
497
+ hopbyhop = HOP_BY_HOP
498
+ if self['connection']
499
+ hopbyhop += self['connection'].split(',').map { |x| x.strip }
500
+ end
501
+ @header.keys.reject { |x| hopbyhop.member? x.downcase }
502
+ end
131
503
  end
132
504
  end
@@ -0,0 +1,106 @@
1
+ require "uri"
2
+
3
+ require "atom/http"
4
+ require "atom/element"
5
+ require "atom/collection"
6
+
7
+ module Atom
8
+ class AutodiscoveryFailure < RuntimeError; end
9
+
10
+ # an Atom::Workspace has a #title (Atom::Text) and #collections, an Array of Atom::Collection s
11
+ class Workspace < Atom::Element
12
+ is_element PP_NS, :workspace
13
+
14
+ elements ['app', PP_NS], :collection, :collections, Atom::Collection
15
+ atom_element :title, Atom::Title
16
+ end
17
+
18
+ # Atom::Service represents an Atom Publishing Protocol service
19
+ # document. Its only child is #workspaces, which is an Array of
20
+ # Atom::Workspace s
21
+ class Service < Atom::Element
22
+ is_element PP_NS, :service
23
+
24
+ elements ['app', PP_NS], :workspace, :workspaces, Atom::Workspace
25
+
26
+ # retrieves and parses an Atom service document.
27
+ def initialize(service_url = "", http = Atom::HTTP.new)
28
+ super()
29
+
30
+ @http = http
31
+
32
+ return if service_url.empty?
33
+
34
+ base = URI.parse(service_url)
35
+
36
+ rxml = nil
37
+
38
+ res = @http.get(base, "Accept" => "application/atomsvc+xml")
39
+ res.validate_content_type(["application/atomsvc+xml"])
40
+
41
+ unless res.code == "200"
42
+ raise Atom::HTTPException, "Unexpected HTTP response code: #{res.code}"
43
+ end
44
+
45
+ self.class.parse(res.body, base, self)
46
+ end
47
+
48
+ def collections
49
+ self.workspaces.map { |ws| ws.collections }.flatten
50
+ end
51
+
52
+ # given a URL, attempt to find a service document
53
+ def self.discover url, http = Atom::HTTP.new
54
+ res = http.get(url, 'Accept' => 'application/atomsvc+xml, text/html')
55
+
56
+ case res.content_type
57
+ when /application\/atomsvc\+xml/
58
+ Service.parse res.body, url
59
+ when /html/
60
+ begin
61
+ require 'hpricot'
62
+ rescue
63
+ raise 'autodiscovering from HTML requires Hpricot.'
64
+ end
65
+
66
+ h = Hpricot(res.body)
67
+
68
+ links = h.search('//link')
69
+
70
+ service_links = links.select { |l| (' ' + l['rel'] + ' ').match(/ service /i) }
71
+
72
+ unless service_links.empty?
73
+ url = url.to_uri + service_links.first['href']
74
+ return Service.new(url.to_s, http)
75
+ end
76
+
77
+ rsd_links = links.select { |l| (' ' + l['rel'] + ' ').match(/ EditURI /i) }
78
+
79
+ unless rsd_links.empty?
80
+ url = url.to_uri + rsd_links.first['href']
81
+ return Service.from_rsd(url, http)
82
+ end
83
+
84
+ raise AutodiscoveryFailure, "couldn't find any autodiscovery links in the HTML"
85
+ else
86
+ raise AutodiscoveryFailure, "can't autodiscover from a document of type #{res.content_type}"
87
+ end
88
+ end
89
+
90
+ def self.from_rsd url, http = Atom::HTTP.new
91
+ rsd = http.get(url)
92
+
93
+ doc = REXML::Document.new(rsd.body)
94
+
95
+ atom = REXML::XPath.first(doc, '/rsd/service/apis/api[@name="Atom"]')
96
+
97
+ unless atom
98
+ raise AutodiscoveryFailure, "couldn't find an Atom link in the RSD"
99
+ end
100
+
101
+ url = url.to_uri + atom.attributes['apiLink']
102
+
103
+ Service.new(url.to_s, http)
104
+ end
105
+ end
106
+ end