Saturday, July 4, 2009

DRY RSS

‹prev | My Chain | next›

I have two RSS feeds, which look depressingly similar.

The meals RSS feed:
get '/main.rss' do
content_type "application/rss+xml"

url = "#{@@db}/_design/meals/_view/by_date?limit=10&descending=true"
data = RestClient.get url
@meal_view = JSON.parse(data)['rows']

rss = RSS::Maker.make("2.0") do |maker|
maker.channel.title = "EEE Cooks: Meals"
maker.channel.link = ROOT_URL
maker.channel.description = "Meals from a Family Cookbook"
@meal_view.each do |couch_rec|
data = RestClient.get "#{@@db}/#{couch_rec['key']}"
meal = JSON.parse(data)
date = Date.parse(meal['date'])
maker.items.new_item do |item|
item.link = ROOT_URL + date.strftime("/meals/%Y/%m/%d")
item.title = meal['title']
item.pubDate = Time.parse(meal['date'])
item.description = meal['summary']
end
end
end

rss.to_s
end
The recipes feed:
get '/recipes.rss' do
content_type "application/rss+xml"

url = "#{@@db}/_design/recipes/_view/by_date?limit=10&descending=true"
data = RestClient.get url
@recipe_view = JSON.parse(data)['rows']

rss = RSS::Maker.make("2.0") do |maker|
maker.channel.title = "EEE Cooks: Recipes"
maker.channel.link = ROOT_URL
maker.channel.description = "Recipes from a Family Cookbook"

@recipe_view.each do |couch_rec|
data = RestClient.get "#{@@db}/#{couch_rec['value'][0]}"
recipe = JSON.parse(data)
maker.items.new_item do |item|
item.link = ROOT_URL + "/recipes/recipe['id']"
item.title = recipe['title']
item.pubDate = Time.parse(recipe['date'])
item.description = recipe['summary']
end
end
end

rss.to_s
end
Whenever I DRY things up, I tend to over-generalize. I feel the need to remove the duplication and to prepare for as much future use as possible. Every time, I have to have an internal dialog with myself, so that I can be convinced that it is OK to simply eliminate duplication of business logic. I do not need to prevent future duplication—I can deal with that in future.

So, with pep talk in hand, I start with the recipe.rss feed. I replace the above with a call to a new helper, rss_for_date_view:
get '/recipes.rss' do
content_type "application/rss+xml"
rss_for_date_view
end
I create the rss_for_date_view helper and move the code that was previously in the recipe.rss handler into the new helper:
    def rss_for_date_view
url = "#{@@db}/_design/recipes/_view/by_date?limit=10&descending=true"
data = RestClient.get url
@recipe_view = JSON.parse(data)['rows']

rss = RSS::Maker.make("2.0") do |maker|
maker.channel.title = "EEE Cooks: Recipes"
maker.channel.link = ROOT_URL
maker.channel.description = "Recipes from a Family Cookbook"

@recipe_view.each do |couch_rec|
data = RestClient.get "#{@@db}/#{couch_rec['value'][0]}"
recipe = JSON.parse(data)
maker.items.new_item do |item|
item.link = ROOT_URL + "/recipes/recipe['id']"
item.title = recipe['title']
item.pubDate = Time.parse(recipe['date'])
item.description = recipe['summary']
end
end
end

rss.to_s
end
Now, I run my tests.

When refactoring, always run your tests (and you better have tests) with every change lest a change two moves prior compound to put the code into a completely unusable state.

When I run my specs, I get errors:
3)
NameError in 'GET /recipe.rss should request the 10 most recent meals from CouchDB'
uninitialized class variable @@db in Eee::Helpers
./helpers.rb:199:in `rss_for_date_view'
./eee.rb:68:in `GET /recipes.rss'
/home/cstrom/.gem/ruby/1.8/gems/sinatra-0.9.2/lib/sinatra/base.rb:779:in `call'
/home/cstrom/.gem/ruby/1.8/gems/sinatra-0.9.2/lib/sinatra/base.rb:779:in `route'
...
Ah, that is a fairly easy one. I do not have access to the @@db Sinatra class variable in the helpers. I created a stub-able _db helper method to work around that. After searching and replacing @@db for _db, all of my tests are passing again.

Before trying to get the rss_for_date_view helper working with meals, I take some time to rename recipe-specific variables to be more general, renaming "recipe_view" to be plain "view" and "recipe" to be "record":
    def rss_for_date_view
url = "#{_db}/_design/recipes/_view/by_date?limit=10&descending=true"
data = RestClient.get url
view = JSON.parse(data)['rows']

