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.
Files changed (49) hide show
  1. data/README +3 -3
  2. data/Rakefile +1 -1
  3. data/bin/atom-client.rb +13 -10
  4. data/lib/atom/collection.rb +2 -2
  5. data/lib/atom/element.rb +5 -1
  6. data/lib/atom/entry.rb +9 -2
  7. data/lib/atom/feed.rb +11 -6
  8. data/lib/atom/http.rb +157 -38
  9. data/lib/atom/service.rb +170 -0
  10. data/lib/atom/text.rb +15 -2
  11. data/lib/atom/xml.rb +1 -1
  12. data/test/conformance/updated.rb +2 -1
  13. data/test/test_constructs.rb +45 -2
  14. data/test/test_feed.rb +27 -0
  15. data/test/test_http.rb +116 -20
  16. data/test/test_protocol.rb +77 -13
  17. data/test/test_xml.rb +15 -1
  18. metadata +3 -38
  19. data/bin/atom-server.rb~ +0 -71
  20. data/doc/classes/Atom/App.html +0 -217
  21. data/doc/classes/Atom/Author.html +0 -130
  22. data/doc/classes/Atom/Category.html +0 -128
  23. data/doc/classes/Atom/Collection.html +0 -322
  24. data/doc/classes/Atom/Content.html +0 -129
  25. data/doc/classes/Atom/Contributor.html +0 -119
  26. data/doc/classes/Atom/Element.html +0 -325
  27. data/doc/classes/Atom/Entry.html +0 -365
  28. data/doc/classes/Atom/Feed.html +0 -585
  29. data/doc/classes/Atom/HTTP.html +0 -374
  30. data/doc/classes/Atom/Link.html +0 -137
  31. data/doc/classes/Atom/Text.html +0 -229
  32. data/doc/classes/XHTML.html +0 -118
  33. data/doc/created.rid +0 -1
  34. data/doc/files/README.html +0 -213
  35. data/doc/files/lib/atom/app_rb.html +0 -110
  36. data/doc/files/lib/atom/collection_rb.html +0 -110
  37. data/doc/files/lib/atom/element_rb.html +0 -109
  38. data/doc/files/lib/atom/entry_rb.html +0 -111
  39. data/doc/files/lib/atom/feed_rb.html +0 -112
  40. data/doc/files/lib/atom/http_rb.html +0 -109
  41. data/doc/files/lib/atom/text_rb.html +0 -108
  42. data/doc/files/lib/atom/xml_rb.html +0 -110
  43. data/doc/files/lib/atom/yaml_rb.html +0 -109
  44. data/doc/fr_class_index.html +0 -39
  45. data/doc/fr_file_index.html +0 -36
  46. data/doc/fr_method_index.html +0 -62
  47. data/doc/index.html +0 -24
  48. data/doc/rdoc-style.css +0 -208
  49. 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/app"
40
+ require "atom/service"
41
41
 
42
- app = Atom::App.new "http://necronomicorp.com/app.xml"
43
- coll = app.collections.first
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
@@ -6,7 +6,7 @@ require "rake/gempackagetask"
6
6
  require "rake/clean"
7
7
 
8
8
  NAME = "atom-tools"
9
- VERS = "0.9.0"
9
+ VERS = "0.9.1"
10
10
 
11
11
  # the following from markaby-0.5's tools/rakehelp
12
12
  def setup_tests
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/app"
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
- update!
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
- # the id doesn't appear in YAML, it should remain the same
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
- # still lame
104
- server.collections.each_with_index do |coll, index|
105
- collections << coll
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
- puts "#{index}: #{coll.title}"
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::App.new(introspection_url, http)
187
+ server = Atom::Service.new(introspection_url, http)
185
188
 
186
189
  coll = choose_collection server
187
190
 
@@ -5,7 +5,7 @@ require "atom/feed"
5
5
  require "webrick/httputils"
6
6
 
7
7
  module Atom
8
- # represents an Atom Publishing Protocol Collection
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 a slug
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 fetched from +base+ into an Atom::Entry
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
- unless res.content_type.match(/^application\/atom\+xml/)
177
- raise Atom::HTTPException, "Unexpected HTTP response Content-Type: #{res.content_type} (wanted application/atom+xml)"
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
- update_time = Time.parse(REXML::XPath.first(coll, "/atom:feed/atom:updated", { "atom" => Atom::NS } ).text)
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 bother
190
- if self.updated and self.updated >= update_time
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.0"
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::App,
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
- # it will be called with the base URL of the requested URL, and the realm used in the WWW-Authenticate header.
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
- def parse_wwwauth www_authenticate
71
- auth_type = www_authenticate.split[0] # "Digest" or "Basic"
72
- auth_params = {}
73
-
74
- www_authenticate =~ /^(\w+) (.*)/
162
+ # parses plain quoted-strings
163
+ def parse_quoted_wwwauth param_string
164
+ params = {}
75
165
 
76
- $2.gsub(/(\w+)="(.*?)"/) { auth_params[$1] = $2 }
166
+ param_string.gsub(/(\w+)="(.*?)"/) { params[$1.to_sym] = $2 }
77
167
 
78
- [ auth_type, auth_params ]
168
+ params
79
169
  end
80
170
 
81
- # performs an authenticated http request
82
- def authenticated_request(url_string, method, wwwauth, body = nil, init_headers = {})
171
+ # HTTP Basic authentication (RFC 2617)
172
+ def basic_authenticate(req, url, param_string = "")
173
+ params = parse_quoted_wwwauth(param_string)
83
174
 
84
- auth_type, params = parse_wwwauth(wwwauth)
85
- req, url = new_request(url_string, method, init_headers)
86
-
87
- realm = params["realm"]
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
- raise Unauthorized unless user and pass
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
- 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
203
+
204
+ [ user, pass ]
105
205
  end
106
206
 
107
- # performs a regular http request. if it responds 401
108
- # then it retries using @user and @pass for authentication
109
- def http_request(url_string, method, body = nil, init_headers = {})
110
- req, url = new_request(url_string, method, init_headers)
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
- res = authenticated_request(url, method, res["WWW-Authenticate"], body, init_headers)
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