Skip to content
A Rails plugin for exposing non-sequential (Youtube-like) string IDs instead of the sequential integer IDs provided by Rails
Branch: master
Clone or download
Fetching latest commit…
Cannot retrieve the latest commit at this time.
Permalink
Type Name Latest commit message Commit time
Failed to load latest commit information.
bin
lib
test
.gitignore
.travis.yml
Gemfile
Gemfile.lock
MIT-LICENSE
README.md
Rakefile
acts_as_having_string_id.gemspec

README.md

ActsAsHavingStringId

codecov

A Rails plugin for exposing non-sequential (Youtube-like) string IDs instead of the sequential integer IDs provided by Rails.

Before, your API may look like

GET /users/123
{
  "id": 123,
  "name": "Alice O'User"
}

After

GET /users/9w63Hubh4oL
{
  "id": "9w63Hubh4oL",
  "name": "Alice O'User"
}

Problem

Exposing sequential integer IDs has several drawbacks:

Why not use UUIDs?

"But why not just use UUIDs", you ask? Rails has built-in support for them. But they are very long. Exposing them in an API is okay, but in a URL just doesn't look nice

http://example.com/objects/be398f64-320f-4731-be73-74699e6795bc

Even base62 encoding that ID is very long

http://example.com/objects/27WzQMxpvINgio2w5Xt0hk

64-bit integers would be optimal, but they can't be random: the risk of collisions would be too high.

Our solution

Rails makes heavy use of sequential integer IDs internally, but there's no need of exposing them. ActsAsHavingStringId provides an alternative string representation of your IDs. This representation is

base62(tea(id, md5(ModelClass.name + Rails.application.secrets.string_id_key)))

The representation looks something like "E0znqip4mRA".

tea above is the "New variant" of the Tiny Encryption Algorithm. You should probably not consider your id to be forever secret, but it should be pretty hard to figure out from the string representation.

Your controllers will continue to work without modification, but will start to accept string IDs. So if http://example.com/orders/104 worked before, something like http://example.com/orders/E0znqip4mRA should magically work.

Usage

First, set up your secrets.yml:

development:
  string_id_key: notverysecret

test:
  string_id_key: notverysecreteither

production:
  string_id_key: <%= ENV["STRING_ID_KEY"] %>

Then, call the method in your model class, after any relations to other models:

class MyModel < ApplicationRecord
  has_many :my_other_model
  acts_as_having_string_id
end

The id of your model will now not be an int, but rather an instance of ActsAsHavingStringId::StringId. As an example:

> m = MyModel.create!
> m.id
=> 1/7EajpSfdWIf
> m.id.to_i
=> 1
> m.id.to_s
=> "7EajpSfdWIf"

All ActiveRecord functions will continue to accept int IDs, but will now also accept the string representation as input:

> MyModel.find("7EajpSfdWIf")
=> #<MyModel id: 1/7EajpSfdWIf, created_at: "2016-08-31 13:27:02", updated_at: "2016-08-31 13:27:02">
> MyModel.where(id: "7EajpSfdWIf")
=> #<ActiveRecord::Relation [#<MyModel id: 1/7EajpSfdWIf, created_at: "2016-08-31 13:27:02", updated_at: "2016-08-31 13:27:02">]>

In all associated models, foreign keys to your model will also be this new type of id.

> MyOtherModel.create! my_model: MyModel.first
=> #<MyOtherModel id: 1, my_model_id: 1/GBpjdLndSR0, created_at: "2016-09-07 10:32:24", updated_at: "2016-09-07 10:32:24">

Then, for exposing your string ID, make sure to always use id.to_s. For example, if you're using ActiveModelSerializers:

class UserSerializer < ActiveModel::Serializer
  attributes :id, :name

  def id
    object.id.to_s
  end
end

You can get the string representation of an ID from a class without having the instance

> MyModel.id_string(1)
=> "7EajpSfdWIf"

And, conversely, getting the ID from the string representation

> MyModel.id_int("7EajpSfdWIf")
=> 1

And that's just about it!

But I'm getting these weird type cast errors

If you have has_many :through relations in your app, you may need to add a few associations more in your app in order for Rails to understand how your data model fits together. For example in this model

class Author < ApplicationRecord
end

class Blog < ApplicationRecord
  has_many :posts
  has_many :authors, through: :posts
  acts_as_having_string_id
end

class Post < ApplicationRecord
  belongs_to :blog
  belongs_to :author
end

an attempt to do Blog.first.authors will raise a TypeError: can't cast ActsAsHavingStringId::StringId.

In order to fix this, you'll need to add the missing association has_many :posts to Author.

TODO

  • Since the MyModel.find("7EajpSfdWIf") functionality depends on the argument now being a string, MyModel.find("5") will no longer mean MyModel.find(5), but rather MyModel.find(4387534) or something. Is that a problem?
  • It's a potential security problem that we don't force strings from controllers (integer id coming from JSON postdata will make it find by original id)

Installation

Add this line to your application's Gemfile:

gem 'acts_as_having_string_id'

And then execute:

$ bundle

Or install it yourself as:

$ gem install acts_as_having_string_id

Contributing

To contribute, fork and clone the repo, edit the code (don't change the version number of the gem). Add tests, run them using

bin/test

Then create a pull request.

To build the gem (this is mostly for myself), run

gem build acts_as_having_string_id.gemspec

Acknowledgements

The Tiny Encryption Algorithm was created by David Wheeler and Roger Needham of the Cambridge Computer Laboratory. This library's implementation is based on this code by Jeremy Hinegardner.

License

The gem is available as open source under the terms of the MIT License.

You can’t perform that action at this time.