atom-tools 1.0.0 → 2.0.0

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/lib/atom/http.rb CHANGED
@@ -2,6 +2,8 @@ require "net/http"
2
2
  require "net/https"
3
3
  require "uri"
4
4
 
5
+ require "atom/cache"
6
+
5
7
  require "sha1"
6
8
  require "digest/md5"
7
9
 
@@ -14,7 +16,7 @@ class String # :nodoc:
14
16
  end
15
17
 
16
18
  module Atom
17
- UA = "atom-tools 1.0.0"
19
+ UA = "atom-tools 2.0.0"
18
20
 
19
21
  module DigestAuth
20
22
  CNONCE = Digest::MD5.hexdigest("%x" % (Time.now.to_i + rand(65535)))
@@ -66,8 +68,8 @@ module Atom
66
68
  response = kd(h(a1), params[:nonce] + ":" + h(a2))
67
69
  else
68
70
  @@nonce_count += 1
69
- nc = ('%08x' % @@nonce_count)
70
-
71
+ nc = ('%08x' % @@nonce_count)
72
+
71
73
  # XXX auth-int
72
74
  data = "#{params[:nonce]}:#{nc}:#{CNONCE}:#{"auth"}:#{h(a2)}"
73
75
 
@@ -75,7 +77,7 @@ module Atom
75
77
  end
76
78
 
77
79
  header = %Q<Digest username="#{user}", uri="#{req.path}", realm="#{params[:realm]}", response="#{response}", nonce="#{params[:nonce]}">
78
-
80
+
79
81
  if params[:opaque]
80
82
  header += %Q<, opaque="#{params[:opaque]}">
81
83
  end
@@ -105,7 +107,7 @@ module Atom
105
107
  #
106
108
  # This object can be used on its own, or passed to an Atom::Service,
107
109
  # Atom::Collection or Atom::Feed, where it will be used for requests.
108
- #
110
+ #
109
111
  # All its HTTP methods return a Net::HTTPResponse
110
112
  class HTTP
111
113
  include DigestAuth
@@ -116,14 +118,14 @@ module Atom
116
118
  # the token used for Google's AuthSub authentication
117
119
  attr_accessor :token
118
120
 
119
- # when set to :basic, :wsse or :authsub, this will send an
120
- # Authentication header with every request instead of waiting for a
121
- # challenge from the server.
122
- #
121
+ # when set to :basic, :wsse or :authsub, this will send an
122
+ # Authentication header with every request instead of waiting for a
123
+ # challenge from the server.
124
+ #
123
125
  # be careful; always_auth :basic will send your username and
124
126
  # password in plain text to every URL this object requests.
125
127
  #
126
- # :digest won't work, since Digest authentication requires an
128
+ # :digest won't work, since Digest authentication requires an
127
129
  # initial challenge to generate a response
128
130
  #
129
131
  # defaults to nil
@@ -136,7 +138,18 @@ module Atom
136
138
  # indicates to redirect a POST/PUT/DELETE
137
139
  attr_accessor :allow_all_redirects
138
140
 
139
- def initialize # :nodoc:
141
+ # if set, 'cache' should be a directory for a disk cache, or an object
142
+ # with the same interface as Atom::FileCache
143
+ def initialize cache = nil
144
+ if cache.is_a? String
145
+ @cache = FileCache.new(cache)
146
+ elsif cache
147
+ @cache = cache
148
+ else
149
+ @cache = NilCache.new
150
+ end
151
+
152
+ # initialize default #when_auth
140
153
  @get_auth_details = lambda do |abs_url, realm|
141
154
  if @user and @pass
142
155
  [@user, @pass]
@@ -150,7 +163,7 @@ module Atom
150
163
  def get url, headers = {}
151
164
  http_request(url, Net::HTTP::Get, nil, headers)
152
165
  end
153
-
166
+
154
167
  # POSTs body to an url
155
168
  def post url, body, headers = {}
156
169
  http_request(url, Net::HTTP::Post, body, headers)
@@ -236,7 +249,7 @@ module Atom
236
249
  now = Time.now.gmtime.iso8601
237
250
 
238
251
  digest = [Digest::SHA1.digest(nonce + now + pass)].pack("m").chomp
239
-
252
+
240
253
  req['X-WSSE'] = %Q<UsernameToken Username="#{user}", PasswordDigest="#{digest}", Nonce="#{nonce_enc}", Created="#{now}">
