/
gid.rb
205 lines (173 loc) · 6.83 KB
/
gid.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
require 'uri/generic'
require 'active_support/core_ext/module/aliasing'
require 'active_support/core_ext/object/blank'
require 'active_support/core_ext/hash/indifferent_access'
module URI
class GID < Generic
# URI::GID encodes an app unique reference to a specific model as an URI.
# It has the components: app name, model class name, model id and params.
# All components except params are required.
#
# The URI format looks like "gid://app/model_name/model_id".
#
# Simple metadata can be stored in params. Useful if your app has multiple databases,
# for instance, and you need to find out which one to look up the model in.
#
# Params will be encoded as query parameters like so
# "gid://app/model_name/model_id?key=value&another_key=another_value".
#
# Params won't be typecast, they're always strings.
# For convenience params can be accessed using both strings and symbol keys.
#
# Multi value params aren't supported. Any params encoding multiple values under
# the same key will return only the last value. For example, when decoding
# params like "key=first_value&key=last_value" key will only be last_value.
#
# Read the documentation for +parse+, +create+ and +build+ for more.
alias :app :host
attr_reader :model_name, :model_id, :params
# Raised when creating a Global ID for a model without an id
class MissingModelIdError < URI::InvalidComponentError; end
class InvalidModelIdError < URI::InvalidComponentError; end
# Maximum size of a model id segment
COMPOSITE_MODEL_ID_MAX_SIZE = 20
COMPOSITE_MODEL_ID_DELIMITER = "/"
class << self
# Validates +app+'s as URI hostnames containing only alphanumeric characters
# and hyphens. An ArgumentError is raised if +app+ is invalid.
#
# URI::GID.validate_app('bcx') # => 'bcx'
# URI::GID.validate_app('foo-bar') # => 'foo-bar'
#
# URI::GID.validate_app(nil) # => ArgumentError
# URI::GID.validate_app('foo/bar') # => ArgumentError
def validate_app(app)
parse("gid://#{app}/Model/1").app
rescue URI::Error
raise ArgumentError, 'Invalid app name. ' \
'App names must be valid URI hostnames: alphanumeric and hyphen characters only.'
end
# Create a new URI::GID by parsing a gid string with argument check.
#
# URI::GID.parse 'gid://bcx/Person/1?key=value'
#
# This differs from URI() and URI.parse which do not check arguments.
#
# URI('gid://bcx') # => URI::GID instance
# URI.parse('gid://bcx') # => URI::GID instance
# URI::GID.parse('gid://bcx/') # => raises URI::InvalidComponentError
def parse(uri)
generic_components = URI.split(uri) << nil << true # nil parser, true arg_check
new(*generic_components)
end
# Shorthand to build a URI::GID from an app, a model and optional params.
#
# URI::GID.create('bcx', Person.find(5), database: 'superhumans')
def create(app, model, params = nil)
build app: app, model_name: model.class.name, model_id: model.id, params: params
end
# Create a new URI::GID from components with argument check.
#
# The allowed components are app, model_name, model_id and params, which can be
# either a hash or an array.
#
# Using a hash:
#
# URI::GID.build(app: 'bcx', model_name: 'Person', model_id: '1', params: { key: 'value' })
#
# Using an array, the arguments must be in order [app, model_name, model_id, params]:
#
# URI::GID.build(['bcx', 'Person', '1', key: 'value'])
def build(args)
parts = Util.make_components_hash(self, args)
parts[:host] = parts[:app]
model_id_segment = Array(parts[:model_id]).map { |p| CGI.escape(p.to_s) }.join(COMPOSITE_MODEL_ID_DELIMITER)
parts[:path] = "/#{parts[:model_name]}/#{model_id_segment}"
if parts[:params] && !parts[:params].empty?
parts[:query] = URI.encode_www_form(parts[:params])
end
super parts
end
end
def to_s
# Implement #to_s to avoid no implicit conversion of nil into string when path is nil
"gid://#{app}#{path}#{'?' + query if query}"
end
def deconstruct_keys(_keys)
{app: app, model_name: model_name, model_id: model_id, params: params}
end
protected
def set_path(path)
set_model_components(path) unless defined?(@model_name) && @model_id
super
end
# Ruby 2.2 uses #query= instead of #set_query
def query=(query)
set_params parse_query_params(query)
super
end
# Ruby 2.1 or less uses #set_query to assign the query
def set_query(query)
set_params parse_query_params(query)
super
end
def set_params(params)
@params = params
end
private
COMPONENT = [ :scheme, :app, :model_name, :model_id, :params ].freeze
def check_host(host)
validate_component(host)
super
end
def check_path(path)
validate_component(path)
set_model_components(path, true)
end
def check_scheme(scheme)
if scheme == 'gid'
true
else
raise URI::BadURIError, "Not a gid:// URI scheme: #{inspect}"
end
end
def set_model_components(path, validate = false)
_, model_name, model_id = path.split('/', 3)
validate_component(model_name) && validate_model_id_section(model_id, model_name) if validate
@model_name = model_name
if model_id
model_id_parts = model_id
.split(COMPOSITE_MODEL_ID_DELIMITER, COMPOSITE_MODEL_ID_MAX_SIZE)
.reject(&:blank?)
model_id_parts.map! do |id|
validate_model_id(id)
CGI.unescape(id)
end
@model_id = model_id_parts.length == 1 ? model_id_parts.first : model_id_parts
end
end
def validate_component(component)
return component unless component.blank?
raise URI::InvalidComponentError,
"Expected a URI like gid://app/Person/1234: #{inspect}"
end
def validate_model_id_section(model_id, model_name)
return model_id unless model_id.blank?
raise MissingModelIdError, "Unable to create a Global ID for " \
"#{model_name} without a model id."
end
def validate_model_id(model_id_part)
return unless model_id_part.include?('/')
raise InvalidModelIdError, "Unable to create a Global ID for " \
"#{model_name} with a malformed model id."
end
def parse_query_params(query)
Hash[URI.decode_www_form(query)].with_indifferent_access if query
end
end
if respond_to?(:register_scheme)
register_scheme('GID', GID)
else
@@schemes['GID'] = GID
end
end