globalid 1.0.1 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e5a7361595eea95f395b66f4a79123ad35e61b7008d256646bea775291fa9f41
4
- data.tar.gz: a9ece75bb440f6b49b8ad623b976bc05f4f3da2151104d045d1b7305c4a8d9b2
3
+ metadata.gz: 15140d3d09fd236ea096c178a473440eb3d2fab55503978d646f3f13a863589c
4
+ data.tar.gz: 65351562c30c6c4e68d5b21ac8a502a7f7f5c8995cf538844395d123c685afba
5
5
  SHA512:
6
- metadata.gz: 8290a4a79953bb92053c2f52ae847c557e7f5d4f0528124c7f1217ee4a34b5c7d42290a2584d729e0c5011c05cd1df4c9c073be49bde7d4fa13597f44a52513f
7
- data.tar.gz: 2d9e7c573f7223406733b8be7ce1d6d764cca584bcfbb0a3f2ffb8d6f71f82970ceacf827e9c8a251975c0681b5d7d8293c1e158927dbf38f362e1337fd027ce
6
+ metadata.gz: b2a642776d9233ad70854e49b1dde8287397b94321a158747abc6738c4a976a4bb93a5de9fa72d8e3f7792a5667cc01eb938200b0fb8da675bce28af8488c93d
7
+ data.tar.gz: c390cbaf842ce68dfd97e4a6d94ae40f9b83fa694ee5931975d96d1b8bd4c8795fbb308a12d3e0ba54f5e8b87d16229a665abe86aa9d00ec0e5e98bb5777ad23
data/MIT-LICENSE CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2014-2016 David Heinemeier Hansson
1
+ Copyright (c) 2014-2023 David Heinemeier Hansson
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining
4
4
  a copy of this software and associated documentation files (the
@@ -18,4 +18,3 @@ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
18
  LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
19
  OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
20
  WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21
-
data/README.md CHANGED
@@ -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 a `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 that 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,7 +201,7 @@ 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
@@ -2,12 +2,20 @@
2
2
 
3
3
  class GlobalID
4
4
  module FixtureSet
5
- def signed_global_id(fixture_set_name, label, column_type: :integer, **options)
6
- identifier = identify(label, column_type)
7
- model_name = default_fixture_model_name(fixture_set_name)
8
- uri = URI::GID.build([GlobalID.app, model_name, identifier, {}])
5
+ def global_id(fixture_set_name, label, column_type: :integer, **options)
6
+ create_global_id(fixture_set_name, label, column_type: column_type, klass: GlobalID, **options)
7
+ end
9
8
 
10
- SignedGlobalID.new(uri, **options)
9
+ def signed_global_id(fixture_set_name, label, column_type: :integer, **options)
10
+ create_global_id(fixture_set_name, label, column_type: column_type, klass: SignedGlobalID, **options)
11
11
  end
12
+
13
+ private
14
+ def create_global_id(fixture_set_name, label, klass:, column_type: :integer, **options)
15
+ identifier = identify(label, column_type)
16
+ model_name = default_fixture_model_name(fixture_set_name)
17
+ uri = URI::GID.build([GlobalID.app, model_name, identifier, {}])
18
+ klass.new(uri, **options)
19
+ end
12
20
  end
13
21
  end
@@ -1,4 +1,3 @@
1
- require 'active_support'
2
1
  require 'active_support/core_ext/string/inflections' # For #model_class constantize
3
2
  require 'active_support/core_ext/array/access'
4
3
  require 'active_support/core_ext/object/try' # For #find
@@ -35,18 +34,12 @@ class GlobalID
35
34
 
36
35
  private
37
36
  def parse_encoded_gid(gid, options)
38
- new(Base64.urlsafe_decode64(repad_gid(gid)), options) rescue nil
39
- end
40
-
41
- # We removed the base64 padding character = during #to_param, now we're adding it back so decoding will work
42
- def repad_gid(gid)
43
- padding_chars = gid.length.modulo(4).zero? ? 0 : (4 - gid.length.modulo(4))
44
- gid + ('=' * padding_chars)
37
+ new(Base64.urlsafe_decode64(gid), options) rescue nil
45
38
  end
46
39
  end
47
40
 
48
41
  attr_reader :uri
49
- delegate :app, :model_name, :model_id, :params, :to_s, to: :uri
42
+ delegate :app, :model_name, :model_id, :params, :to_s, :deconstruct_keys, to: :uri
50
43
 
51
44
  def initialize(gid, options = {})
52
45
  @uri = gid.is_a?(URI::GID) ? gid : URI::GID.parse(gid)
@@ -57,7 +50,14 @@ class GlobalID
57
50
  end
58
51
 
59
52
  def model_class
60
- model_name.constantize
53
+ @model_class ||= begin
54
+ model = model_name.constantize
55
+
56
+ if model <= GlobalID
57
+ raise ArgumentError, "GlobalID and SignedGlobalID cannot be used as model_class."
58
+ end
59
+ model
60
+ end
61
61
  end
62
62
 
63
63
  def ==(other)
@@ -70,7 +70,10 @@ class GlobalID
70
70
  end
71
71
 
72
72
  def to_param
73
- # remove the = padding character for a prettier param -- it'll be added back in parse_encoded_gid
74
- Base64.urlsafe_encode64(to_s).sub(/=+$/, '')
73
+ Base64.urlsafe_encode64(to_s, padding: false)
74
+ end
75
+
76
+ def as_json(*)
77
+ to_s
75
78
  end
76
79
  end
@@ -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.modal_class # => Person
35
+ # global_id.modal_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.modal_class # => Person
56
+ # signed_global_id.modal_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
@@ -1,20 +1,34 @@
1
- require 'active_support'
2
1
  require 'active_support/core_ext/enumerable' # For Enumerable#index_by
3
2
 
4
3
  class GlobalID
5
4
  module Locator
5
+ class InvalidModelIdError < StandardError; end
6
+
6
7
  class << self
7
8
  # Takes either a GlobalID or a string that can be turned into a GlobalID
8
9
  #
9
10
  # Options:
11
+ # * <tt>:includes</tt> - A Symbol, Array, Hash or combination of them.
12
+ # The same structure you would pass into a +includes+ method of Active Record.
13
+ # If present, locate will load all the relationships specified here.
14
+ # See https://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations.
10
15
  # * <tt>:only</tt> - A class, module or Array of classes and/or modules that are
11
16
  # allowed to be located. Passing one or more classes limits instances of returned
12
17
  # classes to those classes or their subclasses. Passing one or more modules in limits
13
18
  # instances of returned classes to those including that module. If no classes or
14
19
  # modules match, +nil+ is returned.
15
20
  def locate(gid, options = {})
16
- if gid = GlobalID.parse(gid)
17
- locator_for(gid).locate gid if find_allowed?(gid.model_class, options[:only])
21
+ gid = GlobalID.parse(gid)
22
+
23
+ return unless gid && find_allowed?(gid.model_class, options[:only])
24
+
25
+ locator = locator_for(gid)
26
+
27
+ if locator.method(:locate).arity == 1
28
+ 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 = {})`."
29
+ locator.locate(gid)
30
+ else
31
+ locator.locate(gid, options.except(:only))
18
32
  end
19
33
  end
20
34
 
@@ -29,6 +43,11 @@ class GlobalID
29
43
  # per model class, but still interpolate the results to match the order in which the gids were passed.
30
44
  #
31
45
  # Options:
46
+ # * <tt>:includes</tt> - A Symbol, Array, Hash or combination of them
47
+ # The same structure you would pass into a includes method of Active Record.
48
+ # @see https://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations
49
+ # If present, locate_many will load all the relationships specified here.
50
+ # Note: It only works if all the gids models have that relationships.
32
51
  # * <tt>:only</tt> - A class, module or Array of classes and/or modules that are
33
52
  # allowed to be located. Passing one or more classes limits instances of returned
34
53
  # classes to those classes or their subclasses. Passing one or more modules in limits
@@ -50,6 +69,10 @@ class GlobalID
50
69
  # Takes either a SignedGlobalID or a string that can be turned into a SignedGlobalID
51
70
  #
52
71
  # Options:
72
+ # * <tt>:includes</tt> - A Symbol, Array, Hash or combination of them
73
+ # The same structure you would pass into a includes method of Active Record.
74
+ # @see https://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations
75
+ # If present, locate_signed will load all the relationships specified here.
53
76
  # * <tt>:only</tt> - A class, module or Array of classes and/or modules that are
54
77
  # allowed to be located. Passing one or more classes limits instances of returned
55
78
  # classes to those classes or their subclasses. Passing one or more modules in limits
@@ -67,6 +90,11 @@ class GlobalID
67
90
  # the results to match the order in which the gids were passed.
68
91
  #
69
92
  # Options:
93
+ # * <tt>:includes</tt> - A Symbol, Array, Hash or combination of them
94
+ # The same structure you would pass into a includes method of Active Record.
95
+ # @see https://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations
96
+ # If present, locate_many_signed will load all the relationships specified here.
97
+ # Note: It only works if all the gids models have that relationships.
70
98
  # * <tt>:only</tt> - A class, module or Array of classes and/or modules that are
71
99
  # allowed to be located. Passing one or more classes limits instances of returned
72
100
  # classes to those classes or their subclasses. Passing one or more modules in limits
@@ -83,7 +111,7 @@ class GlobalID
83
111
  #
84
112
  # Using a block:
85
113
  #
86
- # GlobalID::Locator.use :foo do |gid|
114
+ # GlobalID::Locator.use :foo do |gid, options|
87
115
  # FooRemote.const_get(gid.model_name).find(gid.model_id)
88
116
  # end
89
117
  #
@@ -92,7 +120,7 @@ class GlobalID
92
120
  # GlobalID::Locator.use :bar, BarLocator.new
93
121
  #
94
122
  # class BarLocator
95
- # def locate(gid)
123
+ # def locate(gid, options = {})
96
124
  # @search_client.search name: gid.model_name, id: gid.model_id
97
125
  # end
98
126
  # end
@@ -126,32 +154,55 @@ class GlobalID
126
154
  @locators = {}
127
155
 
128
156
  class BaseLocator
129
- def locate(gid)
130
- gid.model_class.find gid.model_id
157
+ def locate(gid, options = {})
158
+ return unless model_id_is_valid?(gid)
159
+ model_class = gid.model_class
160
+ model_class = model_class.includes(options[:includes]) if options[:includes]
161
+
162
+ model_class.find gid.model_id
131
163
  end
132
164
 
133
165
  def locate_many(gids, options = {})
134
- models_and_ids = gids.collect { |gid| [ gid.model_class, gid.model_id ] }
135
- ids_by_model = models_and_ids.group_by(&:first)
136
- loaded_by_model = Hash[ids_by_model.map { |model, ids|
137
- [ model, find_records(model, ids.map(&:last), ignore_missing: options[:ignore_missing]).index_by { |record| record.id.to_s } ]
138
- }]
166
+ ids_by_model = Hash.new { |hash, key| hash[key] = [] }
167
+
168
+ gids.each do |gid|
169
+ next unless model_id_is_valid?(gid)
170
+ ids_by_model[gid.model_class] << gid.model_id
171
+ end
172
+
173
+ records_by_model_name_and_id = {}
139
174
 
140
- models_and_ids.collect { |(model, id)| loaded_by_model[model][id] }.compact
175
+ ids_by_model.each do |model, ids|
176
+ records = find_records(model, ids, ignore_missing: options[:ignore_missing], includes: options[:includes])
177
+
178
+ records_by_id = records.index_by do |record|
179
+ record.id.is_a?(Array) ? record.id.map(&:to_s) : record.id.to_s
180
+ end
181
+
182
+ records_by_model_name_and_id[model.name] = records_by_id
183
+ end
184
+
185
+ gids.filter_map { |gid| records_by_model_name_and_id[gid.model_name][gid.model_id] }
141
186
  end
142
187
 
143
188
  private
144
189
  def find_records(model_class, ids, options)
190
+ model_class = model_class.includes(options[:includes]) if options[:includes]
191
+
145
192
  if options[:ignore_missing]
146
- model_class.where(id: ids)
193
+ model_class.where(model_class.primary_key => ids)
147
194
  else
148
195
  model_class.find(ids)
149
196
  end
150
197
  end
198
+
199
+ def model_id_is_valid?(gid)
200
+ Array(gid.model_id).size == Array(gid.model_class.primary_key).size
201
+ end
151
202
  end
152
203
 
153
204
  class UnscopedLocator < BaseLocator
154
- def locate(gid)
205
+ def locate(gid, options = {})
155
206
  unscoped(gid.model_class) { super }
156
207
  end
157
208
 
@@ -175,12 +226,12 @@ class GlobalID
175
226
  @locator = block
176
227
  end
177
228
 
178
- def locate(gid)
179
- @locator.call(gid)
229
+ def locate(gid, options = {})
230
+ @locator.call(gid, options)
180
231
  end
181
232
 
182
233
  def locate_many(gids, options = {})
183
- gids.map { |gid| locate(gid) }
234
+ gids.map { |gid| locate(gid, options) }
184
235
  end
185
236
  end
186
237
  end
@@ -3,7 +3,6 @@ require 'rails/railtie'
3
3
  rescue LoadError
4
4
  else
5
5
  require 'global_id'
6
- require 'active_support'
7
6
  require 'active_support/core_ext/string/inflections'
8
7
  require 'active_support/core_ext/integer/time'
9
8
 
@@ -43,6 +42,10 @@ class GlobalID
43
42
  send :extend, GlobalID::FixtureSet
44
43
  end
45
44
  end
45
+
46
+ initializer "web_console.deprecator" do |app|
47
+ app.deprecators[:global_id] = GlobalID.deprecator if app.respond_to?(:deprecators)
48
+ end
46
49
  end
47
50
  end
48
51
 
@@ -1,4 +1,3 @@
1
- require 'global_id'
2
1
  require 'active_support/message_verifier'
3
2
  require 'time'
4
3
 
@@ -6,7 +5,7 @@ class SignedGlobalID < GlobalID
6
5
  class ExpiredMessage < StandardError; end
7
6
 
8
7
  class << self
9
- attr_accessor :verifier
8
+ attr_accessor :verifier, :expires_in
10
9
 
11
10
  def parse(sgid, options = {})
12
11
  super verify(sgid.to_s, options), options
@@ -20,8 +19,6 @@ class SignedGlobalID < GlobalID
20
19
  end
21
20
  end
22
21
 
23
- attr_accessor :expires_in
24
-
25
22
  DEFAULT_PURPOSE = "default"
26
23
 
27
24
  def pick_purpose(options)
@@ -30,11 +27,22 @@ class SignedGlobalID < GlobalID
30
27
 
31
28
  private
32
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)
33
41
  metadata = pick_verifier(options).verify(sgid)
