atom-tools 0.9.0 → 0.9.1

Sign up to get free protection for your applications and to get access to all the features.
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