ape 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (47) hide show
  1. data/CHANGELOG +1 -0
  2. data/LICENSE +19 -0
  3. data/Manifest +45 -0
  4. data/README +12 -0
  5. data/ape.gemspec +57 -0
  6. data/bin/ape_server +28 -0
  7. data/lib/ape.rb +982 -0
  8. data/lib/ape/atomURI.rb +73 -0
  9. data/lib/ape/auth/google_login_credentials.rb +96 -0
  10. data/lib/ape/auth/wsse_credentials.rb +25 -0
  11. data/lib/ape/authent.rb +42 -0
  12. data/lib/ape/categories.rb +95 -0
  13. data/lib/ape/collection.rb +51 -0
  14. data/lib/ape/crumbs.rb +39 -0
  15. data/lib/ape/entry.rb +151 -0
  16. data/lib/ape/escaper.rb +29 -0
  17. data/lib/ape/feed.rb +117 -0
  18. data/lib/ape/handler.rb +34 -0
  19. data/lib/ape/html.rb +17 -0
  20. data/lib/ape/invoker.rb +54 -0
  21. data/lib/ape/invokers/deleter.rb +31 -0
  22. data/lib/ape/invokers/getter.rb +80 -0
  23. data/lib/ape/invokers/poster.rb +57 -0
  24. data/lib/ape/invokers/putter.rb +46 -0
  25. data/lib/ape/layout/ape.css +56 -0
  26. data/lib/ape/layout/ape_logo.png +0 -0
  27. data/lib/ape/layout/index.html +54 -0
  28. data/lib/ape/layout/info.png +0 -0
  29. data/lib/ape/names.rb +24 -0
  30. data/lib/ape/print_writer.rb +21 -0
  31. data/lib/ape/samples.rb +180 -0
  32. data/lib/ape/samples/atom_schema.txt +338 -0
  33. data/lib/ape/samples/basic_entry.eruby +16 -0
  34. data/lib/ape/samples/categories_schema.txt +69 -0
  35. data/lib/ape/samples/mini_entry.eruby +8 -0
  36. data/lib/ape/samples/service_schema.txt +187 -0
  37. data/lib/ape/samples/unclean_xhtml_entry.eruby +21 -0
  38. data/lib/ape/server.rb +32 -0
  39. data/lib/ape/service.rb +12 -0
  40. data/lib/ape/validator.rb +65 -0
  41. data/lib/ape/version.rb +9 -0
  42. data/scripts/go.rb +29 -0
  43. data/test/test_helper.rb +17 -0
  44. data/test/unit/authent_test.rb +35 -0
  45. data/test/unit/invoker_test.rb +25 -0
  46. data/test/unit/samples_test.rb +36 -0
  47. metadata +111 -0