34
42
 
35
43
  raise_if_expired(metadata['expires_at'])
36
44
 
37
- metadata['gid'] if pick_purpose(options) == metadata['purpose']
45
+ metadata['gid'] if pick_purpose(options)&.to_s == metadata['purpose']&.to_s
38
46
  rescue ActiveSupport::MessageVerifier::InvalidSignature, ExpiredMessage
39
47
  nil
40
48
  end
@@ -56,25 +64,19 @@ class SignedGlobalID < GlobalID
56
64
  end
57
65
 
58
66
  def to_s
59
- @sgid ||= @verifier.generate(to_h)
67
+ @sgid ||= @verifier.generate(@uri.to_s, purpose: purpose, expires_at: expires_at)
60
68
  end
61
69
  alias to_param to_s
62
70
 
63
- def to_h
64
- # Some serializers decodes symbol keys to symbols, others to strings.
65
- # Using string keys remedies that.
66
- { 'gid' => @uri.to_s, 'purpose' => purpose, 'expires_at' => encoded_expiration }
67
- end
68
-
69
71
  def ==(other)
70
72
  super && @purpose == other.purpose
71
73
  end
72
74
 
73
- private
74
- def encoded_expiration
75
- expires_at.utc.iso8601(3) if expires_at
76
- end
75
+ def inspect # :nodoc:
76
+ "#<#{self.class.name}:#{'%#016x' % (object_id << 1)}>"
77
+ end
77
78
 
