atom-tools 0.9.0 → 0.9.1
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/README +3 -3
- data/Rakefile +1 -1
- data/bin/atom-client.rb +13 -10
- data/lib/atom/collection.rb +2 -2
- data/lib/atom/element.rb +5 -1
- data/lib/atom/entry.rb +9 -2
- data/lib/atom/feed.rb +11 -6
- data/lib/atom/http.rb +157 -38
- data/lib/atom/service.rb +170 -0
- data/lib/atom/text.rb +15 -2
- data/lib/atom/xml.rb +1 -1
- data/test/conformance/updated.rb +2 -1
- data/test/test_constructs.rb +45 -2
- data/test/test_feed.rb +27 -0
- data/test/test_http.rb +116 -20
- data/test/test_protocol.rb +77 -13
- data/test/test_xml.rb +15 -1
- metadata +3 -38
- data/bin/atom-server.rb~ +0 -71
- data/doc/classes/Atom/App.html +0 -217
- data/doc/classes/Atom/Author.html +0 -130
- data/doc/classes/Atom/Category.html +0 -128
- data/doc/classes/Atom/Collection.html +0 -322
- data/doc/classes/Atom/Content.html +0 -129
- data/doc/classes/Atom/Contributor.html +0 -119
- data/doc/classes/Atom/Element.html +0 -325
- data/doc/classes/Atom/Entry.html +0 -365
- data/doc/classes/Atom/Feed.html +0 -585
- data/doc/classes/Atom/HTTP.html +0 -374
- data/doc/classes/Atom/Link.html +0 -137
- data/doc/classes/Atom/Text.html +0 -229
- data/doc/classes/XHTML.html +0 -118
- data/doc/created.rid +0 -1
- data/doc/files/README.html +0 -213
- data/doc/files/lib/atom/app_rb.html +0 -110
- data/doc/files/lib/atom/collection_rb.html +0 -110
- data/doc/files/lib/atom/element_rb.html +0 -109
- data/doc/files/lib/atom/entry_rb.html +0 -111
- data/doc/files/lib/atom/feed_rb.html +0 -112
- data/doc/files/lib/atom/http_rb.html +0 -109
- data/doc/files/lib/atom/text_rb.html +0 -108
- data/doc/files/lib/atom/xml_rb.html +0 -110
- data/doc/files/lib/atom/yaml_rb.html +0 -109
- data/doc/fr_class_index.html +0 -39
- data/doc/fr_file_index.html +0 -36
- data/doc/fr_method_index.html +0 -62
- data/doc/index.html +0 -24
- data/doc/rdoc-style.css +0 -208
- data/lib/atom/app.rb +0 -87
data/README
CHANGED
@@ -37,10 +37,10 @@ Things are explained in more detail in the RDoc.
|
|
37
37
|
|
38
38
|
== The Atom Publishing Protocol
|
39
39
|
|
40
|
-
require "atom/
|
40
|
+
require "atom/service"
|
41
41
|
|
42
|
-
|
43
|
-
coll =
|
42
|
+
service = Atom::Service.new "http://necronomicorp.com/app.xml"
|
43
|
+
coll = service.workspaces.first.collections.first
|
44
44
|
# => <http://necronomicorp.com/testatom?app entries: 0 title='testing: entry endpoint'>
|
45
45
|
|
46
46
|
coll.update!
|
data/Rakefile
CHANGED
data/bin/atom-client.rb
CHANGED
@@ -1,12 +1,12 @@
|
|
1
1
|
#!/usr/bin/ruby
|
2
2
|
|
3
3
|
# syntax: ./atom-client.rb <introspection-url> [username] [password]
|
4
|
-
# a
|
4
|
+
# a really simple YAML-and-$EDITOR based Publishing Protocol client
|
5
5
|
|
6
6
|
require "tempfile"
|
7
7
|
|
8
8
|
require "atom/yaml"
|
9
|
-
require "atom/
|
9
|
+
require "atom/service"
|
10
10
|
require "atom/http"
|
11
11
|
|
12
12
|
require "rubygems"
|
@@ -44,7 +44,7 @@ class Atom::Entry
|
|
44
44
|
def prepare_for_output
|
45
45
|
filter_hook
|
46
46
|
|
47
|
-
|
47
|
+
updated!
|
48
48
|
end
|
49
49
|
|
50
50
|
def filter_hook
|
@@ -58,7 +58,7 @@ class Atom::Entry
|
|
58
58
|
def edit
|
59
59
|
yaml = YAML.load(self.to_yaml)
|
60
60
|
|
61
|
-
# humans don't care about these things
|
61
|
+
# humans don't care about these things, we can replace it later
|
62
62
|
yaml.delete "id"
|
63
63
|
|
64
64
|
if yaml["links"]
|
@@ -68,7 +68,7 @@ class Atom::Entry
|
|
68
68
|
end
|
69
69
|
|
70
70
|
entry = write_entry(yaml.to_yaml)
|
71
|
-
|
71
|
+
|
72
72
|
entry.id = self.id
|
73
73
|
|
74
74
|
entry
|
@@ -100,11 +100,14 @@ def choose_collection server
|
|
100
100
|
|
101
101
|
collections = []
|
102
102
|
|
103
|
-
#
|
104
|
-
server.
|
105
|
-
|
103
|
+
# flatten it out into one big workspace
|
104
|
+
server.workspaces.each do |ws|
|
105
|
+
puts ws.title.to_s + ":"
|
106
|
+
ws.collections.each_with_index do |coll, index|
|
107
|
+
collections << coll
|
106
108
|
|
107
|
-
|
109
|
+
puts "#{index}: #{coll.title}"
|
110
|
+
end
|
108
111
|
end
|
109
112
|
|
110
113
|
choose_from collections
|
@@ -181,7 +184,7 @@ http = Atom::HTTP.new
|
|
181
184
|
http.user = ARGV[1]
|
182
185
|
http.pass = ARGV[2]
|
183
186
|
|
184
|
-
server = Atom::
|
187
|
+
server = Atom::Service.new(introspection_url, http)
|
185
188
|
|
186
189
|
coll = choose_collection server
|
187
190
|
|
data/lib/atom/collection.rb
CHANGED
@@ -5,7 +5,7 @@ require "atom/feed"
|
|
5
5
|
require "webrick/httputils"
|
6
6
|
|
7
7
|
module Atom
|
8
|
-
#
|
8
|
+
# a Collection is an Atom::Feed with extra Protocol-specific methods
|
9
9
|
class Collection < Feed
|
10
10
|
# comma separated string that contains a list of media types
|
11
11
|
# accepted by a collection.
|
@@ -17,7 +17,7 @@ module Atom
|
|
17
17
|
super uri, http
|
18
18
|
end
|
19
19
|
|
20
|
-
# POST an entry to the collection, with
|
20
|
+
# POST an entry to the collection, with an optional slug
|
21
21
|
def post!(entry, slug = nil)
|
22
22
|
raise "Cowardly refusing to POST a non-Atom::Entry" unless entry.is_a? Atom::Entry
|
23
23
|
headers = {"Content-Type" => "application/atom+xml" }
|
data/lib/atom/element.rb
CHANGED
@@ -49,7 +49,7 @@ module Atom # :nodoc:
|
|
49
49
|
|
50
50
|
# this element's xml:base
|
51
51
|
attr_accessor :base
|
52
|
-
|
52
|
+
|
53
53
|
# The following is a DSL for describing an atom element.
|
54
54
|
|
55
55
|
# this element's attributes
|
@@ -193,6 +193,10 @@ module Atom # :nodoc:
|
|
193
193
|
to_xml.to_s
|
194
194
|
end
|
195
195
|
|
196
|
+
def base= uri # :nodoc:
|
197
|
+
@base = uri.to_s
|
198
|
+
end
|
199
|
+
|
196
200
|
private
|
197
201
|
|
198
202
|
# like +valid_key?+ but raises on failure
|
data/lib/atom/entry.rb
CHANGED
@@ -55,10 +55,15 @@ module Atom
|
|
55
55
|
yield self if block_given?
|
56
56
|
end
|
57
57
|
|
58
|
-
# parses XML
|
58
|
+
# parses XML into an Atom::Entry
|
59
|
+
#
|
60
|
+
# +base+ is the absolute URI the document was fetched from
|
61
|
+
# (if there is one)
|
59
62
|
def self.parse xml, base = ""
|
60
63
|
if xml.respond_to? :to_atom_entry
|
61
64
|
xml.to_atom_entry(base)
|
65
|
+
elsif xml.respond_to? :read
|
66
|
+
self.parse(xml.read)
|
62
67
|
else
|
63
68
|
REXML::Document.new(xml.to_s).to_atom_entry(base)
|
64
69
|
end
|
@@ -68,7 +73,9 @@ module Atom
|
|
68
73
|
"#<Atom::Entry id:'#{self.id}'>"
|
69
74
|
end
|
70
75
|
|
71
|
-
# declare that this entry has updated
|
76
|
+
# declare that this entry has updated.
|
77
|
+
#
|
78
|
+
# (note that this is different from Atom::Feed#update!)
|
72
79
|
def updated!
|
73
80
|
self.updated = Time.now
|
74
81
|
end
|
data/lib/atom/feed.rb
CHANGED
@@ -72,6 +72,8 @@ module Atom
|
|
72
72
|
def self.parse xml, base = ""
|
73
73
|
if xml.respond_to? :to_atom_entry
|
74
74
|
xml.to_atom_feed(base)
|
75
|
+
elsif xml.respond_to? :read
|
76
|
+
self.parse(xml.read)
|
75
77
|
else
|
76
78
|
REXML::Document.new(xml.to_s).to_atom_feed(base)
|
77
79
|
end
|
@@ -155,6 +157,8 @@ module Atom
|
|
155
157
|
|
156
158
|
# fetches this feed's URL, parses the result and #merge!s
|
157
159
|
# changes, new entries, &c.
|
160
|
+
#
|
161
|
+
# (note that this is different from Atom::Entry#updated!
|
158
162
|
def update!
|
159
163
|
raise(RuntimeError, "can't fetch without a uri.") unless @uri
|
160
164
|
|
@@ -172,9 +176,10 @@ module Atom
|
|
172
176
|
elsif res.code != "200"
|
173
177
|
raise Atom::HTTPException, "Unexpected HTTP response code: #{res.code}"
|
174
178
|
end
|
175
|
-
|
176
|
-
|
177
|
-
|
179
|
+
|
180
|
+
media_type = res.content_type.split(";").first
|
181
|
+
unless ["application/atom+xml", "application/xml", "text/xml"].member? media_type
|
182
|
+
raise Atom::HTTPException, "An atom:feed shouldn't have Content-Type: #{res.content_type}"
|
178
183
|
end
|
179
184
|
|
180
185
|
@etag = res["Etag"] if res["Etag"]
|
@@ -184,10 +189,10 @@ module Atom
|
|
184
189
|
|
185
190
|
coll = REXML::Document.new(xml)
|
186
191
|
|
187
|
-
|
192
|
+
update_el = REXML::XPath.first(coll, "/atom:feed/atom:updated", { "atom" => Atom::NS } )
|
188
193
|
|
189
|
-
# the feed hasn't been updated, don't
|
190
|
-
if self.updated and self.updated >=
|
194
|
+
# the feed hasn't been updated, don't do anything.
|
195
|
+
if update_el and self.updated and self.updated >= Time.parse(update_el.text)
|
191
196
|
return self
|
192
197
|
end
|
193
198
|
|
data/lib/atom/http.rb
CHANGED
@@ -1,6 +1,9 @@
|
|
1
1
|
require "net/http"
|
2
2
|
require 'uri'
|
3
3
|
|
4
|
+
require "sha1"
|
5
|
+
require "md5"
|
6
|
+
|
4
7
|
module URI # :nodoc: all
|
5
8
|
class Generic; def to_uri; self; end; end
|
6
9
|
end
|
@@ -10,21 +13,105 @@ class String # :nodoc:
|
|
10
13
|
end
|
11
14
|
|
12
15
|
module Atom
|
13
|
-
UA = "atom-tools 0.9.
|
16
|
+
UA = "atom-tools 0.9.1"
|
17
|
+
|
18
|
+
module DigestAuth
|
19
|
+
CNONCE = Digest::MD5.new("%x" % (Time.now.to_i + rand(65535))).hexdigest
|
20
|
+
|
21
|
+
@@nonce_count = -1
|
22
|
+
|
23
|
+
# quoted-strings plus a few special cases for Digest
|
24
|
+
def parse_wwwauth_digest param_string
|
25
|
+
params = parse_quoted_wwwauth param_string
|
26
|
+
qop = params[:qop] ? params[:qop].split(",") : nil
|
27
|
+
|
28
|
+
param_string.gsub(/stale=([^,]*)/) do
|
29
|
+
params[:stale] = ($1.downcase == "true")
|
30
|
+
end
|
31
|
+
|
32
|
+
params[:algorithm] = "MD5"
|
33
|
+
param_string.gsub(/algorithm=([^,]*)/) { params[:algorithm] = $1 }
|
34
|
+
|
35
|
+
params
|
36
|
+
end
|
37
|
+
|
38
|
+
def h(data); Digest::MD5.hexdigest(data); end
|
39
|
+
def kd(secret, data); h(secret + ":" + data); end
|
40
|
+
|
41
|
+
# HTTP Digest authentication (RFC 2617)
|
42
|
+
def digest_authenticate(req, url, param_string = "")
|
43
|
+
raise "Digest authentication requires a WWW-Authenticate header" if param_string.empty?
|
44
|
+
|
45
|
+
params = parse_wwwauth_digest(param_string)
|
46
|
+
qop = params[:qop]
|
47
|
+
|
48
|
+
user, pass = username_and_password_for_realm(url, params[:realm])
|
49
|
+
|
50
|
+
if params[:algorithm] == "MD5"
|
51
|
+
a1 = user + ":" + params[:realm] + ":" + pass
|
52
|
+
else
|
53
|
+
# XXX MD5-sess
|
54
|
+
raise "I only support MD5 digest authentication (not #{params[:algorithm].inspect})"
|
55
|
+
end
|
56
|
+
|
57
|
+
if qop.nil? or qop.member? "auth"
|
58
|
+
a2 = req.method + ":" + req.path
|
59
|
+
else
|
60
|
+
# XXX auth-int
|
61
|
+
raise "only 'auth' qop supported (none of: #{qop.inspect})"
|
62
|
+
end
|
63
|
+
|
64
|
+
if qop.nil?
|
65
|
+
response = kd(h(a1), params[:nonce] + ":" + h(a2))
|
66
|
+
else
|
67
|
+
@@nonce_count += 1
|
68
|
+
nc = ('%08x' % @@nonce_count)
|
69
|
+
|
70
|
+
# XXX auth-int
|
71
|
+
data = "#{params[:nonce]}:#{nc}:#{CNONCE}:#{"auth"}:#{h(a2)}"
|
72
|
+
|
73
|
+
response = kd(h(a1), data)
|
74
|
+
end
|
75
|
+
|
76
|
+
header = %Q<Digest username="#{user}", uri="#{req.path}", realm="#{params[:realm]}", response="#{response}", nonce="#{params[:nonce]}">
|
77
|
+
|
78
|
+
if params[:opaque]
|
79
|
+
header += %Q<, opaque="#{params[:opaque]}">
|
80
|
+
end
|
81
|
+
|
82
|
+
if params[:algorithm] != "MD5"
|
83
|
+
header += ", algorithm=#{algo}"
|
84
|
+
end
|
85
|
+
|
86
|
+
if qop
|
87
|
+
# XXX auth-int
|
88
|
+
header += %Q<, nc=#{nc}, cnonce="#{CNONCE}", qop=auth>
|
89
|
+
end
|
90
|
+
|
91
|
+
req["Authorization"] = header
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
14
95
|
class Unauthorized < RuntimeError # :nodoc:
|
15
96
|
end
|
16
97
|
|
17
98
|
# An object which handles the details of HTTP - particularly
|
18
99
|
# authentication and caching (neither of which are fully implemented).
|
19
100
|
#
|
20
|
-
# This object can be used on its own, or passed to an Atom::
|
101
|
+
# This object can be used on its own, or passed to an Atom::Service,
|
21
102
|
# Atom::Collection or Atom::Feed, where it will be used for requests.
|
22
103
|
#
|
23
104
|
# All its HTTP methods return a Net::HTTPResponse
|
24
105
|
class HTTP
|
106
|
+
include DigestAuth
|
107
|
+
|
25
108
|
# used by the default #when_auth
|
26
109
|
attr_accessor :user, :pass
|
27
110
|
|
111
|
+
# XXX doc me
|
112
|
+
# :basic, :wsse, nil
|
113
|
+
attr_accessor :always_auth
|
114
|
+
|
28
115
|
def initialize # :nodoc:
|
29
116
|
@get_auth_details = lambda do |abs_url, realm|
|
30
117
|
if @user and @pass
|
@@ -57,62 +144,94 @@ module Atom
|
|
57
144
|
|
58
145
|
# a block that will be called when a remote server responds with
|
59
146
|
# 401 Unauthorized, so that your application can prompt for
|
60
|
-
# authentication details
|
147
|
+
# authentication details.
|
61
148
|
#
|
62
|
-
#
|
149
|
+
# the default is to use the values of @user and @pass.
|
150
|
+
#
|
151
|
+
# your block will be called with two parameters
|
152
|
+
# abs_url:: the base URL of the request URL
|
153
|
+
# realm:: the realm used in the WWW-Authenticate header
|
154
|
+
# (will be nil if there is no WWW-Authenticate header)
|
63
155
|
#
|
64
156
|
# it should return a value of the form [username, password]
|
65
|
-
def when_auth &block
|
157
|
+
def when_auth &block # :yields: abs_url, realm
|
66
158
|
@get_auth_details = block
|
67
159
|
end
|
68
160
|
|
69
161
|
private
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
www_authenticate =~ /^(\w+) (.*)/
|
162
|
+
# parses plain quoted-strings
|
163
|
+
def parse_quoted_wwwauth param_string
|
164
|
+
params = {}
|
75
165
|
|
76
|
-
|
166
|
+
param_string.gsub(/(\w+)="(.*?)"/) { params[$1.to_sym] = $2 }
|
77
167
|
|
78
|
-
|
168
|
+
params
|
79
169
|
end
|
80
170
|
|
81
|
-
#
|
82
|
-
def
|
171
|
+
# HTTP Basic authentication (RFC 2617)
|
172
|
+
def basic_authenticate(req, url, param_string = "")
|
173
|
+
params = parse_quoted_wwwauth(param_string)
|
83
174
|
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
abs_url = (url + "/").to_s
|
175
|
+
user, pass = username_and_password_for_realm(url, params[:realm])
|
176
|
+
|
177
|
+
req.basic_auth user, pass
|
178
|
+
end
|
89
179
|
|
180
|
+
# WSSE authentication <http://www.xml.com/pub/a/2003/12/17/dive.html>
|
181
|
+
def wsse_authenticate(req, url, params = {})
|
182
|
+
# from <http://www.koders.com/ruby/fidFB0C7F9A0F36CB0F30B2280BDDC4F43FF1FA4589.aspx?s=ruby+cgi>.
|
183
|
+
# (thanks midore!)
|
184
|
+
user, pass = username_and_password_for_realm(url, params["realm"])
|
185
|
+
|
186
|
+
nonce = Array.new(10){ rand(0x100000000) }.pack('I*')
|
187
|
+
nonce_base64 = [nonce].pack("m").chomp
|
188
|
+
now = Time.now.utc.iso8601
|
189
|
+
digest = [Digest::SHA1.digest(nonce + now + pass)].pack("m").chomp
|
190
|
+
credentials = sprintf(%Q<UsernameToken Username="%s", PasswordDigest="%s", Nonce="%s", Created="%s">,
|
191
|
+
user, digest, nonce_base64, now)
|
192
|
+
req['X-WSSE'] = credentials
|
193
|
+
req["Authorization"] = 'WSSE profile="UsernameToken"'
|
194
|
+
end
|
195
|
+
|
196
|
+
def username_and_password_for_realm(url, realm)
|
197
|
+
abs_url = (url + "/").to_s
|
90
198
|
user, pass = @get_auth_details.call(abs_url, realm)
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
if auth_type == "Basic"
|
95
|
-
req.basic_auth user, pass
|
96
|
-
else
|
97
|
-
# TODO: implement Digest auth
|
98
|
-
raise "atom-tools only supports Basic authentication"
|
199
|
+
|
200
|
+
unless user and pass
|
201
|
+
raise Unauthorized, "You must provide a username and password"
|
99
202
|
end
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
raise Unauthorized if res.kind_of? Net::HTTPUnauthorized
|
104
|
-
res
|
203
|
+
|
204
|
+
[ user, pass ]
|
105
205
|
end
|
106
206
|
|
107
|
-
# performs a
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
207
|
+
# performs a generic HTTP request.
|
208
|
+
def http_request(url_s, method, body = nil, init_headers = {}, www_authenticate = nil)
|
209
|
+
req, url = new_request(url_s, method, init_headers)
|
210
|
+
|
211
|
+
# two reasons to authenticate;
|
212
|
+
if @always_auth
|
213
|
+
self.send("#{@always_auth}_authenticate", req, url)
|
214
|
+
elsif www_authenticate
|
215
|
+
# XXX multiple challenges, multiple headers
|
216
|
+
param_string = www_authenticate.sub!(/^(\w+) /, "")
|
217
|
+
auth_type = $~[1]
|
218
|
+
self.send("#{auth_type.downcase}_authenticate", req, url, param_string)
|
219
|
+
end
|
220
|
+
|
112
221
|
res = Net::HTTP.start(url.host, url.port) { |h| h.request(req, body) }
|
113
222
|
|
114
223
|
if res.kind_of? Net::HTTPUnauthorized
|
115
|
-
|
224
|
+
if @always_auth or www_authenticate # XXX and not stale (Digest only)
|
225
|
+
# we've tried the credentials you gave us once and failed
|
226
|
+
raise Unauthorized, "Your username and password were rejected"
|
227
|
+
else
|
228
|
+
# once more, with authentication
|
229
|
+
res = http_request(url_s, method, body, init_headers, res["WWW-Authenticate"])
|
230
|
+
|
231
|
+
if res.kind_of? Net::HTTPUnauthorized
|
232
|
+
raise Unauthorized, "Your username and password were rejected"
|
233
|
+
end
|
234
|
+
end
|
116
235
|
end
|
117
236
|
|
118
237
|
res
|