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 `{lang=”ruby”}
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