exid 0.1.2 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 064c2fa33e682ce0cc265bfc7504b5880468084b27d0a494b00de81fd49d24d2
4
- data.tar.gz: 4f1d3c6f0a3dec4cdfab9a944b8914893cecb4440f4c6402d10adc56066a4c94
3
+ metadata.gz: e84938938a68a64371ab17add16229d3cd21a2f225d9205dd34c68a496ec54c7
4
+ data.tar.gz: d0e45c03c17b898eb30b0406075bf239091883a996a7285c138c9ff764ce2a49
5
5
  SHA512:
6
- metadata.gz: 93cf7e89ba36affd92b44427d3c502a24ec746551a393e4ec20854810db697cbf5856a6aa7c372df9dee6bc4fda6650eb22f1e70849b2dd450b788483860b6b1
7
- data.tar.gz: d70ea9392154d335c8e1cb241e07ea1d71162141264fcd5e99c0063a7a404fb70a849d512c0576d965325f9aff58732b01ac6f20ca2de626bdf25a9fa16be0c5
6
+ metadata.gz: 6af718bfd3c92b368bd0ff6ae85ff58b20436d449dd9db007e0a62e151f190facd21bdf4e8331b62d09f8301a76a7f36b31e22c0c017553bc16506549cd097ab
7
+ data.tar.gz: 97daf7c43e837b8c0f78ee5106c3f26f67a0eca0c8e35d6f1fe745069e84a9dcd16d65f195954a8cbc77b9808709e3150e906abe88f68a0eecc9c278562d016c
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.4.4
data/.standard.yml ADDED
@@ -0,0 +1,2 @@
1
+ ---
2
+ ruby_version: 3.2
data/Dockerfile ADDED
@@ -0,0 +1,3 @@
1
+ FROM node:20-bookworm AS node-base
2
+ WORKDIR /app
3
+ RUN npm install -g @anthropic-ai/claude-code
data/README.md CHANGED
@@ -1,84 +1,129 @@
1
- # Exid
1
+ # Exid ![CI](https://github.com/marzdrel/exid/actions/workflows/ci.yml/badge.svg)
2
2
 
3
- **!! Warning: Documentation is not complete yet. Work in progress**
3
+ A Ruby gem for implementing human-friendly, prefixed identifiers for records using Base62-encoded UUIDs.
4
4
 
5
- This gem implements helper methods for implementing External, Prefixed
6
- identifiers for records.
5
+ ## Overview
7
6
 
8
- Core `Exid::Coder.encode` accepts a string prefix with an UUID and
9
- returns an "external ID", composed of this prefix and zero-padded
10
- Base62-encoded UUID.
7
+ Exid provides helper methods to create external, prefixed identifiers for your records, following the pattern popularized by Stripe's API.
11
8
 
12
- For example: `prg, 018977bb-02f0-729c-8c00-2f384eccb763` => `prg_02TOxMzOS0VaLzYiS3NPd9`
9
+ Similar to Stripe's IDs (like `cus_12345` for customers or `prod_67890` for products), Exid generates readable, prefixed identifiers that:
13
10
 
