The projects controller gets more RESTy. It now supports XML, RSS, ATOM, HTML and plain text views of the projects list.

Changes include:

 * Add assert_xml_select method for testing RSS and ATOM results (Thanks, Jamis! http://weblog.jamisbuck.org/2007/1/4/assert_xml_select)
 * Add resource_feeder plugin for generating RSS and ATOM feeds
 * Update the URL on the Feeds page to use /projects.rss or /projects.txt instead of FeedController link
 * Add created_at and updated_at timestamps to project table to support ATOM feeds
 * Added new filter to login_system "login_or_feed_token_required" to allow RSS, ATOM or text requests with token-based authentication 
 
Notes:
 * This will break previous project listing feed subscriptions.
 * RSS, ATOM & text feeds are available via session or HTTP_BASIC authentication, or by passing the user's token on the url; HTML and XML results are only available via session or HTTP_BASIC authentication
 


git-svn-id: http://www.rousette.org.uk/svn/tracks-repos/trunk@415 a4c988fc-2ded-0310-b66e-134b36920a42
This commit is contained in:
lukemelia 2007-02-01 05:32:05 +00:00
parent fda7788237
commit d9d5ff4d06
35 changed files with 735 additions and 94 deletions

View file

@ -62,6 +62,26 @@ class ApplicationController < ActionController::Base
end
end
# Returns a count of next actions in the given context or project
# The result is count and a string descriptor, correctly pluralised if there are no
# actions or multiple actions
#
def count_undone_todos(todos_parent, string="actions")
if (todos_parent.is_a?(Project) && todos_parent.hidden?)
count = eval "@project_project_hidden_todo_counts[#{todos_parent.id}]"
else
count = eval "@#{todos_parent.class.to_s.downcase}_not_done_counts[#{todos_parent.id}]"
end
count = 0 if count == nil
#count = todos_parent.todos.select{|t| !t.done }.size
if count == 1
word = string.singularize
else
word = string.pluralize
end
return count.to_s + " " + word
end
protected
def admin_login_required
@ -101,7 +121,7 @@ class ApplicationController < ActionController::Base
parents.each do |parent|
eval("@#{parent}_project_hidden_todo_counts = Todo.count(:conditions => ['user_id = ? and state = ?', @user.id, 'project_hidden'], :group => :#{parent}_id)")
end
end
end
# Set the contents of the flash message from a controller
# Usage: notify :warning, "This is the message"

View file

@ -45,14 +45,6 @@ class FeedController < ApplicationController
end
headers["Content-Type"] = "text/calendar"
end
def list_projects_only
init_not_done_counts('project')
init_project_hidden_todo_counts
@projects = @user.projects
@description = "Lists all the projects for #{@user.login}."
render :action => 'projects_' + params['feedtype']
end
def list_contexts_only
init_not_done_counts('context')

View file

@ -1,14 +1,31 @@
class ProjectsController < ApplicationController
helper :todos, :notes
helper :application, :todos, :notes
before_filter :init, :except => [:create, :destroy, :order]
skip_before_filter :login_required, :only => [:index]
prepend_before_filter :login_or_feed_token_required, :only => [:index]
session :off, :only => :index, :if => Proc.new { |req| ['rss','atom','txt'].include?(req.parameters[:format]) }
def index
init_project_hidden_todo_counts
@page_title = "TRACKS::List Projects"
respond_to do |wants|
wants.html
wants.xml { render :xml => @projects.to_xml( :except => :user_id ) }
respond_to do |format|
format.html do
init_project_hidden_todo_counts
@page_title = "TRACKS::List Projects"
render
end
format.xml { render :xml => @projects.to_xml( :except => :user_id ) }
format.rss do
render_rss_feed_for @projects, :feed => Project.feed_options(@user),
:item => { :description => lambda { |p| p.summary(count_undone_todos(p)) } }
end
format.atom do
render_atom_feed_for @projects, :feed => Project.feed_options(@user),
:item => { :description => lambda { |p| p.summary(count_undone_todos(p)) },
:author => lambda { |p| nil } }
end
format.text do
render :action => 'index_text', :layout => false, :content_type => Mime::TEXT
end
end
end

View file

@ -12,6 +12,7 @@ class UsersController < ApplicationController
# GET /users
# GET /users.xml
def index
@users = User.find(:all, :order => 'login')
respond_to do |format|
format.html do
@page_title = "TRACKS::Manage Users"
@ -21,10 +22,7 @@ class UsersController < ApplicationController
# we store the URL so that we get returned here when signup is successful
store_location
end
format.xml do
@users = User.find(:all)
render :xml => @users.to_xml(:except => [ :password ])
end
format.xml { render :xml => @users.to_xml(:except => [ :password ]) }
end
end

View file

@ -113,19 +113,7 @@ module ApplicationHelper
# actions or multiple actions
#
def count_undone_todos(todos_parent, string="actions")
if (todos_parent.is_a?(Project) && todos_parent.hidden?)
count = eval "@project_project_hidden_todo_counts[#{todos_parent.id}]"
else
count = eval "@#{todos_parent.class.to_s.downcase}_not_done_counts[#{todos_parent.id}]"
end
count = 0 if count == nil
#count = todos_parent.todos.select{|t| !t.done }.size
if count == 1
word = string.singularize
else
word = string.pluralize
end
return count.to_s + " " + word
@controller.count_undone_todos(todos_parent, string)
end
def link_to_context(context, descriptor = sanitize(context.name))

View file

@ -31,20 +31,6 @@ module FeedHelper
return result_string
end
def build_projects_text_page(projects)
result_string = ""
projects.each do |p|
result_string << "\n" + p.name.upcase + "\n"
result_string << p.description + "\n" if p.description_present?
result_string << "#{count_undone_todos(p)}. Project is #{p.state}.\n"
result_string << "#{p.linkurl}\n" if p.linkurl_present?
result_string << "\n"
end
return result_string
end
def build_contexts_text_page(contexts)
result_string = ""
contexts.each do |c|

View file

@ -6,12 +6,26 @@ module FeedlistHelper
linkoptions.merge!(options)
link_to(image_tag, linkoptions, :title => "RSS feed")
end
def rss_formatted_link(options = {})
image_tag = image_tag("feed-icon.png", :size => "16X16", :border => 0, :class => "rss-icon")
linkoptions = { :token => @user.word, :format => 'rss' }
linkoptions.merge!(options)
link_to(image_tag, linkoptions, :title => "RSS feed")
end
def text_feed_link(options = {})
linkoptions = {:controller => 'feed', :action => 'text', :login => "#{@user.login}", :token => "#{@user.word}"}
linkoptions.merge!(options)
link_to('<span class="feed">TXT</span>', linkoptions, :title => "Plain text feed" )
end
def text_formatted_link(options = {})
linkoptions = { :token => @user.word, :format => 'txt' }
linkoptions.merge!(options)
link_to('<span class="feed">TXT</span>', linkoptions, :title => "Plain text feed" )
end
def ical_feed_link(options = {})
linkoptions = {:controller => 'feed', :action => 'ical', :login => "#{@user.login}", :token => "#{@user.word}"}

View file

@ -37,6 +37,13 @@ class Project < ActiveRecord::Base
NullProject.new
end
def self.feed_options(user)
{
:title => 'Tracks Projects',
:description => "Lists all the projects for #{user.display_name}."
}
end
def to_param
url_friendly_name
end
@ -48,6 +55,20 @@ class Project < ActiveRecord::Base
def linkurl_present?
attribute_present?("linkurl")
end
def title
name
end
def summary(undone_todo_count)
project_description = ''
project_description += sanitize(markdown( description )) if description_present?
project_description += "<p>#{undone_todo_count}. "
project_description += "Project is #{state}. "
project_description += "<a href=\"#{linkurl}\">#{linkurl}</a>" if linkurl_present?
project_description += "</p>"
project_description
end
def hide_todos
todos.each do |t|

View file

@ -8,12 +8,12 @@ class User < ActiveRecord::Base
:order => 'position ASC',
:dependent => :delete_all
has_many :todos,
:order => 'completed_at DESC, created_at DESC',
:order => 'completed_at DESC, todos.created_at DESC',
:dependent => :delete_all
has_many :deferred_todos,
:class_name => 'Todo',
:conditions => [ 'state = ?', 'deferred' ],
:order => 'show_from ASC, created_at DESC' do
:order => 'show_from ASC, todos.created_at DESC' do
def find_and_activate_ready
find(:all, :conditions => ['show_from <= ?', Time.now.utc.to_date ]).collect { |t| t.activate_and_save! }
end

View file

@ -1,21 +0,0 @@
@headers["Content-Type"] = "text/xml; charset=utf-8"
xml.rss("version" => "2.0", "xmlns:dc" => "http://purl.org/dc/elements/1.1/") do
xml.channel do
xml.title(@title)
xml.link(projects_url)
xml.description(@description)
@projects.each do |p|
xml.item do
xml.title(p.name)
xml.link(project_url(p))
project_description = ''
project_description += sanitize(markdown( p.description )) if p.description_present?
project_description += "<p>#{count_undone_todos(p)}. "
project_description += "Project is #{p.state}. "
project_description += "<a href=\"#{p.linkurl}\">#{p.linkurl}</a>" if p.linkurl_present?
project_description += "</p>"
xml.description(project_description)
end
end
end
end

View file

@ -1,2 +0,0 @@
<% @headers["Content-Type"] = "text/plain; charset=utf-8" -%>
<%= build_projects_text_page( @projects ) -%>

View file

@ -45,8 +45,8 @@
All Contexts
</li>
<li>
<%= rss_feed_link({ :action => 'list_projects_only', :feedtype => 'rss' }) %>
<%= text_feed_link({ :action => 'list_projects_only', :feedtype => 'text' }) %>
<%= rss_formatted_link({:controller => 'projects', :action => 'index'}) %>
<%= text_formatted_link({:controller => 'projects', :action => 'index'}) %>
All Projects
</li>
<li><h4>Feeds for uncompleted actions in a specific context:</h4>

View file

@ -0,0 +1,7 @@
<% @projects.each do |p| -%>
<%= p.name.upcase %>
<%= p.description + "\n" if p.description_present? -%>
<%= count_undone_todos(p)%>. Project is <%= p.state %>.
<%= p.linkurl + "\n" if p.linkurl_present? -%>
<% end -%>

View file

@ -35,7 +35,7 @@
<% end -%>
<% if item.completed? -%>
(<%= item.context.name %><%= ", " + item.project.name if item.project_id %>)
(<%= item.context.name %><%= ", " + item.project.name unless item.project.nil? %>)
<% else -%>
<% if (parent_container_type == "project" || parent_container_type == "tickler") -%>
<%= item_link_to_context( item ) %>

View file

@ -36,7 +36,6 @@ ActionController::Routing::Routes.draw do |map|
# Projects Routes
map.resources :projects, :collection => {:order => :post}
map.connect 'project/:project/feed/:action/:login/:token', :controller => 'feed'
map.connect 'projects/feed/:feedtype/:login/:token', :controller => 'feed', :action => 'list_projects_only'
# Notes Routes
map.resources :notes

View file

@ -0,0 +1,12 @@
class AddProjectTimestamps < ActiveRecord::Migration
def self.up
add_column :projects, :created_at, :timestamp
add_column :projects, :updated_at, :timestamp
end
def self.down
remove_column :projects, :created_at
remove_column :projects, :updated_at
end
end

View file

@ -2,7 +2,7 @@
# migrations feature of ActiveRecord to incrementally modify your database, and
# then regenerate this schema definition.
ActiveRecord::Schema.define(:version => 25) do
ActiveRecord::Schema.define(:version => 26) do
create_table "contexts", :force => true do |t|
t.column "name", :string, :default => "", :null => false
@ -60,11 +60,13 @@ ActiveRecord::Schema.define(:version => 25) do
add_index "preferences", ["user_id"], :name => "index_preferences_on_user_id"
create_table "projects", :force => true do |t|
t.column "name", :string, :default => "", :null => false
t.column "position", :integer, :default => 0, :null => false
t.column "user_id", :integer, :default => 0, :null => false
t.column "name", :string, :default => "", :null => false
t.column "position", :integer, :default => 0, :null => false
t.column "user_id", :integer, :default => 0, :null => false
t.column "description", :text
t.column "state", :string, :limit => 20, :default => "active", :null => false
t.column "state", :string, :limit => 20, :default => "active", :null => false
t.column "created_at", :datetime
t.column "updated_at", :datetime
end
add_index "projects", ["user_id"], :name => "index_projects_on_user_id"

View file

@ -30,6 +30,16 @@ module LoginSystem
def protect?(action)
true
end
def login_or_feed_token_required
if ['rss', 'atom', 'txt'].include?(params[:format])
if user = User.find_by_word(params[:token])
set_current_user(user)
return true
end
end
login_required
end
# login_required filter. add
#
@ -53,7 +63,7 @@ module LoginSystem
http_user, http_pass = get_basic_auth_data
if user = User.authenticate(http_user, http_pass)
session['user_id'] = user.id
get_current_user
set_current_user(user)
return true
end
@ -75,7 +85,7 @@ module LoginSystem
http_user, http_pass = get_basic_auth_data
if user = User.authenticate(http_user, http_pass)
session['user_id'] = user.id
get_current_user
set_current_user(user)
return true
end
@ -89,6 +99,12 @@ module LoginSystem
@prefs = @user.prefs unless @user.nil?
@user
end
def set_current_user(user)
@user = user
@prefs = @user.prefs unless @user.nil?
@user
end
# overwrite if you want to have special behavior in case the user is not authorized
# to access the current operation.
@ -96,10 +112,13 @@ module LoginSystem
# example use :
# a popup window might just close itself for instance
def access_denied
respond_to do |wants|
wants.html { redirect_to :controller=>"login", :action =>"login" }
wants.js { render :partial => 'login/redirect_to_login' }
wants.xml { basic_auth_denied }
respond_to do |format|
format.html { redirect_to :controller=>"login", :action =>"login" }
format.js { render :partial => 'login/redirect_to_login' }
format.xml { basic_auth_denied }
format.rss { basic_auth_denied }
format.atom { basic_auth_denied }
format.text { basic_auth_denied }
end
end

View file

@ -1,4 +1,11 @@
# Read about fixtures at http://ar.rubyonrails.org/classes/Fixtures.html
<%
def today
Time.now.utc.to_s(:db)
end
%>
timemachine:
id: 1
@ -7,6 +14,8 @@ timemachine:
position: 1
state: 'active'
user_id: 1
created_at: <%= today %>
updated_at: <%= today %>
moremoney:
id: 2
@ -15,6 +24,8 @@ moremoney:
position: 2
state: 'active'
user_id: 1
created_at: <%= today %>
updated_at: <%= today %>
gardenclean:
id: 3
@ -23,3 +34,5 @@ gardenclean:
position: 3
state: 'active'
user_id: 1
created_at: <%= today %>
updated_at: <%= today %>

View file

@ -0,0 +1,30 @@
require File.dirname(__FILE__) + '/../test_helper'
require 'feedlist_controller'
# Re-raise errors caught by the controller.
class FeedlistController; def rescue_action(e) raise e end; end
class FeedlistControllerTest < Test::Unit::TestCase
fixtures :users, :preferences, :projects, :contexts, :todos, :notes
def setup
assert_equal "test", ENV['RAILS_ENV']
assert_equal "change-me", Tracks::Config.salt
@controller = FeedlistController.new
@request = ActionController::TestRequest.new
@response = ActionController::TestResponse.new
end
def test_get_index_when_not_logged_in
get :index
assert_redirected_to :controller => 'login', :action => 'login'
end
def test_get_index_by_logged_in_user
@request.session['user_id'] = users(:other_user).id
get :index
assert_response :success
assert_equal "TRACKS::Feeds", assigns['page_title']
end
end

View file

@ -6,7 +6,7 @@ require 'projects_controller'
class ProjectsController; def rescue_action(e) raise e end; end
class ProjectsControllerTest < TodoContainerControllerTestBase
fixtures :users, :todos, :preferences, :projects
fixtures :users, :todos, :preferences, :projects, :contexts
def setup
perform_setup(Project, ProjectsController)
@ -78,5 +78,106 @@ class ProjectsControllerTest < TodoContainerControllerTestBase
assert p.reload().active?
end
def test_rss_feed_content
@request.session['user_id'] = users(:admin_user).id
get :index, { :format => "rss" }
assert_equal 'application/rss+xml; charset=utf-8', @response.headers["Content-Type"]
#puts @response.body
assert_xml_select 'rss[version="2.0"]' do
assert_xml_select 'channel' do
assert_xml_select '>title', 'Tracks Projects'
assert_xml_select '>description', "Lists all the projects for #{users(:admin_user).display_name}."
assert_xml_select 'language', 'en-us'
assert_xml_select 'ttl', '40'
end
assert_xml_select 'item', 3 do
assert_xml_select 'title', /.+/
assert_xml_select 'description', /&lt;p&gt;\d+ actions. Project is (active|hidden|completed). &lt;\/p&gt;/
%w(guid link).each do |node|
assert_xml_select node, /http:\/\/test.host\/projects\/.+/
end
assert_xml_select 'pubDate', projects(:timemachine).created_at.to_s(:rfc822)
end
end
end
def test_rss_feed_not_accessible_to_anonymous_user_without_token
@request.session['user_id'] = nil
get :index, { :format => "rss" }
assert_response 401
end
def test_rss_feed_not_accessible_to_anonymous_user_with_invalid_token
@request.session['user_id'] = nil
get :index, { :format => "rss", :token => 'foo' }
assert_response 401
end
def test_rss_feed_accessible_to_anonymous_user_with_valid_token
@request.session['user_id'] = nil
get :index, { :format => "rss", :token => users(:admin_user).word }
assert_response :ok
end
def test_atom_feed_content
@request.session['user_id'] = users(:admin_user).id
get :index, { :format => "atom" }
assert_equal 'application/atom+xml; charset=utf-8', @response.headers["Content-Type"]
#puts @response.body
assert_xml_select 'feed[xmlns="http://www.w3.org/2005/Atom"]' do
assert_xml_select '>title', 'Tracks Projects'
assert_xml_select '>subtitle', "Lists all the projects for #{users(:admin_user).display_name}."
assert_xml_select 'entry', 3 do
assert_xml_select 'title', /.+/
assert_xml_select 'content[type="html"]', /&lt;p&gt;\d+ actions. Project is (active|hidden|completed). &lt;\/p&gt;/
assert_xml_select 'published', projects(:timemachine).created_at.to_s(:rfc822)
end
end
end
def test_atom_feed_not_accessible_to_anonymous_user_without_token
@request.session['user_id'] = nil
get :index, { :format => "atom" }
assert_response 401
end
def test_atom_feed_not_accessible_to_anonymous_user_with_invalid_token
@request.session['user_id'] = nil
get :index, { :format => "atom", :token => 'foo' }
assert_response 401
end
def test_atom_feed_accessible_to_anonymous_user_with_valid_token
@request.session['user_id'] = nil
get :index, { :format => "atom", :token => users(:admin_user).word }
assert_response :ok
end
def test_text_feed_content
@request.session['user_id'] = users(:admin_user).id
get :index, { :format => "txt" }
assert_equal 'text/plain; charset=utf-8', @response.headers["Content-Type"]
#puts @response.body
end
def test_text_feed_not_accessible_to_anonymous_user_without_token
@request.session['user_id'] = nil
get :index, { :format => "txt" }
assert_response 401
end
def test_text_feed_not_accessible_to_anonymous_user_with_invalid_token
@request.session['user_id'] = nil
get :index, { :format => "txt", :token => 'foo' }
assert_response 401
end
def test_text_feed_accessible_to_anonymous_user_with_valid_token
@request.session['user_id'] = nil
get :index, { :format => "txt", :token => users(:admin_user).word }
assert_response :ok
end
end

View file

@ -80,17 +80,17 @@ class FeedSmokeTest < ActionController::IntegrationTest
end
def test_all_projects_rss
assert_success "/projects/feed/rss/admin/#{ users(:admin_user).word }"
assert_success "/projects.rss?token=#{ users(:admin_user).word }"
end
def test_all_projects_txt
assert_success "/projects/feed/text/admin/#{ users(:admin_user).word }"
assert_success "/projects.txt?token=#{ users(:admin_user).word }"
end
def test_all_projects_txt_with_hidden_project
p = projects(:timemachine)
p.hide!
assert_success "/projects/feed/text/admin/#{ users(:admin_user).word }"
assert_success "/projects.txt?token=#{ users(:admin_user).word }"
end
private

View file

@ -4,7 +4,7 @@ require 'projects_controller'
# Re-raise errors caught by the controller.
class ProjectsController; def rescue_action(e) raise e end; end
class ProjectsControllerXmlApiTest < ActionController::IntegrationTest
class ProjectXmlApiTest < ActionController::IntegrationTest
fixtures :users, :projects
@@project_name = "My New Project"
@ -51,7 +51,15 @@ class ProjectsControllerXmlApiTest < ActionController::IntegrationTest
def test_creates_new_project
initial_count = Project.count
authenticated_post_xml_to_project_create
assert_response_and_body_matches 200, %r|^<\?xml version="1\.0" encoding="UTF-8"\?>\n<project>\n <description></description>\n <id type=\"integer\">[0-9]+</id>\n <name>#{@@project_name}</name>\n <position type=\"integer\">1</position>\n <state>active</state>\n</project>$|
assert_response :success
assert_xml_select 'project' do
assert_xml_select "description"
assert_xml_select 'id[type="integer"]', /[0-9]+/
assert_xml_select 'name', @@project_name
assert_xml_select 'position[type="integer"]', 1
assert_xml_select 'state', 'active'
end
#assert_response_and_body_matches 200, %r|^<\?xml version="1\.0" encoding="UTF-8"\?>\n<project>\n <description></description>\n <id type=\"integer\">[0-9]+</id>\n <name>#{@@project_name}</name>\n <position type=\"integer\">1</position>\n <state>active</state>\n</project>$|
assert_equal initial_count + 1, Project.count
project1 = Project.find_by_name(@@project_name)
assert_not_nil project1, "expected project '#{@@project_name}' to be created"

View file

@ -76,7 +76,6 @@ class UsersXmlApiTest < ActionController::IntegrationTest
def test_get_users_as_xml
get '/users.xml', {}, basic_auth_headers()
#puts @response.body
assert_response :success
assert_tag :tag => "users",
:children => { :count => 3, :only => { :tag => "user" } }
@ -85,7 +84,6 @@ class UsersXmlApiTest < ActionController::IntegrationTest
def test_get_user_as_xml
get "/users/#{users(:other_user).login}.xml", {}, basic_auth_headers()
puts @response.body
assert_response :success
assert_tag :tag => "user"
assert_no_tag :tag => "password"

View file

@ -30,6 +30,16 @@ class Test::Unit::TestCase
end
return string
end
def xml_document
@xml_document ||= HTML::Document.new(@response.body, false, true)
end
def assert_xml_select(*args)
@html_document = xml_document
assert_select(*args)
end
end
class ActionController::IntegrationTest

View file

@ -143,4 +143,8 @@ class ProjectTest < Test::Unit::TestCase
assert_equal 'Build_a_working_time_machine', @timemachine.to_param
end
def test_title_reader_returns_name
assert_equal @timemachine.name, @timemachine.title
end
end

View file

@ -0,0 +1,7 @@
ResourceFeeder
==============
Simple feeds for resources
NOTE: This plugin depends on the latest version of simply_helpful, available here:
http://dev.rubyonrails.org/svn/rails/plugins/simply_helpful/

View file

@ -0,0 +1,22 @@
require 'rake'
require 'rake/testtask'
require 'rake/rdoctask'
desc 'Default: run unit tests.'
task :default => :test
desc 'Test the resource_feed plugin.'
Rake::TestTask.new(:test) do |t|
t.libs << 'lib'
t.pattern = 'test/**/*_test.rb'
t.verbose = true
end
desc 'Generate documentation for the resource_feed plugin.'
Rake::RDocTask.new(:rdoc) do |rdoc|
rdoc.rdoc_dir = 'rdoc'
rdoc.title = 'ResourceFeed'
rdoc.options << '--line-numbers' << '--inline-source'
rdoc.rdoc_files.include('README')
rdoc.rdoc_files.include('lib/**/*.rb')
end

View file

@ -0,0 +1,2 @@
require 'resource_feeder'
ActionController::Base.send(:include, ResourceFeeder::Rss, ResourceFeeder::Atom)

View file

@ -0,0 +1,2 @@
require 'resource_feeder/rss'
require 'resource_feeder/atom'

View file

@ -0,0 +1,78 @@
module ResourceFeeder
module Atom
extend self
def render_atom_feed_for(resources, options = {})
render :text => atom_feed_for(resources, options), :content_type => Mime::ATOM
end
def atom_feed_for(resources, options = {})
xml = Builder::XmlMarkup.new(:indent => 2)
options[:feed] ||= {}
options[:item] ||= {}
options[:url_writer] ||= self
if options[:class] || resources.first
klass = options[:class] || resources.first.class
new_record = klass.new
else
options[:feed] = { :title => "Empty", :link => "http://example.com" }
end
options[:feed][:title] ||= klass.name.pluralize
options[:feed][:id] ||= "tag:#{request.host_with_port}:#{klass.name.pluralize}"
options[:feed][:link] ||= SimplyHelpful::RecordIdentifier.polymorphic_url(new_record, options[:url_writer])
options[:item][:title] ||= [ :title, :subject, :headline, :name ]
options[:item][:description] ||= [ :description, :body, :content ]
options[:item][:pub_date] ||= [ :updated_at, :updated_on, :created_at, :created_on ]
options[:item][:author] ||= [ :author, :creator ]
resource_link = lambda { |r| SimplyHelpful::RecordIdentifier.polymorphic_url(r, options[:url_writer]) }
xml.instruct!
xml.feed "xml:lang" => "en-US", "xmlns" => 'http://www.w3.org/2005/Atom' do
xml.title(options[:feed][:title])
xml.id(options[:feed][:id])
xml.link(:rel => 'alternate', :type => 'text/html', :href => options[:feed][:link])
xml.link(:rel => 'self', :type => 'application/atom+xml', :href => options[:feed][:self]) if options[:feed][:self]
xml.subtitle(options[:feed][:description]) if options[:feed][:description]
for resource in resources
published_at = call_or_read(options[:item][:pub_date], resource)
xml.entry do
xml.title(call_or_read(options[:item][:title], resource))
xml.content(call_or_read(options[:item][:description], resource), :type => 'html')
xml.id("tag:#{request.host_with_port},#{published_at.xmlschema}:#{call_or_read(options[:item][:guid] || options[:item][:link] || resource_link, resource)}")
xml.published(published_at.xmlschema)
xml.updated((resource.respond_to?(:updated_at) ? call_or_read(options[:item][:pub_date] || :updated_at, resource) : published_at).xmlschema)
xml.link(:rel => 'alternate', :type => 'text/html', :href => call_or_read(options[:item][:link] || options[:item][:guid] || resource_link, resource))
if author = call_or_read(options[:item][:author], resource)
xml.author do
xml.name()
end
end
end
end
end
end
private
def call_or_read(procedure_or_attributes, resource)
case procedure_or_attributes
when Array
attributes = procedure_or_attributes
resource.send(attributes.select { |a| resource.respond_to?(a) }.first)
when Symbol
attribute = procedure_or_attributes
resource.send(attribute)
when Proc
procedure = procedure_or_attributes
procedure.call(resource)
end
end
end
end

View file

@ -0,0 +1,79 @@
module ResourceFeeder
module Rss
extend self
def render_rss_feed_for(resources, options = {})
render :text => rss_feed_for(resources, options), :content_type => Mime::RSS
end
def rss_feed_for(resources, options = {})
xml = Builder::XmlMarkup.new(:indent => 2)
options[:feed] ||= {}
options[:item] ||= {}
options[:url_writer] ||= self
if options[:class] || resources.first
klass = options[:class] || resources.first.class
new_record = klass.new
else
options[:feed] = { :title => "Empty", :link => "http://example.com" }
end
use_content_encoded = options[:item].has_key?(:content_encoded)
options[:feed][:title] ||= klass.name.pluralize
options[:feed][:link] ||= SimplyHelpful::RecordIdentifier.polymorphic_url(new_record, options[:url_writer])
options[:feed][:language] ||= "en-us"
options[:feed][:ttl] ||= "40"
options[:item][:title] ||= [ :title, :subject, :headline, :name ]
options[:item][:description] ||= [ :description, :body, :content ]
options[:item][:pub_date] ||= [ :updated_at, :updated_on, :created_at, :created_on ]
resource_link = lambda { |r| SimplyHelpful::RecordIdentifier.polymorphic_url(r, options[:url_writer]) }
rss_root_attributes = { :version => 2.0 }
rss_root_attributes.merge!("xmlns:content" => "http://purl.org/rss/1.0/modules/content/") if use_content_encoded
xml.instruct!
xml.rss(rss_root_attributes) do
xml.channel do
xml.title(options[:feed][:title])
xml.link(options[:feed][:link])
xml.description(options[:feed][:description]) if options[:feed][:description]
xml.language(options[:feed][:language])
xml.ttl(options[:feed][:ttl])
for resource in resources
xml.item do
xml.title(call_or_read(options[:item][:title], resource))
xml.description(call_or_read(options[:item][:description], resource))
if use_content_encoded then
xml.content(:encoded) { xml.cdata!(call_or_read(options[:item][:content_encoded], resource)) }
end
xml.pubDate(call_or_read(options[:item][:pub_date], resource).to_s(:rfc822))
xml.guid(call_or_read(options[:item][:guid] || options[:item][:link] || resource_link, resource))
xml.link(call_or_read(options[:item][:link] || options[:item][:guid] || resource_link, resource))
end
end
end
end
end
private
def call_or_read(procedure_or_attributes, resource)
case procedure_or_attributes
when Array
attributes = procedure_or_attributes
resource.send(attributes.select { |a| resource.respond_to?(a) }.first)
when Symbol
attribute = procedure_or_attributes
resource.send(attribute)
when Proc
procedure = procedure_or_attributes
procedure.call(resource)
end
end
end
end

View file

@ -0,0 +1,85 @@
require File.dirname(__FILE__) + '/test_helper'
class AtomFeedTest < Test::Unit::TestCase
attr_reader :request
def setup
@request = OpenStruct.new
@request.host_with_port = 'example.com'
@records = Array.new(5).fill(Post.new)
@records.each &:save
end
def test_default_atom_feed
atom_feed_for @records
assert_select 'feed' do
assert_select '>title', 'Posts'
assert_select '>id', "tag:#{request.host_with_port}:Posts"
assert_select '>link' do
assert_select "[rel='alternate']"
assert_select "[type='text/html']"
assert_select "[href='http://example.com/posts']"
end
assert_select 'entry', 5 do
assert_select 'title', :text => 'feed title (title)'
assert_select "content[type='html']", '&lt;p&gt;feed description (description)&lt;/p&gt;'
assert_select 'id', "tag:#{request.host_with_port},#{@records.first.created_at.xmlschema}:#{'http://example.com/posts/1'}"
assert_select 'published', @records.first.created_at.xmlschema
assert_select 'updated', @records.first.created_at.xmlschema
assert_select 'link' do
assert_select "[rel='alternate']"
assert_select "[type='text/html']"
assert_select "[href='http://example.com/posts/1']"
end
end
end
end
def test_should_allow_custom_feed_options
atom_feed_for @records, :feed => { :title => 'Custom Posts', :link => '/posts', :description => 'stuff', :self => '/posts.atom' }
assert_select 'feed>title', 'Custom Posts'
assert_select "feed>link[href='/posts']"
assert_select 'feed>subtitle', 'stuff'
assert_select 'feed>link' do
assert_select "[rel='self']"
assert_select "[type='application/atom+xml']"
assert_select "[href='/posts.atom']"
end
end
def test_should_allow_custom_item_attributes
atom_feed_for @records, :item => { :title => :name, :description => :body, :pub_date => :create_date, :link => :id }
assert_select 'entry', 5 do
assert_select 'title', :text => 'feed title (name)'
assert_select "content[type='html']", '&lt;p&gt;feed description (body)&lt;/p&gt;'
assert_select 'published', (@records.first.created_at - 5.minutes).xmlschema
assert_select 'updated', (@records.first.created_at - 5.minutes).xmlschema
assert_select 'id', "tag:#{request.host_with_port},#{(@records.first.created_at - 5.minutes).xmlschema}:1"
assert_select 'link' do
assert_select "[rel='alternate']"
assert_select "[type='text/html']"
assert_select "[href='1']"
end
end
end
def test_should_allow_custom_item_attribute_blocks
atom_feed_for @records, :item => { :title => lambda { |r| r.name }, :description => lambda { |r| r.body }, :pub_date => lambda { |r| r.create_date },
:link => lambda { |r| "/#{r.created_at.to_i}" }, :guid => lambda { |r| r.created_at.to_i } }
assert_select 'entry', 5 do
assert_select 'title', :text => 'feed title (name)'
assert_select "content[type='html']", '&lt;p&gt;feed description (body)&lt;/p&gt;'
assert_select 'published', (@records.first.created_at - 5.minutes).xmlschema
assert_select 'updated', (@records.first.created_at - 5.minutes).xmlschema
assert_select 'id', /:\d+$/
assert_select 'link' do
assert_select "[rel='alternate']"
assert_select "[type='text/html']"
assert_select "[href=?]", /^\/\d+$/
end
end
end
end

View file

@ -0,0 +1,86 @@
require File.dirname(__FILE__) + '/test_helper'
class RssFeedTest < Test::Unit::TestCase
def setup
@records = Array.new(5).fill(Post.new)
@records.each &:save
end
def test_default_rss_feed
rss_feed_for @records
assert_select 'rss[version="2.0"]' do
assert_select 'channel' do
assert_select '>title', 'Posts'
assert_select '>link', 'http://example.com/posts'
assert_select 'language', 'en-us'
assert_select 'ttl', '40'
end
assert_select 'item', 5 do
assert_select 'title', :text => 'feed title (title)'
assert_select 'description', '&lt;p&gt;feed description (description)&lt;/p&gt;'
%w(guid link).each do |node|
assert_select node, 'http://example.com/posts/1'
end
assert_select 'pubDate', @records.first.created_at.to_s(:rfc822)
end
end
end
def test_should_allow_custom_feed_options
rss_feed_for @records, :feed => { :title => 'Custom Posts', :link => '/posts', :description => 'stuff', :language => 'en-gb', :ttl => '80' }
assert_select 'channel>title', 'Custom Posts'
assert_select 'channel>link', '/posts'
assert_select 'channel>description', 'stuff'
assert_select 'channel>language', 'en-gb'
assert_select 'channel>ttl', '80'
end
def test_should_allow_custom_item_attributes
rss_feed_for @records, :item => { :title => :name, :description => :body, :pub_date => :create_date, :link => :id }
assert_select 'item', 5 do
assert_select 'title', :text => 'feed title (name)'
assert_select 'description', '&lt;p&gt;feed description (body)&lt;/p&gt;'
assert_select 'pubDate', (@records.first.created_at - 5.minutes).to_s(:rfc822)
assert_select 'link', '1'
assert_select 'guid', '1'
end
end
def test_should_allow_custom_item_attribute_blocks
rss_feed_for @records, :item => { :title => lambda { |r| r.name }, :description => lambda { |r| r.body }, :pub_date => lambda { |r| r.create_date },
:link => lambda { |r| "/#{r.created_at.to_i}" }, :guid => lambda { |r| r.created_at.to_i } }
assert_select 'item', 5 do
assert_select 'title', :text => 'feed title (name)'
assert_select 'description', '&lt;p&gt;feed description (body)&lt;/p&gt;'
assert_select 'pubDate', (@records.first.created_at - 5.minutes).to_s(:rfc822)
end
end
# note that assert_select isnt easily able to get elements that have xml namespaces (as it thinks they are
# invalid html psuedo children), so we do some manual testing with the response body
def test_should_allow_content_encoded_for_items
rss_feed_for @records, :item => { :content_encoded => :full_html_body }
html_content = "<strong>Here is some <i>full</i> content, with out any excerpts</strong>"
assert_equal 5, @response.body.scan("<![CDATA[#{html_content}]]>").size
assert_select 'item', 5 do
assert_select 'description + *', "<![CDATA[#{html_content}" # assert_select seems to strip the ending cdata tag
end
end
def test_should_have_content_encoded_namespace_if_used
rss_feed_for @records, :item => { :content_encoded => :full_html_body }
assert_equal %[<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">\n],
@response.body.grep(/<rss version="2\.0.*"/).first
end
def test_should_have_normal_rss_root_without_content_encoded
rss_feed_for @records
assert_equal %[<rss version="2.0">\n],
@response.body.grep(/<rss version="2\.0.*"/).first
end
end

View file

@ -0,0 +1,64 @@
RAILS_ENV = 'test'
require File.expand_path(File.join(File.dirname(__FILE__), '../../../../config/environment.rb'))
require 'action_controller/test_process'
require 'breakpoint'
require 'ostruct'
class Post
attr_reader :id, :created_at
def save; @id = 1; @created_at = Time.now.utc end
def new_record?; @id.nil? end
[:title, :name].each do |attr_name|
define_method attr_name do
"feed title (#{attr_name})"
end
end
[:description, :body].each do |attr_name|
define_method attr_name do
"<p>feed description (#{attr_name})</p>"
end
end
def full_html_body
"<strong>Here is some <i>full</i> content, with out any excerpts</strong>"
end
def create_date
@created_at - 5.minutes
end
end
class Test::Unit::TestCase
include ResourceFeeder::Rss, ResourceFeeder::Atom
def render_feed(xml)
@response = OpenStruct.new
@response.headers = {'Content-Type' => 'text/xml'}
@response.body = xml
end
def rss_feed_for_with_ostruct(resources, options = {})
render_feed rss_feed_for_without_ostruct(resources, options)
end
def atom_feed_for_with_ostruct(resources, options = {})
render_feed atom_feed_for_without_ostruct(resources, options)
end
alias_method_chain :rss_feed_for, :ostruct
alias_method_chain :atom_feed_for, :ostruct
def html_document
@html_document ||= HTML::Document.new(@response.body, false, true)
end
def posts_url
"http://example.com/posts"
end
def post_url(post)
"http://example.com/posts/#{post.id}"
end
end