testingbot 0.2.3 → 1.0.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 +4 -4
- data/.github/workflows/release.yml +49 -0
- data/.github/workflows/test.yml +25 -10
- data/CHANGELOG.md +61 -0
- data/README.md +217 -62
- data/Rakefile +15 -14
- data/bin/testingbot +32 -11
- data/lib/testingbot/api.rb +107 -205
- data/lib/testingbot/connection.rb +193 -0
- data/lib/testingbot/errors.rb +52 -0
- data/lib/testingbot/resources/builds.rb +21 -0
- data/lib/testingbot/resources/jobs.rb +11 -0
- data/lib/testingbot/resources/lab.rb +74 -0
- data/lib/testingbot/resources/lab_suites.rb +46 -0
- data/lib/testingbot/resources/platform.rb +29 -0
- data/lib/testingbot/resources/screenshots.rb +19 -0
- data/lib/testingbot/resources/sharing.rb +13 -0
- data/lib/testingbot/resources/storage.rb +44 -0
- data/lib/testingbot/resources/team.rb +35 -0
- data/lib/testingbot/resources/tests.rb +28 -0
- data/lib/testingbot/resources/tunnels.rb +20 -0
- data/lib/testingbot/resources/user.rb +19 -0
- data/lib/testingbot/version.rb +2 -2
- data/lib/testingbot.rb +4 -1
- metadata +37 -13
- data/spec/integration/api_spec.rb +0 -108
- data/spec/resources/test.apk +0 -1
- data/spec/spec_helper.rb +0 -7
data/bin/testingbot
CHANGED
|
@@ -1,19 +1,40 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
|
|
3
|
+
begin
|
|
4
|
+
require 'io/console'
|
|
5
|
+
rescue LoadError
|
|
6
|
+
# io/console is unavailable (e.g. some JRuby builds); fall back to plain gets.
|
|
7
|
+
end
|
|
8
|
+
|
|
3
9
|
if ARGV.any?
|
|
4
|
-
api_key, api_secret = ARGV.first.split(':')
|
|
10
|
+
api_key, api_secret = ARGV.first.split(':', 2)
|
|
5
11
|
else
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
12
|
+
puts "This utility will help you set up your system to use TestingBot's infrastructure."
|
|
13
|
+
puts "Once you have entered your API key and secret you will be ready to use our grid."
|
|
14
|
+
puts "You can get these credentials from the TestingBot dashboard: https://testingbot.com/members"
|
|
15
|
+
|
|
16
|
+
print "Please enter your TestingBot API KEY: "
|
|
17
|
+
api_key = (gets || '').chomp
|
|
10
18
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
19
|
+
print "Please enter your TestingBot API SECRET: "
|
|
20
|
+
api_secret =
|
|
21
|
+
if $stdin.respond_to?(:noecho) && $stdin.tty?
|
|
22
|
+
value = $stdin.noecho(&:gets)
|
|
23
|
+
puts
|
|
24
|
+
(value || '').chomp
|
|
25
|
+
else
|
|
26
|
+
(gets || '').chomp
|
|
27
|
+
end
|
|
15
28
|
end
|
|
16
29
|
|
|
17
|
-
|
|
30
|
+
if api_key.to_s.empty? || api_secret.to_s.empty?
|
|
31
|
+
abort "Both an API key and secret are required. Nothing was written."
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
config_file = File.join(Dir.home, ".testingbot")
|
|
35
|
+
File.open(config_file, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |f|
|
|
36
|
+
f.write("#{api_key}:#{api_secret}")
|
|
37
|
+
end
|
|
38
|
+
File.chmod(0o600, config_file)
|
|
18
39
|
|
|
19
|
-
|
|
40
|
+
puts "Your system is now ready to use TestingBot's grid infrastructure."
|
data/lib/testingbot/api.rb
CHANGED
|
@@ -1,253 +1,155 @@
|
|
|
1
|
+
require 'cgi'
|
|
2
|
+
require 'uri'
|
|
3
|
+
require 'digest'
|
|
1
4
|
require 'json'
|
|
2
|
-
require "rest-client"
|
|
3
5
|
|
|
4
|
-
|
|
6
|
+
require 'testingbot/errors'
|
|
7
|
+
require 'testingbot/connection'
|
|
8
|
+
|
|
9
|
+
require 'testingbot/resources/user'
|
|
10
|
+
require 'testingbot/resources/team'
|
|
11
|
+
require 'testingbot/resources/platform'
|
|
12
|
+
require 'testingbot/resources/tests'
|
|
13
|
+
require 'testingbot/resources/builds'
|
|
14
|
+
require 'testingbot/resources/screenshots'
|
|
15
|
+
require 'testingbot/resources/tunnels'
|
|
16
|
+
require 'testingbot/resources/storage'
|
|
17
|
+
require 'testingbot/resources/jobs'
|
|
18
|
+
require 'testingbot/resources/lab'
|
|
19
|
+
require 'testingbot/resources/lab_suites'
|
|
20
|
+
require 'testingbot/resources/sharing'
|
|
5
21
|
|
|
22
|
+
module TestingBot
|
|
23
|
+
# Ruby client for the TestingBot REST API (https://testingbot.com/support/api).
|
|
24
|
+
#
|
|
25
|
+
# Credentials are resolved, in order, from: explicit arguments, the
|
|
26
|
+
# ~/.testingbot file, the TESTINGBOT_KEY/TESTINGBOT_SECRET environment
|
|
27
|
+
# variables, then TB_KEY/TB_SECRET.
|
|
28
|
+
#
|
|
29
|
+
# Endpoint methods live in the TestingBot::Resources::* mixins (one per API
|
|
30
|
+
# category) and are included below; they call the private get/post/put/delete
|
|
31
|
+
# helpers and @connection. Transport (timeouts, retries, error handling) lives
|
|
32
|
+
# in TestingBot::Connection; a custom connection can be injected via
|
|
33
|
+
# options[:connection] for testing.
|
|
6
34
|
class Api
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
35
|
+
# Public API version used in the URL path (https://api.testingbot.com/v1/...).
|
|
36
|
+
API_URL = Connection::API_URL
|
|
37
|
+
API_VERSION = Connection::API_VERSION
|
|
38
|
+
# Deprecated alias kept for backward compatibility (this is the *API* version,
|
|
39
|
+
# not the gem version — see TestingBot::VERSION for the gem version).
|
|
40
|
+
VERSION = API_VERSION
|
|
41
|
+
|
|
42
|
+
include Resources::User
|
|
43
|
+
include Resources::Team
|
|
44
|
+
include Resources::Platform
|
|
45
|
+
include Resources::Tests
|
|
46
|
+
include Resources::Builds
|
|
47
|
+
include Resources::Screenshots
|
|
48
|
+
include Resources::Tunnels
|
|
49
|
+
include Resources::Storage
|
|
50
|
+
include Resources::Jobs
|
|
51
|
+
include Resources::Lab
|
|
52
|
+
include Resources::LabSuites
|
|
53
|
+
include Resources::Sharing
|
|
10
54
|
|
|
11
55
|
attr_reader :key, :secret, :options
|
|
12
56
|
|
|
13
57
|
def initialize(key = nil, secret = nil, options = {})
|
|
14
|
-
@
|
|
15
|
-
@
|
|
16
|
-
@
|
|
17
|
-
|
|
18
|
-
}.merge(options)
|
|
19
|
-
if @key.nil? || @secret.nil?
|
|
20
|
-
cached_credentials = load_config_file
|
|
21
|
-
@key, @secret = cached_credentials unless cached_credentials.nil?
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
if @key.nil? || @secret.nil?
|
|
25
|
-
@key = ENV["TESTINGBOT_KEY"] if ENV["TESTINGBOT_KEY"]
|
|
26
|
-
@secret = ENV["TESTINGBOT_SECRET"] if ENV["TESTINGBOT_SECRET"]
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
if @key.nil? || @secret.nil?
|
|
30
|
-
@key = ENV["TB_KEY"] if ENV["TB_KEY"]
|
|
31
|
-
@secret = ENV["TB_SECRET"] if ENV["TB_SECRET"]
|
|
32
|
-
end
|
|
58
|
+
@options = { :debug => false }.merge(options)
|
|
59
|
+
@key = key
|
|
60
|
+
@secret = secret
|
|
61
|
+
resolve_credentials!
|
|
33
62
|
|
|
34
|
-
raise "Please supply a TestingBot Key"
|
|
63
|
+
raise "Please supply a TestingBot Key" if @key.nil?
|
|
35
64
|
raise "Please supply a TestingBot Secret" if @secret.nil?
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def get_user_info
|
|
39
|
-
get("/user")
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def get_browsers
|
|
43
|
-
get("/browsers")
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def get_devices
|
|
47
|
-
get("/devices")
|
|
48
|
-
end
|
|
49
65
|
|
|
50
|
-
|
|
51
|
-
|
|
66
|
+
@connection = @options[:connection] ||
|
|
67
|
+
Connection.new(key: @key, secret: @secret, options: @options)
|
|
52
68
|
end
|
|
53
69
|
|
|
54
|
-
|
|
55
|
-
get("/team-management")
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def get_users_in_team(offset = 0, count = 10)
|
|
59
|
-
get("/team-management/users?offset=#{offset}&count=#{count}")
|
|
60
|
-
end
|
|
70
|
+
private
|
|
61
71
|
|
|
62
|
-
def
|
|
63
|
-
get
|
|
72
|
+
def get(path)
|
|
73
|
+
@connection.request(:get, path)
|
|
64
74
|
end
|
|
65
75
|
|
|
66
|
-
def
|
|
67
|
-
post
|
|
76
|
+
def post(path, params = {})
|
|
77
|
+
@connection.request(:post, path, params)
|
|
68
78
|
end
|
|
69
79
|
|
|
70
|
-
def
|
|
71
|
-
put
|
|
80
|
+
def put(path, params = {})
|
|
81
|
+
@connection.request(:put, path, params)
|
|
72
82
|
end
|
|
73
83
|
|
|
74
|
-
def
|
|
75
|
-
|
|
84
|
+
def delete(path, params = {})
|
|
85
|
+
@connection.request(:delete, path, params)
|
|
76
86
|
end
|
|
77
87
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
88
|
+
# Wrap a flat hash under a bracketed prefix, e.g. wrap_params("test", name: "x")
|
|
89
|
+
# => { "test[name]" => "x" } — the form encoding the Rails-style backend expects.
|
|
90
|
+
def wrap_params(prefix, params)
|
|
91
|
+
params.each_with_object({}) do |(key, value), memo|
|
|
92
|
+
memo["#{prefix}[#{key}]"] = value
|
|
82
93
|
end
|
|
83
|
-
response = put("/user", new_params)
|
|
84
|
-
response["success"]
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def get_tests(offset = 0, count = 10)
|
|
88
|
-
get("/tests?offset=#{offset}&count=#{count}")
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def get_test(test_id)
|
|
92
|
-
get("/tests/#{test_id}")
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def update_test(test_id, params = {})
|
|
96
|
-
new_params = {}
|
|
97
|
-
params.keys.each do |key|
|
|
98
|
-
new_params["test[#{key}]"] = params[key]
|
|
99
|
-
end
|
|
100
|
-
response = put("/tests/#{test_id}", new_params)
|
|
101
|
-
response["success"]
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
def delete_test(test_id)
|
|
105
|
-
response = delete("/tests/#{test_id}")
|
|
106
|
-
response["success"]
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
def stop_test(test_id)
|
|
110
|
-
response = put("/tests/#{test_id}/stop")
|
|
111
|
-
response["success"]
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def get_builds(offset = 0, count = 10)
|
|
115
|
-
get("/builds?offset=#{offset}&count=#{count}")
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def get_build(build_identifier)
|
|
119
|
-
get("/builds/#{build_identifier}")
|
|
120
94
|
end
|
|
121
95
|
|
|
122
|
-
|
|
123
|
-
|
|
96
|
+
# Build a "?a=1&b=2" query string from a hash, dropping nil values and
|
|
97
|
+
# URL-encoding keys/values. Returns "" when nothing is left.
|
|
98
|
+
def query(params)
|
|
99
|
+
filtered = params.reject { |_, value| value.nil? }
|
|
100
|
+
return "" if filtered.empty?
|
|
101
|
+
"?" + URI.encode_www_form(filtered)
|
|
124
102
|
end
|
|
125
103
|
|
|
126
|
-
|
|
127
|
-
|
|
104
|
+
# URL-encode a path segment (numeric IDs, session IDs, etc.).
|
|
105
|
+
def escape(value)
|
|
106
|
+
CGI.escape(value.to_s)
|
|
128
107
|
end
|
|
129
108
|
|
|
130
|
-
|
|
131
|
-
|
|
109
|
+
# Strip the tb:// scheme from an app URL. The value targets a wildcard route,
|
|
110
|
+
# so any remaining slashes are intentionally preserved (not escaped).
|
|
111
|
+
def strip_scheme(app_url)
|
|
112
|
+
app_url.to_s.gsub(%r{tb://}, '')
|
|
132
113
|
end
|
|
133
114
|
|
|
134
|
-
def
|
|
135
|
-
|
|
115
|
+
def success?(response)
|
|
116
|
+
response.is_a?(Hash) ? !!response["success"] : false
|
|
136
117
|
end
|
|
137
118
|
|
|
138
|
-
def
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
def delete_tunnel(tunnel_identifier)
|
|
143
|
-
delete("/tunnel/#{tunnel_identifier}")
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def get_authentication_hash(identifier)
|
|
147
|
-
Digest::MD5.hexdigest("#{@key}:#{@secret}:#{identifier}")
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
def upload_local_file(file_path)
|
|
151
|
-
response = RestClient::Request.execute(
|
|
152
|
-
method: :post,
|
|
153
|
-
url: API_URL + "/v1/storage",
|
|
154
|
-
user: @key,
|
|
155
|
-
password: @secret,
|
|
156
|
-
timeout: 600,
|
|
157
|
-
payload: {
|
|
158
|
-
multipart: true,
|
|
159
|
-
file: File.new(file_path, 'rb')
|
|
160
|
-
}
|
|
161
|
-
)
|
|
162
|
-
parsed = JSON.parse(response.body)
|
|
163
|
-
parsed
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
def upload_remote_file(url)
|
|
167
|
-
post("/storage/", {
|
|
168
|
-
:url => url
|
|
169
|
-
})
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
def get_uploaded_files(offset = 0, count = 10)
|
|
173
|
-
get("/storage?offset=#{offset}&count=#{count}")
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
def get_uploaded_file(app_url)
|
|
177
|
-
get("/storage/#{app_url.gsub(/tb:\/\//, '')}")
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
def delete_uploaded_file(app_url)
|
|
181
|
-
response = delete("/storage/#{app_url.gsub(/tb:\/\//, '')}")
|
|
182
|
-
response["success"]
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
private
|
|
186
|
-
|
|
187
|
-
def load_config_file
|
|
188
|
-
config_file = File.join(Dir.home, ".testingbot")
|
|
189
|
-
|
|
190
|
-
if File.exist?(config_file)
|
|
191
|
-
str = File.open(config_file) { |f| f.readline }.chomp
|
|
192
|
-
return str.split(':')
|
|
119
|
+
def resolve_credentials!
|
|
120
|
+
if @key.nil? || @secret.nil?
|
|
121
|
+
cached = load_config_file
|
|
122
|
+
@key, @secret = cached unless cached.nil?
|
|
193
123
|
end
|
|
194
|
-
|
|
195
|
-
return nil
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
def get(url)
|
|
199
|
-
uri = API_URL + '/v' + VERSION.to_s + url
|
|
200
124
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
p parsed if @options[:debug]
|
|
205
|
-
if !parsed.is_a?(Array) && !parsed["error"].nil? && !parsed["error"].empty?
|
|
206
|
-
raise parsed["error"]
|
|
125
|
+
if @key.nil? || @secret.nil?
|
|
126
|
+
@key = ENV["TESTINGBOT_KEY"] if ENV["TESTINGBOT_KEY"]
|
|
127
|
+
@secret = ENV["TESTINGBOT_SECRET"] if ENV["TESTINGBOT_SECRET"]
|
|
207
128
|
end
|
|
208
129
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
def put(url, params = {})
|
|
213
|
-
uri = API_URL + '/v' + VERSION.to_s + url
|
|
214
|
-
|
|
215
|
-
response = RestClient::Request.execute method: :put, url: uri, payload: params, user: @key, password: @secret
|
|
216
|
-
parsed = JSON.parse(response.body)
|
|
217
|
-
|
|
218
|
-
p parsed if @options[:debug]
|
|
219
|
-
if !parsed.is_a?(Array) && !parsed["error"].nil? && !parsed["error"].empty?
|
|
220
|
-
raise parsed["error"]
|
|
130
|
+
if @key.nil? || @secret.nil?
|
|
131
|
+
@key = ENV["TB_KEY"] if ENV["TB_KEY"]
|
|
132
|
+
@secret = ENV["TB_SECRET"] if ENV["TB_SECRET"]
|
|
221
133
|
end
|
|
222
|
-
|
|
223
|
-
parsed
|
|
224
134
|
end
|
|
225
135
|
|
|
226
|
-
def
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
parsed = JSON.parse(response.body)
|
|
136
|
+
def load_config_file
|
|
137
|
+
config_file = File.join(Dir.home, ".testingbot")
|
|
138
|
+
return nil unless File.exist?(config_file)
|
|
230
139
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
raise parsed["error"]
|
|
140
|
+
if insecure_permissions?(config_file)
|
|
141
|
+
warn("TestingBot: #{config_file} is readable by other users; run `chmod 600 #{config_file}`")
|
|
234
142
|
end
|
|
235
143
|
|
|
236
|
-
|
|
144
|
+
str = File.open(config_file) { |f| f.readline }.chomp
|
|
145
|
+
# Split on the first colon only, so a secret containing ':' is preserved.
|
|
146
|
+
str.split(':', 2)
|
|
237
147
|
end
|
|
238
148
|
|
|
239
|
-
def
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
parsed = JSON.parse(response.body)
|
|
244
|
-
|
|
245
|
-
p parsed if @options[:debug]
|
|
246
|
-
if !parsed.is_a?(Array) && !parsed["error"].nil? && !parsed["error"].empty?
|
|
247
|
-
raise parsed["error"]
|
|
248
|
-
end
|
|
249
|
-
|
|
250
|
-
parsed
|
|
149
|
+
def insecure_permissions?(path)
|
|
150
|
+
(File.stat(path).mode & 0o077) != 0
|
|
151
|
+
rescue StandardError
|
|
152
|
+
false
|
|
251
153
|
end
|
|
252
154
|
end
|
|
253
155
|
end
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'rest-client'
|
|
3
|
+
|
|
4
|
+
require 'testingbot/errors'
|
|
5
|
+
|
|
6
|
+
module TestingBot
|
|
7
|
+
# Transport layer for the TestingBot API.
|
|
8
|
+
#
|
|
9
|
+
# Owns the base URL, API version, credentials and HTTP client, and exposes a
|
|
10
|
+
# single +request+ entry point that every endpoint goes through. Centralising
|
|
11
|
+
# transport here means timeouts, retries/backoff, rate-limit handling, JSON
|
|
12
|
+
# parsing, error mapping and debug redaction all live in one place.
|
|
13
|
+
#
|
|
14
|
+
# The HTTP client is injectable (+http:+) so the gem can be unit-tested
|
|
15
|
+
# without real network access.
|
|
16
|
+
class Connection
|
|
17
|
+
API_URL = "https://api.testingbot.com".freeze
|
|
18
|
+
API_VERSION = 1
|
|
19
|
+
|
|
20
|
+
DEFAULT_OPEN_TIMEOUT = 10
|
|
21
|
+
DEFAULT_READ_TIMEOUT = 30
|
|
22
|
+
DEFAULT_MAX_RETRIES = 3
|
|
23
|
+
|
|
24
|
+
# Verbs safe to retry automatically. POST is excluded so we never replay a
|
|
25
|
+
# side-effecting create/upload.
|
|
26
|
+
IDEMPOTENT = [:get, :put, :delete].freeze
|
|
27
|
+
|
|
28
|
+
# Transient failures worth retrying on idempotent verbs.
|
|
29
|
+
RETRYABLE = [
|
|
30
|
+
RestClient::RequestTimeout, # 408
|
|
31
|
+
RestClient::TooManyRequests, # 429
|
|
32
|
+
RestClient::InternalServerError, # 500
|
|
33
|
+
RestClient::BadGateway, # 502
|
|
34
|
+
RestClient::ServiceUnavailable, # 503
|
|
35
|
+
RestClient::GatewayTimeout, # 504
|
|
36
|
+
RestClient::Exceptions::Timeout, # open/read timeout
|
|
37
|
+
RestClient::ServerBrokeConnection,
|
|
38
|
+
SocketError,
|
|
39
|
+
Errno::ECONNRESET,
|
|
40
|
+
Errno::ECONNREFUSED,
|
|
41
|
+
Errno::EHOSTUNREACH,
|
|
42
|
+
Errno::ETIMEDOUT
|
|
43
|
+
].freeze
|
|
44
|
+
|
|
45
|
+
# Response keys whose values are redacted in debug output.
|
|
46
|
+
REDACT_KEYS = %w[key secret api_key api_secret client_key token password].freeze
|
|
47
|
+
|
|
48
|
+
def initialize(key:, secret:, options: {}, http: RestClient::Request)
|
|
49
|
+
@key = key
|
|
50
|
+
@secret = secret
|
|
51
|
+
@options = options || {}
|
|
52
|
+
@http = http
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Execute an HTTP request and return the parsed JSON body.
|
|
56
|
+
#
|
|
57
|
+
# verb - one of :get, :post, :put, :delete
|
|
58
|
+
# path - path after the version prefix, e.g. "/tests/123"
|
|
59
|
+
# params - request payload (ignored for :get and for empty payloads)
|
|
60
|
+
# opts - per-call overrides, e.g. { timeout: 600 } for uploads
|
|
61
|
+
def request(verb, path, params = {}, opts = {})
|
|
62
|
+
idempotent = IDEMPOTENT.include?(verb)
|
|
63
|
+
attempt = 0
|
|
64
|
+
|
|
65
|
+
begin
|
|
66
|
+
parse(execute(verb, path, params, opts))
|
|
67
|
+
rescue *RETRYABLE => e
|
|
68
|
+
attempt += 1
|
|
69
|
+
if idempotent && attempt <= max_retries
|
|
70
|
+
sleep(delay_for(e, attempt))
|
|
71
|
+
retry
|
|
72
|
+
end
|
|
73
|
+
raise map_exception(e)
|
|
74
|
+
rescue RestClient::ExceptionWithResponse => e
|
|
75
|
+
raise map_exception(e)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def execute(verb, path, params, opts)
|
|
82
|
+
args = {
|
|
83
|
+
method: verb,
|
|
84
|
+
url: build_url(path),
|
|
85
|
+
user: @key,
|
|
86
|
+
password: @secret,
|
|
87
|
+
open_timeout: @options.fetch(:open_timeout, DEFAULT_OPEN_TIMEOUT),
|
|
88
|
+
read_timeout: @options.fetch(:read_timeout, DEFAULT_READ_TIMEOUT)
|
|
89
|
+
}
|
|
90
|
+
args[:timeout] = opts[:timeout] if opts[:timeout]
|
|
91
|
+
args[:payload] = params if verb != :get && !blank_payload?(params)
|
|
92
|
+
@http.execute(**args)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def parse(response)
|
|
96
|
+
body = response.body.to_s
|
|
97
|
+
return {} if body.strip.empty?
|
|
98
|
+
|
|
99
|
+
parsed = begin
|
|
100
|
+
JSON.parse(body)
|
|
101
|
+
rescue JSON::ParserError
|
|
102
|
+
raise ParseError.new(
|
|
103
|
+
"TestingBot API returned a non-JSON response (HTTP #{response.code}): #{body[0, 200]}",
|
|
104
|
+
http_status: response.code, body: body
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
log(parsed) if @options[:debug]
|
|
109
|
+
|
|
110
|
+
if parsed.is_a?(Hash) && !parsed["error"].to_s.empty?
|
|
111
|
+
raise ApiError.new(parsed["error"], http_status: response.code, body: body)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
parsed
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def build_url(path)
|
|
118
|
+
"#{API_URL}/v#{API_VERSION}#{path}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def blank_payload?(params)
|
|
122
|
+
params.nil? || (params.respond_to?(:empty?) && params.empty?)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def max_retries
|
|
126
|
+
@options.fetch(:max_retries, DEFAULT_MAX_RETRIES)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def delay_for(error, attempt)
|
|
130
|
+
if error.is_a?(RestClient::TooManyRequests)
|
|
131
|
+
seconds = retry_after(error)
|
|
132
|
+
return [seconds, 120].min if seconds && seconds > 0
|
|
133
|
+
end
|
|
134
|
+
backoff(attempt)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Exponential backoff (base 0.5s, doubling) with +/-20% jitter.
|
|
138
|
+
def backoff(attempt)
|
|
139
|
+
base = 0.5 * (2**(attempt - 1))
|
|
140
|
+
jitter = base * 0.2
|
|
141
|
+
base + ((rand * 2) - 1) * jitter
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def retry_after(error)
|
|
145
|
+
headers = error.response && error.response.headers
|
|
146
|
+
return nil unless headers
|
|
147
|
+
(headers[:retry_after] || headers["Retry-After"]).to_i
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def map_exception(error)
|
|
151
|
+
case error
|
|
152
|
+
when RestClient::Unauthorized, RestClient::Forbidden
|
|
153
|
+
AuthenticationError.new(error.message, http_status: error.http_code, body: response_body(error))
|
|
154
|
+
when RestClient::ResourceNotFound
|
|
155
|
+
NotFoundError.new(error.message, http_status: error.http_code, body: response_body(error))
|
|
156
|
+
when RestClient::TooManyRequests
|
|
157
|
+
RateLimitError.new(error.message, http_status: error.http_code,
|
|
158
|
+
body: response_body(error), retry_after: retry_after(error))
|
|
159
|
+
when RestClient::ExceptionWithResponse
|
|
160
|
+
code = error.http_code
|
|
161
|
+
klass = code && code >= 500 ? ServerError : ClientError
|
|
162
|
+
klass.new(error.message, http_status: code, body: response_body(error))
|
|
163
|
+
when RestClient::Exceptions::Timeout, SocketError, SystemCallError
|
|
164
|
+
ConnectionError.new(error.message)
|
|
165
|
+
else
|
|
166
|
+
Error.new(error.message)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def response_body(error)
|
|
171
|
+
error.response && error.response.body
|
|
172
|
+
rescue StandardError
|
|
173
|
+
nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def log(parsed)
|
|
177
|
+
warn(redact(parsed).inspect)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def redact(obj)
|
|
181
|
+
case obj
|
|
182
|
+
when Hash
|
|
183
|
+
obj.each_with_object({}) do |(k, v), memo|
|
|
184
|
+
memo[k] = REDACT_KEYS.include?(k.to_s.downcase) ? "[REDACTED]" : redact(v)
|
|
185
|
+
end
|
|
186
|
+
when Array
|
|
187
|
+
obj.map { |v| redact(v) }
|
|
188
|
+
else
|
|
189
|
+
obj
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module TestingBot
|
|
2
|
+
# Base class for every error raised by this gem. Rescue +TestingBot::Error+
|
|
3
|
+
# to catch any failure originating from the client.
|
|
4
|
+
#
|
|
5
|
+
# Instances carry the HTTP status (when the failure came from an HTTP
|
|
6
|
+
# response) and the raw response body for diagnostics.
|
|
7
|
+
class Error < StandardError
|
|
8
|
+
attr_reader :http_status, :body
|
|
9
|
+
|
|
10
|
+
def initialize(message = nil, http_status: nil, body: nil)
|
|
11
|
+
@http_status = http_status
|
|
12
|
+
@body = body
|
|
13
|
+
super(message)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Network-level failure: connection refused/reset, DNS failure, or a
|
|
18
|
+
# request that exceeded the configured open/read timeout.
|
|
19
|
+
class ConnectionError < Error; end
|
|
20
|
+
|
|
21
|
+
# The API returned a body that could not be parsed as JSON (e.g. an HTML
|
|
22
|
+
# gateway/maintenance page) or an empty body where JSON was expected.
|
|
23
|
+
class ParseError < Error; end
|
|
24
|
+
|
|
25
|
+
# Any 4xx response that is not covered by a more specific subclass.
|
|
26
|
+
class ClientError < Error; end
|
|
27
|
+
|
|
28
|
+
# 401/403 — missing or invalid credentials, or an account lacking the
|
|
29
|
+
# required permission.
|
|
30
|
+
class AuthenticationError < ClientError; end
|
|
31
|
+
|
|
32
|
+
# 404 — the requested resource does not exist or does not belong to you.
|
|
33
|
+
class NotFoundError < ClientError; end
|
|
34
|
+
|
|
35
|
+
# 429 — the account hit the API rate limit. +retry_after+ exposes the
|
|
36
|
+
# server-provided Retry-After value (seconds) when present.
|
|
37
|
+
class RateLimitError < ClientError
|
|
38
|
+
attr_reader :retry_after
|
|
39
|
+
|
|
40
|
+
def initialize(message = nil, http_status: nil, body: nil, retry_after: nil)
|
|
41
|
+
@retry_after = retry_after
|
|
42
|
+
super(message, http_status: http_status, body: body)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# 5xx — the API encountered a server-side error.
|
|
47
|
+
class ServerError < Error; end
|
|
48
|
+
|
|
49
|
+
# A 2xx response whose JSON body carried an application-level
|
|
50
|
+
# <tt>{"error": "..."}</tt> field.
|
|
51
|
+
class ApiError < Error; end
|
|
52
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module TestingBot
|
|
2
|
+
module Resources
|
|
3
|
+
# Endpoints under /builds.
|
|
4
|
+
module Builds
|
|
5
|
+
def get_builds(offset = 0, count = 10)
|
|
6
|
+
get("/builds#{query(:offset => offset, :count => count)}")
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# +params+ accepts optional offset/count/skip_fields query params.
|
|
10
|
+
# The build identifier maps to a wildcard route, so it is passed through
|
|
11
|
+
# unescaped to preserve any slashes it may contain.
|
|
12
|
+
def get_build(build_identifier, params = {})
|
|
13
|
+
get("/builds/#{build_identifier}#{query(params)}")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def delete_build(build_identifier)
|
|
17
|
+
delete("/builds/#{build_identifier}")
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|