@@ -0,0 +1,73 @@
1
+ # Copyright © 2006 Sun Microsystems, Inc. All rights reserved
2
+ # Use is subject to license terms - see file "LICENSE"
3
+ require 'uri'
4
+
5
+ module Ape
6
+ class AtomURI
7
+
8
+ def initialize base_uri
9
+ if base_uri.kind_of? URI
10
+ @base = base_uri
11
+ else
12
+ @base = URI.parse base_uri
13
+ end
14
+ end
15
+
16
+ # Given a URI pulled out of the middle of an XML doc ('context' provides
17
+ # containing element) absolutize it if it's relative, with proper regard
18
+ # for xml:base
19
+ #
20
+ def absolutize uri_s, context
21
+ begin
22
+ uri = URI.parse uri_s
23
+ return uri_s if uri.absolute?
24
+
25
+ path_base = @base
26
+ path_to(context).each do |node|
27
+ if (xb = node.attributes['xml:base'])
28
+ xb = URI.parse xb
29
+ if xb.absolute? then path_base = xb else path_base.merge! xb end
30
+ end
31
+ end
32
+
33
+ return path_base.merge(uri).to_s
34
+ rescue URI::InvalidURIError
35
+ return nil
36
+ end
37
+ end
38
+
39
+ def path_to node
40
+ if node.class == REXML::Element
41
+ path_to(node.parent) << node
42
+ else
43
+ [ ]
44
+ end
45
+ end
46
+
47
+ def AtomURI.check(uri_string)
48
+ if uri_string.kind_of? URI
49
+ uri = uri_string
50
+ else
51
+ begin
52
+ uri = URI.parse(uri_string)
53
+ rescue URI::InvalidURIError
54
+ return "Invalid URI: #{$!}"
55
+ end
56
+ end
57
+
58
+ unless uri.scheme =~ /^https?$/
59
+ return "URI scheme must be 'http' or 'https', not '#{uri.scheme}'"
60
+ else
61
+ return uri
62
+ end
63
+ end
64
+
65
+ def AtomURI.on_the_wire uri
66
+ if uri.query
67
+ "#{uri.path}?#{uri.query}"
68
+ else
69
+ uri.path
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,96 @@
1
+ require 'net/http'
2
+ require 'net/https'
3
+ require 'cgi'
4
+
5
+ module Ape
6
+ class GoogleLoginAuthError < StandardError ; end
7
+ class GoogleLoginAuthUnknownError < GoogleLoginAuthError ; end
8
+
9
+ class GoogleLoginCredentials
10
+
11
+ GOOGLE_ERROR_MESSAGES = {
12
+ "BadAuthentication" => "The login request used a username or password that is not recognized.",
13
+ "NotVerified" => "The account email address has not been verified. The user will need to access their Google account directly to resolve the issue before logging in using a non-Google application.",
14
+ "TermsNotAgreed" => "The user has not agreed to terms. The user will need to access their Google account directly to resolve the issue before logging in using a non-Google application.",
15
+ "CaptchaRequired" => "Please visit https://www.google.com/accounts/DisplayUnlockCaptcha to enable access.",
16
+ "Unknown" => "The error is unknown or unspecified; the request contained invalid input or was malformed.",
17
+ "AccountDeleted" => "The user account has been deleted.",
18
+ "AccountDisabled" => "The user account has been disabled.",
19
+ "ServiceDisabled" => "The user's access to the specified service has been disabled. (The user account may still be valid.)",
20
+ "ServiceUnavailable" => "The service is not available; try again later.",
21
+ } unless defined?(GOOGLE_ERROR_MESSAGES)
22
+
23
+ def initialize
24
+ @credentials = nil
25
+ end
26
+
27
+ def add_credentials(req, auth, user, password)
28
+ unless @credentials
29
+ challenge = parse_www_authenticate(auth)
30
+ @credentials = googlelogin(username, password, 'ruby-ape-1.0', challenge)
31
+ end
32
+ req['Authorization'] = "GoogleLogin auth=#{@credentials}"
33
+ end
34
+
35
+ private
36
+
37
+ def parse_www_authenticate(authenticate)
38
+ # Returns a dictionary of dictionaries, one dict
39
+ # per auth-scheme. The dictionary for each auth-scheme
40
+ # contains all the auth-params.
41
+ retval = {}
42
+ authenticate.chomp()
43
+ # Break off the scheme at the beginning of the line
44
+ auth_scheme, the_rest = authenticate.split(/ /, 2)
45
+ # Now loop over all the key value pairs that come after the scheme
46
+ keyvalues = the_rest.split(/[ ,]/)
47
+ auth_params = {}
48
+ keyvalues.each do |keyvalue|
49
+ if keyvalue.include?("=")
50
+ key, value = keyvalue.split(/=/, 2)
51
+ if value.scan(/^\"/).size > 0
52
+ value = value.strip()[1..-2]
53
+ end
54
+ auth_params[key.downcase()] = value.gsub(/\\(.)/, "\\1")
55
+ elsif keyvalue.size > 0
56
+ retval[auth_scheme.downcase()] = auth_params
57
+ auth_scheme = keyvalue
58
+ auth_params = {}
59
+ end
60
+ end
61
+ retval[auth_scheme.downcase()] = auth_params
62
+ return retval
63
+ end
64
+
65
+ def googlelogin(name, password, useragent, challenge)
66
+ service = challenge['googlelogin']['service']
67
+
68
+ h = Net::HTTP.new('www.google.com', 443)
69
+ h.use_ssl = true
70
+ params = {'Email'=>name, 'Passwd'=>password, 'service'=>service, 'source'=>useragent}
71
+ data = params.map {|k,v| "#{CGI::escape(k.to_s)}=#{CGI::escape(v.to_s)}" }.join('&')
72
+ res = h.request_post('/accounts/ClientLogin', data, {'Content-Type' => 'application/x-www-form-urlencoded'})
73
+ d = {}
74
+ res.body.split(/\n/).each do |keyvalue|
75
+ key, value = keyvalue.split(/=/)
76
+ d[key] = value
77
+ end
78
+ auth = ""
79
+ if res == Net::HTTPForbidden
80
+ if d.has_key?('Error')
81
+ errorname = d['Error']
82
+ if GOOGLE_ERROR_MESSAGES.has_key?(errorname)
83
+ raise GoogleLoginAuthError, GOOGLE_ERROR_MESSAGES[errorname]
84
+ else
85
+ raise GoogleLoginAuthUnknownError, errorname
86
+ end
87
+ else
88
+ raise res.error!
89
+ end
90
+ else
91
+ auth = d['Auth']
92
+ end
93
+ return auth
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,25 @@
1
+ require 'base64'
2
+ require 'digest/sha1'
3
+
4
+ module Ape
5
+ class WsseCredentials
6
+ def initialize
7
+ @credentials = nil
8
+ end
9
+
10
+ def add_credentials(req, auth, user, password)
11
+ wsse_auth(user, password) unless @credentials
12
+ req['X-WSSE'] = @credentials
13
+ req['Authorization'] = auth
14
+ end
15
+
16
+ def wsse_auth(user, password)
17
+ nonce = Array.new(10){ rand(0x1000000) }.pack('I*')
18
+ nonce_b64 = [nonce].pack("m").chomp
19
+ now = Time.now.gmtime.strftime("%FT%TZ")
20
+ digest = [Digest::SHA1.digest(nonce_b64 + now + password)].pack("m").chomp
21
+
22
+ @credentials = %Q<UsernameToken Username="#{user}", PasswordDigest="#{digest}", Nonce="#{nonce_b64}", Created="#{now}">
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,42 @@
1
+ # Copyright © 2006 Sun Microsystems, Inc. All rights reserved
2
+ # Use is subject to license terms - see file "LICENSE"
3
+
4
+ module Ape
5
+ class AuthenticationError < StandardError ; end
6
+
7
+ class Authent
8
+ def initialize(username, password, scheme=nil)
9
+ @username = username
10
+ @password = password
11
+ @auth_plugin = nil
12
+ end
13
+
14
+ def add_to(req, authentication = nil)
15
+ return unless @username && @password
16
+ if (authentication)
17
+ if authentication.strip.downcase.include? 'basic'
18
+ req.basic_auth @username, @password
19
+ else
20
+ @auth_plugin = resolve_plugin(authentication) unless @auth_plugin
21
+ @auth_plugin.add_credentials(req, authentication, @username, @password)
22
+ end
23
+ else
24
+ req.basic_auth @username, @password
25
+ end
26
+ end
27
+
28
+ def resolve_plugin(authentication)
29
+ Dir.glob(File.join(File.dirname(__FILE__), 'auth/*.rb')).each do |file|
30
+ plugin_name = file.gsub(/(.+\/auth\/)(.+)(_credentials.rb)/, '\2').gsub(/_/, '')
31
+ plugin_class = file.gsub(/(.+\/auth\/)(.+)(.rb)/, '\2').gsub(/(^|_)(.)/) { $2.upcase }
32
+
33
+ if (authentication.strip.downcase.include?(plugin_name))
34
+ return eval("#{plugin_class}.new", binding, __FILE__, __LINE__)
35
+ end
36
+ end
37
+ raise AuthenticationError, "Unknown authentication method: #{authentication}"
38
+ end
39
+ end
40
+ end
41
+
42
+ Dir[File.dirname(__FILE__) + '/auth/*.rb'].each { |l| require l }
@@ -0,0 +1,95 @@
1
+ # Copyright © 2006 Sun Microsystems, Inc. All rights reserved
2
+ # Use is subject to license terms - see file "LICENSE"
3
+
4
+ require 'rexml/document'
5
+ require 'rexml/xpath'
6
+
7
+ module Ape
8
+ class Categories
9
+ attr_reader :fixed
10
+
11
+ def Categories.from_collection(collection, authent, ape=nil)
12
+
13
+ # "catses" because if cats is short for categories, then catses
14
+ # suggests multiple <app:categories> elements
15
+ catses = collection.catses
16
+
17
+ catses.collect! do |cats|
18
+ if cats.attribute(:href)
19
+ getter = Getter.new(cats.attribute(:href).value, authent)
20
+ if getter.last_error # wonky URI
21
+ ape.error getter.last_error if ape
22
+ nil
23
+ end
24
+
25
+ if !getter.get('application/atomcat+xml')
26
+ ape.error "Can't fetch categories doc " +
27
+ "'#{cats.attribute(:href).value}': getter.last_error" if ape
28
+ nil
29
+ end
30
+
31
+ ape.warning(getter.last_error) if ape && getter.last_error
32
+ REXML::Document.new(getter.body).root
33
+ else
34
+ # no href attribute
35
+ cats
36
+ end
37
+ end
38
+ catses.compact
39
+ end
40
+
41
+ end
42
+
43
+ # Decorate an entry which is about to be posted to a collection with some
44
+ # categories. For each fixed categories element, pick one of the categories
45
+ # and add that. If there are no categories elements at all, or if there's
46
+ # at least one with fixed="no", also add a syntho-cat that we make up.
47
+ # Return the list of categories that we added.
48
+ #
49
+ def Categories.add_cats(entry, collection, authent, ape=nil)
50
+
51
+ added = []
52
+ c = from_collection(collection, authent)
53
+ if c.empty?
54
+ add_syntho = true
55
+ else
56
+ add_syntho = false
57
+
58
+ # for each <app:categories>
59
+ c.each do |cats|
60
+
61
+ default_scheme = cats.attributes['scheme']
62
+
63
+ # if it's fixed, pick the first one
64
+ if cats.attributes['fixed'] == "yes"
65
+ cat_list = REXML::XPath.match(cats, './atom:category', Names::XmlNamespaces)
66
+ if cat_list
67
+
68
+ # for each <app:category> take the first one whose attribute "term" is not empty
69
+ cat_list.each do |cat|
70
+ if cat.attributes['term'].empty?
71
+ ape.warning 'A mangled category is present in your categories list' if ape
72
+ else
73
+ scheme = cat.attributes['scheme']
74
+ if !scheme
75
+ scheme = default_scheme
76
+ end
77
+ added << entry.add_category(cat.attributes['term'], scheme)
78
+ break
79
+ end
80
+ end
81
+ end
82
+ else
83
+ add_syntho = true
84
+ end
85
+
86
+ end
87
+ end
88
+
89
+ if add_syntho
90
+ added << entry.add_category('simians', 'http://tbray.org/cat-test')
91
+ end
92
+ added
93
+ end
94
+ end
95
+
@@ -0,0 +1,51 @@
1
+ # Copyright © 2006 Sun Microsystems, Inc. All rights reserved
2
+ # Use is subject to license terms - see file "LICENSE"
3
+ require 'rexml/xpath'
4
+
5
+ module Ape
6
+ class Collection
7
+ attr_reader :title, :accept, :href
8
+
9
+ def initialize(input, doc_uri = nil)
10
+ @input = input
11
+ @accept = []
12
+ @title = REXML::XPath.first(input, './atom:title', Names::XmlNamespaces)
13
+
14
+ # sigh, RNC validation *should* take care of this
15
+ unless @title
16
+ raise(SyntaxError, "Collection is missing required 'atom:title'")
17
+ end
18
+ @title = @title.texts.join
19
+
20
+ if doc_uri
21
+ uris = AtomURI.new(doc_uri)
22
+ @href = uris.absolutize(input.attributes['href'], input)
23
+ else
24
+ @href = input.attributes['href']
25
+ end
26
+
27
+ # now we have to go looking for the accept
28
+ @accept = []
29
+ REXML::XPath.each(input, './app:accept', Names::XmlNamespaces) do |a|
30
+ @accept << a.texts.join
31
+ end
32
+
33
+ if @accept.empty?
34
+ @accept = [ Names::AtomEntryMediaType ]
35
+ end
36
+ end
37
+
38
+ def to_s
39
+ @input.to_s
40
+ end
41
+
42
+ def to_str
43
+ to_s
44
+ end
45
+
46
+ # the name is supposed to suggest multiple instances of "categories"
47
+ def catses
48
+ REXML::XPath.match(@input, './app:categories', Names::XmlNamespaces)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,39 @@
1
+ # Copyright © 2006 Sun Microsystems, Inc. All rights reserved
2
+ # Use is subject to license terms - see file "LICENSE"
3
+
4
+ # All the methods (getter/poster/putter/deleter) use the set_debug_output method
5
+ # of Net::HTTP to capture the dialogue. This class is what gets passed to
6
+ # set_debug_output; it exists to define << to save only the interesting bits
7
+ # of the dialog.
8
+ #
9
+ module Ape
10
+ class Crumbs
11
+ def initialize
12
+ @crumbs = []
13
+ @keep_next = false
14
+ end
15
+
16
+ def grep(pattern)
17
+ @crumbs.grep(pattern)
18
+ end
19
+
20
+ def << data
21
+ if @keep_next
22
+ @crumbs << "> #{data}"
23
+ @keep_next = false
24
+ elsif data =~ /^->/
25
+ @crumbs << "< #{data.gsub(/^.../, '')}"
26
+ elsif data =~ /^<-/
27
+ @keep_next = true
28
+ end
29
+ end
30
+
31
+ def each
32
+ @crumbs.each { |c| yield c }
33
+ end
34
+
35
+ def to_s
36
+ " " + @crumbs.join("...\n")
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,151 @@
1
+ # Copyright © 2006 Sun Microsystems, Inc. All rights reserved
2
+ # Use is subject to license terms - see file "LICENSE"
3
+
4
+ require 'rexml/document'
5
+ require 'rexml/xpath'
6
+ require 'cgi'
7
+
8
+ # represents an Atom Entry
9
+ module Ape
10
+ class Entry
11
+ # @element is the REXML dom
12
+ # @base is the base URI if known
13
+
14
+ def initialize(node, uri = nil)
15
+ if node.class == String
16
+ @element = REXML::Document.new(node, { :raw => nil }).root
17
+ else
18
+ @element = node
19
+ end
20
+ if uri
21
+ @base = AtomURI.new(uri)
22
+ else
23
+ @base = nil
24
+ end
25
+ end
26
+
27
+ def to_s
28
+ "<?xml version='1.0' ?>\n" + @element.to_s
29
+ end
30
+
31
+ def content_src
32
+ content = REXML::XPath.first(@element, './atom:content', Names::XmlNamespaces)
33
+ if content
34
+ cs = content.attributes['src']
35
+ cs = @base.absolutize(cs, @element) if @base
36
+ else
37
+ nil
38
+ end
39
+ end
40
+
41
+ def get_child(field, namespace = nil)
42
+ if (namespace)
43
+ thisNS = {}
44
+ prefix = 'NN'
45
+ thisNS[prefix] = namespace
46
+ else
47
+ prefix = 'atom'
48
+ thisNS = Names::XmlNamespaces
49
+ end
50
+ xpath = "./#{prefix}:#{field}"
51
+ return REXML::XPath.first(@element, xpath, thisNS)
52
+ end
53
+
54
+ def add_category(term, scheme = nil, label = nil)
55
+ c = REXML::Element.new('atom:category', @element)
56
+ c.add_namespace('atom', Names::AtomNamespace)
57
+ c.add_attribute('term', term)
58
+ c.add_attribute('scheme', scheme) if scheme
59
+ c.add_attribute('label', label) if label
60
+ c
61
+ end
62
+
63
+ def has_cat(cat)
64
+ xpath = "./atom:category[@term=\"#{cat.attributes['term']}\""
65
+ if cat.attributes['scheme']
66
+ xpath += "and @scheme=\"#{cat.attributes['scheme']}\""
67
+ end
68
+ xpath += "]"
69
+ REXML::XPath.first(@element, xpath, Names::XmlNamespaces)
70
+ end
71
+
72
+ def delete_category(c)
73
+ @element.delete_element c
74
+ end
75
+
76
+ def child_type(field)
77
+ n = get_child(field, nil)
78
+ return nil unless n
79
+ return n.attributes['type'] || "text"
80
+ end
81
+
82
+ def child_content(field, namespace = nil)
83
+ n = get_child(field, namespace)
84
+ return nil unless n
85
+
86
+ # if it's type="xhtml", we'll get the content out of the contained
87
+ # XHTML <div> rather than this element
88
+ if n.attributes['type'] == 'xhtml'
89
+ n = REXML::XPath.first(n, "./xhtml:div", Names::XmlNamespaces)
90
+ unless n
91
+ return "Error: required xhtml:div child of #{field} is missing"
92
+ end
93
+ end
94
+
95
+ text_from n
96
+ end
97
+
98
+ def text_from node
99
+ text = ''
100
+ is_html = node.name =~ /(rights|subtitle|summary|title|content)$/ && node.attributes['type'] == 'html'
101
+ node.find_all do | child |
102
+ if child.kind_of? REXML::Text
103
+ v = child.value
104
+ v = CGI.unescapeHTML(v).gsub(/&apos;/, "'") if is_html
105
+ text << v
106
+ elsif child.kind_of? REXML::Element
107
+ text << text_from(child)
108
+ end
109
+ end
110
+ text
111
+ end
112
+
113
+ def link(rel, ape=nil)
114
+ l = nil
115
+ a = REXML::XPath.first(@element, "./atom:link[@rel=\"#{rel}\"]", Names::XmlNamespaces)
116
+ if a
117
+ l = a.attributes['href']
118
+ l = @base.absolutize(l, @element) if @base
119
+ end
120
+ l
121
+ end
122
+
123
+ def alt_links
124
+ REXML::XPath.match(@element, "./atom:link", Names::XmlNamespaces).select do |l|
125
+ l.attributes['rel'] == nil || l.attributes['rel'] == 'alternate'
126
+ end
127
+ end
128
+
129
+ def summarize
130
+ child_content('title')
131
+ end
132
+
133
+ # utility routine
134
+ def xpath_match(xp)
135
+ REXML::XPath.match(@element, xp, Names::XmlNamespaces)
136
+ end
137
+
138
+ # debugging
139
+ def Entry.dump(node, depth=0)
140
+ prefix = '.' * depth
141
+ name = node.getNodeName
142
+ uri = node.getNamespaceURI
143
+ if uri
144
+ puts "#{prefix} #{uri}:#{node.getNodeName}"
145
+ else
146
+ puts "#{prefix} #{node.getNodeName}"
147
+ end
148
+ Nodes.each_node(node.getChildNodes) {|child| dump(child, depth+1)}
149
+ end
150
+ end
151
+ end