├── .gitignore ├── lib ├── ape │ ├── layout │ │ ├── info.png │ │ ├── ape_logo.png │ │ ├── ape.css │ │ └── index.html │ ├── version.rb │ ├── samples │ │ ├── mini_entry.eruby │ │ ├── basic_entry.eruby │ │ ├── unclean_xhtml_entry.eruby │ │ ├── categories_schema.txt │ │ ├── service_schema.txt │ │ └── atom_schema.txt │ ├── service.rb │ ├── html.rb │ ├── print_writer.rb │ ├── escaper.rb │ ├── auth │ │ ├── wsse_credentials.rb │ │ └── google_login_credentials.rb │ ├── invokers │ │ ├── deleter.rb │ │ ├── putter.rb │ │ ├── poster.rb │ │ └── getter.rb │ ├── names.rb │ ├── handler.rb │ ├── server.rb │ ├── collection.rb │ ├── authent.rb │ ├── crumbs.rb │ ├── invoker.rb │ ├── validator.rb │ ├── categories.rb │ ├── atomURI.rb │ ├── feed.rb │ ├── entry.rb │ └── samples.rb └── ape.rb ├── test ├── test_helper.rb └── unit │ ├── invoker_test.rb │ ├── authent_test.rb │ └── samples_test.rb ├── scripts └── go.rb ├── Rakefile ├── bin └── ape_server ├── README └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | Manifest 2 | pkg 3 | ape.gemspec 4 | doc 5 | -------------------------------------------------------------------------------- /lib/ape/layout/info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr/ape/HEAD/lib/ape/layout/info.png -------------------------------------------------------------------------------- /lib/ape/layout/ape_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr/ape/HEAD/lib/ape/layout/ape_logo.png -------------------------------------------------------------------------------- /lib/ape/version.rb: -------------------------------------------------------------------------------- 1 | module Ape 2 | module VERSION 3 | MAJOR = 1 4 | MINOR = 0 5 | TINY = 0 6 | 7 | STRING = [MAJOR, MINOR, TINY].join('.') 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/ape/samples/mini_entry.eruby: -------------------------------------------------------------------------------- 1 | 2 | 3 | Entry Mini-1 4 | EM 5 | <%= id %> 6 | <%= now %> 7 | Content of Mini-1 8 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | $:.unshift File.dirname(__FILE__) + '/../lib' 2 | require 'test/unit' 3 | require 'ape' 4 | 5 | def load_test_dir(dir) 6 | Dir[File.join(File.dirname(__FILE__), dir, "*.rb")].each do |file| 7 | require file 8 | end 9 | end 10 | 11 | module Writer 12 | def response=(response) 13 | @response = response 14 | end 15 | end 16 | 17 | Ape::Invoker.send(:include, Writer) 18 | -------------------------------------------------------------------------------- /lib/ape/service.rb: -------------------------------------------------------------------------------- 1 | # Copyright (c) 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 Service 7 | def Service.collections(service, uri) 8 | nodes = REXML::XPath.match(service, '//app:collection', Names::XmlNamespaces) 9 | nodes.collect { |n| Collection.new(n, uri) } 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/ape/html.rb: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2006 Sun Microsystems, Inc. All rights reserved 2 | # Use is subject to license terms - see file "LICENSE" 3 | 4 | module Ape 5 | class HTML 6 | def HTML.error(message, output=STDOUT) 7 | headers(output) 8 | output.puts <Error: #{message} 10 | 11 | 12 |

Error

13 |

#{message}.

14 | EndOfText 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/ape/print_writer.rb: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2006 Sun Microsystems, Inc. All rights reserved 2 | # See the included LICENSE[link:/files/LICENSE.html] file for details. 3 | module Ape 4 | # this is a wrapper for the weird derived-from-PrintWriter class that comes 5 | # out of HttpResponse.getWriter 6 | class Printwriter 7 | def initialize(java_writer) 8 | @w = java_writer 9 | end 10 | 11 | def puts(s) 12 | @w.println s 13 | end 14 | 15 | def << (s) 16 | @w.print s 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /scripts/go.rb: -------------------------------------------------------------------------------- 1 | $:.unshift File.dirname(__FILE__) + '/../lib' 2 | require 'cgi' 3 | require 'html' 4 | require 'ape' 5 | 6 | debug = ENV['APE_DEBUG'] || false 7 | 8 | cgi = debug ? CGI.new('html4') : CGI.new 9 | 10 | if !cgi['uri'] || (cgi['uri'] == '') 11 | HTML.error "URI argument is required" 12 | end 13 | 14 | uri = cgi['uri'] 15 | user = cgi['username'] 16 | pass = cgi['password'] 17 | 18 | ape = Ape::Ape.new({:crumbs => true, :output => 'html', :debug => debug}) 19 | 20 | if user == '' 21 | ape.check(uri) 22 | else 23 | ape.check(uri, user, pass) 24 | end 25 | ape.report 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /lib/ape/samples/basic_entry.eruby: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%= id %> 4 | <%= title %> 5 | The Atom Protocol Exerciser 6 | <%= now %> 7 | 8 | <%= summary %> 9 |
10 |

A test post from the <APE> at #{updated}

11 |

If you see this in an entry, it's probably a left-over from an 12 | unsuccessful Ape run; feel free to delete it.

13 |
14 |
15 | Simians 16 |
-------------------------------------------------------------------------------- /lib/ape/escaper.rb: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2006 Sun Microsystems, Inc. All rights reserved 2 | # Use is subject to license terms - see file "LICENSE" 3 | module Ape 4 | class Escaper 5 | def Escaper.escape(text) 6 | text.gsub(/([&<'">])/) do 7 | case $1 8 | when '&' then '&' 9 | when '<' then '<' 10 | when "'" then ''' 11 | when '"' then '"' 12 | when '>' then '>' 13 | end 14 | end 15 | end 16 | 17 | def Escaper.unescape(text) 18 | text.gsub(/&([^;]*);/) do 19 | case $1 20 | when 'lt' then '<' 21 | when 'amp' then '&' 22 | when 'gt' then '>' 23 | when 'apos' then "'" 24 | when 'quot' then '"' 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/ape/samples/unclean_xhtml_entry.eruby: -------------------------------------------------------------------------------- 1 | 2 | 3 | Unclean! 4 | The Atom Protocol Exerciser 5 | <%= id %> 6 | <%= now %> 7 | 8 |
9 |

hey

10 | 12 |

Hey

13 |
14 |
15 | 16 |
17 |

OK