241
254
  req["Authorization"] = 'WSSE profile="UsernameToken"'
242
255
  end
@@ -257,22 +270,46 @@ module Atom
257
270
  end
258
271
 
259
272
  # performs a generic HTTP request.
260
- def http_request(url_s, method, body = nil, init_headers = {}, www_authenticate = nil, redirect_limit = 5)
261
- req, url = new_request(url_s, method, init_headers)
262
-
273
+ def http_request(url_s, method, body = nil, headers = {}, www_authenticate = nil, redirect_limit = 5)
274
+ cachekey = url_s.to_s
275
+
276
+ cached_value = @cache[cachekey]
277
+ if cached_value
278
+ sock = Net::BufferedIO.new(StringIO.new(cached_value))
279
+ info = Net::HTTPResponse.read_new(sock)
280
+ info.reading_body(sock, true) {}
281
+
282
+ if method == Net::HTTP::Put and info.key? 'etag' and not headers['If-Match']
283
+ headers['If-Match'] = info['etag']
284
+ end
285
+ end
286
+
287
+ if cached_value and not [Net::HTTP::Get, Net::HTTP::Head].member? method
288
+ @cache.delete(cachekey)
289
+ elsif cached_value
290
+ entry_disposition = _entry_disposition(info, headers)
291
+
292
+ if entry_disposition == :FRESH
293
+ info.extend Atom::HTTPResponse
294
+
295
+ return info
296
+ elsif entry_disposition == :STALE
297
+ if info.key? 'etag' and not headers['If-None-Match']
298
+ headers['If-None-Match'] = info['etag']
299
+ end
300
+ if info.key? 'last-modified' and not headers['Last-Modified']
301
+ headers['If-Modified-Since'] = info['last-modified']
302
+ end
303
+ end
304
+ end
305
+
306
+ req, url = new_request(url_s, method, headers)
307
+
263
308
  # two reasons to authenticate;
264
309
  if @always_auth
265
310
  self.send("#{@always_auth}_authenticate", req, url)
266
311
  elsif www_authenticate
267
- # XXX multiple challenges, multiple headers
268
- param_string = www_authenticate.sub!(/^(\w+) /, "")
269
- auth_method = ($~[1].downcase + "_authenticate").to_sym
270
-
271
- if self.respond_to? auth_method, true # includes private methods
272
- self.send(auth_method, req, url, param_string)
273
- else
274
- raise "No support for #{$~[1]} authentication"
275
- end
312
+ dispatch_authorization www_authenticate, req, url
276
313
  end
277
314
 
278
315
  http_obj = Net::HTTP.new(url.host, url.port)
@@ -282,47 +319,82 @@ module Atom
282
319
  h.request(req, body)
283
320
  end
284
321
 
322
+ # a bit of added convenience
323
+ res.extend Atom::HTTPResponse
324
+
285
325
  case res
286
326
  when Net::HTTPUnauthorized
287
- if @always_auth or www_authenticate or not res["WWW-Authenticate"] # XXX and not stale (Digest only)
327
+ if @always_auth or www_authenticate or not res["WWW-Authenticate"] # XXX and not stale (Digest only)
288
328
  # we've tried the credentials you gave us once
289
329
  # and failed, or the server gave us no way to fix it
290
330
  raise Unauthorized, "Your authorization was rejected"
291
331
  else
292
332
  # once more, with authentication
293
- res = http_request(url_s, method, body, init_headers, res["WWW-Authenticate"])
333
+ res = http_request(url_s, method, body, headers, res["WWW-Authenticate"])
294
334
 
295
335
  if res.kind_of? Net::HTTPUnauthorized
296
336
  raise Unauthorized, "Your authorization was rejected"
297
337
  end
298
338
  end
299
339
  when Net::HTTPRedirection
300
- if res["Location"] and (allow_all_redirects or [Net::HTTP::Get, Net::HTTP::Head].member? method)
340
+ if res.code == "304" and method == Net::HTTP::Get
341
+ res.end2end_headers.each { |k| info[k] = res[k] }
342
+
343
+ res = info
344
+
345
+ res["Content-Length"] = res.body.length
346
+
347
+ res.extend Atom::HTTPResponse
348
+
349
+ _updateCache(headers, res, @cache, cachekey)
350
+ elsif res["Location"] and (allow_all_redirects or [Net::HTTP::Get, Net::HTTP::Head].member? method)
301
351
  raise HTTPException, "Too many redirects" if redirect_limit.zero?
