One of the aspects of Rails and ActiveRecord that I never have liked is using integer-based identifiers in public-facing routes. We’ve all seen something like this a million times in URLs:
GET https://example.com/projects/12345
Is there something wrong with everyone in the world knowing this is project #12345? No, not really. But I don’t like it. For an internal facing application, it doesn’t matter. But once I start to get into a multi-tenant application with many customers I prefer to keep my identifiers more obfuscated.
My primary goal here is to obscure the ability for customers of my apps to see how many records are in my database. Nobody needs to know if they are the first account in our system, or the millionth.
One solution to this problem is to use UUIDs for primary keys. Something like this, in a table creation migration file is all that’s needed to set up UUID:
create_table :projects, id: :uuid do |t|
t.string :name
t.timestamps
end
An example of a Rails migration using a UUID as the primary key.
But here’s the thing: UUIDs are ugly, long and annoying to work with. This isn't much better for me:
GET https://example.com/projects/ff39caa6-3dc1-4d9a-9f67-46362a48dbc8
I also don’t want to change all of my primary keys from integers to UUIDs! I like the integers internally, but I just don’t want them exposed publicly.
What I’m looking towards is something similar to the gold standard of APIs and identifiers: Stripe. Stripe prefixes its identifiers with a prefix related to the underlying model, which is really nice. So Project
with an ID of 12345, becomes something like proj_73OofhdO
.[1]
This is where a typical developers would reach for a gem. There’s plenty of them, FriendlyID looks really nice. Prefixed IDs is another great one, and has a very similar implementation to mine.
But there’s something you should know about my style: I do not like dependencies. I do not like them at all. I do not like having to maintain a list of hundreds of gems in my projects. I’m a software developer, not a collector of other people’s code. Dependencies are fine if you have a good reason, but this is not one of them.
My solution is simple and is only two files. I copy these between projects when I kick things off and I never have to think about it again. Simple, portable, and maintainable.
Enter the Cfid
Naming things is hard, as you may know. I call my identifier a “Cfid”, short for Customer Facing Identifier. Or maybe even Customer Friendly Identifier, whatever. It’s just something that I use when the customer is presented with a URL with an identifier, so the name fits.
To do the heavy lifting, I have a model class named Cfid
. Here are the basics of creating a Cfid
:
class Cfid
attr_reader :prefix
attr_reader :id
def initialize(prefix:, id:)
@prefix = prefix
@id = id
end
def encoded_id
sqids.encode([id])
end
def to_s
"#{ prefix }_#{ encoded_id }"
end
def self.alphabet
# Rails.application.credentials.dig(:cfid, :alphabet)
# - or -
# ENV["CFID_ALPHABET"]
#
# This is a hard-coded example for this explanation:
"GWkjSVLyXKsoJ1nziMNue8Rafqlgxb4IhQCBtr03HUYAd9v7mP2D6FE5wOpTcZ"
end
def self.sqids
@sqids ||= Sqids.new(alphabet:, min_length: 8)
end
private
def sqids
self.class.sqids
end
end
A basic Cfid class for encoding a record
Let’s dig into what’s going on here a bit with an example of usage. This basic implementation gives us the ability to encode the record id. In this case I'm just using Project #1. Here's how to turn Project #1 into a prefix and encoded Cfid:
cfid = Cfid.new(prefix: "proj", id: 1)
cfid.to_s # => "proj_73OofhdO"
Using the new Cfid class to generate an encoded id.
Nice and handy! We have created an encoded id, with a per-model prefix. In this case I’m using proj
for Project
, but you can use anything you like.
The magic under the hood here is the Sqids gem (formerly known as Hashids), which is implemented in a ton of languages and is a very simple utility for encoding and decoding integer values into an obfuscated string.[2]
One important piece to mention here is the alphabet parameter for the Sqids setup. An "alphabet" lets us choose a specific set of letters and numbers to use for the encoding of the ids. You could certainly just use the standard alphabet, but that would be very easily reversible. I like to generate a random alphabet, just to obfuscate things a bit using a snippet like this:
puts ((0..9).map(&:to_s) + ('A'..'Z').to_a + ('a'..'z').to_a).shuffle.join("")
A script to output a randomized alphabet
Decoding Cfids
I've demonstrated a simple way here to encode your primary key, but how do we look up the model from the id? Here's the Cfid
class again, but with a way to find a specific record for a given model:
class Cfid
# ...
def self.find(cfid)
prefix, encoded_id = cfid.to_s.strip.split("_")
return nil unless prefix.present? && encoded_id.present?
ids = sqids.decode(encoded_id)
return nil unless ids.present?
new(prefix:, id: ids.last)
end
# ...
end
A new class method for finding a record by model and Cfid string.
With this new find
method, we can load the original record back from the Cfid
:
cfid = Cfid.find("proj_73OofhdO")
cfid.id # => 1
cfid.prefix # => "proj"
Decoding a Cfid string with a valid encoded id.
This is all working nicely enough, but it's very cumbersome. I want to deal with models, not ids and remembering prefixes. Let's integrate the Cfid
a bit more into our application context.
The Concern
I love concerns in Rails. Technically this is just a syntax improvement over Ruby's Module
implementation, but I'm calling them concerns for this type of use.
In order to make Cfid
a bit more usable and friendly inside of our application, I'm going to mix in the following concern into all models where I'd like to use customer facing ids:
module CustomerFacingIdentifiable
extend ActiveSupport::Concern
def cfid
if persisted?
Cfid.new(prefix: cfid_prefix, id:)
end
end
def cfid_prefix
self.class.cfid_prefix
end
def to_cfid
cfid&.to_s
end
class_methods do
def cfid_prefix=(prefix)
@cfid_prefix = prefix.to_s
end
def cfid_prefix
@cfid_prefix || default_prefix_for_cfid
end
# Fallback to a calculated prefix, if none is provided for this model.
#
# Project => "pr"
# ProjectTask => "prta"
def default_prefix_for_cfid
self.model_name.name.underscore.split("_").map { |s| s[0..1] }.join("")
end
def customer_facing_prefix(value)
self.cfid_prefix = value
end
end
end
A Rails concern to mix in Cfid capability to a model.
Then, to use the concern and its capabilities, we need to include the concern in our model. In this case, the Project
model. The concern does provide a primitive default prefix for us, but let's use a specific prefix:
class Project < ApplicationRecord
include CustomerFacingIdentifiable
customer_facing_prefix :proj
end
Adding Cfid capability to a model with the concern
Now we have the ability to generate a Cfid
easily from a model:
project = Project.find(1)
# Get a Cfid instance
project.cfid # => Cfid.new(prefix: "proj", id: 1)
# Or more likely, just convert it to a string id:
project.to_cfid # => "proj_73OofhdO"
Creating a new Cfid from a model.
This is much cleaner than remembering ids and prefixes! Just like earlier, we now can generate a Cfid string from a model, but we still need to find a model based on an existing id. For that, I'll add another method to the Cfid model itself so we can find a particular model by its prefix:
class Cfid
# A memoized list of models and prefix for easy lookup later
#
# If any prefixes are changed, make sure you restart your server or console
# to reload this value.
def self.models
return @models if defined?(@models)
@models = {}
Dir.glob(Rails.root.join("app/models/**/*.rb")).each do |path|
class_name = path.gsub(Rails.root.join("app/models/").to_s, "").gsub(/\.rb$/, "").gsub("concerns/", "").classify
klass = class_name.safe_constantize
next unless klass&.respond_to?(:cfid_prefix)
clean_prefix = klass.cfid_prefix.to_s.gsub(/_$/, "").to_sym
next unless clean_prefix.present?
@models[clean_prefix] = klass
end
@models
end
end
A models method to keep track of prefixes and models.
This is a bit of a gnarly method. All we are doing here is looping through all Rails model files (less concerns) and checking for any of those models that have a cfid_prefix
. We won't use this method directly in our application, but it gives the Cfid the ability to find a specific model class by prefix. Here's what the models method generates:
Cfid.models # => { "proj" => Project }
The output of Cfid.models method
The .models
method allows us to add another method to the Cfid
class that returns the model record for the given id:
class Cfid
def model
self.class.models[prefix]
end
def record
if model.present? && id.present?
model.find_by(id:)
end
end
end
Find a model and record instance for a given prefix and id
Finally, we're able to generate and look up a Cfid string for a given model:
project = Project.find(1)
project.to_cfid # => "proj_73OofhdO"
Cfid.find("proj_73OofhdO").record # => Project(1)
Generating and finding a Cfid
I still don't love having to ever refer to the Cfid
class in my application code. I'd prefer to have a finder method that looks up a Project
by its customer id value. Let's hop back into our CustomerFacingIdentifiable
concern and add that:
module CustomerFacingIdentifiable
# ...
class_methods do
def find_by_cfid(cfid_str)
cfid = Cfid.find(cfid_str)
return nil unless cfid.present?
return nil unless cfid.prefix == cfid_prefix
cfid.record
end
def find_by_cfid!(cfid)
if record = find_by_cfid(cfid)
record
else
raise ActiveRecord::RecordNotFound
end
end
end
# ...
end
Adding finder methods to CustomerFacingIdentifiable concern
Continuing along with our sample, we can now simply use the following code to generate a cfid and lookup the model:
project = Project.find(1)
project.to_cfid # => "proj_73OofhdO"
Project.find_by_cfid("proj_73OofhdO") # => Project(1)
Project.find_by_cfid("proj_xyz") # => nil
Project.find_by_cfid!("proj_73OofhdO") # => Project(1)
Project.find_by_cfid!("proj_xyz") # => raises ActiveRecord::RecordNotFound
Generating a Cfid and retrieving a record from Cfid
Bringing it all together
Bringing us back to the top, our main goal here was to create obfuscated routes for our models. Rails kindly offers a method (to_param
) that determines how ActiveRecord instances are turned into routes, so we'll hook into that for our Cfid-enabled models. Once again, let’s modify our CustomerFacingIdentifiable
concern to override the to_key
and to_param
methods that Rails relies upon to build routes:
module CustomerFacingIdentifiable
# ...
def key
[ to_param ]
end
def to_param
to_cfid
end
# ...
end
Modifying our concern to allow Rails to generate routes using Cfids
With these additions, a routing helper for our original project above might now look like this:
GET https://example.com/projects/proj_tvhCdE5D
This route is generated using normal Rails routing helpers:
project_path(@project)
Last but not least, in our controller where we look up the project we'll change the find
method to use find_by_cfid!
:
class ProjectsController < ApplicationController
def show
@project = Project.find_by_cfid!(params[:id])
end
end
And there we have it! Two files (the Cfid
model, and our CustomerFacingIdentifiable
concern) and we have a basic working version of customer facing identifiers.
Here are the full final files that are ready to use: [3]
# app/models/cfid.rb
class Cfid
attr_reader :prefix
attr_reader :id
def initialize(prefix:, id:)
@prefix = prefix
@id = id
end
def encoded_id
sqids.encode([id])
end
def model
self.class.models[prefix]
end
def record
if model.present? && id.present?
model.find_by(id:)
end
end
def to_s
"#{ prefix }_#{ encoded_id }"
end
def self.alphabet
# Rails.application.credentials.dig(:cfid, :alphabet)
# - or -
# ENV["CFID_ALPHABET"]
#
# This is a hard-coded example for this explanation:
"GWkjSVLyXKsoJ1nziMNue8Rafqlgxb4IhQCBtr03HUYAd9v7mP2D6FE5wOpTcZ"
end
def self.find(cfid)
prefix, encoded_id = cfid.to_s.strip.split("_")
return nil unless prefix.present? && encoded_id.present?
ids = sqids.decode(encoded_id)
return nil unless ids.present?
new(prefix:, id: ids.last)
end
# A memoized list of models and prefix for easy lookup later
#
# If any prefixes are changed, make sure you restart your server or console
# to reload this value.
def self.models
return @models if defined?(@models)
@models = {}
Dir.glob(Rails.root.join("app/models/**/*.rb")).each do |path|
class_name = path.gsub(Rails.root.join("app/models/").to_s, "").gsub(/\.rb$/, "").gsub("concerns/", "").classify
klass = class_name.safe_constantize
next unless klass&.respond_to?(:cfid_prefix)
clean_prefix = klass.cfid_prefix.to_s.gsub(/_$/, "").to_s
next unless clean_prefix.present?
@models[clean_prefix] = klass
end
@models
end
def self.sqids
@sqids ||= Sqids.new(alphabet:, min_length: 8)
end
private
def sqids
self.class.sqids
end
end
# app/concerns/customer_facing_identifiable.rb
module CustomerFacingIdentifiable
extend ActiveSupport::Concern
def cfid
if persisted?
Cfid.new(prefix: cfid_prefix, id:)
end
end
def cfid_prefix
self.class.cfid_prefix
end
def key
[ to_param ]
end
def to_cfid
cfid&.to_s
end
def to_param
to_cfid
end
class_methods do
def cfid_prefix=(prefix)
@cfid_prefix = prefix.to_s
end
def cfid_prefix
@cfid_prefix || default_prefix_for_cfid
end
def customer_facing_prefix(value)
self.cfid_prefix = value
end
# Fallback to a calculated prefix, if none is provided for this model.
#
# Project => "pr"
# ProjectTask => "prta"
def default_prefix_for_cfid
self.model_name.name.underscore.split("_").map { |s| s[0..1] }.join("")
end
def find_by_cfid(cfid_str)
cfid = Cfid.find(cfid_str)
return nil unless cfid.present?
return nil unless cfid.prefix == cfid_prefix
cfid.record
end
def find_by_cfid!(cfid)
if record = find_by_cfid(cfid)
record
else
raise ActiveRecord::RecordNotFound
end
end
end
end
Also available in a Gist.
Drop me a line any time.
-
One side benefit of prefixing the identifiers with the name of the model is they become very easily findable in application logs. It’s difficult to search log files for identifiers some times, especially when an app has a lot of models with similar record counts. Searching for
proj_abc123
is very clear and only returns the specific record I need ↩︎ -
Yes, I know I said I don’t like external dependencies. That’s true, but I don’t mind utilities like Sqids at all. These are easily upgradeable, do one thing well, and don’t prescribe how to use it in your app. ↩︎
-
We haven’t taken care of much error checking or handling of exceptions, but that's beyond the scope of this simple demo. ↩︎