encoded_id 0.3.0 → 1.0.0.rc1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b69898cce6c9514fc7269f139e4280673b319cf2c879cbb925fdc5a8c03abc27
4
- data.tar.gz: b0f17c21e5baa1a171df12629d27ebae511c647a66c0dc82dd8690e573c94754
3
+ metadata.gz: a860b12577a70042f6d17a1788cb8557f3ab5ab477441a44c3be037b70165ded
4
+ data.tar.gz: bd9d57c92ada9e47a1080ea5b4d04cb3069a055a213305b698f1a2c7c424297f
5
5
  SHA512:
6
- metadata.gz: 133e6d70f9a3613e9009e0d5fe65dd3fdebd095c79533d7976924ed2d9589a47d4854a19d0a9f718caf864673a066f07d92b6ba5947ae043ecb170ed2aab6175
7
- data.tar.gz: a5368e9bc5eb8f49438cff06c5c3fbd22d054b386d31ae194ccdea2f8ed148bc9f54492eb93da2e88893056615c6ef73dc24eaef1e22194feac2e160660f0f10
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
@@ -9,6 +9,6 @@ gem "rake", "~> 13.0"
9
9
 
10
10
  gem "minitest", "~> 5.0"
11
11
 
12
- gem "standard", "~> 1.3"
12
+ gem "standard", "~> 1.25"
13
13
 
14
- gem "steep", "~> 1.2"
14
+ gem "steep", "~> 1.5"
data/README.md CHANGED
@@ -1,65 +1,76 @@
1
1
  # EncodedId
2
2
 
3
- Encode your numerical IDs (eg record primary keys) into obfuscated strings that can be used in URLs.
3
+ Encode numerical or hex IDs into obfuscated strings that can be used in URLs.
4
4
 
5
- `::EncodedId::ReversibleId.new(salt: my_salt).encode(123)` => `"p5w9-z27j"`
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, so you can decode them back into the original numerical IDs. Also supports
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
- Length of the ID, the alphabet used, and the number of characters per group can be configured.
17
+ ```ruby
18
+ my_salt = "salt!"
19
+ coder = ::EncodedId::ReversibleId.new(salt: my_salt)
17
20
 
18
- The custom alphabet (at least 16 characters needed) and character group sizes is to make the IDs easier to read or share.
19
- Easily confused characters (eg `i` and `j`, `0` and `O`, `1` and `I` etc) are mapped to counterpart characters, to help
20
- common mistakes when sharing (eg customer over phone to customer service agent).
21
+ # One of more values can be encoded
22
+ coder.encode([78, 45])
23
+ # => "z2j7-0dmw"
21
24
 
22
- Also supports UUIDs if needed
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
- ::EncodedId::ReversibleId.new(salt: my_salt).encode_hex("9a566b8b-8618-42ab-8db7-a5a0276401fd")
26
- => "rppv-tg8a-cx8q-gu9e-zq15-jxes-4gpr-06xk-wfk8-aw"
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
- Build with https://hashids.org
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
- * Hashids are reversible, no need to persist the generated Id
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
- ## Note on security of encoded IDs (hashids)
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
- ## Usage
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
- TODO: Write usage instructions here
114
+ ### `length`
76
115
 
77
- ### Rails
116
+ `length`: the minimum length of the encoded string. The default is 8 characters.
78
117
 
79
- To use with rails try the `encoded_id-rails` gem.
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
- class User < ApplicationRecord
83
- include EncodedId::WithEncodedId
84
- end
131
+ alphabet = EncodedId::Alphabet.new("0123456789abcdef")
132
+ ```
133
+
134
+ `EncodedId::Alphabet.new(characters, equivalences)`
85
135
 
86
- User.find_by_encoded_id("p5w9-z27j") # => #<User id: 78>
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/[USERNAME]/encoded_id.
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
- ALPHABET = "0123456789abcdefghjkmnpqrstuvwxyz"
12
-
13
- def initialize(salt:, length: 8, split_at: 4, alphabet: ALPHABET, hex_digit_encoding_group_size: 4)
14
- unique_alphabet = alphabet.chars.uniq
15
- raise InvalidAlphabetError, "Alphabet must be at least 16 characters" if unique_alphabet.size < 16
16
-
17
- @human_friendly_alphabet = unique_alphabet.join
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(integer_representation(hexs))
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
- integers_to_hex_strings(integers)
43
+ hex_represention_encoder.integers_as_hex(integers)
50
44
  end
51
45
 
52
46
  private
53
47
 
54
- attr_reader :salt, :length, :human_friendly_alphabet, :split_at, :hex_digit_encoding_group_size
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, human_friendly_alphabet)
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, '\0-')
102
+ hash.gsub(split_regex, "\\0#{split_with}")
73
103
  end
74
104
 
75
105
  def convert_to_hash(str)
76
- map_crockford_set(str.delete("-").downcase)
77
- end
78
-
79
- def map_crockford_set(str)
80
- # Crockford suggest i==1 , but I think i==j is more appropriate as we
81
- # only use lowercase
82
- str.tr("o", "0").tr("l", "1").tr("i", "j")
83
- end
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EncodedId
4
- VERSION = "0.3.0"
4
+ VERSION = "1.0.0.rc1"
5
5
  end
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 EncodedIdFormatError < ArgumentError; end
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
@@ -9,8 +9,6 @@ sources:
9
9
  path: .gem_rbs_collection
10
10
 
11
11
  gems:
12
- - name: encoded_id
13
- ignore: true
14
12
  # Skip loading rbs gem's RBS.
15
13
  # It's unnecessary if you don't use rbs as a library.
16
14
  - name: rbs
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 ReversibleId
7
- ALPHABET: ::String
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 initialize: (salt: ::String, ?length: ::Integer, ?split_at: ::Integer, ?alphabet: ::String, ?hex_digit_encoding_group_size: ::Integer) -> void
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: (untyped values) -> ::String
59
+ def encode: (encodeableValue values) -> ::String
13
60
 
14
61
  # Encode hex strings into a hash
15
- def encode_hex: (untyped hexs) -> ::String
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 human_friendly_alphabet: ::String
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
- attr_reader hex_digit_encoding_group_size: ::Integer
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 map_crockford_set: (::String str) -> ::String
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.3.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: 2022-10-12 00:00:00.000000000 Z
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: '0'
72
+ version: 1.3.1
71
73
  requirements: []
72
- rubygems_version: 3.3.7
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