302
352
 
303
- res = http_request res["Location"], method, body, init_headers, nil, (redirect_limit - 1)
353
+ res = http_request res["Location"], method, body, headers, nil, (redirect_limit - 1)
304
354
  end
355
+ when Net::HTTPOK, Net::HTTPNonAuthoritativeInformation
356
+ unless res.key? 'Content-Location'
357
+ res['Content-Location'] = url_s
358
+ end
359
+ _updateCache(headers, res, @cache, cachekey)
305
360
  end
306
361
 
307
- # a bit of added convenience
308
- res.extend Atom::HTTPResponse
309
-
310
362
  res
311
363
  end
312
-
364
+
313
365
  def new_request(url_string, method, init_headers = {})
314
366
  headers = { "User-Agent" => UA }.merge(init_headers)
315
-
367
+
316
368
  url = url_string.to_uri
317
-
369
+
318
370
  rel = url.path
319
371
  rel += "?" + url.query if url.query
320
372
 
321
373
  [method.new(rel, headers), url]
322
374
  end
375
+
376
+ def dispatch_authorization www_authenticate, req, url
377
+ param_string = www_authenticate.sub(/^(\w+) /, "")
378
+ auth_method = ($~[1].downcase + "_authenticate").to_sym
379
+
380
+ if self.respond_to? auth_method, true # includes private methods
381
+ self.send(auth_method, req, url, param_string)
382
+ else
383
+ # didn't support the first offered, find the next header
384
+ next_to_try = www_authenticate.sub(/.* ([\w]+ )/, '\1')
385
+ if next_to_try == www_authenticate
386
+ # this was the last WWW-Authenticate header
387
+ raise Atom::Unauthorized, "No support for offered authentication types"
388
+ else
389
+ dispatch_authorization next_to_try, req, url
390
+ end
391
+ end
392
+ end
323
393
  end
324
394
 
325
395
  module HTTPResponse
396
+ HOP_BY_HOP = ['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailers', 'transfer-encoding', 'upgrade']
397
+
326
398
  # this should probably support ranges (eg. text/*)
327
399
  def validate_content_type( valid )
328
400
  raise Atom::HTTPException, "HTTP response contains no Content-Type!" if not self.content_type or self.content_type.empty?
@@ -333,5 +405,13 @@ module Atom
333
405
  raise Atom::WrongMimetype, "unexpected response Content-Type: #{media_type.inspect}. should be one of: #{valid.inspect}"
334
406
  end
335
407
  end
408
+
409
+ def end2end_headers
410
+ hopbyhop = HOP_BY_HOP
411
+ if self['connection']
412
+ hopbyhop += self['connection'].split(',').map { |x| x.strip }
413
+ end
414
+ @header.keys.reject { |x| hopbyhop.member? x.downcase }
415
+ end
336
416
  end
337
417
  end
data/lib/atom/service.rb CHANGED
@@ -5,98 +5,28 @@ require "atom/element"
5
5
  require "atom/collection"
6
6
 
7
7
  module Atom
8
+ class AutodiscoveryFailure < RuntimeError; end
9
+
8
10
  # an Atom::Workspace has a #title (Atom::Text) and #collections, an Array of Atom::Collection s
9
11
  class Workspace < Atom::Element
