globalid 1.1.0 → 1.4.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: d57eeec2571a0ec07f96c46e09a1f2513958e798069847b6001c08088ebcb69c
4
+ data.tar.gz: d6585f62af8ae463201fdc6994d9f816c71e793dffb00803561e53bde284711e
5
5
  SHA512:
6
- metadata.gz: 49ff5a4b4d4f68afdffa67a85b5aebfb3b2f518257ffec22be8a60a29ac5596a67552aae46bebab28a4fe6b37a5802ac520b0f567098617110b2f54c4a74cfcb
7
- data.tar.gz: f6ba35dc2c080b0c996bd4529cec1438a35545c693cc0271e25c6b7bdaa8f759de61f26ae6ecf16487d89c7e6e5afc41caab7114541cd8a54ebdf9e2a3ce1f60
6
+ metadata.gz: 3fdc67028c552eb91fed9fc35031c5dec4215090260c21ce3d4d10b2db511be531162f3f93f7710ad15534e747a5610e0f9ecab5bed5c9fc5a45f30ee9936dd8
7
+ data.tar.gz: 2d07eeee13b8337cea053b4c67ba52dd5a9f05ff80e53b123d97364cf3dc0bf32654ff64c4a3f882fa69a44c960d2e11a2f7bb6a4edaf717331ebaf8ebe65528
data/README.md CHANGED
@@ -37,6 +37,23 @@ GlobalID::Locator.locate person_gid
37
37
  # => #<Person:0x007fae94bf6298 @id="1">
38
38
  ```
39
39
 
40
+ `locate` returns `nil` for a blank or unparseable Global ID, and lets the
41
+ backend's own exceptions bubble up when a record can't be found. Use `fetch`
42
+ when you want to tell apart a record that's gone for good from a transient
43
+ backend failure:
44
+
45
+ ```ruby
46
+ GlobalID::Locator.fetch person_gid
47
+ # => #<Person:0x007fae94bf6298 @id="1"> # found
48
+ # => raises GlobalID::Locator::RecordNotFound # the record no longer exists
49
+ # => raises GlobalID::Locator::RecordUnavailable # the backend failed; retry may succeed
50
+ ```
51
+
52
+ Both errors extend `GlobalID::Locator::Error`, so you can rescue either at
53
+ once. This is useful, for example, to discard a background job whose argument
54
+ points at a deleted record, without also discarding jobs that hit a temporary
55
+ database error.
56
+
40
57
  ### Signed Global IDs
41
58
 
42
59
  For added security GlobalIDs can also be signed to ensure that the data hasn't been tampered with.
@@ -57,7 +74,7 @@ GlobalID::Locator.locate_signed person_sgid
57
74
 
58
75
  **Expiration**
59
76
 
60
- Signed Global IDs can expire some time in the future. This is useful if there's a resource
77
+ Signed Global IDs can expire sometime in the future. This is useful if there's a resource
61
78
  people shouldn't have indefinite access to, like a share link.
62
79
 
63
80
  ```ruby
@@ -73,8 +90,8 @@ GlobalID::Locator.locate_signed(expiring_sgid.to_s, for: 'sharing')
73
90
  # => nil
74
91
  ```
75
92
 
76
- **In Rails, an auto-expiry of 1 month is set by default.** You can alter that deal
77
- in an initializer with:
93
+ **In Rails, an auto-expiry of 1 month is set by default.** You can alter that
94
+ default in an initializer with:
78
95
 
79
96
  ```ruby
80
97
  # config/initializers/global_id.rb
@@ -87,7 +104,7 @@ You can assign a default SGID lifetime like so:
87
104
  SignedGlobalID.expires_in = 1.month
88
105
  ```
89
106
 
90
- This way any generated SGID will use that relative expiry.
107
+ This way, any generated SGID will use that relative expiry.
91
108
 
92
109
  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
110
 
@@ -161,6 +178,25 @@ GlobalID::Locator.locate_many gids
161
178
 
162
179
  Note the order is maintained in the returned results.
163
180
 
181
+ ### Options
182
+
183
+ Either `GlobalID::Locator.locate` or `GlobalID::Locator.locate_many` supports a hash of options as second parameter. The supported options are:
184
+
185
+ * `:includes` - A Symbol, Array, Hash or combination of them.
186
+ The same structure you would pass into an `includes` method of Active Record.
187
+ See [Active Record eager loading associations](https://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations).
188
+ If present, `locate` or `locate_many` will eager load all the relationships specified here.
189
+ Note: It only works if all the GIDs Models have those relationships.
190
+ * `:only` - A class, module, or Array of classes and/or modules that are
191
+ allowed to be located. Passing one or more classes limits instances of returned
192
+ classes to those classes or their subclasses. Passing one or more modules in limits
193
+ instances of returned classes to those including that module. If no classes or
194
+ modules match, `nil` is returned.
195
+ * `:ignore_missing` (Only for `locate_many`) - By default, `locate_many` will call `#find` on the model to locate the
196
+ ids extracted from the GIDs. In Active Record (and other data stores following the same pattern),
197
+ `#find` will raise an exception if a named ID can't be found. When you set this option to `true`,
198
+ we will use `#where(id: ids)` instead, which does not raise on missing records.
199
+
164
200
  ### Custom App Locator
