encoded_id-rails 1.0.0.rc6 → 1.0.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/CHANGELOG.md +29 -4
- data/README.md +44 -33
- data/context/encoded_id-rails.md +298 -80
- data/context/encoded_id.md +229 -75
- data/lib/encoded_id/rails/active_record_finders.rb +16 -3
- data/lib/encoded_id/rails/annotated_id.rb +11 -14
- data/lib/encoded_id/rails/annotated_id_parser.rb +1 -0
- data/lib/encoded_id/rails/coder.rb +45 -18
- data/lib/encoded_id/rails/composite_id_base.rb +39 -0
- data/lib/encoded_id/rails/configuration.rb +28 -12
- data/lib/encoded_id/rails/encoder_methods.rb +24 -9
- data/lib/encoded_id/rails/finder_methods.rb +1 -0
- data/lib/encoded_id/rails/model.rb +35 -5
- data/lib/encoded_id/rails/path_param.rb +1 -0
- data/lib/encoded_id/rails/persists.rb +7 -5
- data/lib/encoded_id/rails/query_methods.rb +1 -0
- data/lib/encoded_id/rails/salt.rb +1 -0
- data/lib/encoded_id/rails/slugged_id.rb +11 -14
- data/lib/encoded_id/rails/slugged_id_parser.rb +1 -0
- data/lib/encoded_id/rails/slugged_path_param.rb +1 -0
- data/lib/encoded_id/rails.rb +2 -0
- data/lib/generators/encoded_id/rails/install_generator.rb +36 -2
- data/lib/generators/encoded_id/rails/templates/hashids_encoded_id.rb +122 -0
- data/lib/generators/encoded_id/rails/templates/{encoded_id.rb → sqids_encoded_id.rb} +29 -11
- metadata +7 -5
data/context/encoded_id.md
CHANGED
|
@@ -14,6 +14,25 @@
|
|
|
14
14
|
- **Performance Optimized**: Uses an optimized HashIds implementation for better performance
|
|
15
15
|
- **Profanity Protection**: Built-in blocklist support to prevent offensive words in generated IDs
|
|
16
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
|
+
```
|
|
17
36
|
|
|
18
37
|
## Core API
|
|
19
38
|
|
|
@@ -21,30 +40,65 @@
|
|
|
21
40
|
|
|
22
41
|
The main class for encoding and decoding IDs.
|
|
23
42
|
|
|
24
|
-
####
|
|
43
|
+
#### Factory Methods (Recommended)
|
|
44
|
+
|
|
45
|
+
Factory methods provide the cleanest way to create encoders:
|
|
25
46
|
|
|
26
47
|
```ruby
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
30
72
|
split_at: 4, # Split encoded string every X characters
|
|
31
73
|
split_with: "-", # Character to split with
|
|
32
74
|
alphabet: EncodedId::Alphabet.modified_crockford,
|
|
33
75
|
hex_digit_encoding_group_size: 4,
|
|
34
76
|
max_length: 128, # Maximum length limit
|
|
35
77
|
max_inputs_per_id: 32, # Maximum IDs to encode together
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
38
89
|
)
|
|
90
|
+
coder = EncodedId::ReversibleId.new(config)
|
|
39
91
|
```
|
|
40
92
|
|
|
93
|
+
**Note**: As of v1.0.0, the default encoder is `:sqids`. For backwards compatibility with pre-v1 versions, use `ReversibleId.hashid()`.
|
|
94
|
+
|
|
41
95
|
#### Key Methods
|
|
42
96
|
|
|
43
97
|
##### encode(values)
|
|
44
98
|
Encodes one or more integer IDs into an obfuscated string.
|
|
45
99
|
|
|
46
100
|
```ruby
|
|
47
|
-
coder = EncodedId::ReversibleId.
|
|
101
|
+
coder = EncodedId::ReversibleId.sqids
|
|
48
102
|
|
|
49
103
|
# Single ID
|
|
50
104
|
coder.encode(123) # => "p5w9-z27j"
|
|
@@ -53,17 +107,22 @@ coder.encode(123) # => "p5w9-z27j"
|
|
|
53
107
|
coder.encode([78, 45]) # => "z2j7-0dmw"
|
|
54
108
|
```
|
|
55
109
|
|
|
56
|
-
##### decode(encoded_id, downcase:
|
|
110
|
+
##### decode(encoded_id, downcase: false)
|
|
57
111
|
Decodes an encoded string back to original IDs.
|
|
58
112
|
|
|
59
113
|
```ruby
|
|
60
114
|
coder.decode("p5w9-z27j") # => [123]
|
|
61
115
|
coder.decode("z2j7-0dmw") # => [78, 45]
|
|
62
116
|
|
|
63
|
-
#
|
|
64
|
-
coder.decode("p5w9-z27J") # => [
|
|
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]
|
|
65
122
|
```
|
|
66
123
|
|
|
124
|
+
**Note**: As of v1.0.0, decoding is case-sensitive by default (`downcase: false`). Set `downcase: true` for backwards compatibility.
|
|
125
|
+
|
|
67
126
|
##### encode_hex(hex_strings) (Experimental)
|
|
68
127
|
Encodes hexadecimal strings (like UUIDs).
|
|
69
128
|
|
|
@@ -73,19 +132,19 @@ coder.encode_hex("9a566b8b-8618-42ab-8db7-a5a0276401fd")
|
|
|
73
132
|
# => "5jjy-c8d9-hxp2-qsve-rgh9-rxnt-7nb5-tve7-bf84-vr"
|
|
74
133
|
|
|
75
134
|
# With larger group size for shorter output
|
|
76
|
-
coder = EncodedId::ReversibleId.
|
|
77
|
-
salt: "my-salt",
|
|
78
|
-
hex_digit_encoding_group_size: 32
|
|
79
|
-
)
|
|
135
|
+
coder = EncodedId::ReversibleId.sqids(hex_digit_encoding_group_size: 32)
|
|
80
136
|
coder.encode_hex("9a566b8b-8618-42ab-8db7-a5a0276401fd")
|
|
81
137
|
# => "vr7m-qra8-m5y6-dkgj-5rqr-q44e-gp4a-52"
|
|
82
138
|
```
|
|
83
139
|
|
|
84
|
-
##### decode_hex(encoded_id, downcase:
|
|
140
|
+
##### decode_hex(encoded_id, downcase: false) (Experimental)
|
|
85
141
|
Decodes back to hexadecimal strings.
|
|
86
142
|
|
|
87
143
|
```ruby
|
|
88
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"]
|
|
89
148
|
```
|
|
90
149
|
|
|
91
150
|
### EncodedId::Alphabet
|
|
@@ -116,62 +175,127 @@ alphabet = EncodedId::Alphabet.new(
|
|
|
116
175
|
|
|
117
176
|
# Greek alphabet example
|
|
118
177
|
alphabet = EncodedId::Alphabet.new("αβγδεζηθικλμνξοπρστυφχψω")
|
|
119
|
-
coder = EncodedId::ReversibleId.
|
|
178
|
+
coder = EncodedId::ReversibleId.sqids(alphabet: alphabet)
|
|
120
179
|
coder.encode(123) # => "θεαψ-ζκυο"
|
|
121
180
|
```
|
|
122
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
|
+
|
|
123
221
|
## Configuration Options
|
|
124
222
|
|
|
125
223
|
### Basic Options
|
|
126
224
|
|
|
127
|
-
- **
|
|
128
|
-
- **length**: Minimum length of encoded string (default: 8)
|
|
225
|
+
- **min_length**: Minimum length of encoded string (default: 8)
|
|
129
226
|
- **max_length**: Maximum allowed length (default: 128) to prevent DoS attacks
|
|
130
227
|
- **max_inputs_per_id**: Maximum IDs encodable together (default: 32)
|
|
228
|
+
- **hex_digit_encoding_group_size**: Group size for hex encoding (default: 4)
|
|
131
229
|
|
|
132
230
|
### Encoder Selection
|
|
133
231
|
|
|
134
232
|
```ruby
|
|
135
|
-
#
|
|
136
|
-
coder = EncodedId::ReversibleId.
|
|
233
|
+
# Sqids encoder (default, no salt required)
|
|
234
|
+
coder = EncodedId::ReversibleId.sqids
|
|
137
235
|
|
|
138
|
-
#
|
|
139
|
-
coder = EncodedId::ReversibleId.
|
|
236
|
+
# Hashids encoder (requires salt - minimum 4 characters)
|
|
237
|
+
coder = EncodedId::ReversibleId.hashid(salt: "my-salt-minimum-4-chars")
|
|
140
238
|
```
|
|
141
239
|
|
|
142
|
-
**Important**:
|
|
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
|
|
143
246
|
|
|
144
|
-
###
|
|
247
|
+
### Blocklist Configuration
|
|
248
|
+
|
|
249
|
+
#### Blocklist Modes
|
|
250
|
+
|
|
251
|
+
Control how blocklist checking behaves to balance performance and safety:
|
|
145
252
|
|
|
146
253
|
```ruby
|
|
147
|
-
#
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
152
260
|
)
|
|
153
|
-
coder.encode(123) # => "p5w.9z2.7j"
|
|
154
261
|
|
|
155
|
-
#
|
|
156
|
-
|
|
262
|
+
# :always - Always check blocklist regardless of encoded length
|
|
263
|
+
# Can be slow for long IDs or large blocklists
|
|
264
|
+
coder = EncodedId::ReversibleId.hashid(
|
|
157
265
|
salt: "my-salt",
|
|
158
|
-
|
|
266
|
+
blocklist: ["bad", "words"],
|
|
267
|
+
blocklist_mode: :always
|
|
159
268
|
)
|
|
160
|
-
|
|
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
|
|
161
278
|
```
|
|
162
279
|
|
|
163
|
-
|
|
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
|
|
164
287
|
|
|
165
288
|
```ruby
|
|
166
|
-
#
|
|
167
|
-
coder = EncodedId::ReversibleId.
|
|
168
|
-
|
|
169
|
-
|
|
289
|
+
# Custom splitting
|
|
290
|
+
coder = EncodedId::ReversibleId.sqids(
|
|
291
|
+
split_at: 3, # Group every 3 chars
|
|
292
|
+
split_with: "." # Use dots
|
|
170
293
|
)
|
|
294
|
+
coder.encode(123) # => "p5w.9z2.7j"
|
|
171
295
|
|
|
172
|
-
#
|
|
173
|
-
|
|
174
|
-
#
|
|
296
|
+
# No splitting
|
|
297
|
+
coder = EncodedId::ReversibleId.sqids(split_at: nil)
|
|
298
|
+
coder.encode(123) # => "p5w9z27j"
|
|
175
299
|
```
|
|
176
300
|
|
|
177
301
|
## Exception Handling
|
|
@@ -183,14 +307,15 @@ coder = EncodedId::ReversibleId.new(
|
|
|
183
307
|
| `EncodedId::EncodedIdFormatError` | Invalid encoded ID format |
|
|
184
308
|
| `EncodedId::EncodedIdLengthError` | Encoded ID exceeds max_length |
|
|
185
309
|
| `EncodedId::InvalidInputError` | Invalid input (negative integers, too many inputs) |
|
|
186
|
-
| `EncodedId::SaltError` | Invalid salt (too short) |
|
|
310
|
+
| `EncodedId::SaltError` | Invalid salt (too short, only for Hashids) |
|
|
311
|
+
| `EncodedId::BlocklistError` | Generated ID contains blocklisted word (Hashids only) |
|
|
187
312
|
|
|
188
313
|
## Usage Examples
|
|
189
314
|
|
|
190
315
|
### Basic Usage
|
|
191
316
|
```ruby
|
|
192
|
-
# Initialize
|
|
193
|
-
coder = EncodedId::ReversibleId.
|
|
317
|
+
# Initialize with Sqids (no salt needed)
|
|
318
|
+
coder = EncodedId::ReversibleId.sqids
|
|
194
319
|
|
|
195
320
|
# Encode/decode cycle
|
|
196
321
|
encoded = coder.encode(123) # => "p5w9-z27j"
|
|
@@ -205,44 +330,79 @@ encoded = coder.encode([78, 45, 92]) # => "z2j7-0dmw-kf8p"
|
|
|
205
330
|
decoded = coder.decode(encoded) # => [78, 45, 92]
|
|
206
331
|
```
|
|
207
332
|
|
|
208
|
-
###
|
|
333
|
+
### With Hashids and Blocklist
|
|
209
334
|
```ruby
|
|
210
|
-
|
|
211
|
-
coder = EncodedId::ReversibleId.new(
|
|
335
|
+
coder = EncodedId::ReversibleId.hashid(
|
|
212
336
|
salt: "my-app-salt",
|
|
213
|
-
|
|
214
|
-
|
|
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,
|
|
215
351
|
split_at: 3,
|
|
216
352
|
split_with: ".",
|
|
217
353
|
alphabet: EncodedId::Alphabet.new("0123456789ABCDEF"),
|
|
218
|
-
blocklist: ["BAD", "FAKE"]
|
|
354
|
+
blocklist: ["BAD", "FAKE"],
|
|
355
|
+
blocklist_mode: :length_threshold,
|
|
356
|
+
blocklist_max_length: 32
|
|
219
357
|
)
|
|
220
358
|
```
|
|
221
359
|
|
|
222
360
|
### Hex Encoding (UUIDs)
|
|
223
361
|
```ruby
|
|
224
362
|
# For encoding UUIDs efficiently
|
|
225
|
-
coder = EncodedId::ReversibleId.
|
|
226
|
-
salt: "my-salt",
|
|
227
|
-
hex_digit_encoding_group_size: 32
|
|
228
|
-
)
|
|
363
|
+
coder = EncodedId::ReversibleId.sqids(hex_digit_encoding_group_size: 32)
|
|
229
364
|
|
|
230
365
|
uuid = "550e8400-e29b-41d4-a716-446655440000"
|
|
231
366
|
encoded = coder.encode_hex(uuid)
|
|
232
|
-
decoded = coder.decode_hex(encoded).first # => original UUID
|
|
367
|
+
decoded = coder.decode_hex(encoded).first # => original UUID (without hyphens)
|
|
233
368
|
```
|
|
234
369
|
|
|
235
370
|
## Performance Considerations
|
|
236
371
|
|
|
237
|
-
1. **Algorithm Choice**:
|
|
372
|
+
1. **Algorithm Choice**:
|
|
238
373
|
- HashIds: Faster encoding, especially with blocklists
|
|
239
|
-
- Sqids: Faster decoding
|
|
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
|
|
240
391
|
|
|
241
|
-
|
|
392
|
+
## Version Compatibility
|
|
242
393
|
|
|
243
|
-
|
|
394
|
+
**v1.0.0 Breaking Changes:**
|
|
244
395
|
|
|
245
|
-
|
|
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
|
+
```
|
|
246
406
|
|
|
247
407
|
## Security Notes
|
|
248
408
|
|
|
@@ -252,32 +412,26 @@ Use encoded IDs for:
|
|
|
252
412
|
- Hiding sequential database IDs
|
|
253
413
|
- Creating user-friendly URLs
|
|
254
414
|
- Preventing ID enumeration attacks
|
|
415
|
+
- Obscuring business metrics (user counts, order volumes)
|
|
255
416
|
|
|
256
417
|
Do NOT use for:
|
|
257
418
|
- Secure tokens
|
|
258
419
|
- Authentication
|
|
259
420
|
- Sensitive data protection
|
|
421
|
+
- Cryptographic purposes
|
|
260
422
|
|
|
261
423
|
## Installation
|
|
262
424
|
|
|
263
425
|
```ruby
|
|
264
426
|
# Gemfile
|
|
265
427
|
gem 'encoded_id'
|
|
266
|
-
|
|
267
|
-
# Or install directly
|
|
268
|
-
gem install encoded_id
|
|
269
|
-
```
|
|
270
|
-
|
|
271
|
-
For Sqids support:
|
|
272
|
-
```ruby
|
|
273
|
-
gem 'encoded_id'
|
|
274
|
-
gem 'sqids'
|
|
275
428
|
```
|
|
276
429
|
|
|
277
430
|
## Best Practices
|
|
278
431
|
|
|
279
|
-
1. **
|
|
280
|
-
2. **
|
|
281
|
-
3. **
|
|
282
|
-
4. **
|
|
283
|
-
5. **
|
|
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
|
|
@@ -18,14 +18,27 @@ module EncodedId
|
|
|
18
18
|
if columns_hash["id"]&.type == :string
|
|
19
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
20
|
end
|
|
21
|
+
|
|
22
|
+
unless columns_hash.key?("id")
|
|
23
|
+
::Rails.logger.warn("EncodedId::Rails::ActiveRecordFinders has been included in #{name}, but this model has no 'id' column. The finders will not work as expected.")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
if primary_key && primary_key != "id" && columns_hash.key?("id")
|
|
27
|
+
::Rails.logger.warn("EncodedId::Rails::ActiveRecordFinders has been included in #{name}, but the primary key is '#{primary_key}', not 'id'. This may cause unexpected behavior with find methods.")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Use prepend so our methods take precedence over ActiveRecord's dynamic finders
|
|
31
|
+
singleton_class.prepend(ClassMethodsPrepend)
|
|
21
32
|
end
|
|
22
33
|
|
|
23
|
-
|
|
34
|
+
# Class methods for overriding ActiveRecord's finder methods to decode encoded IDs.
|
|
35
|
+
module ClassMethodsPrepend
|
|
24
36
|
# @rbs (*untyped args) -> untyped
|
|
25
37
|
def find(*args)
|
|
26
|
-
|
|
38
|
+
first_arg = args.first
|
|
39
|
+
return super unless args.size == 1 && first_arg.is_a?(String)
|
|
27
40
|
|
|
28
|
-
decoded_ids = decode_encoded_id(
|
|
41
|
+
decoded_ids = decode_encoded_id(first_arg)
|
|
29
42
|
|
|
30
43
|
if decoded_ids.blank?
|
|
31
44
|
raise ::ActiveRecord::RecordNotFound
|
|
@@ -2,28 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
# rbs_inline: enabled
|
|
4
4
|
|
|
5
|
-
require "cgi"
|
|
6
|
-
|
|
7
5
|
module EncodedId
|
|
8
6
|
module Rails
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
# @rbs @id_part: String
|
|
12
|
-
# @rbs @separator: String
|
|
13
|
-
|
|
7
|
+
# Represents an encoded ID with an annotation prefix (e.g., "user_ABC123").
|
|
8
|
+
class AnnotatedId < CompositeIdBase
|
|
14
9
|
# @rbs (annotation: String, id_part: String, ?separator: String) -> void
|
|
15
10
|
def initialize(annotation:, id_part:, separator: "_")
|
|
16
|
-
|
|
17
|
-
@id_part = id_part
|
|
18
|
-
@separator = separator
|
|
11
|
+
super(first_part: annotation, id_part: id_part, separator: separator)
|
|
19
12
|
end
|
|
20
13
|
|
|
21
14
|
# @rbs return: String
|
|
22
15
|
def annotated_id
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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"
|
|
27
24
|
end
|
|
28
25
|
end
|
|
29
26
|
end
|
|
@@ -4,24 +4,32 @@
|
|
|
4
4
|
|
|
5
5
|
module EncodedId
|
|
6
6
|
module Rails
|
|
7
|
+
# Encodes and decodes IDs using the configured encoder and settings.
|
|
7
8
|
class Coder
|
|
8
|
-
# @rbs @salt: String
|
|
9
|
+
# @rbs @salt: String?
|
|
9
10
|
# @rbs @id_length: Integer
|
|
10
11
|
# @rbs @character_group_size: Integer
|
|
11
12
|
# @rbs @separator: String
|
|
12
13
|
# @rbs @alphabet: ::EncodedId::Alphabet
|
|
13
|
-
# @rbs @encoder:
|
|
14
|
+
# @rbs @encoder: Symbol
|
|
14
15
|
# @rbs @blocklist: ::EncodedId::Blocklist?
|
|
16
|
+
# @rbs @blocklist_mode: Symbol
|
|
17
|
+
# @rbs @blocklist_max_length: Integer
|
|
18
|
+
# @rbs @downcase_on_decode: bool
|
|
15
19
|
|
|
16
|
-
# @rbs (salt: String
|
|
17
|
-
def initialize(salt:, id_length:, character_group_size:, separator:, alphabet:, encoder: nil, blocklist: nil)
|
|
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)
|
|
18
22
|
@salt = salt
|
|
19
23
|
@id_length = id_length
|
|
20
24
|
@character_group_size = character_group_size
|
|
21
25
|
@separator = separator
|
|
22
26
|
@alphabet = alphabet
|
|
23
|
-
|
|
24
|
-
@
|
|
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
|
|
25
33
|
end
|
|
26
34
|
|
|
27
35
|
# @rbs (Integer | Array[Integer] id) -> String
|
|
@@ -29,26 +37,45 @@ module EncodedId
|
|
|
29
37
|
coder.encode(id)
|
|
30
38
|
end
|
|
31
39
|
|
|
32
|
-
# @rbs (String encoded_id) -> Array[Integer]
|
|
40
|
+
# @rbs (String encoded_id) -> Array[Integer]
|
|
33
41
|
def decode(encoded_id)
|
|
34
|
-
coder.decode(encoded_id)
|
|
42
|
+
coder.decode(encoded_id, downcase: @downcase_on_decode)
|
|
35
43
|
rescue EncodedId::EncodedIdFormatError, EncodedId::InvalidInputError
|
|
36
|
-
|
|
44
|
+
[]
|
|
37
45
|
end
|
|
38
46
|
|
|
39
47
|
private
|
|
40
48
|
|
|
41
49
|
# @rbs return: ::EncodedId::ReversibleId
|
|
42
50
|
def coder
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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)
|
|
52
79
|
end
|
|
53
80
|
end
|
|
54
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
|