No No No 18 | aahouch 19 |
20 |
21 |
-------------------------------------------------------------------------------- /lib/ape/auth/wsse_credentials.rb: -------------------------------------------------------------------------------- 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 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/unit/invoker_test.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/../test_helper.rb' 2 | 3 | class InvokerTest < Test::Unit::TestCase 4 | 5 | def setup 6 | @invoker = Ape::Invoker.new("http://localhost", Ape::Authent.new('david', 'mypassword')) 7 | unauthorized = Net::HTTPUnauthorized.new(401, '1.1', '') 8 | unauthorized['WWW-Authenticate'] = 'Wsse' 9 | @invoker.response = unauthorized 10 | end 11 | 12 | def test_assert_need_athentication_avoids_infinite_loops 13 | (1..5).each do |x| 14 | if x > 2 15 | assert_raise(Ape::AuthenticationError) { 16 | @invoker.need_authentication?(Net::HTTP::Get.new('/')) 17 | } 18 | else 19 | assert_nothing_raised(Ape::AuthenticationError) { 20 | @invoker.need_authentication?(Net::HTTP::Get.new('/')) 21 | } 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/lib/ape/version' 2 | 3 | begin 4 | require 'rubygems' 5 | require 'echoe' 6 | Echoe.new('ape', Ape::VERSION::STRING) do |p| 7 | p.rubyforge_name = 'ape' 8 | p.summary = 'A tool to exercice AtomPub server.' 9 | p.url = 'http://www.tbray.org/ongoing/misc/Software#p-4' 10 | p.author = 'Tim Bray' 11 | p.email = 'tim.bray@sun.com' 12 | p.dependencies << 'builder >= 2.1.2' 13 | p.extra_deps = ['mongrel >= 1.1.3', 'erubis >= 2.5.0'] 14 | p.test_pattern = 'test/unit/*.rb' 15 | end 16 | rescue LoadError => boom 17 | puts 'You are missing a dependency required for meta-operations on this gem.' 18 | puts boom.to_s.capitalize 19 | end 20 | 21 | desc 'Install the package as a gem, without generating documentation(ri/rdoc)' 22 | task :install_gem_no_doc => [:clean, :package] do 23 | sh "#{'sudo ' unless Hoe::WINDOZE }gem install pkg/*.gem --no-rdoc --no-ri" 24 | end 25 | 26 | -------------------------------------------------------------------------------- /lib/ape/invokers/deleter.rb: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2006 Sun Microsystems, Inc. All rights reserved 2 | # Use is subject to license terms - see file "LICENSE" 3 | require 'net/http' 4 | 5 | module Ape 6 | class Deleter < Invoker 7 | 8 | def delete( req = nil ) 9 | req = Net::HTTP::Delete.new(AtomURI.on_the_wire(@uri)) unless req 10 | 11 | begin 12 | http = prepare_http 13 | 14 | http.start do |connection| 15 | @response = connection.request(req) 16 | 17 | return delete(req) if need_authentication?(req) 18 | restart_authent_checker 19 | 20 | return true if @response.kind_of? Net::HTTPSuccess 21 | 22 | @last_error = @response.message 23 | return false 24 | end 25 | rescue Exception 26 | @last_error = "Can't connect to #{@uri.host} on port #{@uri.port}: #{$!}" 27 | return nil 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/ape/names.rb: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2006 Sun Microsystems, Inc. All rights reserved 2 | # Use is subject to license terms - see file "LICENSE" 3 | 4 | module Ape 5 | module Names 6 | AtomNamespace = 'http://www.w3.org/2005/Atom' unless defined?(AtomNamespace) 7 | AppNamespace = 'http://www.w3.org/2007/app' unless defined?(AppNamespace) 8 | DcNamespace = 'http://purl.org/dc/elements/1.1/' unless defined?(DcNamespace) 9 | XhtmlNamespace = 'http://www.w3.org/1999/xhtml' unless defined?(XhtmlNamespace) 10 | XmlNamespaces = { 11 | 'app' => AppNamespace, 12 | 'atom' => AtomNamespace, 13 | 'dc' => DcNamespace, 14 | 'xhtml' => XhtmlNamespace 15 | } unless defined?(XmlNamespaces) 16 | 17 | AtomMediaType = 'application/atom+xml' unless defined?(AtomMediaType) 18 | AtomEntryMediaType = 'application/atom+xml;type=entry' unless defined?(AtomEntryMediaType) 19 | AppMediaType = 'application/atomsvc+xml' unless defined?(AppMediaType) 20 | 21 | end 22 | end 23 | 24 | 25 | -------------------------------------------------------------------------------- /test/unit/authent_test.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/../test_helper.rb' 2 | 3 | class AuthentTest < Test::Unit::TestCase 4 | def setup 5 | @authent = Ape::Authent.new('david', 'my secret password') 6 | end 7 | 8 | def test_assert_raise_auth_error 9 | assert_raise(Ape::AuthenticationError) { load_plugin("Oauth") } 10 | end 11 | 12 | def test_assert_load_wsse_plugin 13 | assert_not_nil(load_plugin("Wsse")) 14 | end 15 | 16 | def test_assert_load_google_login_plugin 17 | assert_not_nil(load_plugin("GoogleLogin")) 18 | end 19 | 20 | #def test_assert_add_google_login_credentials_not_fail 21 | # @authent.add_to(Net::HTTP::Get.new('/'), 'GoogleLogin') 22 | #end 23 | 24 | def test_assert_add_wsse_credentials_not_fail 25 | assert_nothing_raised(Exception) { 26 | @authent.add_to(Net::HTTP::Get.new('/'), 'Wsse') 27 | } 28 | end 29 | 30 | def load_plugin(plugin_name) 31 | @authent.resolve_plugin(plugin_name) 32 | end 33 | 34 | 35 | end 36 | -------------------------------------------------------------------------------- /bin/ape_server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # Copyright (c) 2006 Sun Microsystems, Inc. All rights reserved 3 | # Use is subject to license terms - see file "LICENSE" 4 | $:.unshift File.dirname(__FILE__) + '/../lib' 5 | 6 | require 'rubygems' 7 | require 'mongrel' 8 | require 'optparse' 9 | require 'ape/server' 10 | require 'ape/samples' 11 | 12 | OPTIONS = { 13 | :host => '0.0.0.0', 14 | :port => '4000', 15 | :home => nil 16 | } 17 | 18 | parser = OptionParser.new do |opts| 19 | opts.banner = '@@ FIXME' 20 | opts.separator '' 21 | opts.on('-a', '--address ADDRESS', 'Address to bind to', "default: #{OPTIONS[:host]}") { |v| OPTIONS[:host] = v } 22 | opts.on('-p', '--port PORT', 'Port to bind to', "default: #{OPTIONS[:port]}") { |v| OPTIONS[:port] = v } 23 | opts.on('-d', '--directory DIRECTORY', 'ape home directory', "default: #{Ape::Samples.home}") { |v| OPTIONS[:home] = v } 24 | opts.on('-h', '--help', 'Displays this help') { puts opts; exit } 25 | opts.parse!(ARGV) 26 | end 27 | 28 | Ape::Server.run(OPTIONS) 29 | -------------------------------------------------------------------------------- /lib/ape/layout/ape.css: -------------------------------------------------------------------------------- 1 | h2 { 2 | color: #086; 3 | } 4 | 5 | body { 6 | margin-left: 50px; 7 | margin-right: 100px; 8 | font-family: verdana, arial, sans-serif; 9 | } 10 | 11 | .dialog { 12 | border-left: 8px solid #eee; 13 | } 14 | .from { 15 | padding: 0 10px 0 10px; 16 | background: #ddf; 17 | } 18 | .to { 19 | padding: 0 10px 0 10px; 20 | background: #dfd; 21 | } 22 | .dialab { 23 | padding: 3px 10px 3px 0px; 24 | background: #eee; 25 | } 26 | 27 | a.diaref { 28 | text-decoration: none; 29 | font-weight: bold; 30 | color: #086; 31 | } 32 | 33 | span.good { 34 | font-size: 125%; 35 | color: green; 36 | } 37 | span.warning { 38 | font-size: 125%; 39 | font-weight: bold; 40 | color: #fa0; 41 | } 42 | span.error { 43 | font-size: 125%; 44 | font-weight: bold; 45 | color: red; 46 | } 47 | 48 | #ape { 49 | position: absolute; right: 100px; top: 8px; 50 | padding-left: 20px; 51 | padding-right: 10px; 52 | 53 | width: 170px; 54 | border: 1px solid #086; 55 | } 56 | 57 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | == Atom Protocol Exerciser (APE) 2 | 3 | APE is a sanity-checker for implementations of the Atom Publishing Protocol (AtomPub or APP). It is written in Ruby, 4 | and provides a Mongrel-based HTML interface describing its interactions with the APP implementation under test. 5 | 6 | For more information about the history and impetus for the creation of APE, see Tim Bray's account here[http://www.tbray.org/ongoing/When/200x/2006/08/11/Meet-the-Ape]. 7 | 8 | == License 9 | 10 | Copyright (c) 2006 Sun Microsystems, Inc. All rights reserved. See the included LICENSE[link:/files/LICENSE.html] file for details. 11 | 12 | == Quick Start 13 | 14 | Install APE via RubyGems: 15 | 16 | $ gem install ape 17 | 18 | Now, you should have the ape_server command available in your $PATH. Start the server with: 19 | 20 | $ ape_server 21 | 22 | This will start the server in the foreground. You can access APE in your browser at http://localhost:4000 23 | 24 | == The Source 25 | 26 | To access the latest source code for APE, see the project site at https://rubyforge.org/projects/ape 27 | -------------------------------------------------------------------------------- /test/unit/samples_test.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/../test_helper.rb' 2 | 3 | require 'rexml/document' 4 | class SamplesTest < Test::Unit::TestCase 5 | 6 | def test_ape_home 7 | assert_equal('/Users/david/.ape', Ape::Samples.home) 8 | end 9 | 10 | def test_load_service_schema 11 | assert_not_nil(Ape::Samples.service_RNC) 12 | end 13 | 14 | def test_load_categories_schema 15 | assert_not_nil(Ape::Samples.categories_RNC) 16 | end 17 | 18 | def test_load_atom_schema 19 | assert_not_nil(Ape::Samples.atom_RNC) 20 | end 21 | 22 | def test_load_mini_entry 23 | doc = REXML::Document.new(Ape::Samples.mini_entry) 24 | assert_not_nil(REXML::XPath.first(doc.root, './id')) 25 | end 26 | 27 | def test_load_basic_entry 28 | doc = REXML::Document.new(Ape::Samples.basic_entry) 29 | assert_not_nil(REXML::XPath.first(doc.root, './summary')) 30 | end 31 | 32 | def test_load_unclean_xhtml_entry 33 | doc = REXML::Document.new(Ape::Samples.unclean_xhtml_entry) 34 | assert_not_nil(REXML::XPath.first(doc.root, './id')) 35 | end 36 | end -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2006 Sun Microsystems, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 17 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 18 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 19 | OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /lib/ape/handler.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'mongrel' 3 | require 'ape' 4 | 5 | module Ape 6 | 7 | # Implements the APE application handler for processing 8 | # and responding to requests. See process for more detail. 9 | class Handler < Mongrel::HttpHandler 10 | 11 | # Called by Mongrel with Mongrel::HttpRequest and 12 | # Mongrel::HttpResponse objects. Creates an Ape 13 | # instance for the request and responds with its report. 14 | def process(request, response) 15 | cgi = Mongrel::CGIWrapper.new(request, response) 16 | 17 | uri = cgi['uri'].strip 18 | user = cgi['username'].strip 19 | pass = cgi['password'].strip 20 | 21 | # invoke_ape uri, user, pass, request, response 22 | 23 | if uri.empty? 24 | response.start(200, true) do |header, body| 25 | header['Content-Type'] = 'text/plain' 26 | body << 'URI argument is required' 27 | end 28 | return 29 | end 30 | 31 | format = request.params['HTTP_ACCEPT'] == 'text/plain' ? 'text' : 'html' 32 | ape = Ape.new({ :crumbs => true, :output => format }) 33 | (user && pass) ? ape.check(uri, user, pass) : ape.check(uri) 34 | 35 | response.start(200, true) do |header, body| 36 | header['Content-Type'] = 'text/html' 37 | ape.report(body) 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/ape/invokers/putter.rb: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2006 Sun Microsystems, Inc. All rights reserved 2 | # Use is subject to license terms - see file "LICENSE" 3 | require 'net/http' 4 | 5 | module Ape 6 | class Putter < Invoker 7 | attr_reader :headers 8 | 9 | def initialize(uriString, authent) 10 | super uriString, authent 11 | @headers = {} 12 | end 13 | 14 | def set_header(name, val) 15 | @headers[name] = val 16 | end 17 | 18 | def put(contentType, body, req = nil) 19 | req = Net::HTTP::Put.new(AtomURI.on_the_wire(@uri)) unless req 20 | 21 | req.set_content_type contentType 22 | @headers.each { |k, v| req[k]= v } 23 | 24 | begin 25 | http = prepare_http 26 | 27 | http.start do |connection| 28 | @response = connection.request(req, body) 29 | 30 | return put(contentType, body, req) if need_authentication?(req) 31 | restart_authent_checker 32 | 33 | unless @response.kind_of? Net::HTTPSuccess 34 | @last_error = @response.message 35 | return false 36 | end 37 | 38 | return true 39 | end 40 | rescue Exception 41 | @last_error = "Can't connect to #{@uri.host} on port #{@uri.port}: #{$!}" 42 | return nil 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/ape/server.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'mongrel' 3 | require 'ape/handler' 4 | require 'ape/samples' 5 | 6 | module Ape 7 | 8 | # Manages and initializes the Mongrel handler. See run for more details. 9 | class Server 10 | 11 | # Starts the Mongrel handler with options given in +options+ and 12 | # maps the /, /ape and /atompub/go URIs to handlers. 13 | # 14 | # ==== Options 15 | # * :host - the IP address to bind to 16 | # * :port - the port number to listen on 17 | def self.run(options) 18 | Samples.home = options[:home] 19 | 20 | mongrel = Mongrel::Configurator.new(:host => options[:host], :port => options[:port]) do 21 | log "=> Booting mongrel" 22 | begin 23 | log "=> The ape starting on http://#{options[:host]}:#{options[:port]}" 24 | listener do 25 | redirect '/', '/ape/index.html' 26 | uri '/ape', :handler => Mongrel::DirHandler.new(File.dirname(__FILE__) + '/layout', true) 27 | uri '/atompub/go', :handler => Handler.new 28 | end 29 | rescue Errno::EADDRINUSE 30 | log "ERROR: Address (#{options[:host]}:#{options[:port]}) is already in use" 31 | exit 1 32 | end 33 | trap("INT") { stop } 34 | trap("TERM") { stop } 35 | log "=> Ctrl-C to shutdown" 36 | run 37 | end 38 | mongrel.join 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/ape/collection.rb: -------------------------------------------------------------------------------- 1 | # Copyright (c) 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 52 | -------------------------------------------------------------------------------- /lib/ape/layout/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Atom Protocol Exerciser 4 | 5 | 6 | 7 | 8 | The Ape logo by Greg Borenstein 9 |
10 |

