encoded_id-rails 1.0.0.rc1 → 1.0.0.rc7

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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +97 -18
  3. data/LICENSE.txt +1 -1
  4. data/README.md +81 -473
  5. data/context/encoded_id-rails.md +651 -0
  6. data/context/encoded_id.md +437 -0
  7. data/lib/encoded_id/rails/active_record_finders.rb +54 -0
  8. data/lib/encoded_id/rails/annotated_id.rb +14 -9
  9. data/lib/encoded_id/rails/annotated_id_parser.rb +9 -1
  10. data/lib/encoded_id/rails/coder.rb +55 -10
  11. data/lib/encoded_id/rails/composite_id_base.rb +39 -0
  12. data/lib/encoded_id/rails/configuration.rb +66 -10
  13. data/lib/encoded_id/rails/encoder_methods.rb +30 -7
  14. data/lib/encoded_id/rails/finder_methods.rb +11 -0
  15. data/lib/encoded_id/rails/model.rb +60 -7
  16. data/lib/encoded_id/rails/path_param.rb +8 -0
  17. data/lib/encoded_id/rails/persists.rb +55 -9
  18. data/lib/encoded_id/rails/query_methods.rb +21 -4
  19. data/lib/encoded_id/rails/railtie.rb +13 -0
  20. data/lib/encoded_id/rails/salt.rb +8 -0
  21. data/lib/encoded_id/rails/slugged_id.rb +14 -9
  22. data/lib/encoded_id/rails/slugged_id_parser.rb +9 -1
  23. data/lib/encoded_id/rails/slugged_path_param.rb +8 -0
  24. data/lib/encoded_id/rails.rb +11 -6
  25. data/lib/generators/encoded_id/rails/install_generator.rb +36 -2
  26. data/lib/generators/encoded_id/rails/templates/{encoded_id.rb → hashids_encoded_id.rb} +49 -5
  27. data/lib/generators/encoded_id/rails/templates/sqids_encoded_id.rb +116 -0
  28. metadata +16 -24
  29. data/.devcontainer/Dockerfile +0 -17
  30. data/.devcontainer/compose.yml +0 -10
  31. data/.devcontainer/devcontainer.json +0 -12
  32. data/.standard.yml +0 -3
  33. data/Appraisals +0 -9
  34. data/Gemfile +0 -24
  35. data/Rakefile +0 -20
  36. data/Steepfile +0 -4
  37. data/gemfiles/.bundle/config +0 -2
  38. data/gemfiles/rails_7.2.gemfile +0 -19
  39. data/gemfiles/rails_8.0.gemfile +0 -19
  40. data/lib/encoded_id/rails/version.rb +0 -7
  41. data/rbs_collection.yaml +0 -24
  42. data/sig/encoded_id/rails.rbs +0 -141
