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/COPYING +3 -3
- data/README +4 -44
- data/Rakefile +9 -2
- data/bin/atom-cp +159 -0
- data/bin/atom-grep +78 -0
- data/bin/atom-post +72 -0
- data/bin/atom-purge +82 -0
- data/lib/atom/cache.rb +178 -0
- data/lib/atom/collection.rb +77 -17
- data/lib/atom/element.rb +520 -166
- data/lib/atom/entry.rb +82 -142
- data/lib/atom/feed.rb +48 -66
- data/lib/atom/http.rb +115 -35
- data/lib/atom/service.rb +56 -113
- data/lib/atom/text.rb +79 -63
- data/lib/atom/tools.rb +163 -0
- data/test/conformance/order.rb +11 -10
- data/test/conformance/title.rb +9 -9
- data/test/test_constructs.rb +23 -10
- data/test/test_feed.rb +0 -44
- data/test/test_general.rb +0 -40
- data/test/test_http.rb +18 -0
- data/test/test_protocol.rb +60 -22
- data/test/test_xml.rb +73 -41
- metadata +47 -37
- data/bin/atom-client.rb +0 -275
- data/lib/atom/xml.rb +0 -213
- data/lib/atom/yaml.rb +0 -116
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
|
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
|
-
|
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,
|
261
|
-
|
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
|
-
|
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,
|
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
|
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,
|
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
|
-
|
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
|
-
|
87
|
-
|
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
|
-
|
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(
|
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
|
-
#
|
127
|
-
def
|
128
|
-
|
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
|
-
|
137
|
-
|
138
|
-
|
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
|
-
|
141
|
-
self.workspaces << Atom::Workspace.parse(ws_el, base, @http)
|
142
|
-
end
|
66
|
+
h = Hpricot(res.body)
|
143
67
|
|
144
|
-
|
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
|
-
|
148
|
-
|
149
|
-
|
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
|
-
|
156
|
-
|
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
|
-
|
160
|
-
|
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
|