ape 1.0.0

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 (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