Atom Protocol Exerciser

11 | 12 |

(Only the URI argument is required)

13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 |
URI:
Username:
Password:
34 |
35 |
36 | 41 |
42 |

The Ape source code may be found at 43 | ape.rubyforge.org. 44 | 45 |


46 |

Ape Logo by Greg 47 | Borenstein.

48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /lib/ape/authent.rb: -------------------------------------------------------------------------------- 1 | # Copyright (c) 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 } 43 | -------------------------------------------------------------------------------- /lib/ape/crumbs.rb: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2006 Sun Microsystems, Inc. All rights reserved 2 | # Use is subject to license terms - see file "LICENSE" 3 | 4 | module Ape 5 | 6 | # All descendants of Invoker use Net::HTTP::set_debug_output 7 | # to capture the dialogue. This class is what gets passed to 8 | # set_debug_output; it exists to define #<< to save only the interesting bits 9 | # of the dialog. 10 | class Crumbs 11 | 12 | def initialize #:nodoc: 13 | @crumbs = [] 14 | @keep_next = false 15 | end 16 | 17 | # call-seq: 18 | # crumbs.grep(pattern) => array 19 | # 20 | # Returns an array of crumbs for which pattern === crumb 21 | # 22 | # ==== Options 23 | # * pattern - A string or Regexp literal, as with Enumerable#grep. 24 | def grep(pattern) 25 | @crumbs.grep(pattern) 26 | end 27 | 28 | # Appends +data+ to the report dialog 29 | # 30 | # ==== Options 31 | # * data - The message, as passed by an Invoker descendant. Required. 32 | def <<(data) 33 | if @keep_next 34 | @crumbs << "> #{data}" 35 | @keep_next = false 36 | elsif data =~ /^->/ 37 | @crumbs << "< #{data.gsub(/^.../, '')}" 38 | elsif data =~ /^<-/ 39 | @keep_next = true 40 | end 41 | end 42 | 43 | # Yields each crumb sequentially to the supplied block. 44 | def each #:yields: crumb 45 | @crumbs.each { |c| yield c } 46 | end 47 | 48 | # call-seq: 49 | # crumbs.to_s => string 50 | # 51 | # Returns a string containing all crumbs, seperated by newlines. 52 | def to_s 53 | " " + @crumbs.join("...\n") 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/ape/samples/categories_schema.txt: -------------------------------------------------------------------------------- 1 | # -*- rnc -*- 2 | # RELAX NG Compact Syntax Grammar for the Atom Protocol 3 | 4 | namespace app = "http://www.w3.org/2007/app" 5 | namespace atom = "http://www.w3.org/2005/Atom" 6 | namespace xsd = "http://www.w3.org/2001/XMLSchema" 7 | namespace local = "" 8 | 9 | start = appCategories 10 | 11 | atomCommonAttributes = 12 | attribute xml:base { atomURI }?, 13 | attribute xml:lang { atomLanguageTag }?, 14 | undefinedAttribute* 15 | 16 | undefinedAttribute = 17 | attribute * - (xml:base | xml:lang | local:*) { text } 18 | 19 | atomURI = text 20 | 21 | atomLanguageTag = xsd:string { 22 | pattern = "[A-Za-z]{1,8}(-[A-Za-z0-9]{1,8})*" 23 | } 24 | 25 | 26 | atomCategory = 27 | element atom:category { 28 | atomCommonAttributes, 29 | attribute term { text }, 30 | attribute scheme { atomURI }?, 31 | attribute label { text }?, 32 | undefinedContent 33 | } 34 | 35 | appInlineCategories = 36 | element app:categories { 37 | attribute fixed { "yes" | "no" }?, 38 | attribute scheme { atomURI }?, 39 | (atomCategory*) 40 | } 41 | 42 | appOutOfLineCategories = 43 | element app:categories { 44 | attribute href { atomURI }, 45 | (empty) 46 | } 47 | 48 | appCategories = appInlineCategories | appOutOfLineCategories 49 | 50 | 51 | # Extensibility 52 | 53 | undefinedContent = (text|anyForeignElement)* 54 | 55 | anyElement = 56 | element * { 57 | (attribute * { text } 58 | | text 59 | | anyElement)* 60 | } 61 | 62 | anyForeignElement = 63 | element * - atom:* { 64 | (attribute * { text } 65 | | text 66 | | anyElement)* 67 | } 68 | 69 | # EOF -------------------------------------------------------------------------------- /lib/ape/invoker.rb: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2006 Sun Microsystems, Inc. All rights reserved 2 | # Use is subject to license terms - see file "LICENSE" 3 | require 'net/https' 4 | 5 | module Ape 6 | class Invoker 7 | attr_reader :last_error, :crumbs, :response 8 | 9 | def initialize(uriString, authent) 10 | @last_error = nil 11 | @crumbs = Crumbs.new 12 | @uri = AtomURI.check(uriString) 13 | if (@uri.class == String) 14 | @last_error = @uri 15 | end 16 | @authent = authent 17 | @authent_checker = 0 18 | end 19 | 20 | def header(which) 21 | @response[which] 22 | end 23 | 24 | def prepare_http 25 | http = Net::HTTP.new(@uri.host, @uri.port) 26 | if @uri.scheme == 'https' 27 | http.use_ssl = true 28 | http.verify_mode = OpenSSL::SSL::VERIFY_NONE 29 | end 30 | http.set_debug_output @crumbs if @crumbs 31 | http 32 | end 33 | 34 | def need_authentication?(req) 35 | if @response.instance_of?(Net::HTTPUnauthorized) && @authent 36 | #tries to authenticate just two times in order to avoid infinite loops 37 | raise AuthenticationError, 'Authentication is required' unless @authent_checker <= 1 38 | @authent_checker += 1 39 | 40 | @authent.add_to req, header('WWW-Authenticate') 41 | #clean the request body attribute, if we don't do it http.request(req, body) will raise an exception 42 | req.body = nil unless req.body.nil? 43 | return true 44 | end 45 | return false 46 | end 47 | 48 | def restart_authent_checker 49 | @authent_checker = 0 50 | end 51 | end 52 | end 53 | 54 | Dir[File.dirname(__FILE__) + '/invokers/*.rb'].each { |l| require l } 55 | -------------------------------------------------------------------------------- /lib/ape/invokers/poster.rb: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2006 Sun Microsystems, Inc. All rights reserved 2 | # Use is subject to license terms - see file "LICENSE" 3 | require 'net/http' 4 | 5 | module Ape 6 | class Poster < Invoker 7 | attr_reader :entry, :uri 8 | 9 | def initialize(uriString, authent) 10 | super uriString, authent 11 | @headers = {} 12 | @entry = nil 13 | end 14 | 15 | def set_header(name, val) 16 | @headers[name] = val 17 | end 18 | 19 | def post(contentType, body, req = nil) 20 | req = Net::HTTP::Post.new(AtomURI.on_the_wire(@uri)) if req.nil? 21 | req.set_content_type contentType 22 | @headers.each { |k, v| req[k]= v } 23 | 24 | begin 25 | http = prepare_http 26 | 27 | http.start do |connection| 28 | @response = connection.request(req, body) 29 | 30 | return post(contentType, body, req) if need_authentication?(req) 31 | restart_authent_checker 32 | 33 | if @response.code != '201' 34 | @last_error = @response.message 35 | return false 36 | end 37 | 38 | if (!((@response['Content-type'] =~ %r{^application/atom\+xml}) || 39 | (@response['Content-type'] =~ %r{^application/atom\+xml;type=entry}))) 40 | return true 41 | end 42 | 43 | begin 44 | @entry = Entry.new(@response.body) 45 | return true 46 | rescue ArgumentError 47 | @last_error = @entry.broken 48 | return false 49 | end 50 | end 51 | rescue Exception 52 | @last_error = "Can't connect to #{@uri.host} on port #{@uri.port}: #{$!}" 53 | return false 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/ape/validator.rb: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2006 Sun Microsystems, Inc. All rights reserved 2 | # Use is subject to license terms - see file "LICENSE" 3 | 4 | if RUBY_PLATFORM =~ /java/ 5 | require 'java' 6 | CompactSchemaReader = com.thaiopensource.validate.rng.CompactSchemaReader 7 | ValidationDriver = com.thaiopensource.validate.ValidationDriver 8 | StringReader = java.io.StringReader 9 | StringWriter = java.io.StringWriter 10 | InputSource = org.xml.sax.InputSource 11 | ErrorHandlerImpl = com.thaiopensource.xml.sax.ErrorHandlerImpl 12 | PropertyMapBuilder = com.thaiopensource.util.PropertyMapBuilder 13 | ValidateProperty = com.thaiopensource.validate.ValidateProperty 14 | end 15 | 16 | module Ape 17 | class Validator 18 | 19 | attr_reader :error 20 | 21 | def Validator.validate(schema, text, name, ape) 22 | # Can do this in JRuby, not native Ruby (sigh) 23 | if RUBY_PLATFORM =~ /java/ 24 | rnc_validate(schema, text, name, ape) 25 | else 26 | true 27 | end 28 | end 29 | 30 | def Validator.rnc_validate(schema, text, name, ape) 31 | schemaError = StringWriter.new 32 | schemaEH = ErrorHandlerImpl.new(schemaError) 33 | properties = PropertyMapBuilder.new 34 | properties.put(ValidateProperty::ERROR_HANDLER, schemaEH) 35 | error = nil 36 | driver = ValidationDriver.new(properties.toPropertyMap, CompactSchemaReader.getInstance) 37 | if driver.loadSchema(InputSource.new(StringReader.new(schema))) 38 | begin 39 | if !driver.validate(InputSource.new(StringReader.new(text))) 40 | error = schemaError.toString 41 | end 42 | rescue org.xml.sax.SAXParseException 43 | error = $!.to_s.sub(/\n.*$/, '') 44 | end 45 | else 46 | error = schemaError.toString 47 | end 48 | 49 | if !error 50 | ape.good "#{name} passed schema validation." 51 | true 52 | else 53 | # this kind of sucks, but I spent a looong time lost in a maze of twisty 54 | # little passages without being able to figure out how to 55 | # tell jing what name I'd like to call the InputSource 56 | ape.error "#{name} failed schema validation:\n" + error.gsub('(unknown file):', 'Line ') 57 | false 58 | end 59 | 60 | end 61 | 62 | end 63 | end 64 | 65 | 66 | -------------------------------------------------------------------------------- /lib/ape/invokers/getter.rb: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2006 Sun Microsystems, Inc. All rights reserved 2 | # Use is subject to license terms - see file "LICENSE" 3 | require 'net/http' 4 | 5 | module Ape 6 | class Getter < Invoker 7 | attr_reader :contentType, :charset, :body, :security_warning 8 | 9 | def get(contentType = nil, depth = 0, req = nil) 10 | req = Net::HTTP::Get.new(AtomURI.on_the_wire(@uri)) unless req 11 | @last_error = nil 12 | 13 | return false if document_failed?(depth, req) 14 | 15 | begin 16 | http = prepare_http 17 | 18 | http.start do |connection| 19 | @response = connection.request(req) 20 | 21 | if need_authentication?(req) 22 | @security_warning = true unless http.use_ssl? 23 | return get(contentType, depth + 1, req) 24 | end 25 | restart_authent_checker 26 | 27 | case @response 28 | when Net::HTTPSuccess 29 | return getBody(contentType) 30 | 31 | when Net::HTTPRedirection 32 | redirect_to = @uri.merge(@response['location']) 33 | @uri = AtomURI.check(redirect_to) 34 | return get(contentType, depth + 1) 35 | 36 | else 37 | @last_error = @response.message 38 | return false 39 | end 40 | end 41 | rescue Exception 42 | @last_error = "Can't connect to #{@uri.host} on port #{@uri.port}: #{$!}" 43 | return false 44 | end 45 | end 46 | 47 | def document_failed?(depth, req) 48 | if (depth > 10) 49 | if need_authentication?(req) 50 | #Authentication required 51 | @last_error = "Authentication is required" 52 | else 53 | # too many redirects 54 | @last_error = "Too many redirects" 55 | end 56 | return true 57 | end 58 | return false 59 | end 60 | 61 | def getBody contentType 62 | 63 | if contentType 64 | @contentType = @response['Content-Type'] 65 | # XXX TODO - better regex 66 | if @contentType =~ /^([^;]*);/ 67 | @contentType = $1 68 | end 69 | 70 | if contentType != @contentType 71 | @last_error = "Content-type must be '#{contentType}', not '#{@contentType}'" 72 | end 73 | end 74 | 75 | @body = @response.body 76 | return true 77 | end 78 | end 79 | end 80 | 81 | -------------------------------------------------------------------------------- /lib/ape/categories.rb: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2006 Sun Microsystems, Inc. All rights reserved 2 | # Copyright © 2006 Sun Microsystems, Inc. All rights reserved 3 | # Use is subject to license terms - see file "LICENSE" 4 | 5 | require 'rexml/document' 6 | require 'rexml/xpath' 7 | 8 | module Ape 9 | class Categories 10 | attr_reader :fixed 11 | 12 | def Categories.from_collection(collection, authent, ape=nil) 13 | 14 | # "catses" because if cats is short for categories, then catses 15 | # suggests multiple elements 16 | catses = collection.catses 17 | 18 | catses.collect! do |cats| 19 | if cats.attribute(:href) 20 | getter = Getter.new(cats.attribute(:href).value, authent) 21 | if getter.last_error # wonky URI 22 | ape.error getter.last_error if ape 23 | nil 24 | end 25 | 26 | if !getter.get('application/atomcat+xml') 27 | ape.error "Can't fetch categories doc " + 28 | "'#{cats.attribute(:href).value}': getter.last_error" if ape 29 | nil 30 | end 31 | 32 | ape.warning(getter.last_error) if ape && getter.last_error 33 | REXML::Document.new(getter.body).root 34 | else 35 | # no href attribute 36 | cats 37 | end 38 | end 39 | catses.compact 40 | end 41 | 42 | end 43 | 44 | # Decorate an entry which is about to be posted to a collection with some 45 | # categories. For each fixed categories element, pick one of the categories 46 | # and add that. If there are no categories elements at all, or if there's 47 | # at least one with fixed="no", also add a syntho-cat that we make up. 48 | # Return the list of categories that we added. 49 | # 50 | def Categories.add_cats(entry, collection, authent, ape=nil) 51 | 52 | added = [] 53 | c = from_collection(collection, authent) 54 | if c.empty? 55 | add_syntho = true 56 | else 57 | add_syntho = false 58 | 59 | # for each 60 | c.each do |cats| 61 | 62 | default_scheme = cats.attributes['scheme'] 63 | 64 | # if it's fixed, pick the first one 65 | if cats.attributes['fixed'] == "yes" 66 | cat_list = REXML::XPath.match(cats, './atom:category', Names::XmlNamespaces) 67 | if cat_list 68 | 69 | # for each take the first one whose attribute "term" is not empty 70 | cat_list.each do |cat| 71 | if cat.attributes['term'].empty? 72 | ape.warning 'A mangled category is present in your categories list' if ape 73 | else 74 | scheme = cat.attributes['scheme'] 75 | if !scheme 76 | scheme = default_scheme 77 | end 78 | added << entry.add_category(cat.attributes['term'], scheme) 79 | break 80 | end 81 | end 82 | end 83 | else 84 | add_syntho = true 85 | end 86 | 87 | end 88 | end 89 | 90 | if add_syntho 91 | added << entry.add_category('simians', 'http://tbray.org/cat-test') 92 | end 93 | added 94 | end 95 | end 96 | 97 | -------------------------------------------------------------------------------- /lib/ape/atomURI.rb: -------------------------------------------------------------------------------- 1 | # Copyright (c) 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 | # Represents an Atom URI and encapsulates Atom-specific URI helpers. 7 | class AtomURI 8 | 9 | # call-seq: 10 | # AtomURI.new('http://...') => object 11 | # AtomURI.new(uri) => object 12 | # 13 | # Creates a new instance using +base_uri+. 14 | # 15 | # ==== Options 16 | # * base_uri - a URI string or object. If a string is passed, URI.parse is used for conversion. Required. 17 | def initialize(base_uri) 18 | if base_uri.kind_of? URI 19 | @base = base_uri 20 | else 21 | @base = URI.parse base_uri 22 | end 23 | end 24 | 25 | # Given a URI string from an XML document and its containing element, 26 | # absolutize it if it's relative, with proper regard for xml:base. 27 | # Returns the absolute URI, or nil on failure. 28 | # 29 | # ==== Options 30 | # * uri_s - The URI string to be made absolute. Required. 31 | # * context - The containing element, inside which uri_s is found. Required. 32 | def absolutize(uri_s, context) 33 | begin 34 | uri = URI.parse uri_s 35 | return uri_s if uri.absolute? 36 | 37 | path_base = @base 38 | path_to(context).each do |node| 39 | if (xb = node.attributes['xml:base']) 40 | xb = URI.parse xb 41 | if xb.absolute? then path_base = xb else path_base.merge! xb end 42 | end 43 | end 44 | 45 | return path_base.merge(uri).to_s 46 | rescue URI::InvalidURIError 47 | return nil 48 | end 49 | end 50 | 51 | # call-seq: 52 | # path_to(element) => array 53 | # 54 | # Returns an array containing each successive element in the path from 55 | # the root node to +element+. 56 | # 57 | # ==== Options 58 | # * element - The REXML::Element object to path-find. 59 | def path_to(node) 60 | if node.class == REXML::Element 61 | path_to(node.parent) << node 62 | else 63 | [ ] 64 | end 65 | end 66 | 67 | # call-seq: 68 | # AtomURI.check('http://...') => string 69 | # 70 | # Validates that +uri_string+ is well-formed and uses a supported scheme. 71 | # Returns +uri_string+ if successful or an error message on failure. 72 | # 73 | # ==== Options 74 | # * uri_string - A URI string or object to check for correctness. Required. 75 | def AtomURI.check(uri_string) 76 | if uri_string.kind_of? URI 77 | uri = uri_string 78 | else 79 | begin 80 | uri = URI.parse(uri_string) 81 | rescue URI::InvalidURIError 82 | return "Invalid URI: #{$!}" 83 | end 84 | end 85 | 86 | unless uri.scheme =~ /^https?$/ 87 | return "URI scheme must be 'http' or 'https', not '#{uri.scheme}'" 88 | else 89 | return uri 90 | end 91 | end 92 | 93 | # call-seq: 94 | # AtomURI.on_the_wire(uri) => string 95 | # 96 | # Returns +uri+ in the proper format for Net::HTTPRequest and friends. 97 | # 98 | # ==== Options 99 | # * uri - a URI object (*not* a string). Required. 100 | def AtomURI.on_the_wire(uri) 101 | if uri.query 102 | "#{uri.path}?#{uri.query}" 103 | else 104 | uri.path 105 | end 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /lib/ape/feed.rb: -------------------------------------------------------------------------------- 1 | # Copyright (c) 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 Feed 9 | # Load up a collection feed from its URI. Return an array of objects. 10 | # follow pointers as required to get the whole 11 | # collection 12 | def Feed.read(uri, name, ape, report=true) 13 | 14 | entries = [] 15 | uris = [] 16 | next_page = uri 17 | page_num = 1 18 | 19 | while (next_page) && (page_num < 10) do 20 | 21 | label = "Page #{page_num} of #{name}" 22 | uris << next_page 23 | page = ape.check_resource(next_page, label, Names::AtomMediaType, report) 24 | break unless page 25 | 26 | # * Validate it 27 | Validator.validate(Samples.atom_RNC, page.body, label, ape) if report 28 | 29 | # XML-parse the feed 30 | error = "not well-formed" 31 | feed = nil 32 | begin 33 | feed = REXML::Document.new(page.body, { :raw => nil }) 34 | rescue Exception 35 | error = $!.to_s 36 | feed = nil 37 | end 38 | if feed == nil 39 | ape.error "Can't parse #{label} at #{next_page}, Parser said: #{$!}" if report 40 | break 41 | end 42 | 43 | feed = feed.root 44 | if feed == nil 45 | ape.warning "#{label} is empty." 46 | break 47 | end 48 | 49 | page_entries = REXML::XPath.match(feed, "./atom:entry", Names::XmlNamespaces) 50 | if page_entries.empty? && report 51 | ape.info "#{label} has no entries." 52 | end 53 | 54 | entries += page_entries.map { |e| Entry.new(e, next_page)} 55 | 56 | next_link = REXML::XPath.first(feed, "./atom:link[@rel=\"next\"]", Names::XmlNamespaces) 57 | if next_link 58 | next_link = next_link.attributes['href'] 59 | base = AtomURI.new(next_page) 60 | next_link = base.absolutize(next_link, feed) 61 | if uris.index(next_link) 62 | ape.error "Collection contains circular 'next' linkage: #{next_link}" if report 63 | break 64 | end 65 | page_num += 1 66 | end 67 | next_page = next_link 68 | end 69 | 70 | if report && next_page 71 | ape.warning "Stopped reading collection after #{page_num} pages." 72 | end 73 | 74 | # all done unless we're error-checking 75 | return entries unless report 76 | 77 | # Ensure that entries are ordered by app:edited 78 | last_date = nil 79 | with_app_date = 0 80 | clean = true 81 | entries.each do |e| 82 | datestr = e.child_content("edited", Names::AppNamespace) 83 | error = nil 84 | if datestr 85 | if datestr =~ /\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?(Z|([-+]\d\d:\d\d))/ 86 | begin 87 | date = Time.parse(datestr) 88 | with_app_date += 1 89 | if last_date && (date > last_date) 90 | error = "app:edited values out of order, d #{date} ld #{last_date}" 91 | end 92 | last_date = date 93 | rescue ArgumentError 94 | error = "invalid app:edited value: #{datestr}" 95 | end 96 | else 97 | error = "invalid app:edited child: #{datestr}" 98 | end 99 | if error 100 | title = e.child_content "title" 101 | ape.error "In entry with title '#{title}', #{error}." 102 | clean = false 103 | end 104 | end 105 | end 106 | if with_app_date < entries.size 107 | ape.error "#{entries.size - with_app_date} of #{entries.size} entries in #{name} lack app:edited elements." 108 | clean = false 109 | end 110 | 111 | ape.good "#{name} has correct app:edited value order." if clean 112 | 113 | entries 114 | end 115 | end 116 | end 117 | 118 | -------------------------------------------------------------------------------- /lib/ape/auth/google_login_credentials.rb: -------------------------------------------------------------------------------- 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 97 | -------------------------------------------------------------------------------- /lib/ape/samples/service_schema.txt: -------------------------------------------------------------------------------- 1 | # -*- rnc -*- 2 | # RELAX NG Compact Syntax Grammar for the Atom Protocol 3 | 4 | namespace app = "http://www.w3.org/2007/app" 5 | namespace atom = "http://www.w3.org/2005/Atom" 6 | namespace xsd = "http://www.w3.org/2001/XMLSchema" 7 | namespace xhtml = "http://www.w3.org/1999/xhtml" 8 | namespace local = "" 9 | 10 | start = appService 11 | 12 | # common:attrs 13 | 14 | atomURI = text 15 | 16 | appCommonAttributes = 17 | attribute xml:base { atomURI }?, 18 | attribute xml:lang { atomLanguageTag }?, 19 | attribute xml:space {"default"|"preserved"}?, 20 | undefinedAttribute* 21 | 22 | 23 | atomCommonAttributes = appCommonAttributes 24 | 25 | undefinedAttribute = 26 | attribute * - (xml:base | xml:space | xml:lang | local:*) { text } 27 | 28 | atomLanguageTag = xsd:string { 29 | pattern = "([A-Za-z]{1,8}(-[A-Za-z0-9]{1,8})*)?" 30 | } 31 | 32 | atomDateConstruct = 33 | appCommonAttributes, 34 | xsd:dateTime 35 | 36 | # app:service 37 | 38 | appService = 39 | element app:service { 40 | appCommonAttributes, 41 | ( appWorkspace+ 42 | & extensionElement* ) 43 | } 44 | 45 | # app:workspace 46 | 47 | appWorkspace = 48 | element app:workspace { 49 | appCommonAttributes, 50 | ( atomTitle 51 | & appCollection* 52 | & extensionSansTitleElement* ) 53 | } 54 | 55 | atomTitle = element atom:title { atomTextConstruct } 56 | 57 | # app:collection 58 | 59 | appCollection = 60 | element app:collection { 61 | appCommonAttributes, 62 | attribute href { atomURI }, 63 | ( atomTitle 64 | & appAccept* 65 | & appCategories* 66 | & extensionSansTitleElement* ) 67 | } 68 | 69 | # app:categories 70 | 71 | atomCategory = 72 | element atom:category { 73 | atomCommonAttributes, 74 | attribute term { text }, 75 | attribute scheme { atomURI }?, 76 | attribute label { text }?, 77 | undefinedContent 78 | } 79 | 80 | appInlineCategories = 81 | element app:categories { 82 | attribute fixed { "yes" | "no" }?, 83 | attribute scheme { atomURI }?, 84 | (atomCategory*, 85 | undefinedContent) 86 | } 87 | 88 | appOutOfLineCategories = 89 | element app:categories { 90 | attribute href { atomURI }, 91 | undefinedContent 92 | } 93 | 94 | appCategories = appInlineCategories | appOutOfLineCategories 95 | 96 | 97 | # app:accept 98 | 99 | appAccept = 100 | element app:accept { 101 | appCommonAttributes, 102 | ( text? ) 103 | } 104 | 105 | # Simple Extension 106 | 107 | simpleSansTitleExtensionElement = 108 | element * - (app:*|atom:title) { 109 | text 110 | } 111 | 112 | simpleExtensionElement = 113 | element * - (app:*) { 114 | text 115 | } 116 | 117 | 118 | # Structured Extension 119 | 120 | structuredSansTitleExtensionElement = 121 | element * - (app:*|atom:title) { 122 | (attribute * { text }+, 123 | (text|anyElement)*) 124 | | (attribute * { text }*, 125 | (text?, anyElement+, (text|anyElement)*)) 126 | } 127 | 128 | structuredExtensionElement = 129 | element * - (app:*) { 130 | (attribute * { text }+, 131 | (text|anyElement)*) 132 | | (attribute * { text }*, 133 | (text?, anyElement+, (text|anyElement)*)) 134 | } 135 | 136 | # Other Extensibility 137 | 138 | extensionSansTitleElement = 139 | simpleSansTitleExtensionElement|structuredSansTitleExtensionElement 140 | 141 | 142 | extensionElement = 143 | simpleExtensionElement | structuredExtensionElement 144 | 145 | undefinedContent = (text|anyForeignElement)* 146 | 147 | # Extensions 148 | 149 | anyElement = 150 | element * { 151 | (attribute * { text } 152 | | text 153 | | anyElement)* 154 | } 155 | 156 | anyForeignElement = 157 | element * - app:* { 158 | (attribute * { text } 159 | | text 160 | | anyElement)* 161 | } 162 | 163 | atomPlainTextConstruct = 164 | atomCommonAttributes, 165 | attribute type { "text" | "html" }?, 166 | text 167 | 168 | atomXHTMLTextConstruct = 169 | atomCommonAttributes, 170 | attribute type { "xhtml" }, 171 | xhtmlDiv 172 | 173 | atomTextConstruct = atomPlainTextConstruct | atomXHTMLTextConstruct 174 | 175 | anyXHTML = element xhtml:* { 176 | (attribute * { text } 177 | | text 178 | | anyXHTML)* 179 | } 180 | 181 | xhtmlDiv = element xhtml:div { 182 | (attribute * { text } 183 | | text 184 | | anyXHTML)* 185 | } 186 | 187 | # EOF -------------------------------------------------------------------------------- /lib/ape/entry.rb: -------------------------------------------------------------------------------- 1 | # Copyright (c) 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 | "\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
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(/'/, "'") 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 152 | -------------------------------------------------------------------------------- /lib/ape/samples.rb: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2006 Sun Microsystems, Inc. All rights reserved 2 | # Use is subject to license terms - see file "LICENSE" 3 | require 'rexml/xpath' 4 | require 'date' 5 | require 'base64' 6 | require 'erubis' 7 | 8 | module Ape 9 | class Samples 10 | 11 | @@service_schema = nil 12 | @@categories_schema = nil 13 | @@atom_schema = nil 14 | @@home = nil 15 | 16 | def Samples.home=(home) 17 | @@home = home 18 | end 19 | 20 | def Samples.foreign_child 21 | 'subject' 22 | end 23 | def Samples.foreign_namespace 24 | Names::DcNamespace 25 | end 26 | def Samples.foreign_child_content 27 | 'Simians' 28 | end 29 | 30 | def Samples.load_schema(file_name) 31 | schema = "" 32 | File.open(File.join(File.dirname(__FILE__), "/samples/#{file_name}_schema.txt")) do |file| 33 | while(line = file.gets) 34 | schema << line 35 | end 36 | end 37 | schema 38 | end 39 | 40 | def Samples.service_RNC 41 | @@service_schema = load_schema('service') unless @@service_schema 42 | @@service_schema 43 | end 44 | 45 | def Samples.categories_RNC 46 | @@categories_schema = load_schema('categories') unless @@categories_schema 47 | @@categories_schema 48 | end 49 | 50 | def Samples.atom_RNC 51 | @@atom_schema = load_schema('atom') unless @@atom_schema 52 | @@atom_schema 53 | end 54 | 55 | #recipe from cap 56 | def Samples.home_directory 57 | ENV["HOME"] || (ENV["HOMEPATH"] && "#{ENV["HOMEDRIVE"]}#{ENV["HOMEPATH"]}") || 58 | "/" 59 | end 60 | 61 | def Samples.home 62 | @@home || ENV["APE_HOME"] || File.join(home_directory,".ape") 63 | end 64 | 65 | def Samples.make_id 66 | id = '' 67 | 5.times { id += rand(1000000).to_s } 68 | "tag:tbray.org,2005:#{id}" 69 | end 70 | 71 | def Samples.entry_path(type) 72 | File.exist?(File.join(home, "/#{type}.eruby"))? 73 | File.join(home, "/#{type}.eruby") : 74 | File.join(File.dirname(__FILE__), "/samples/#{type}.eruby") 75 | end 76 | 77 | def Samples.load_template(type) 78 | entry_path = entry_path(type) 79 | input = File.read(entry_path) 80 | eruby = Erubis::Eruby.new(input) 81 | end 82 | 83 | 84 | def Samples.mini_entry 85 | eruby = load_template('mini_entry') 86 | 87 | now = DateTime::now.strftime("%Y-%m-%dT%H:%M:%S%z").sub(/(..)$/, ':\1') 88 | id = make_id 89 | 90 | eruby.result(binding()) 91 | end 92 | 93 | def Samples.basic_entry 94 | eruby = load_template('basic_entry') 95 | 96 | id = make_id 97 | now = DateTime::now.strftime("%Y-%m-%dT%H:%M:%S%z").sub(/(..)$/, ':\1') 98 | title = Escaper.escape('From the (サル)') 99 | summary = "Summary from the <b>&lt;&nbsp;APE&nbsp;></b> at #{now}" 100 | subject = Names::DcNamespace 101 | 102 | eruby.result(binding()) 103 | end 104 | 105 | def Samples.unclean_xhtml_entry 106 | eruby = load_template('unclean_xhtml_entry') 107 | 108 | id = make_id 109 | now = DateTime::now.strftime("%Y-%m-%dT%H:%M:%S%z").sub(/(..)$/, ':\1') 110 | 111 | eruby.result(binding()) 112 | end 113 | 114 | def Samples.cat_test_entry 115 | e = retitled_entry('Testing category posting') 116 | end 117 | 118 | def Samples.retitled_entry(new_title, new_id = nil) 119 | e = basic_entry 120 | e.gsub!(/.*<\/title>/, "<title>#{new_title}") 121 | new_id = make_id unless new_id 122 | e.gsub(/.*<\/id>/, "#{new_id}") 123 | end 124 | 125 | def Samples.picture 126 | b64 =< nil }) 72 | rescue REXML::ParseException 73 | prob = $!.to_s.gsub(/\n/, '
') 74 | error "Service document not well-formed: #{prob}" 75 | return 76 | end 77 | 78 | # RNC-validate the service doc 79 | Validator.validate(Samples.service_RNC, text, 'Service doc', self) 80 | 81 | # * Do we have collections we can post an entry and a picture to? 82 | # the requested_* arguments are the requested collection titles; if 83 | # provided, try to match them, otherwise just pick the first listed 84 | # 85 | begin 86 | collections = Service.collections(service, uri) 87 | rescue Exception 88 | error "Couldn't read collections from service doc: #{$!}" 89 | return 90 | end 91 | entry_coll = media_coll = nil 92 | if collections.length > 0 93 | start_list "Found these collections" 94 | collections.each do |collection| 95 | list_item "'#{collection.title}' " + 96 | "accepts #{collection.accept.join(', ')}" 97 | if (!entry_coll) && collection.accept.index(Names::AtomEntryMediaType) 98 | if requested_e_coll 99 | if requested_e_coll == collection.title 100 | entry_coll = collection 101 | end 102 | else 103 | entry_coll = collection 104 | end 105 | end 106 | 107 | if !media_coll 108 | image_jpeg_ok = false 109 | collection.accept.each do |types| 110 | types.split(/, */).each do |type| 111 | 112 | if type == '*/*' || type == 'image/*' || type == 'image/jpeg' 113 | image_jpeg_ok = true 114 | end 115 | end 116 | end 117 | if image_jpeg_ok 118 | if requested_m_coll 119 | if requested_m_coll == collection.title 120 | media_coll = collection 121 | end 122 | else 123 | media_coll = collection 124 | end 125 | end 126 | end 127 | end 128 | end 129 | 130 | end_list 131 | 132 | if entry_coll 133 | info "Will use collection '#{entry_coll.title}' for entry creation." 134 | test_entry_posts entry_coll 135 | test_sorting entry_coll 136 | test_sanitization entry_coll 137 | else 138 | warning "No collection for 'application/atom+xml;type=entry', won't test entry posting." 139 | end 140 | 141 | if media_coll 142 | info "Will use collection '#{media_coll.title}' for media creation." 143 | test_media_posts media_coll.href 144 | test_media_linkage media_coll 145 | else 146 | warning "No collection for 'image/jpeg', won't test media posting." 147 | end 148 | end 149 | 150 | def test_media_linkage(coll) 151 | info "TESTING: Media collection re-ordering after PUT." 152 | 153 | # We'll post three mini entries to the collection 154 | data = Samples.picture 155 | poster = Poster.new(coll.href, @authent) 156 | ['One', 'Two', 'Three'].each do |num| 157 | slug = "Picture #{num}" 158 | poster.set_header('Slug', slug) 159 | name = "Posting pic #{num}" 160 | worked = poster.post('image/jpeg', data) 161 | save_dialog(name, poster) 162 | if !worked 163 | error("Can't POST Picture #{num}: #{poster.last_error}", name) 164 | return 165 | end 166 | sleep 2 167 | end 168 | 169 | # grab the collection to gather the MLE ids 170 | entries = Feed.read(coll.href, 'Pictures from multi-post', self, true) 171 | if entries.size < 3 172 | error "Pictures apparently not in collection" 173 | return 174 | end 175 | 176 | ids = entries.map { |e| e.child_content('id)') } 177 | 178 | # let's update one of them; have to fetch it first to get the ETag 179 | two_media = entries[1].link('edit-media') 180 | if !two_media 181 | error "Second entry from feed doesn't have an 'edit-media' link." 182 | return 183 | end 184 | two_resp = check_resource(two_media, 'Fetch image to get ETag', 'image/jpeg', true) 185 | unless two_resp 186 | error "Can't fetch image to get ETag" 187 | return 188 | end 189 | etag = two_resp.header 'etag' 190 | 191 | putter = Putter.new(two_media, @authent) 192 | putter.set_header('If-Match', etag) 193 | 194 | name = 'Updating one of three pix with PUT' 195 | if putter.put('image/jpeg', data) 196 | good "Update one of newly posted pictures went OK." 197 | else 198 | save_dialog(name, putter) 199 | error("Can't update picture at #{two_media}", name) 200 | return 201 | end 202 | 203 | # now the order should have changed 204 | wanted = [ ids[2], ids[0], ids[1] ] 205 | entries = Feed.read(coll.href, 'MLEs post-update', self, true) 206 | entries.each do |from_feed| 207 | want = wanted.pop 208 | unless from_feed.child_content('id').eql?(want) 209 | error "Updating bits failed to re-order link entries in media collection." 210 | return 211 | end 212 | 213 | # next to godliness 214 | delete_entry(from_feed) 215 | 216 | break if wanted.empty? 217 | end 218 | good "Entries correctly ordered after update of multi-post." 219 | 220 | end 221 | 222 | def test_sanitization(coll) 223 | info "TESTING: Content sanitization" 224 | 225 | poster = Poster.new(coll.href, @authent) 226 | name = 'Posting unclean XHTML' 227 | worked = poster.post(Names::AtomEntryMediaType, Samples.unclean_xhtml_entry) 228 | if !worked 229 | save_dialog(name, poster) 230 | error("Can't POST unclean XHTML: #{poster.last_error}", name) 231 | return 232 | end 233 | 234 | location = poster.header('Location') 235 | name = "Retrieval of unclean XHTML entry" 236 | entry = check_resource(location, name, Names::AtomMediaType) 237 | return unless entry 238 | 239 | begin 240 | entry = Entry.new(entry.body, location) 241 | rescue REXML::ParseException 242 | prob = $!.to_s.gsub(/\n/, '
') 243 | error "New entry is not well-formed: #{prob}" 244 | return 245 | end 246 | 247 | no_problem = true 248 | patterns = { 249 | '//xhtml:script' => "Published entry retains xhtml:script element.", 250 | '//*[@background]' => "Published entry retains 'background' attribute.", 251 | '//*[@style]' => "Published entry retains 'style' attribute.", 252 | 253 | } 254 | patterns.each { |xp, message| warning(message) unless entry.xpath_match(xp).empty? } 255 | 256 | entry.xpath_match('//xhtml:a').each do |a| 257 | if a.attributes['href'] =~ /^([a-zA-Z]+):/ 258 | if $1 != 'http' 259 | no_problem = false 260 | warning "Published entry retains dangerous hyperlink: '#{a.attributes['href']}'." 261 | end 262 | end 263 | end 264 | 265 | delete_entry(entry) 266 | 267 | good "Published entry appears to be sanitized." if no_problem 268 | end 269 | 270 | def test_sorting(coll) 271 | 272 | info "TESTING: Collection re-ordering after PUT." 273 | 274 | # We'll post three mini entries to the collection 275 | poster = Poster.new(coll.href, @authent) 276 | ['One', 'Two', 'Three'].each do |num| 277 | sleep 2 278 | text = Samples.mini_entry.gsub('Mini-1', "Mini #{num}") 279 | name = "Posting Mini #{num}" 280 | worked = poster.post(Names::AtomEntryMediaType, text) 281 | save_dialog(name, poster) 282 | if !worked 283 | error("Can't POST Mini #{name}: #{poster.last_error}", name) 284 | return 285 | end 286 | end 287 | 288 | # now let's grab the collection & check the order 289 | wanted = ['Mini One', 'Mini Two', 'Mini Three'] 290 | two = nil 291 | entries = Feed.read(coll.href, 'Entries with multi-post', self, true) 292 | entries.each do |from_feed| 293 | want = wanted.pop 294 | unless from_feed.child_content('title').index(want) 295 | error "Entries feed out of order after multi-post." 296 | return 297 | end 298 | two = from_feed if want == 'Mini Two' 299 | break if wanted.empty? 300 | end 301 | good "Entries correctly ordered after multi-post." 302 | 303 | # let's update one of them; have to fetch it first to get the ETag 304 | link = two.link('edit', self) 305 | unless link 306 | error "Can't check entry without edit link, entry id: #{two.get_child('id/text()')}" 307 | return 308 | end 309 | two_resp = check_resource(link, 'fetch two', Names::AtomMediaType, false) 310 | 311 | correctly_ordered = false 312 | if two_resp 313 | etag = two_resp.header 'etag' 314 | 315 | putter = Putter.new(link, @authent) 316 | putter.set_header('If-Match', etag) 317 | 318 | name = 'Updating mini-entry with PUT' 319 | sleep 2 320 | updated = two_resp.body.gsub('Mini Two', 'Mini-4') 321 | unless putter.put(Names::AtomEntryMediaType, updated) 322 | save_dialog(name, putter) 323 | error("Can't update mini-entry at #{link}", name) 324 | return 325 | end 326 | # now the order should have changed 327 | wanted = ['Mini One', 'Mini Three', 'Mini-4'] 328 | correctly_ordered = true 329 | else 330 | error "Mini Two entry not received. Can't assure the correct order after update." 331 | wanted = ['Mini One', 'Mini Two', 'Mini Three'] 332 | end 333 | 334 | entries = Feed.read(coll.href, 'Entries post-update', self, true) 335 | entries.each do |from_feed| 336 | want = wanted.pop 337 | unless from_feed.child_content('title').index(want) 338 | error "Entries feed out of order after update of multi-post." 339 | return 340 | end 341 | 342 | # next to godliness 343 | delete_entry(from_feed) 344 | 345 | break if wanted.empty? 346 | end 347 | good "Entries correctly ordered after update of multi-post." if correctly_ordered 348 | 349 | end 350 | 351 | def test_entry_posts(entry_collection) 352 | 353 | collection_uri = entry_collection.href 354 | entries = Feed.read(collection_uri, 'Entry collection', self) 355 | 356 | # * List the current entries, remember which IDs we've seen 357 | info "TESTING: Entry-posting basics." 358 | ids = [] 359 | unless entries.empty? 360 | start_list "Now in the Entries feed" 361 | entries.each do |entry| 362 | list_item entry.summarize 363 | ids << entry.child_content('id') 364 | end 365 | end_list 366 | end 367 | 368 | # Setting up to post a new entry 369 | poster = Poster.new(collection_uri, @authent) 370 | if poster.last_error 371 | error("Unacceptable URI for '#{entry_collection.title}' collection: " + 372 | poster.last_error) 373 | return 374 | end 375 | 376 | my_entry = Entry.new(Samples.basic_entry) 377 | 378 | # ask it to use this in the URI 379 | slug_num = rand(100000) 380 | slug = "ape-#{slug_num}" 381 | slug_re = %r{ape.?#{slug_num}} 382 | poster.set_header('Slug', slug) 383 | 384 | # add some categories to the entry, and remember which 385 | @cats = Categories.add_cats(my_entry, entry_collection, @authent, self) 386 | 387 | # * OK, post it 388 | worked = poster.post(Names::AtomEntryMediaType, my_entry.to_s) 389 | name = 'Posting new entry' 390 | save_dialog(name, poster) 391 | if !worked 392 | error("Can't POST new entry: #{poster.last_error}", name) 393 | return 394 | end 395 | 396 | location = poster.header('Location') 397 | unless location 398 | error("No Location header upon POST creation", name) 399 | return 400 | end 401 | good("Posting of new entry to the Entries collection " + 402 | "reported success, Location: #{location}", name) 403 | 404 | info "Examining the new entry as returned in the POST response" 405 | check_new_entry(my_entry, poster.entry, "Returned entry") if poster.entry 406 | 407 | # * See if the Location uri can be retrieved, and check its consistency 408 | name = "Retrieval of newly created entry" 409 | new_entry = check_resource(location, name, Names::AtomMediaType) 410 | return unless new_entry 411 | 412 | # Grab its etag 413 | etag = new_entry.header 'etag' 414 | 415 | info "Examining the new entry as retrieved using Location header in POST response:" 416 | 417 | begin 418 | new_entry = Entry.new(new_entry.body, location) 419 | rescue REXML::ParseException 420 | prob = $!.to_s.gsub(/\n/, '
') 421 | error "New entry is not well-formed: #{prob}" 422 | return 423 | end 424 | 425 | # * See if the slug was used 426 | slug_used = false 427 | new_entry.alt_links.each do |a| 428 | href = a.attributes['href'] 429 | if href && href.index(slug_re) 430 | slug_used = true 431 | end 432 | end 433 | if slug_used 434 | good "Client-provided slug '#{slug}' was used in server-generated URI." 435 | else 436 | warning "Client-provided slug '#{slug}' not used in server-generated URI." 437 | end 438 | 439 | check_new_entry(my_entry, new_entry, "Retrieved entry") 440 | 441 | entry_id = new_entry.child_content('id') 442 | 443 | # * fetch the feed again and check that version 444 | from_feed = find_entry(collection_uri, "entry collection", entry_id) 445 | if from_feed.class == String 446 | good "About to check #{collection_uri}" 447 | Feed.read(collection_uri, "Can't find entry in collection", self) 448 | error "New entry didn't show up in the collections feed." 449 | return 450 | end 451 | 452 | info "Examining the new entry as it appears in the collection feed:" 453 | 454 | # * Check the entry from the feed 455 | check_new_entry(my_entry, from_feed, "Entry from collection feed") 456 | 457 | edit_uri = new_entry.link('edit', self) 458 | if !edit_uri 459 | error "Entry from Location header has no edit link." 460 | return 461 | end 462 | 463 | # * Update the entry, see if the update took 464 | name = 'In-place update with put' 465 | putter = Putter.new(edit_uri, @authent) 466 | 467 | # Conditional PUT if an etag 468 | putter.set_header('If-Match', etag) if etag 469 | 470 | new_title = "Let’s all do the Ape!" 471 | new_text = Samples.retitled_entry(new_title, entry_id) 472 | response = putter.put(Names::AtomEntryMediaType, new_text) 473 | save_dialog(name, putter) 474 | 475 | if response 476 | good("Update of new entry reported success.", name) 477 | from_feed = find_entry(collection_uri, "entry collection", entry_id) 478 | if from_feed.class == String 479 | check_resource(collection_uri, "Check collection after lost update", nil, true) 480 | error "Updated entry ID #{entry_id} not found in entries collection." 481 | return 482 | end 483 | if from_feed.child_content('title') == new_title 484 | good "Title of new entry successfully updated." 485 | else 486 | warning "After PUT update of title, Expected " + 487 | "'#{new_title}', but saw '#{from_feed.child_content('title')}'" 488 | end 489 | else 490 | warning("Can't update new entry with PUT: #{putter.last_error}", name) 491 | end 492 | 493 | # the edit-uri might have changed 494 | return unless delete_entry(from_feed, 'New Entry deletion') 495 | 496 | # See if it's gone from the feed 497 | still_there = find_entry(collection_uri, "entry collection", entry_id) 498 | if still_there.class != String 499 | error "Entry is still in collection post-deletion." 500 | else 501 | good "Entry not found in feed after deletion." 502 | end 503 | 504 | end 505 | 506 | def test_media_posts media_collection 507 | 508 | info "TESTING: Posting to media collection." 509 | 510 | # * Post a picture to the media collection 511 | # 512 | poster = Poster.new(media_collection, @authent) 513 | if poster.last_error 514 | error("Unacceptable URI for '#{media_coll.title}' collection: " + 515 | poster.last_error) 516 | return 517 | end 518 | 519 | name = 'Post image to media collection' 520 | 521 | # ask it to use this in the URI 522 | slug_num = rand(100000) 523 | slug = "apix-#{slug_num}" 524 | slug_re = %r{apix.?#{slug_num}} 525 | poster.set_header('Slug', slug) 526 | 527 | #poster.set_header('Slug', slug) 528 | worked = poster.post('image/jpeg', Samples.picture) 529 | save_dialog(name, poster) 530 | if !worked 531 | error("Can't POST picture to media collection: #{poster.last_error}", 532 | name) 533 | return 534 | end 535 | 536 | good("Post of image file reported success, media link location: " + 537 | "#{poster.header('Location')}", name) 538 | 539 | # * Retrieve the media link entry 540 | mle_uri = poster.header('Location') 541 | 542 | media_link_entry = check_resource(mle_uri, 'Retrieval of media link entry', Names::AtomMediaType) 543 | return unless media_link_entry 544 | 545 | if media_link_entry.last_error 546 | error "Can't proceed with media-post testing." 547 | return 548 | end 549 | 550 | # * See if the ') 555 | error "Media link entry is not well-formed: #{prob}" 556 | return 557 | end 558 | content_src = media_link_entry.content_src 559 | if (!content_src) || (content_src == "") 560 | error "Media link entry has no content@src pointer to media resource." 561 | return 562 | end 563 | 564 | # see if slug was used in media URI 565 | if content_src =~ slug_re 566 | good "Client-provided slug '#{slug}' was used in Media Resource URI." 567 | else 568 | warning "Client-provided slug '#{slug}' not used in Media Resource URI." 569 | end 570 | 571 | media_link_id = media_link_entry.child_content('id') 572 | 573 | name = 'Retrieval of media resource' 574 | picture = check_resource(content_src, name, 'image/jpeg') 575 | return unless picture 576 | 577 | if picture.body == Samples.picture 578 | good "Media resource was apparently stored and retrieved properly." 579 | else 580 | warning "Media resource differs from posted picture" 581 | end 582 | 583 | # * Delete the media link entry 584 | return unless delete_entry(media_link_entry, 'Deletion of media link entry') 585 | 586 | # * media link entry still in feed? 587 | still_there = find_entry(media_collection, "media collection", media_link_id) 588 | if still_there.class != String 589 | error "Media link entry is still in collection post-deletion." 590 | else 591 | good "Media link entry no longer in feed." 592 | end 593 | 594 | # is the resource there any more? 595 | name = 'Check Media Resource deletion' 596 | if check_resource(content_src, name, 'image/jpeg', false) 597 | error "Media resource still there after media link entry deletion." 598 | else 599 | good "Media resource no longer fetchable." 600 | end 601 | 602 | end 603 | 604 | def check_new_entry(as_posted, new_entry, desc) 605 | 606 | if compare_entries(as_posted, new_entry, "entry as posted", desc) 607 | good "#{desc} is consistent with posted entry." 608 | end 609 | 610 | # * See if the categories we sent made it in 611 | cat_probs = false 612 | @cats.each do |cat| 613 | if !new_entry.has_cat(cat) 614 | cat_probs = true 615 | warning "Provided category not in #{desc}: #{cat}" 616 | end 617 | end 618 | good "Provided categories included in #{desc}." unless cat_probs 619 | 620 | # * See if the dc:subject survived 621 | dc_subject = new_entry.child_content(Samples.foreign_child, Samples.foreign_namespace) 622 | if dc_subject 623 | if dc_subject == Samples.foreign_child_content 624 | good "Server preserved foreign markup in #{desc}." 625 | else 626 | warning "Server altered content of foreign markup in #{desc}." 627 | end 628 | else 629 | warning "Server discarded foreign markup in #{desc}." 630 | end 631 | end 632 | 633 | # 634 | # End of tests; support functions from here down 635 | # 636 | 637 | # Fetch a feed and look up an entry by ID in it 638 | def find_entry(feed_uri, name, id, report=false) 639 | entries = Feed.read(feed_uri, name, self, report) 640 | entries.each do |from_feed| 641 | return from_feed if id == from_feed.child_content('id') 642 | end 643 | 644 | return "Couldn't find id #{id} in feed #{feed_uri}" 645 | end 646 | 647 | # remember the dialogue that the get/put/post/delete actor recorded 648 | def save_dialog(name, actor) 649 | @dialogs[name] = actor.crumbs if @dialogs 650 | end 651 | 652 | # Get a resource, optionally check its content-type 653 | def check_resource(uri, name, content_type, report=true) 654 | resource = Getter.new(uri, @authent) 655 | 656 | # * Check the URI 657 | if resource.last_error 658 | error("Unacceptable #{name} URI: " + resource.last_error, name) if report 659 | return nil 660 | end 661 | 662 | # * Get it, make sure it has the right content-type 663 | worked = resource.get(content_type) 664 | @dialogs[name] = resource.crumbs if @dialogs 665 | 666 | if (resource.security_warning and not @security_warning) 667 | @security_warning = true 668 | warning("Sending authentication information over a open channel is not a good security practice.", name) 669 | end 670 | 671 | if !worked 672 | # oops, couldn't even get get it 673 | error("#{name} failed: " + resource.last_error, name) if report 674 | return nil 675 | 676 | elsif resource.last_error 677 | # oops, media-type problem 678 | error("#{name}: #{resource.last_error}", name) if report 679 | 680 | else 681 | # resource fetched and is of right type 682 | good("#{name}: it exists and is served properly.", name) if report 683 | end 684 | 685 | return resource 686 | end 687 | 688 | # Sets the header for the report 689 | # 690 | # ==== Options 691 | # * uri - The URI of the service document. Required. 692 | def header(uri) 693 | @header = "APP Service doc: #{uri}" 694 | end 695 | 696 | def footer(message) 697 | @footer = message 698 | end 699 | 700 | def show_crumbs key 701 | @dialogs[key].each do |d| 702 | puts "D: #{d}" 703 | end 704 | end 705 | 706 | def warning(message, crumb_key=nil) 707 | @warnings += 1 708 | if @dialogs 709 | step "D#{crumb_key}" if crumb_key 710 | show_crumbs(crumb_key) if crumb_key && @@debugging 711 | end 712 | step "W" + message 713 | end 714 | 715 | def error(message, crumb_key=nil) 716 | @errors += 1 717 | if @dialogs 718 | step "D#{crumb_key}" if crumb_key 719 | show_crumbs(crumb_key) if crumb_key && @@debugging 720 | end 721 | step "E" + message 722 | end 723 | 724 | def good(message, crumb_key=nil) 725 | if @dialogs 726 | step "D#{crumb_key}" if crumb_key 727 | show_crumbs(crumb_key) if crumb_key && @@debugging 728 | end 729 | step "G" + message 730 | end 731 | 732 | # Outputs an informational +message+, prepended by an "I" 733 | # 734 | # ==== Options 735 | # * message - The message to be output. Required. 736 | def info(message) 737 | step "I" + message 738 | end 739 | 740 | def step(message) 741 | puts "PROGRESS: #{message[1..-1]}" if @@debugging 742 | @steps << message 743 | end 744 | 745 | def start_list(message) 746 | step [ message + ":" ] 747 | end 748 | 749 | def list_item(message) 750 | @steps[-1] << message 751 | end 752 | 753 | def end_list 754 | end 755 | 756 | def line 757 | printf "%2d. ", @lnum 758 | @lnum += 1 759 | end 760 | 761 | def report(output=STDOUT) 762 | if @output == 'text' 763 | report_text output 764 | else 765 | report_html output 766 | end 767 | end 768 | 769 | def report_html(output=STDOUT) 770 | dialog = nil 771 | 772 | if output == STDOUT 773 | output.puts "Status: 200 OK\r" 774 | output.puts "Content-type: text/html; charset=utf-8\r" 775 | output.puts "\r" 776 | end 777 | 778 | @w = Builder::XmlMarkup.new(:target => output) 779 | @w.html do 780 | @w.head do 781 | @w.title { @w.text! 'Atom Protocol Exerciser Report' } 782 | @w.text! "\n" 783 | @w.link(:rel => 'stylesheet', :type => 'text/css',:href => '../ape/ape.css' ) 784 | end 785 | @w.text! "\n" 786 | @w.body do 787 | @w.h2 { @w.text! 'The Ape says:' } 788 | @w.text! "\n" 789 | if @header 790 | @w.p { @w.text! @header } 791 | @w.p do 792 | @w.text! "Summary: " 793 | @w.text!((@errors == 1) ? '1 error, ' : "#{@errors} errors, ") 794 | @w.text!((@warnings == 1) ? '1 warning.' : "#{@warnings} warnings.") 795 | end 796 | @w.text! "\n" 797 | end 798 | @w.ol do 799 | @w.text! "\n" 800 | @steps.each do |step| 801 | if step.kind_of? Array 802 | # it's a list; no dialog applies 803 | @w.li do 804 | @w.p do 805 | write_mark :info 806 | @w.text! " #{step[0]}\n" 807 | end 808 | @w.ul do 809 | step[1 .. -1].each { |li| report_li(nil, nil, li) } 810 | end 811 | @w.text! "\n" 812 | end 813 | else 814 | body = step[1 .. -1] 815 | opcode = step[0,1] 816 | if opcode == "D" 817 | dialog = body 818 | else 819 | case opcode 820 | when "W" then report_li(dialog, :question, body) 821 | when "E" then report_li(dialog, :exclamation, body) 822 | when "G" then report_li(dialog, :check, body) 823 | when "I" then report_li(dialog, :info, body) 824 | else 825 | line 826 | puts "HUH? #{step}" 827 | end 828 | dialog = nil 829 | end 830 | end 831 | end 832 | end 833 | 834 | @w.text! "\n" 835 | if @footer then @w.p { @w.text! @footer } end 836 | @w.text! "\n" 837 | 838 | #unless @dialog.nil? 839 | if @dialogs 840 | @w.h2 { @w.text! 'Recorded client/server dialogs' } 841 | @w.text! "\n" 842 | @diarefs.each do |k, v| 843 | dialog = @dialogs[k] 844 | @w.h3(:id => "dia-#{v}") do 845 | @w.text! k 846 | end 847 | @w.div(:class => 'dialog') do 848 | 849 | @w.div(:class => 'dialab') do 850 | @w.text! "\nTo server:\n" 851 | dialog.grep(/^>/).each { |crumb| show_message(crumb, :to) } 852 | end 853 | @w.div( :class => 'dialab' ) do 854 | @w.text! "\nFrom Server:\n" 855 | dialog.grep(/^ 'diaref', :href => "#dia-#{@dianum}") do 881 | @w.text! ' [Dialog]' 882 | end 883 | @diarefs[dialog] = @dianum 884 | @dianum += 1 885 | end 886 | end 887 | end 888 | @w.text! "\n" 889 | end 890 | 891 | def show_message(crumb, tf) 892 | message = crumb[1 .. -1] 893 | message.gsub!(/^\s*"/, '') 894 | message.gsub!(/"\s*$/, '') 895 | message.gsub!(/\\"/, '"') 896 | message = Escaper.escape message 897 | message.gsub!(/\\n/, "\n
") 898 | message.gsub!(/\\t/, '    ') 899 | @w.div(:class => tf) { @w.target! << message } 900 | end 901 | 902 | def report_text(output=STDOUT) 903 | output.puts @header if @header 904 | @steps.each do |step| 905 | if step.class == Crumbs 906 | output.puts " Dialog:" 907 | step.each { |crumb| output.puts " #{crumb}" } 908 | else 909 | body = step[1 .. -1] 910 | case step[0,1] 911 | when "W" 912 | line 913 | output.puts "WARNING: #{body}" 914 | when "E" 915 | line 916 | output.puts "ERROR: #{body}" 917 | when "G" 918 | line 919 | output.puts body 920 | when "L" 921 | line 922 | output.puts body 923 | when "e" 924 | # no-op 925 | when "I" 926 | output.puts " #{body}" 927 | when "D" 928 | # later, dude 929 | else 930 | line 931 | output.puts "HUH? #{body}" 932 | end 933 | end 934 | output.puts @footer if @footer 935 | end 936 | end 937 | 938 | def compare_entries(e1, e2, e1Name, e2Name) 939 | problems = 0 940 | [ 'title', 'summary', 'content' ].each do |field| 941 | problems += 1 if compare1(e1, e2, e1Name, e2Name, field) 942 | end 943 | return problems == 0 944 | end 945 | 946 | def compare1(e1, e2, e1Name, e2Name, field) 947 | c1 = e1.child_content(field) 948 | c2 = e2.child_content(field) 949 | if c1 != c2 950 | problem = true 951 | if c1 == nil 952 | warning "'#{field}' absent in #{e1Name}." 953 | elsif c2 == nil 954 | warning "'#{field}' absent in #{e2Name}." 955 | else 956 | t1 = e1.child_type(field) 957 | t2 = e2.child_type(field) 958 | if t1 != t2 959 | warning "'#{field}' has type='#{t1}' " + 960 | "in #{e1Name}, type='#{t2}' in #{e2Name}." 961 | else 962 | c1 = Escaper.escape(c1) 963 | c2 = Escaper.escape(c2) 964 | warning "'#{field}' in #{e1Name} [#{c1}] " + 965 | "differs from that in #{e2Name} [#{c2}]." 966 | end 967 | end 968 | end 969 | return problem 970 | end 971 | 972 | def write_mark(mark) 973 | case mark 974 | when :check 975 | @w.span(:class => 'good') { @w.target << '✓' } 976 | when :question 977 | @w.span(:class => 'warning') { @w.text! '?' } 978 | when :exclamation 979 | @w.span(:class => 'error') { @w.text! '!' } 980 | when :info 981 | @w.img(:align => 'top', :src => '../ape/info.png') 982 | end 983 | end 984 | 985 | def delete_entry(entry, name = nil) 986 | link = entry.link('edit', self) 987 | unless link 988 | error "Can't delete entry without edit link" 989 | return false 990 | end 991 | deleter = Deleter.new(link, @authent) 992 | worked = deleter.delete 993 | 994 | save_dialog(name, deleter) if name 995 | if worked 996 | good("Entry deletion reported success.", name) 997 | else 998 | error("Couldn't delete the entry: " + deleter.last_error, name) 999 | end 1000 | return worked 1001 | end 1002 | end 1003 | end 1004 | 1005 | --------------------------------------------------------------------------------