Question

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.

Was it helpful?

Solution 2

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).

OTHER TIPS

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?

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

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.

You could just save yourself the stress and use friendly_id. Its awesome, does the job and you could look at a screencast by Ryan Bates to get started.

Licensed under: CC-BY-SA with attribution
Not affiliated with StackOverflow
scroll top