165
201
 
166
202
  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 +208,7 @@ A custom locator can either be a block or a class.
172
208
  Using a block:
173
209
 
174
210
  ```ruby
175
- GlobalID::Locator.use :foo do |gid|
211
+ GlobalID::Locator.use :foo do |gid, options|
176
212
  FooRemote.const_get(gid.model_name).find(gid.model_id)
177
213
  end
178
214
  ```
@@ -180,17 +216,85 @@ end
180
216
  Using a class:
181
217
 
182
218
  ```ruby
183
- GlobalID::Locator.use :bar, BarLocator.new
184
219
  class BarLocator
185
- def locate(gid)
220
+ def locate(gid, options = {})
186
221
  @search_client.search name: gid.model_name, id: gid.model_id
187
222
  end
188
223
  end
224
+
225
+ GlobalID::Locator.use :bar, BarLocator.new
189
226
  ```
190
227
 
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.
228
+ It's recommended to inherit from `GlobalID::Locator::BaseLocator` (or `GlobalID::Locator::UnscopedLocator` for Active Record models) to get default implementations of `model_class` and `locate_many`:
229
+
230
+ ```ruby
231
+ class BarLocator < GlobalID::Locator::BaseLocator
232
+ def locate(gid, options = {})
233
+ @search_client.search name: gid.model_name, id: gid.model_id
234
+ end
235
+ end
236
+
237
+ GlobalID::Locator.use :bar, BarLocator.new
238
+ ```
239
+
240
+ 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
241
  Other apps will still keep using the default locator.
193
242
 
243
+ #### Custom Model Class Derivation
244
+
245
+ By default, GlobalID derives the model class by calling `constantize` on the model name from the GID. Custom locators can override this behavior by implementing a `model_class` method. This is useful when the model name in the GID doesn't match the actual class name, or when you want to redirect to a different model.
246
+
247
+ Inherit from `BaseLocator` and override `model_class`:
248
+
249
+ ```ruby
250
+ class RemoteLocator < GlobalID::Locator::BaseLocator
251
+ def model_class(gid)
252
+ # Map remote model names to local models
253
+ case gid.model_name
254
+ when 'User'
255
+ RemoteUser
256
+ when 'Profile'
257
+ RemoteProfile
258
+ else
259
+ super # Fall back to default constantize behavior
260
+ end
261
+ end
262
+
263
+ def locate(gid, options = {})
264
+ # Use the mapped model class to find the record
265
+ model_class(gid).find_by(remote_id: gid.model_id)
266
+ end
267
+ end
268
+
269
+ GlobalID::Locator.use :remote, RemoteLocator.new
270
+ ```
271
+
272
+ This allows you to work with Global IDs that reference models that don't exist in your application, redirecting them to the appropriate local models.
273
+
274
+ **Note**: For backward compatibility, if a custom locator doesn't implement `model_class`, GlobalID will fall back to the default behavior (`constantize`) but will emit a deprecation warning. To avoid this, inherit from `GlobalID::Locator::BaseLocator` or `GlobalID::Locator::UnscopedLocator`.
275
+
276
+ ### Custom Default Locator
277
+
278
+ 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.
279
+
280
+ ```ruby
281
+ class MyCustomLocator < UnscopedLocator
282
+ def locate(gid, options = {})
283
+ ActiveRecord::Base.connected_to(role: :reading) do
284
+ super(gid, options)
285
+ end
286
+ end
287
+
288
+ def locate_many(gids, options = {})
289
+ ActiveRecord::Base.connected_to(role: :reading) do
290
+ super(gids, options)
291
+ end
292
+ end
293
+ end
294
+
295
+ GlobalID::Locator.default_locator = MyCustomLocator.new
296
+ ```
297
+
194
298
  ## Contributing to GlobalID
195
299
 
196
300
  GlobalID is work of many contributors. You're encouraged to submit pull requests, propose
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'active_support/core_ext/string/inflections' # For #model_class constantize
2
3
  require 'active_support/core_ext/array/access'
3
4
  require 'active_support/core_ext/object/try' # For #find
@@ -32,6 +33,10 @@ class GlobalID
32
33
  @app = URI::GID.validate_app(app)
33
34
  end
34
35
 
36
+ def default_locator(default_locator)
37
+ Locator.default_locator = default_locator
38
+ end
39
+
35
40
  private
36
41
  def parse_encoded_gid(gid, options)
37
42
  new(Base64.urlsafe_decode64(gid), options) rescue nil
@@ -50,12 +55,26 @@ class GlobalID
50
55
  end
51
56
 
52
57
  def model_class