@@ -0,0 +1,437 @@
1
+ # EncodedId Ruby Gem - Technical Documentation
2
+
3
+ ## Overview
4
+
5
+ `encoded_id` is a Ruby gem that provides reversible obfuscation of numerical and hexadecimal IDs into human-readable strings suitable for use in URLs. It offers a secure way to hide sequential database IDs from users while maintaining the ability to decode them back to their original values.
6
+
7
+ ## Key Features
8
+
9
+ - **Reversible Encoding**: Unlike UUIDs, encoded IDs can be decoded back to their original numeric values
10
+ - **Multiple ID Support**: Encode multiple numeric IDs in a single string
11
+ - **Algorithm Choice**: Supports both HashIds and Sqids encoding algorithms
12
+ - **Human-Readable Format**: Character grouping and configurable separators for better readability
13
+ - **Character Mapping**: Handles easily confused characters (0/O, 1/I/l) through equivalence mapping
14
+ - **Performance Optimized**: Uses an optimized HashIds implementation for better performance
15
+ - **Profanity Protection**: Built-in blocklist support to prevent offensive words in generated IDs
16
+ - **Customizable**: Configurable alphabets, lengths, and formatting options
17
+ - **Blocklist Modes**: Three modes for controlling blocklist checking performance
18
+
19
+ ## Quick Reference
20
+
21
+ ```ruby
22
+ # Sqids encoder (default, no salt required)
23
+ coder = EncodedId::ReversibleId.sqids(min_length: 10)
24
+ id = coder.encode(123) # => "p5w9-z27j-k8"
25
+ nums = coder.decode(id) # => [123]
26
+
27
+ # Hashids encoder (requires salt)
28
+ coder = EncodedId::ReversibleId.hashid(salt: "my-salt", min_length: 8)
29
+ id = coder.encode([78, 45]) # => "z2j7-0dmw"
30
+ nums = coder.decode(id) # => [78, 45]
31
+
32
+ # UUID encoding (experimental)
33
+ hex_id = coder.encode_hex("9a566b8b-8618-42ab-8db7-a5a0276401fd")
34
+ uuid = coder.decode_hex(hex_id).first
35
+ ```
36
+
37
+ ## Core API
38
+
39
+ ### EncodedId::ReversibleId
40
+
41
+ The main class for encoding and decoding IDs.
42
+
43
+ #### Factory Methods (Recommended)
44
+
45
+ Factory methods provide the cleanest way to create encoders:
46
+
47
+ ```ruby
48
+ # Sqids encoder (default, no salt required)
49
+ coder = EncodedId::ReversibleId.sqids(
50
+ min_length: 10,
51
+ blocklist: ["bad", "words"]
52
+ )
53
+
54
+ # Hashids encoder (requires salt)
55
+ coder = EncodedId::ReversibleId.hashid(
56
+ salt: "my-salt",
57
+ min_length: 8,
58
+ blocklist: ["bad", "words"]
59
+ )
60
+ ```
61
+
62
+ Both factory methods accept all configuration options described below.
63
+
64
+ #### Constructor (Alternative)
65
+
66
+ You can also use the constructor with explicit configuration objects:
67
+
68
+ ```ruby
69
+ # Using Sqids configuration
70
+ config = EncodedId::Encoders::SqidsConfiguration.new(
71
+ min_length: 8, # Minimum length of encoded string
72
+ split_at: 4, # Split encoded string every X characters
73
+ split_with: "-", # Character to split with
74
+ alphabet: EncodedId::Alphabet.modified_crockford,
75
+ hex_digit_encoding_group_size: 4,
76
+ max_length: 128, # Maximum length limit
77
+ max_inputs_per_id: 32, # Maximum IDs to encode together
78
+ blocklist: nil, # Words to prevent in IDs
79
+ blocklist_mode: :length_threshold, # :always, :length_threshold, or :raise_if_likely
80
+ blocklist_max_length: 32 # Max length for :length_threshold mode
81
+ )
82
+ coder = EncodedId::ReversibleId.new(config)
83
+
84
+ # Using Hashids configuration (requires salt)
85
+ config = EncodedId::Encoders::HashidConfiguration.new(
86
+ salt: "my-salt", # Required for Hashids (min 4 chars)
87
+ min_length: 8,
88
+ # ... other options same as above
89
+ )
90
+ coder = EncodedId::ReversibleId.new(config)
91
+ ```
92
+
93
+ **Note**: As of v1.0.0, the default encoder is `:sqids`. For backwards compatibility with pre-v1 versions, use `ReversibleId.hashid()`.
94
+
95
+ #### Key Methods
96
+
97
+ ##### encode(values)
98
+ Encodes one or more integer IDs into an obfuscated string.
99
+
100
+ ```ruby
101
+ coder = EncodedId::ReversibleId.sqids
102
+
103
+ # Single ID
104
+ coder.encode(123) # => "p5w9-z27j"
105
+
106
+ # Multiple IDs
107
+ coder.encode([78, 45]) # => "z2j7-0dmw"
108
+ ```
109
+
110
+ ##### decode(encoded_id, downcase: false)
111
+ Decodes an encoded string back to original IDs.
112
+
113
+ ```ruby
114
+ coder.decode("p5w9-z27j") # => [123]
115
+ coder.decode("z2j7-0dmw") # => [78, 45]
116
+
117
+ # Case-sensitive by default (v1.0.0+)
118
+ coder.decode("p5w9-z27J") # => [] (case doesn't match)
119
+
120
+ # For case-insensitive matching (pre-v1 behavior)
121
+ coder.decode("p5w9-z27J", downcase: true) # => [123]
122
+ ```
123
+
124
+ **Note**: As of v1.0.0, decoding is case-sensitive by default (`downcase: false`). Set `downcase: true` for backwards compatibility.
125
+
126
+ ##### encode_hex(hex_strings) (Experimental)
127
+ Encodes hexadecimal strings (like UUIDs).
128
+
129
+ ```ruby
130
+ # Encode UUID
131
+ coder.encode_hex("9a566b8b-8618-42ab-8db7-a5a0276401fd")
132
+ # => "5jjy-c8d9-hxp2-qsve-rgh9-rxnt-7nb5-tve7-bf84-vr"
133
+
134
+ # With larger group size for shorter output
135
+ coder = EncodedId::ReversibleId.sqids(hex_digit_encoding_group_size: 32)
136
+ coder.encode_hex("9a566b8b-8618-42ab-8db7-a5a0276401fd")
137
+ # => "vr7m-qra8-m5y6-dkgj-5rqr-q44e-gp4a-52"
138
+ ```
139
+
140
+ ##### decode_hex(encoded_id, downcase: false) (Experimental)
141
+ Decodes back to hexadecimal strings.
142
+
143
+ ```ruby
144
+ coder.decode_hex("w72a-y0az") # => ["10f8c"]
145
+
146
+ # For case-insensitive decoding (pre-v1 behavior)
147
+ coder.decode_hex("W72A-Y0AZ", downcase: true) # => ["10f8c"]
148
+ ```
149
+
150
+ ### EncodedId::Alphabet
151
+
152
+ Class for creating custom alphabets.
153
+
154
+ #### Predefined Alphabets
155
+
156
+ ```ruby
157
+ # Default: modified Crockford Base32
158
+ # Characters: "0123456789abcdefghjkmnpqrstuvwxyz"
159
+ # Excludes: i, l, o, u (easily confused)
160
+ # Equivalences: {"o"=>"0", "i"=>"j", "l"=>"1", ...}
161
+ EncodedId::Alphabet.modified_crockford
162
+ ```
163
+
164
+ #### Custom Alphabets
165
+
166
+ ```ruby
167
+ # Simple custom alphabet
168
+ alphabet = EncodedId::Alphabet.new("0123456789abcdef")
169
+
170
+ # With character equivalences
171
+ alphabet = EncodedId::Alphabet.new(
172
+ "0123456789ABCDEF",
173
+ {"a"=>"A", "b"=>"B", "c"=>"C", "d"=>"D", "e"=>"E", "f"=>"F"}
174
+ )
175
+
176
+ # Greek alphabet example
177
+ alphabet = EncodedId::Alphabet.new("αβγδεζηθικλμνξοπρστυφχψω")
178
+ coder = EncodedId::ReversibleId.sqids(alphabet: alphabet)
179
+ coder.encode(123) # => "θεαψ-ζκυο"
180
+ ```
181
+
182
+ ### EncodedId::Blocklist
183
+
184
+ Class for managing profanity/word blocklists.
185
+
186
+ #### Predefined Blocklists
187
+
188
+ ```ruby
189
+ # Empty blocklist (no filtering)
190
+ EncodedId::Blocklist.empty
191
+
192
+ # Minimal blocklist (~50 common profane words)
193
+ EncodedId::Blocklist.minimal
194
+
195
+ # Full Sqids default blocklist (comprehensive)
196
+ EncodedId::Blocklist.sqids_blocklist
197
+
198
+ # Use in configuration
199
+ coder = EncodedId::ReversibleId.sqids(
200
+ blocklist: EncodedId::Blocklist.minimal
201
+ )
202
+ ```
203
+
204
+ #### Custom Blocklists
205
+
206
+ ```ruby
207
+ # From array
208
+ blocklist = EncodedId::Blocklist.new(["bad", "offensive", "words"])
209
+
210
+ # Merge blocklists
211
+ combined = EncodedId::Blocklist.minimal.merge(
212
+ EncodedId::Blocklist.new(["custom", "words"])
213
+ )
214
+
215
+ # Filter for specific alphabet (automatic with configuration)
216
+ filtered = blocklist.filter_for_alphabet(EncodedId::Alphabet.modified_crockford)
217
+ ```
218
+
219
+ **Note**: Blocklists are automatically filtered to only include words possible with your configured alphabet. This optimization improves performance.
220
+
221
+ ## Configuration Options
222
+
223
+ ### Basic Options
224
+
225
+ - **min_length**: Minimum length of encoded string (default: 8)
226
+ - **max_length**: Maximum allowed length (default: 128) to prevent DoS attacks
227
+ - **max_inputs_per_id**: Maximum IDs encodable together (default: 32)
228
+ - **hex_digit_encoding_group_size**: Group size for hex encoding (default: 4)
229
+
230
+ ### Encoder Selection
231
+
232
+ ```ruby
233
+ # Sqids encoder (default, no salt required)
234
+ coder = EncodedId::ReversibleId.sqids
235
+
236
+ # Hashids encoder (requires salt - minimum 4 characters)
237
+ coder = EncodedId::ReversibleId.hashid(salt: "my-salt-minimum-4-chars")
238
+ ```
239
+
240
+ **Important**:
241
+ - As of v1.0.0, `:sqids` is the default encoder
242
+ - **Sqids**: No salt required, automatically avoids blocklisted words via iteration
243
+ - **Hashids**: Salt required (min 4 chars), raises exception if blocklisted word appears
244
+ - HashIds and Sqids produce different encodings and are **not compatible**
245
+ - Do NOT change encoders after going to production with existing encoded IDs
246
+
247
+ ### Blocklist Configuration
248
+
249
+ #### Blocklist Modes
250
+
251
+ Control how blocklist checking behaves to balance performance and safety:
252
+
253
+ ```ruby
254
+ # :length_threshold (default) - Check blocklist only until encoded length reaches blocklist_max_length
255
+ # Best for most use cases - prevents performance issues with very long IDs
256
+ coder = EncodedId::ReversibleId.sqids(
257
+ blocklist: EncodedId::Blocklist.minimal,
258
+ blocklist_mode: :length_threshold,
259
+ blocklist_max_length: 32 # Stop checking after 32 characters
260
+ )
261
+
262
+ # :always - Always check blocklist regardless of encoded length
263
+ # Can be slow for long IDs or large blocklists
264
+ coder = EncodedId::ReversibleId.hashid(
265
+ salt: "my-salt",
266
+ blocklist: ["bad", "words"],
267
+ blocklist_mode: :always
268
+ )
269
+
270
+ # :raise_if_likely - Raise error at configuration time if settings likely cause blocklist collisions
271
+ # Prevents configurations that would cause performance issues
272
+ coder = EncodedId::ReversibleId.sqids(
273
+ min_length: 8,
274
+ blocklist: ["bad", "words"],
275
+ blocklist_mode: :raise_if_likely
276
+ )
277
+ # Raises InvalidConfigurationError if min_length > blocklist_max_length
278
+ ```
279
+
280
+ **Blocklist Behavior by Encoder**:
281
+ - **Sqids**: Iteratively regenerates to avoid blocklisted words (may impact encoding performance)
282
+ - **Hashids**: Raises `EncodedId::BlocklistError` if a blocklisted word appears
283
+
284
+ **Recommendation**: Use `:length_threshold` mode (default) for best balance of performance and safety.
285
+
286
+ ### Formatting Options
287
+
288
+ ```ruby
289
+ # Custom splitting
290
+ coder = EncodedId::ReversibleId.sqids(
291
+ split_at: 3, # Group every 3 chars
292
+ split_with: "." # Use dots
293
+ )
294
+ coder.encode(123) # => "p5w.9z2.7j"
295
+
296
+ # No splitting
297
+ coder = EncodedId::ReversibleId.sqids(split_at: nil)
298
+ coder.encode(123) # => "p5w9z27j"
299
+ ```
300
+
301
+ ## Exception Handling
302
+
303
+ | Exception | Description |
304
+ |-----------|-------------|
305
+ | `EncodedId::InvalidConfigurationError` | Invalid configuration parameters |
306
+ | `EncodedId::InvalidAlphabetError` | Invalid alphabet (< 16 unique chars) |
307
+ | `EncodedId::EncodedIdFormatError` | Invalid encoded ID format |
308
+ | `EncodedId::EncodedIdLengthError` | Encoded ID exceeds max_length |
309
+ | `EncodedId::InvalidInputError` | Invalid input (negative integers, too many inputs) |
310
+ | `EncodedId::SaltError` | Invalid salt (too short, only for Hashids) |
311
+ | `EncodedId::BlocklistError` | Generated ID contains blocklisted word (Hashids only) |
312
+
313
+ ## Usage Examples
314
+
315
+ ### Basic Usage
316
+ ```ruby
317
+ # Initialize with Sqids (no salt needed)
318
+ coder = EncodedId::ReversibleId.sqids
319
+
320
+ # Encode/decode cycle
321
+ encoded = coder.encode(123) # => "p5w9-z27j"
322
+ decoded = coder.decode(encoded) # => [123]
323
+ original_id = decoded.first # => 123
324
+ ```
325
+
326
+ ### Multiple IDs
327
+ ```ruby
328
+ # Encode multiple IDs in one string
329
+ encoded = coder.encode([78, 45, 92]) # => "z2j7-0dmw-kf8p"
330
+ decoded = coder.decode(encoded) # => [78, 45, 92]
331
+ ```
332
+
333
+ ### With Hashids and Blocklist
334
+ ```ruby
335
+ coder = EncodedId::ReversibleId.hashid(
336
+ salt: "my-app-salt",
337
+ min_length: 12,
338
+ blocklist: EncodedId::Blocklist.minimal,
339
+ blocklist_mode: :length_threshold
340
+ )
341
+
342
+ encoded = coder.encode(123)
343
+ # Raises BlocklistError if result contains blocklisted word
344
+ ```
345
+
346
+ ### Custom Configuration
347
+ ```ruby
348
+ # Highly customized Sqids instance
349
+ coder = EncodedId::ReversibleId.sqids(
350
+ min_length: 12,
351
+ split_at: 3,
352
+ split_with: ".",
353
+ alphabet: EncodedId::Alphabet.new("0123456789ABCDEF"),
354
+ blocklist: ["BAD", "FAKE"],
355
+ blocklist_mode: :length_threshold,
356
+ blocklist_max_length: 32
357
+ )
358
+ ```
359
+
360
+ ### Hex Encoding (UUIDs)
361
+ ```ruby
362
+ # For encoding UUIDs efficiently
363
+ coder = EncodedId::ReversibleId.sqids(hex_digit_encoding_group_size: 32)
364
+
365
+ uuid = "550e8400-e29b-41d4-a716-446655440000"
366
+ encoded = coder.encode_hex(uuid)
367
+ decoded = coder.decode_hex(encoded).first # => original UUID (without hyphens)
368
+ ```
369
+
370
+ ## Performance Considerations
371
+
372
+ 1. **Algorithm Choice**:
373
+ - HashIds: Faster encoding, especially with blocklists
374
+ - Sqids: Faster decoding, automatically avoids blocklisted words
375
+
376
+ 2. **Blocklist Impact**:
377
+ - Large blocklists slow encoding, especially with Sqids (which iterates to avoid words)
378
+ - Hashids may raise exceptions requiring retry logic
379
+ - Use `blocklist_mode: :length_threshold` for best performance
380
+ - `:always` mode can significantly impact encoding speed for long IDs
381
+ - Blocklists are automatically filtered for your alphabet, improving performance
382
+
383
+ 3. **Blocklist Mode Performance**:
384
+ - `:length_threshold` (default): Only checks blocklist for IDs ≤ `blocklist_max_length` (default: 32)
385
+ - `:always`: Checks all IDs regardless of length (can be slow)
386
+ - `:raise_if_likely`: Validates configuration at initialization to prevent performance issues
387
+
388
+ 4. **Length vs Performance**: Longer minimum lengths may require more computation
389
+
390
+ 5. **Memory Usage**: The gem uses optimized implementations to minimize memory allocation
391
+
392
+ ## Version Compatibility
393
+
394
+ **v1.0.0 Breaking Changes:**
395
+
396
+ 1. **Default encoder**: Changed from `:hashids` to `:sqids`
397
+ 2. **Case sensitivity**: `decode` is now case-sensitive by default (`downcase: false`)
398
+ - Pre-v1: `decode("ABC")` and `decode("abc")` were equivalent
399
+ - v1.0.0+: These produce different results unless `downcase: true`
400
+ 3. **Salt requirement**: Sqids (default) doesn't require salt; Hashids still requires salt
401
+ 4. **Migration**: For backwards compatibility with pre-v1:
402
+ ```ruby
403
+ coder = EncodedId::ReversibleId.hashid(salt: "your-salt")
404
+ decoded = coder.decode(id, downcase: true)
405
+ ```
406
+
407
+ ## Security Notes
408
+
409
+ **Important**: Encoded IDs are NOT cryptographically secure. They provide obfuscation, not encryption. Do not rely on them for security purposes. They can potentially be reversed through brute-force attacks if the salt is compromised.
410
+
411
+ Use encoded IDs for:
412
+ - Hiding sequential database IDs
413
+ - Creating user-friendly URLs
414
+ - Preventing ID enumeration attacks
415
+ - Obscuring business metrics (user counts, order volumes)
416
+
417
+ Do NOT use for:
418
+ - Secure tokens
419
+ - Authentication
420
+ - Sensitive data protection
421
+ - Cryptographic purposes
422
+
423
+ ## Installation
424
+
425
+ ```ruby
426
+ # Gemfile
427
+ gem 'encoded_id'
428
+ ```
429
+
430
+ ## Best Practices
431
+
432
+ 1. **Consistent Configuration**: Once in production, don't change salt, encoder, or alphabet
433
+ 2. **Error Handling**: Always handle potential exceptions when decoding user input
434
+ 3. **Length Limits**: Set appropriate max_length to prevent DoS attacks
435
+ 4. **Validation**: Validate decoded IDs before using them in database queries
436
+ 5. **Blocklist Mode**: Use `:length_threshold` (default) for production - best performance/safety balance
437
+ 6. **Factory Methods**: Prefer `ReversibleId.sqids()` and `ReversibleId.hashid()` over constructor
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ module EncodedId
6
+ module Rails
7
+ # This module overrides standard ActiveRecord finder methods to automatically decode encoded IDs.
8
+ # Important: This module should NOT be used with models that use string-based primary keys (e.g., UUIDs)
9
+ # as it will cause conflicts between string IDs and encoded IDs.
10
+ module ActiveRecordFinders
11
+ extend ActiveSupport::Concern
12
+
13
+ # @rbs!
14
+ # include ::ActiveRecord::FinderMethods
15
+ # extend ::ActiveRecord::QueryMethods
16
+
17
+ included do
18
+ if columns_hash["id"]&.type == :string
19
+ ::Rails.logger.warn("EncodedId::Rails::ActiveRecordFinders has been included in #{name}, but this model uses string-based IDs. This may cause conflicts with encoded ID handling.")
20
+ end
21
+ end
22
+
23
+ # Class methods for overriding ActiveRecord's finder methods to decode encoded IDs.
24
+ module ClassMethods
25
+ # @rbs (*untyped args) -> untyped
26
+ def find(*args)
27
+ first_arg = args.first
28
+ return super unless args.size == 1 && first_arg.is_a?(String)
29
+
30
+ decoded_ids = decode_encoded_id(first_arg)
31
+
32
+ if decoded_ids.blank?
33
+ raise ::ActiveRecord::RecordNotFound
34
+ elsif decoded_ids.size == 1
35
+ super(decoded_ids.first)
36
+ else
37
+ super(decoded_ids)
38
+ end
39
+ end
40
+
41
+ # Override find_by_id to handle encoded IDs
42
+ # @rbs (untyped id) -> untyped
43
+ def find_by_id(id)
44
+ if id.is_a?(String)
45
+ decoded_ids = decode_encoded_id(id)
46
+ return nil if decoded_ids.blank?
47
+ return super(decoded_ids.first)
48
+ end
49
+ super
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -1,21 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "cgi"
3
+ # rbs_inline: enabled
4
4
 