rss = RSS::Maker.make("2.0") do |maker|
maker.channel.title = "EEE Cooks: Recipes"
maker.channel.link = ROOT_URL
maker.channel.description = "Recipes from a Family Cookbook"

view.each do |couch_rec|
data = RestClient.get "#{_db}/#{couch_rec['value'][0]}"
record = JSON.parse(data)
maker.items.new_item do |item|
item.link = ROOT_URL + "/recipes/record['id']"
item.title = record['title']
item.pubDate = Time.parse(record['date'])
item.description = record['summary']

end
end
end

rss.to_s
end
And then I run my tests to ensure that I have not broken anything (I haven't—this time).

The primary difference between the meal and recipe feeds is the name—both in the title and in the CouchDB view. The name ("recipes" or "meals") is always plural, so I can introduce it as the first argument to the rss_for_date_view helper:
get '/recipes.rss' do
content_type "application/rss+xml"
rss_for_date_view("recipes")
end
And the updated helper:
    def rss_for_date_view(feed)
url = "#{_db}/_design/#{feed}/_view/by_date?limit=10&descending=true"
data = RestClient.get url
view = JSON.parse(data)['rows']

rss = RSS::Maker.make("2.0") do |maker|
maker.channel.title = "EEE Cooks: #{feed.upcase}"
maker.channel.link = ROOT_URL
maker.channel.description = "#{feed.upcase} from a Family Cookbook"

view.each do |couch_rec|
data = RestClient.get "#{_db}/#{couch_rec['value'][0]}"
record = JSON.parse(data)
maker.items.new_item do |item|
item.link = ROOT_URL + "/recipes/record['id']"
item.title = record['title']
item.pubDate = Time.parse(record['date'])
item.description = record['summary']
end
end
end

rss.to_s
end
And then I run the tests.

This time I do get an error:
1)
'GET /recipe.rss should be the meals rss feed' FAILED
expected following output to contain a <channel title>EEE Cooks: Recipes</channel title> tag:
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
<?xml version="1.0" encoding="UTF-8"?><html><body><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:trackback="http://madskills.com/public/xml/rss/module/trackback/"><channel><title>EEE Cooks: RECIPES</title>
<link>http://www.eeecooks.com
<description>RECIPES from a Family Cookbook</description></channel></rss></body></html>
./spec/eee_spec.rb:467:
Whoops! It is capitalize, not upcase. Good thing I had those tests!

And again, I run all of the tests (now they pass).

The last reference to recipes in the rss_for_date_view helper is when calculating a link to the recipe for the RSS feed. Since I will need to calculate the link for both the recipe and meal RSS feeds, that means that I will have to pass a code block into the helper. Something like this:
get '/recipes.rss' do
content_type "application/rss+xml"
rss_for_date_view("recipes") do |rss_item, recipe|
rss_item.link = ROOT_URL + "/recipes/#{recipe['id']}"
end

end
And, to get the helper to use the code block, it needs to yield:
    def rss_for_date_view(feed)
url = "#{_db}/_design/#{feed}/_view/by_date?limit=10&descending=true"
data = RestClient.get url
view = JSON.parse(data)['rows']

rss = RSS::Maker.make("2.0") do |maker|
maker.channel.title = "EEE Cooks: #{feed.capitalize}"
maker.channel.link = ROOT_URL
maker.channel.description = "#{feed.capitalize} from a Family Cookbook"

view.each do |couch_rec|
data = RestClient.get "#{_db}/#{couch_rec['value'][0]}"
record = JSON.parse(data)
maker.items.new_item do |item|
item.title = record['title']
item.pubDate = Time.parse(record['date'])
item.description = record['summary']

yield item, record
end
end
end

rss.to_s
end
With that, I am ready to replace the duplicate RSS code in the meal RSS action with this:
get '/main.rss' do
content_type "application/rss+xml"

rss_for_date_view("meals") do |rss_item, meal|
date = Date.parse(meal['date'])
rss_item.link = ROOT_URL + date.strftime("/meals/%Y/%m/%d")
end
end
Again, I run all of my tests. And this time, they pass.

Not too shabby, I replaced some horrid duplication with two much smaller code blocks:
get '/main.rss' do
content_type "application/rss+xml"

rss_for_date_view("meals") do |rss_item, meal|
date = Date.parse(meal['date'])
rss_item.link = ROOT_URL + date.strftime("/meals/%Y/%m/%d")
end
end

get '/recipes.rss' do
content_type "application/rss+xml"

rss_for_date_view("recipes") do |rss_item, recipe|
rss_item.link = ROOT_URL + "/recipes/#{recipe['id']}"
end
end
Best of all, I did it very quickly and with a high degree of assurance that nothing went wrong—thanks to my tests.
(commit)

No comments:

Post a Comment