53
- model = model_name.constantize
54
-
55
- unless model <= GlobalID
58
+ @model_class ||= begin
59
+ locator = Locator.locator_for(self)
60
+ model = begin
61
+ locator.model_class(self)
62
+ rescue NoMethodError
63
+ if locator.respond_to?(:model_class)
64
+ raise
65
+ else
66
+ GlobalID.deprecator.warn <<~MSG.squish
67
+ Your locator #{locator.class.name} does not implement the
68
+ `model_class` method. Please add a `model_class(gid)` method
69
+ to your locator or inherit from `GlobalID::Locator::BaseLocator`.
70
+ MSG
71
+ model_name.constantize
72
+ end
73
+ end
74
+ if model <= GlobalID
75
+ raise ArgumentError, "GlobalID and SignedGlobalID cannot be used as model_class."
76
+ end
56
77
  model
57
- else
58
- raise ArgumentError, "GlobalID and SignedGlobalID cannot be used as model_class."
59
78
  end
60
79
  end
61
80
 
@@ -1,19 +1,119 @@
1
+ # frozen_string_literal: true
1
2
  class GlobalID
3
+ # Mix `GlobalID::Identification` into any model with a `#find(id)` class
4
+ # method. Support is automatically included in Active Record.
5
+ #
6
+ # class Person
7
+ # include ActiveModel::Model
8
+ # include GlobalID::Identification
9
+ #
10
+ # attr_accessor :id
11
+ #
12
+ # def self.find(id)
13
+ # new id: id
14
+ # end
15
+ #
16
+ # def ==(other)
17
+ # id == other.try(:id)
18
+ # end
19
+ # end
20
+ #
21
+ # person_gid = Person.find(1).to_global_id
22
+ # # => #<GlobalID ...
23
+ # person_gid.uri
24
+ # # => #<URI ...
25
+ # person_gid.to_s
26
+ # # => "gid://app/Person/1"
27
+ # GlobalID::Locator.locate person_gid
28
+ # # => #<Person:0x007fae94bf6298 @id="1">
2
29
  module Identification
30
+
31
+ # Returns the Global ID of the model.
32
+ #
33
+ # model = Person.new id: 1
34
+ # global_id = model.to_global_id
35
+ # global_id.model_class # => Person
36
+ # global_id.model_id # => "1"
37
+ # global_id.to_param # => "Z2lkOi8vYm9yZGZvbGlvL1BlcnNvbi8x"
3
38
  def to_global_id(options = {})
4
39
  GlobalID.create(self, options)
5
40
  end
6
41
  alias to_gid to_global_id
7
42
 
43
+ # Returns the Global ID parameter of the model.
44
+ #
45
+ # model = Person.new id: 1
46
+ # model.to_gid_param # => ""Z2lkOi8vYm9yZGZvbGlvL1BlcnNvbi8x"
8
47
  def to_gid_param(options = {})
9
48
  to_global_id(options).to_param
10
49
  end
11
50
 
51
+ # Returns the Signed Global ID of the model.
52
+ # Signed Global IDs ensure that the data hasn't been tampered with.
53
+ #
54
+ # model = Person.new id: 1
55
+ # signed_global_id = model.to_signed_global_id
56
+ # signed_global_id.model_class # => Person
57
+ # signed_global_id.model_id # => "1"
58
+ # signed_global_id.to_param # => "BAh7CEkiCGdpZAY6BkVUSSIiZ2..."
59
+ #
60
+ # ==== Expiration
61
+ #
62
+ # Signed Global IDs can expire some time in the future. This is useful if
63
+ # there's a resource people shouldn't have indefinite access to, like a
64
+ # share link.
65
+ #
66
+ # expiring_sgid = Document.find(5).to_sgid(expires_in: 2.hours, for: 'sharing')
67
+ # # => #<SignedGlobalID:0x008fde45df8937 ...>
68
+ # # Within 2 hours...
69
+ # GlobalID::Locator.locate_signed(expiring_sgid.to_s, for: 'sharing')
70
+ # # => #<Document:0x007fae94bf6298 @id="5">
71
+ # # More than 2 hours later...
72
+ # GlobalID::Locator.locate_signed(expiring_sgid.to_s, for: 'sharing')
73
+ # # => nil
74
+ #
75
+ # In Rails, an auto-expiry of 1 month is set by default.
76
+ #
77
+ # You need to explicitly pass `expires_in: nil` to generate a permanent
78
+ # SGID that will not expire,
79
+ #
80
+ # never_expiring_sgid = Document.find(5).to_sgid(expires_in: nil)
81
+ # # => #<SignedGlobalID:0x008fde45df8937 ...>
82
+ #
83
+ # # Any time later...
84
+ # GlobalID::Locator.locate_signed never_expiring_sgid
85
+ # # => #<Document:0x007fae94bf6298 @id="5">
86
+ #
87
+ # It's also possible to pass a specific expiry time
88
+ #
89
+ # explicit_expiring_sgid = SecretAgentMessage.find(5).to_sgid(expires_at: Time.now.advance(hours: 1))
90
+ # # => #<SignedGlobalID:0x008fde45df8937 ...>
91
+ #
92
+ # # 1 hour later...
93
+ # GlobalID::Locator.locate_signed explicit_expiring_sgid.to_s
94
+ # # => nil
95
+ #
96
+ # Note that an explicit `:expires_at` takes precedence over a relative `:expires_in`.
97
+ #
98
+ # ==== Purpose
99
+ #
100
+ # You can even bump the security up some more by explaining what purpose a
101
+ # Signed Global ID is for. In this way evildoers can't reuse a sign-up
102
+ # form's SGID on the login page. For example.
103
+ #
104
+ # signup_person_sgid = Person.find(1).to_sgid(for: 'signup_form')
105
+ # # => #<SignedGlobalID:0x007fea1984b520
106
+ # GlobalID::Locator.locate_signed(signup_person_sgid.to_s, for: 'signup_form')
107
+ # => #<Person:0x007fae94bf6298 @id="1">
12
108
  def to_signed_global_id(options = {})
