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.
- data/COPYING +3 -3
- data/README +9 -49
- data/Rakefile +65 -62
- data/bin/atom-cp +159 -0
- data/bin/atom-grep +78 -0
- data/bin/atom-post +73 -0
- data/bin/atom-purge +82 -0
- data/doc/classes/Atom/AttrEl.html +163 -0
- data/doc/classes/Atom/Author.html +2 -19
- data/doc/classes/Atom/AutodiscoveryFailure.html +111 -0
- data/doc/classes/Atom/Categories.html +168 -0
- data/doc/classes/Atom/Category.html +10 -1
- data/doc/classes/Atom/Collection.html +194 -91
- data/doc/classes/Atom/Content.html +42 -1
- data/doc/classes/Atom/Contributor.html +2 -8
- data/doc/classes/Atom/Control.html +126 -0
- data/doc/classes/Atom/Converters.html +571 -0
- data/doc/classes/Atom/DigestAuth.html +285 -0
- data/doc/classes/Atom/Element.html +673 -106
- data/doc/classes/Atom/FileCache.html +287 -0
- data/doc/classes/Atom/HTTP.html +211 -95
- data/doc/classes/Atom/HTTPResponse.html +190 -0
- data/doc/classes/Atom/HasCategories.html +187 -0
- data/doc/classes/Atom/HasLinks.html +168 -0
- data/doc/classes/Atom/Link.html +80 -3
- data/doc/classes/Atom/NilCache.html +202 -0
- data/doc/classes/Atom/ParseError.html +111 -0
- data/doc/classes/Atom/Parsers.html +311 -0
- data/doc/classes/Atom/Person.html +130 -0
- data/doc/classes/Atom/Rights.html +113 -0
- data/doc/classes/Atom/Service.html +298 -0
- data/doc/{files/lib/atom/yaml_rb.html → classes/Atom/Source.html} +26 -24
- data/doc/classes/Atom/Subtitle.html +113 -0
- data/doc/classes/Atom/Summary.html +113 -0
- data/doc/classes/Atom/Text.html +196 -53
- data/doc/classes/Atom/Title.html +113 -0
- data/doc/classes/Atom/Tools.html +525 -0
- data/doc/classes/Atom/Workspace.html +121 -0
- data/doc/classes/Object.html +204 -0
- data/doc/created.rid +1 -1
- data/doc/files/README.html +9 -49
- data/doc/files/lib/atom/cache_rb.html +302 -0
- data/doc/files/lib/atom/collection_rb.html +1 -2
- data/doc/files/lib/atom/element_rb.html +2 -1
- data/doc/files/lib/atom/entry_rb.html +2 -2
- data/doc/files/lib/atom/feed_rb.html +1 -2
- data/doc/files/lib/atom/http_rb.html +6 -1
- data/doc/files/lib/atom/{xml_rb.html → service_rb.html} +8 -6
- data/doc/files/lib/atom/text_rb.html +2 -1
- data/doc/files/lib/atom/{app_rb.html → tools_rb.html} +4 -6
- data/doc/fr_class_index.html +23 -3
- data/doc/fr_file_index.html +3 -3
- data/doc/fr_method_index.html +111 -36
- data/lib/atom/cache.rb +178 -0
- data/lib/atom/collection.rb +90 -40
- data/lib/atom/element.rb +569 -161
- data/lib/atom/entry.rb +63 -89
- data/lib/atom/feed.rb +65 -67
- data/lib/atom/http.rb +421 -49
- data/lib/atom/service.rb +106 -0
- data/lib/atom/text.rb +138 -61
- data/lib/atom/tools.rb +163 -0
- data/spec/entry_spec.rb +364 -0
- data/spec/ext_spec.rb +42 -0
- data/spec/feed_spec.rb +39 -0
- data/spec/fixtures/contacts-feed.xml +34 -0
- data/spec/fixtures/entry-w-ext.xml +15 -0
- data/spec/fixtures/entry-w-xml.xml +24 -0
- data/spec/fixtures/entry.xml +42 -0
- data/spec/fixtures/feed-w-ext.xml +33 -0
- data/spec/fixtures/service-w-xhtml-ns.xml +21 -0
- data/spec/fixtures/service.xml +36 -0
- data/spec/service_spec.rb +108 -0
- data/spec/spec_helper.rb +9 -0
- data/test/conformance/order.rb +11 -10
- data/test/conformance/title.rb +9 -9
- data/test/conformance/updated.rb +2 -1
- data/test/runtests.rb +0 -0
- data/test/test_constructs.rb +78 -8
- data/test/test_feed.rb +35 -29
- data/test/test_general.rb +3 -30
- data/test/test_http.rb +248 -34
- data/test/test_protocol.rb +143 -24
- data/test/test_xml.rb +145 -53
- metadata +116 -62
- data/bin/atom-client.rb +0 -246
- data/bin/atom-server.rb~ +0 -71
- data/doc/classes/Atom/App.html +0 -217
- data/doc/classes/Atom/Entry.html +0 -365
- data/doc/classes/Atom/Feed.html +0 -585
- data/lib/atom/app.rb +0 -87
- data/lib/atom/xml.rb +0 -200
- data/lib/atom/yaml.rb +0 -101
data/lib/atom/http.rb
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
require "net/http"
|
|
2
|
-
require
|
|
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
|
-
|
|
14
|
-
|
|
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::
|
|
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
|
-
|
|
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
|
-
#
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
261
|
+
def authsub_authenticate req, url, param_string = ""
|
|
262
|
+
req["Authorization"] = %{AuthSub token="#{@token}"}
|
|
79
263
|
end
|
|
80
264
|
|
|
81
|
-
#
|
|
82
|
-
def
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
data/lib/atom/service.rb
ADDED
|
@@ -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
|