79
+ private
78
80
  def pick_expiration(options)
79
81
  return options[:expires_at] if options.key?(:expires_at)
80
82
 
@@ -30,6 +30,11 @@ 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 = "/"
33
38
 
34
39
  class << self
35
40
  # Validates +app+'s as URI hostnames containing only alphanumeric characters
@@ -83,7 +88,8 @@ module URI
83
88
  def build(args)
84
89
  parts = Util.make_components_hash(self, args)
85
90
  parts[:host] = parts[:app]
86
- parts[:path] = "/#{parts[:model_name]}/#{CGI.escape(parts[:model_id].to_s)}"
91
+ model_id_segment = Array(parts[:model_id]).map { |p| CGI.escape(p.to_s) }.join(COMPOSITE_MODEL_ID_DELIMITER)
92
+ parts[:path] = "/#{parts[:model_name]}/#{model_id_segment}"
87
93
 
88
94
  if parts[:params] && !parts[:params].empty?
89
95
  parts[:query] = URI.encode_www_form(parts[:params])
@@ -98,6 +104,10 @@ module URI
98
104
  "gid://#{app}#{path}#{'?' + query if query}"
99
105
  end
100
106
 
107
+ def deconstruct_keys(_keys)
108
+ {app: app, model_name: model_name, model_id: model_id, params: params}
109
+ end
110
+
101
111
  protected
