A smarter Rails url_for helper

Problem

In my Rails application, I want to be able to determine a URL for any given model, without the calling code having to know the type of the model. Instead of calling post_path(@post) or comment_path(@comment), I want to just say url_for(@post) or url_for(@comment). This already works in many cases, when Rails can infer the correct route helper method to call. However, Rails cannot always infer the correct helper, and even if it does, it might not be the one you want.

For example, suppose you have comments as a nested resource under posts (config/routes.rb):

resources :posts do
resources :comments
end

I have a @post with id 8, which has a @comment with id 4. If I call url_for(@post), it will correctly resolve to /posts/8. However, if I call url_for(@comment), I get an exception:

Rails incorrectly guessed the the route helper method would be comment_path (unfortunately, it knows nothing of your route configuration). The correct call would be post_comment_path(@comment.post, @comment), which returns /posts/8/comments/4. However, that requires the calling code to know too much about comments.

My solution

I wanted a way to “teach” my application how to resolve my various models into URLs. The idea is inspired by FubuMVC’s UrlRegistry (which was originally inspired by Rails’ url_for functionality…). I came up with SmartUrlHelper. It provides a single method: smart_url_for, which is a wrapper around url_for. The difference is that you can register “handlers” which know how to resolve your edge cases.

To solve my example problem above, I’d add the following code to config/initializers/smart_urls.rb:

Now I can call smart_url_for(@post) or smart_url_for(@comment) and get the expected URL. The comment is resolved by the special case handler, and the post just falls through to the default url_for call. Note that in this example, I use instance variables named @post and @comment, which implies I know the type of object stored in the variable. In that case, smart_url_for is just a convenience. However, consider a scenario where you have generic code that needs to build a URL for any model passed to it (like the form_for helper). In that case, something like smart_url_for is a necessity.

Feedback

First, does Rails already have this functionality built-in, or is there an accepted solution in the community? If not, what do you think of this approach? I’d welcome suggestions for improvement. Particularly, I’m not wild about storing the handlers in the Rails.config object, but didn’t know a better way to separate the configuration step (config/initializer) from the consuming step (calls to smart_url_for). So far, it is working out well on my project.

I don’t know, I’m asking you!
But the example you provided and linked to is NOT a solution to my problem. You know that you have a comment, so you know you have to also pass a post to get the url. I’m referring to a situation where you don’t know what the model is. The problem I’m trying to solve is when you have generic code that needs to be able to handle any model, and generate a url.

If (and I have no idea why you would want to) you wanted to avoid knowing what the parent object was, you could just implement a method on comment and use that

class Comment
def parent
post
end
end

url_for [@comment.parent, @@comment:disqus ]

Anonymous

The *call site* does not know the model. Yes, somewhere in my application, that knowledge exists (the config/initializer). But the point at which I need to generate a URL, I do not know the model.
I have a method that returns a collection of models. Models of different types. I want to display a list of links to those models. The code that iterates over the models doesn’t know, and shouldn’t have to care, what the models actually are. It just wants to ask Rails for their URL. That is what url_for is supposed to do. But it doesn’t in some circumstances, like nested resources.

Your assertion that “This is what url_for is supposed to do” is just wrong. There is a well founded presumption that the view knows the properties model structure it is displaying (as you would for knowing the fields for a form for example).

If your view is that generic that it is only asking about a few properties of a model via a protocol (say “title” and url link), then implement a method as in the class above – models that implement this protocol can be displayed

Anonymous

Thanks for your feedback, Jak. I didn’t intend this as a patch submission to Rails, so I don’t need everyone to like it. Personally, putting the url generation on the model (which is how we started) was hard for me to stomach. To each his own.
Wrapping each model in a decorator again presumes that you know the model type (I think) so that you know which decorator to wrap it with. I haven’t used Draper yet, but I do agree with its intent. If it has a way to generically wrap a model with the correct decorator, without you explicitly telling it which decorator to use, it may be a possible solution.

And if you don’t like the idea of using a view only property in your model, you should probably be using view models anyway, I suggest Draper, where you can then implement view only logic about modelshttps://github.com/jcasimir/draper

Mark IJbema

If you have a lot of models this becomes quite expensive though. As far as I can see the lookup is O(n). If your primary usecase is checking the model class, wouldn’t it be better to store the mappings in a hash, so that lookup is constant? (and then per class you could possibly still do another check, if you need to check other stuff than the model class)

Anonymous

First, its worth pointing out that I only need to register handlers for the “special case” models. Everything else just falls through to url_for.
The predicate approach gives me more flexibility, but it is possible I won’t need its full power. The class checking will likely be the most common case. Another possibility would be a hybrid approach – first check a hash for the class of the model. If it not found there, check the model against the collection of predicates, and only after that fails, fall through to url_for.