opaque_id 1.4.0 → 1.7.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 +206 -0
- data/README.md +93 -79
- data/docs/_config.yml +2 -0
- data/docs/algorithms.md +4 -4
- data/docs/api-reference.md +6 -6
- data/docs/benchmarks.md +385 -0
- data/docs/configuration.md +32 -18
- data/docs/index.md +21 -3
- data/docs/performance.md +3 -3
- data/docs/usage.md +22 -18
- data/lib/generators/opaque_id/install_generator.rb +13 -0
- data/lib/generators/opaque_id/templates/migration.rb.tt +3 -3
- data/lib/opaque_id/model.rb +2 -2
- data/lib/opaque_id/version.rb +1 -1
- data/lib/opaque_id.rb +4 -1
- data/release-please-config.json +2 -1
- metadata +2 -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,191 +0,0 @@
|
|
1
|
-
# Product Requirements Document: OpaqueId Documentation Site
|
2
|
-
|
3
|
-
## Introduction/Overview
|
4
|
-
|
5
|
-
Create a professional documentation website for the OpaqueId Ruby gem using [Just the Docs](https://just-the-docs.com) Jekyll theme. The site will provide comprehensive, developer-focused documentation that mirrors the README content but with improved navigation, search capabilities, and a clean, minimal design similar to the Just the Docs site itself.
|
6
|
-
|
7
|
-
**Problem Solved:** The current README is comprehensive but long (1600+ lines), making it difficult to navigate and find specific information quickly. A dedicated documentation site will improve developer experience and make the gem more accessible.
|
8
|
-
|
9
|
-
**Goal:** Create a professional, searchable documentation site that makes OpaqueId easy to understand and implement for developers.
|
10
|
-
|
11
|
-
## Goals
|
12
|
-
|
13
|
-
1. **Improve Developer Experience**: Provide easy navigation and search for finding specific documentation
|
14
|
-
2. **Professional Presentation**: Create a polished, modern documentation site that reflects the quality of the gem
|
15
|
-
3. **Maintain Content Quality**: Preserve all valuable information from the README while improving organization
|
16
|
-
4. **Enable Auto-Deployment**: Set up GitHub Actions for automatic deployment on successful pushes
|
17
|
-
5. **Ensure Accessibility**: Make documentation accessible and mobile-friendly
|
18
|
-
6. **Minimize Maintenance**: Keep the site simple and maintainable
|
19
|
-
|
20
|
-
## User Stories
|
21
|
-
|
22
|
-
- **As a developer** evaluating OpaqueId, I want to quickly understand what the gem does and how to install it
|
23
|
-
- **As a developer** implementing OpaqueId, I want to find specific configuration options and usage examples
|
24
|
-
- **As a developer** troubleshooting issues, I want to search for specific error messages or solutions
|
25
|
-
- **As a developer** learning advanced features, I want to explore performance benchmarks and security considerations
|
26
|
-
- **As a developer** contributing to the project, I want to understand the development setup and guidelines
|
27
|
-
|
28
|
-
## Functional Requirements
|
29
|
-
|
30
|
-
### 1. Site Structure & Navigation
|
31
|
-
|
32
|
-
1.1. Create a `/docs` directory with Jekyll site structure
|
33
|
-
1.2. Implement sidebar navigation based on README Table of Contents
|
34
|
-
1.3. Use minimal, flat navigation structure (no collapsible sections)
|
35
|
-
1.4. Include search functionality using Just the Docs built-in search
|
36
|
-
1.5. Add "Getting Started" quick start guide as a separate page
|
37
|
-
|
38
|
-
### 2. Content Organization
|
39
|
-
|
40
|
-
2.1. Convert README sections into individual documentation pages
|
41
|
-
2.2. Create logical page hierarchy: Home → Getting Started → Installation → Usage → Configuration → etc.
|
42
|
-
2.3. Maintain all existing content from README
|
43
|
-
2.4. Add code syntax highlighting for Ruby examples
|
44
|
-
2.5. Include proper cross-references between pages
|
45
|
-
|
46
|
-
### 3. Design & Styling
|
47
|
-
|
48
|
-
3.1. Use Just the Docs theme with default light/dark mode switching
|
49
|
-
3.2. Apply minimal design similar to just-the-docs.com
|
50
|
-
3.3. Include OpaqueId branding and gem badges
|
51
|
-
3.4. Ensure responsive design for mobile devices
|
52
|
-
3.5. Use consistent typography and spacing
|
53
|
-
|
54
|
-
### 4. GitHub Pages Integration
|
55
|
-
|
56
|
-
4.1. Configure site for `https://nyaggah.github.io/opaque_id`
|
57
|
-
4.2. Set up GitHub Actions workflow for automatic deployment
|
58
|
-
4.3. Deploy on successful pushes to main branch
|
59
|
-
4.4. Include proper Jekyll configuration for GitHub Pages
|
60
|
-
|
61
|
-
### 5. Content Pages
|
62
|
-
|
63
|
-
5.1. **Home**: Overview, badges, quick introduction
|
64
|
-
5.2. **Getting Started**: Step-by-step installation and basic usage
|
65
|
-
5.3. **Installation**: Detailed installation instructions and requirements
|
66
|
-
5.4. **Usage**: Standalone and ActiveRecord integration examples
|
67
|
-
5.5. **Configuration**: All configuration options and examples
|
68
|
-
5.6. **Alphabets**: Built-in alphabets and custom alphabet guide
|
69
|
-
5.7. **Algorithms**: Technical details about fast path and unbiased algorithms
|
70
|
-
5.8. **Performance**: Benchmarks and optimization tips
|
71
|
-
5.9. **Security**: Security considerations and best practices
|
72
|
-
5.10. **Use Cases**: Real-world examples and applications
|
73
|
-
5.11. **Development**: Contributing guidelines and development setup
|
74
|
-
5.12. **API Reference**: Complete method documentation
|
75
|
-
|
76
|
-
### 6. Technical Implementation
|
77
|
-
|
78
|
-
6.1. Use Jekyll with Just the Docs theme
|
79
|
-
6.2. Configure `_config.yml` for GitHub Pages
|
80
|
-
6.3. Set up proper front matter for all pages
|
81
|
-
6.4. Include necessary Jekyll plugins for GitHub Pages
|
82
|
-
6.5. Optimize for fast loading and SEO
|
83
|
-
|
84
|
-
## Non-Goals (Out of Scope)
|
85
|
-
|
86
|
-
- **Live Code Examples**: No interactive demos or live code execution
|
87
|
-
- **Version-Specific Documentation**: Single version documentation only
|
88
|
-
- **Automatic README Sync**: Manual content maintenance
|
89
|
-
- **Custom Domain**: Using default GitHub.io domain
|
90
|
-
- **Breadcrumb Navigation**: Sidebar navigation only
|
91
|
-
- **User Authentication**: Public documentation only
|
92
|
-
- **Comments/Feedback System**: Static documentation only
|
93
|
-
|
94
|
-
## Design Considerations
|
95
|
-
|
96
|
-
### Visual Design
|
97
|
-
|
98
|
-
- **Theme**: [Just the Docs](https://just-the-docs.com) Jekyll theme
|
99
|
-
- **Color Scheme**: Default light/dark mode switching
|
100
|
-
- **Layout**: Clean, minimal design with focus on content
|
101
|
-
- **Typography**: Clear, readable fonts with proper hierarchy
|
102
|
-
- **Navigation**: Left sidebar with flat structure
|
103
|
-
|
104
|
-
### Content Structure
|
105
|
-
|
106
|
-
- **Homepage**: Hero section with badges, overview, and quick start
|
107
|
-
- **Sidebar**: Logical grouping of documentation sections
|
108
|
-
- **Search**: Full-text search across all documentation
|
109
|
-
- **Code Examples**: Syntax-highlighted Ruby code blocks
|
110
|
-
- **Cross-References**: Links between related sections
|
111
|
-
|
112
|
-
## Technical Considerations
|
113
|
-
|
114
|
-
### Jekyll Configuration
|
115
|
-
|
116
|
-
- Use gem-based approach with `just-the-docs` gem
|
117
|
-
- Configure for GitHub Pages compatibility
|
118
|
-
- Include necessary plugins: jekyll-feed, jekyll-sitemap, jekyll-seo-tag
|
119
|
-
- Set up proper permalinks and URL structure
|
120
|
-
|
121
|
-
### GitHub Actions Workflow
|
122
|
-
|
123
|
-
- Trigger on pushes to main branch
|
124
|
-
- Build Jekyll site
|
125
|
-
- Deploy to GitHub Pages
|
126
|
-
- Include proper error handling and notifications
|
127
|
-
|
128
|
-
### File Structure
|
129
|
-
|
130
|
-
```
|
131
|
-
/docs/
|
132
|
-
├── _config.yml
|
133
|
-
├── Gemfile
|
134
|
-
├── index.md (Home)
|
135
|
-
├── getting-started.md
|
136
|
-
├── installation.md
|
137
|
-
├── usage.md
|
138
|
-
├── configuration.md
|
139
|
-
├── alphabets.md
|
140
|
-
├── algorithms.md
|
141
|
-
├── performance.md
|
142
|
-
├── security.md
|
143
|
-
├── use-cases.md
|
144
|
-
├── development.md
|
145
|
-
├── api-reference.md
|
146
|
-
└── assets/
|
147
|
-
└── images/
|
148
|
-
```
|
149
|
-
|
150
|
-
## Success Metrics
|
151
|
-
|
152
|
-
1. **Developer Adoption**: Increased gem downloads and usage
|
153
|
-
2. **User Engagement**: Time spent on documentation pages
|
154
|
-
3. **Search Usage**: Frequency of search functionality usage
|
155
|
-
4. **Page Performance**: Fast loading times (< 3 seconds)
|
156
|
-
5. **Mobile Usage**: Successful mobile documentation access
|
157
|
-
6. **SEO Performance**: High search engine rankings for OpaqueId-related queries
|
158
|
-
|
159
|
-
## Open Questions
|
160
|
-
|
161
|
-
1. Should we include a changelog page or link to GitHub releases?
|
162
|
-
2. Do we want to include a "Troubleshooting" section for common issues?
|
163
|
-
3. Should we add a "Migration Guide" for users switching from nanoid.rb?
|
164
|
-
4. Do we want to include performance comparison charts or keep them as tables?
|
165
|
-
5. Should we add a "Community" section with links to discussions and support?
|
166
|
-
|
167
|
-
## Implementation Priority
|
168
|
-
|
169
|
-
### Phase 1: Core Setup
|
170
|
-
|
171
|
-
- Set up Jekyll site structure
|
172
|
-
- Configure GitHub Pages deployment
|
173
|
-
- Create basic page structure
|
174
|
-
|
175
|
-
### Phase 2: Content Migration
|
176
|
-
|
177
|
-
- Convert README content to individual pages
|
178
|
-
- Set up navigation and cross-references
|
179
|
-
- Add code syntax highlighting
|
180
|
-
|
181
|
-
### Phase 3: Enhancement
|
182
|
-
|
183
|
-
- Add search functionality
|
184
|
-
- Optimize performance and SEO
|
185
|
-
- Polish design and user experience
|
186
|
-
|
187
|
-
### Phase 4: Launch
|
188
|
-
|
189
|
-
- Deploy to GitHub Pages
|
190
|
-
- Test all functionality
|
191
|
-
- Announce to community
|
@@ -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
|
-
```
|