Discussion URLs: Opaque, Usable, and Readable
« Human-Readable ActiveResource URLs
» Logging Internal Server Errors
Code: ActiveResource, human-readable, named routes, nested routes, Rails, resources, RESTful, routes, routing, Ruby, URLs
Comments Off on Discussion URLs: Opaque, Usable, and Readable
I just wrote about Human-Readable ActiveResource URLs, and now I want to examine one example of them more in-depth. Discussion forum URLs have several conflicting goals:
- human-readable
- I should get some idea of what the discussion’s about when I hover over a link
- permanent
- bookmarks, incoming links, and search engines all need reliable URLs
- editable
- If a discussion drifts, it needs a new subject, which means it needs a new URL
- opaque
- I’d rather not roll out the carpet for someone to scrape all the discussions
After a bit of pondering, I decided that URLs should look like this:
http://nearbygamers.com/discussions/{slug}–{post count}–{subject}
- slug
- An unchanging random 5-character string.
- post count
- The number of posts in this discussion, so that when you look at the discussions index you know at a glance if you’ve read a thread because your browser colors visited links. I snagged this off the Joel.
- subject
- Just the alphanumerics, dash-separated, updated as the subject is edited
So two parts (post count and subject) on a discussion can change, and the controller uses the slug to load the discussion and redirect to the correct URL if either has changed. Bookmarks and search engines can update, and outside links never stop working.
The Code
First, a little snippet of code to generate slugs lives in lib/slug.rb:
def random_slug(length=5)
chars = ("a".."z").to_a + ("A".."Z").to_a + ("1".."9").to_a
Array.new(length, '').collect{chars[rand(chars.size)]}.join
end
The Discussion model uses this to set a slug on itself if it doesn’t have one yet, and then uses that slug to generate URLs (note that I remove multiple and leading/trailing hyphens from the subjects):
require 'slug'
class Discussion < ActiveRecord::Base
def to_param
"#{slug}-#{posts.count}-#{subject.gsub(/[^[:alnum:]]+/i, '-').gsub(/-+/, '-').gsub(/^-|-$/, '')}"
end
protected
def before_validation
if self.slug.empty?
begin
self.slug = random_slug
end while(Discussion.count(:conditions => ['slug = ?', self.slug]) > 0)
end
end
end
Finally, the DiscussionsController loads discussion objects, throws 404 errors, and redirects appropriately:
class DiscussionsController < ApplicationController
before_filter :load_discussion, :except => [ :index ]
private
def load_discussion
slug = params[:id].split('-').first if params[:id]
@discussion = Discussion.find_by_slug(slug) if slug
raise ::ActionController::RoutingError, "Recognition failed for #{request.path}" if @discussion.nil?
redirect_to discussion_path(@discussion) and return if @discussion.to_param != params[:id]
end