encoded_ids 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 31d25d05fa2a21196dee435efb25c144e2cf90ee3837c8da263f49c22f1e8825
4
+ data.tar.gz: 54af5781a3455a70b96b5033e6dc9e58b1ea0c8cc3b64a0afc4b5dbe83fd5c35
5
+ SHA512:
6
+ metadata.gz: bebf4a30fd0307ad7514e57088cca68bfab718789ef7d5d13506bd6bf312caf3d3f02442e1d87186c3fc5e3c5c9dbbf2dbf84fd71188537af41472a1048a3583
7
+ data.tar.gz: b83f9254cae57f08270f9c4da0db06fef2883b07641ad20d0fe6f00b6e25d60b2a3ca1737d2cd993b84ea144140de382268f29bfd5af9bfdcc3bf8738dbbdd6e
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --require spec_helper
2
+ --color
3
+ --format documentation
data/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.0.0] - 2026-02-09
9
+
10
+ ### Added
11
+ - Initial stable release
12
+ - `HashidEncodedIds` concern for integer primary keys
13
+ - `UuidEncodedIds` concern for UUID primary keys
14
+ - Automatic `to_param` override for Rails URL generation
15
+ - Overridden `find` method to accept both internal and public IDs
16
+ - `find_by_public_id` and `find_by_public_id!` class methods
17
+ - Controller helpers: `find_by_any_id` and `find_by_any_id!`
18
+ - Compositional prefix support with `add_public_id_segment`
19
+ - Configurable hash length for hashids
20
+ - Global configuration via `EncodedIds.configure`
21
+ - Automatic Rails integration via Railtie
data/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jasper Mayone
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,254 @@
1
+ # EncodedIds
2
+
3
+ Stripe-like public IDs for Rails models. Generate API-friendly identifiers like `usr_k5qx9z` or `org_4k8xJm2pN9qW` that:
4
+
5
+ - Hide sequential integer IDs from your API
6
+ - Provide type context in the ID itself (the prefix tells you it's a user, organization, etc.)
7
+ - Work seamlessly with Rails routing and controllers
8
+ - Support both integer and UUID primary keys
9
+
10
+ ## Installation
11
+
12
+ Add to your Gemfile:
13
+
14
+ ```ruby
15
+ gem 'encoded_ids'
16
+ ```
17
+
18
+ Or install directly:
19
+
20
+ ```bash
21
+ gem install encoded_ids
22
+ ```
23
+
24
+ ## Quick Start
25
+
26
+ ### For models with integer IDs (uses hashids):
27
+
28
+ ```ruby
29
+ class User < ApplicationRecord
30
+ include EncodedIds::HashidIdentifiable
31
+ set_public_id_prefix :usr
32
+ end
33
+
34
+ user = User.first
35
+ user.public_id # => "usr_k5qx9z"
36
+ user.to_param # => "k5qx9z" (used in URLs - no prefix by default)
37
+
38
+ # Find by public_id (with or without prefix)
39
+ User.find("k5qx9z") # => <User id: 1>
40
+ User.find("usr_k5qx9z") # => <User id: 1>
41
+ User.find_by_public_id("usr_k5qx9z") # => <User id: 1>
42
+ ```
43
+
44
+ ### For models with UUID IDs (uses base62 encoding):
45
+
46
+ ```ruby
47
+ class Organization < ApplicationRecord
48
+ include EncodedIds::UuidIdentifiable
49
+ set_public_id_prefix "org"
50
+ end
51
+
52
+ org = Organization.first
53
+ org.public_id # => "org_4k8xJm2pN9qW"
54
+ org.to_param # => "4k8xJm2pN9qW" (no prefix by default)
55
+
56
+ # Find by public_id (with or without prefix)
57
+ Organization.find("4k8xJm2pN9qW") # => <Organization id: "uuid...">
58
+ Organization.find("org_4k8xJm2pN9qW") # => <Organization id: "uuid...">
59
+ Organization.find_by_public_id("org_4k8xJm2pN9qW") # => <Organization id: "uuid...">
60
+ ```
61
+
62
+ ## Features
63
+
64
+ ### Automatic URL Parameter Handling
65
+
66
+ The gem overrides `to_param` automatically, so Rails will use the hashid/encoded ID in all your URLs:
67
+
68
+ ```ruby
69
+ link_to "View User", user_path(user)
70
+ # => /users/k5qx9z (clean URLs without prefix by default)
71
+
72
+ redirect_to @user
73
+ # => /users/k5qx9z
74
+
75
+ # You can still use the full public_id with prefix if needed:
76
+ User.find_by_public_id("usr_k5qx9z") # Works!
77
+ ```
78
+
79
+ ### Overridden `find` Method
80
+
81
+ The `find` method is automatically enhanced to accept internal IDs, hashids, and full public IDs:
82
+
83
+ ```ruby
84
+ # These all work:
85
+ User.find(1) # Regular internal ID
86
+ User.find("k5qx9z") # Hashid (no prefix)
87
+ User.find("usr_k5qx9z") # Full public ID (with prefix)
88
+ ```
89
+
90
+ ### Compositional Prefixes (Hashid only)
91
+
92
+ For namespaced models, you can build prefixes from multiple segments:
93
+
94
+ ```ruby
95
+ class Intel::Tool::PhoneNumber < ApplicationRecord
96
+ include EncodedIds::HashidIdentifiable
97
+ add_public_id_segment :int
98
+ add_public_id_segment :tool
99
+ add_public_id_segment :phn
100
+ end
101
+
102
+ phone.public_id # => "int_tool_phn_k5qx9z"
103
+ ```
104
+
105
+ ### Configurable Hash Length
106
+
107
+ For tables with many records, increase the minimum hash length:
108
+
109
+ ```ruby
110
+ class Enrollment < ApplicationRecord
111
+ include EncodedIds::HashidIdentifiable
112
+ set_public_id_prefix :enr, min_hash_length: 12
113
+ end
114
+
115
+ enrollment.public_id # => "enr_x5qp9z2m8n4k"
116
+ enrollment.to_param # => "x5qp9z2m8n4k"
117
+ ```
118
+
119
+ ### Route Behavior Configuration
120
+
121
+ By default, `to_param` returns just the hash (no prefix) for cleaner URLs. You can change this globally or per-model:
122
+
123
+ ```ruby
124
+ # Global configuration - include prefix in all URLs (Stripe style)
125
+ EncodedIds.configure do |config|
126
+ config.use_prefix_in_routes = true
127
+ end
128
+
129
+ # Per-model override
130
+ class User < ApplicationRecord
131
+ include EncodedIds::HashidIdentifiable
132
+ set_public_id_prefix :usr, use_prefix_in_routes: true
133
+ end
134
+
135
+ user.to_param # => "usr_k5qx9z" instead of just "k5qx9z"
136
+ ```
137
+
138
+ ### Controller Helpers
139
+
140
+ Automatically included in all controllers:
141
+
142
+ ```ruby
143
+ class UsersController < ApplicationController
144
+ def show
145
+ # Accepts both internal ID and public_id
146
+ @user = find_by_any_id(User, params[:id])
147
+
148
+ # Or with bang method (raises RecordNotFound)
149
+ @user = find_by_any_id!(User, params[:id])
150
+ end
151
+ end
152
+ ```
153
+
154
+ ## Configuration
155
+
156
+ Create an initializer at `config/initializers/encoded_ids.rb`:
157
+
158
+ ```ruby
159
+ EncodedIds.configure do |config|
160
+ # Hashid configuration (for integer IDs)
161
+ config.hashid_salt = Rails.application.credentials.dig(:hashid, :salt)
162
+ config.hashid_min_length = 8
163
+ config.hashid_alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
164
+
165
+ # Base62 alphabet (for UUID encoding)
166
+ config.base62_alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
167
+
168
+ # Separator between prefix and hash
169
+ config.separator = "_"
170
+
171
+ # Whether to include prefix in to_param URLs
172
+ # false (default) = /users/k5qx9z
173
+ # true = /users/usr_k5qx9z (Stripe style)
174
+ config.use_prefix_in_routes = false
175
+ end
176
+ ```
177
+
178
+ **Important:** For production, set a unique `hashid_salt` in your credentials:
179
+
180
+ ```bash
181
+ rails credentials:edit
182
+ ```
183
+
184
+ ```yaml
185
+ hashid:
186
+ salt: your-unique-salt-here # Generate with: SecureRandom.hex(32)
187
+ ```
188
+
189
+ ## How It Works
190
+
191
+ ### Integer IDs (HashidIdentifiable)
192
+
193
+ Uses [hashid-rails](https://github.com/jcypret/hashid-rails) to encode integer IDs into short, URL-safe strings. The encoding is:
194
+ - Reversible (can decode back to the integer ID)
195
+ - Obfuscated (not sequential, not easily guessable)
196
+ - Short (configurable minimum length)
197
+
198
+ ### UUID IDs (UuidIdentifiable)
199
+
200
+ Uses base62 encoding to shorten UUIDs from 36 characters to ~22 characters. Since UUIDs are already random and non-sequential, this just adds the prefix and shortens the representation.
201
+
202
+ ## API
203
+
204
+ ### Model Methods (both types)
205
+
206
+ ```ruby
207
+ # Instance methods
208
+ model.public_id # Returns the full public ID
209
+ model.to_param # Returns the public ID (used by Rails in URLs)
210
+
211
+ # Class methods
212
+ Model.find(id) # Accepts both internal and public IDs
213
+ Model.find_by_public_id(id) # Only finds by public ID
214
+ Model.find_by_public_id!(id) # Like above, but raises if not found
215
+ Model.get_public_id_prefix # Returns the configured prefix
216
+ ```
217
+
218
+ ### Controller Methods
219
+
220
+ ```ruby
221
+ find_by_any_id(Model, id) # Returns record or nil
222
+ find_by_any_id!(Model, id) # Returns record or raises RecordNotFound
223
+ ```
224
+
225
+ ## Migration from Existing Code
226
+
227
+ If you have existing `PublicIdentifiable` concerns in your app:
228
+
229
+ 1. Add `encoded_ids` to your Gemfile
230
+ 2. Replace `include PublicIdentifiable` with:
231
+ - `include EncodedIds::HashidIdentifiable` (for integer IDs)
232
+ - `include EncodedIds::UuidIdentifiable` (for UUID IDs)
233
+ 3. Update your hashid initializer to use `EncodedIds.configure`
234
+ 4. Remove your old `PublicIdentifiable` concern
235
+ 5. Controllers automatically get the helper methods
236
+
237
+ ## Why Public IDs?
238
+
239
+ Public IDs provide several benefits:
240
+
241
+ 1. **Security**: Don't expose sequential integer IDs that leak information about your data volume
242
+ 2. **Type Safety**: The prefix makes it obvious what type of resource an ID refers to
243
+ 3. **API Ergonomics**: Easier to debug and understand API calls
244
+ 4. **Future-Proofing**: Can change internal IDs without breaking external APIs
245
+
246
+ Inspired by Stripe's API design and the [hashid-rails](https://github.com/jcypret/hashid-rails) gem, as well as my time at Hack Club which used a base version of this extensively.
247
+
248
+ ## License
249
+
250
+ MIT
251
+
252
+ ## Contributing
253
+
254
+ Bug reports and pull requests are welcome on GitHub at https://github.com/jaspermayone/encoded_ids
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/encoded_ids/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "encoded_ids"
7
+ spec.version = EncodedIds::VERSION
8
+ spec.authors = ["Jasper Mayone"]
9
+ spec.email = ["me@jaspermayone.com"]
10
+
11
+ spec.summary = "Stripe-like public IDs for Rails models"
12
+ spec.description = "Provides Stripe-style public identifiers (like usr_abc123) for Rails models using hashids or base62 encoding. Supports both integer and UUID primary keys, with automatic URL parameter handling and controller helpers."
13
+ spec.homepage = "https://github.com/jaspermayone/encoded_ids"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 3.0.0"
16
+
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = spec.homepage
19
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
20
+
21
+ spec.files = Dir.chdir(__dir__) do
22
+ `git ls-files -z`.split("\x0").reject do |f|
23
+ (File.expand_path(f) == __FILE__) ||
24
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
25
+ end
26
+ end
27
+ spec.bindir = "exe"
28
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
29
+ spec.require_paths = ["lib"]
30
+
31
+ spec.add_dependency "rails", ">= 6.1"
32
+ spec.add_dependency "hashid-rails", "~> 1.0"
33
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Example controller usage
4
+
5
+ class UsersController < ApplicationController
6
+ # Standard Rails controller actions work automatically
7
+ # because to_param is overridden to use public_id
8
+
9
+ def show
10
+ # Method 1: Use the overridden find method
11
+ # Accepts both internal ID and public_id
12
+ @user = User.find(params[:id])
13
+
14
+ # Method 2: Explicitly use find_by_public_id
15
+ # @user = User.find_by_public_id(params[:id])
16
+
17
+ # Method 3: Use the controller helper (accepts both formats)
18
+ # @user = find_by_any_id!(User, params[:id])
19
+ end
20
+
21
+ def update
22
+ @user = User.find(params[:id])
23
+
24
+ if @user.update(user_params)
25
+ # Automatically redirects to /users/:public_id
26
+ redirect_to @user, notice: "User updated"
27
+ else
28
+ render :edit
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def user_params
35
+ params.require(:user).permit(:name, :email)
36
+ end
37
+ end
38
+
39
+ # API controller example
40
+ class Api::V1::UsersController < Api::V1::BaseController
41
+ def show
42
+ # Controller helper is great for APIs
43
+ @user = find_by_any_id!(User, params[:id])
44
+ render json: @user
45
+ end
46
+
47
+ def lookup
48
+ # Can mix internal and public IDs in the same endpoint
49
+ users = params[:ids].map { |id| find_by_any_id(User, id) }.compact
50
+ render json: users
51
+ end
52
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Example configuration for config/initializers/encoded_ids.rb
4
+ #
5
+ # Copy this file to your Rails app's config/initializers/ directory
6
+ # and customize as needed.
7
+
8
+ EncodedIds.configure do |config|
9
+ # Hashid configuration (for integer IDs)
10
+ # IMPORTANT: Set a unique salt in production via credentials
11
+ # rails credentials:edit
12
+ # Add: hashid: { salt: "your-unique-salt-here" }
13
+ config.hashid_salt = Rails.application.credentials.dig(:hashid, :salt)
14
+ config.hashid_min_length = 8
15
+ config.hashid_alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
16
+
17
+ # Base62 alphabet (for UUID encoding)
18
+ # Standard base62 includes both uppercase and lowercase
19
+ config.base62_alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
20
+
21
+ # Separator between prefix and hash
22
+ # Default is "_" for Stripe-style IDs like "usr_abc123"
23
+ # You could use "-" for "usr-abc123" or any other character
24
+ config.separator = "_"
25
+
26
+ # Whether to include prefix in to_param URLs
27
+ # false (default) = /users/k5qx9z (cleaner)
28
+ # true = /users/usr_k5qx9z (Stripe style - redundant with route path)
29
+ config.use_prefix_in_routes = false
30
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Example model configurations
4
+
5
+ # Integer primary key with simple prefix
6
+ class User < ApplicationRecord
7
+ include EncodedIds::HashidEncodedIds
8
+ set_public_id_prefix :usr
9
+ end
10
+ # user.public_id => "usr_k5qx9z"
11
+ # user.to_param => "k5qx9z" (no prefix in URLs by default)
12
+
13
+ # Integer primary key with longer hash for high-volume tables
14
+ class Event < ApplicationRecord
15
+ include EncodedIds::HashidEncodedIds
16
+ set_public_id_prefix :evt, min_hash_length: 12
17
+ end
18
+ # event.public_id => "evt_x5qp9z2m8n4k"
19
+
20
+ # Integer primary key with compositional prefix
21
+ class Intel::Tool::PhoneNumber < ApplicationRecord
22
+ include EncodedIds::HashidEncodedIds
23
+ add_public_id_segment :int
24
+ add_public_id_segment :tool
25
+ add_public_id_segment :phn
26
+ end
27
+ # phone.public_id => "int_tool_phn_k5qx9z"
28
+
29
+ # UUID primary key
30
+ class Organization < ApplicationRecord
31
+ include EncodedIds::UuidEncodedIds
32
+ set_public_id_prefix "org"
33
+ end
34
+ # org.public_id => "org_4k8xJm2pN9qW"
35
+
36
+ # UUID primary key with different prefix
37
+ class Team < ApplicationRecord
38
+ include EncodedIds::UuidEncodedIds
39
+ set_public_id_prefix "team"
40
+ end
41
+ # team.public_id => "team_7n2kLp4xMq8R"
42
+
43
+ # Override per-model to include prefix in routes (Stripe style)
44
+ class ApiKey < ApplicationRecord
45
+ include EncodedIds::HashidEncodedIds
46
+ set_public_id_prefix :key, use_prefix_in_routes: true
47
+ end
48
+ # api_key.public_id => "key_abc123"
49
+ # api_key.to_param => "key_abc123" (includes prefix)
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EncodedIds
4
+ class Configuration
5
+ # Hashid configuration
6
+ attr_accessor :hashid_salt, :hashid_min_length, :hashid_alphabet
7
+
8
+ # Base62 alphabet for UUID encoding
9
+ attr_accessor :base62_alphabet
10
+
11
+ # Separator between prefix and hash
12
+ attr_accessor :separator
13
+
14
+ # Whether to include the prefix in to_param URLs
15
+ # true = /users/usr_k5qx9z (Stripe style)
16
+ # false = /users/k5qx9z (cleaner)
17
+ attr_accessor :use_prefix_in_routes
18
+
19
+ def initialize
20
+ @hashid_salt = nil # Will fall back to Rails.application.secret_key_base
21
+ @hashid_min_length = 8
22
+ @hashid_alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
23
+ @base62_alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
24
+ @separator = "_"
25
+ @use_prefix_in_routes = false
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EncodedIds
4
+ # Controller helpers for looking up records by either internal ID or public_id
5
+ #
6
+ # Automatically included in all controllers via Railtie
7
+ #
8
+ # Usage:
9
+ # # Find or return nil
10
+ # user = find_by_any_id(User, params[:user_id])
11
+ #
12
+ # # Find or raise RecordNotFound
13
+ # user = find_by_any_id!(User, params[:user_id])
14
+ #
15
+ module ControllerHelpers
16
+ extend ActiveSupport::Concern
17
+
18
+ # Find a record by internal ID, public_id (with prefix), or hashid/encoded UUID (without prefix)
19
+ def find_by_any_id(model_class, id)
20
+ return nil if id.blank?
21
+
22
+ # If it contains the separator, it's a full public_id with prefix
23
+ if id.to_s.include?(EncodedIds.configuration.separator)
24
+ model_class.find_by_public_id(id)
25
+ else
26
+ # Use the model's find method which handles both hashids and regular IDs
27
+ model_class.find(id)
28
+ end
29
+ rescue ActiveRecord::RecordNotFound
30
+ nil
31
+ end
32
+
33
+ # Find a record by either internal ID or public_id, raising if not found
34
+ def find_by_any_id!(model_class, id)
35
+ result = find_by_any_id(model_class, id)
36
+ raise ActiveRecord::RecordNotFound.new(nil, model_class.name) if result.nil?
37
+
38
+ result
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EncodedIds
4
+ # HashidIdentifiable for models with integer primary keys using hashids
5
+ #
6
+ # Usage:
7
+ # class User < ApplicationRecord
8
+ # include EncodedIds::HashidIdentifiable
9
+ # set_public_id_prefix :usr
10
+ # end
11
+ #
12
+ # user = User.first
13
+ # user.public_id # => "usr_k5qx9z"
14
+ # User.find_by_public_id("usr_k5qx9z") # => <User id: 1>
15
+ #
16
+ # Compositional prefixes:
17
+ # class Intel::Tool::PhoneNumber < ApplicationRecord
18
+ # include EncodedIds::HashidIdentifiable
19
+ # add_public_id_segment :int
20
+ # add_public_id_segment :tool
21
+ # add_public_id_segment :phn
22
+ # end
23
+ #
24
+ # phone.public_id # => "int_tool_phn_k5qx9z"
25
+ #
26
+ module HashidIdentifiable
27
+ extend ActiveSupport::Concern
28
+
29
+ included do
30
+ include Hashid::Rails
31
+ class_attribute :public_id_prefix
32
+ class_attribute :public_id_segments, default: []
33
+ class_attribute :hashid_min_length, default: 8
34
+ class_attribute :use_prefix_in_routes, default: nil
35
+ end
36
+
37
+ def public_id
38
+ "#{self.class.get_public_id_prefix}#{separator}#{hashid}"
39
+ end
40
+
41
+ # Override to_param for Rails URL generation
42
+ # Respects use_prefix_in_routes configuration
43
+ def to_param
44
+ use_prefix = self.class.use_prefix_in_routes.nil? ?
45
+ EncodedIds.configuration.use_prefix_in_routes :
46
+ self.class.use_prefix_in_routes
47
+
48
+ use_prefix ? public_id : hashid
49
+ end
50
+
51
+ def separator
52
+ EncodedIds.configuration.separator
53
+ end
54
+
55
+ module ClassMethods
56
+ # Simple approach: set the full prefix directly
57
+ def set_public_id_prefix(prefix, min_hash_length: 8, use_prefix_in_routes: nil)
58
+ self.public_id_prefix = prefix.to_s.downcase
59
+ self.hashid_min_length = min_hash_length
60
+ self.use_prefix_in_routes = use_prefix_in_routes
61
+
62
+ # Configure hashid-rails for this model with custom length
63
+ hashid_config(min_hash_length: min_hash_length)
64
+ end
65
+
66
+ # Compositional approach: add segments that get joined with underscores
67
+ def add_public_id_segment(segment)
68
+ self.public_id_segments = public_id_segments + [segment.to_s.downcase]
69
+ end
70
+
71
+ # Find by public_id, hashid, or regular id
72
+ def find(*args)
73
+ id = args.first
74
+
75
+ # If it's a public_id string (with prefix), find by public_id
76
+ if id.is_a?(String) && id.include?(EncodedIds.configuration.separator)
77
+ find_by_public_id!(id)
78
+ # If it's a string (just the hash without prefix), try finding by hashid
79
+ elsif id.is_a?(String)
80
+ record = find_by_hashid(id)
81
+ return record if record
82
+ # Fall back to regular find in case it's actually an integer ID passed as string
83
+ super
84
+ else
85
+ super
86
+ end
87
+ end
88
+
89
+ def find_by_public_id(id)
90
+ return nil unless id.is_a?(String)
91
+
92
+ parts = id.split(EncodedIds.configuration.separator)
93
+ hash = parts.pop # last part is always the hash
94
+ prefix = parts.join(EncodedIds.configuration.separator)
95
+
96
+ return nil unless prefix == get_public_id_prefix
97
+
98
+ find_by_hashid(hash)
99
+ end
100
+
101
+ def find_by_public_id!(id)
102
+ obj = find_by_public_id(id)
103
+ raise ActiveRecord::RecordNotFound.new(nil, name) if obj.nil?
104
+
105
+ obj
106
+ end
107
+
108
+ def get_public_id_prefix
109
+ # Segments take precedence if defined
110
+ return public_id_segments.join(EncodedIds.configuration.separator) if public_id_segments.present?
111
+ return public_id_prefix.to_s.downcase if public_id_prefix.present?
112
+
113
+ raise NotImplementedError, "The #{name} model includes #{self.class.name}, but no prefix has been set. Use set_public_id_prefix or add_public_id_segment."
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EncodedIds
4
+ class Railtie < ::Rails::Railtie
5
+ initializer "encoded_ids.configure_hashid_rails" do
6
+ # Configure hashid-rails with gem settings
7
+ Hashid::Rails.configure do |config|
8
+ config.salt = EncodedIds.configuration.hashid_salt ||
9
+ Rails.application.credentials.dig(:hashid, :salt) ||
10
+ Rails.application.secret_key_base
11
+ config.min_hash_length = EncodedIds.configuration.hashid_min_length
12
+ config.alphabet = EncodedIds.configuration.hashid_alphabet
13
+ end
14
+ end
15
+
16
+ initializer "encoded_ids.include_controller_helpers" do
17
+ ActiveSupport.on_load(:action_controller) do
18
+ include EncodedIds::ControllerHelpers
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EncodedIds
4
+ # UuidIdentifiable for models with UUID primary keys using base62 encoding
5
+ #
6
+ # Since UUIDs are already non-sequential and random, this focuses on:
7
+ # 1. Adding a type prefix for easy identification
8
+ # 2. Shortening the ID for better URL/API ergonomics
9
+ # 3. Maintaining bi-directional conversion (public_id <-> UUID)
10
+ #
11
+ # Usage:
12
+ # class User < ApplicationRecord
13
+ # include EncodedIds::UuidIdentifiable
14
+ # set_public_id_prefix "usr"
15
+ # end
16
+ #
17
+ # user.public_id # => "usr_4k8xJm2pN9qW"
18
+ # User.find_by_public_id("usr_4k8xJm2pN9qW") # => User instance
19
+ #
20
+ module UuidIdentifiable
21
+ extend ActiveSupport::Concern
22
+
23
+ included do
24
+ class_attribute :public_id_prefix, default: nil
25
+ class_attribute :use_prefix_in_routes, default: nil
26
+ end
27
+
28
+ # Returns the full public ID with prefix
29
+ def public_id
30
+ prefix = self.class.get_public_id_prefix
31
+ encoded = self.class.encode_uuid(id)
32
+ "#{prefix}#{separator}#{encoded}"
33
+ end
34
+
35
+ # Returns just the encoded UUID (without prefix) for use in URLs
36
+ def encoded_id
37
+ self.class.encode_uuid(id)
38
+ end
39
+
40
+ # Override to_param for Rails URL generation
41
+ # Respects use_prefix_in_routes configuration
42
+ def to_param
43
+ use_prefix = self.class.use_prefix_in_routes.nil? ?
44
+ EncodedIds.configuration.use_prefix_in_routes :
45
+ self.class.use_prefix_in_routes
46
+
47
+ use_prefix ? public_id : encoded_id
48
+ end
49
+
50
+ def separator
51
+ EncodedIds.configuration.separator
52
+ end
53
+
54
+ # Alias for Rails conventions
55
+ alias_method :to_public_param, :public_id
56
+
57
+ class_methods do
58
+ # Set the prefix for this model's public IDs
59
+ def set_public_id_prefix(prefix, use_prefix_in_routes: nil)
60
+ self.public_id_prefix = prefix.to_s.freeze
61
+ self.use_prefix_in_routes = use_prefix_in_routes
62
+ end
63
+
64
+ # Get the configured prefix, with validation
65
+ def get_public_id_prefix
66
+ raise "Public ID prefix not set for #{name}. Call set_public_id_prefix in your model." if public_id_prefix.blank?
67
+ public_id_prefix
68
+ end
69
+
70
+ # Find by public_id, encoded UUID, or regular UUID
71
+ def find(*args)
72
+ id = args.first
73
+
74
+ # If it's a public_id string (with prefix), find by public_id
75
+ if id.is_a?(String) && id.include?(EncodedIds.configuration.separator)
76
+ find_by_public_id!(id)
77
+ # If it's a string that looks like an encoded UUID (not a standard UUID format)
78
+ elsif id.is_a?(String) && !id.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
79
+ uuid = decode_to_uuid(id)
80
+ return find_by(id: uuid) if uuid
81
+ # Fall back to regular find
82
+ super
83
+ else
84
+ super
85
+ end
86
+ end
87
+
88
+ # Find a record by its public ID
89
+ def find_by_public_id(public_id)
90
+ return nil if public_id.blank?
91
+
92
+ prefix = get_public_id_prefix
93
+ separator = EncodedIds.configuration.separator
94
+ return nil unless public_id.to_s.start_with?("#{prefix}#{separator}")
95
+
96
+ encoded = public_id.to_s.sub("#{prefix}#{separator}", "")
97
+ uuid = decode_to_uuid(encoded)
98
+ return nil unless uuid
99
+
100
+ find_by(id: uuid)
101
+ end
102
+
103
+ # Find a record by its public ID, raising RecordNotFound if not found
104
+ def find_by_public_id!(public_id)
105
+ find_by_public_id(public_id) || raise(ActiveRecord::RecordNotFound, "Couldn't find #{name} with public_id=#{public_id}")
106
+ end
107
+
108
+ # Check if a string looks like a valid public ID for this model
109
+ def valid_public_id?(public_id)
110
+ return false if public_id.blank?
111
+ separator = EncodedIds.configuration.separator
112
+ public_id.to_s.start_with?("#{get_public_id_prefix}#{separator}")
113
+ end
114
+
115
+ # Encode a UUID to a shorter base62 string
116
+ def encode_uuid(uuid)
117
+ return nil if uuid.blank?
118
+
119
+ alphabet = EncodedIds.configuration.base62_alphabet
120
+
121
+ # Remove hyphens and convert to integer
122
+ hex = uuid.to_s.delete("-")
123
+ num = hex.to_i(16)
124
+
125
+ # Convert to base62
126
+ return "0" if num.zero?
127
+
128
+ result = ""
129
+ while num > 0
130
+ result = alphabet[num % 62] + result
131
+ num /= 62
132
+ end
133
+
134
+ result
135
+ end
136
+
137
+ # Decode a base62 string back to UUID format
138
+ def decode_to_uuid(encoded)
139
+ return nil if encoded.blank?
140
+
141
+ alphabet = EncodedIds.configuration.base62_alphabet
142
+
143
+ # Convert from base62 to integer
144
+ num = 0
145
+ encoded.each_char do |char|
146
+ index = alphabet.index(char)
147
+ return nil if index.nil? # Invalid character
148
+ num = num * 62 + index
149
+ end
150
+
151
+ # Convert to hex and format as UUID
152
+ hex = num.to_s(16).rjust(32, "0")
153
+ return nil if hex.length > 32 # Overflow protection
154
+
155
+ # Format as UUID: 8-4-4-4-12
156
+ "#{hex[0..7]}-#{hex[8..11]}-#{hex[12..15]}-#{hex[16..19]}-#{hex[20..31]}"
157
+ rescue StandardError
158
+ nil
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EncodedIds
4
+ VERSION = "1.0.0"
5
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hashid/rails"
4
+ require_relative "encoded_ids/version"
5
+ require_relative "encoded_ids/configuration"
6
+ require_relative "encoded_ids/hashid_identifiable"
7
+ require_relative "encoded_ids/uuid_identifiable"
8
+ require_relative "encoded_ids/controller_helpers"
9
+ require_relative "encoded_ids/railtie" if defined?(Rails::Railtie)
10
+
11
+ module EncodedIds
12
+ class << self
13
+ attr_writer :configuration
14
+
15
+ def configuration
16
+ @configuration ||= Configuration.new
17
+ end
18
+
19
+ def configure
20
+ yield(configuration)
21
+ end
22
+
23
+ def reset_configuration!
24
+ @configuration = Configuration.new
25
+ end
26
+ end
27
+ end
metadata ADDED
@@ -0,0 +1,89 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: encoded_ids
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Jasper Mayone
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '6.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '6.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: hashid-rails
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.0'
40
+ description: Provides Stripe-style public identifiers (like usr_abc123) for Rails
41
+ models using hashids or base62 encoding. Supports both integer and UUID primary
42
+ keys, with automatic URL parameter handling and controller helpers.
43
+ email:
44
+ - me@jaspermayone.com
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - ".rspec"
50
+ - CHANGELOG.md
51
+ - LICENSE.md
52
+ - README.md
53
+ - Rakefile
54
+ - encoded_ids.gemspec
55
+ - examples/controller.rb
56
+ - examples/initializer.rb
57
+ - examples/models.rb
58
+ - lib/encoded_ids.rb
59
+ - lib/encoded_ids/configuration.rb
60
+ - lib/encoded_ids/controller_helpers.rb
61
+ - lib/encoded_ids/hashid_identifiable.rb
62
+ - lib/encoded_ids/railtie.rb
63
+ - lib/encoded_ids/uuid_identifiable.rb
64
+ - lib/encoded_ids/version.rb
65
+ homepage: https://github.com/jaspermayone/encoded_ids
66
+ licenses:
67
+ - MIT
68
+ metadata:
69
+ homepage_uri: https://github.com/jaspermayone/encoded_ids
70
+ source_code_uri: https://github.com/jaspermayone/encoded_ids
71
+ changelog_uri: https://github.com/jaspermayone/encoded_ids/blob/main/CHANGELOG.md
72
+ rdoc_options: []
73
+ require_paths:
74
+ - lib
75
+ required_ruby_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: 3.0.0
80
+ required_rubygems_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: '0'
85
+ requirements: []
86
+ rubygems_version: 3.6.9
87
+ specification_version: 4
88
+ summary: Stripe-like public IDs for Rails models
89
+ test_files: []