globalid 1.1.0 → 1.3.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: 52c4368f15fc8952c9d79189ed9a78e649e0142421721aef338625c19b24c3b9
4
- data.tar.gz: 4d6d1c379d45ccaf5761488c540d19cac9d0488b578564964edd88c52073c511
3
+ metadata.gz: e7f6fd8b8b0b07aad73739e2004b5c4229e48dfa64960dcfa243f6c1b6276b44
4
+ data.tar.gz: a1cc24c9968a2236d2974f4ecac4d3fbb8513e4ac74d18685185afa684fad41d
5
5
  SHA512:
6
- metadata.gz: 49ff5a4b4d4f68afdffa67a85b5aebfb3b2f518257ffec22be8a60a29ac5596a67552aae46bebab28a4fe6b37a5802ac520b0f567098617110b2f54c4a74cfcb
7
- data.tar.gz: f6ba35dc2c080b0c996bd4529cec1438a35545c693cc0271e25c6b7bdaa8f759de61f26ae6ecf16487d89c7e6e5afc41caab7114541cd8a54ebdf9e2a3ce1f60
6
+ metadata.gz: 68bd4cc42217eb59cff8b1f73c989e9f17ddce332f7d65f533e5b2fa6e429a10328d9c5b02f165e778cd83c02afa095103984910c098e75c9dcab788a4c97024
7
+ data.tar.gz: 652bc775cb1da110f6eca011d5a6ef3d907f79ddbfda755206d84eddca06c2153b734befb5b5dc6ea09ed911b87c72fcae6f9bfb5554da1b4616d60d22a298fb
data/README.md CHANGED
@@ -57,7 +57,7 @@ GlobalID::Locator.locate_signed person_sgid
57
57
 
58
58
  **Expiration**
59
59
 
60
- Signed Global IDs can expire some time in the future. This is useful if there's a resource
60
+ Signed Global IDs can expire sometime in the future. This is useful if there's a resource
61
61
  people shouldn't have indefinite access to, like a share link.
62
62
 
63
63
  ```ruby
@@ -73,8 +73,8 @@ GlobalID::Locator.locate_signed(expiring_sgid.to_s, for: 'sharing')
73
73
  # => nil
74
74
  ```
75
75
 
76
- **In Rails, an auto-expiry of 1 month is set by default.** You can alter that deal
77
- in an initializer with:
76
+ **In Rails, an auto-expiry of 1 month is set by default.** You can alter that
77
+ default in an initializer with:
78
78
 
79
79
  ```ruby
80
80
  # config/initializers/global_id.rb
@@ -87,7 +87,7 @@ You can assign a default SGID lifetime like so:
87
87
  SignedGlobalID.expires_in = 1.month
88
88
  ```
89
89
 
90
- This way any generated SGID will use that relative expiry.
90
+ This way, any generated SGID will use that relative expiry.
91
91
 
92
92
  It's worth noting that _expiring SGIDs are not idempotent_ because they encode the current timestamp; repeated calls to `to_sgid` will produce different results. For example, in Rails
93
93
 
@@ -161,6 +161,25 @@ GlobalID::Locator.locate_many gids
161
161
 
162
162
  Note the order is maintained in the returned results.
163
163
 
