A URL shortener in pure Rails, and the one line that decides who it redirects to
Text version
The whole feature is a link that goes in and a link that comes out:
ShortUrl.create(target_url: "https://rubycademy.com", note: "RubyCademy")
# => #<ShortUrl slug: "kR3xQ", target_url: "https://rubycademy.com", clicks_count: 0>
Visit /u/kR3xQ and you land on the target, with the counter one higher than it was. No gem, no
service object, no background job. What follows is every decision that fits between those two
lines, in the order the video makes them.
The ShortUrl model, and its three columns
Three columns, and each one earns its place. The slug is the identity, the target is where we send people, and the counter is the only thing anyone ever asks a shortener afterwards.
rails generate model ShortUrl slug:string target_url:string clicks_count:integer
The generated migration needs one edit before it runs:
create_table :short_urls do |t|
t.string :slug, null: false
t.string :target_url, null: false
t.integer :clicks_count, default: 0, null: false
t.timestamps
end
add_index :short_urls, :slug, unique: true
The default belongs in the database rather than in the model. A counter that starts at nil
because a row was inserted by a migration, a console script or a fixture is a counter that raises
on its first increment, and NOT NULL DEFAULT 0 is the only version of "starts at zero" that holds
for every writer, not just the ones going through Active Record.
Slugs that generate themselves
The interesting line is the slug. It has to exist before validation runs, or a brand-new record fails its own uniqueness check for the wrong reason:
class ShortUrl < ApplicationRecord
DEFAULT_SLUG_SIZE = 5
attribute :slug, :string, default: -> { SecureRandom.alphanumeric(DEFAULT_SLUG_SIZE) }
validates :slug, presence: true, uniqueness: true
end
attribute takes a lambda rather than a value, and that distinction is the whole thing. A plain
default is evaluated once, when the class is loaded, and every record in the process would share one
slug. The lambda is called per instance, at initialization, which is why this works:
ShortUrl.new.slug # => "7bQxK"
ShortUrl.new.slug # => "mE2pV"
Note what that buys: the record is valid before it is saved. Nothing has to reach the database to find out whether the object is coherent, which is what makes it testable without a transaction.
SecureRandom.alphanumeric rather than hex or uuid, because the slug is going to be read aloud,
typed from a phone and pasted into a chat window. Five alphanumeric characters is about 916 million
combinations, which is comfortable until it very suddenly is not - see the end of this article.
Why the validation and the index are both required
The uniqueness: true validation runs a SELECT and then an INSERT. Between those two statements
another request can insert the same slug, and one of the two writes wins silently. The validation is
the polite path that gives a user an error message; the unique index is the one that is actually
true. Neither replaces the other, and a shortener with only the validation will hand two people the
same link eventually.
The redirect action, and why find_by!
class ShortUrlsController < ApplicationController
before_action :set_short_url
def show
@short_url.increment!(:clicks_count)
redirect_to @short_url.target_url, allow_other_host: true
end
private
def set_short_url
@short_url = ShortUrl.find_by!(slug: params[:slug])
end
end
find_by! rather than find_by, so an unknown slug raises RecordNotFound and Rails renders the
404 it already has, instead of the NoMethodError on nil that find_by would produce two lines
later.
Counting a click without restamping the row
increment! is doing more than it looks. It issues an atomic UPDATE short_urls SET clicks_count =
clicks_count + 1, so two simultaneous clicks cannot both read 4 and both write 5. And by default it
does not touch updated_at: the column moves only if you pass touch: true. That matters more than
it sounds, because updated_at on a row like this is the honest answer to "when did the content
last change", and a view counter that restamps it turns every visit into a content change - which is
exactly the sort of thing that ends up in a sitemap's lastmod and quietly asks Google to re-crawl
a page that did not move.
There is one case where increment! is the wrong tool, and it is worth knowing before you copy this
into a bigger model: on a class with optimistic locking, incrementing through Active Record bumps
lock_version, and every other request holding that record then fails with a
StaleObjectError. A counter on such a model wants a raw update_all instead. This one has no
lock_version, so increment! is the simple, correct choice.
allow_other_host, and the open redirect it exists to stop
This is the line the whole article is really about:
redirect_to @short_url.target_url, allow_other_host: true
Since Rails 7, redirect_to refuses an external host unless you say otherwise. That default is not
paranoia. Drop the flag into the wrong place and you have built an open redirect:
# Never do this.
redirect_to params[:url], allow_other_host: true
Now https://yoursite.com/u?url=https://phishing.example is a link that wears your domain, your
TLS certificate and your reputation, and lands the visitor somewhere else. It sails through the mail
filters that trust your domain. It is one of the oldest entries in the OWASP list and it is still
shipped weekly, because the fix reads like an inconvenience.
The version above is safe for one reason only: the target comes from a row you control, written
by a code path that authenticates and validates, not from the request. allow_other_host is not a
switch you flip to make an error go away. It is an assertion that you know where the URL came from,
and it is only true if you actually do.
If shorteners can be created by anyone with an account, that assertion stops holding and the
validation moves up a level: check the scheme is http or https at write time, and reject
anything else before it ever reaches a redirect.
One route, and what param: :slug changes
resources :short_urls, only: :show, param: :slug, path: "u"
Three options doing three jobs. only: :show because six routes for a feature with one action is
six routes to read. param: :slug swaps :id for :slug in the path and in params, which is
what lets the controller read params[:slug] instead of the misleading params[:id]. And path:
"u" gets the URL down to /u/kR3xQ, because every character in a shortener is one the user has to
type or read aloud.
Confirm what you actually got rather than assuming:
bin/rails routes -g short_urls
# short_url GET /u/:slug(.:format) short_urls#show
What the shortener does not do yet
Three things, named rather than left as a surprise.
Collisions get likely faster than you think. Five characters is 916 million slugs, but the
birthday bound means a 1% collision chance arrives at roughly 135,000 rows, not at 9 million. The
unique index turns each collision into a RecordNotUnique rather than a duplicate, so the fix is a
retry loop or a longer slug, and the moment to add it is before it happens.
The counter counts requests, not readers. A refresh, a preview bot, a Slack unfurl and a mail scanner each add one. Filtering those is the same problem every view counter has, and the same answer: a user-agent test and a prefetch header check, applied consistently everywhere you count.
Nothing expires. A shortener with no expiry is a permanent redirect to a URL you no longer
control once the target domain lapses. A expires_at column and a scope is twenty minutes of work
and the difference between a link and a liability.
Related quizzes
Three short ones on the machinery this article leans on:
- Two
rendercalls in one Rails action: why a redirect after a render raises, and what "and return" is actually doing. - When
upcase!returnsnilmid-chain: the bang convention that makesincrement!behave the way it does. - Measuring a Rails app with
rake stats: a number for how little code the whole feature above really is.
Comments
No comments yet. Be the first.