tokenize_attr 0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: abf1c8d9447a7b940dca5bd4759bf60c82ffb967dfcffa18d5df14b41512cfe2
4
+ data.tar.gz: c88d3236ac9fae09d5c9d879f4ac0baba43df11ebe8408db5d039fae58b2bd6c
5
+ SHA512:
6
+ metadata.gz: f50afa2586d8689212b18a1577da21b372cf499fb1022279475ed609adc0dea4ddcfa712d7ecaa6d70a9fba38732886b68445737f82f8541cd0cf8ba620da4b5
7
+ data.tar.gz: d6e087c763c99973351e884a3cb81b7a13527b04e8664cbe15946fbb1bb77d7fe5bec4a49dfc1827271206f827681c68c3ca782f4386a3a0b76e023edee827b0
data/AGENTS.md ADDED
@@ -0,0 +1,110 @@
1
+ # AGENTS.md — tokenize_attr
2
+
3
+ This file is the primary context document for AI agents and LLMs working on
4
+ this gem. Read it fully before making any changes.
5
+
6
+ ---
7
+
8
+ ## What this gem does
9
+
10
+ `tokenize_attr` provides a single public API — the `tokenize` class macro —
11
+ available on any ActiveRecord model (or any class with `before_create` and
12
+ a class-level `exists?` method).
13
+
14
+ **When no prefix and no generator are given** and the including class responds
15
+ to `has_secure_token` (Rails 5+), `tokenize` delegates entirely to that
16
+ built-in. This gives Rails-idiomatic token generation for free and avoids
17
+ duplicating Rails core behaviour.
18
+
19
+ **When a prefix or a custom generator is given** (or `has_secure_token` is
20
+ unavailable), `tokenize` installs a `before_create` callback that:
21
+
22
+ 1. Skips if the attribute already has a value (`present?` check).
23
+ 2. Calls `generator.call(size)` if a generator was provided, otherwise calls
24
+ `SecureRandom.base58(size)` to produce the random portion.
25
+ 3. Prepends `prefix-` to the random portion when `prefix:` is set.
26
+ 4. Checks uniqueness via `self.class.exists?(attribute => candidate)`.
27
+ 5. Retries up to `retries` times (default 3).
28
+ 6. Raises `TokenizeAttr::RetryExceededError` if all retries are exhausted.
29
+
30
+ ---
31
+
32
+ ## File map
33
+
34
+ | File | Role |
35
+ |-----------------------------------|-------------------------------------------------------------------|
36
+ | `lib/tokenize_attr.rb` | Entry point. Requires sub-files and registers the on_load hook. |
37
+ | `lib/tokenize_attr/version.rb` | `TokenizeAttr::VERSION` constant. |
38
+ | `lib/tokenize_attr/errors.rb` | `TokenizeAttr::Error` and `TokenizeAttr::RetryExceededError`. |
39
+ | `lib/tokenize_attr/tokenizer.rb` | `TokenizeAttr::Tokenizer` — all token-generation logic (private). |
40
+ | `lib/tokenize_attr/concern.rb` | `TokenizeAttr::Concern` — thin concern with the `tokenize` macro. |
41
+ | `test/test_helper.rb` | In-memory SQLite setup, loads the gem. |
42
+ | `test/test_tokenize_attr.rb` | Full test suite. |
43
+ | `sig/tokenize_attr.rbs` | RBS type signatures. |
44
+ | `llms/overview.md` | Internal design notes for LLMs. |
45
+ | `llms/usage.md` | Common usage patterns for LLMs. |
46
+
47
+ ---
48
+
49
+ ## Design decisions
50
+
51
+ ### Why delegate to `has_secure_token`?
52
+ Rails' `has_secure_token` is battle-tested and available in every Rails 5+
53
+ app. Delegating when possible avoids reimplementing core Rails behaviour and
54
+ ensures compatibility with any `regenerate_<attr>` helpers Rails adds.
55
+
56
+ ### Why `SecureRandom.base58`?
57
+ Base58 produces URL-safe strings without ambiguous characters (no `0`, `O`,
58
+ `I`, `l`). Combined with a meaningful prefix it creates tokens that are both
59
+ human-readable and collision-resistant.
60
+
61
+ ### Why configurable `retries` with an explicit error?
62
+ Silent token-generation failures are worse than loud ones. A configurable
63
+ retry budget with an explicit error makes the failure mode observable and
64
+ actionable. The default of 3 is generous given the token space size.
65
+
66
+ ### Why is `retries` ignored when delegating to `has_secure_token`?
67
+ Rails does not perform uniqueness checks in `has_secure_token`; it relies on
68
+ the DB constraint and the astronomically large token space. Retrying there
69
+ would be misleading. Document the discrepancy clearly.
70
+
71
+ ### Why does a generator always bypass `has_secure_token`?
72
+ The user has explicitly chosen a custom algorithm. Delegating to
73
+ `has_secure_token` anyway would silently ignore the generator, which is
74
+ surprising. Presence of a generator always routes through the callback path.
75
+
76
+ ### Why is `TokenizeAttr::Tokenizer` a separate class?
77
+ Keeping all generation helpers in `Tokenizer` rather than in the
78
+ `class_methods` block of `Concern` ensures that none of those methods are
79
+ mixed into the user's model class. This eliminates any risk of method-name
80
+ collisions for models that happen to define methods with similar names.
81
+ `Concern` only exposes the single public `tokenize` macro.
82
+
83
+ ---
84
+
85
+ ## Guardrails
86
+
87
+ - **Do not change the `tokenize` signature** without a major version bump.
88
+ - **Do not add a hard runtime dependency on `activerecord`**. Only
89
+ `activesupport` is required. AR's `before_create` and `exists?` are
90
+ consumed via duck typing.
91
+ - **Keep tests without the `rails` gem**. The suite uses bare `activerecord`
92
+ + in-memory SQLite — no full Rails boot.
93
+ - **Do not swallow `RetryExceededError`**. It is intentionally raised so
94
+ callers can handle it.
95
+ - **Keep `TokenizeAttr::Tokenizer` methods private** (except `apply`). The
96
+ class is `@api private`; only `apply` is the stable internal contract.
97
+
98
+ ---
99
+
100
+ ## Test conventions
101
+
102
+ - One `Minitest::Test` subclass per behavioural group.
103
+ - `setup` truncates the shared `records` table via a raw SQL DELETE.
104
+ - Collision scenarios are tested using **anonymous subclasses** that override
105
+ `exists?` directly (e.g. `Class.new(ApiClient) { def self.exists?(*) = true }`).
106
+ Do **not** use minitest's `stub` on AR model classes — ActiveRecord 8.x
107
+ `method_missing` intercepts `stub` before minitest can install it.
108
+ - Model classes share `self.table_name = "records"` so the schema stays flat.
109
+ - All column names used by test models must exist in the `records` table
110
+ defined in `test/test_helper.rb`.
data/CLAUDE.md ADDED
@@ -0,0 +1,13 @@
1
+ # CLAUDE.md — tokenize_attr
2
+
3
+ ## Start here
4
+
5
+ Before writing any code, read these files in order:
6
+
7
+ 1. **@AGENTS.md** — architecture, design decisions, guardrails, test conventions
8
+ 2. **@llms/overview.md** — class responsibilities and internal design notes
9
+ 3. **@llms/usage.md** — common patterns and recipes
10
+
11
+ ---
12
+
13
+ ## Project context
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Pawel Niemczyk
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,180 @@
1
+ # tokenize_attr
2
+
3
+ Declarative secure token generation for ActiveRecord model attributes.
4
+
5
+ `tokenize_attr` adds a `tokenize` class macro to ActiveRecord models. When no
6
+ prefix is needed it transparently delegates to Rails' built-in
7
+ `has_secure_token` (Rails 5+). When a prefix is required — or when
8
+ `has_secure_token` is unavailable — it installs a `before_create` callback
9
+ backed by `SecureRandom.base58` with configurable size, prefix, and retry
10
+ logic.
11
+
12
+ ## Installation
13
+
14
+ Add to your Gemfile:
15
+
16
+ ```ruby
17
+ gem "tokenize_attr"
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ ### Basic — delegates to `has_secure_token`
23
+
24
+ When no prefix is provided and the model supports `has_secure_token` (every
25
+ Rails 5+ `ApplicationRecord`), the built-in Rails implementation is used
26
+ automatically.
27
+
28
+ ```ruby
29
+ class User < ApplicationRecord
30
+ tokenize :api_token
31
+ end
32
+
33
+ user = User.create!
34
+ user.api_token # => "aBcD1234..." (64 base58 chars by default)
35
+ ```
36
+
37
+ ### With prefix
38
+
39
+ When a `prefix:` is given the gem installs its own `before_create` callback.
40
+
41
+ ```ruby
42
+ class AccessToken < ApplicationRecord
43
+ tokenize :token, prefix: "tok", size: 32
44
+ end
45
+
46
+ AccessToken.create!.token # => "tok-aBcD1234..." ("tok-" + 32 random chars)
47
+ ```
48
+
49
+ ### Custom size (no prefix)
50
+
51
+ ```ruby
52
+ class Session < ApplicationRecord
53
+ tokenize :session_id, size: 128
54
+ end
55
+ ```
56
+
57
+ ### Custom retry budget
58
+
59
+ ```ruby
60
+ class InviteCode < ApplicationRecord
61
+ # Retry up to 5 times before raising TokenizeAttr::RetryExceededError
62
+ tokenize :code, prefix: "inv", retries: 5
63
+ end
64
+ ```
65
+
66
+ ### Custom generator
67
+
68
+ Pass any callable as the second argument (or as a block) to replace
69
+ `SecureRandom.base58` with your own algorithm. The callable receives `size`
70
+ and must return a String.
71
+
72
+ ```ruby
73
+ # Proc as second positional argument
74
+ class Order < ApplicationRecord
75
+ tokenize :reference, proc { |size| SecureRandom.alphanumeric(size) },
76
+ prefix: "ord", size: 12
77
+ end
78
+
79
+ Order.create!.reference # => "ord-aB3cD4eF5gH6"
80
+ ```
81
+
82
+ ```ruby
83
+ # Method reference via &
84
+ class Order < ApplicationRecord
85
+ def self.reference_generator(size) = SecureRandom.alphanumeric(size)
86
+ tokenize :reference, &method(:reference_generator)
87
+ end
88
+ ```
89
+
90
+ ```ruby
91
+ # Inline block (parentheses required)
92
+ class Order < ApplicationRecord
93
+ tokenize(:reference) { |size| SecureRandom.alphanumeric(size) }
94
+ end
95
+ ```
96
+
97
+ > **Note:** Providing a generator always uses the callback path — even when
98
+ > no `prefix:` is given. The generator takes precedence over
99
+ > `has_secure_token`.
100
+
101
+ ### Multiple tokenized attributes
102
+
103
+ ```ruby
104
+ class ApiCredential < ApplicationRecord
105
+ tokenize :public_key, prefix: "pk", size: 32
106
+ tokenize :private_key, prefix: "sk", size: 64
107
+ end
108
+ ```
109
+
110
+ ## Options
111
+
112
+ | Option | Default | Description |
113
+ |-------------|---------|------------------------------------------------------------------|
114
+ | `generator` | `nil` | Proc/block called as `generator.call(size)` → String. |
115
+ | | | Overrides the default `SecureRandom.base58` algorithm. |
116
+ | `size` | `64` | Length passed to the generator or to `SecureRandom.base58`. |
117
+ | `prefix` | `nil` | String prepended as `"prefix-<token>"`. |
118
+ | `retries` | `3` | Max uniqueness-check attempts (custom callback path only). |
119
+
120
+ > **Note:** `retries` is ignored when delegating to `has_secure_token`
121
+ > because Rails does not perform uniqueness checks there. Use a DB unique
122
+ > index to enforce uniqueness and let the DB raise on collision.
123
+
124
+ ## Error handling
125
+
126
+ When the custom callback exhausts all retry attempts it raises
127
+ `TokenizeAttr::RetryExceededError` (a subclass of `TokenizeAttr::Error <
128
+ StandardError`).
129
+
130
+ ```ruby
131
+ begin
132
+ InviteCode.create!
133
+ rescue TokenizeAttr::RetryExceededError => e
134
+ Rails.logger.error(e.message)
135
+ # => "Could not generate a unique token for :code after 5 retries"
136
+ end
137
+ ```
138
+
139
+ ## Rails integration
140
+
141
+ When loaded inside a Rails application `TokenizeAttr::Concern` is
142
+ auto-included into `ActiveRecord::Base` via `ActiveSupport.on_load`, so
143
+ every model gains the `tokenize` macro without an explicit include.
144
+
145
+ Outside of Rails (or with non-AR classes that provide `before_create` and
146
+ `exists?`) include the concern manually:
147
+
148
+ ```ruby
149
+ class MyModel
150
+ include TokenizeAttr::Concern
151
+ end
152
+ ```
153
+
154
+ ## Recommended migration
155
+
156
+ Add a unique index on tokenized columns to enforce uniqueness at the DB
157
+ level:
158
+
159
+ ```ruby
160
+ add_column :access_tokens, :token, :string
161
+ add_index :access_tokens, :token, unique: true
162
+ ```
163
+
164
+ ## Development
165
+
166
+ ```bash
167
+ bin/setup # install dependencies
168
+ bundle exec rake test # run the test suite
169
+ bin/console # interactive prompt
170
+ ```
171
+
172
+ ## Contributing
173
+
174
+ Bug reports and pull requests are welcome on GitHub at
175
+ https://github.com/pniemczyk/tokenize_attr.
176
+
177
+ ## License
178
+
179
+ The gem is available as open source under the terms of the
180
+ [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[test rubocop]
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenizeAttr
4
+ # Include this concern in any ActiveRecord model (or any class that
5
+ # provides +before_create+ and a class-level +exists?+ predicate) to gain
6
+ # the +tokenize+ class macro.
7
+ #
8
+ # When Rails is loaded the concern is auto-included into
9
+ # +ActiveRecord::Base+ via an +ActiveSupport.on_load+ hook, so explicit
10
+ # inclusion is not needed in Rails apps.
11
+ #
12
+ # All generation logic is delegated to +TokenizeAttr::Tokenizer+ so that no
13
+ # internal helpers are mixed into the model class.
14
+ module Concern
15
+ extend ActiveSupport::Concern
16
+
17
+ class_methods do
18
+ # Configures secure token generation for +attribute+.
19
+ #
20
+ # When +prefix+ is +nil+ *and* no +generator+ is given *and* the
21
+ # including class responds to +has_secure_token+ (Rails 5+), the
22
+ # Rails built-in is used. Note: +retries+ is ignored in that path
23
+ # because +has_secure_token+ relies on DB constraints rather than
24
+ # an application-level uniqueness check.
25
+ #
26
+ # Otherwise a custom +before_create+ callback is installed. All
27
+ # implementation details live in +TokenizeAttr::Tokenizer+.
28
+ #
29
+ # @param attribute [Symbol] the attribute to assign the token to
30
+ # @param generator [Proc, nil] optional callable invoked as
31
+ # +generator.call(size)+ to produce the random portion of the token.
32
+ # May also be supplied as a block. When present the custom callback
33
+ # path is always used, even when +has_secure_token+ is available.
34
+ # @param size [Integer] length passed to the generator or
35
+ # to +SecureRandom.base58+ (default 64)
36
+ # @param prefix [String, nil] optional prefix, joined as
37
+ # +"prefix-<token>"+
38
+ # @param retries [Integer] max uniqueness-check retries
39
+ # (default 3); ignored on the +has_secure_token+ path
40
+ #
41
+ # @raise [TokenizeAttr::RetryExceededError] when uniqueness cannot be
42
+ # established within the retry budget
43
+ #
44
+ # @example Delegate to has_secure_token (no prefix, no generator)
45
+ # class User < ApplicationRecord
46
+ # tokenize :api_token
47
+ # end
48
+ #
49
+ # @example Custom callback with prefix
50
+ # class AccessToken < ApplicationRecord
51
+ # tokenize :token, size: 32, prefix: "tok"
52
+ # end
53
+ #
54
+ # AccessToken.create!.token #=> "tok-aBcD1234..."
55
+ #
56
+ # @example Proc passed as second argument
57
+ # class Order < ApplicationRecord
58
+ # tokenize :reference, proc { |size| SecureRandom.alphanumeric(size) },
59
+ # prefix: "ord", size: 12
60
+ # end
61
+ #
62
+ # @example Method reference via &
63
+ # class Order < ApplicationRecord
64
+ # def self.reference_generator(size) = SecureRandom.alphanumeric(size)
65
+ # tokenize :reference, &method(:reference_generator)
66
+ # end
67
+ #
68
+ # @example Inline block
69
+ # class Order < ApplicationRecord
70
+ # tokenize(:reference) { |size| SecureRandom.alphanumeric(size) }
71
+ # end
72
+ def tokenize(attribute, generator = nil, size: 64, prefix: nil, retries: 3, &block)
73
+ TokenizeAttr::Tokenizer.apply(
74
+ self, attribute,
75
+ generator: generator || block,
76
+ size: size,
77
+ prefix: prefix,
78
+ retries: retries
79
+ )
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenizeAttr
4
+ # Base error class for all tokenize_attr exceptions.
5
+ class Error < StandardError; end
6
+
7
+ # Raised when a unique token cannot be generated within the configured
8
+ # number of retries.
9
+ #
10
+ # @example
11
+ # begin
12
+ # MyModel.create!
13
+ # rescue TokenizeAttr::RetryExceededError => e
14
+ # Rails.logger.error(e.message)
15
+ # end
16
+ class RetryExceededError < Error
17
+ # @param attribute [Symbol, String] the attribute that failed to tokenize
18
+ # @param retries [Integer] the number of attempts that were made
19
+ def initialize(attribute, retries)
20
+ super("Could not generate a unique token for :#{attribute} after #{retries} retries")
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenizeAttr
4
+ # Internal class that holds all token-generation logic.
5
+ #
6
+ # Keeping this logic here — rather than inside +TokenizeAttr::Concern+'s
7
+ # +class_methods+ block — ensures that none of these methods are mixed
8
+ # into the including model class, eliminating any risk of method-name
9
+ # collisions on user models.
10
+ #
11
+ # @api private
12
+ class Tokenizer
13
+ class << self
14
+ # Determines which generation strategy to use and configures +klass+.
15
+ #
16
+ # Routes to +has_secure_token+ when all of the following are true:
17
+ # - +generator+ is +nil+
18
+ # - +prefix+ is +nil+
19
+ # - +klass+ responds to +has_secure_token+ (Rails 5+)
20
+ #
21
+ # Otherwise installs a custom +before_create+ callback.
22
+ #
23
+ # @param klass [Class] the model class to configure
24
+ # @param attribute [Symbol] the attribute to assign the token to
25
+ # @param generator [Proc, nil] optional callable invoked as
26
+ # +generator.call(size)+ to produce the random portion
27
+ # @param size [Integer] length passed to the generator or to
28
+ # +SecureRandom.base58+
29
+ # @param prefix [String, nil] optional prefix, joined as
30
+ # +"prefix-<token>"+
31
+ # @param retries [Integer] max uniqueness-check attempts;
32
+ # ignored on the +has_secure_token+ path
33
+ def apply(klass, attribute, generator:, size:, prefix:, retries:) # rubocop:disable Metrics/ParameterLists
34
+ if generator.nil? && prefix.nil? && has_secure_token?(klass)
35
+ via_has_secure_token(klass, attribute, size)
36
+ else
37
+ via_callback(klass, attribute, size: size, prefix: prefix,
38
+ retries: retries, generator: generator)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def has_secure_token?(klass) # rubocop:disable Naming/PredicatePrefix
45
+ klass.respond_to?(:has_secure_token)
46
+ end
47
+
48
+ # Delegates to Rails' has_secure_token. Passes +length:+ when supported
49
+ # (Rails 6.1+); falls back silently on older Rails versions.
50
+ def via_has_secure_token(klass, attribute, size)
51
+ klass.has_secure_token(attribute, length: size)
52
+ rescue ArgumentError
53
+ klass.has_secure_token(attribute)
54
+ end
55
+
56
+ # Installs a +before_create+ callback for custom token generation.
57
+ #
58
+ # When +generator+ is provided it is called as +generator.call(size)+
59
+ # to produce the random portion; otherwise +SecureRandom.base58(size)+
60
+ # is used.
61
+ def via_callback(klass, attribute, size:, prefix:, retries:, generator: nil) # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
62
+ klass.before_create do
63
+ next if send(attribute).present?
64
+
65
+ token_generated = false
66
+
67
+ retries.times do
68
+ random_part = generator ? generator.call(size) : SecureRandom.base58(size)
69
+ candidate = [prefix, random_part].compact.join("-")
70
+ send(:"#{attribute}=", candidate)
71
+
72
+ unless self.class.exists?(attribute => candidate)
73
+ token_generated = true
74
+ break
75
+ end
76
+ end
77
+
78
+ raise TokenizeAttr::RetryExceededError.new(attribute, retries) unless token_generated
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenizeAttr
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/concern"
5
+ require "securerandom"
6
+
7
+ require_relative "tokenize_attr/version"
8
+ require_relative "tokenize_attr/errors"
9
+ require_relative "tokenize_attr/tokenizer"
10
+ require_relative "tokenize_attr/concern"
11
+
12
+ module TokenizeAttr
13
+ # When loaded inside a Rails application, auto-include the concern into
14
+ # ActiveRecord::Base so every model gains the +tokenize+ macro without
15
+ # an explicit include. If ActiveRecord is already loaded (e.g. in tests
16
+ # without a full Rails boot) the block executes immediately.
17
+ ActiveSupport.on_load(:active_record) { include TokenizeAttr::Concern }
18
+ end
data/llms/overview.md ADDED
@@ -0,0 +1,81 @@
1
+ # tokenize_attr — LLM Context Overview
2
+
3
+ > Load this file before modifying or extending the gem.
4
+
5
+ ## Purpose
6
+
7
+ `tokenize_attr` is a Ruby gem that provides declarative secure token generation
8
+ for ActiveRecord model attributes via a single `tokenize` class macro.
9
+
10
+ ---
11
+
12
+ ## Public API
13
+
14
+ ```ruby
15
+ # Full signature
16
+ tokenize(attribute, generator = nil, size: 64, prefix: nil, retries: 3, &block)
17
+ ```
18
+
19
+ | Param | Type | Default | Notes |
20
+ |-------------|------------------|---------|----------------------------------------------------|
21
+ | `attribute` | Symbol | — | The attribute to tokenize |
22
+ | `generator` | Proc / nil | nil | Called as `generator.call(size)` for random part. |
23
+ | | | | May also be supplied as a `&block`. |
24
+ | `size` | Integer | 64 | Passed to generator or to `SecureRandom.base58` |
25
+ | `prefix` | String / nil | nil | Prepended as `"prefix-<token>"` |
26
+ | `retries` | Integer | 3 | Max uniqueness-check attempts (callback path only) |
27
+
28
+ ---
29
+
30
+ ## Internal decision tree
31
+
32
+ All logic below lives in `TokenizeAttr::Tokenizer` (see
33
+ `lib/tokenize_attr/tokenizer.rb`). `TokenizeAttr::Concern#tokenize` is a thin
34
+ delegator that calls `TokenizeAttr::Tokenizer.apply`.
35
+
36
+ ```
37
+ tokenize called → TokenizeAttr::Tokenizer.apply(klass, attribute, ...)
38
+ └─ generator nil? AND prefix nil? AND has_secure_token available?
39
+ ├─ YES → Tokenizer.via_has_secure_token(klass, attribute, size)
40
+ │ tries: klass.has_secure_token(attribute, length: size)
41
+ │ rescue ArgumentError → klass.has_secure_token(attribute)
42
+ │ (retries param is IGNORED on this path)
43
+
44
+ └─ NO → Tokenizer.via_callback(klass, attribute, size:, prefix:, retries:, generator:)
45
+ klass.before_create:
46
+ attribute.present? → skip
47
+ loop retries times:
48
+ random_part = generator ? generator.call(size)
49
+ : SecureRandom.base58(size)
50
+ candidate = [prefix, random_part].compact.join("-")
51
+ assign candidate to attribute
52
+ exists?(attribute => candidate)? → next iteration
53
+ else → token_generated = true; break
54
+ token_generated? → done
55
+ else → raise RetryExceededError
56
+ ```
57
+
58
+ ---
59
+
60
+ ## Error class hierarchy
61
+
62
+ ```
63
+ StandardError
64
+ └── TokenizeAttr::Error
65
+ └── TokenizeAttr::RetryExceededError
66
+ ```
67
+
68
+ ---
69
+
70
+ ## Key constraints
71
+
72
+ - Only `activesupport` is a hard runtime dependency.
73
+ - `ActiveRecord::Base` receives the concern via `ActiveSupport.on_load(:active_record)`.
74
+ - All generation helpers live in `TokenizeAttr::Tokenizer`, not in the model
75
+ class. This prevents method-name collisions on user models. Only the
76
+ `tokenize` macro is mixed in via `Concern`.
77
+ - Tests use bare `activerecord` + SQLite3 — no `rails` gem.
78
+ - `has_secure_token` delegation is skipped when `prefix:` is provided
79
+ because that built-in has no prefix support.
80
+ - `has_secure_token` delegation is also skipped when a `generator` is
81
+ provided — the generator always routes through the callback path.
data/llms/usage.md ADDED
@@ -0,0 +1,181 @@
1
+ # tokenize_attr — Usage Patterns
2
+
3
+ > Common patterns and recipes for LLMs helping users work with this gem.
4
+
5
+ ---
6
+
7
+ ## Basic (no prefix) — delegates to `has_secure_token`
8
+
9
+ ```ruby
10
+ class User < ApplicationRecord
11
+ tokenize :api_token
12
+ end
13
+
14
+ user = User.create!
15
+ user.api_token # => "aBcD1234..." (64 base58 chars)
16
+ ```
17
+
18
+ `has_secure_token` is used automatically. `retries` is ignored on this path.
19
+
20
+ ---
21
+
22
+ ## With prefix — uses custom callback
23
+
24
+ ```ruby
25
+ class AccessToken < ApplicationRecord
26
+ tokenize :token, prefix: "tok", size: 32
27
+ end
28
+
29
+ token = AccessToken.create!
30
+ token.token # => "tok-aBcD1234..." ("tok-" + 32 random chars)
31
+ ```
32
+
33
+ ---
34
+
35
+ ## Custom size (no prefix)
36
+
37
+ ```ruby
38
+ class Session < ApplicationRecord
39
+ tokenize :session_id, size: 128
40
+ end
41
+ ```
42
+
43
+ ---
44
+
45
+ ## Custom retry budget
46
+
47
+ ```ruby
48
+ class InviteCode < ApplicationRecord
49
+ tokenize :code, prefix: "inv", retries: 5
50
+ end
51
+ ```
52
+
53
+ ---
54
+
55
+ ## Handling `RetryExceededError`
56
+
57
+ ```ruby
58
+ begin
59
+ InviteCode.create!
60
+ rescue TokenizeAttr::RetryExceededError => e
61
+ Rails.logger.error("Token generation failed: #{e.message}")
62
+ # => "Could not generate a unique token for :code after 5 retries"
63
+ end
64
+ ```
65
+
66
+ ---
67
+
68
+ ## Custom generator — proc as second argument
69
+
70
+ ```ruby
71
+ class Order < ApplicationRecord
72
+ tokenize :reference, proc { |size| SecureRandom.alphanumeric(size) },
73
+ prefix: "ord", size: 12
74
+ end
75
+
76
+ Order.create!.reference # => "ord-aB3cD4eF5gH6"
77
+ ```
78
+
79
+ The proc receives `size` and must return a String. The `prefix` is still
80
+ applied on top of whatever the proc returns.
81
+
82
+ ---
83
+
84
+ ## Custom generator — method reference via `&`
85
+
86
+ ```ruby
87
+ class Order < ApplicationRecord
88
+ def self.reference_generator(size)
89
+ SecureRandom.alphanumeric(size)
90
+ end
91
+
92
+ tokenize :reference, &method(:reference_generator)
93
+ end
94
+ ```
95
+
96
+ `method(:reference_generator)` converts the class method to a `Method`
97
+ object which responds to `call(size)`. This is identical to passing a proc.
98
+
99
+ ---
100
+
101
+ ## Custom generator — inline block
102
+
103
+ ```ruby
104
+ class Order < ApplicationRecord
105
+ # Parentheses required when passing a block to a method call inside a class
106
+ tokenize(:reference) { |size| SecureRandom.alphanumeric(size) }
107
+ end
108
+ ```
109
+
110
+ ---
111
+
112
+ ## Custom generator with no prefix — bypasses `has_secure_token`
113
+
114
+ Providing any generator always uses the callback path, even when no `prefix:`
115
+ is given and `has_secure_token` is available:
116
+
117
+ ```ruby
118
+ class User < ApplicationRecord
119
+ tokenize :api_token, proc { |size| "usr_#{SecureRandom.hex(size / 2)}" }
120
+ end
121
+ ```
122
+
123
+ ---
124
+
125
+ ## Multiple tokenized attributes
126
+
127
+ ```ruby
128
+ class ApiCredential < ApplicationRecord
129
+ tokenize :public_key, prefix: "pk", size: 32
130
+ tokenize :private_key, prefix: "sk", size: 64
131
+ end
132
+ ```
133
+
134
+ ---
135
+
136
+ ## Manual inclusion (non-Rails or non-AR classes)
137
+
138
+ ```ruby
139
+ class MyModel
140
+ include TokenizeAttr::Concern
141
+
142
+ # Provide before_create and self.exists? for the concern to work.
143
+ end
144
+ ```
145
+
146
+ ---
147
+
148
+ ## Recommended migration
149
+
150
+ ```ruby
151
+ # db/migrate/YYYYMMDDHHMMSS_add_token_to_access_tokens.rb
152
+ class AddTokenToAccessTokens < ActiveRecord::Migration[7.1]
153
+ def change
154
+ add_column :access_tokens, :token, :string
155
+ add_index :access_tokens, :token, unique: true
156
+ end
157
+ end
158
+ ```
159
+
160
+ ---
161
+
162
+ ## Checking which path `tokenize` will use
163
+
164
+ ```ruby
165
+ # In a Rails console or script:
166
+ MyModel.respond_to?(:has_secure_token) # => true/false
167
+ # true → tokenize without prefix will use has_secure_token
168
+ # false → tokenize always uses the custom callback
169
+ ```
170
+
171
+ ---
172
+
173
+ ## Preserving a pre-set token
174
+
175
+ The callback checks `present?` before generating, so a pre-set value is
176
+ always preserved:
177
+
178
+ ```ruby
179
+ token = AccessToken.create!(token: "tok-my-custom-value")
180
+ token.token # => "tok-my-custom-value"
181
+ ```
@@ -0,0 +1,35 @@
1
+ module TokenizeAttr
2
+ VERSION: String
3
+
4
+ class Error < StandardError
5
+ end
6
+
7
+ class RetryExceededError < Error
8
+ def initialize: (Symbol | String attribute, Integer retries) -> void
9
+ end
10
+
11
+ # @api private
12
+ class Tokenizer
13
+ def self.apply: (
14
+ Class klass,
15
+ Symbol attribute,
16
+ generator: Proc?,
17
+ size: Integer,
18
+ prefix: String?,
19
+ retries: Integer
20
+ ) -> void
21
+ end
22
+
23
+ module Concern
24
+ module ClassMethods
25
+ # generator may be passed as a positional Proc OR as a block.
26
+ def tokenize: (
27
+ Symbol attribute,
28
+ ?Proc? generator,
29
+ ?size: Integer,
30
+ ?prefix: String?,
31
+ ?retries: Integer
32
+ ) ?{ (Integer) -> String } -> void
33
+ end
34
+ end
35
+ end
metadata ADDED
@@ -0,0 +1,80 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tokenize_attr
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Pawel Niemczyk
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: activesupport
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.0'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '7.0'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9'
32
+ description: |
33
+ tokenize_attr adds a `tokenize` class macro to ActiveRecord models. When no
34
+ prefix is needed it transparently delegates to Rails' built-in
35
+ has_secure_token (Rails 5+). When a prefix is required it installs a
36
+ before_create callback backed by SecureRandom.base58 with configurable
37
+ size, prefix, and retry logic.
38
+ email:
39
+ - pniemczyk.info@gmail.com
40
+ executables: []
41
+ extensions: []
42
+ extra_rdoc_files: []
43
+ files:
44
+ - AGENTS.md
45
+ - CLAUDE.md
46
+ - LICENSE.txt
47
+ - README.md
48
+ - Rakefile
49
+ - lib/tokenize_attr.rb
50
+ - lib/tokenize_attr/concern.rb
51
+ - lib/tokenize_attr/errors.rb
52
+ - lib/tokenize_attr/tokenizer.rb
53
+ - lib/tokenize_attr/version.rb
54
+ - llms/overview.md
55
+ - llms/usage.md
56
+ - sig/tokenize_attr.rbs
57
+ homepage: https://github.com/pniemczyk/tokenize_attr
58
+ licenses:
59
+ - MIT
60
+ metadata:
61
+ homepage_uri: https://github.com/pniemczyk/tokenize_attr
62
+ source_code_uri: https://github.com/pniemczyk/tokenize_attr
63
+ rdoc_options: []
64
+ require_paths:
65
+ - lib
66
+ required_ruby_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: 3.2.0
71
+ required_rubygems_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ requirements: []
77
+ rubygems_version: 3.6.9
78
+ specification_version: 4
79
+ summary: Declarative secure token generation for ActiveRecord model attributes.
80
+ test_files: []