Building a Custom CSV Renderer in Rails 3

This post will walk you through creating a simple renderer in Rails 3.

What are renderers?

A renderer in Rails is a way of customizing how content is rendered for the browser or any client interacting with your web service. Rails has a handful of built in rendering formats such as html, xml, and json, and exposes an effortless method for adding additional custom rendering functionality that can be shared among your controllers and applications.

Custom renderers provide a standard, reusable interface for rendering content, in turn allowing you to DRY up your application logic architecture. This post will show you how to add a custom renderer that converts ActiveRecord collections to a downloadable CSV format.

Below is an example of how the final CSV renderer will be used:

app/locations_controller.rb

class LocationsController < ApplicationController
  def index
    @locations = Location.all

    respond_to do |format|
      format.html
      format.csv  { render :csv => @locations, :except => [:id] }
    end
  end
end

Some background info:

Before getting started with our CSV renderer, let's look at the Rails source to see how it defines its own custom renderers:

actionpack/lib/action_controller/metal/renderers.rb

module ActionController
  def self.add_renderer(key, &block)
    Renderers.add(key, &block)
  end

  module Renderers
    # Lots of ommitted code...

    def self.add(key, &block)
      define_method("_render_option_#{key}", &block)
      RENDERERS[key] = block
    end

    # More ommitted code...

    add :xml do |xml, options|
      self.content_type ||= Mime::XML
      xml.respond_to?(:to_xml) ? xml.to_xml(options) : xml
    end
  end
end

At the top you see an add_renderer method, which will be our interface to add a custom renderer. Ignoring the fact that I've removed some of the implementation details, further down you see a class method called add which does two things: 1) It defines an internal method used by the rendering stack, and 2) stores the key (:xml, :json, etc) in a hash.

As we move to the bottom of this file, you'll see where add is being used to define an XML renderer. This method takes a block with two arguments. The first argument is an object that responds to to_xml, and the second is a set of options that are passed as arguments to to_xml. If for some reason the object doesn't respond to to_xml then it simply returns itself.

If you've ever used the Rails scaffold generator, you will notice that it includes code for responding to XML requests:

default scaffolded controller.rb

def show
  @location = Location.find(params[:id])

  respond_to do |format|
    format.html # show.html.erb
    format.xml  { render :xml => @location }
  end
end

This is exacly what adding the XML renderer has provided, a clean syntax for allowing the server to respond with XML formatted data.

Why should I use it?

Let's say you have a Location model with the following schema:

db/schema.rb

ActiveRecord::Schema.define(:version => 20110726022558) do
  create_table "locations", :force => true do |t|
    t.string   "name"
    t.string   "address"
    t.string   "city"
    t.string   "state"
    t.string   "zip"
    t.datetime "created_at"
    t.datetime "updated_at"
  end
end

Although your main application may use all of these fields, you might need to import this data into some sort of enterprise/exchange-powered legacy system in CSV format. You could easily define a to_csv class method on your Location model and just call that from the controller, but that presents a few problems. The Location model shouldn't know anything about converting itself to a CSV format because this violates the rule that components should have a single, well-defined purpose. Also, what if you want to download other models in CSV format? These are problems that respond_to and render aim to solve.

How does it work?

Looking back to the first code snippet I posted, you can see that the CSV render syntax in my controller is very similar to the XML render syntax. The only difference is that our CSV format allows you to specify which columns (attributes) you want to include or exclude in the downloaded CSV file. This detail has almost nothing to do with the renderer itself, and is implemented by the object's Array class, as shown below.

To understand how this works, let's look at our custom CSV renderer:

csv_renderer.rb

require 'action_controller/metal/renderers'

ActionController.add_renderer :csv do |csv, options|
  self.response_body = csv.respond_to?(:to_csv) ? csv.to_csv(options) : csv
end

You will notice a few differences between our custom renderer and the XML renderer defined by Rails. First, we are using add_renderer as opposed to just add. This is a personal preference as you can also use: ActionController::Renderers.add :csv. Both methods accomplish exactly the same thing, but I find add_renderer to be slightly more readable. Second, we are not setting the contenttype. The reason for this is because Rails automatically adds a CSV Mime Type within the actionpack library. I'm not going to go into the details of how that works, but feel free to explore the complete list of Mime Types. The last detail to note is that we are requiring action_controller/metal/renderers which provides us access to the add_renderer method. You could alternatively require any module that inclues this file, such as action_controller/base if you need access to methods like send_data.

Finally, let's look at the to_csv Array method. The reason this method is defined on Array is because ActiveRecord converts collection queries such as Location.where(:state => 'vt') or Location.all to arrays.

array.rb

class Array

  # Converts an array to CSV formatted string
  # Options include:
  # :only => [:col1, :col2] # Specify which columns to include
  # :except => [:col1, :col2] # Specify which columns to exclude
  # :add_methods => [:method1, :method2] # Include addtional methods that aren't columns
  def to_csv(options={})
    return '' if empty?
    return join(',') unless first.class.respond_to? :column_names

    columns = first.class.column_names
    columns &= options[:only].map(&:to_s) if options[:only]
    columns -= options[:except].map(&:to_s) if options[:except]
    columns += options[:add_methods].map(&:to_s) if options[:add_methods]

    csv = [columns.join(',')]
    csv.concat(map {|v| columns.map {|c| v.send(c) }.join(',') })

    csv.join("\n")
  end
end

Above we can see that to_csv takes a hash of options which are used to determine which columns should be included in the CSV file. One option :only => [:col1, :col2] allows you to specify the exact columns you want included, another option, :except => [:col1, :col2], allows you to specify which columns you DO NOT want to include, and the last option, :add_methods => [:method1, :method2] allows you to add data defined in methods that aren't saved in the database. Calling to_csv without any arguments will include all columns.

Usage examples:

Tying that all together, we can now use this renderer in our controllers to let users or clients download CSV data. Here are a few examples:

Generate a CSV that includes every column:

app/controllers/location_controller.rb

class LocationsController < ApplicationController
  def index
    @locations = Location.all

    respond_to do |format|
      format.csv  { render :csv => @locations }
    end
  end
end

Generate a CSV that includes every column except the id:

app/controllers/location_controller.rb

class LocationsController < ApplicationController
  def index
    @locations = Location.all

    respond_to do |format|
      format.csv  { render :csv => @locations, :except => [:id] }
    end
  end
end

Generate a CSV that includes only the state and zipcode:

app/controllers/location_controller.rb

class LocationsController < ApplicationController
  def index
    @locations = Location.all

    respond_to do |format|
      format.csv  { render :csv => @locations, :only => [:state, :zip] }
    end
  end
end

Generate a CSV that adds a model method:

app/controllers/location_controller.rb

class LocationsController < ApplicationController
  def index
    @locations = Location.all

    respond_to do |format|
      format.csv  { render :csv => @locations, :add_methods => [:my_method] }
    end
  end
end

Finishing up

This post walked you through a simple yet practical approach to building a custom Rails renderer in Rails 3. While it may be overkill for one-off uses in a small code base, it shows it's true potential in large applications by helping prevent redundant code, and for building APIs that may need to respond to formats other than JSON and XML.

Additional Resources

All the code used in this post was developed for a RubyGem called render_csv. For more information about renderers and the Rails rendering stack, I highly recommend Crafting Rails Applications by José Valim. Also, the Layouts and Rendering Rails Guide provides a comprehensive overview of rendering in controllers as well as views.

comments powered by Disqus