5
5
  module EncodedId
6
6
  module Rails
7
- class AnnotatedId
7
+ # Represents an encoded ID with an annotation prefix (e.g., "user_ABC123").
8
+ class AnnotatedId < CompositeIdBase
9
+ # @rbs (annotation: String, id_part: String, ?separator: String) -> void
8
10
  def initialize(annotation:, id_part:, separator: "_")
9
- @annotation = annotation
10
- @id_part = id_part
11
- @separator = separator
11
+ super(first_part: annotation, id_part: id_part, separator: separator)
12
12
  end
13
13
 
14
+ # @rbs return: String
14
15
  def annotated_id
15
- unless @id_part.present? && @annotation.present?
16
- raise ::StandardError, "The model does not provide a valid ID and/or annotation"
17
- end
18
- "#{@annotation.to_s.parameterize}#{CGI.escape(@separator)}#{@id_part}"
16
+ build_composite_id
17
+ end
18
+
19
+ private
20
+
21
+ # @rbs return: String
22
+ def invalid_id_error_message
23
+ "The model does not return a valid ID and/or annotation"
19
24
  end
20
25
  end
21
26
  end
@@ -1,8 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rbs_inline: enabled
4
+
3
5
  module EncodedId
4
6
  module Rails
7
+ # Parses an annotated ID into its annotation and ID components.
5
8
  class AnnotatedIdParser