13
109
  SignedGlobalID.create(self, options)
14
110
  end
15
111
  alias to_sgid to_signed_global_id
16
112
 
113
+ # Returns the Signed Global ID parameter.
114
+ #
115
+ # model = Person.new id: 1
116
+ # model.to_sgid_param # => "BAh7CEkiCGdpZAY6BkVUSSIiZ2..."
17
117
  def to_sgid_param(options = {})
18
118
  to_signed_global_id(options).to_param
19
119
  end
@@ -1,22 +1,81 @@
1
+ # frozen_string_literal: true
1
2
  require 'active_support/core_ext/enumerable' # For Enumerable#index_by
2
3
 
3
4
  class GlobalID
4
5
  module Locator
6
+ class InvalidModelIdError < StandardError; end
7
+ class Error < StandardError; end
8
+
9
+ # Raised by GlobalID::Locator.fetch when the GlobalID is valid but the
10
+ # record it references no longer exists. The record is gone for good, so
11
+ # retrying won't help.
12
+ class RecordNotFound < Error; end
13
+
14
+ # Raised by GlobalID::Locator.fetch when the record couldn't be located
15
+ # due to any other error in the backend, such as a database connection
16
+ # error. The record may still exist, so retrying may succeed.
17
+ class RecordUnavailable < Error; end
18
+
5
19
  class << self
20
+ # The default locator used when no app-specific locator is found.
21
+ attr_accessor :default_locator
22
+
6
23
  # Takes either a GlobalID or a string that can be turned into a GlobalID
7
24
  #
8
25
  # Options:
26
+ # * <tt>:includes</tt> - A Symbol, Array, Hash or combination of them.
27
+ # The same structure you would pass into a +includes+ method of Active Record.
28
+ # If present, locate will load all the relationships specified here.
29
+ # See https://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations.
9
30
  # * <tt>:only</tt> - A class, module or Array of classes and/or modules that are
10
31
  # allowed to be located. Passing one or more classes limits instances of returned
11
32
  # classes to those classes or their subclasses. Passing one or more modules in limits
12
33
  # instances of returned classes to those including that module. If no classes or
13
34
  # modules match, +nil+ is returned.
14
35
  def locate(gid, options = {})