102
112
  def set_path(path)
103
113
  set_model_components(path) unless defined?(@model_name) && @model_id
@@ -135,7 +145,7 @@ module URI
135
145
 
136
146
  def check_scheme(scheme)
137
147
  if scheme == 'gid'
138
- super
148
+ true
139
149
  else
140
150
  raise URI::BadURIError, "Not a gid:// URI scheme: #{inspect}"
141
151
  end
@@ -143,12 +153,22 @@ module URI
143
153
 
144
154
  def set_model_components(path, validate = false)
145
155
  _, model_name, model_id = path.split('/', 3)
146
- validate_component(model_name) && validate_model_id(model_id, model_name) if validate
147
-
148
- model_id = CGI.unescape(model_id) if model_id
149
156
 
157
+ validate_component(model_name) && validate_model_id_section(model_id, model_name) if validate
150
158
  @model_name = model_name
151
- @model_id = model_id
159
+
160
+ if model_id
161
+ model_id_parts = model_id
162
+ .split(COMPOSITE_MODEL_ID_DELIMITER, COMPOSITE_MODEL_ID_MAX_SIZE)
163
+ .reject(&:blank?)
164
+
165
+ model_id_parts.map! do |id|
166
+ validate_model_id(id)
167
+ CGI.unescape(id)
168
+ end
169
+
170
+ @model_id = model_id_parts.length == 1 ? model_id_parts.first : model_id_parts
171
+ end
152
172
  end
