opaque_id 1.4.0 → 1.6.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 +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +116 -0
- data/README.md +62 -70
- data/docs/_config.yml +2 -0
- data/docs/algorithms.md +4 -4
- data/docs/index.md +3 -3
- data/docs/performance.md +3 -3
- data/docs/usage.md +15 -15
- data/lib/opaque_id/version.rb +1 -1
- data/lib/opaque_id.rb +4 -1
- data/release-please-config.json +2 -1
- metadata +1 -9
- data/tasks/0001-prd-opaque-id-gem.md +0 -202
- data/tasks/0002-prd-publishing-release-automation.md +0 -206
- data/tasks/0003-prd-documentation-site.md +0 -191
- data/tasks/references/opaque_gem_requirements.md +0 -482
- data/tasks/references/original_identifiable_concern_and_nanoid.md +0 -110
- data/tasks/tasks-0001-prd-opaque-id-gem.md +0 -109
- data/tasks/tasks-0002-prd-publishing-release-automation.md +0 -177
- data/tasks/tasks-0003-prd-documentation-site.md +0 -84
@@ -1,482 +0,0 @@
|
|
1
|
-
# OpaqueId Gem - Complete File Structure
|
2
|
-
|
3
|
-
## File Structure
|
4
|
-
|
5
|
-
```
|
6
|
-
opaque_id/
|
7
|
-
├── lib/
|
8
|
-
│ ├── opaque_id.rb # Main module with generator
|
9
|
-
│ ├── opaque_id/
|
10
|
-
│ │ ├── version.rb # Version constant
|
11
|
-
│ │ └── model.rb # ActiveRecord concern
|
12
|
-
│ └── generators/
|
13
|
-
│ └── opaque_id/
|
14
|
-
│ ├── install_generator.rb # Migration generator
|
15
|
-
│ └── templates/
|
16
|
-
│ └── migration.rb.tt # Migration template
|
17
|
-
├── spec/
|
18
|
-
│ ├── spec_helper.rb
|
19
|
-
│ ├── opaque_id_spec.rb
|
20
|
-
│ └── opaque_id/
|
21
|
-
│ └── model_spec.rb
|
22
|
-
├── opaque_id.gemspec
|
23
|
-
├── Gemfile
|
24
|
-
├── Rakefile
|
25
|
-
├── README.md
|
26
|
-
├── LICENSE.txt
|
27
|
-
└── CHANGELOG.md
|
28
|
-
```
|
29
|
-
|
30
|
-
## opaque_id.gemspec
|
31
|
-
|
32
|
-
```ruby
|
33
|
-
# frozen_string_literal: true
|
34
|
-
|
35
|
-
require_relative "lib/opaque_id/version"
|
36
|
-
|
37
|
-
Gem::Specification.new do |spec|
|
38
|
-
spec.name = "opaque_id"
|
39
|
-
spec.version = OpaqueId::VERSION
|
40
|
-
spec.authors = ["Your Name"]
|
41
|
-
spec.email = ["your.email@example.com"]
|
42
|
-
|
43
|
-
spec.summary = "Generate cryptographically secure, collision-free opaque IDs for ActiveRecord models"
|
44
|
-
spec.description = <<~DESC
|
45
|
-
OpaqueId provides a simple way to generate unique, URL-friendly identifiers for your ActiveRecord models.
|
46
|
-
Uses rejection sampling for unbiased random generation, ensuring perfect uniformity across the alphabet.
|
47
|
-
Prevents exposing incremental database IDs in URLs and APIs.
|
48
|
-
DESC
|
49
|
-
spec.homepage = "https://github.com/yourusername/opaque_id"
|
50
|
-
spec.license = "MIT"
|
51
|
-
spec.required_ruby_version = ">= 2.7.0"
|
52
|
-
|
53
|
-
spec.metadata["homepage_uri"] = spec.homepage
|
54
|
-
spec.metadata["source_code_uri"] = "https://github.com/yourusername/opaque_id"
|
55
|
-
spec.metadata["changelog_uri"] = "https://github.com/yourusername/opaque_id/blob/main/CHANGELOG.md"
|
56
|
-
|
57
|
-
spec.files = Dir.chdir(__dir__) do
|
58
|
-
`git ls-files -z`.split("\x0").reject do |f|
|
59
|
-
(f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|circleci)|appveyor)})
|
60
|
-
end
|
61
|
-
end
|
62
|
-
|
63
|
-
spec.bindir = "exe"
|
64
|
-
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
65
|
-
spec.require_paths = ["lib"]
|
66
|
-
|
67
|
-
# Dependencies
|
68
|
-
spec.add_dependency "activerecord", ">= 6.0"
|
69
|
-
spec.add_dependency "activesupport", ">= 6.0"
|
70
|
-
|
71
|
-
# Development dependencies
|
72
|
-
spec.add_development_dependency "rake", "~> 13.0"
|
73
|
-
spec.add_development_dependency "rspec", "~> 3.0"
|
74
|
-
spec.add_development_dependency "sqlite3", "~> 1.4"
|
75
|
-
spec.add_development_dependency "rubocop", "~> 1.21"
|
76
|
-
end
|
77
|
-
```
|
78
|
-
|
79
|
-
## Gemfile
|
80
|
-
|
81
|
-
```ruby
|
82
|
-
# frozen_string_literal: true
|
83
|
-
|
84
|
-
source "https://rubygems.org"
|
85
|
-
|
86
|
-
gemspec
|
87
|
-
|
88
|
-
gem "rake", "~> 13.0"
|
89
|
-
gem "rspec", "~> 3.0"
|
90
|
-
gem "sqlite3", "~> 1.4"
|
91
|
-
gem "rubocop", "~> 1.21"
|
92
|
-
```
|
93
|
-
|
94
|
-
## Rakefile
|
95
|
-
|
96
|
-
```ruby
|
97
|
-
# frozen_string_literal: true
|
98
|
-
|
99
|
-
require "bundler/gem_tasks"
|
100
|
-
require "rspec/core/rake_task"
|
101
|
-
require "rubocop/rake_task"
|
102
|
-
|
103
|
-
RSpec::Core::RakeTask.new(:spec)
|
104
|
-
RuboCop::RakeTask.new
|
105
|
-
|
106
|
-
task default: %i[spec rubocop]
|
107
|
-
```
|
108
|
-
|
109
|
-
## lib/opaque_id.rb (Entry Point)
|
110
|
-
|
111
|
-
```ruby
|
112
|
-
# frozen_string_literal: true
|
113
|
-
|
114
|
-
require "securerandom"
|
115
|
-
require_relative "opaque_id/version"
|
116
|
-
require_relative "opaque_id/model"
|
117
|
-
|
118
|
-
module OpaqueId
|
119
|
-
class Error < StandardError; end
|
120
|
-
class GenerationError < Error; end
|
121
|
-
class ConfigurationError < Error; end
|
122
|
-
|
123
|
-
# Standard URL-safe alphabet (64 characters)
|
124
|
-
STANDARD_ALPHABET = "_-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
125
|
-
|
126
|
-
# Lowercase alphanumeric (36 characters)
|
127
|
-
ALPHANUMERIC_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz"
|
128
|
-
|
129
|
-
class << self
|
130
|
-
# Generate a cryptographically secure random ID
|
131
|
-
def generate(size: 21, alphabet: ALPHANUMERIC_ALPHABET)
|
132
|
-
raise ConfigurationError, "Size must be positive" unless size.positive?
|
133
|
-
raise ConfigurationError, "Alphabet cannot be empty" if alphabet.empty?
|
134
|
-
|
135
|
-
alphabet_size = alphabet.size
|
136
|
-
return generate_fast(size, alphabet) if alphabet_size == 64
|
137
|
-
|
138
|
-
generate_unbiased(size, alphabet, alphabet_size)
|
139
|
-
end
|
140
|
-
|
141
|
-
private
|
142
|
-
|
143
|
-
def generate_fast(size, alphabet)
|
144
|
-
bytes = SecureRandom.random_bytes(size).bytes
|
145
|
-
size.times.map { |i| alphabet[bytes[i] & 63] }.join
|
146
|
-
end
|
147
|
-
|
148
|
-
def generate_unbiased(size, alphabet, alphabet_size)
|
149
|
-
mask = (2 << Math.log2(alphabet_size - 1).floor) - 1
|
150
|
-
step = (1.6 * mask * size / alphabet_size).ceil
|
151
|
-
id = String.new(capacity: size)
|
152
|
-
|
153
|
-
loop do
|
154
|
-
bytes = SecureRandom.random_bytes(step).bytes
|
155
|
-
bytes.each do |byte|
|
156
|
-
masked_byte = byte & mask
|
157
|
-
if masked_byte < alphabet_size
|
158
|
-
id << alphabet[masked_byte]
|
159
|
-
return id if id.size == size
|
160
|
-
end
|
161
|
-
end
|
162
|
-
end
|
163
|
-
end
|
164
|
-
end
|
165
|
-
end
|
166
|
-
```
|
167
|
-
|
168
|
-
## lib/generators/opaque_id/install_generator.rb
|
169
|
-
|
170
|
-
```ruby
|
171
|
-
# frozen_string_literal: true
|
172
|
-
|
173
|
-
require "rails/generators"
|
174
|
-
require "rails/generators/active_record"
|
175
|
-
|
176
|
-
module OpaqueId
|
177
|
-
module Generators
|
178
|
-
class InstallGenerator < Rails::Generators::Base
|
179
|
-
include ActiveRecord::Generators::Migration
|
180
|
-
|
181
|
-
source_root File.expand_path("templates", __dir__)
|
182
|
-
|
183
|
-
argument :table_name, type: :string, default: nil, banner: "table_name"
|
184
|
-
|
185
|
-
class_option :column_name, type: :string, default: "opaque_id",
|
186
|
-
desc: "Name of the column to add"
|
187
|
-
|
188
|
-
def create_migration_file
|
189
|
-
if table_name.present?
|
190
|
-
migration_template "migration.rb.tt",
|
191
|
-
"db/migrate/add_opaque_id_to_#{table_name}.rb"
|
192
|
-
else
|
193
|
-
say "Usage: rails generate opaque_id:install TABLE_NAME", :red
|
194
|
-
say "Example: rails generate opaque_id:install posts", :green
|
195
|
-
end
|
196
|
-
end
|
197
|
-
end
|
198
|
-
end
|
199
|
-
end
|
200
|
-
```
|
201
|
-
|
202
|
-
## lib/generators/opaque_id/templates/migration.rb.tt
|
203
|
-
|
204
|
-
```ruby
|
205
|
-
class AddOpaqueIdTo<%= table_name.camelize %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
206
|
-
def change
|
207
|
-
add_column :<%= table_name %>, :<%= options[:column_name] %>, :string
|
208
|
-
add_index :<%= table_name %>, :<%= options[:column_name] %>, unique: true
|
209
|
-
end
|
210
|
-
end
|
211
|
-
```
|
212
|
-
|
213
|
-
## README.md
|
214
|
-
|
215
|
-
````markdown
|
216
|
-
# OpaqueId
|
217
|
-
|
218
|
-
Generate cryptographically secure, collision-free opaque IDs for your ActiveRecord models. Perfect for exposing public identifiers in URLs and APIs without revealing your database's internal structure.
|
219
|
-
|
220
|
-
## Features
|
221
|
-
|
222
|
-
- 🔒 **Cryptographically secure** - Uses Ruby's `SecureRandom` for entropy
|
223
|
-
- 🎯 **Unbiased generation** - Implements rejection sampling for perfect uniformity
|
224
|
-
- ⚡ **Performance optimized** - Fast path for 64-character alphabets
|
225
|
-
- 🎨 **Highly configurable** - Customize alphabet, length, and behavior per model
|
226
|
-
- ✅ **HTML-valid by default** - IDs start with letters for use as HTML element IDs
|
227
|
-
- 🔄 **Collision detection** - Automatic retry logic with configurable attempts
|
228
|
-
- 🚀 **Zero dependencies** - Only requires ActiveSupport/ActiveRecord
|
229
|
-
|
230
|
-
## Installation
|
231
|
-
|
232
|
-
Add to your Gemfile:
|
233
|
-
|
234
|
-
```ruby
|
235
|
-
gem 'opaque_id'
|
236
|
-
```
|
237
|
-
````
|
238
|
-
|
239
|
-
Then run:
|
240
|
-
|
241
|
-
```bash
|
242
|
-
bundle install
|
243
|
-
rails generate opaque_id:install posts
|
244
|
-
rails db:migrate
|
245
|
-
```
|
246
|
-
|
247
|
-
## Usage
|
248
|
-
|
249
|
-
### Basic Usage
|
250
|
-
|
251
|
-
```ruby
|
252
|
-
class Post < ApplicationRecord
|
253
|
-
include OpaqueId::Model
|
254
|
-
end
|
255
|
-
|
256
|
-
post = Post.create(title: "Hello World")
|
257
|
-
post.opaque_id #=> "k3x9m2n8p5q7r4t6"
|
258
|
-
```
|
259
|
-
|
260
|
-
### Custom Configuration
|
261
|
-
|
262
|
-
```ruby
|
263
|
-
class Invoice < ApplicationRecord
|
264
|
-
include OpaqueId::Model
|
265
|
-
|
266
|
-
self.opaque_id_column = :public_id # Custom column name
|
267
|
-
self.opaque_id_length = 24 # Longer IDs
|
268
|
-
self.opaque_id_alphabet = OpaqueId::STANDARD_ALPHABET # 64-char alphabet
|
269
|
-
self.opaque_id_require_letter_start = false # Allow starting with numbers
|
270
|
-
self.opaque_id_purge_chars = %w[0 1 5 o O i I l] # Exclude confusing chars
|
271
|
-
self.opaque_id_max_retry = 2000 # More collision attempts
|
272
|
-
end
|
273
|
-
```
|
274
|
-
|
275
|
-
### Standalone Generation
|
276
|
-
|
277
|
-
```ruby
|
278
|
-
# Generate a random ID
|
279
|
-
OpaqueId.generate #=> "k3x9m2n8p5q7r4t6"
|
280
|
-
|
281
|
-
# Custom size
|
282
|
-
OpaqueId.generate(size: 32) #=> "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
|
283
|
-
|
284
|
-
# Custom alphabet
|
285
|
-
OpaqueId.generate(
|
286
|
-
size: 21,
|
287
|
-
alphabet: OpaqueId::STANDARD_ALPHABET
|
288
|
-
) #=> "V-x3_Kp9Mq2Nn8Rt6Wz4"
|
289
|
-
```
|
290
|
-
|
291
|
-
### Finding Records
|
292
|
-
|
293
|
-
```ruby
|
294
|
-
Post.find_by_opaque_id("k3x9m2n8p5q7r4t6")
|
295
|
-
Post.find_by_opaque_id!("k3x9m2n8p5q7r4t6") # Raises if not found
|
296
|
-
```
|
297
|
-
|
298
|
-
## Configuration Options
|
299
|
-
|
300
|
-
| Option | Default | Description |
|
301
|
-
| -------------------------------- | ------------ | ---------------------------- |
|
302
|
-
| `opaque_id_column` | `:opaque_id` | Database column name |
|
303
|
-
| `opaque_id_length` | `18` | Length of generated IDs |
|
304
|
-
| `opaque_id_alphabet` | `0-9a-z` | Character set for IDs |
|
305
|
-
| `opaque_id_require_letter_start` | `true` | Ensure HTML validity |
|
306
|
-
| `opaque_id_purge_chars` | `nil` | Characters to exclude |
|
307
|
-
| `opaque_id_max_retry` | `1000` | Max collision retry attempts |
|
308
|
-
|
309
|
-
## Alphabets
|
310
|
-
|
311
|
-
### ALPHANUMERIC_ALPHABET (default)
|
312
|
-
|
313
|
-
- **Characters**: `0123456789abcdefghijklmnopqrstuvwxyz`
|
314
|
-
- **Size**: 36 characters
|
315
|
-
- **Use case**: User-facing IDs, URLs
|
316
|
-
|
317
|
-
### STANDARD_ALPHABET
|
318
|
-
|
319
|
-
- **Characters**: `_-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ`
|
320
|
-
- **Size**: 64 characters
|
321
|
-
- **Use case**: Maximum entropy, API keys
|
322
|
-
|
323
|
-
## Algorithm
|
324
|
-
|
325
|
-
OpaqueId uses **rejection sampling** to ensure perfectly uniform distribution:
|
326
|
-
|
327
|
-
1. Calculate optimal bit mask based on alphabet size
|
328
|
-
2. Generate random bytes using `SecureRandom`
|
329
|
-
3. Apply mask and check if value is within alphabet range
|
330
|
-
4. Accept valid values, reject others (no modulo bias)
|
331
|
-
|
332
|
-
For 64-character alphabets, uses optimized bitwise operations (`byte & 63`).
|
333
|
-
|
334
|
-
## Performance
|
335
|
-
|
336
|
-
Benchmarks on Ruby 3.3 (1M iterations):
|
337
|
-
|
338
|
-
```
|
339
|
-
Standard alphabet (64 chars): ~1.2M IDs/sec
|
340
|
-
Alphanumeric (36 chars): ~180K IDs/sec
|
341
|
-
Custom alphabet (20 chars): ~150K IDs/sec
|
342
|
-
```
|
343
|
-
|
344
|
-
## Why OpaqueId?
|
345
|
-
|
346
|
-
### The Problem
|
347
|
-
|
348
|
-
```ruby
|
349
|
-
# ❌ Exposes database structure
|
350
|
-
GET /api/posts/1
|
351
|
-
GET /api/posts/2 # Easy to enumerate
|
352
|
-
|
353
|
-
# ❌ Reveals business metrics
|
354
|
-
GET /api/invoices/10523 # "They have 10,523 invoices"
|
355
|
-
```
|
356
|
-
|
357
|
-
### The Solution
|
358
|
-
|
359
|
-
```ruby
|
360
|
-
# ✅ Opaque, non-sequential IDs
|
361
|
-
GET /api/posts/k3x9m2n8p5q7r4
|
362
|
-
GET /api/posts/t6v8w1x4y7z9a2
|
363
|
-
|
364
|
-
# ✅ No information leakage
|
365
|
-
GET /api/invoices/m3n8p5q7r4t6v8
|
366
|
-
```
|
367
|
-
|
368
|
-
## License
|
369
|
-
|
370
|
-
MIT License - see LICENSE.txt
|
371
|
-
|
372
|
-
## Contributing
|
373
|
-
|
374
|
-
1. Fork it
|
375
|
-
2. Create your feature branch (`git checkout -b my-new-feature`)
|
376
|
-
3. Commit your changes (`git commit -am 'Add some feature'`)
|
377
|
-
4. Push to the branch (`git push origin my-new-feature`)
|
378
|
-
5. Create new Pull Request
|
379
|
-
|
380
|
-
````
|
381
|
-
|
382
|
-
## spec/spec_helper.rb
|
383
|
-
|
384
|
-
```ruby
|
385
|
-
# frozen_string_literal: true
|
386
|
-
|
387
|
-
require "opaque_id"
|
388
|
-
require "active_record"
|
389
|
-
|
390
|
-
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
|
391
|
-
|
392
|
-
ActiveRecord::Schema.define do
|
393
|
-
create_table :test_models, force: true do |t|
|
394
|
-
t.string :opaque_id
|
395
|
-
t.timestamps
|
396
|
-
end
|
397
|
-
|
398
|
-
add_index :test_models, :opaque_id, unique: true
|
399
|
-
end
|
400
|
-
|
401
|
-
class TestModel < ActiveRecord::Base
|
402
|
-
include OpaqueId::Model
|
403
|
-
end
|
404
|
-
|
405
|
-
RSpec.configure do |config|
|
406
|
-
config.expect_with :rspec do |expectations|
|
407
|
-
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
|
408
|
-
end
|
409
|
-
|
410
|
-
config.mock_with :rspec do |mocks|
|
411
|
-
mocks.verify_partial_doubles = true
|
412
|
-
end
|
413
|
-
|
414
|
-
config.shared_context_metadata_behavior = :apply_to_host_groups
|
415
|
-
end
|
416
|
-
````
|
417
|
-
|
418
|
-
## spec/opaque_id_spec.rb
|
419
|
-
|
420
|
-
```ruby
|
421
|
-
# frozen_string_literal: true
|
422
|
-
|
423
|
-
require "spec_helper"
|
424
|
-
|
425
|
-
RSpec.describe OpaqueId do
|
426
|
-
describe ".generate" do
|
427
|
-
it "generates IDs of default length" do
|
428
|
-
id = described_class.generate
|
429
|
-
expect(id.length).to eq(21)
|
430
|
-
end
|
431
|
-
|
432
|
-
it "generates IDs of custom length" do
|
433
|
-
id = described_class.generate(size: 32)
|
434
|
-
expect(id.length).to eq(32)
|
435
|
-
end
|
436
|
-
|
437
|
-
it "uses only characters from the alphabet" do
|
438
|
-
alphabet = "abc123"
|
439
|
-
id = described_class.generate(size: 100, alphabet: alphabet)
|
440
|
-
expect(id.chars.all? { |c| alphabet.include?(c) }).to be true
|
441
|
-
end
|
442
|
-
|
443
|
-
it "generates unique IDs" do
|
444
|
-
ids = 10_000.times.map { described_class.generate }
|
445
|
-
expect(ids.uniq.length).to eq(10_000)
|
446
|
-
end
|
447
|
-
|
448
|
-
it "raises error for invalid size" do
|
449
|
-
expect { described_class.generate(size: 0) }.to raise_error(OpaqueId::ConfigurationError)
|
450
|
-
expect { described_class.generate(size: -1) }.to raise_error(OpaqueId::ConfigurationError)
|
451
|
-
end
|
452
|
-
|
453
|
-
it "raises error for empty alphabet" do
|
454
|
-
expect { described_class.generate(alphabet: "") }.to raise_error(OpaqueId::ConfigurationError)
|
455
|
-
end
|
456
|
-
|
457
|
-
context "with 64-character alphabet" do
|
458
|
-
it "uses fast path" do
|
459
|
-
id = described_class.generate(size: 21, alphabet: OpaqueId::STANDARD_ALPHABET)
|
460
|
-
expect(id.length).to eq(21)
|
461
|
-
expect(id.chars.all? { |c| OpaqueId::STANDARD_ALPHABET.include?(c) }).to be true
|
462
|
-
end
|
463
|
-
end
|
464
|
-
end
|
465
|
-
|
466
|
-
describe "statistical uniformity" do
|
467
|
-
it "distributes characters evenly" do
|
468
|
-
alphabet = "0123456789"
|
469
|
-
samples = 10_000.times.map { described_class.generate(size: 1, alphabet: alphabet) }
|
470
|
-
|
471
|
-
frequency = samples.tally
|
472
|
-
expected = samples.length / alphabet.length
|
473
|
-
|
474
|
-
# Chi-square test: all frequencies should be within 20% of expected
|
475
|
-
frequency.each_value do |count|
|
476
|
-
deviation = (count - expected).abs.to_f / expected
|
477
|
-
expect(deviation).to be < 0.2
|
478
|
-
end
|
479
|
-
end
|
480
|
-
end
|
481
|
-
end
|
482
|
-
```
|
@@ -1,110 +0,0 @@
|
|
1
|
-
This is the original functionality we are replicating and enhancing. typically implemented as a concern in a Model via `include Identifiable`
|
2
|
-
|
3
|
-
```ruby
|
4
|
-
require "nanoid"
|
5
|
-
|
6
|
-
# generate a public_id (nanoid) when creating new records
|
7
|
-
# unique URL-friendly ID that'll prevent exposing incremental db ids where
|
8
|
-
# we otherwise can't or don't want to use friendly_ids e.g. in URLs and APIs
|
9
|
-
#
|
10
|
-
# https://planetscale.com/blog/why-we-chose-nanoids-for-planetscales-api#generating-nanoids-in-rails
|
11
|
-
# https://maful.web.id/posts/how-i-use-nano-id-in-rails/
|
12
|
-
# https://zelark.github.io/nano-id-cc/
|
13
|
-
module Identifiable
|
14
|
-
extend ActiveSupport::Concern
|
15
|
-
|
16
|
-
included do
|
17
|
-
before_create :set_public_id
|
18
|
-
end
|
19
|
-
|
20
|
-
PUBLIC_ID_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz"
|
21
|
-
PUBLIC_ID_LENGTH = 18
|
22
|
-
MAX_RETRY = 1000
|
23
|
-
|
24
|
-
PUBLIC_ID_REGEX = /[#{PUBLIC_ID_ALPHABET}]{#{PUBLIC_ID_LENGTH}}\z/
|
25
|
-
|
26
|
-
class_methods do
|
27
|
-
def generate_nanoid(alphabet: PUBLIC_ID_ALPHABET, size: PUBLIC_ID_LENGTH)
|
28
|
-
Nanoid.generate(size:, alphabet:)
|
29
|
-
end
|
30
|
-
end
|
31
|
-
|
32
|
-
def set_public_id
|
33
|
-
return if public_id.present?
|
34
|
-
|
35
|
-
MAX_RETRY.times do
|
36
|
-
self.public_id = generate_public_id
|
37
|
-
return unless self.class.where(public_id:).exists?
|
38
|
-
end
|
39
|
-
raise "Failed to generate a unique public id after #{MAX_RETRY} attempts"
|
40
|
-
end
|
41
|
-
|
42
|
-
def generate_public_id
|
43
|
-
self.class.generate_nanoid(alphabet: PUBLIC_ID_ALPHABET)
|
44
|
-
end
|
45
|
-
end
|
46
|
-
```
|
47
|
-
|
48
|
-
The original nanoid.rb is below
|
49
|
-
|
50
|
-
```ruby
|
51
|
-
require 'securerandom'
|
52
|
-
|
53
|
-
module Nanoid
|
54
|
-
SAFE_ALPHABET = '_-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'.freeze
|
55
|
-
|
56
|
-
def self.generate(size: 21, alphabet: SAFE_ALPHABET, non_secure: false)
|
57
|
-
return non_secure_generate(size: size, alphabet: alphabet) if non_secure
|
58
|
-
|
59
|
-
return simple_generate(size: size) if alphabet == SAFE_ALPHABET
|
60
|
-
|
61
|
-
complex_generate(size: size, alphabet: alphabet)
|
62
|
-
end
|
63
|
-
|
64
|
-
private
|
65
|
-
|
66
|
-
def self.simple_generate(size:)
|
67
|
-
bytes = random_bytes(size)
|
68
|
-
|
69
|
-
(0...size).reduce('') do |acc, i|
|
70
|
-
acc << SAFE_ALPHABET[bytes[i] & 63]
|
71
|
-
end
|
72
|
-
end
|
73
|
-
|
74
|
-
def self.complex_generate(size:, alphabet:)
|
75
|
-
alphabet_size = alphabet.size
|
76
|
-
mask = (2 << Math.log(alphabet_size - 1) / Math.log(2)) - 1
|
77
|
-
step = (1.6 * mask * size / alphabet_size).ceil
|
78
|
-
|
79
|
-
id = ''
|
80
|
-
|
81
|
-
loop do
|
82
|
-
bytes = random_bytes(size)
|
83
|
-
(0...step).each do |idx|
|
84
|
-
byte = bytes[idx] & mask
|
85
|
-
character = byte && alphabet[byte]
|
86
|
-
if character
|
87
|
-
id << character
|
88
|
-
return id if id.size == size
|
89
|
-
end
|
90
|
-
end
|
91
|
-
end
|
92
|
-
end
|
93
|
-
|
94
|
-
def self.non_secure_generate(size:, alphabet:)
|
95
|
-
alphabet_size = alphabet.size
|
96
|
-
|
97
|
-
id = ''
|
98
|
-
|
99
|
-
size.times do
|
100
|
-
id << alphabet[(Random.rand * alphabet_size).floor]
|
101
|
-
end
|
102
|
-
|
103
|
-
id
|
104
|
-
end
|
105
|
-
|
106
|
-
def self.random_bytes(size)
|
107
|
-
SecureRandom.random_bytes(size).bytes
|
108
|
-
end
|
109
|
-
end
|
110
|
-
```
|
@@ -1,109 +0,0 @@
|
|
1
|
-
# Task List: OpaqueId Ruby Gem Implementation
|
2
|
-
|
3
|
-
Based on PRD: `0001-prd-opaque-id-gem.md`
|
4
|
-
|
5
|
-
## Relevant Files
|
6
|
-
|
7
|
-
- `lib/opaque_id.rb` - Main module with core ID generation functionality and error classes
|
8
|
-
- `lib/opaque_id/version.rb` - Version constant (already exists)
|
9
|
-
- `lib/opaque_id/model.rb` - ActiveRecord concern for model integration
|
10
|
-
- `lib/generators/opaque_id/install_generator.rb` - Rails generator for migrations and model updates
|
11
|
-
- `lib/generators/opaque_id/templates/migration.rb.tt` - Migration template for generator
|
12
|
-
- `opaque_id.gemspec` - Gem specification and dependencies (updated for Rails 8.0+)
|
13
|
-
- `Gemfile` - Development dependencies
|
14
|
-
- `Rakefile` - Rake tasks for testing and linting
|
15
|
-
- `.rubocop.yml` - RuboCop configuration for code quality
|
16
|
-
- `test/test_helper.rb` - Test setup with Rails configuration and deprecation fixes
|
17
|
-
- `test/test_opaque_id.rb` - Unit tests for main module
|
18
|
-
- `test/opaque_id_test.rb` - Comprehensive tests for core functionality
|
19
|
-
- `test/opaque_id/model_test.rb` - Unit tests for ActiveRecord concern
|
20
|
-
- `test/opaque_id/generators/install_generator_test.rb` - Unit tests for generator
|
21
|
-
- `README.md` - Documentation and usage examples
|
22
|
-
- `CHANGELOG.md` - Version history and changes
|
23
|
-
|
24
|
-
### Notes
|
25
|
-
|
26
|
-
- Unit tests should be placed in the `test/` directory following Minitest conventions
|
27
|
-
- Use `ruby -Ilib:test test/test_opaque_id.rb` to run specific test files
|
28
|
-
- Use `rake test` to run all tests (once Rakefile is configured)
|
29
|
-
- The existing `test/test_helper.rb` already sets up Minitest and loads the gem
|
30
|
-
|
31
|
-
## Tasks
|
32
|
-
|
33
|
-
- [x] 1.0 Implement Core ID Generation Module
|
34
|
-
|
35
|
-
- [x] 1.1 Create error classes (Error, GenerationError, ConfigurationError)
|
36
|
-
- [x] 1.2 Implement alphabet constants (STANDARD_ALPHABET, ALPHANUMERIC_ALPHABET)
|
37
|
-
- [x] 1.3 Implement generate_fast method for 64-character alphabets using bitwise operations
|
38
|
-
- [x] 1.4 Implement generate_unbiased method with rejection sampling algorithm
|
39
|
-
- [x] 1.5 Implement main generate method with parameter validation
|
40
|
-
- [x] 1.6 Add proper error handling for invalid size and empty alphabet
|
41
|
-
- [x] 1.7 Add require statements for SecureRandom and version
|
42
|
-
|
43
|
-
- [x] 2.0 Create ActiveRecord Model Integration
|
44
|
-
|
45
|
-
- [x] 2.1 Create OpaqueId::Model concern with ActiveSupport::Concern
|
46
|
-
- [x] 2.2 Implement before_create callback for automatic ID generation
|
47
|
-
- [x] 2.3 Add find_by_opaque_id and find_by_opaque_id! class methods
|
48
|
-
- [x] 2.4 Implement collision detection with configurable retry attempts
|
49
|
-
- [x] 2.5 Add configuration options (column, length, alphabet, require_letter_start, purge_chars, max_retry)
|
50
|
-
- [x] 2.6 Implement set_opaque_id private method with collision handling
|
51
|
-
- [x] 2.7 Add generate_opaque_id private method using main module
|
52
|
-
- [x] 2.8 Handle edge cases (already has ID, collision resolution failure)
|
53
|
-
|
54
|
-
- [x] 3.0 Build Rails Generator for Easy Setup
|
55
|
-
|
56
|
-
- [x] 3.1 Create lib/generators/opaque_id directory structure
|
57
|
-
- [x] 3.2 Implement InstallGenerator class with Rails::Generators::Base
|
58
|
-
- [x] 3.3 Add table_name argument and column_name option handling
|
59
|
-
- [x] 3.4 Create migration template (migration.rb.tt) with add_column and add_index
|
60
|
-
- [x] 3.5 Implement create_migration_file method with error handling
|
61
|
-
- [x] 3.6 Add model file modification to include OpaqueId::Model
|
62
|
-
- [x] 3.7 Handle edge cases (missing model file, already included, different class names)
|
63
|
-
- [x] 3.8 Add proper console output and error messages
|
64
|
-
|
65
|
-
- [x] 4.0 Update Gem Configuration and Dependencies
|
66
|
-
|
67
|
-
- [x] 4.1 Update gemspec with proper summary and description
|
68
|
-
- [x] 4.2 Add ActiveRecord and ActiveSupport dependencies (>= 8.0)
|
69
|
-
- [x] 4.3 Add development dependencies (rake, sqlite3, rubocop, rails)
|
70
|
-
- [x] 4.4 Update metadata (homepage, source_code_uri, changelog_uri)
|
71
|
-
- [x] 4.5 Set required_ruby_version to >= 3.2
|
72
|
-
- [x] 4.6 Configure file inclusion and exclusion patterns
|
73
|
-
- [x] 4.7 Update Gemfile to match gemspec dependencies
|
74
|
-
- [x] 4.8 Update Rakefile with test and rubocop tasks
|
75
|
-
|
76
|
-
- [x] 5.0 Create Comprehensive Test Suite
|
77
|
-
|
78
|
-
- [x] 5.1 Create test/opaque_id directory structure
|
79
|
-
- [x] 5.2 Implement test_opaque_id.rb with core module tests
|
80
|
-
- [x] 5.3 Add tests for generate method (default length, custom length, alphabet validation)
|
81
|
-
- [x] 5.4 Add tests for error conditions (invalid size, empty alphabet)
|
82
|
-
- [x] 5.5 Add statistical uniformity tests for character distribution
|
83
|
-
- [x] 5.6 Add performance benchmark tests for different alphabet sizes
|
84
|
-
- [x] 5.7 Create test/opaque_id/model_test.rb for ActiveRecord concern
|
85
|
-
- [x] 5.8 Test model integration (automatic generation, find methods, configuration)
|
86
|
-
- [x] 5.9 Test collision detection and retry logic
|
87
|
-
- [x] 5.10 Create test/opaque_id/generators/install_generator_test.rb
|
88
|
-
- [x] 5.11 Test generator behavior (migration creation, model modification, edge cases)
|
89
|
-
- [x] 5.12 Add test database setup and cleanup
|
90
|
-
|
91
|
-
- [x] 5.13 Configure RuboCop for code quality
|
92
|
-
|
93
|
-
- [x] 5.13.1 Create .rubocop.yml configuration file
|
94
|
-
- [x] 5.13.2 Set appropriate rules for gem development
|
95
|
-
- [x] 5.13.3 Auto-correct all RuboCop offenses
|
96
|
-
- [x] 5.13.4 Fix Rails 8.1 deprecation warning in test helper
|
97
|
-
|
98
|
-
- [ ] 6.0 Write Documentation and Examples
|
99
|
-
- [x] 6.1 Update README.md with comprehensive feature list
|
100
|
-
- [x] 6.2 Add installation instructions and gem usage
|
101
|
-
- [x] 6.3 Create basic usage examples with code snippets
|
102
|
-
- [x] 6.4 Add custom configuration examples
|
103
|
-
- [x] 6.5 Document standalone generation usage
|
104
|
-
- [x] 6.6 Add configuration options table with defaults
|
105
|
-
- [x] 6.7 Document alphabet options (ALPHANUMERIC_ALPHABET, STANDARD_ALPHABET)
|
106
|
-
- [x] 6.8 Add algorithm explanation and performance benchmarks
|
107
|
-
- [x] 6.9 Create security considerations and use case examples
|
108
|
-
- [x] 6.10 Add contributing guidelines and license information
|
109
|
-
- [x] 6.11 Update CHANGELOG.md with version history
|