9
+ # @rbs @annotation: String?
10
+ # @rbs @id: String
11
+
12
+ # @rbs (String annotated_id, ?separator: String) -> void
6
13
  def initialize(annotated_id, separator: "_")
7
14
  if separator && annotated_id.include?(separator)
8
15
  parts = annotated_id.split(separator)
@@ -13,7 +20,8 @@ module EncodedId
13
20
  end
14
21
  end
15
22
 
16
- attr_reader :annotation, :id
23
+ attr_reader :annotation #: String?
24
+ attr_reader :id #: String
17
25
  end
18
26
  end
19
27
  end
@@ -1,36 +1,81 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rbs_inline: enabled
4
+
3
5
  module EncodedId
4
6
  module Rails
7
+ # Encodes and decodes IDs using the configured encoder and settings.
5
8
  class Coder
6
- def initialize(salt:, id_length:, character_group_size:, separator:, alphabet:)
9
+ # @rbs @salt: String?
10
+ # @rbs @id_length: Integer
11
+ # @rbs @character_group_size: Integer
12
+ # @rbs @separator: String
13
+ # @rbs @alphabet: ::EncodedId::Alphabet
14
+ # @rbs @encoder: Symbol
15
+ # @rbs @blocklist: ::EncodedId::Blocklist?
16
+ # @rbs @blocklist_mode: Symbol
17
+ # @rbs @blocklist_max_length: Integer
18
+ # @rbs @downcase_on_decode: bool
19
+
20
+ # @rbs (salt: String?, id_length: Integer, character_group_size: Integer, separator: String, alphabet: ::EncodedId::Alphabet, ?encoder: Symbol?, ?blocklist: ::EncodedId::Blocklist?, ?blocklist_mode: Symbol?, ?blocklist_max_length: Integer?, ?downcase_on_decode: bool?) -> void
21
+ def initialize(salt:, id_length:, character_group_size:, separator:, alphabet:, encoder: nil, blocklist: nil, blocklist_mode: nil, blocklist_max_length: nil, downcase_on_decode: nil)
7
22
  @salt = salt