15
- if gid = GlobalID.parse(gid)
16
- locator_for(gid).locate gid if find_allowed?(gid.model_class, options[:only])
36
+ gid = GlobalID.parse(gid)
37
+
38
+ return unless gid && find_allowed?(gid, options[:only])
39
+
40
+ locator = locator_for(gid)
41
+
42
+ if locator.method(:locate).arity == 1
43
+ 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 = {})`."
44
+ locator.locate(gid)
45
+ else
46
+ locator.locate(gid, options.except(:only))
17
47
  end
18
48
  end
19
49
 
50
+ # Like .locate, but instead of returning +nil+ or leaking the backend's
51
+ # own exceptions when the record can't be returned, it raises one of two
52
+ # GlobalID-specific errors so callers can tell the cases apart:
53
+ #
54
+ # * GlobalID::Locator::RecordNotFound when the record no longer exists.
55
+ # Retrying won't help.
56
+ # * GlobalID::Locator::RecordUnavailable when the record couldn't be
57
+ # located due to any other failure in the backend, like a database
58
+ # connection error. The record may still exist, so retrying may succeed.
59
+ #
60
+ # The distinction is drawn without knowing the backend's exception
61
+ # classes: the record is looked up through a query that doesn't raise on
62
+ # missing records (like .locate_many's +:ignore_missing+), so an empty
63
+ # result means the record is gone, while an error from the query itself
64
+ # means the backend is unavailable.
65
+ #
66
+ # Returns +nil+ for a blank or unparseable GlobalID, or one disallowed by
67
+ # the +:only+ option, just like .locate, and accepts the same options.
68
+ #
69
+ # Note: custom locators registered with .use need to implement locate_many
70
+ # with support for the +:ignore_missing+ option for this method to work.
71
+ def fetch(gid, options = {})
72
+ gid = GlobalID.parse(gid)
73
+
74
+ return unless gid && find_allowed?(gid, options[:only])
75
+
76
+ fetch_record(gid, options.except(:only)) or raise RecordNotFound, "Couldn't find record for #{gid}"
77
+ end
78
+
20
79
  # Takes an array of GlobalIDs or strings that can be turned into a GlobalIDs.
21
80
  # All GlobalIDs must belong to the same app, as they will be located using
22
81
  # the same locator using its locate_many method.
@@ -28,6 +87,11 @@ class GlobalID
28
87
  # per model class, but still interpolate the results to match the order in which the gids were passed.
29
88
  #
30
89
  # Options:
90
+ # * <tt>:includes</tt> - A Symbol, Array, Hash or combination of them
91
+ # The same structure you would pass into a includes method of Active Record.
92
+ # @see https://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations
93
+ # If present, locate_many will load all the relationships specified here.
94
+ # Note: It only works if all the gids models have that relationships.
31
95
  # * <tt>:only</tt> - A class, module or Array of classes and/or modules that are
32
96
  # allowed to be located. Passing one or more classes limits instances of returned
33
97
  # classes to those classes or their subclasses. Passing one or more modules in limits
@@ -49,6 +113,10 @@ class GlobalID
49
113
  # Takes either a SignedGlobalID or a string that can be turned into a SignedGlobalID
50
114
  #
51
115
  # Options:
116
+ # * <tt>:includes</tt> - A Symbol, Array, Hash or combination of them
117
+ # The same structure you would pass into a includes method of Active Record.
118
+ # @see https://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations
119
+ # If present, locate_signed will load all the relationships specified here.
52
120
  # * <tt>:only</tt> - A class, module or Array of classes and/or modules that are
53
121
  # allowed to be located. Passing one or more classes limits instances of returned
54
122
  # classes to those classes or their subclasses. Passing one or more modules in limits
@@ -66,6 +134,11 @@ class GlobalID
66
134
  # the results to match the order in which the gids were passed.
67
135
  #
68
136
  # Options:
137
+ # * <tt>:includes</tt> - A Symbol, Array, Hash or combination of them
138
+ # The same structure you would pass into a includes method of Active Record.
139
+ # @see https://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations
140
+ # If present, locate_many_signed will load all the relationships specified here.
141
+ # Note: It only works if all the gids models have that relationships.
69
142
  # * <tt>:only</tt> - A class, module or Array of classes and/or modules that are
70
143
  # allowed to be located. Passing one or more classes limits instances of returned
71
144
  # classes to those classes or their subclasses. Passing one or more modules in limits
@@ -82,7 +155,7 @@ class GlobalID
82
155
  #
83
156
  # Using a block:
84
157
  #
85
- # GlobalID::Locator.use :foo do |gid|
158
+ # GlobalID::Locator.use :foo do |gid, options|
86
159
  # FooRemote.const_get(gid.model_name).find(gid.model_id)
87
160
  # end
88
161
  #
@@ -91,7 +164,7 @@ class GlobalID
91
164
  # GlobalID::Locator.use :bar, BarLocator.new
92
165
  #
93
166
  # class BarLocator
94
- # def locate(gid)
167
+ # def locate(gid, options = {})
95
168
  # @search_client.search name: gid.model_name, id: gid.model_id
96
169
  # end
97
170
  # end
@@ -103,17 +176,23 @@ class GlobalID
103
176
  @locators[normalize_app(app)] = locator || BlockLocator.new(locator_block)
104
177
  end
105
178
 
179
+ def locator_for(gid)
180
+ @locators.fetch(normalize_app(gid.app)) { default_locator }
181
+ end
182
+
106
183
  private
107
- def locator_for(gid)
108
- @locators.fetch(normalize_app(gid.app)) { DEFAULT_LOCATOR }
184
+ def find_allowed?(gid, only = nil)
185
+ only ? Array(only).any? { |c| gid.model_class <= c } : true
109
186
  end
110
187
 
111
- def find_allowed?(model_class, only = nil)
112
- only ? Array(only).any? { |c| model_class <= c } : true
188
+ def fetch_record(gid, options)
189
+ locator_for(gid).locate_many([gid], options.merge(ignore_missing: true)).first
190
+ rescue => error
191
+ raise RecordUnavailable, "Couldn't fetch record for #{gid}: #{error.message}"
113
192
  end
114
193
 
115
194
  def parse_allowed(gids, only = nil)
116
- gids.collect { |gid| GlobalID.parse(gid) }.compact.select { |gid| find_allowed?(gid.model_class, only) }
195
+ gids.collect { |gid| GlobalID.parse(gid) }.compact.select { |gid| find_allowed?(gid, only) }
117
196
  end
118
197
 
119
198
  def normalize_app(app)
@@ -125,32 +204,63 @@ class GlobalID
125
204
  @locators = {}
126
205
 
127
206
  class BaseLocator
128
- def locate(gid)
129
- gid.model_class.find gid.model_id
207
+ def model_class(gid)
208
+ gid.model_name.constantize
209
+ end
210
+
211
+ def locate(gid, options = {})
212
+ return unless model_id_is_valid?(gid)
213
+ model_class = gid.model_class
214
+ model_class = model_class.includes(options[:includes]) if options[:includes]
215
+
216
+ model_class.find gid.model_id
130
217
  end
131
218
 
132
219
  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
- }]
220
+ ids_by_model = Hash.new { |hash, key| hash[key] = [] }
221
+
222
+ gids.each do |gid|
223
+ next unless model_id_is_valid?(gid)
224
+ ids_by_model[gid.model_class] << gid.model_id
225
+ end
226
+
227
+ records_by_model_name_and_id = {}
228
+
229
+ ids_by_model.each do |model, ids|
230
+ records = find_records(model, ids, ignore_missing: options[:ignore_missing], includes: options[:includes])
231
+
232
+ records_by_id = records.index_by do |record|
233
+ record.id.is_a?(Array) ? record.id.map(&:to_s) : record.id.to_s
234
+ end
235
+
236
+ records_by_model_name_and_id[model.name] = records_by_id
237
+ end
138
238
 
139
- models_and_ids.collect { |(model, id)| loaded_by_model[model][id] }.compact
239
+ gids.filter_map { |gid| records_by_model_name_and_id[gid.model_name][gid.model_id] }
140
240
  end
141
241
 
142
242
  private
143
243
  def find_records(model_class, ids, options)
244
+ model_class = model_class.includes(options[:includes]) if options[:includes]
245
+
144
246
  if options[:ignore_missing]
145
- model_class.where(id: ids)
247
+ model_class.where(primary_key(model_class) => ids)
146
248
  else
147
249
  model_class.find(ids)
148
250
  end
149
251
  end
252
+
253
+ def model_id_is_valid?(gid)
254
+ Array(gid.model_id).size == Array(primary_key(gid.model_class)).size
255
+ end
256
+
257
+ def primary_key(model_class)
258
+ model_class.respond_to?(:primary_key) ? model_class.primary_key : :id
259
+ end
150
260
  end
151
261
 
152
262
  class UnscopedLocator < BaseLocator
153
- def locate(gid)
263
+ def locate(gid, options = {})
154
264
  unscoped(gid.model_class) { super }
155
265
  end
156
266
 
@@ -167,19 +277,24 @@ class GlobalID
167
277
  end
168
278
  end
169
279
  end
170
- DEFAULT_LOCATOR = UnscopedLocator.new
280
+
281
+ self.default_locator = UnscopedLocator.new
171
282
 
172
283
  class BlockLocator
173
284
  def initialize(block)
174
285
  @locator = block
175
286
  end
176
287
 
177
- def locate(gid)
178
- @locator.call(gid)
288
+ def model_class(gid)
289
+ gid.model_name.constantize
290
+ end
291
+
292
+ def locate(gid, options = {})
293
+ @locator.call(gid, options)
179
294
  end
180
295
 
181
296
  def locate_many(gids, options = {})
182
- gids.map { |gid| locate(gid) }
297
+ gids.map { |gid| locate(gid, options) }
183
298
  end
184
299
  end
185
300
  end
@@ -1,7 +1,5 @@
1
- begin
2
- require 'rails/railtie'
3
- rescue LoadError
4
- else
1
+ # frozen_string_literal: true
2
+ require 'rails'
5
3
  require 'global_id'
6
4
  require 'active_support/core_ext/string/inflections'
7
5
  require 'active_support/core_ext/integer/time'
@@ -42,7 +40,9 @@ class GlobalID
42
40
  send :extend, GlobalID::FixtureSet
43
41
  end
44
42
  end
45
- end
46
- end
47
43
 
44
+ initializer "web_console.deprecator" do |app|
45
+ app.deprecators[:global_id] = GlobalID.deprecator if app.respond_to?(:deprecators)
46
+ end
47
+ end
48
48
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'active_support/message_verifier'
2
3
  require 'time'
3
4
 
@@ -5,7 +6,7 @@ class SignedGlobalID < GlobalID
5
6
  class ExpiredMessage < StandardError; end
6
7
 
7
8
  class << self
8
- attr_accessor :verifier
9
+ attr_accessor :verifier, :expires_in
9
10
 
10
11
  def parse(sgid, options = {})
11
12
  super verify(sgid.to_s, options), options
@@ -19,8 +20,6 @@ class SignedGlobalID < GlobalID
19
20
  end
20
21
  end
21
22
 
22
- attr_accessor :expires_in
23
-
24
23
  DEFAULT_PURPOSE = "default"
25
24
 
26
25
  def pick_purpose(options)
@@ -29,11 +28,22 @@ class SignedGlobalID < GlobalID
29
28
 
30
29
  private
31
30
  def verify(sgid, options)
31
+ verify_with_verifier_validated_metadata(sgid, options) ||
32
+ verify_with_legacy_self_validated_metadata(sgid, options)
33
+ end
34
+
35
+ def verify_with_verifier_validated_metadata(sgid, options)
36
+ pick_verifier(options).verify(sgid, purpose: pick_purpose(options))
37
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
38
+ nil
39
+ end
40
+
41
+ def verify_with_legacy_self_validated_metadata(sgid, options)
32
42
  metadata = pick_verifier(options).verify(sgid)
33
43
 
34
44
  raise_if_expired(metadata['expires_at'])
35
45
 
36
- metadata['gid'] if pick_purpose(options) == metadata['purpose']
46
+ metadata['gid'] if pick_purpose(options)&.to_s == metadata['purpose']&.to_s
37
47
  rescue ActiveSupport::MessageVerifier::InvalidSignature, ExpiredMessage
38
48
  nil
39
49
  end
@@ -55,25 +65,19 @@ class SignedGlobalID < GlobalID
55
65
  end
56
66
 
57
67
  def to_s
58
- @sgid ||= @verifier.generate(to_h)
68
+ @sgid ||= @verifier.generate(@uri.to_s, purpose: purpose, expires_at: expires_at)
59
69
  end
60
70
  alias to_param to_s
61
71
 
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
72
  def ==(other)
69
73
  super && @purpose == other.purpose
70
74
  end
71
75
 
72
- private
73
- def encoded_expiration
74
- expires_at.utc.iso8601(3) if expires_at
75
- end
76
+ def inspect # :nodoc:
77
+ "#<#{self.class.name}:#{'%#016x' % (object_id << 1)}>"
78
+ end
76
79
 
80
+ private
77
81
  def pick_expiration(options)
78
82
  return options[:expires_at] if options.key?(:expires_at)
79
83
 
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'uri/generic'
2
3
  require 'active_support/core_ext/module/aliasing'
3
4
  require 'active_support/core_ext/object/blank'
@@ -30,13 +31,21 @@ module URI
30
31
 
31
32
  # Raised when creating a Global ID for a model without an id
32
33
  class MissingModelIdError < URI::InvalidComponentError; end
34
+ class InvalidModelIdError < URI::InvalidComponentError; end
35
+
36
+ # Maximum size of a model id segment
37
+ COMPOSITE_MODEL_ID_MAX_SIZE = 20
38
+ COMPOSITE_MODEL_ID_DELIMITER = "/"
39
+
40
+ URI_PARSER = URI::RFC3986_Parser.new # :nodoc:
33
41
 
34
42
  class << self
35
- # Validates +app+'s as URI hostnames containing only alphanumeric characters
36
- # and hyphens. An ArgumentError is raised if +app+ is invalid.
43
+ # Validates +app+'s as URI hostnames containing only alphanumeric characters,
44
+ # hyphens and dashes. An ArgumentError is raised if +app+ is invalid.
37
45
  #
38
46
  # URI::GID.validate_app('bcx') # => 'bcx'
39
47
  # URI::GID.validate_app('foo-bar') # => 'foo-bar'
48
+ # URI::GID.validate_app('foo_bar') # => 'foo_bar'
40
49
  #
41
50
  # URI::GID.validate_app(nil) # => ArgumentError
42
51
  # URI::GID.validate_app('foo/bar') # => ArgumentError
@@ -44,7 +53,7 @@ module URI
44
53
  parse("gid://#{app}/Model/1").app
45
54
  rescue URI::Error
46
55
  raise ArgumentError, 'Invalid app name. ' \
47
- 'App names must be valid URI hostnames: alphanumeric and hyphen characters only.'
56
+ 'App names must be valid URI hostnames: alphanumeric, hyphen and underscore characters only.'
48
57
  end
49
58
 
50
59
  # Create a new URI::GID by parsing a gid string with argument check.
@@ -57,7 +66,7 @@ module URI
57
66
  # URI.parse('gid://bcx') # => URI::GID instance
58
67
  # URI::GID.parse('gid://bcx/') # => raises URI::InvalidComponentError
59
68
  def parse(uri)
60
- generic_components = URI.split(uri) << nil << true # nil parser, true arg_check
69
+ generic_components = URI.split(uri) << URI_PARSER << true # RFC3986 parser, true arg_check
61
70
  new(*generic_components)
62
71
  end
63
72
 
@@ -83,7 +92,8 @@ module URI
83
92
  def build(args)
84
93
  parts = Util.make_components_hash(self, args)
85
94
  parts[:host] = parts[:app]
86
- parts[:path] = "/#{parts[:model_name]}/#{CGI.escape(parts[:model_id].to_s)}"
95
+ model_id_segment = Array(parts[:model_id]).map { |p| CGI.escape(p.to_s) }.join(COMPOSITE_MODEL_ID_DELIMITER)
96
+ parts[:path] = "/#{parts[:model_name]}/#{model_id_segment}"
87
97
 
88
98
  if parts[:params] && !parts[:params].empty?
89
99
  parts[:query] = URI.encode_www_form(parts[:params])
@@ -147,12 +157,22 @@ module URI
147
157
 
148
158
  def set_model_components(path, validate = false)
149
159
  _, 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
160
 
161
+ validate_component(model_name) && validate_model_id_section(model_id, model_name) if validate
154
162
  @model_name = model_name
155
- @model_id = model_id
163
+
164
+ if model_id
165
+ model_id_parts = model_id
166
+ .split(COMPOSITE_MODEL_ID_DELIMITER, COMPOSITE_MODEL_ID_MAX_SIZE)
167
+ .reject(&:blank?)
168
+
169
+ model_id_parts.map! do |id|
170
+ validate_model_id(id)
171
+ CGI.unescape(id)
172
+ end
173
+
174
+ @model_id = model_id_parts.length == 1 ? model_id_parts.first : model_id_parts
175
+ end
156
176
  end
157
177
 
158
178
  def validate_component(component)
@@ -162,13 +182,20 @@ module URI
162
182
  "Expected a URI like gid://app/Person/1234: #{inspect}"
163
183
  end
164
184
 
165
- def validate_model_id(model_id, model_name)
166
- return model_id unless model_id.blank? || model_id.include?('/')
185
+ def validate_model_id_section(model_id, model_name)
186
+ return model_id unless model_id.blank?
167
187
 
168
188
  raise MissingModelIdError, "Unable to create a Global ID for " \
169
189
  "#{model_name} without a model id."
170
190
  end
171
191
 
192
+ def validate_model_id(model_id_part)
193
+ return unless model_id_part.include?('/')
194
+
195
+ raise InvalidModelIdError, "Unable to create a Global ID for " \
196
+ "#{model_name} with a malformed model id."
197
+ end
198
+
172
199
  def parse_query_params(query)
173
200
  Hash[URI.decode_www_form(query)].with_indifferent_access if query
174
201
  end
@@ -1,13 +1,14 @@
1
+ # frozen_string_literal: true
1
2
  require 'active_support/message_verifier'
2
3
 
3
4
  class GlobalID
4
5
  class Verifier < ActiveSupport::MessageVerifier
5
6
  private
6
- def encode(data)
7
+ def encode(data, **)
7
8
  ::Base64.urlsafe_encode64(data)
8
9
  end
9
10
 
10
- def decode(data)
11
+ def decode(data, **)
11
12
  ::Base64.urlsafe_decode64(data)
12
13
  end
13
14
  end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ class GlobalID
3
+ VERSION = "1.4.0"
4
+ end
data/lib/global_id.rb CHANGED
@@ -1,4 +1,6 @@
1
+ # frozen_string_literal: true
1
2
  require 'active_support'
3
+ require 'global_id/version'
2
4
  require 'global_id/global_id'
3
5
 
4
6
  autoload :SignedGlobalID, 'global_id/signed_global_id'
@@ -16,4 +18,8 @@ class GlobalID
16
18
  super
17
19
  require 'global_id/signed_global_id'
18
20
  end
21
+
22
+ def self.deprecator # :nodoc:
23
+ @deprecator ||= ActiveSupport::Deprecation.new("2.1", "GlobalID")
24
+ end
19
25
  end
data/lib/globalid.rb CHANGED
@@ -1,2 +1,3 @@
1
+ # frozen_string_literal: true
1
2
  require 'global_id'
2
- require 'global_id/railtie'
3
+ require 'global_id/railtie' if defined?(Rails::Railtie)
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.4.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
@@ -38,6 +37,20 @@ dependencies:
38
37
  - - ">="
39
38
  - !ruby/object:Gem::Version
40
39
  version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: minitest
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "<"
45
+ - !ruby/object:Gem::Version
46
+ version: '6'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "<"
52
+ - !ruby/object:Gem::Version
53
+ version: '6'
41
54
  description: URIs for your models makes it easy to pass references around.
42
55
  email: david@loudthinking.com
43
56
  executables: []
@@ -55,13 +68,17 @@ files:
55
68
  - lib/global_id/signed_global_id.rb
56
69
  - lib/global_id/uri/gid.rb
57
70
  - lib/global_id/verifier.rb
71
+ - lib/global_id/version.rb
58
72
  - lib/globalid.rb
59
73
  homepage: http://www.rubyonrails.org
60
74
  licenses:
61
75
  - MIT
62
76
  metadata:
77
+ bug_tracker_uri: https://github.com/rails/globalid/issues
78
+ changelog_uri: https://github.com/rails/globalid/releases/tag/v1.4.0
79
+ mailing_list_uri: https://discuss.rubyonrails.org/c/rubyonrails-talk
80
+ source_code_uri: https://github.com/rails/globalid/tree/v1.4.0
63
81
  rubygems_mfa_required: 'true'
64
- post_install_message:
65
82
  rdoc_options: []
66
83
  require_paths:
67
84
  - lib
@@ -69,15 +86,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
69
86
  requirements:
70
87
  - - ">="
71
88
  - !ruby/object:Gem::Version
72
- version: 2.5.0
89
+ version: 2.7.0
73
90
  required_rubygems_version: !ruby/object:Gem::Requirement
74
91
  requirements:
75
92
  - - ">="
76
93
  - !ruby/object:Gem::Version
77
94
  version: '0'
78
95
  requirements: []
79
- rubygems_version: 3.4.1
80
- signing_key:
96
+ rubygems_version: 4.0.12
81
97
  specification_version: 4
82
98
  summary: 'Refer to any model with a URI: gid://app/class/id'
83
99
  test_files: []