DHH
posted about this a couple weeks ago and I'm bringing the subject back with two subtle differences: I'm aiming on Rails 2.2 (not Rails 3) and I will actually show you the code!
BackgroundMerb provides a nice way to specify on top of your controllers which format it responds to using the method
provides. Let's see an example:
class Projects < Application
provides :html, :xml, :json
def index
@projects = Project.all
display @projects
end
end
In other words, this controller responds to :html, :xml and :json. When a request comes, it tries to render a template in your view path, for example: "projects/index.html". If the template can't be found, we call :to_format on the object given in display, in this case we would attempt @projects.to_html. If not successful again, we return 404 not found.
DHH proposed a similar approach in Rails, which would be:
class ProjectsController < ApplicationController
respond_to :html, :xml, :json
def index
@projects = Project.all
respond_with(@projects)
end
end
DHH also proposed that we could overwrite our class method respond_to definition just doing:
respond_with(@projects, :to => [:html, :rss])
Nice, huh? Since now our methods behavior is defined, let's put some work on it.
Coding pt. 1: respond_withLet's forget about respond_to class method a little bit and implement a respond_with method that requires :to option to be sent. The code (at first) is quite straightforward (this code goes inside ApplicationController::Base):
def respond_with(object, options)
mime_types = Array(options.delete(:to))
mime_types.map!{ |mime| mime.to_sym }
format = @request.format.to_sym
if mime_types.include?(format)
response.template.template_format = format
response.content_type = @request.format.to_s #=> "text/html"
if template_exists?
render :action => action_name
elsif object.respond_to? "to_#{format}"
render :text => object.send("to_#{format}")
else
render :text => '404 Not Found', :status => 404
end
else
head :not_acceptable
end
end
Well, this is the behavior we described above, but now in Ruby. :)
Notice that if the format the user is expecting is not available, we respond with a not acceptable (406) status.
Coding pt. 2: respond_to class methodOur respond_to class method is even easier. It's mainly a class inheritable array which Rails ActiveSupport already implemented for us:
class ActionController::Base
def self.respond_to(*formats)
formats.map!{ |format| format.to_sym }
write_inheritable_array(:formats_for_respond_to, formats)
end
class_inheritable_reader :formats_for_respond_to
respond_to :html, :xml
end
You know when you declare something in your ApplicationController (like session, request_from_forgery) and it appears in all your controllers? write_inheritable_array and class_inheritable_reader are the magic behind it.
And we are doing the same here. All the values given in respond_to are converted to symbols and will be kept in an inheritable array aliased as :formats_for_respond_to. We can retrieve them by calling formats_for_respond_to method (both instance and class methods are defined).
Then we defined that all of you controllers respond to :html and :xml. Of course, you can change it in your ProjectsController. Since Ruby class variables are shared through the whole class hierarchy, class inheritable array is also responsible that changes in child controllers (for example, ProjectsController) does not affect my parent controller (ApplicationController).
Got it? Now let's merge things.
Coding pt. 3: respond_with using respond_to defined formatsThe deal is: if the user send
:to as option, we should use the mime types given to satisfy our request. On the other hand, if
:to is not given, we have to get the mime types defined in the class by calling formats_for_respond_to method.
def respond_with(object, options = {}) # options is really optional now
if options[:to]
mime_types = Array(options.delete(:to))
mime_types.map!{ |mime| mime.to_sym }
else
mime_types = formats_for_respond_to
end
format = @request.format.to_sym
if mime_types.include?(format)
response.template.template_format = format
response.content_type = @request.format.to_s #=> "text/html"
if template_exists?
render :action => action_name
elsif object.respond_to? "to_#{format}"
render :text => object.send("to_#{format}")
else
render :text => '404 Not Found', :status => 404
end
else
head :not_acceptable
end
end
Now we put our code to run and then it...
does not work for HTML requests (but works for XML, JSON and so on)!
All right, we have just one thing to solve. You probably noticed that Rails URL does not require a format at the end, so we can have urls like "/project/1/edit", right? In such cases, the mime given in @request.format is Mime::ALL. So we have to handle it properly:
def respond_with(object, options = {}) # options is really optional now
if options[:to]
mime_types = Array(options.delete(:to))
mime_types.map!{ |mime| mime.to_sym }
else
mime_types = formats_for_respond_to
end
format = @request.format.to_sym
if format == Mime::ALL && template_exists?
render :action => action_name
elsif mime_types.include?(format)
response.template.template_format = format
response.content_type = @request.format.to_s #=> "text/html"
if template_exists?
render :action => action_name
elsif object.respond_to? "to_#{format}"
render :text => object.send("to_#{format}")
else
render :text => '404 Not Found', :status => 404
end
else
head :not_acceptable
end
end
Great, now it works! :)
Taking to the next levelOf course, this is the first interation. I'm using this code as basis on
Inherited Resources (this
file) and it has grown with much more functionalities:
- All extra options sent to respond_with are sent to the render method called next. This allows us to do this:
respond_with(@project.errors, :status => :unprocessable_entity)
And also:
respond_with(@project, :status => created, :location => @project)
- While I was coding it, I've stumbled with this post which gave me those nice ideas:
respond_to(:html, :xml, :json, :with => @project)
And you can also use blocks:
respond_to(:xml, :json, :with => @project) do |format|
format.html { redirect_to @project }
end
In such cases, the format given in the block has higher priority than the others. The nice thing is that respond_to only delegates the :with behavior to respond_with that we just implemented. So everything is kept simple.
- ActionController has a class called Responder that deals with mime type, so I've put all the mime type logic inside this class, check out the source code.
- To have this working on Rails 2.3 is quite easy. First we have to define a template_exists? method, since it was wiped out of edge Rails. Second, since ApplicationController is already loaded when we reopen ActionController::Base, our respond_to class definitions won't be properly inherited, so we have to ask the ApplicationController to be reloaded (again, check the code here).
That's all. If you just want to use the respond_to and respond_with functionality in your app and not the whole
Inherited Resources stack, you can grab the gem anyway. Inherited Resources is just loaded if you actually inherit from it.
Enjoy! :)