views:

544

answers:

4

I'd like my website to have URLs looking like this:

example.com/2010/02/my-first-post

I have my Post model with slug field ('my-first-post') and published_on field (from which we will deduct the year and month parts in the url).

I want my Post model to be RESTful, so things like url_for(@post) work like they should, ie: it should generate the aforementioned url.

Is there a way to do this? I know you need to override to_param and have map.resources :posts with :requirements option set, but I cannot get it all to work.


I have it almost done, I'm 90% there. Using resource_hacks plugin I can achieve this:

map.resources :posts, :member_path => '/:year/:month/:slug',
  :member_path_requirements => {:year => /[\d]{4}/, :month => /[\d]{2}/, :slug => /[a-z0-9\-]+/}

rake routes
(...)
post GET    /:year/:month/:slug(.:format)      {:controller=>"posts", :action=>"show"}

and in the view:

<%= link_to 'post', post_path(:slug => @post.slug, :year => '2010', :month => '02') %>

generates proper example.com/2010/02/my-first-post link.

I would like this to work too:

<%= link_to 'post', post_path(@post) %>

But it needs overriding the to_param method in the model. Should be fairly easy, except for the fact, that to_param must return String, not Hash as I'd like it.

class Post < ActiveRecord::Base
  def to_param
   {:slug => 'my-first-post', :year => '2010', :month => '02'}
  end
end

Results in can't convert Hash into String error.

This seems to be ignored:

def to_param
  '2010/02/my-first-post'
end

as it results in error: post_url failed to generate from {:action=>"show", :year=>#<Post id: 1, title: (...) (it wrongly assigns @post object to the :year key). I'm kind of clueless at how to hack it.

A: 

Ryan Bates talked about it in his screen cast "how to add custom routes, make some parameters optional, and add requirements for other parameters." http://railscasts.com/episodes/70-custom-routes

Zakwan Alhajjar
Generic routes (map.connect, which Ryan Bates talks about), named_routes and RESTful routes are three different things. I can easily have any URL I want using both generic and named routes. I want to have pretty urls using RESTful routes.
Paweł Gościcki
A: 

This might be helpful. You can define a default_url_options method in your ApplicationController that receives a Hash of options that were passed to the url helper and returns a Hash of additional options that you want to use for those urls.

If a post is given as a parameter to post_path, it will be assigned to the first (unnassigned) parameter of the route. Haven't tested it, but it might work:

def default_url_options(options = {})
  if options[:controller] == "posts" && options[:year].is_a?Post
    post = options[:year]
    {
      :year  => post.created_at.year,
      :month => post.created_at.month,
      :slug  => post.slug
    }
  else
    {}
  end
end

I'm in the similar situation, where a post has a language parameter and slug parameter. Writing post_path(@post) sends this hash to the default_url_options method:

{:language=>#<Post id: 1, ...>, :controller=>"posts", :action=>"show"}

UPDATE: There's a problem that you can't override url parameters from that method. The parameters passed to the url helper take precedence. So you could do something like:

post_path(:slug => @post)

and:

def default_url_options(options = {})
  if options[:controller] == "posts" && options[:slug].is_a?Post
    {
      :year  => options[:slug].created_at.year,
      :month => options[:slug].created_at.month
    }
  else
    {}
  end
end

This would work if Post.to_param returned the slug. You would only need to add the year and month to the hash.

Tomas Markauskas
Works! Very clever, although very hackish too. Now is there a cleaner way to solve this?
Paweł Gościcki
That's interesting, because it didn't work for me. I could only see that the post instance is passed. Maybe it's because of the plugin you use. I can only get it to work when I pass the post as `:slug => @post`.
Tomas Markauskas
Now I understand: if you set a value in the `options` hash directly as I did by mistake in my first revision, e.g. `options[:year] = 2009` then it's set correctly (as the hash is being passed by reference). So you can actually overwrite given keys.
Tomas Markauskas
Yes, it requires a plugin so that the routes are correct (this is a prerequirement). Check this: http://gist.github.com/303710
Paweł Gościcki
Here is my application_controller bit: http://gist.github.com/303742
Paweł Gościcki
+2  A: 

Pretty URLs for Rails 3.x and Rails 2.x without the need for any external plugin, but with a little hack, unfortunately.

routes.rb

map.resources :posts, :except => [:show]
map.post '/:year/:month/:slug', :controller => :posts, :action => :show, :year => /\d{4}/, :month => /\d{2}/, :slug => /[a-z0-9\-]+/

application_controller.rb

def default_url_options(options = {})
  # resource hack so that url_for(@post) works like it should
  if options[:controller] == 'posts' && options[:action] == 'show'
    options[:year] = @post.year
    options[:month] = @post.month
  end
  options
end

post.rb

def to_param # optional
  slug
end

def year
  published_on.year
end

def month
  published_on.strftime('%m')
end

view

<%= link_to 'post', @post %>

Note, for Rails 3.x you might want to use this route definition:

resources :posts
match '/:year/:month/:slug', :to => "posts#show", :as => :post, :year => /\d{4}/, :month => /\d{2}/, :slug => /[a-z0-9\-]+/

Is there any badge for answering your own question? ;)

Btw: the routing_test file is a good place to see what you can do with Rails routing.

Update: Using default_url_options is a dead end. The posted solution works only when there is @post variable defined in the controller. If there is, for example, @posts variable with Array of posts, we are out of luck (becase default_url_options doesn't have access to view variables, like p in @posts.each do |p|.

So this is still an open problem. Somebody help?

Paweł Gościcki
Badge? There is self learner (if you answered your own question and it has 3 upvotes) and you can accept your answer.
klew
That was just a joke ;) There's a bigger issue. My solution breaks when I'm dealing with posts in other controllers. Specifically, when there is no @post present (while there may be @posts variable there).
Paweł Gościcki
+1  A: 

It's still a hack, but the following works:

In application_controller.rb:

def url_for(options = {})
  if options[:year].class.to_s == 'Post'
    post = options[:year]
    options[:year] = post.year
    options[:month] = post.month
    options[:slug] = post.slug
  end
  super(options)
end

And the following will work (both in Rails 2.3.x and 3.0.0):

url_for(@post)
post_path(@post)
link_to @post.title, @post
etc.

This is the answer from some nice soul for a similar question of mine, url_for of a custom RESTful resource (composite key; not just id).

Paweł Gościcki