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/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
|