153
173
 
154
174
  def validate_component(component)
@@ -158,13 +178,20 @@ module URI
158
178
  "Expected a URI like gid://app/Person/1234: #{inspect}"
159
179
  end
160
180
 
161
- def validate_model_id(model_id, model_name)
162
- return model_id unless model_id.blank? || model_id.include?('/')
181
+ def validate_model_id_section(model_id, model_name)
182
+ return model_id unless model_id.blank?
163
183
 
164
184
  raise MissingModelIdError, "Unable to create a Global ID for " \
165
185
  "#{model_name} without a model id."
166
186
  end
167
187
 
188
+ def validate_model_id(model_id_part)
189
+ return unless model_id_part.include?('/')
190
+
191
+ raise InvalidModelIdError, "Unable to create a Global ID for " \
192
+ "#{model_name} with a malformed model id."
193
+ end
194
+
168
195
  def parse_query_params(query)
169
196
  Hash[URI.decode_www_form(query)].with_indifferent_access if query
170
197
  end
@@ -1,14 +1,13 @@
1
- require 'active_support'
2
1
  require 'active_support/message_verifier'
3
2
 
4
3
  class GlobalID
5
4
  class Verifier < ActiveSupport::MessageVerifier
6
5
  private
7
- def encode(data)
6
+ def encode(data, **)
8
7
  ::Base64.urlsafe_encode64(data)
9
8
  end
10
9
 
11
- def decode(data)
10
+ def decode(data, **)
12
11
  ::Base64.urlsafe_decode64(data)
13
12
  end
14
13
  end
data/lib/global_id.rb CHANGED
@@ -1,5 +1,5 @@
1
- require 'global_id/global_id'
2
1
  require 'active_support'
2
+ require 'global_id/global_id'
3
3
 
4
4
  autoload :SignedGlobalID, 'global_id/signed_global_id'
5
5
 
@@ -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,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: globalid
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Heinemeier Hansson
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-01-17 00:00:00.000000000 Z
11
+ date: 2023-09-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: '5.0'
19
+ version: '6.1'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
- version: '5.0'
26
+ version: '6.1'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: rake
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -59,8 +59,9 @@ files:
59
59
  homepage: http://www.rubyonrails.org
60
60
  licenses:
61
61
  - MIT
62
- metadata: {}
63
- post_install_message:
62
+ metadata:
63
+ rubygems_mfa_required: 'true'
64
+ post_install_message:
64
65
  rdoc_options: []
65
66
  require_paths:
66
67
  - lib
@@ -75,8 +76,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
75
76
  - !ruby/object:Gem::Version
76
77
  version: '0'
77
78
  requirements: []
78
- rubygems_version: 3.5.0.dev
79
- signing_key:
79
+ rubygems_version: 3.4.1
80
+ signing_key:
80
81
  specification_version: 4
81
82
  summary: 'Refer to any model with a URI: gid://app/class/id'
82
83
  test_files: []