atom-tools 1.0.0 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
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