8
23
  @id_length = id_length
9
24
  @character_group_size = character_group_size
10
25
  @separator = separator
11
26
  @alphabet = alphabet
27
+ config = EncodedId::Rails.configuration
28
+ @encoder = encoder || config.encoder
29
+ @blocklist = blocklist || config.blocklist
30
+ @blocklist_mode = blocklist_mode || config.blocklist_mode
31
+ @blocklist_max_length = blocklist_max_length || config.blocklist_max_length
32
+ @downcase_on_decode = downcase_on_decode.nil? ? config.downcase_on_decode : downcase_on_decode
12
33
  end
13
34
 
35
+ # @rbs (Integer | Array[Integer] id) -> String
14
36
  def encode(id)
15
37
  coder.encode(id)
16
38
  end
17
39
 
40
+ # @rbs (String encoded_id) -> Array[Integer]
18
41
  def decode(encoded_id)
19
- coder.decode(encoded_id)
42
+ coder.decode(encoded_id, downcase: @downcase_on_decode)
20
43
  rescue EncodedId::EncodedIdFormatError, EncodedId::InvalidInputError
21
- nil
44
+ []
22
45
  end
23
46
 
24
47
  private
25
48
 
49
+ # @rbs return: ::EncodedId::ReversibleId
26
50
  def coder
