kennel 1.89.0 → 1.89.1
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/Readme.md +5 -1
- data/lib/kennel/api.rb +67 -44
- data/lib/kennel/file_cache.rb +9 -5
- data/lib/kennel/importer.rb +2 -1
- data/lib/kennel/models/record.rb +1 -1
- data/lib/kennel/progress.rb +2 -0
- data/lib/kennel/syncer.rb +43 -35
- data/lib/kennel/tasks.rb +1 -1
- data/lib/kennel/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ba1d58935b541b89cb78318964470bb209933a491d9b81e23785de0fb6ab2654
|
|
4
|
+
data.tar.gz: aed4b13dc2c35ced80073e592fd0d2ba2afda8dfb763d2c6baa4b1f30851cf53
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d3595608df6e40ec4241bf2b758f1ed1b3fde0e63dff7f5a4fa393a358cf9cb9fa33bb0c5f72899b7df54817cb9482366d9c387e3c21a33249e35e3bcc4fc8ea
|
|
7
|
+
data.tar.gz: 745ec8c762acc885a9d0f2ae23fc9f43ca05f56b8c580501075e2b9237e7199a09499053cebb45e5bfa477aa0a187cabf5899e05f7bf6c361f952347ab6cac34
|
data/Readme.md
CHANGED
|
@@ -52,6 +52,7 @@ end
|
|
|
52
52
|
```
|
|
53
53
|
|
|
54
54
|
<!-- NOT IN template/Readme.md -->
|
|
55
|
+
|
|
55
56
|
## Installation
|
|
56
57
|
|
|
57
58
|
- create a new private `kennel` repo for your organization (do not fork this repo)
|
|
@@ -293,9 +294,12 @@ https://foo.datadog.com/monitor/123
|
|
|
293
294
|
|
|
294
295
|
<!-- NOT IN template/Readme.md -->
|
|
295
296
|
|
|
296
|
-
|
|
297
297
|
## Development
|
|
298
298
|
|
|
299
|
+
### Benchmarking
|
|
300
|
+
|
|
301
|
+
Setting `FORCE_GET_CACHE=true` will cache all get requests, which makes benchmarking improvements more reliable.
|
|
302
|
+
|
|
299
303
|
### Integration testing
|
|
300
304
|
|
|
301
305
|
```Bash
|
data/lib/kennel/api.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# encapsulates knowledge around how the api works
|
|
3
|
+
# especially 1-off weirdness that should not lak into other parts of the code
|
|
2
4
|
module Kennel
|
|
3
|
-
# encapsulates knowledge around how the api works
|
|
4
5
|
class Api
|
|
5
6
|
CACHE_FILE = "tmp/cache/details"
|
|
6
7
|
|
|
@@ -11,34 +12,24 @@ module Kennel
|
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def show(api_resource, id, params = {})
|
|
14
|
-
|
|
15
|
-
api_resource == "slo"
|
|
15
|
+
response = request :get, "/api/v1/#{api_resource}/#{id}", params: params
|
|
16
|
+
response = response.fetch(:data) if api_resource == "slo"
|
|
17
|
+
response
|
|
16
18
|
end
|
|
17
19
|
|
|
18
20
|
def list(api_resource, params = {})
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
loop do
|
|
26
|
-
result = request :get, "/api/v1/#{api_resource}", params: params.merge(limit: limit, offset: offset)
|
|
27
|
-
data = result.fetch(:data)
|
|
28
|
-
all.concat data
|
|
29
|
-
break all if data.size < limit
|
|
30
|
-
offset += limit
|
|
31
|
-
end
|
|
32
|
-
else
|
|
33
|
-
result = request :get, "/api/v1/#{api_resource}", params: params
|
|
34
|
-
result = result.fetch(:dashboards) if api_resource == "dashboard"
|
|
35
|
-
result
|
|
21
|
+
with_pagination api_resource == "slo", params do |paginated_params|
|
|
22
|
+
response = request :get, "/api/v1/#{api_resource}", params: paginated_params
|
|
23
|
+
response = response.fetch(:dashboards) if api_resource == "dashboard"
|
|
24
|
+
response = response.fetch(:data) if api_resource == "slo"
|
|
25
|
+
response
|
|
36
26
|
end
|
|
37
27
|
end
|
|
38
28
|
|
|
39
29
|
def create(api_resource, attributes)
|
|
40
|
-
|
|
41
|
-
|
|
30
|
+
response = request :post, "/api/v1/#{api_resource}", body: attributes
|
|
31
|
+
response = response.fetch(:data).first if api_resource == "slo"
|
|
32
|
+
response
|
|
42
33
|
end
|
|
43
34
|
|
|
44
35
|
def update(api_resource, id, attributes)
|
|
@@ -53,7 +44,6 @@ module Kennel
|
|
|
53
44
|
end
|
|
54
45
|
|
|
55
46
|
def fill_details!(api_resource, list)
|
|
56
|
-
return unless api_resource == "dashboard"
|
|
57
47
|
details_cache do |cache|
|
|
58
48
|
Utils.parallel(list) { |a| fill_detail!(api_resource, a, cache) }
|
|
59
49
|
end
|
|
@@ -61,6 +51,21 @@ module Kennel
|
|
|
61
51
|
|
|
62
52
|
private
|
|
63
53
|
|
|
54
|
+
def with_pagination(enabled, params)
|
|
55
|
+
return yield params unless enabled
|
|
56
|
+
raise ArgumentError if params[:limit] || params[:offset]
|
|
57
|
+
limit = 1000
|
|
58
|
+
offset = 0
|
|
59
|
+
all = []
|
|
60
|
+
|
|
61
|
+
loop do
|
|
62
|
+
response = yield params.merge(limit: limit, offset: offset)
|
|
63
|
+
all.concat response
|
|
64
|
+
return all if response.size < limit
|
|
65
|
+
offset += limit
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
64
69
|
# Make diff work even though we cannot mass-fetch definitions
|
|
65
70
|
def fill_detail!(api_resource, a, cache)
|
|
66
71
|
args = [api_resource, a.fetch(:id)]
|
|
@@ -74,34 +79,52 @@ module Kennel
|
|
|
74
79
|
end
|
|
75
80
|
|
|
76
81
|
def request(method, path, body: nil, params: {}, ignore_404: false)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
82
|
+
path = "#{path}?#{Faraday::FlatParamsEncoder.encode(params)}" if params.any?
|
|
83
|
+
with_cache ENV["FORCE_GET_CACHE"] && method == :get, path do
|
|
84
|
+
response = nil
|
|
85
|
+
tries = 2
|
|
86
|
+
|
|
87
|
+
tries.times do |i|
|
|
88
|
+
response = Utils.retry Faraday::ConnectionFailed, Faraday::TimeoutError, times: 2 do
|
|
89
|
+
@client.send(method, path) do |request|
|
|
90
|
+
request.body = JSON.generate(body) if body
|
|
91
|
+
request.headers["Content-type"] = "application/json"
|
|
92
|
+
request.headers["DD-API-KEY"] = @api_key
|
|
93
|
+
request.headers["DD-APPLICATION-KEY"] = @app_key
|
|
94
|
+
end
|
|
87
95
|
end
|
|
96
|
+
|
|
97
|
+
break if i == tries - 1 || method != :get || response.status < 500
|
|
98
|
+
Kennel.err.puts "Retrying on server error #{response.status} for #{path}"
|
|
88
99
|
end
|
|
89
100
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
101
|
+
if !response.success? && (response.status != 404 || !ignore_404)
|
|
102
|
+
message = +"Error #{response.status} during #{method.upcase} #{path}\n"
|
|
103
|
+
message << "request:\n#{JSON.pretty_generate(body)}\nresponse:\n" if body
|
|
104
|
+
message << response.body
|
|
105
|
+
raise message
|
|
106
|
+
end
|
|
93
107
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
108
|
+
if response.body.empty?
|
|
109
|
+
{}
|
|
110
|
+
else
|
|
111
|
+
JSON.parse(response.body, symbolize_names: true)
|
|
112
|
+
end
|
|
99
113
|
end
|
|
114
|
+
end
|
|
100
115
|
|
|
101
|
-
|
|
102
|
-
|
|
116
|
+
# allow caching all requests to speedup/benchmark logic that includes repeated requests
|
|
117
|
+
def with_cache(enabled, key)
|
|
118
|
+
return yield unless enabled
|
|
119
|
+
dir = "tmp/cache"
|
|
120
|
+
FileUtils.mkdir_p(dir) unless File.directory?(dir)
|
|
121
|
+
file = "#{dir}/#{key.delete("/?=")}" # TODO: encode nicely
|
|
122
|
+
if File.exist?(file)
|
|
123
|
+
Marshal.load(File.read(file)) # rubocop:disable Security/MarshalLoad
|
|
103
124
|
else
|
|
104
|
-
|
|
125
|
+
result = yield
|
|
126
|
+
File.write(file, Marshal.dump(result))
|
|
127
|
+
result
|
|
105
128
|
end
|
|
106
129
|
end
|
|
107
130
|
end
|
data/lib/kennel/file_cache.rb
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# cache that reads everything from a single file
|
|
4
|
-
#
|
|
5
|
-
#
|
|
4
|
+
# - avoids doing multiple disk reads while iterating all definitions
|
|
5
|
+
# - has a global expiry to not keep deleted resources forever
|
|
6
6
|
module Kennel
|
|
7
7
|
class FileCache
|
|
8
8
|
def initialize(file, cache_version)
|
|
@@ -22,10 +22,11 @@ module Kennel
|
|
|
22
22
|
|
|
23
23
|
def fetch(key, key_version)
|
|
24
24
|
old_value, old_version = @data[key]
|
|
25
|
-
|
|
25
|
+
expected_version = [key_version, @cache_version]
|
|
26
|
+
return old_value if old_version == expected_version
|
|
26
27
|
|
|
27
28
|
new_value = yield
|
|
28
|
-
@data[key] = [new_value,
|
|
29
|
+
@data[key] = [new_value, expected_version, @expires]
|
|
29
30
|
new_value
|
|
30
31
|
end
|
|
31
32
|
|
|
@@ -46,8 +47,11 @@ module Kennel
|
|
|
46
47
|
File.write(@file, Marshal.dump(@data))
|
|
47
48
|
end
|
|
48
49
|
|
|
50
|
+
# keep the cache small to make loading it fast (5MB ~= 100ms)
|
|
51
|
+
# - delete expired keys
|
|
52
|
+
# - delete what would be deleted anyway when updating
|
|
49
53
|
def expire_old_data
|
|
50
|
-
@data.reject! { |_, (_, _,
|
|
54
|
+
@data.reject! { |_, (_, (_, cv), expires)| expires < @now || cv != @cache_version }
|
|
51
55
|
end
|
|
52
56
|
end
|
|
53
57
|
end
|
data/lib/kennel/importer.rb
CHANGED
|
@@ -113,8 +113,9 @@ module Kennel
|
|
|
113
113
|
next if request[:formulas] && request[:formulas] != [{ formula: "query1" }]
|
|
114
114
|
next if request[:queries]&.size != 1
|
|
115
115
|
next if request[:queries].any? { |q| q[:data_source] != "metrics" }
|
|
116
|
+
next if widget.dig(:definition, :type) != request[:response_format]
|
|
116
117
|
request.delete(:formulas)
|
|
117
|
-
request
|
|
118
|
+
request.delete(:response_format)
|
|
118
119
|
request[:q] = request.delete(:queries).first.fetch(:query)
|
|
119
120
|
end
|
|
120
121
|
end
|
data/lib/kennel/models/record.rb
CHANGED
|
@@ -6,7 +6,7 @@ module Kennel
|
|
|
6
6
|
TRACKING_FIELDS = [:message, :description].freeze
|
|
7
7
|
READONLY_ATTRIBUTES = [
|
|
8
8
|
:deleted, :id, :created, :created_at, :creator, :org_id, :modified, :modified_at,
|
|
9
|
-
:klass # added by syncer.rb
|
|
9
|
+
:klass, :tracking_id # added by syncer.rb
|
|
10
10
|
].freeze
|
|
11
11
|
|
|
12
12
|
settings :id, :kennel_id
|
data/lib/kennel/progress.rb
CHANGED
data/lib/kennel/syncer.rb
CHANGED
|
@@ -2,21 +2,12 @@
|
|
|
2
2
|
module Kennel
|
|
3
3
|
class Syncer
|
|
4
4
|
DELETE_ORDER = ["dashboard", "slo", "monitor"].freeze # dashboards references monitors + slos, slos reference monitors
|
|
5
|
-
LINE_UP = "\e[1A"
|
|
5
|
+
LINE_UP = "\e[1A\033[K" # go up and clear
|
|
6
6
|
|
|
7
7
|
def initialize(api, expected, project: nil)
|
|
8
8
|
@api = api
|
|
9
9
|
@project_filter = project
|
|
10
10
|
@expected = expected
|
|
11
|
-
if @project_filter
|
|
12
|
-
original = @expected
|
|
13
|
-
@expected = @expected.select { |e| e.project.kennel_id == @project_filter }
|
|
14
|
-
if @expected.empty?
|
|
15
|
-
possible = original.map { |e| e.project.kennel_id }.uniq.sort
|
|
16
|
-
raise "#{@project_filter} does not match any projects, try any of these:\n#{possible.join("\n")}"
|
|
17
|
-
end
|
|
18
|
-
end
|
|
19
|
-
@expected.each(&:add_tracking_id)
|
|
20
11
|
calculate_diff
|
|
21
12
|
prevent_irreversible_partial_updates
|
|
22
13
|
end
|
|
@@ -41,9 +32,9 @@ module Kennel
|
|
|
41
32
|
message = "#{e.class.api_resource} #{e.tracking_id}"
|
|
42
33
|
Kennel.out.puts "Creating #{message}"
|
|
43
34
|
reply = @api.create e.class.api_resource, e.as_json
|
|
44
|
-
reply
|
|
35
|
+
cache_metadata reply, e.class
|
|
45
36
|
id = reply.fetch(:id)
|
|
46
|
-
populate_id_map [reply] # allow resolving ids we could previously no resolve
|
|
37
|
+
populate_id_map [], [reply] # allow resolving ids we could previously no resolve
|
|
47
38
|
Kennel.out.puts "#{LINE_UP}Created #{message} #{e.class.url(id)}"
|
|
48
39
|
end
|
|
49
40
|
|
|
@@ -56,8 +47,7 @@ module Kennel
|
|
|
56
47
|
|
|
57
48
|
@delete.each do |id, _, a|
|
|
58
49
|
klass = a.fetch(:klass)
|
|
59
|
-
|
|
60
|
-
message = "#{klass.api_resource} #{tracking_id} #{id}"
|
|
50
|
+
message = "#{klass.api_resource} #{a.fetch(:tracking_id)} #{id}"
|
|
61
51
|
Kennel.out.puts "Deleting #{message}"
|
|
62
52
|
@api.delete klass.api_resource, id
|
|
63
53
|
Kennel.out.puts "#{LINE_UP}Deleted #{message}"
|
|
@@ -108,14 +98,14 @@ module Kennel
|
|
|
108
98
|
|
|
109
99
|
actual = Progress.progress("Downloading definitions") { download_definitions }
|
|
110
100
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
101
|
+
Progress.progress "Diffing" do
|
|
102
|
+
filter_expected_by_project! @expected
|
|
103
|
+
populate_id_map @expected, actual
|
|
104
|
+
filter_actual_by_project! actual
|
|
105
|
+
resolve_linked_tracking_ids! @expected # resolve dependencies to avoid diff
|
|
115
106
|
|
|
116
|
-
|
|
107
|
+
@expected.each(&:add_tracking_id) # avoid diff with actual
|
|
117
108
|
|
|
118
|
-
Progress.progress "Diffing" do
|
|
119
109
|
items = actual.map do |a|
|
|
120
110
|
e = matching_expected(a)
|
|
121
111
|
if e && @expected.delete(e)
|
|
@@ -126,9 +116,8 @@ module Kennel
|
|
|
126
116
|
end
|
|
127
117
|
|
|
128
118
|
# fill details of things we need to compare
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
detailed.each { |klass, actuals| @api.fill_details! klass.api_resource, actuals }
|
|
119
|
+
details = items.map { |e, a| a if e && e.class.api_resource == "dashboard" }.compact
|
|
120
|
+
@api.fill_details! "dashboard", details
|
|
132
121
|
|
|
133
122
|
# pick out things to update or delete
|
|
134
123
|
items.each do |e, a|
|
|
@@ -136,26 +125,30 @@ module Kennel
|
|
|
136
125
|
if e
|
|
137
126
|
diff = e.diff(a)
|
|
138
127
|
@update << [id, e, a, diff] if diff.any?
|
|
139
|
-
elsif a.fetch(:
|
|
128
|
+
elsif a.fetch(:tracking_id) # was previously managed
|
|
140
129
|
@delete << [id, nil, a]
|
|
141
130
|
end
|
|
142
131
|
end
|
|
143
132
|
|
|
144
133
|
ensure_all_ids_found
|
|
145
134
|
@create = @expected.map { |e| [nil, e] }
|
|
135
|
+
@delete.sort_by! { |_, _, a| DELETE_ORDER.index a.fetch(:klass).api_resource }
|
|
146
136
|
end
|
|
147
|
-
|
|
148
|
-
@delete.sort_by! { |_, _, a| DELETE_ORDER.index a.fetch(:klass).api_resource }
|
|
149
137
|
end
|
|
150
138
|
|
|
151
139
|
def download_definitions
|
|
152
140
|
Utils.parallel(Models::Record.subclasses) do |klass|
|
|
153
141
|
results = @api.list(klass.api_resource, with_downtimes: false) # lookup monitors without adding unnecessary downtime information
|
|
154
142
|
results = results[results.keys.first] if results.is_a?(Hash) # dashboards are nested in {dashboards: []}
|
|
155
|
-
results.each { |
|
|
143
|
+
results.each { |a| cache_metadata(a, klass) }
|
|
156
144
|
end.flatten(1)
|
|
157
145
|
end
|
|
158
146
|
|
|
147
|
+
def cache_metadata(a, klass)
|
|
148
|
+
a[:klass] = klass
|
|
149
|
+
a[:tracking_id] = a.fetch(:klass).parse_tracking_id(a)
|
|
150
|
+
end
|
|
151
|
+
|
|
159
152
|
def ensure_all_ids_found
|
|
160
153
|
@expected.each do |e|
|
|
161
154
|
next unless id = e.id
|
|
@@ -176,14 +169,14 @@ module Kennel
|
|
|
176
169
|
end
|
|
177
170
|
|
|
178
171
|
klass = a.fetch(:klass)
|
|
179
|
-
@lookup_map["#{klass.api_resource}:#{a.fetch(:id)}"] || @lookup_map[
|
|
172
|
+
@lookup_map["#{klass.api_resource}:#{a.fetch(:id)}"] || @lookup_map[a.fetch(:tracking_id)]
|
|
180
173
|
end
|
|
181
174
|
|
|
182
175
|
def print_plan(step, list, color)
|
|
183
176
|
return if list.empty?
|
|
184
177
|
list.each do |_, e, a, diff|
|
|
185
178
|
klass = (e ? e.class : a.fetch(:klass))
|
|
186
|
-
Kennel.out.puts Utils.color(color, "#{step} #{klass.api_resource} #{e&.tracking_id ||
|
|
179
|
+
Kennel.out.puts Utils.color(color, "#{step} #{klass.api_resource} #{e&.tracking_id || a.fetch(:tracking_id)}")
|
|
187
180
|
print_diff(diff) if diff # only for update
|
|
188
181
|
end
|
|
189
182
|
end
|
|
@@ -232,19 +225,34 @@ module Kennel
|
|
|
232
225
|
end
|
|
233
226
|
end
|
|
234
227
|
|
|
235
|
-
def populate_id_map(actual)
|
|
236
|
-
actual.each
|
|
228
|
+
def populate_id_map(expected, actual)
|
|
229
|
+
actual.each do |a|
|
|
230
|
+
next unless tracking_id = a.fetch(:tracking_id)
|
|
231
|
+
@id_map[tracking_id] = a.fetch(:id)
|
|
232
|
+
end
|
|
233
|
+
expected.each { |e| @id_map[e.tracking_id] ||= :new }
|
|
237
234
|
end
|
|
238
235
|
|
|
239
236
|
def resolve_linked_tracking_ids!(list, force: false)
|
|
240
237
|
list.each { |e| e.resolve_linked_tracking_ids!(@id_map, force: force) }
|
|
241
238
|
end
|
|
242
239
|
|
|
243
|
-
def
|
|
240
|
+
def filter_actual_by_project!(actual)
|
|
241
|
+
return unless @project_filter
|
|
242
|
+
actual.select! do |a|
|
|
243
|
+
tracking_id = a.fetch(:tracking_id)
|
|
244
|
+
!tracking_id || tracking_id.start_with?("#{@project_filter}:")
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def filter_expected_by_project!(expected)
|
|
244
249
|
return unless @project_filter
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
250
|
+
original = expected.dup
|
|
251
|
+
expected.select! { |e| e.project.kennel_id == @project_filter }
|
|
252
|
+
|
|
253
|
+
if expected.empty?
|
|
254
|
+
possible = original.map { |e| e.project.kennel_id }.uniq.sort
|
|
255
|
+
raise "#{@project_filter} does not match any projects, try any of these:\n#{possible.join("\n")}"
|
|
248
256
|
end
|
|
249
257
|
end
|
|
250
258
|
end
|
data/lib/kennel/tasks.rb
CHANGED
|
@@ -138,7 +138,7 @@ namespace :kennel do
|
|
|
138
138
|
resources.each do |resource|
|
|
139
139
|
Kennel::Progress.progress("Downloading #{resource}") do
|
|
140
140
|
list = api.list(resource)
|
|
141
|
-
api.fill_details!(resource, list)
|
|
141
|
+
api.fill_details!(resource, list) if resource == "dashboard"
|
|
142
142
|
end
|
|
143
143
|
list.each do |r|
|
|
144
144
|
r[:api_resource] = resource
|
data/lib/kennel/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: kennel
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.89.
|
|
4
|
+
version: 1.89.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michael Grosser
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2021-
|
|
11
|
+
date: 2021-07-01 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: faraday
|