10
- element :collections, Atom::Multiple(Atom::Collection)
11
- element :title, Atom::Text
12
-
13
- def self.parse(xml, base = "", http = Atom::HTTP.new) # :nodoc:
14
- ws = Atom::Workspace.new("workspace")
15
-
16
- rxml = if xml.is_a? REXML::Document
17
- xml.root
18
- elsif xml.is_a? REXML::Element
19
- xml
20
- else
21
- begin
22
- REXML::Document.new(xml)
23
- rescue REXML::ParseException
24
- raise Atom::ParseError
25
- end
26
- end
27
-
28
- xml.fill_text_construct(ws, "title")
29
-
30
- REXML::XPath.match( rxml,
31
- "./app:collection",
32
- {"app" => Atom::PP_NS} ).each do |col_el|
33
- # absolutize relative URLs
34
- url = base.to_uri + col_el.attributes["href"].to_uri
35
-
36
- coll = Atom::Collection.new(url, http)
37
-
38
- col_el.fill_text_construct(coll, "title")
39
-
40
- accepts = REXML::XPath.first( col_el,
41
- "./app:accept",
42
- {"app" => Atom::PP_NS} )
43
-
44
- accepts = []
45
- REXML::XPath.each(col_el, "./app:accept", {"app" => Atom::PP_NS}) do |a|
46
- accepts << a.texts.join
47
- end
48
-
49
- coll.accepts = (accepts.empty? ? ["application/atom+xml;type=entry"] : accepts)
50
-
51
- ws.collections << coll
52
- end
53
-
54
- ws
55
- end
56
-
57
- def to_element # :nodoc:
58
- root = REXML::Element.new "workspace"
59
-
60
- if self.title
61
- title = self.title.to_element
62
- title.name = "atom:title"
63
- root << title
64
- end
65
-
66
- self.collections.each do |coll|
67
- el = REXML::Element.new "collection"
68
-
69
- el.attributes["href"] = coll.uri.to_s
70
-
71
- title = coll.title.to_element
72
- title.name = "atom:title"
73
- el << title
74
-
75
- unless coll.accepts.nil?
76
- coll.accepts.each do |acc|
77
- accept = REXML::Element.new "accept"
78
- accept.text = acc
79
- el << accept
80
- end
81
- end
82
-
83
- root << el
84
- end
12
+ is_element PP_NS, :workspace
85
13
 
86
- root
87
- end
14
+ elements ['app', PP_NS], :collection, :collections, Atom::Collection
15
+ atom_element :title, Atom::Title
88
16
  end
89
17
 
90
18
  # Atom::Service represents an Atom Publishing Protocol service
91
- # document. Its only child is #workspaces, which is an Array of
19
+ # document. Its only child is #workspaces, which is an Array of
92
20
  # Atom::Workspace s
93
21
  class Service < Atom::Element
94
- element :workspaces, Atom::Multiple(Atom::Workspace)
22
+ is_element PP_NS, :service
23
+
24
+ elements ['app', PP_NS], :workspace, :workspaces, Atom::Workspace
95
25
 
96
26
  # retrieves and parses an Atom service document.
97
27
  def initialize(service_url = "", http = Atom::HTTP.new)
98
- super("service")
99
-
28
+ super()
29
+
100
30
  @http = http
101
31
 
102
32
  return if service_url.empty?
@@ -112,52 +42,65 @@ module Atom
112
42
  raise Atom::HTTPException, "Unexpected HTTP response code: #{res.code}"
113
43
  end
114
44
 
115
- parse(res.body, base)
116
- end
117
-
118
- def self.parse xml, base = ""
119
- Atom::Service.new.parse(xml, base)
45
+ self.class.parse(res.body, base, self)
120
46
  end
121
47
 
122
48
  def collections
123
49
  self.workspaces.map { |ws| ws.collections }.flatten
124
50
  end
125
51
 
126
- # parse a service document, adding its workspaces to this object
127
- def parse xml, base = ""
128
- rxml = if xml.is_a? REXML::Document
129
- xml.root
130
- elsif xml.is_a? REXML::Element
131
- xml
132
- else
133
- REXML::Document.new(xml)
134
- end
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')
135
55
 
136
- unless rxml.root.namespace == PP_NS
137
- raise Atom::ParseError, "this isn't an atom service document! (wrong namespace: #{rxml.root.namespace})"
138
- end
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
139
65
 
140
- REXML::XPath.match( rxml, "/app:service/app:workspace", {"app" => Atom::PP_NS} ).each do |ws_el|
141
- self.workspaces << Atom::Workspace.parse(ws_el, base, @http)
142
- end
66
+ h = Hpricot(res.body)
143
67
 
144
- self
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
145
88
  end
146
89
 
147
- # serialize to a (namespaced) REXML::Document
148
- def to_xml
149
- doc = REXML::Document.new
150
-
151
- root = REXML::Element.new "service"
152
- root.add_namespace Atom::PP_NS
153
- root.add_namespace "atom", Atom::NS
90
+ def self.from_rsd url, http = Atom::HTTP.new
91
+ rsd = http.get(url)
92
+
93
+ doc = REXML::Document.new(rsd.body)
154
94
 
155
- self.workspaces.each do |ws|
156
- root << ws.to_element
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"
157
99
  end
158
100
 
159
- doc << root
160
- doc
101
+ url = url.to_uri + atom.attributes['apiLink']
102
+
103
+ Service.new(url.to_s, http)
161
104
  end
162
105
  end
163
106
  end