encoded_id 0.3.0 → 1.0.0.rc1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +16 -0
- data/Gemfile +2 -2
- data/README.md +243 -43
- data/lib/encoded_id/alphabet.rb +65 -0
- data/lib/encoded_id/hex_representation.rb +89 -0
- data/lib/encoded_id/reversible_id.rb +56 -72
- data/lib/encoded_id/version.rb +1 -1
- data/lib/encoded_id.rb +5 -1
- data/rbs_collection.yaml +0 -2
- data/sig/encoded_id.rbs +63 -14
- metadata +7 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: a860b12577a70042f6d17a1788cb8557f3ab5ab477441a44c3be037b70165ded
|
4
|
+
data.tar.gz: bd9d57c92ada9e47a1080ea5b4d04cb3069a055a213305b698f1a2c7c424297f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e78405858e31da61d3361c3e42f999591e68f2053f1b58944c1f07616f5a57a599a174708eda301d52743d9dabaae8e76bf50442936401a9e943699589e0ad5c
|
7
|
+
data.tar.gz: a4e9ee6327f0d0e0d68eab43af5f21126e5ccef888b7478e07e14ade2a1532f618ac95ddb80e3406956ed7e505fda76c577e63368d376955024ab65df5084aa0
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,21 @@
|
|
1
1
|
## [Unreleased]
|
2
2
|
|
3
|
+
## [1.0.0] - 2023-08-06
|
4
|
+
|
5
|
+
- Improved RBS definitions
|
6
|
+
- Improved test coverage
|
7
|
+
|
8
|
+
## [0.4.0] - 2022-12-04
|
9
|
+
|
10
|
+
- Support custom split character which must not be in the alphabet
|
11
|
+
- Ability to provide a custom character equivalences mapping
|
12
|
+
|
13
|
+
## [0.3.0] - 2022-10-12
|
14
|
+
|
15
|
+
- Fix splitting of encoded ID string
|
16
|
+
- Checks that integer values to be encoded are positive
|
17
|
+
- Experimental support for encoding hex strings
|
18
|
+
|
3
19
|
## [0.1.0] - 2022-10-11
|
4
20
|
|
5
21
|
- Initial release
|
data/Gemfile
CHANGED
data/README.md
CHANGED
@@ -1,65 +1,76 @@
|
|
1
1
|
# EncodedId
|
2
2
|
|
3
|
-
Encode
|
3
|
+
Encode numerical or hex IDs into obfuscated strings that can be used in URLs.
|
4
4
|
|
5
|
-
|
5
|
+
```ruby
|
6
|
+
coder = ::EncodedId::ReversibleId.new(salt: my_salt)
|
7
|
+
coder.encode(123)
|
8
|
+
# => "p5w9-z27j"
|
9
|
+
coder.encode_hex("10f8c")
|
10
|
+
# => "w72a-y0az"
|
11
|
+
```
|
6
12
|
|
7
|
-
The obfuscated strings are reversible
|
8
|
-
encoding multiple IDs at once.
|
13
|
+
The obfuscated strings are reversible (they decode them back into the original IDs).
|
9
14
|
|
10
|
-
|
11
|
-
reversibles = ::EncodedId::ReversibleId.new(salt: my_salt)
|
12
|
-
reversibles.encode([78, 45]) # "7aq6-0zqw"
|
13
|
-
reversibles.decode("7aq6-0zqw") # [78, 45]
|
14
|
-
```
|
15
|
+
Also supports encoding multiple IDs at once.
|
15
16
|
|
16
|
-
|
17
|
+
```ruby
|
18
|
+
my_salt = "salt!"
|
19
|
+
coder = ::EncodedId::ReversibleId.new(salt: my_salt)
|
17
20
|
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
+
# One of more values can be encoded
|
22
|
+
coder.encode([78, 45])
|
23
|
+
# => "z2j7-0dmw"
|
21
24
|
|
22
|
-
|
25
|
+
# The encoded string can then be reversed back into the original IDs
|
26
|
+
coder.decode("z2j7-0dmw")
|
27
|
+
# => [78, 45]
|
23
28
|
|
24
|
-
|
25
|
-
|
26
|
-
=>
|
29
|
+
# The decoder can be resilient to easily confused characters
|
30
|
+
coder.decode("z2j7-Odmw") # (note the capital 'o' instead of zero)
|
31
|
+
# => [78, 45]
|
27
32
|
```
|
28
33
|
|
29
34
|
## Features
|
30
35
|
|
31
|
-
|
36
|
+
* encoded IDs are reversible (uses with https://hashids.org))
|
37
|
+
* supports multiple IDs encoded in one encoded string (eg `7aq6-0zqw` decodes to `[78, 45]`)
|
38
|
+
* supports encoding of hex strings (eg UUIDs), including multiple IDs encoded in one string **(experimental)**
|
39
|
+
* supports custom alphabets for the encoded string (at least 16 characters needed)
|
40
|
+
- by default uses a variation of the Crockford reduced character set (https://www.crockford.com/base32.html)
|
41
|
+
- easily confused characters (eg `i` and `j`, `0` and `O`, `1` and `I` etc) are mapped to counterpart characters, to help
|
42
|
+
avoid common readability mistakes when reading/sharing
|
43
|
+
- build in profanity limitation
|
44
|
+
* encoded string can be split into groups of letters to improve human-readability
|
45
|
+
- eg `nft9hr834htu` as `nft9-hr83-4htu`
|
32
46
|
|
33
|
-
|
34
|
-
* supports slugged IDs (eg 'beef-tenderloins-prime--p5w9-z27j')
|
35
|
-
* supports multiple IDs encoded in one `EncodedId` (eg '7aq6-0zqw' decodes to `[78, 45]`)
|
36
|
-
* supports encoding of hex strings (eg UUIDs), including mutliple IDs encoded in one `EncodedId`
|
37
|
-
* uses a reduced character set (Crockford alphabet) & ids split into groups of letters, ie 'human-readability'
|
38
|
-
* profanity limitation
|
47
|
+
### Rails support `encoded_id-rails`
|
39
48
|
|
40
|
-
To use with **Rails** check out the `encoded_id-rails` gem.
|
49
|
+
To use with **Rails** check out the [`encoded_id-rails`](https://github.com/stevegeek/encoded_id-rails) gem.
|
41
50
|
|
42
|
-
|
51
|
+
```ruby
|
52
|
+
class User < ApplicationRecord
|
53
|
+
include EncodedId::WithEncodedId
|
54
|
+
end
|
55
|
+
|
56
|
+
User.find_by_encoded_id("p5w9-z27j")
|
57
|
+
# => #<User id: 78>
|
58
|
+
```
|
59
|
+
|
60
|
+
### Note on security of encoded IDs (hashids)
|
43
61
|
|
44
62
|
**Encoded IDs are not secure**. It maybe possible to reverse them via brute-force. They are meant to be used in URLs as
|
45
63
|
an obfuscation. The algorithm is not an encryption.
|
46
64
|
|
47
65
|
Please read more on https://hashids.org/
|
48
66
|
|
49
|
-
|
50
|
-
## Compared to alternate Gems
|
67
|
+
## Compare to alternate Gems
|
51
68
|
|
52
69
|
- https://github.com/excid3/prefixed_ids
|
53
70
|
- https://github.com/namick/obfuscate_id
|
54
71
|
- https://github.com/norman/friendly_id
|
55
72
|
- https://github.com/SPBTV/with_uid
|
56
73
|
|
57
|
-
## See also
|
58
|
-
|
59
|
-
- https://hashids.org
|
60
|
-
- https://www.crockford.com/wrmg/base32.html
|
61
|
-
|
62
|
-
|
63
74
|
## Installation
|
64
75
|
|
65
76
|
Install the gem and add to the application's Gemfile by executing:
|
@@ -70,20 +81,189 @@ If bundler is not being used to manage dependencies, install the gem by executin
|
|
70
81
|
|
71
82
|
$ gem install encoded_id
|
72
83
|
|
73
|
-
##
|
84
|
+
## `EncodedId::ReversibleId.new`
|
85
|
+
|
86
|
+
To create an instance of the encoder/decoder use `.new` with the `salt` option:
|
87
|
+
|
88
|
+
```ruby
|
89
|
+
coder = EncodedId::ReversibleId.new(
|
90
|
+
# The salt is required
|
91
|
+
salt: ...,
|
92
|
+
# And then the following options are optional
|
93
|
+
length: 8,
|
94
|
+
split_at: 4,
|
95
|
+
split_with: "-",
|
96
|
+
alphabet: EncodedId::Alphabet.modified_crockford,
|
97
|
+
hex_digit_encoding_group_size: 4 # Experimental
|
98
|
+
)
|
99
|
+
```
|
100
|
+
|
101
|
+
Note the `salt` value is required and should be a string of some length (greater than 3 characters). This is used to generate the encoded string.
|
102
|
+
|
103
|
+
It will need to be the same value when decoding the string back into the original ID. If the salt is changed, the encoded
|
104
|
+
strings will be different and possibly decode to different IDs.
|
105
|
+
|
106
|
+
### Options
|
107
|
+
|
108
|
+
The encoded ID is configurable. The following can be changed:
|
109
|
+
|
110
|
+
- the length, eg 8 characters for `p5w9-z27j`
|
111
|
+
- the alphabet used in it (min 16 characters)
|
112
|
+
- and the number of characters to split the output into and the separator
|
74
113
|
|
75
|
-
|
114
|
+
### `length`
|
76
115
|
|
77
|
-
|
116
|
+
`length`: the minimum length of the encoded string. The default is 8 characters.
|
78
117
|
|
79
|
-
|
118
|
+
The actual length of the encoded string can be longer if the inputs cannot be represented in the minimum length.
|
119
|
+
|
120
|
+
### `alphabet`
|
121
|
+
|
122
|
+
`alphabet`: the alphabet used in the encoded string. By default it uses a variation of the Crockford reduced character set (https://www.crockford.com/base32.html).
|
123
|
+
|
124
|
+
`alphabet` must be an instance of `EncodedId::Alphabet`.
|
125
|
+
|
126
|
+
The default alphabet is `EncodedId::Alphabet.modified_crockford`.
|
127
|
+
|
128
|
+
To create a new alphabet, use `EncodedId::Alphabet.new`:
|
80
129
|
|
81
130
|
```ruby
|
82
|
-
|
83
|
-
|
84
|
-
|
131
|
+
alphabet = EncodedId::Alphabet.new("0123456789abcdef")
|
132
|
+
```
|
133
|
+
|
134
|
+
`EncodedId::Alphabet.new(characters, equivalences)`
|
85
135
|
|
86
|
-
|
136
|
+
**characters**
|
137
|
+
|
138
|
+
`characters`: the characters of the alphabet. Can be a string or array of strings.
|
139
|
+
|
140
|
+
Note that the `characters` of the alphabet must be at least 16 _unique_ characters long.
|
141
|
+
|
142
|
+
|
143
|
+
```ruby
|
144
|
+
alphabet = EncodedId::Alphabet.new("ςερτυθιοπλκξηγφδσαζχψωβνμ")
|
145
|
+
coder = ::EncodedId::ReversibleId.new(salt: my_salt, alphabet: alphabet)
|
146
|
+
coder.encode(123)
|
147
|
+
# => "πφλχ-ψησω"
|
148
|
+
```
|
149
|
+
|
150
|
+
Note that larger alphabets can result in shorter encoded strings (but remember that `length` specifies the minimum length
|
151
|
+
of the encoded string).
|
152
|
+
|
153
|
+
**equivalences**
|
154
|
+
|
155
|
+
You can optionally pass an appropriate character `equivalences` mapping. This is used to map easily confused characters
|
156
|
+
to their counterpart.
|
157
|
+
|
158
|
+
`equivalences`: a hash of characters keys, with their equivalent alphabet character mapped to in the values.
|
159
|
+
|
160
|
+
Note that the characters to be mapped:
|
161
|
+
- must not be in the alphabet,
|
162
|
+
- must map to a character that is in the alphabet.
|
163
|
+
|
164
|
+
`nil` is the default value which means no equivalences are used.
|
165
|
+
|
166
|
+
```ruby
|
167
|
+
alphabet = EncodedId::Alphabet.new("!@#$%^&*()+-={}", {"_" => "-"})
|
168
|
+
coder = ::EncodedId::ReversibleId.new(salt: my_salt, alphabet: alphabet)
|
169
|
+
coder.encode(123)
|
170
|
+
# => "}*^(-^}*="
|
171
|
+
```
|
172
|
+
|
173
|
+
### `split_at` and `split_with`
|
174
|
+
|
175
|
+
For readability, the encoded string can be split into groups of characters.
|
176
|
+
|
177
|
+
`split_at`: specifies the number of characters to split the encoded string into. Defaults to 4.
|
178
|
+
|
179
|
+
`split_with`: specifies the separator to use between the groups. Default is `-`.
|
180
|
+
|
181
|
+
### `hex_digit_encoding_group_size`
|
182
|
+
|
183
|
+
**Experimental**
|
184
|
+
|
185
|
+
`hex_digit_encoding_group_size`: specifies the number of hex digits to encode in a group. Defaults to 4. Can be
|
186
|
+
between 1 and 32.
|
187
|
+
|
188
|
+
Can be used to control the size of the encoded string when encoding hex strings. Larger values will result in shorter
|
189
|
+
encoded strings for long inputs, and shorter values will result in shorter encoded strings for smaller inputs.
|
190
|
+
|
191
|
+
But note that bigger values will also result in larger markers that separate the groups so could end up increasing
|
192
|
+
the encoded string length undesirably.
|
193
|
+
|
194
|
+
See below section `Using with hex strings` for more details.
|
195
|
+
|
196
|
+
## `EncodedId::ReversibleId#encode`
|
197
|
+
|
198
|
+
`#encode(id)`: where `id` is an integer or array of integers to encode.
|
199
|
+
|
200
|
+
```ruby
|
201
|
+
coder.encode(123)
|
202
|
+
# => "p5w9-z27j"
|
203
|
+
|
204
|
+
# One of more values can be encoded
|
205
|
+
coder.encode([78, 45])
|
206
|
+
# => "z2j7-0dmw"
|
207
|
+
```
|
208
|
+
|
209
|
+
## `EncodedId::ReversibleId#decode`
|
210
|
+
|
211
|
+
`#decode(encoded_id)`: where `encoded_id` is a string to decode.
|
212
|
+
|
213
|
+
```ruby
|
214
|
+
# The encoded string can then be reversed back into the original IDs
|
215
|
+
coder.decode("z2j7-0dmw")
|
216
|
+
# => [78, 45]
|
217
|
+
```
|
218
|
+
|
219
|
+
## Using with hex strings
|
220
|
+
|
221
|
+
**Experimental** (subject to incompatible changes in future versions)
|
222
|
+
|
223
|
+
```ruby
|
224
|
+
# Hex input strings are also supported
|
225
|
+
coder.encode_hex("10f8c")
|
226
|
+
# => "w72a-y0az"
|
227
|
+
```
|
228
|
+
|
229
|
+
When encoding hex strings, the input is split into groups of hex digits, and each group is encoded separately as its
|
230
|
+
integer equivalent. In other words the input is converted into an array of integers and encoded as normal with the
|
231
|
+
`encode` method.
|
232
|
+
|
233
|
+
eg with `hex_digit_encoding_group_size=1` and inpu `f1`, is split into `f` and `1`, and then encoded as `15` and `1`
|
234
|
+
respectively, ie `encode` is called with `[15, 1]`.
|
235
|
+
|
236
|
+
To encode multiple hex inputs the encoded string contains markers to indicate the start of a new hex input. This
|
237
|
+
marker is equal to an integer value which is 1 larger than the maximum value the hex digit encoding group size can
|
238
|
+
represent (ie it is `2^(hex_digit_encoding_group_size * 4)`).
|
239
|
+
|
240
|
+
So for a hex digit encoding group size of 4 (ie group max value is `0xFFFF`), the marker is `65536`
|
241
|
+
|
242
|
+
For example with `hex_digit_encoding_group_size=1` for the inputs `f1` and `e2` encoded together, the
|
243
|
+
actual encoded integer array is `[15, 1, 16, 14, 2]`.
|
244
|
+
|
245
|
+
### `EncodedId::ReversibleId#encode_hex`
|
246
|
+
|
247
|
+
`encode_hex(hex_string)` , where `hex_string` is a string of hex digits or an array of hex strings.
|
248
|
+
|
249
|
+
```ruby
|
250
|
+
# UUIDs will result in long output strings...
|
251
|
+
coder.encode_hex("9a566b8b-8618-42ab-8db7-a5a0276401fd")
|
252
|
+
# => "5jjy-c8d9-hxp2-qsve-rgh9-rxnt-7nb5-tve7-bf84-vr"
|
253
|
+
#
|
254
|
+
# but there is an option to help reduce this...
|
255
|
+
coder = ::EncodedId::ReversibleId.new(salt: my_salt, hex_digit_encoding_group_size: 32)
|
256
|
+
coder.encode_hex("9a566b8b-8618-42ab-8db7-a5a0276401fd")
|
257
|
+
# => "vr7m-qra8-m5y6-dkgj-5rqr-q44e-gp4a-52"
|
258
|
+
```
|
259
|
+
|
260
|
+
### `EncodedId::ReversibleId#decode_hex`
|
261
|
+
|
262
|
+
`decode_hex(encoded_id)` , where the output is an array of hex strings.
|
263
|
+
|
264
|
+
```ruby
|
265
|
+
coder.decode_hex("5jjy-c8d9-hxp2-qsve-rgh9-rxnt-7nb5-tve7-bf84-vr")
|
266
|
+
# => ["9a566b8b-8618-42ab-8db7-a5a0276401fd"]
|
87
267
|
```
|
88
268
|
|
89
269
|
## Development
|
@@ -95,9 +275,29 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
|
|
95
275
|
number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git
|
96
276
|
commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
97
277
|
|
278
|
+
### Type check
|
279
|
+
|
280
|
+
First install dependencies:
|
281
|
+
|
282
|
+
```bash
|
283
|
+
rbs collection install
|
284
|
+
```
|
285
|
+
|
286
|
+
Then run:
|
287
|
+
|
288
|
+
```bash
|
289
|
+
steep check
|
290
|
+
```
|
291
|
+
|
292
|
+
|
293
|
+
## See also
|
294
|
+
|
295
|
+
- https://hashids.org
|
296
|
+
- https://www.crockford.com/base32.html
|
297
|
+
|
98
298
|
## Contributing
|
99
299
|
|
100
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/
|
300
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/stevegeek/encoded_id.
|
101
301
|
|
102
302
|
## License
|
103
303
|
|
@@ -0,0 +1,65 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module EncodedId
|
4
|
+
class Alphabet
|
5
|
+
MIN_UNIQUE_CHARACTERS = 16
|
6
|
+
|
7
|
+
class << self
|
8
|
+
def modified_crockford
|
9
|
+
new(
|
10
|
+
"0123456789abcdefghjkmnpqrstuvwxyz",
|
11
|
+
{
|
12
|
+
"o" => "0",
|
13
|
+
"i" => "j",
|
14
|
+
"l" => "1"
|
15
|
+
}
|
16
|
+
)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def initialize(characters, equivalences = nil)
|
21
|
+
raise_invalid_alphabet! unless valid_input_characters?(characters)
|
22
|
+
unique_characters = unique_character_alphabet(characters)
|
23
|
+
raise_character_set_too_small! unless sufficient_characters?(unique_characters.size)
|
24
|
+
raise_invalid_equivalences! unless valid_equivalences?(equivalences, unique_characters)
|
25
|
+
|
26
|
+
@characters = unique_characters.join
|
27
|
+
@equivalences = equivalences
|
28
|
+
end
|
29
|
+
|
30
|
+
attr_reader :characters, :equivalences
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def valid_input_characters?(characters)
|
35
|
+
(characters.is_a?(Array) || characters.is_a?(String)) && characters.size > 0
|
36
|
+
end
|
37
|
+
|
38
|
+
def unique_character_alphabet(characters)
|
39
|
+
(characters.is_a?(Array) ? characters : characters.chars).uniq
|
40
|
+
end
|
41
|
+
|
42
|
+
def sufficient_characters?(size)
|
43
|
+
size >= MIN_UNIQUE_CHARACTERS
|
44
|
+
end
|
45
|
+
|
46
|
+
def valid_equivalences?(equivalences, unique_characters)
|
47
|
+
return true if equivalences.nil?
|
48
|
+
return false unless equivalences.is_a?(Hash)
|
49
|
+
|
50
|
+
(unique_characters & equivalences.keys).empty? && (equivalences.values - unique_characters).empty?
|
51
|
+
end
|
52
|
+
|
53
|
+
def raise_invalid_alphabet!
|
54
|
+
raise InvalidAlphabetError, "Alphabet must be a string or array."
|
55
|
+
end
|
56
|
+
|
57
|
+
def raise_character_set_too_small!
|
58
|
+
raise InvalidAlphabetError, "Alphabet must contain at least #{MIN_UNIQUE_CHARACTERS} unique characters."
|
59
|
+
end
|
60
|
+
|
61
|
+
def raise_invalid_equivalences!
|
62
|
+
raise InvalidConfigurationError, "Character equivalences must be a hash or nil."
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,89 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module EncodedId
|
4
|
+
class HexRepresentation
|
5
|
+
def initialize(hex_digit_encoding_group_size)
|
6
|
+
@hex_digit_encoding_group_size = validate_hex_digit_encoding_group_size(hex_digit_encoding_group_size)
|
7
|
+
end
|
8
|
+
|
9
|
+
def hex_as_integers(hexs)
|
10
|
+
integer_representation(hexs)
|
11
|
+
end
|
12
|
+
|
13
|
+
def integers_as_hex(integers)
|
14
|
+
integers_to_hex_strings(integers)
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
# Number of hex digits to encode in each group, larger values will result in shorter hashes for longer inputs.
|
20
|
+
# Vice versa for smaller values, ie a smaller value will result in smaller hashes for small inputs.
|
21
|
+
def validate_hex_digit_encoding_group_size(hex_digit_encoding_group_size)
|
22
|
+
if !hex_digit_encoding_group_size.is_a?(Integer) || hex_digit_encoding_group_size < 1 || hex_digit_encoding_group_size > 32
|
23
|
+
raise InvalidConfigurationError, "hex_digit_encoding_group_size must be > 0 and <= 32"
|
24
|
+
end
|
25
|
+
hex_digit_encoding_group_size
|
26
|
+
end
|
27
|
+
|
28
|
+
# Convert hex strings to integer representations
|
29
|
+
def integer_representation(hexs)
|
30
|
+
inputs = Array(hexs).map(&:to_s)
|
31
|
+
digits_to_encode = []
|
32
|
+
|
33
|
+
inputs.map { |hex_string| hex_string_as_integer_representation(hex_string) }.each do |integer_groups|
|
34
|
+
digits_to_encode.concat(integer_groups)
|
35
|
+
digits_to_encode << hex_string_separator
|
36
|
+
end
|
37
|
+
|
38
|
+
# Remove the last marker
|
39
|
+
digits_to_encode.pop unless digits_to_encode.empty?
|
40
|
+
digits_to_encode
|
41
|
+
end
|
42
|
+
|
43
|
+
# Convert integer representations to hex strings
|
44
|
+
def integers_to_hex_strings(integers)
|
45
|
+
hex_strings = []
|
46
|
+
hex_string = []
|
47
|
+
add_leading = false
|
48
|
+
|
49
|
+
integers.reverse_each do |integer|
|
50
|
+
if integer == hex_string_separator # Marker to separate hex strings, so start a new one
|
51
|
+
hex_strings << hex_string.join
|
52
|
+
hex_string = []
|
53
|
+
add_leading = false
|
54
|
+
else
|
55
|
+
hex_string << (add_leading ? "%.#{@hex_digit_encoding_group_size}x" % integer : integer.to_s(16))
|
56
|
+
add_leading = true
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# Add the last hex string
|
61
|
+
hex_strings << hex_string.join unless hex_string.empty?
|
62
|
+
hex_strings.reverse
|
63
|
+
end
|
64
|
+
|
65
|
+
def hex_string_as_integer_representation(hex_string)
|
66
|
+
cleaned = remove_non_hex_characters(hex_string)
|
67
|
+
convert_to_integer_groups(cleaned)
|
68
|
+
end
|
69
|
+
|
70
|
+
# Marker to separate hex strings, must be greater than largest value encoded
|
71
|
+
def hex_string_separator
|
72
|
+
@hex_string_separator ||= 2.pow(@hex_digit_encoding_group_size * 4)
|
73
|
+
end
|
74
|
+
|
75
|
+
def remove_non_hex_characters(hex_string)
|
76
|
+
hex_string.gsub(/[^0-9a-f]/i, "")
|
77
|
+
end
|
78
|
+
|
79
|
+
def convert_to_integer_groups(hex_string_cleaned)
|
80
|
+
groups = []
|
81
|
+
hex_string_cleaned.chars.reverse.each_with_index do |char, i|
|
82
|
+
group_id = i / @hex_digit_encoding_group_size
|
83
|
+
groups[group_id] ||= []
|
84
|
+
groups[group_id].unshift(char)
|
85
|
+
end
|
86
|
+
groups.map { |c| c.join.to_i(16) }
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
@@ -8,19 +8,13 @@ require "hashids"
|
|
8
8
|
# Note hashIds already has a built in profanity limitation algorithm
|
9
9
|
module EncodedId
|
10
10
|
class ReversibleId
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
@
|
18
|
-
@salt = salt
|
19
|
-
@length = length
|
20
|
-
@split_at = split_at
|
21
|
-
# Number of hex digits to encode in each group, larger values will result in shorter hashes for longer inputs.
|
22
|
-
# Vice versa for smaller values, ie a smaller value will result in smaller hashes for small inputs.
|
23
|
-
@hex_digit_encoding_group_size = hex_digit_encoding_group_size
|
11
|
+
def initialize(salt:, length: 8, split_at: 4, split_with: "-", alphabet: Alphabet.modified_crockford, hex_digit_encoding_group_size: 4)
|
12
|
+
@alphabet = validate_alphabet(alphabet)
|
13
|
+
@salt = validate_salt(salt)
|
14
|
+
@length = validate_length(length)
|
15
|
+
@split_at = validate_split_at(split_at)
|
16
|
+
@split_with = validate_split_with(split_with, alphabet)
|
17
|
+
@hex_represention_encoder = HexRepresentation.new(hex_digit_encoding_group_size)
|
24
18
|
end
|
25
19
|
|
26
20
|
# Encode the input values into a hash
|
@@ -33,7 +27,7 @@ module EncodedId
|
|
33
27
|
|
34
28
|
# Encode hex strings into a hash
|
35
29
|
def encode_hex(hexs)
|
36
|
-
encode(
|
30
|
+
encode(hex_represention_encoder.hex_as_integers(hexs))
|
37
31
|
end
|
38
32
|
|
39
33
|
# Decode the hash to original array
|
@@ -46,12 +40,48 @@ module EncodedId
|
|
46
40
|
# Decode hex strings from a hash
|
47
41
|
def decode_hex(str)
|
48
42
|
integers = encoded_id_generator.decode(convert_to_hash(str))
|
49
|
-
|
43
|
+
hex_represention_encoder.integers_as_hex(integers)
|
50
44
|
end
|
51
45
|
|
52
46
|
private
|
53
47
|
|
54
|
-
attr_reader :salt,
|
48
|
+
attr_reader :salt,
|
49
|
+
:length,
|
50
|
+
:alphabet,
|
51
|
+
:split_at,
|
52
|
+
:split_with,
|
53
|
+
:hex_represention_encoder
|
54
|
+
|
55
|
+
def validate_alphabet(alphabet)
|
56
|
+
raise InvalidAlphabetError, "alphabet must be an instance of Alphabet" unless alphabet.is_a?(Alphabet)
|
57
|
+
alphabet
|
58
|
+
end
|
59
|
+
|
60
|
+
def validate_salt(salt)
|
61
|
+
raise InvalidConfigurationError, "Salt must be a string and longer than 3 characters" unless salt.is_a?(String) && salt.size > 3
|
62
|
+
salt
|
63
|
+
end
|
64
|
+
|
65
|
+
# Target length of the encoded string (the minimum but not maximum length)
|
66
|
+
def validate_length(length)
|
67
|
+
raise InvalidConfigurationError, "Length must be an integer greater than 0" unless length.is_a?(Integer) && length > 0
|
68
|
+
length
|
69
|
+
end
|
70
|
+
|
71
|
+
# Split the encoded string into groups of this size
|
72
|
+
def validate_split_at(split_at)
|
73
|
+
unless (split_at.is_a?(Integer) && split_at > 0) || split_at.nil?
|
74
|
+
raise InvalidConfigurationError, "Split at must be an integer greater than 0 or nil"
|
75
|
+
end
|
76
|
+
split_at
|
77
|
+
end
|
78
|
+
|
79
|
+
def validate_split_with(split_with, alphabet)
|
80
|
+
unless split_with.is_a?(String) && !alphabet.characters.include?(split_with)
|
81
|
+
raise InvalidConfigurationError, "Split with must be a string and not part of the alphabet"
|
82
|
+
end
|
83
|
+
split_with
|
84
|
+
end
|
55
85
|
|
56
86
|
def prepare_input(value)
|
57
87
|
inputs = value.is_a?(Array) ? value.map(&:to_i) : [value.to_i]
|
@@ -61,7 +91,7 @@ module EncodedId
|
|
61
91
|
end
|
62
92
|
|
63
93
|
def encoded_id_generator
|
64
|
-
@encoded_id_generator ||= ::Hashids.new(salt, length,
|
94
|
+
@encoded_id_generator ||= ::Hashids.new(salt, length, alphabet.characters)
|
65
95
|
end
|
66
96
|
|
67
97
|
def split_regex
|
@@ -69,65 +99,19 @@ module EncodedId
|
|
69
99
|
end
|
70
100
|
|
71
101
|
def humanize_length(hash)
|
72
|
-
hash.gsub(split_regex,
|
102
|
+
hash.gsub(split_regex, "\\0#{split_with}")
|
73
103
|
end
|
74
104
|
|
75
105
|
def convert_to_hash(str)
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
# TODO: optimize this
|
86
|
-
def integer_representation(hexs)
|
87
|
-
inputs = hexs.is_a?(Array) ? hexs.map(&:to_s) : [hexs.to_s]
|
88
|
-
inputs.map! do |hex_string|
|
89
|
-
cleaned = hex_string.gsub(/[^0-9a-f]/i, "")
|
90
|
-
# Convert to groups of integers. Process least significant hex digits first
|
91
|
-
groups = []
|
92
|
-
cleaned.chars.reverse.each_with_index do |char, i|
|
93
|
-
group_id = i / hex_digit_encoding_group_size.to_i
|
94
|
-
groups[group_id] ||= []
|
95
|
-
groups[group_id].unshift(char)
|
96
|
-
end
|
97
|
-
groups.map { |c| c.join.to_i(16) }
|
98
|
-
end
|
99
|
-
digits_to_encode = []
|
100
|
-
inputs.each_with_object(digits_to_encode) do |hex_digits, digits|
|
101
|
-
digits.concat(hex_digits)
|
102
|
-
digits << hex_string_separator
|
103
|
-
end
|
104
|
-
digits_to_encode.pop unless digits_to_encode.empty? # Remove the last marker
|
105
|
-
digits_to_encode
|
106
|
-
end
|
107
|
-
|
108
|
-
# Marker to separate hex strings, must be greater than largest value encoded
|
109
|
-
def hex_string_separator
|
110
|
-
@hex_string_separator ||= 2.pow(hex_digit_encoding_group_size * 4) + 1
|
111
|
-
end
|
112
|
-
|
113
|
-
# TODO: optimize this
|
114
|
-
def integers_to_hex_strings(integers)
|
115
|
-
hex_strings = []
|
116
|
-
hex_string = []
|
117
|
-
add_leading = false
|
118
|
-
# Digits are encoded in least significant digit first order, but string is most significant first, so reverse
|
119
|
-
integers.reverse_each do |integer|
|
120
|
-
if integer == hex_string_separator # Marker to separate hex strings, so start a new one
|
121
|
-
hex_strings << hex_string.join
|
122
|
-
hex_string = []
|
123
|
-
add_leading = false
|
124
|
-
else
|
125
|
-
hex_string << (add_leading ? "%.#{hex_digit_encoding_group_size}x" % integer : integer.to_s(16))
|
126
|
-
add_leading = true
|
127
|
-
end
|
106
|
+
clean = str.delete(split_with).downcase
|
107
|
+
alphabet.equivalences.nil? ? clean : map_equivalent_characters(clean)
|
108
|
+
end
|
109
|
+
|
110
|
+
def map_equivalent_characters(str)
|
111
|
+
alphabet.equivalences.reduce(str) do |cleaned, ceq|
|
112
|
+
from, to = ceq
|
113
|
+
cleaned.tr(from, to)
|
128
114
|
end
|
129
|
-
hex_strings << hex_string.join unless hex_string.empty? # Add the last hex string
|
130
|
-
hex_strings.reverse # Reverse final values to get the original order (the encoding process also reverses the encoded value order)
|
131
115
|
end
|
132
116
|
end
|
133
117
|
end
|
data/lib/encoded_id/version.rb
CHANGED
data/lib/encoded_id.rb
CHANGED
@@ -1,12 +1,16 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require_relative "encoded_id/version"
|
4
|
+
require_relative "encoded_id/alphabet"
|
5
|
+
require_relative "encoded_id/hex_representation"
|
4
6
|
require_relative "encoded_id/reversible_id"
|
5
7
|
|
6
8
|
module EncodedId
|
7
|
-
class
|
9
|
+
class InvalidConfigurationError < StandardError; end
|
8
10
|
|
9
11
|
class InvalidAlphabetError < ArgumentError; end
|
10
12
|
|
13
|
+
class EncodedIdFormatError < ArgumentError; end
|
14
|
+
|
11
15
|
class InvalidInputError < ArgumentError; end
|
12
16
|
end
|
data/rbs_collection.yaml
CHANGED
data/sig/encoded_id.rbs
CHANGED
@@ -1,18 +1,65 @@
|
|
1
1
|
module EncodedId
|
2
|
+
VERSION: ::String
|
3
|
+
|
4
|
+
InvalidConfigurationError: ::StandardError
|
2
5
|
EncodedIdFormatError: ::ArgumentError
|
3
6
|
InvalidAlphabetError: ::ArgumentError
|
4
7
|
InvalidInputError: ::ArgumentError
|
5
8
|
|
6
|
-
class
|
7
|
-
|
9
|
+
class Alphabet
|
10
|
+
MIN_UNIQUE_CHARACTERS: ::Integer
|
11
|
+
|
12
|
+
def initialize: (String, ?::Hash[::String, ::String]) -> void
|
13
|
+
|
14
|
+
attr_reader characters: String
|
15
|
+
attr_reader equivalences: ::Hash[::String, ::String]
|
16
|
+
|
17
|
+
def self.modified_crockford: () -> Alphabet
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def valid_input_characters?: ((::Array[::String] | ::String) characters) -> bool
|
22
|
+
|
23
|
+
def sufficient_characters?: (::Integer size) -> bool
|
24
|
+
|
25
|
+
def unique_character_alphabet: ((::Array[::String] | ::String) characters) -> ::Array[::String]
|
26
|
+
|
27
|
+
def valid_equivalences?: (::Hash[::String, ::String] equivalences, ::Array[::String] unique_characters) -> bool
|
28
|
+
|
29
|
+
def raise_character_set_too_small!: -> untyped
|
30
|
+
|
31
|
+
def raise_invalid_alphabet!: -> void
|
8
32
|
|
9
|
-
def
|
33
|
+
def raise_invalid_equivalences!: -> void
|
34
|
+
end
|
35
|
+
|
36
|
+
type encodeableValue = ::Array[::String | ::Integer] | ::String | ::Integer
|
37
|
+
type encodeableHexValue = ::Array[::String] | ::String
|
38
|
+
|
39
|
+
class HexRepresentation
|
40
|
+
def initialize: (::Integer) -> void
|
41
|
+
def hex_as_integers: (encodeableHexValue) -> ::Array[::Integer]
|
42
|
+
def integers_as_hex: (::Array[::Integer]) -> ::Array[::String]
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
def validate_hex_digit_encoding_group_size: (::Integer) -> ::Integer
|
47
|
+
def integer_representation: (encodeableHexValue) -> ::Array[::Integer]
|
48
|
+
def integers_to_hex_strings: (::Array[::Integer]) -> ::Array[::String]
|
49
|
+
def hex_string_as_integer_representation: (::String) -> ::Array[::Integer]
|
50
|
+
def hex_string_separator: -> ::Integer
|
51
|
+
def remove_non_hex_characters: (::String) -> ::String
|
52
|
+
def convert_to_integer_groups: (::String) -> ::Array[::Integer]
|
53
|
+
end
|
54
|
+
|
55
|
+
class ReversibleId
|
56
|
+
def initialize: (salt: ::String, ?length: ::Integer, ?split_at: ::Integer, ?split_with: ::String, ?alphabet: Alphabet, ?hex_digit_encoding_group_size: ::Integer) -> void
|
10
57
|
|
11
58
|
# Encode the input values into a hash
|
12
|
-
def encode: (
|
59
|
+
def encode: (encodeableValue values) -> ::String
|
13
60
|
|
14
61
|
# Encode hex strings into a hash
|
15
|
-
def encode_hex: (
|
62
|
+
def encode_hex: (encodeableHexValue hexs) -> ::String
|
16
63
|
|
17
64
|
# Decode the hash to original array
|
18
65
|
def decode: (::String str) -> ::Array[::Integer]
|
@@ -30,11 +77,19 @@ module EncodedId
|
|
30
77
|
|
31
78
|
attr_reader length: ::Integer
|
32
79
|
|
33
|
-
attr_reader
|
80
|
+
attr_reader alphabet: Alphabet
|
34
81
|
|
35
82
|
attr_reader split_at: ::Integer | nil
|
83
|
+
attr_reader split_with: ::String
|
84
|
+
|
85
|
+
attr_reader hex_represention_encoder: HexRepresentation
|
36
86
|
|
37
|
-
|
87
|
+
def validate_alphabet: (Alphabet) -> Alphabet
|
88
|
+
def validate_salt: (::String) -> ::String
|
89
|
+
def validate_length: (::Integer) -> ::Integer
|
90
|
+
def validate_split_at: (::Integer | nil) -> (::Integer | nil)
|
91
|
+
def validate_split_with: (::String, Alphabet) -> ::String
|
92
|
+
def validate_hex_digit_encoding_group_size: (::Integer) -> ::Integer
|
38
93
|
|
39
94
|
def prepare_input: (untyped value) -> ::Array[::Integer]
|
40
95
|
|
@@ -46,12 +101,6 @@ module EncodedId
|
|
46
101
|
|
47
102
|
def convert_to_hash: (::String str) -> ::String
|
48
103
|
|
49
|
-
def
|
50
|
-
|
51
|
-
def integer_representation: (untyped hexs) -> ::Array[::Integer]
|
52
|
-
|
53
|
-
def integers_to_hex_strings: (::Array[::Integer] integers) -> ::Array[::String]
|
54
|
-
|
55
|
-
def hex_string_separator: () -> ::Integer
|
104
|
+
def map_equivalent_characters: (::String str) -> ::String
|
56
105
|
end
|
57
106
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: encoded_id
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 1.0.0.rc1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Stephen Ierodiaconou
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2023-08-06 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: hashids
|
@@ -42,6 +42,8 @@ files:
|
|
42
42
|
- Rakefile
|
43
43
|
- Steepfile
|
44
44
|
- lib/encoded_id.rb
|
45
|
+
- lib/encoded_id/alphabet.rb
|
46
|
+
- lib/encoded_id/hex_representation.rb
|
45
47
|
- lib/encoded_id/reversible_id.rb
|
46
48
|
- lib/encoded_id/version.rb
|
47
49
|
- rbs_collection.yaml
|
@@ -65,11 +67,11 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
65
67
|
version: 2.7.0
|
66
68
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
67
69
|
requirements:
|
68
|
-
- - "
|
70
|
+
- - ">"
|
69
71
|
- !ruby/object:Gem::Version
|
70
|
-
version:
|
72
|
+
version: 1.3.1
|
71
73
|
requirements: []
|
72
|
-
rubygems_version: 3.
|
74
|
+
rubygems_version: 3.4.10
|
73
75
|
signing_key:
|
74
76
|
specification_version: 4
|
75
77
|
summary: EncodedId is a gem for creating reversible obfuscated IDs from numerical
|