opaque_id 1.1.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 +7 -0
- data/.cursor/rules/create-prd.md +56 -0
- data/.cursor/rules/generate-tasks.md +60 -0
- data/.cursor/rules/process-task-list.md +47 -0
- data/.cursorignore +55 -0
- data/.release-please-manifest.json +3 -0
- data/.rubocop.yml +31 -0
- data/CHANGELOG.md +169 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +1602 -0
- data/Rakefile +12 -0
- data/lib/generators/opaque_id/install_generator.rb +57 -0
- data/lib/generators/opaque_id/templates/migration.rb.tt +6 -0
- data/lib/opaque_id/model.rb +130 -0
- data/lib/opaque_id/version.rb +5 -0
- data/lib/opaque_id.rb +58 -0
- data/release-please-config.json +55 -0
- data/sig/opaque_id.rbs +4 -0
- data/tasks/0001-prd-opaque-id-gem.md +202 -0
- data/tasks/0002-prd-publishing-release-automation.md +206 -0
- data/tasks/references/opaque_gem_requirements.md +482 -0
- data/tasks/references/original_identifiable_concern_and_nanoid.md +110 -0
- data/tasks/tasks-0001-prd-opaque-id-gem.md +109 -0
- data/tasks/tasks-0002-prd-publishing-release-automation.md +177 -0
- metadata +168 -0
@@ -0,0 +1,206 @@
|
|
1
|
+
# Product Requirements Document: Publishing & Release Automation
|
2
|
+
|
3
|
+
## Introduction/Overview
|
4
|
+
|
5
|
+
This PRD outlines the implementation of a fully automated publishing and release system for the OpaqueId Ruby gem. The system will automatically version, test, and publish releases to RubyGems.org based on conventional commits, eliminating manual release processes and ensuring consistent, professional releases.
|
6
|
+
|
7
|
+
**Problem**: Manual release processes are error-prone, time-consuming, and inconsistent. Without automated versioning and publishing, releases can be delayed, version numbers can be inconsistent, and changelogs can be incomplete.
|
8
|
+
|
9
|
+
**Goal**: Implement a fully automated CI/CD pipeline that handles versioning, testing, changelog generation, and publishing based on conventional commits and the state of the main branch.
|
10
|
+
|
11
|
+
## Goals
|
12
|
+
|
13
|
+
1. **Automated Versioning**: Automatically determine version bumps (patch/minor/major) based on conventional commit types
|
14
|
+
2. **Automated Publishing**: Automatically publish to RubyGems.org when changes are ready
|
15
|
+
3. **Quality Gates**: Ensure all tests pass, code quality standards are met, and security checks pass before release
|
16
|
+
4. **Automated Changelog**: Generate changelog entries from conventional commits
|
17
|
+
5. **Dependency Management**: Automatically check for and update dependencies weekly
|
18
|
+
6. **Commit Standardization**: Enforce conventional commit format for consistent messaging
|
19
|
+
7. **Security**: Use RubyGems trusted publishing for secure, keyless releases
|
20
|
+
|
21
|
+
## User Stories
|
22
|
+
|
23
|
+
### As a Developer
|
24
|
+
|
25
|
+
- **US1**: I want my commits to automatically trigger appropriate version bumps so that I don't have to manually manage version numbers
|
26
|
+
- **US2**: I want releases to be published automatically when I push to main so that I don't have to remember to manually publish
|
27
|
+
- **US3**: I want commit message validation so that I follow consistent formatting standards
|
28
|
+
- **US4**: I want automated dependency updates so that my gem stays secure and up-to-date
|
29
|
+
|
30
|
+
### As a Maintainer
|
31
|
+
|
32
|
+
- **US5**: I want automated testing and quality checks before release so that I can trust the published code
|
33
|
+
- **US6**: I want automated changelog generation so that users know what changed in each release
|
34
|
+
- **US7**: I want security scanning before release so that vulnerabilities are caught early
|
35
|
+
|
36
|
+
### As a User
|
37
|
+
|
38
|
+
- **US8**: I want consistent, predictable releases so that I can trust the gem's stability
|
39
|
+
- **US9**: I want clear changelogs so that I understand what changed between versions
|
40
|
+
|
41
|
+
## Functional Requirements
|
42
|
+
|
43
|
+
### 1. Conventional Commits Integration
|
44
|
+
|
45
|
+
1.1. **Commit Message Validation**: Enforce conventional commit format (feat:, fix:, docs:, etc.) on all commits
|
46
|
+
1.2. **Commit Linting**: Use commitlint or similar tool to validate commit message format
|
47
|
+
1.3. **Pre-commit Hooks**: Automatically validate commit messages before they are accepted
|
48
|
+
1.4. **Commit Types**: Support standard types: feat, fix, docs, style, refactor, perf, test, chore, ci, build
|
49
|
+
|
50
|
+
### 2. Automated Versioning
|
51
|
+
|
52
|
+
2.1. **Semantic Versioning**: Use semantic versioning (MAJOR.MINOR.PATCH) based on conventional commits
|
53
|
+
2.2. **Version Bump Logic**:
|
54
|
+
|
55
|
+
- `feat:` commits → MINOR version bump
|
56
|
+
- `fix:` commits → PATCH version bump
|
57
|
+
- `BREAKING CHANGE:` or `!` → MAJOR version bump
|
58
|
+
- Other types → no version bump
|
59
|
+
2.3. **Version Detection**: Automatically detect when version should be bumped based on unreleased commits
|
60
|
+
2.4. **Version File Update**: Automatically update `lib/opaque_id/version.rb` with new version
|
61
|
+
|
62
|
+
### 3. GitHub Actions Workflow
|
63
|
+
|
64
|
+
3.1. **Single Workflow**: Update existing `main.yml` to include release automation
|
65
|
+
3.2. **Trigger Conditions**: Run on push to main branch when unreleased changes exist
|
66
|
+
3.3. **Workflow Steps**:
|
67
|
+
|
68
|
+
- Checkout code
|
69
|
+
- Setup Ruby environment
|
70
|
+
- Install dependencies
|
71
|
+
- Run tests
|
72
|
+
- Run RuboCop
|
73
|
+
- Security audit
|
74
|
+
- Determine version bump
|
75
|
+
- Update version file
|
76
|
+
- Generate changelog
|
77
|
+
- Create git tag
|
78
|
+
- Publish to RubyGems.org
|
79
|
+
- Create GitHub release
|
80
|
+
|
81
|
+
### 4. Quality Gates
|
82
|
+
|
83
|
+
4.1. **Test Execution**: Run full test suite before any release
|
84
|
+
4.2. **Code Quality**: Run RuboCop and fail if violations exist
|
85
|
+
4.3. **Security Scanning**: Run `bundle audit` to check for vulnerable dependencies
|
86
|
+
4.4. **Dependency Check**: Ensure all dependencies are up-to-date and secure
|
87
|
+
|
88
|
+
### 5. Changelog Generation
|
89
|
+
|
90
|
+
5.1. **Automated Generation**: Generate changelog from conventional commits since last release
|
91
|
+
5.2. **Changelog Format**: Use conventional changelog format with categorized sections
|
92
|
+
5.3. **Changelog Sections**: Features, Bug Fixes, Breaking Changes, Documentation, etc.
|
93
|
+
5.4. **Changelog Update**: Automatically update `CHANGELOG.md` with new entries
|
94
|
+
|
95
|
+
### 6. RubyGems Publishing
|
96
|
+
|
97
|
+
6.1. **Trusted Publishing**: Use RubyGems trusted publishing (no API keys required)
|
98
|
+
6.2. **MFA Enforcement**: Ensure MFA is required for publishing (already configured)
|
99
|
+
6.3. **Build Process**: Build gem package before publishing
|
100
|
+
6.4. **Publish Process**: Automatically push to RubyGems.org when all checks pass
|
101
|
+
|
102
|
+
### 7. Dependabot Integration
|
103
|
+
|
104
|
+
7.1. **Weekly Updates**: Check for dependency updates every Monday at 9 AM
|
105
|
+
7.2. **Update Types**: Check for both direct and indirect dependency updates
|
106
|
+
7.3. **Security Updates**: Prioritize security-related updates
|
107
|
+
7.4. **Update Strategy**: Create pull requests for dependency updates
|
108
|
+
|
109
|
+
### 8. Git Tag Management
|
110
|
+
|
111
|
+
8.1. **Automatic Tagging**: Create git tags for each release (e.g., `v1.2.3`)
|
112
|
+
8.2. **Tag Format**: Use semantic versioning format with `v` prefix
|
113
|
+
8.3. **Tag Push**: Automatically push tags to GitHub repository
|
114
|
+
8.4. **GitHub Release**: Create GitHub release with changelog and tag
|
115
|
+
|
116
|
+
## Non-Goals (Out of Scope)
|
117
|
+
|
118
|
+
1. **Manual Release Override**: No manual release triggers or overrides
|
119
|
+
2. **Multiple Branch Support**: Only support releases from main branch
|
120
|
+
3. **Pre-release Versions**: No support for alpha/beta/rc versions initially
|
121
|
+
4. **Monorepo Support**: Single gem repository only
|
122
|
+
5. **Custom Versioning Logic**: No custom versioning rules beyond conventional commits
|
123
|
+
6. **Rollback Automation**: No automatic rollback of failed releases
|
124
|
+
|
125
|
+
## Technical Considerations
|
126
|
+
|
127
|
+
### Dependencies
|
128
|
+
|
129
|
+
- **Release Please**: Google's tool for PR-based versioning and changelog generation
|
130
|
+
- **Bundler's release tasks**: Built-in rake tasks for building and tagging (already present)
|
131
|
+
- **commitlint**: For commit message validation (optional)
|
132
|
+
- **bundle-audit**: For security scanning
|
133
|
+
|
134
|
+
### GitHub Actions
|
135
|
+
|
136
|
+
- **googleapis/release-please-action**: For creating release PRs and versioning
|
137
|
+
- **rubygems/release-gem**: For RubyGems publishing with trusted publishing
|
138
|
+
- **actions/checkout**: For code checkout
|
139
|
+
- **ruby/setup-ruby**: For Ruby environment setup
|
140
|
+
|
141
|
+
### Configuration Files
|
142
|
+
|
143
|
+
- **release-please-config.json**: Release Please configuration
|
144
|
+
- **dependabot.yml**: Dependency update configuration
|
145
|
+
- **.github/workflows/release-please.yml**: Release PR workflow
|
146
|
+
- **.github/workflows/publish.yml**: Publishing workflow
|
147
|
+
|
148
|
+
### RubyGems Trusted Publishing
|
149
|
+
|
150
|
+
- Configure trusted publishing on RubyGems.org
|
151
|
+
- Use GitHub OIDC for authentication
|
152
|
+
- No API keys or secrets required
|
153
|
+
|
154
|
+
## Success Metrics
|
155
|
+
|
156
|
+
1. **Release Automation**: 100% of releases are automated (no manual intervention)
|
157
|
+
2. **Release Frequency**: Ability to release multiple times per day if needed
|
158
|
+
3. **Quality Gates**: 0% of releases with failing tests or security issues
|
159
|
+
4. **Commit Compliance**: 100% of commits follow conventional commit format
|
160
|
+
5. **Dependency Currency**: Dependencies updated within 7 days of availability
|
161
|
+
6. **Release Time**: From commit to published gem in under 10 minutes
|
162
|
+
|
163
|
+
## Open Questions
|
164
|
+
|
165
|
+
1. **Commit Message Enforcement**: Should we use pre-commit hooks or GitHub Actions validation?
|
166
|
+
2. **Changelog Format**: Should we use conventional-changelog or custom format?
|
167
|
+
3. **Version Bump Strategy**: Should we batch multiple commits into single releases?
|
168
|
+
4. **Rollback Strategy**: How should we handle failed releases or rollbacks?
|
169
|
+
5. **Notification Strategy**: Should we notify maintainers of successful/failed releases?
|
170
|
+
6. **Branch Protection**: Should we require status checks before merging to main?
|
171
|
+
|
172
|
+
## Implementation Priority
|
173
|
+
|
174
|
+
### Phase 1: Core Automation
|
175
|
+
|
176
|
+
- Conventional commits validation
|
177
|
+
- Automated versioning
|
178
|
+
- Basic GitHub Actions workflow
|
179
|
+
- RubyGems publishing
|
180
|
+
|
181
|
+
### Phase 2: Quality & Security
|
182
|
+
|
183
|
+
- Quality gates (tests, RuboCop, security)
|
184
|
+
- Automated changelog generation
|
185
|
+
- Dependabot integration
|
186
|
+
|
187
|
+
### Phase 3: Polish & Monitoring
|
188
|
+
|
189
|
+
- Enhanced notifications
|
190
|
+
- Release monitoring
|
191
|
+
- Performance optimization
|
192
|
+
|
193
|
+
## Acceptance Criteria
|
194
|
+
|
195
|
+
- [ ] All commits follow conventional commit format
|
196
|
+
- [ ] Version numbers are automatically bumped based on commit types
|
197
|
+
- [ ] Releases are automatically published to RubyGems.org
|
198
|
+
- [ ] Changelog is automatically generated from commits
|
199
|
+
- [ ] All tests pass before release
|
200
|
+
- [ ] RuboCop passes before release
|
201
|
+
- [ ] Security audit passes before release
|
202
|
+
- [ ] Dependencies are automatically updated weekly
|
203
|
+
- [ ] Git tags are automatically created for releases
|
204
|
+
- [ ] GitHub releases are automatically created
|
205
|
+
- [ ] RubyGems trusted publishing is configured
|
206
|
+
- [ ] No manual intervention required for releases
|
@@ -0,0 +1,482 @@
|
|
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
|
+
```
|