164
+ ### Options
165
+
166
+ Either `GlobalID::Locator.locate` or `GlobalID::Locator.locate_many` supports a hash of options as second parameter. The supported options are:
167
+
168
+ * `:includes` - A Symbol, Array, Hash or combination of them.
169
+ The same structure you would pass into an `includes` method of Active Record.
170
+ See [Active Record eager loading associations](https://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations).
171
+ If present, `locate` or `locate_many` will eager load all the relationships specified here.
172
+ Note: It only works if all the GIDs Models have those relationships.
173
+ * `:only` - A class, module, or Array of classes and/or modules that are
174
+ allowed to be located. Passing one or more classes limits instances of returned
175
+ classes to those classes or their subclasses. Passing one or more modules in limits
176
+ instances of returned classes to those including that module. If no classes or
177
+ modules match, `nil` is returned.
178
+ * `:ignore_missing` (Only for `locate_many`) - By default, `locate_many` will call `#find` on the model to locate the
179
+ ids extracted from the GIDs. In Active Record (and other data stores following the same pattern),
180
+ `#find` will raise an exception if a named ID can't be found. When you set this option to `true`,
181
+ we will use `#where(id: ids)` instead, which does not raise on missing records.
182
+
164
183
  ### Custom App Locator
165
184
 
166
185
  A custom locator can be set for an app by calling `GlobalID::Locator.use` and providing an app locator to use for that app.
@@ -172,7 +191,7 @@ A custom locator can either be a block or a class.
172
191
  Using a block:
173
192
 
174
193
  ```ruby
175
- GlobalID::Locator.use :foo do |gid|
194
+ GlobalID::Locator.use :foo do |gid, options|
176
195
  FooRemote.const_get(gid.model_name).find(gid.model_id)
177
196
  end
178
197
  ```
@@ -182,15 +201,37 @@ Using a class:
182
201
  ```ruby
183
202
  GlobalID::Locator.use :bar, BarLocator.new
184
203
  class BarLocator
185
- def locate(gid)
204
+ def locate(gid, options = {})
186
205
  @search_client.search name: gid.model_name, id: gid.model_id
187
206
  end
188
207
  end
189
208
  ```
190
209
 
191
- After defining locators as above, URIs like "gid://foo/Person/1" and "gid://bar/Person/1" will now use the foo block locator and `BarLocator` respectively.
210
+ After defining locators as above, URIs like `gid://foo/Person/1` and `gid://bar/Person/1` will now use the foo block locator and `BarLocator` respectively.
192
211
  Other apps will still keep using the default locator.
193
212
 
213
+ ### Custom Default Locator
214
+
215
+ A custom default locator can be set for an app by calling `GlobalID::Locator.default_locator=` and providing a default locator to use for that app.
216
+
217
+ ```ruby
218
+ class MyCustomLocator < UnscopedLocator
219
+ def locate(gid, options = {})
220
+ ActiveRecord::Base.connected_to(role: :reading) do
221
+ super(gid, options)
222
+ end
223
+ end
224
+
225
+ def locate_many(gids, options = {})
226
+ ActiveRecord::Base.connected_to(role: :reading) do
227
+ super(gids, options)
228
+ end
229
+ end
230
+ end
231
+
232
+ GlobalID::Locator.default_locator = MyCustomLocator.new
233
+ ```
234
+
194
235
  ## Contributing to GlobalID
195
236
 
196
237
  GlobalID is work of many contributors. You're encouraged to submit pull requests, propose
@@ -32,6 +32,10 @@ class GlobalID
32
32
  @app = URI::GID.validate_app(app)
33
33
  end
34
34
 
35
+ def default_locator(default_locator)
36
+ Locator.default_locator = default_locator
37
+ end
38
+
35
39
  private
36
40
  def parse_encoded_gid(gid, options)
37
41
  new(Base64.urlsafe_decode64(gid), options) rescue nil
@@ -50,12 +54,13 @@ class GlobalID
50
54
  end
51
55
 
52
56
  def model_class
53
- model = model_name.constantize
57
+ @model_class ||= begin
58
+ model = model_name.constantize
54
59
 
55
- unless model <= GlobalID
60
+ if model <= GlobalID
61
+ raise ArgumentError, "GlobalID and SignedGlobalID cannot be used as model_class."
62
+ end
56
63
  model
57
- else
58
- raise ArgumentError, "GlobalID and SignedGlobalID cannot be used as model_class."
59
64
  end
60
65
  end
61
66
 
@@ -1,19 +1,118 @@
1
1
  class GlobalID
2
+ # Mix `GlobalID::Identification` into any model with a `#find(id)` class
3
+ # method. Support is automatically included in Active Record.
4
+ #
5
+ # class Person
6
+ # include ActiveModel::Model
7
+ # include GlobalID::Identification
8
+ #
9
+ # attr_accessor :id
10
+ #
11
+ # def self.find(id)
12
+ # new id: id
13
+ # end
14
+ #
15
+ # def ==(other)
16
+ # id == other.try(:id)
17
+ # end
18
+ # end
19
+ #
20
+ # person_gid = Person.find(1).to_global_id
21
+ # # => #<GlobalID ...
22
+ # person_gid.uri
23
+ # # => #<URI ...
24
+ # person_gid.to_s
25
+ # # => "gid://app/Person/1"
26
+ # GlobalID::Locator.locate person_gid
27
+ # # => #<Person:0x007fae94bf6298 @id="1">
2
28
  module Identification
29
+
30
+ # Returns the Global ID of the model.
31
+ #
32
+ # model = Person.new id: 1
33
+ # global_id = model.to_global_id
34
+ # global_id.model_class # => Person
35
+ # global_id.model_id # => "1"
36
+ # global_id.to_param # => "Z2lkOi8vYm9yZGZvbGlvL1BlcnNvbi8x"
3
37
  def to_global_id(options = {})
4
38
  GlobalID.create(self, options)
5
39
  end
6
40
  alias to_gid to_global_id
7
41
 
42
+ # Returns the Global ID parameter of the model.
43
+ #
44
+ # model = Person.new id: 1
45
+ # model.to_gid_param # => ""Z2lkOi8vYm9yZGZvbGlvL1BlcnNvbi8x"
8
46
  def to_gid_param(options = {})
9
47
  to_global_id(options).to_param
10
48
  end
11
49
 
50
+ # Returns the Signed Global ID of the model.
51
+ # Signed Global IDs ensure that the data hasn't been tampered with.
52
+ #
53
+ # model = Person.new id: 1
54
+ # signed_global_id = model.to_signed_global_id
55
+ # signed_global_id.model_class # => Person
56
+ # signed_global_id.model_id # => "1"
57
+ # signed_global_id.to_param # => "BAh7CEkiCGdpZAY6BkVUSSIiZ2..."
58
+ #
59
+ # ==== Expiration
60
+ #
61
+ # Signed Global IDs can expire some time in the future. This is useful if
62
+ # there's a resource people shouldn't have indefinite access to, like a
63
+ # share link.
64
+ #
65
+ # expiring_sgid = Document.find(5).to_sgid(expires_in: 2.hours, for: 'sharing')
66
+ # # => #<SignedGlobalID:0x008fde45df8937 ...>
67
+ # # Within 2 hours...
68
+ # GlobalID::Locator.locate_signed(expiring_sgid.to_s, for: 'sharing')
69
+ # # => #<Document:0x007fae94bf6298 @id="5">
70
+ # # More than 2 hours later...
71
+ # GlobalID::Locator.locate_signed(expiring_sgid.to_s, for: 'sharing')
72
+ # # => nil
73
+ #
74
+ # In Rails, an auto-expiry of 1 month is set by default.
75
+ #
76
+ # You need to explicitly pass `expires_in: nil` to generate a permanent
77
+ # SGID that will not expire,
78
+ #
79
+ # never_expiring_sgid = Document.find(5).to_sgid(expires_in: nil)
80
+ # # => #<SignedGlobalID:0x008fde45df8937 ...>
81
+ #
82
+ # # Any time later...
83
+ # GlobalID::Locator.locate_signed never_expiring_sgid
84
+ # # => #<Document:0x007fae94bf6298 @id="5">
85
+ #
86
+ # It's also possible to pass a specific expiry time
87
+ #
88
+ # explicit_expiring_sgid = SecretAgentMessage.find(5).to_sgid(expires_at: Time.now.advance(hours: 1))
89
+ # # => #<SignedGlobalID:0x008fde45df8937 ...>
90
+ #
91
+ # # 1 hour later...
92
+ # GlobalID::Locator.locate_signed explicit_expiring_sgid.to_s
93
+ # # => nil
94
+ #
95
+ # Note that an explicit `:expires_at` takes precedence over a relative `:expires_in`.
96
+ #
97
+ # ==== Purpose
98
+ #
99
+ # You can even bump the security up some more by explaining what purpose a
100
+ # Signed Global ID is for. In this way evildoers can't reuse a sign-up
101
+ # form's SGID on the login page. For example.
102
+ #
103
+ # signup_person_sgid = Person.find(1).to_sgid(for: 'signup_form')
104
+ # # => #<SignedGlobalID:0x007fea1984b520
105
+ # GlobalID::Locator.locate_signed(signup_person_sgid.to_s, for: 'signup_form')
106
+ # => #<Person:0x007fae94bf6298 @id="1">
12
107
  def to_signed_global_id(options = {})
13
108
  SignedGlobalID.create(self, options)
14
109
  end
15
110
  alias to_sgid to_signed_global_id
16
111
 
112
+ # Returns the Signed Global ID parameter.
113
+ #
114
+ # model = Person.new id: 1
115
+ # model.to_sgid_param # => "BAh7CEkiCGdpZAY6BkVUSSIiZ2..."
17
116
  def to_sgid_param(options = {})
18
117
  to_signed_global_id(options).to_param
19
118
  end
@@ -2,18 +2,36 @@ require 'active_support/core_ext/enumerable' # For Enumerable#index_by
2
2
 
3
3
  class GlobalID
4
4
  module Locator
5
+ class InvalidModelIdError < StandardError; end
6
+
5
7
  class << self
8
+ # The default locator used when no app-specific locator is found.
9
+ attr_accessor :default_locator
10
+
6
11
  # Takes either a GlobalID or a string that can be turned into a GlobalID
7
12
  #
8
13
  # Options:
14
+ # * <tt>:includes</tt> - A Symbol, Array, Hash or combination of them.
15
+ # The same structure you would pass into a +includes+ method of Active Record.
16
+ # If present, locate will load all the relationships specified here.
17
+ # See https://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations.
9
18
  # * <tt>:only</tt> - A class, module or Array of classes and/or modules that are
10
19
  # allowed to be located. Passing one or more classes limits instances of returned
11
20
  # classes to those classes or their subclasses. Passing one or more modules in limits
12
21
  # instances of returned classes to those including that module. If no classes or
13
22
  # modules match, +nil+ is returned.
14
23
  def locate(gid, options = {})
15
- if gid = GlobalID.parse(gid)
16
- locator_for(gid).locate gid if find_allowed?(gid.model_class, options[:only])
24
+ gid = GlobalID.parse(gid)
25
+
26
+ return unless gid && find_allowed?(gid.model_class, options[:only])
27
+
28
+ locator = locator_for(gid)
29
+
30
+ if locator.method(:locate).arity == 1
31
+ GlobalID.deprecator.warn "It seems your locator is defining the `locate` method only with one argument. Please make sure your locator is receiving the options argument as well, like `locate(gid, options = {})`."
32
+ locator.locate(gid)
33
+ else
34
+ locator.locate(gid, options.except(:only))
17
35
  end
18
36
  end
19
37
 
@@ -28,6 +46,11 @@ class GlobalID
28
46
  # per model class, but still interpolate the results to match the order in which the gids were passed.
29
47
  #
30
48
  # Options:
49
+ # * <tt>:includes</tt> - A Symbol, Array, Hash or combination of them
50
+ # The same structure you would pass into a includes method of Active Record.
51
+ # @see https://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations
52
+ # If present, locate_many will load all the relationships specified here.
53
+ # Note: It only works if all the gids models have that relationships.
31
54
  # * <tt>:only</tt> - A class, module or Array of classes and/or modules that are
32
55
  # allowed to be located. Passing one or more classes limits instances of returned
33
56
  # classes to those classes or their subclasses. Passing one or more modules in limits
@@ -49,6 +72,10 @@ class GlobalID
49
72
  # Takes either a SignedGlobalID or a string that can be turned into a SignedGlobalID
50
73
  #
51
74
  # Options:
75
+ # * <tt>:includes</tt> - A Symbol, Array, Hash or combination of them
76
+ # The same structure you would pass into a includes method of Active Record.
77
+ # @see https://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations
78
+ # If present, locate_signed will load all the relationships specified here.
52
79
  # * <tt>:only</tt> - A class, module or Array of classes and/or modules that are
53
80
  # allowed to be located. Passing one or more classes limits instances of returned
54
81
  # classes to those classes or their subclasses. Passing one or more modules in limits
@@ -66,6 +93,11 @@ class GlobalID
66
93
  # the results to match the order in which the gids were passed.
67
94
  #
68
95
  # Options:
96
+ # * <tt>:includes</tt> - A Symbol, Array, Hash or combination of them
97
+ # The same structure you would pass into a includes method of Active Record.
98
+ # @see https://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations
99
+ # If present, locate_many_signed will load all the relationships specified here.
100
+ # Note: It only works if all the gids models have that relationships.
69
101
  # * <tt>:only</tt> - A class, module or Array of classes and/or modules that are
70
102
  # allowed to be located. Passing one or more classes limits instances of returned
71
103
  # classes to those classes or their subclasses. Passing one or more modules in limits
@@ -82,7 +114,7 @@ class GlobalID
82
114
  #
83
115
  # Using a block:
84
116
  #
85
- # GlobalID::Locator.use :foo do |gid|
117
+ # GlobalID::Locator.use :foo do |gid, options|
86
118
  # FooRemote.const_get(gid.model_name).find(gid.model_id)
87
119
  # end
88
120
  #
@@ -91,7 +123,7 @@ class GlobalID
91
123
  # GlobalID::Locator.use :bar, BarLocator.new
92
124
  #
93
125
  # class BarLocator
94
- # def locate(gid)
126
+ # def locate(gid, options = {})
95
127
  # @search_client.search name: gid.model_name, id: gid.model_id
96
128
  # end
97
129
  # end
@@ -105,7 +137,7 @@ class GlobalID
105
137
 
106
138
  private
107
139
  def locator_for(gid)
108
- @locators.fetch(normalize_app(gid.app)) { DEFAULT_LOCATOR }
140
+ @locators.fetch(normalize_app(gid.app)) { default_locator }
109
141
  end
110
142
 
111
143
  def find_allowed?(model_class, only = nil)
@@ -125,32 +157,59 @@ class GlobalID
125
157
  @locators = {}
126
158
 
127
159
  class BaseLocator
128
- def locate(gid)
129
- gid.model_class.find gid.model_id
160
+ def locate(gid, options = {})
161
+ return unless model_id_is_valid?(gid)
162
+ model_class = gid.model_class
163
+ model_class = model_class.includes(options[:includes]) if options[:includes]
164
+
165
+ model_class.find gid.model_id
130
166
  end
131
167
 
132
168
  def locate_many(gids, options = {})
133
- models_and_ids = gids.collect { |gid| [ gid.model_class, gid.model_id ] }
134
- ids_by_model = models_and_ids.group_by(&:first)
135
- loaded_by_model = Hash[ids_by_model.map { |model, ids|
136
- [ model, find_records(model, ids.map(&:last), ignore_missing: options[:ignore_missing]).index_by { |record| record.id.to_s } ]
137
- }]
169
+ ids_by_model = Hash.new { |hash, key| hash[key] = [] }
170
+
171
+ gids.each do |gid|
172
+ next unless model_id_is_valid?(gid)
173
+ ids_by_model[gid.model_class] << gid.model_id
174
+ end
175
+
176
+ records_by_model_name_and_id = {}
177
+
178
+ ids_by_model.each do |model, ids|
179
+ records = find_records(model, ids, ignore_missing: options[:ignore_missing], includes: options[:includes])
138
180
 
139
- models_and_ids.collect { |(model, id)| loaded_by_model[model][id] }.compact
181
+ records_by_id = records.index_by do |record|
182
+ record.id.is_a?(Array) ? record.id.map(&:to_s) : record.id.to_s
183
+ end
184
+
185
+ records_by_model_name_and_id[model.name] = records_by_id
186
+ end
187
+
188
+ gids.filter_map { |gid| records_by_model_name_and_id[gid.model_name][gid.model_id] }
140
189
  end
141
190
 
142
191
  private
143
192
  def find_records(model_class, ids, options)
193
+ model_class = model_class.includes(options[:includes]) if options[:includes]
194
+
144
195
  if options[:ignore_missing]
145
- model_class.where(id: ids)
196
+ model_class.where(primary_key(model_class) => ids)
146
197
  else
147
198
  model_class.find(ids)
148
199
  end
149
200
  end
201
+
202
+ def model_id_is_valid?(gid)
203
+ Array(gid.model_id).size == Array(primary_key(gid.model_class)).size
204
+ end
205
+
206
+ def primary_key(model_class)
207
+ model_class.respond_to?(:primary_key) ? model_class.primary_key : :id
208
+ end
150
209
  end
151
210
 
152
211
  class UnscopedLocator < BaseLocator
153
- def locate(gid)
212
+ def locate(gid, options = {})
154
213
  unscoped(gid.model_class) { super }
155
214
  end
156
215
 
@@ -167,19 +226,20 @@ class GlobalID
167
226
  end
168
227
  end
169
228
  end
170
- DEFAULT_LOCATOR = UnscopedLocator.new
229
+
230
+ self.default_locator = UnscopedLocator.new
171
231
 
172
232
  class BlockLocator
173
233
  def initialize(block)
174
234
  @locator = block
175
235
  end
176
236
 
177
- def locate(gid)
178
- @locator.call(gid)
237
+ def locate(gid, options = {})
238
+ @locator.call(gid, options)
179
239
  end
180
240
 
181
241
  def locate_many(gids, options = {})
182
- gids.map { |gid| locate(gid) }
242
+ gids.map { |gid| locate(gid, options) }
183
243
  end
184
244
  end
185
245
  end
@@ -42,6 +42,10 @@ class GlobalID
42
42
  send :extend, GlobalID::FixtureSet
43
43
  end
44
44
  end
45
+
46
+ initializer "web_console.deprecator" do |app|
47
+ app.deprecators[:global_id] = GlobalID.deprecator if app.respond_to?(:deprecators)
48
+ end
45
49
  end
46
50
  end
47
51
 
@@ -5,7 +5,7 @@ class SignedGlobalID < GlobalID
5
5
  class ExpiredMessage < StandardError; end
6
6
 
7
7
  class << self
8
- attr_accessor :verifier
8
+ attr_accessor :verifier, :expires_in
9
9
 
10
10
  def parse(sgid, options = {})
11
11
  super verify(sgid.to_s, options), options
@@ -19,8 +19,6 @@ class SignedGlobalID < GlobalID
19
19
  end
20
20
  end
21
21
 
22
- attr_accessor :expires_in
23
-
24
22
  DEFAULT_PURPOSE = "default"
25
23
 
26
24
  def pick_purpose(options)
@@ -29,11 +27,22 @@ class SignedGlobalID < GlobalID
29
27
 
30
28
  private
31
29
  def verify(sgid, options)
30
+ verify_with_verifier_validated_metadata(sgid, options) ||
31
+ verify_with_legacy_self_validated_metadata(sgid, options)
32
+ end
33
+
34
+ def verify_with_verifier_validated_metadata(sgid, options)
35
+ pick_verifier(options).verify(sgid, purpose: pick_purpose(options))
36
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
37
+ nil
38
+ end
39
+
40
+ def verify_with_legacy_self_validated_metadata(sgid, options)
32
41
  metadata = pick_verifier(options).verify(sgid)
33
42
 
34
43
  raise_if_expired(metadata['expires_at'])
35
44
 
36
- metadata['gid'] if pick_purpose(options) == metadata['purpose']
45
+ metadata['gid'] if pick_purpose(options)&.to_s == metadata['purpose']&.to_s
37
46
  rescue ActiveSupport::MessageVerifier::InvalidSignature, ExpiredMessage
38
47
  nil
39
48
  end
@@ -55,25 +64,19 @@ class SignedGlobalID < GlobalID
55
64
  end
56
65
 
57
66
  def to_s
58
- @sgid ||= @verifier.generate(to_h)
67
+ @sgid ||= @verifier.generate(@uri.to_s, purpose: purpose, expires_at: expires_at)
59
68
  end
60
69
  alias to_param to_s
61
70
 
62
- def to_h
63
- # Some serializers decodes symbol keys to symbols, others to strings.
64
- # Using string keys remedies that.
65
- { 'gid' => @uri.to_s, 'purpose' => purpose, 'expires_at' => encoded_expiration }
66
- end
67
-
68
71
  def ==(other)
69
72
  super && @purpose == other.purpose
70
73
  end
71
74
 
72
- private
73
- def encoded_expiration
74
- expires_at.utc.iso8601(3) if expires_at
75
- end
75
+ def inspect # :nodoc:
76
+ "#<#{self.class.name}:#{'%#016x' % (object_id << 1)}>"
77
+ end
76
78
 
79
+ private
77
80
  def pick_expiration(options)
78
81
  return options[:expires_at] if options.key?(:expires_at)
79
82
 
@@ -30,6 +30,13 @@ module URI
30
30
 
31
31
  # Raised when creating a Global ID for a model without an id
32
32
  class MissingModelIdError < URI::InvalidComponentError; end
33
+ class InvalidModelIdError < URI::InvalidComponentError; end
34
+
35
+ # Maximum size of a model id segment
36
+ COMPOSITE_MODEL_ID_MAX_SIZE = 20
37
+ COMPOSITE_MODEL_ID_DELIMITER = "/"
38
+
39
+ URI_PARSER = URI::RFC2396_Parser.new # :nodoc:
33
40
 
34
41
  class << self
35
42
  # Validates +app+'s as URI hostnames containing only alphanumeric characters
@@ -57,7 +64,7 @@ module URI
57
64
  # URI.parse('gid://bcx') # => URI::GID instance
58
65
  # URI::GID.parse('gid://bcx/') # => raises URI::InvalidComponentError
59
66
  def parse(uri)
60
- generic_components = URI.split(uri) << nil << true # nil parser, true arg_check
67
+ generic_components = URI.split(uri) << URI_PARSER << true # RFC2396 parser, true arg_check
61
68
  new(*generic_components)
62
69
  end
63
70
 
@@ -83,7 +90,8 @@ module URI
83
90
  def build(args)
84
91
  parts = Util.make_components_hash(self, args)
85
92
  parts[:host] = parts[:app]
86
- parts[:path] = "/#{parts[:model_name]}/#{CGI.escape(parts[:model_id].to_s)}"
93
+ model_id_segment = Array(parts[:model_id]).map { |p| CGI.escape(p.to_s) }.join(COMPOSITE_MODEL_ID_DELIMITER)
94
+ parts[:path] = "/#{parts[:model_name]}/#{model_id_segment}"
87
95
 
88
96
  if parts[:params] && !parts[:params].empty?
89
97
  parts[:query] = URI.encode_www_form(parts[:params])
@@ -147,12 +155,22 @@ module URI
147
155
 
148
156
  def set_model_components(path, validate = false)
149
157
  _, model_name, model_id = path.split('/', 3)
150
- validate_component(model_name) && validate_model_id(model_id, model_name) if validate
151
-
152
- model_id = CGI.unescape(model_id) if model_id
153
158
 
159
+ validate_component(model_name) && validate_model_id_section(model_id, model_name) if validate
154
160
  @model_name = model_name
155
- @model_id = model_id
161
+
162
+ if model_id
163
+ model_id_parts = model_id
164
+ .split(COMPOSITE_MODEL_ID_DELIMITER, COMPOSITE_MODEL_ID_MAX_SIZE)
165
+ .reject(&:blank?)
166
+
167
+ model_id_parts.map! do |id|
168
+ validate_model_id(id)
169
+ CGI.unescape(id)
170
+ end
171
+
172
+ @model_id = model_id_parts.length == 1 ? model_id_parts.first : model_id_parts
173
+ end
156
174
  end
157
175
 
158
176
  def validate_component(component)
@@ -162,13 +180,20 @@ module URI
162
180
  "Expected a URI like gid://app/Person/1234: #{inspect}"
163
181
  end
164
182
 
165
- def validate_model_id(model_id, model_name)
166
- return model_id unless model_id.blank? || model_id.include?('/')
183
+ def validate_model_id_section(model_id, model_name)
184
+ return model_id unless model_id.blank?
167
185
 
168
186
  raise MissingModelIdError, "Unable to create a Global ID for " \
169
187
  "#{model_name} without a model id."
170
188
  end
171
189
 
190
+ def validate_model_id(model_id_part)
191
+ return unless model_id_part.include?('/')
192
+
193
+ raise InvalidModelIdError, "Unable to create a Global ID for " \
194
+ "#{model_name} with a malformed model id."
195
+ end
196
+
172
197
  def parse_query_params(query)
173
198
  Hash[URI.decode_www_form(query)].with_indifferent_access if query
174
199
  end
@@ -3,11 +3,11 @@ require 'active_support/message_verifier'
3
3
  class GlobalID
4
4
  class Verifier < ActiveSupport::MessageVerifier
5
5
  private
6
- def encode(data)
6
+ def encode(data, **)
7
7
  ::Base64.urlsafe_encode64(data)
8
8
  end
9
9
 
10
- def decode(data)
10
+ def decode(data, **)
11
11
  ::Base64.urlsafe_decode64(data)
12
12
  end
13
13
  end
data/lib/global_id.rb CHANGED
@@ -16,4 +16,8 @@ class GlobalID
16
16
  super
17
17
  require 'global_id/signed_global_id'
18
18
  end
19
+
20
+ def self.deprecator # :nodoc:
21
+ @deprecator ||= ActiveSupport::Deprecation.new("2.1", "GlobalID")
22
+ end
19
23
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: globalid
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Heinemeier Hansson
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2023-01-25 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: activesupport
@@ -16,14 +15,14 @@ dependencies:
16
15
  requirements:
17
16
  - - ">="
18
17
  - !ruby/object:Gem::Version
19
- version: '5.0'
18
+ version: '6.1'
20
19
  type: :runtime
21
20
  prerelease: false
22
21
  version_requirements: !ruby/object:Gem::Requirement
23
22
  requirements:
24
23
  - - ">="
25
24
  - !ruby/object:Gem::Version
26
- version: '5.0'
25
+ version: '6.1'
27
26
  - !ruby/object:Gem::Dependency
28
27
  name: rake
29
28
  requirement: !ruby/object:Gem::Requirement
@@ -60,8 +59,11 @@ homepage: http://www.rubyonrails.org
60
59
  licenses:
61
60
  - MIT
62
61
  metadata:
62
+ bug_tracker_uri: https://github.com/rails/globalid/issues
63
+ changelog_uri: https://github.com/rails/globalid/releases/tag/v1.3.0
64
+ mailing_list_uri: https://discuss.rubyonrails.org/c/rubyonrails-talk
65
+ source_code_uri: https://github.com/rails/globalid/tree/v1.3.0
63
66
  rubygems_mfa_required: 'true'
64
- post_install_message:
65
67
  rdoc_options: []
66
68
  require_paths:
67
69
  - lib
@@ -69,15 +71,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
69
71
  requirements:
70
72
  - - ">="
71
73
  - !ruby/object:Gem::Version
72
- version: 2.5.0
74
+ version: 2.7.0
73
75
  required_rubygems_version: !ruby/object:Gem::Requirement
74
76
  requirements:
75
77
  - - ">="
76
78
  - !ruby/object:Gem::Version
77
79
  version: '0'
78
80
  requirements: []
79
- rubygems_version: 3.4.1
80
- signing_key:
81
+ rubygems_version: 3.6.7
81
82
  specification_version: 4
82
83
  summary: 'Refer to any model with a URI: gid://app/class/id'
83
84
  test_files: []