Friday, November 30, 2007

Raising Routeable Errors in Merb Router

On the #merb channel today there was an interesting puzzle to solve with routes. Merb routes have some crazy power, and today took my first crack at trying to give it a bit of a flex.

There were two distinct pieces of the puzzle that needed to be addressed. First, requests sent to actions on the application and exceptions controllers. The application controller will feed back with an error that says

"The 'Application' controller has no public actions"

The exception controller can be routed to like any other action. Failing missing actions are handled like any other controller, and actions rendering a known template will render but without the error message.

This behavior was not good for me and I didn't want it to occur. You might want the flexibility of being able to directly call the exceptions controller. But for this I don't.

Second, the error should be short and not give out too much information when either you request application or exceptions controller actions, or non-existent routes.

When Merb catches an exception it gives you a small amount of information about what the problem was. For this the requirement was that just a non-descript message be displayed.

This route setup prevents both issues from occuring and just renders an error screen with a simple message.

class Merb::Router
DEFAULT_NOT_FOUND_HASH = { :exception => Merb::ControllerExceptions::NotFound.new("URL Not Found"),
:controller => "exceptions",
:action => "not_found"}
end

puts "Compiling routes.."
Merb::Router.prepare do |r|

r.match(%r{^\/(application|exceptions)}).defer_to do |request, params|
params.merge!(Merb::Router::DEFAULT_NOT_FOUND_HASH)
end

# All the other routes

r.match(%r{.+}).defer_to do |request, params|
params.merge!(Merb::Router::DEFAULT_NOT_FOUND_HASH)
end
end
The first part of this route will catch any routes requested for application or exceptions controllers and render the error NotFound with the message "URL Not Found". The second part just catches any routes you haven't defined and renders that same error.

This setup will not work with default_routes. If you use default routes in between these to matchers, the bottom matcher will most likely not get called, and if you put the default routes after the last matcher in the example, default_routes will never be called. Personally, I don't like the idea of over riding the default behavior for not found errors, so I wouldn't have the second matcher, but I don't mind restricting routing from application and exceptions. At least that way you can use default_routes if you wanted to.

The defer_to method on a route will wait until the route comes in to assess it. If it matches the regexp then control is yielded to the block with the request and params objects.

Merbs error handling in controllers means that you just raise an error and the error will be caught by the exceptions controller and rendered. If you directly raise an error in the defer_to block though, your app will crash. You need to set the :controller, :action, and :exception options manually in the params hash to get error handling.

It's interesting to note that if the result of your defer_to block is false, it will not be considered as a match and will move onto the next. This does not allow for multiple defer_to matches to influence the result of another defer_to. For example, without default_routes set:
    r.match(%r{.+}).defer_to do |request, params|
params.merge!(:one => "one")
puts params.inspect
false
end

r.match(%r{.+}).defer_to do |request, params|
params.merge!(:two => "two")
puts params.inspect
false
end
will produce
{"action"=>"index", "one"=>"one"}
{"two"=>"two", "action"=>"index"}

# Instead of

{"action"=>"index", "one"=>"one", "two"=>"two"}
I'm not sold yet on what any of this means in terms of usefulness. I think I'll include the route to prevent requests going to application or exceptions directly, but I'll leave the normal behavior in for the rest of them.