testingbot 0.2.2 → 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 +27 -13
- data/CHANGELOG.md +61 -0
- data/README.md +261 -42
- data/Rakefile +15 -14
- data/bin/testingbot +32 -11
- data/lib/testingbot/api.rb +107 -182
- 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 +36 -14
- data/.travis.yml +0 -9
- 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,230 +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
65
|
|
|
42
|
-
|
|
43
|
-
|
|
66
|
+
@connection = @options[:connection] ||
|
|
67
|
+
Connection.new(key: @key, secret: @secret, options: @options)
|
|
44
68
|
end
|
|
45
69
|
|
|
46
|
-
|
|
47
|
-
get("/devices")
|
|
48
|
-
end
|
|
70
|
+
private
|
|
49
71
|
|
|
50
|
-
def
|
|
51
|
-
get
|
|
72
|
+
def get(path)
|
|
73
|
+
@connection.request(:get, path)
|
|
52
74
|
end
|
|
53
75
|
|
|
54
|
-
def
|
|
55
|
-
|
|
56
|
-
params.keys.each do |key|
|
|
57
|
-
new_params["user[#{key}]"] = params[key]
|
|
58
|
-
end
|
|
59
|
-
response = put("/user", new_params)
|
|
60
|
-
response["success"]
|
|
76
|
+
def post(path, params = {})
|
|
77
|
+
@connection.request(:post, path, params)
|
|
61
78
|
end
|
|
62
79
|
|
|
63
|
-
def
|
|
64
|
-
|
|
80
|
+
def put(path, params = {})
|
|
81
|
+
@connection.request(:put, path, params)
|
|
65
82
|
end
|
|
66
83
|
|
|
67
|
-
def
|
|
68
|
-
|
|
84
|
+
def delete(path, params = {})
|
|
85
|
+
@connection.request(:delete, path, params)
|
|
69
86
|
end
|
|
70
87
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
75
93
|
end
|
|
76
|
-
response = put("/tests/#{test_id}", new_params)
|
|
77
|
-
response["success"]
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def delete_test(test_id)
|
|
81
|
-
response = delete("/tests/#{test_id}")
|
|
82
|
-
response["success"]
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def stop_test(test_id)
|
|
86
|
-
response = put("/tests/#{test_id}/stop")
|
|
87
|
-
response["success"]
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
def get_builds(offset = 0, count = 10)
|
|
91
|
-
get("/builds?offset=#{offset}&count=#{count}")
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def get_build(build_identifier)
|
|
95
|
-
get("/builds/#{build_identifier}")
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
def delete_build(build_identifier)
|
|
99
|
-
delete("/builds/#{build_identifier}")
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
def get_tunnels
|
|
103
|
-
get("/tunnel/list")
|
|
104
94
|
end
|
|
105
95
|
|
|
106
|
-
|
|
107
|
-
|
|
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)
|
|
108
102
|
end
|
|
109
103
|
|
|
110
|
-
|
|
111
|
-
|
|
104
|
+
# URL-encode a path segment (numeric IDs, session IDs, etc.).
|
|
105
|
+
def escape(value)
|
|
106
|
+
CGI.escape(value.to_s)
|
|
112
107
|
end
|
|
113
108
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
user: @key,
|
|
119
|
-
password: @secret,
|
|
120
|
-
timeout: 600,
|
|
121
|
-
payload: {
|
|
122
|
-
multipart: true,
|
|
123
|
-
file: File.new(file_path, 'rb')
|
|
124
|
-
}
|
|
125
|
-
)
|
|
126
|
-
parsed = JSON.parse(response.body)
|
|
127
|
-
parsed
|
|
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://}, '')
|
|
128
113
|
end
|
|
129
114
|
|
|
130
|
-
def
|
|
131
|
-
|
|
132
|
-
:url => url
|
|
133
|
-
})
|
|
115
|
+
def success?(response)
|
|
116
|
+
response.is_a?(Hash) ? !!response["success"] : false
|
|
134
117
|
end
|
|
135
118
|
|
|
136
|
-
def
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
def get_uploaded_file(app_url)
|
|
141
|
-
get("/storage/#{app_url.gsub(/tb:\/\//, '')}")
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
def delete_uploaded_file(app_url)
|
|
145
|
-
response = delete("/storage/#{app_url.gsub(/tb:\/\//, '')}")
|
|
146
|
-
response["success"]
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
private
|
|
150
|
-
|
|
151
|
-
def load_config_file
|
|
152
|
-
is_windows = false
|
|
153
|
-
|
|
154
|
-
begin
|
|
155
|
-
require 'rbconfig'
|
|
156
|
-
is_windows = (RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/)
|
|
157
|
-
rescue
|
|
158
|
-
is_windows = (RUBY_PLATFORM =~ /w.*32/) || (ENV["OS"] && ENV["OS"] == "Windows_NT")
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
if is_windows
|
|
162
|
-
config_file = "#{ENV['HOMEDRIVE']}\\.testingbot"
|
|
163
|
-
else
|
|
164
|
-
config_file = File.expand_path("#{Dir.home}/.testingbot")
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
if File.exists?(config_file)
|
|
168
|
-
str = File.open(config_file) { |f| f.readline }.chomp
|
|
169
|
-
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?
|
|
170
123
|
end
|
|
171
|
-
|
|
172
|
-
return nil
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
def get(url)
|
|
176
|
-
uri = API_URL + '/v' + VERSION.to_s + url
|
|
177
124
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
p parsed if @options[:debug]
|
|
182
|
-
if !parsed.is_a?(Array) && !parsed["error"].nil? && !parsed["error"].empty?
|
|
183
|
-
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"]
|
|
184
128
|
end
|
|
185
129
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
def put(url, params = {})
|
|
190
|
-
uri = API_URL + '/v' + VERSION.to_s + url
|
|
191
|
-
|
|
192
|
-
response = RestClient::Request.execute method: :put, url: uri, payload: params, user: @key, password: @secret
|
|
193
|
-
parsed = JSON.parse(response.body)
|
|
194
|
-
|
|
195
|
-
p parsed if @options[:debug]
|
|
196
|
-
if !parsed.is_a?(Array) && !parsed["error"].nil? && !parsed["error"].empty?
|
|
197
|
-
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"]
|
|
198
133
|
end
|
|
199
|
-
|
|
200
|
-
parsed
|
|
201
134
|
end
|
|
202
135
|
|
|
203
|
-
def
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
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)
|
|
207
139
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
raise parsed["error"]
|
|
140
|
+
if insecure_permissions?(config_file)
|
|
141
|
+
warn("TestingBot: #{config_file} is readable by other users; run `chmod 600 #{config_file}`")
|
|
211
142
|
end
|
|
212
143
|
|
|
213
|
-
|
|
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)
|
|
214
147
|
end
|
|
215
148
|
|
|
216
|
-
def
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
parsed = JSON.parse(response.body)
|
|
221
|
-
|
|
222
|
-
p parsed if @options[:debug]
|
|
223
|
-
if !parsed.is_a?(Array) && !parsed["error"].nil? && !parsed["error"].empty?
|
|
224
|
-
raise parsed["error"]
|
|
225
|
-
end
|
|
226
|
-
|
|
227
|
-
parsed
|
|
149
|
+
def insecure_permissions?(path)
|
|
150
|
+
(File.stat(path).mode & 0o077) != 0
|
|
151
|
+
rescue StandardError
|
|
152
|
+
false
|
|
228
153
|
end
|
|
229
154
|
end
|
|
230
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
|