zuora_api 1.6.15 → 1.6.16
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 +4 -4
- data/lib/zuora_api/login.rb +265 -24
- data/lib/zuora_api/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: cf22e129425646f0333a0fbfba46454caed076d8
|
4
|
+
data.tar.gz: 8bef1bdcdc8d148b6450846f8d8fc014f6964543
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: b7f61fe146b9593d91315f95e91dbc073229b80e71274630c76b5c30d099b12ed4b282c54cdac7106bb02141f490cfd2d2df8d881623af200be415435d77070f
|
7
|
+
data.tar.gz: 7a4e6c34d8c7019c1d1d474b7c946db1d85b075ea173bc4571fb29ae15d28113857002e5ec03f809b8c681e4dc8345f80840adede4f5ff899e931eddaac2b636
|
data/lib/zuora_api/login.rb
CHANGED
@@ -4,23 +4,200 @@ require "uri"
|
|
4
4
|
|
5
5
|
module ZuoraAPI
|
6
6
|
class Login
|
7
|
-
ENVIRONMENTS = [SANDBOX = 'Sandbox', PRODUCTION = 'Production', PREFORMANCE = 'Preformance', SERVICES = 'Services', UNKNOWN = 'Unknown' ]
|
7
|
+
ENVIRONMENTS = [SANDBOX = 'Sandbox', PRODUCTION = 'Production', PREFORMANCE = 'Preformance', SERVICES = 'Services', UNKNOWN = 'Unknown', STAGING = 'Staging' ]
|
8
8
|
REGIONS = [EU = 'EU', US = 'US', NA = 'NA' ]
|
9
9
|
MIN_Endpoint = '91.0'
|
10
10
|
XML_SAVE_OPTIONS = Nokogiri::XML::Node::SaveOptions::AS_XML | Nokogiri::XML::Node::SaveOptions::NO_DECLARATION
|
11
|
-
attr_accessor :region, :url, :wsdl_number, :current_session, :environment, :status, :errors, :current_error, :user_info, :tenant_id, :tenant_name, :entity_id, :timeout_sleep
|
11
|
+
attr_accessor :region, :url, :wsdl_number, :current_session, :environment, :status, :errors, :current_error, :user_info, :tenant_id, :tenant_name, :entity_id, :timeout_sleep, :hostname, :zconnect_provider
|
12
12
|
|
13
13
|
def initialize(url: nil, entity_id: nil, session: nil, status: nil, **keyword_args)
|
14
|
+
raise "URL is nil or empty, but URL is required" if url.nil? | url.empty?
|
15
|
+
# raise "URL is improper. URL must contain zuora.com, zuora.eu, or zuora.na" if /zuora.com|zuora.eu|zuora.na/ === url
|
14
16
|
@url = url.gsub(/(\d{2}\.\d)$/, MIN_Endpoint)
|
17
|
+
@hostname = /(?<=https:\/\/|http:\/\/)(.*?)(?=\/|$)/.match(url)[0] if !/(?<=https:\/\/|http:\/\/)(.*?)(?=\/|$)/.match(url).nil?
|
15
18
|
@entity_id = get_entity_id(entity_id: entity_id)
|
16
19
|
@errors = Hash.new
|
17
20
|
@current_session = session
|
18
21
|
@status = status.blank? ? "Active" : status
|
19
22
|
@user_info = Hash.new
|
23
|
+
self.update_region
|
20
24
|
self.update_environment
|
25
|
+
self.update_zconnect_provider
|
21
26
|
@timeout_sleep = 5
|
22
27
|
end
|
23
28
|
|
29
|
+
def get_identity(cookies)
|
30
|
+
zsession = cookies["ZSession"]
|
31
|
+
zconnect_accesstoken = get_zconnect_accesstoken(cookies)
|
32
|
+
begin
|
33
|
+
if false && !zsession.blank?
|
34
|
+
# Does not currently exist / function properly
|
35
|
+
# use the zsession API when/if it exists
|
36
|
+
elsif !zconnect_accesstoken.blank?
|
37
|
+
code = zconnect_accesstoken.split("#!").last
|
38
|
+
encrypted_token, tenant_id = Base64.decode64(code).split(":")
|
39
|
+
begin
|
40
|
+
body = {'token' => encrypted_token}.to_json
|
41
|
+
rescue => ex
|
42
|
+
raise ZuoraAPI::Exceptions::ZuoraAPIError.new("Invalid ZConnect Cookie", {}, 400)
|
43
|
+
end
|
44
|
+
response = HTTParty.post("https://#{self.hostname}/apps/zconnectsession/identity", :body => body, :headers => { 'Content-Type' => 'application/json' })
|
45
|
+
output_json = JSON.parse(response.body)
|
46
|
+
else
|
47
|
+
if zconnect_accesstoken.blank? && cookies.keys.any? { |x| x.include? "ZConnect"}
|
48
|
+
raise ZuoraAPI::Exceptions::ZuoraAPIError.new("No ZConnect cookie present matching #{self.hostname}", {}, 400)
|
49
|
+
else
|
50
|
+
raise ZuoraAPI::Exceptions::ZuoraAPIError.new("No ZSession cookie present", {}, 400)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
rescue JSON::ParserError => ex
|
54
|
+
output_json = {}
|
55
|
+
end
|
56
|
+
raise_errors(type: :JSON, body: output_json, response: response)
|
57
|
+
return output_json
|
58
|
+
end
|
59
|
+
|
60
|
+
def get_full_nav(cookies)
|
61
|
+
zsession = cookies["ZSession"]
|
62
|
+
zconnect_accesstoken = get_zconnect_accesstoken(cookies)
|
63
|
+
begin
|
64
|
+
if !zsession.blank?
|
65
|
+
response = HTTParty.get("https://#{self.hostname}/apps/v1/navigation", :headers => {'Cookie' => "ZSession=#{zsession}", 'Content-Type' => 'application/json'})
|
66
|
+
output_json = JSON.parse(response.body)
|
67
|
+
elsif !zconnect_accesstoken.blank?
|
68
|
+
response = HTTParty.get("https://#{self.hostname}/apps/zconnectsession/navigation", :headers => {'Cookie' => "#{self.zconnect_provider}=#{zconnect_accesstoken}",'Content-Type' => 'application/json'})
|
69
|
+
output_json = JSON.parse(response.body)
|
70
|
+
else
|
71
|
+
if zconnect_accesstoken.blank? && cookies.keys.any? { |x| x.include? "ZConnect"}
|
72
|
+
raise ZuoraAPI::Exceptions::ZuoraAPIError.new("No ZConnect cookie present matching #{self.hostname}", {}, 400)
|
73
|
+
else
|
74
|
+
raise ZuoraAPI::Exceptions::ZuoraAPIError.new("No ZSession cookie present", {}, 400)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
rescue JSON::ParserError => ex
|
78
|
+
output_json = {}
|
79
|
+
end
|
80
|
+
raise_errors(type: :JSON, body: output_json, response: response)
|
81
|
+
return output_json
|
82
|
+
end
|
83
|
+
|
84
|
+
def set_nav(state, cookies)
|
85
|
+
zsession = cookies["ZSession"]
|
86
|
+
zconnect_accesstoken = get_zconnect_accesstoken(cookies)
|
87
|
+
begin
|
88
|
+
if !zsession.blank?
|
89
|
+
response = HTTParty.put("https://#{self.hostname}/apps/v1/preference/navigation", :body => state.to_json, :headers => {'Cookie' => "ZSession=#{zsession}", 'Content-Type' => 'application/json'})
|
90
|
+
output_json = JSON.parse(response.body)
|
91
|
+
elsif !zconnect_accesstoken.blank?
|
92
|
+
response = HTTParty.post("https://#{self.hostname}/apps/zconnectsession/navigationstate", :body => state.to_json, :headers => {'Cookie' => "#{self.zconnect_provider}=#{zconnect_accesstoken}", 'Content-Type' => 'application/json'})
|
93
|
+
output_json = JSON.parse(response.body)
|
94
|
+
else
|
95
|
+
if zconnect_accesstoken.blank? && cookies.keys.any? { |x| x.include? "ZConnect"}
|
96
|
+
raise ZuoraAPI::Exceptions::ZuoraAPIError.new("No ZConnect cookie present matching #{self.hostname}", {}, 400)
|
97
|
+
else
|
98
|
+
raise ZuoraAPI::Exceptions::ZuoraAPIError.new("No ZSession cookie present", {}, 400)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
rescue JSON::ParserError => ex
|
102
|
+
output_json = {}
|
103
|
+
end
|
104
|
+
raise_errors(type: :JSON, body: output_json, response: response)
|
105
|
+
return output_json
|
106
|
+
end
|
107
|
+
|
108
|
+
def refresh_nav(cookies)
|
109
|
+
zsession = cookies["ZSession"]
|
110
|
+
zconnect_accesstoken = get_zconnect_accesstoken(cookies)
|
111
|
+
begin
|
112
|
+
if !zsession.blank?
|
113
|
+
response = HTTParty.post("https://#{self.hostname}/apps/v1/navigation/fetch", :headers => {'Cookie' => "ZSession=#{zsession}", 'Content-Type' => 'application/json'})
|
114
|
+
output_json = JSON.parse(response.body)
|
115
|
+
elsif !zconnect_accesstoken.blank?
|
116
|
+
response = HTTParty.post("https://#{self.hostname}/apps/zconnectsession/refresh-navbarcache", :headers => {'Cookie' => "#{self.zconnect_provider}=#{zconnect_accesstoken}", 'Content-Type' => 'application/json'})
|
117
|
+
output_json = JSON.parse(response.body)
|
118
|
+
else
|
119
|
+
if zconnect_accesstoken.blank? && cookies.keys.any? { |x| x.include? "ZConnect"}
|
120
|
+
raise ZuoraAPI::Exceptions::ZuoraAPIError.new("No ZConnect cookie present matching #{self.hostname}", {}, 400)
|
121
|
+
else
|
122
|
+
raise ZuoraAPI::Exceptions::ZuoraAPIError.new("No ZSession cookie present", {}, 400)
|
123
|
+
end
|
124
|
+
end
|
125
|
+
rescue JSON::ParserError => ex
|
126
|
+
output_json = {}
|
127
|
+
end
|
128
|
+
raise_errors(type: :JSON, body: output_json, response: response)
|
129
|
+
return output_json
|
130
|
+
end
|
131
|
+
|
132
|
+
def get_zconnect_accesstoken(cookies)
|
133
|
+
accesstoken = nil
|
134
|
+
self.update_zconnect_provider
|
135
|
+
if !cookies[self.zconnect_provider].nil? && !cookies[self.zconnect_provider].empty?
|
136
|
+
accesstoken = cookies[self.zconnect_provider]
|
137
|
+
end
|
138
|
+
return accesstoken
|
139
|
+
end
|
140
|
+
|
141
|
+
def reporting_url(path)
|
142
|
+
map = {"US" => {"Sandbox" => "https://zconnectsandbox.zuora.com/api/rest/v1/",
|
143
|
+
"Production" => "https://zconnect.zuora.com/api/rest/v1/",
|
144
|
+
"Services"=> ""},
|
145
|
+
"EU" => {"Sandbox" => "https://zconnect.sandbox.eu.zuora.com/api/rest/v1/",
|
146
|
+
"Production" => "https://zconnect.eu.zuora.com/api/rest/v1/",
|
147
|
+
"Services"=> ""},
|
148
|
+
"NA" => {"Sandbox" => "https://zconnect.sandbox.na.zuora.com/api/rest/v1/",
|
149
|
+
"Production" => "https://zconnect.na.zuora.com/api/rest/v1/",
|
150
|
+
"Services"=> ""}
|
151
|
+
}
|
152
|
+
return map[zuora_client.region][zuora_client.environment].insert(-1, path)
|
153
|
+
end
|
154
|
+
|
155
|
+
# There are two ways to call this method. The first way is best.
|
156
|
+
# 1. Pass in cookies and optionally custom_authorities, name, and description
|
157
|
+
# 2. Pass in user_id, entity_ids, client_id, client_secret, and optionally custom_authorities, name, and description
|
158
|
+
# https://intranet.zuora.com/confluence/display/Sunburst/Create+an+OAuth+Client+through+API+Gateway#CreateanOAuthClientthroughAPIGateway-ZSession
|
159
|
+
def get_oauth_client (custom_authorities = [], info_name: "No Name", info_desc: "This client was created without a description.", user_id: nil, entity_ids: nil, client_id: nil, client_secret: nil, new_client_id: nil, new_client_secret: nil, cookies: nil)
|
160
|
+
authorization = ""
|
161
|
+
new_client_id = SecureRandom.uuid if new_client_id.blank?
|
162
|
+
new_client_secret = SecureRandom.hex(10) if new_client_secret.blank?
|
163
|
+
|
164
|
+
if !cookies.nil?
|
165
|
+
authorization = cookies["ZSession"]
|
166
|
+
authorization = "ZSession-a3N2w #{authorization}"
|
167
|
+
if entity_ids.blank? && cookies["ZuoraCurrentEntity"].present?
|
168
|
+
entity_ids = Array(cookies["ZuoraCurrentEntity"].unpack("a8a4a4a4a12").join('-'))
|
169
|
+
else
|
170
|
+
raise ZuoraAPI::Exceptions::ZuoraAPIError.new("Zuora Entity ID not provided", {}, 400)
|
171
|
+
end
|
172
|
+
if user_id.blank? && cookies["Zuora-User-Id"].present?
|
173
|
+
user_id = cookies["Zuora-User-Id"]
|
174
|
+
else
|
175
|
+
raise ZuoraAPI::Exceptions::ZuoraAPIError.new("Zuora User ID not provided", {}, 400)
|
176
|
+
end
|
177
|
+
elsif !client_id.nil? && !client_secret.nil?
|
178
|
+
bearer_response = HTTParty.post("https://#{self.hostname}/oauth/token", :headers => {'Content-Type' => 'application/x-www-form-urlencoded', 'Accept' => 'application/json'}, :body => {'client_id' => client_id, 'client_secret' => URI::encode(client_secret), 'grant_type' => 'client_credentials'})
|
179
|
+
bearer_hash = JSON.parse(bearer_response.body)
|
180
|
+
bearer_token = bearer_hash["access_token"]
|
181
|
+
authorization = "Bearer #{bearer_token}"
|
182
|
+
end
|
183
|
+
|
184
|
+
if !authorization.blank? && !user_id.blank? && !entity_ids.blank?
|
185
|
+
endpoint = self.rest_endpoint("genesis/clients")
|
186
|
+
oauth_response = HTTParty.post(endpoint, :headers => {'authorization' => authorization, 'Content-Type' => 'application/json'}, :body => {'clientId' => new_client_id, 'clientSecret' => new_client_secret, 'userId' => user_id, 'entityIds' => entity_ids, 'customAuthorities' => custom_authorities, 'additionalInformation' => {'description' => info_desc, 'name' => info_name}}.to_json)
|
187
|
+
output_json = JSON.parse(oauth_response.body)
|
188
|
+
if oauth_response.code == 201
|
189
|
+
output_json["clientSecret"] = new_client_secret
|
190
|
+
return output_json
|
191
|
+
elsif oauth_response.code == 401 && !oauth_response.message.blank?
|
192
|
+
raise ZuoraAPI::Exceptions::ZuoraAPIError.new(output_json["message"], {}, oauth_response.code)
|
193
|
+
else
|
194
|
+
raise ZuoraAPI::Exceptions::ZuoraAPIError.new(output_json["error"], {}, oauth_response.code)
|
195
|
+
end
|
196
|
+
else
|
197
|
+
raise ZuoraAPI::Exceptions::ZuoraAPIError.new("Insufficient credentials provided", {}, 400)
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
24
201
|
def self.environments
|
25
202
|
%w(Sandbox Production Services Performance Staging)
|
26
203
|
end
|
@@ -58,45 +235,105 @@ module ZuoraAPI
|
|
58
235
|
return entity_id
|
59
236
|
end
|
60
237
|
|
238
|
+
def update_region
|
239
|
+
if !self.hostname.blank?
|
240
|
+
if /(?<=\.|\/|^)(eu)(?=\.|\/|$)/ === self.hostname
|
241
|
+
self.region = "EU"
|
242
|
+
elsif /(?<=\.|\/|^)(na)(?=\.|\/|$)/ === self.hostname
|
243
|
+
self.region = "NA"
|
244
|
+
else
|
245
|
+
self.region = "US"
|
246
|
+
end
|
247
|
+
else # This will never happen
|
248
|
+
# raise "Can't update region because URL is blank"
|
249
|
+
self.region = "Unknown"
|
250
|
+
end
|
251
|
+
end
|
252
|
+
|
61
253
|
def update_environment
|
62
254
|
if !self.url.blank?
|
63
|
-
|
64
|
-
self.region = self.url.include?("eu.") ? "EU" : self.url.include?("na.") ? "NA" : "US"
|
65
|
-
if env_path == 'apisandbox' || self.url.include?('sandbox')
|
255
|
+
if /(?<=\.|\/|-|^)(apisandbox|sandbox)(?=\.|\/|-|$)/ === self.hostname
|
66
256
|
self.environment = 'Sandbox'
|
67
|
-
elsif
|
68
|
-
self.environment = 'Production'
|
69
|
-
elsif env_path.include?('service') || env_path.include?('ep-edge')
|
257
|
+
elsif /(?<=\.|\/|^)(service|services[\d]*|ep-edge)(?=\.|\/|$)/ === self.hostname
|
70
258
|
self.environment = 'Services'
|
71
|
-
elsif
|
259
|
+
elsif /(?<=\.|\/|-|^)(pt[\d]*)(?=\.|\/|-|$)/ === self.hostname
|
72
260
|
self.environment = 'Performance'
|
73
|
-
elsif
|
74
|
-
self.region = 'US'
|
261
|
+
elsif /(?<=\.|\/|^)(staging1|staging2|stg)(?=\.|\/|$)/ === self.hostname
|
75
262
|
self.environment = 'Staging'
|
263
|
+
elsif is_prod_env
|
264
|
+
self.environment = 'Production'
|
76
265
|
else
|
77
266
|
self.environment = 'Unknown'
|
78
267
|
end
|
268
|
+
else # this will never happen
|
269
|
+
raise "Can't determine environment from blank URL"
|
270
|
+
end
|
271
|
+
end
|
272
|
+
|
273
|
+
def is_prod_env
|
274
|
+
is_prod = false
|
275
|
+
www_or_api = /(?<=\.|\/|^)(www|api)(?=\.|\/|$)/ === self.hostname
|
276
|
+
host_prefix_match = /(^|tls10\.|origin-www\.|zforsf\.|eu\.|na\.)(zuora\.com)/ === self.hostname
|
277
|
+
if www_or_api || host_prefix_match
|
278
|
+
is_prod = true
|
79
279
|
end
|
280
|
+
return is_prod
|
281
|
+
end
|
282
|
+
|
283
|
+
def update_zconnect_provider
|
284
|
+
region = update_region
|
285
|
+
environment = update_environment
|
286
|
+
mappings = {"US" => {"Sandbox" => "ZConnectSbx", "KubeSTG" => "ZConnectDev", "KubeDEV" => "ZConnectDev", "KubePROD" => "ZConnectDev", "Services" => "ZConnectQA", "Production" => "ZConnectProd", "Performance" => "ZConnectPT1", "Staging" => "ZConnectQA"},
|
287
|
+
"NA" => {"Sandbox" => "ZConnectSbxNA", "Services" => "ZConnectQANA", "Production" => "ZConnectProdNA", "Performance" => "ZConnectPT1NA"},
|
288
|
+
"EU" => {"Sandbox" => "ZConnectSbxEU", "Services" => "ZConnectQAEU", "Production" => "ZConnectProdEU", "Performance" => "ZConnectPT1EU"},
|
289
|
+
"Unknown" => {"Unknown" => "Unknown"}}
|
290
|
+
self.zconnect_provider = mappings[region][environment]
|
291
|
+
# raise "Can't find ZConnect Provider for #{region} region and #{environment} environment" if self.zconnect_provider.nil?
|
80
292
|
end
|
81
293
|
|
82
294
|
def aqua_endpoint(url="")
|
83
|
-
|
295
|
+
match = /.*(\/apps\/)/.match(self.url)
|
296
|
+
if !match.nil?
|
297
|
+
url_slash_apps_slash = match[0]
|
298
|
+
else
|
299
|
+
raise "self.url has no /apps in it"
|
300
|
+
end
|
301
|
+
return "#{url_slash_apps_slash}api/#{url}"
|
84
302
|
end
|
85
303
|
|
86
304
|
def rest_endpoint(url="")
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
305
|
+
update_environment
|
306
|
+
endpoint = url
|
307
|
+
|
308
|
+
case self.environment
|
309
|
+
when 'Sandbox'
|
310
|
+
case self.region
|
311
|
+
when 'US'
|
312
|
+
endpoint = "https://rest.apisandbox.zuora.com/v1/".concat(url)
|
313
|
+
when 'EU'
|
314
|
+
endpoint = "https://rest.sandbox.eu.zuora.com/v1/".concat(url)
|
315
|
+
when 'NA'
|
316
|
+
endpoint = "https://rest.sandbox.na.zuora.com/v1/".concat(url)
|
317
|
+
end
|
318
|
+
when 'Production'
|
319
|
+
case self.region
|
320
|
+
when 'US'
|
321
|
+
endpoint = "https://rest.zuora.com/v1/".concat(url)
|
322
|
+
when 'EU'
|
323
|
+
endpoint = "https://rest.eu.zuora.com/v1/".concat(url)
|
324
|
+
when 'NA'
|
325
|
+
endpoint = "https://rest.na.zuora.com/v1/".concat(url)
|
326
|
+
end
|
327
|
+
when 'Services', 'Performance'
|
328
|
+
https = /https:\/\/|http:\/\//.match(self.url)[0]
|
329
|
+
host = self.hostname
|
330
|
+
endpoint = "#{https}#{host}/apps/v1/#{url}"
|
331
|
+
when 'Staging'
|
332
|
+
endpoint = "https://rest-staging2.zuora.com/".concat(url)
|
333
|
+
when 'Unknown'
|
334
|
+
raise "Environment unknown, returning passed in parameter unaltered"
|
99
335
|
end
|
336
|
+
return endpoint
|
100
337
|
end
|
101
338
|
|
102
339
|
def fileURL(url="")
|
@@ -282,6 +519,10 @@ module ZuoraAPI
|
|
282
519
|
raise ZuoraAPI::Exceptions::ZuoraAPIAuthenticationTypeError.new()
|
283
520
|
end
|
284
521
|
|
522
|
+
if body['errorMessage']
|
523
|
+
raise ZuoraAPI::Exceptions::ZuoraAPIError.new(body['errorMessage'],body,response.code)
|
524
|
+
end
|
525
|
+
|
285
526
|
if body.dig("reasons").nil? ? false : body.dig("reasons")[0].dig("code") == 90000020
|
286
527
|
raise ZuoraAPI::Exceptions::BadEntityError.new("#{messages_array.join(', ')}", body, response.code)
|
287
528
|
end
|
data/lib/zuora_api/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: zuora_api
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.6.
|
4
|
+
version: 1.6.16
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Zuora Strategic Solutions Group
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2018-12-
|
11
|
+
date: 2018-12-06 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: bundler
|