27
- ::EncodedId::ReversibleId.new(
28
- salt: @salt,
29
- length: @id_length,
30
- split_at: @character_group_size,
31
- split_with: @separator,
32
- alphabet: @alphabet
33
- )
51
+ # Build the appropriate configuration based on encoder type
52
+ config = case @encoder
53
+ when :hashids
54
+ ::EncodedId::Encoders::HashidConfiguration.new(
55
+ salt: @salt || raise(ArgumentError, "Salt is required for hashids encoder"),
56
+ min_length: @id_length,
57
+ split_at: @character_group_size,
58
+ split_with: @separator,
59
+ alphabet: @alphabet,
60
+ blocklist: @blocklist,
61
+ blocklist_mode: @blocklist_mode,
62
+ blocklist_max_length: @blocklist_max_length
63
+ )
64
+ when :sqids
65
+ ::EncodedId::Encoders::SqidsConfiguration.new(
66
+ min_length: @id_length,
67
+ split_at: @character_group_size,
68
+ split_with: @separator,
69
+ alphabet: @alphabet,
70
+ blocklist: @blocklist,
71
+ blocklist_mode: @blocklist_mode,
72
+ blocklist_max_length: @blocklist_max_length
73
+ )
74
+ else
75
+ raise ArgumentError, "Unknown encoder type: #{@encoder}"
76
+ end
77
+
78
+ ::EncodedId::ReversibleId.new(config)
34
79
  end
35
80
  end
36
81
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ require "cgi"
6
+
7
+ module EncodedId
8
+ module Rails
9
+ # Base class for composite IDs (slugged and annotated)
10
+ class CompositeIdBase
11
+ # @rbs @first_part: String
12
+ # @rbs @id_part: String
13
+ # @rbs @separator: String
14
+
15
+ # @rbs (first_part: String, id_part: String, separator: String) -> void
16
+ def initialize(first_part:, id_part:, separator:)
17
+ @first_part = first_part
18
+ @id_part = id_part
19
+ @separator = separator
20
+ end
21
+
22
+ private
23
+
24
+ # @rbs return: String
25
+ def build_composite_id
26
+ unless @id_part.present? && @first_part.present?
27
+ raise ::StandardError, invalid_id_error_message
28
+ end
29
+ "#{@first_part.to_s.parameterize}#{CGI.escape(@separator)}#{@id_part}"
30
+ end
31
+
32
+ # Default error message. Subclasses can override for more specific messages.
33
+ # @rbs return: String
34
+ def invalid_id_error_message
35
+ "The model does not return a valid ID and/or prefix"
36
+ end
37
+ end
38
+ end
39
+ end