14
- See more:
15
- - https://dev.to/stripe/designing-apis-for-humans-object-ids-3o5a
16
- - https://danschultzer.com/posts/prefixed-base62-uuidv7-object-ids-with-ecto
17
- - https://dev.to/drnic/friendly-ids-for-ruby-on-rails-1c8p
18
- - https://github.com/excid3/prefixed_ids
19
- - https://github.com/sprql/uuid7-ruby
20
- - https://github.com/steventen/base62-rb
11
+ - Are human-friendly and can be safely exposed in URLs
12
+ - Contain semantic prefixes that indicate resource type
13
+ - Hide internal database IDs
14
+ - Maintain global uniqueness
15
+ - Are collision-resistant
16
+ - Have constant length for a consistent user experience
17
+
18
+ The core `Exid::Coder.encode` method accepts a string prefix with a UUID and returns an "external ID" composed of the prefix and a zero-padded **Base62-encoded UUID**.
19
+
20
+ For example:
21
+ ```
22
+ prg, 018977bb-02f0-729c-8c00-2f384eccb763 => prg_02TOxMzOS0VaLzYiS3NPd9
23
+ ```
24
+
25
+ ### Resources
26
+
27
+ For more information on this approach:
28
+ - [Designing APIs for Humans: Object IDs](https://dev.to/stripe/designing-apis-for-humans-object-ids-3o5a)
29
+ - [Prefixed Base62 UUIDv7 Object IDs with Ecto](https://danschultzer.com/posts/prefixed-base62-uuidv7-object-ids-with-ecto)
30
+ - [Friendly IDs for Ruby on Rails](https://dev.to/drnic/friendly-ids-for-ruby-on-rails-1c8p)
31
+ - [Prefixed IDs](https://github.com/excid3/prefixed_ids)
32
+ - [UUID7 Ruby](https://github.com/sprql/uuid7-ruby)
33
+ - [Base62 Ruby](https://github.com/steventen/base62-rb)
34
+
35
+ ## Installation
36
+
37
+ Add this line to your application's Gemfile:
38
+
39
+ ```ruby
40
+ gem "exid"
41
+ ```
42
+
43
+ Then execute:
44
+
45
+ ```
46
+ $ bundle install
47
+ ```
21
48
 
22
49
  ## Usage
23
50
 
24
- Add a UUID or (preferably) UUIDv7 to your model include a helper module. Pass a
25
- prefix (String) and a field name (Symbol) to the `Exid::Record.new` method.
51
+ ### Basic Setup
52
+
53
+ Add a UUID or (preferably) UUIDv7 field to your model and include the helper module. Pass a prefix (String) and a field name (Symbol) to the `Exid::Record.new` method:
26
54
 
27
55
  ```ruby
28
56
  class User < ApplicationRecord
29
57
  include Exid::Record.new("user", :uuid)
30
58
 
31
- # Optional, but recommended. Use the Exid value as the primary object
32
- # identier.
33
-
59
+ # Optional, but recommended: Use the external ID as the primary object identifier
34
60
  def to_param = exid_value
35
61
  end
36
62
  ```
37
63
 
38
- That's all. This will add certain class and instance methods to your object.
64
+ That's all! This adds several helper methods to your model.
65
+
66
+ ### Example
39
67
 
40
68
  ```ruby
69
+ # Create a record with a UUID
41
70
  user = User.create!(uuid: "018977bb-02f0-729c-8c00-2f384eccb763")
71
+
72
+ # Access the methods
73
+ user.exid_value # => "user_02TOxMzOS0VaLzYiS3NPd9"
74
+ user.exid_prefix_name # => "user"
75
+ user.exid_field # => :uuid
42
76
  ```
43
77
 
44
- Following methods are now available on the instance class.
78
+ The `exid_handle` instance method returns the last 10 characters of the identifier. This is useful for displaying a distinguishing identifier in the UI. When using UUID7, the first few characters derive from timestamps and will be similar for objects created at the same time. You can pass an integer argument to get a specific number of trailing characters.
45
79
 
46
80
  ```ruby
47
- user.exid_value # => "user_02TOxMzOS0VaLzYiS3NPd9"
81
+ user.exid_handle # => "OBtqZqRhLm"
82
+ user.exid_handle(6) # => "ZqRhLm"
83
+ ```
48
84
 
49
- user.exid_prefix_name # => "user"
85
+ ### Configuration
50
86
 
51
- user.exid_field # => :uuid
52
- ```
87
+ You can configure Exid's prefix validation at the application level. Create an initializer (e.g., `config/initializers/exid.rb` in Rails):
53
88
 
54
- The `exid_handle` instanec method simply returns last 10 characters of
55
- identifier. This might be useful for displaying in the UI as distinguishing
56
- identifier. If the UUID7 is used as the identifier, the first few characters
57
- are not random. They come from the timestamp, so they will be the same for most
58
- objects created at the same time. Pass integer as the argument to get the last
59
- N characters.
89
+ By default, prefixes are limited to 4 characters. You can provide a custom validator proc that returns `true` if the prefix is valid, `false` otherwise:
60
90
 
61
91
  ```ruby
62
- user.exid_handle # => "OBtqZqRhLm"
63
-
64
- user.exid_handle(6) # => "ZqRhLm"
92
+ Exid.configure do |config|
93
+ config.prefix_validator = proc { it.match?(/\A[a-z]{2,6}\z/) }
94
+ end
65
95
  ```
96
+ **Note**: When you set a custom `prefix_validator`, it replaces the default 4-character length validation. Your validator has full control over validation logic.
66
97
 
67
- The `Exid::Record` also offers couple of instance methods designed load
68
- records. This is another way to mimic Rails `GlobalID`. Warning: Steer
69
- away from using this as default way to load records using user supplied
70
- identifiers. User might replace the identifier with other record which might
71
- lead to unexpected results and security issues.
98
+ ### Loading Records by External ID
72
99
 
73
- The `fetch` class method will return the record or nil if not found. The
74
- `fetch!` variant will use Rails 7.1+ `sole` under the hood and raise an
75
- exception if the record is not found (or if more than one record is found).
100
+ Use the class method `exid_loader` to load a record using its external ID:
76
101
 
77
102
  ```ruby
78
- Exid::Record.fetch!("pref_02WoeojY8dqVYcAhs321rm")
79
- Exid::Record.fetch("pref_02WoeojY8dqVYcAhs321rm")
103
+ User.exid_loader("user_02TOxMzOS0VaLzYiS3NPd9")
104
+ # Raises ActiveRecord::RecordNotFound if not found
105
+ # Raises NoMatchingPatternError if ID format is invalid
80
106
  ```
81
107
 
108
+ The `Exid::Record` module also provides global loading methods that mimic Rails `GlobalID` functionality:
109
+
110
+ ```ruby
111
+ Exid::Record.fetch!("pref_02WoeojY8dqVYcAhs321rm") # Raises exception if not found
112
+ Exid::Record.fetch("pref_02WoeojY8dqVYcAhs321rm") # Returns `nil` if not found
113
+ ```
114
+
115
+ > **⚠️ Security Warning**: Exercise caution when using global loading methods with user-supplied identifiers, as this could lead to unexpected results or security issues if users substitute identifiers.
116
+
117
+ **Note**: When using this gem in Rails development mode (with `eager_load` set to `false`), ensure the model class is referenced before calling `.fetch` or `.fetch!`. This ensures the prefix is added to the global registry so the loader can identify which class is associated with the prefix.
118
+
119
+ ## Contributing
120
+
121
+ 1. Fork the repository
122
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
123
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
124
+ 4. Push to the branch (`git push origin my-new-feature`)
125
+ 5. Create a new Pull Request
126
+
82
127
  ## License
83
128
 
84
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
129
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,14 @@
1
+ ---
2
+ name: exid
3
+
4
+ x-llm: &llm
5
+ build:
6
+ context: .
7
+ dockerfile: Dockerfile
8
+ target: node-base
9
+ image: claude
10
+ tmpfs:
11
+ - /tmp:mode=1777
12
+ volumes:
13
+ - .:/app
14
+ - ~/.claude.json:/root/.claude.json
data/lib/exid/base62.rb CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  module Exid
4
4
  class Base62
5
+ class DecodeError < Error; end
6
+
5
7
  CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
6
8
  BASE = CHARS.length
7
9
  CHARS_HASH = CHARS.each_char.zip(0...BASE).to_h
@@ -22,7 +24,13 @@ module Exid
22
24
  def self.decode(str)
23
25
  max = str.length - 1
24
26
  str.each_char.zip(0..max).reduce(0) do |acc, (char, index)|
25
- acc + (CHARS_HASH[char] * (BASE**(max - index)))
27
+ character = CHARS_HASH[char]
28
+
29
+ if character.nil?
30
+ raise DecodeError, "Invalid character '#{char}' in string '#{str}'"
31
+ end
32
+
33
+ acc + (character * (BASE**(max - index)))
26
34
  end
27
35
  end
28
36
  end
data/lib/exid/coder.rb CHANGED
@@ -33,7 +33,7 @@ module Exid
33
33
  def self.encode(prefix, uuid)
34
34
  [
35
35
  prefix.to_s,
36
- Base62.encode(uuid.delete("-").hex),
36
+ Base62.encode(uuid.delete("-").hex)
37
37
  ].join("_")
38
38
  end
39
39
 
@@ -48,7 +48,7 @@ module Exid
48
48
 
49
49
  Result.new(
50
50
  prefix,
51
- [hex[0..7], hex[8..11], hex[12..15], hex[16..19], hex[20..31]].join("-"),
51
+ [hex[0..7], hex[8..11], hex[12..15], hex[16..19], hex[20..31]].join("-")
52
52
  )
53
53
  end
54
54
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exid
4
+ class Configuration
5
+ attr_accessor :prefix_validator
6
+
7
+ def initialize
8
+ @prefix_validator = default_validator
9
+ end
10
+
11
+ def validate_prefix(prefix)
12
+ return if prefix_validator.call(prefix)
13
+
14
+ raise Error, "Prefix validation failed for: #{prefix}"
15
+ end
16
+
17
+ private
18
+
19
+ def default_validator
20
+ proc { |prefix| prefix.length <= 4 }
21
+ end
22
+ end
23
+
24
+ class << self
25
+ attr_writer :configuration
26
+
27
+ def configuration
28
+ @_configuration ||= Configuration.new
29
+ end
30
+
31
+ def configure
32
+ yield(configuration)
33
+ end
34
+
35
+ def reset_configuration!
36
+ @configuration = Configuration.new
37
+ end
38
+ end
39
+ end
data/lib/exid/error.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exid
4
+ class Error < StandardError; end
5
+ end
data/lib/exid/record.rb CHANGED
@@ -5,6 +5,7 @@ module Exid
5
5
  Entry =
6
6
  Data.define(:prefix, :field, :klass) do
7
7
  def ==(other) = prefix == other.prefix
8
+
8
9
  def hash = prefix.hash
9
10
 
10
11
  alias_method :eql?, :==
@@ -13,22 +14,22 @@ module Exid
13
14
  @registered_modules = Set.new
14
15
 
15
16
  def included(base)
16
- base.send(:include, @module_value)
17
- base.send(:include, @module_shared)
18
- base.send(:extend, @module_shared)
19
- base.send(:extend, @module_static)
17
+ base.public_send(:include, @module_value)
18
+ base.public_send(:include, @module_shared)
19
+ base.public_send(:extend, @module_shared)
20
+ base.public_send(:extend, @module_static)
20
21
 
21
22
  self.class.register_module(
22
23
  Entry.new(
23
24
  prefix: base.exid_prefix_name,
24
25
  field: base.exid_field,
25
- klass: base,
26
- ),
26
+ klass: base
27
+ )
27
28
  )
28
29
  end
29
30
 
30
31
  def initialize(prefix, field)
31
- raise Error, "Prefix cannot be longer than 4 characters" if prefix.length > 4
32
+ Exid.configuration.validate_prefix(prefix)
32
33
 
33
34
  @module_static = build_module_static(prefix, field)
34
35
  @module_value = build_module_value(prefix, field)
@@ -62,7 +63,7 @@ module Exid
62
63
  end
63
64
 
64
65
  def self.find_module(prefix)
65
- registered_modules.detect { it.prefix == prefix } or
66
+ registered_modules.detect { _1.prefix == prefix } or
66
67
  raise Error, "Model for \"#{prefix}\" not found"
67
68
  end
68
69
 
@@ -74,6 +75,7 @@ module Exid
74
75
  end
75
76
 
76
77
  def self.fetch(eid) = finder(eid).first
78
+
77
79
  def self.fetch!(eid) = finder(eid).sole
78
80
 
79
81
  private
@@ -90,7 +92,7 @@ module Exid
90
92
  # display only, do not use this to fetch records, etc.
91
93
 
92
94
  define_method :exid_handle do |amount = 10|
93
- exid_value.split("_").last[-amount..-1]
95
+ exid_value.split("_").last[-amount..]
94
96
  end
95
97
  end
96
98
  end
data/lib/exid/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Exid
4
- VERSION = "0.1.2"
4
+ VERSION = "0.2.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: exid
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - John Doe
@@ -31,12 +31,18 @@ extensions: []
31
31
  extra_rdoc_files: []
32
32
  files:
33
33
  - ".rspec"
34
+ - ".ruby-version"
35
+ - ".standard.yml"
36
+ - Dockerfile
34
37
  - LICENSE.txt
35
38
  - README.md
36
39
  - Rakefile
40
+ - docker-compose.yml
37
41
  - lib/exid.rb
38
42
  - lib/exid/base62.rb
39
43
  - lib/exid/coder.rb
44
+ - lib/exid/configuration.rb
45
+ - lib/exid/error.rb
40
46
  - lib/exid/record.rb
41
47
  - lib/exid/version.rb
42
48
  - sig/exid.rbs
@@ -59,7 +65,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
59
65
  - !ruby/object:Gem::Version
60
66
  version: '0'
61
67
  requirements: []
62
- rubygems_version: 3.6.7
68
+ rubygems_version: 3.7.2
63
69
  specification_version: 4
64
70
  summary: Easy External identifier management for models
65
71
  test_files: []