runapi-core 0.4.0 → 0.5.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/lib/runapi/core/http_client.rb +36 -11
- data/lib/runapi/core/hybrid_lifecycle.rb +171 -0
- data/lib/runapi/core/resource_helpers.rb +5 -9
- data/lib/runapi/core/response.rb +3 -2
- data/lib/runapi/core/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ea78f8bdda7cd96b63e4bdac55eac74d6c06bb4b4db80ab0cf98905309fd911c
|
|
4
|
+
data.tar.gz: d78ae1407c76cc538503619385d19862fcc6277fa5f3cbfc8afd637794bd1df5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6d08554a3bbe9e1019c94c2faa1f70af23be53681d025590adde44836c77c4b6a05ec7ea6666307ceb287c281195fb73009f2f33064943c750d993cd9fd20bf8
|
|
7
|
+
data.tar.gz: 2b99533c6b2c4991066b752d9cff43730939173eb1a0df17639fe90b8c602670b1c22dcb5aeb1350dd59b466558ac67b253219facb8e5e567da2d6c0d80c2e35
|
|
@@ -13,7 +13,10 @@ module RunApi
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def request(method, path, body: nil, options: nil, raw: false)
|
|
16
|
-
uri = URI.
|
|
16
|
+
uri = URI.parse(path)
|
|
17
|
+
uri = URI.join(@options.base_url, path) unless uri.absolute?
|
|
18
|
+
raise ValidationError, "Request URL must use the configured RunAPI origin" unless base_origin?(uri)
|
|
19
|
+
|
|
17
20
|
req = build_request(method, uri, body, options)
|
|
18
21
|
max_retries = options&.max_retries || @options.max_retries
|
|
19
22
|
retries = 0
|
|
@@ -21,10 +24,7 @@ module RunApi
|
|
|
21
24
|
|
|
22
25
|
loop do
|
|
23
26
|
response = begin
|
|
24
|
-
|
|
25
|
-
http.start unless http.started?
|
|
26
|
-
http.request(req)
|
|
27
|
-
end
|
|
27
|
+
perform_request(uri, req)
|
|
28
28
|
rescue *STALE_CONNECTION_ERRORS
|
|
29
29
|
unless stale_retried
|
|
30
30
|
stale_retried = true
|
|
@@ -44,16 +44,16 @@ module RunApi
|
|
|
44
44
|
if response.is_a?(Net::HTTPSuccess)
|
|
45
45
|
return response.body if raw
|
|
46
46
|
|
|
47
|
-
body = parse_body(response.body)
|
|
47
|
+
body = parse_body(response.body, response["content-type"])
|
|
48
48
|
return nil if body.nil?
|
|
49
49
|
return body unless body.is_a?(Hash) || body.is_a?(Array)
|
|
50
50
|
|
|
51
|
-
return Response.new(body:, headers: response_headers(response))
|
|
51
|
+
return Response.new(body:, headers: response_headers(response), status: response.code.to_i)
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
error = Error.from_response(response, response.body)
|
|
55
55
|
|
|
56
|
-
if retryable?(method, response.code.to_i) && retries < max_retries
|
|
56
|
+
if retryable?(method, response.code.to_i, req) && retries < max_retries
|
|
57
57
|
retries += 1
|
|
58
58
|
sleep(retry_delay(retries, error))
|
|
59
59
|
stale_retried = false
|
|
@@ -100,6 +100,10 @@ module RunApi
|
|
|
100
100
|
|
|
101
101
|
def build_connection
|
|
102
102
|
uri = URI.parse(@options.base_url)
|
|
103
|
+
build_connection_for(uri)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def build_connection_for(uri)
|
|
103
107
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
104
108
|
http.use_ssl = (uri.scheme == "https")
|
|
105
109
|
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
@@ -108,6 +112,22 @@ module RunApi
|
|
|
108
112
|
http
|
|
109
113
|
end
|
|
110
114
|
|
|
115
|
+
def perform_request(uri, request)
|
|
116
|
+
if base_origin?(uri)
|
|
117
|
+
return @pool.with do |http|
|
|
118
|
+
http.start unless http.started?
|
|
119
|
+
http.request(request)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
build_connection_for(uri).start { |http| http.request(request) }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def base_origin?(uri)
|
|
127
|
+
base_uri = URI.parse(@options.base_url)
|
|
128
|
+
uri.scheme == base_uri.scheme && uri.host == base_uri.host && uri.port == base_uri.port
|
|
129
|
+
end
|
|
130
|
+
|
|
111
131
|
def build_request(method, uri, body, options)
|
|
112
132
|
klass = Net::HTTP.const_get(method.to_s.capitalize)
|
|
113
133
|
req = klass.new(uri.request_uri)
|
|
@@ -154,11 +174,15 @@ module RunApi
|
|
|
154
174
|
end
|
|
155
175
|
end
|
|
156
176
|
|
|
157
|
-
def retryable?(method, status)
|
|
158
|
-
|
|
177
|
+
def retryable?(method, status, request)
|
|
178
|
+
retryable_request?(method, request) &&
|
|
159
179
|
Constants::RETRYABLE_STATUS_CODES.include?(status)
|
|
160
180
|
end
|
|
161
181
|
|
|
182
|
+
def retryable_request?(method, request)
|
|
183
|
+
Constants::IDEMPOTENT_METHODS.include?(method.to_s.upcase) || request["Idempotency-Key"].to_s != ""
|
|
184
|
+
end
|
|
185
|
+
|
|
162
186
|
def retry_delay(attempt, error)
|
|
163
187
|
if error.is_a?(RateLimitError) && error.retry_after&.positive?
|
|
164
188
|
return error.retry_after
|
|
@@ -169,8 +193,9 @@ module RunApi
|
|
|
169
193
|
[base + jitter, @options.retry_max_delay].min
|
|
170
194
|
end
|
|
171
195
|
|
|
172
|
-
def parse_body(body)
|
|
196
|
+
def parse_body(body, content_type = nil)
|
|
173
197
|
return nil if body.nil? || body.empty?
|
|
198
|
+
return body if content_type && !content_type.downcase.include?("json")
|
|
174
199
|
|
|
175
200
|
JSON.parse(body)
|
|
176
201
|
rescue JSON::ParserError
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RunApi
|
|
4
|
+
module Core
|
|
5
|
+
module HybridLifecycle
|
|
6
|
+
StoredHttpResponse = Struct.new(:status, :headers) do
|
|
7
|
+
def code = status.to_s
|
|
8
|
+
def [](name) = headers[name]
|
|
9
|
+
def each_header(&block) = headers.each(&block)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def run_hybrid(path, body:, options:, response_class: default_response_class)
|
|
15
|
+
terminal = nil
|
|
16
|
+
subscribe_hybrid(path, body:, options:, response_class:).each { |result| terminal = result }
|
|
17
|
+
terminal
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def subscribe_hybrid(path, body:, options:, response_class: default_response_class)
|
|
21
|
+
options = hybrid_request_options(options)
|
|
22
|
+
Enumerator.new do |subscriber|
|
|
23
|
+
response = raw_request(:post, path, body:, options:)
|
|
24
|
+
unless response.status == 202
|
|
25
|
+
terminal = decode_hybrid_response(response, response_class)
|
|
26
|
+
subscriber << terminal
|
|
27
|
+
next terminal
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
location = response.response_headers["Location"]
|
|
31
|
+
raise Core::TaskFailedError, "Accepted task response is missing Location" if location.to_s.empty?
|
|
32
|
+
|
|
33
|
+
poll_hybrid_task(location, options:, response_class:, subscriber:)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def raw_request(method, path, body: :__runapi_no_body__, options: nil)
|
|
38
|
+
response = if body == :__runapi_no_body__
|
|
39
|
+
options ? @http.request(method, path, options:) : @http.request(method, path)
|
|
40
|
+
else
|
|
41
|
+
kwargs = {body: body}
|
|
42
|
+
kwargs[:options] = options if options
|
|
43
|
+
@http.request(method, path, **kwargs)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
return response if response.is_a?(Core::Response)
|
|
47
|
+
|
|
48
|
+
Core::Response.new(body: response, status: 200)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def hybrid_request_options(options)
|
|
52
|
+
headers = options&.headers&.dup || {}
|
|
53
|
+
return options if headers.keys.any? { |key| key.to_s.casecmp?("Idempotency-Key") }
|
|
54
|
+
|
|
55
|
+
headers["Idempotency-Key"] = SecureRandom.uuid
|
|
56
|
+
Core::RequestOptions.new(
|
|
57
|
+
headers:,
|
|
58
|
+
timeout: options&.timeout,
|
|
59
|
+
max_retries: options&.max_retries,
|
|
60
|
+
allow_not_modified: options&.allow_not_modified
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def poll_hybrid_task(location, options:, response_class:, subscriber:)
|
|
65
|
+
loop do
|
|
66
|
+
response = raw_request(:get, location, options:)
|
|
67
|
+
payload = response.body
|
|
68
|
+
status = hybrid_task_status(payload)
|
|
69
|
+
|
|
70
|
+
if status == "completed"
|
|
71
|
+
terminal = decode_hybrid_response(stored_hybrid_response(payload, response), response_class)
|
|
72
|
+
subscriber << terminal
|
|
73
|
+
break terminal
|
|
74
|
+
end
|
|
75
|
+
raise stored_hybrid_error(payload, response) if status == "failed"
|
|
76
|
+
|
|
77
|
+
unless Core::Polling::ACTIVE_STATUSES.include?(status)
|
|
78
|
+
raise_task_failed(payload, response, "Unknown task status: #{status}")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
subscriber << decode_processing_response(response)
|
|
82
|
+
sleep retry_after(response)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def hybrid_task_status(payload)
|
|
87
|
+
case payload
|
|
88
|
+
when Core::BaseModel
|
|
89
|
+
payload.status.to_s.downcase
|
|
90
|
+
when Hash
|
|
91
|
+
(payload["status"] || payload[:status]).to_s.downcase
|
|
92
|
+
else
|
|
93
|
+
""
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def decode_processing_response(response)
|
|
98
|
+
result = Core::BaseModel.coerce(response.body, as: Core::TaskResponse)
|
|
99
|
+
attach_response_headers(result, response.response_headers)
|
|
100
|
+
result
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def decode_hybrid_response(response, response_class)
|
|
104
|
+
return response.body unless json_response?(response)
|
|
105
|
+
|
|
106
|
+
result = Core::BaseModel.coerce(response.body, as: response_class)
|
|
107
|
+
attach_response_headers(result, response.response_headers)
|
|
108
|
+
result
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def stored_hybrid_response(payload, polling_response)
|
|
112
|
+
envelope = payload.is_a?(Core::BaseModel) ? payload.to_h : payload
|
|
113
|
+
stored = envelope.is_a?(Hash) ? (envelope["response"] || envelope[:response]) : nil
|
|
114
|
+
unless stored.is_a?(Hash)
|
|
115
|
+
raise_task_failed(payload, polling_response, "Terminal Task Result is missing response")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
status = stored["status"] || stored[:status]
|
|
119
|
+
content_type = stored["content_type"] || stored[:content_type]
|
|
120
|
+
unless status.is_a?(Integer) && content_type.is_a?(String) && !content_type.empty?
|
|
121
|
+
raise_task_failed(payload, polling_response, "Terminal Task Result response is invalid")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
headers = (stored["headers"] || stored[:headers] || {}).to_h
|
|
125
|
+
headers = headers.merge("Content-Type" => content_type)
|
|
126
|
+
Core::Response.new(body: stored.key?("body") ? stored["body"] : stored[:body], headers:, status:)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def stored_hybrid_error(payload, polling_response)
|
|
130
|
+
response = stored_hybrid_response(payload, polling_response)
|
|
131
|
+
serialized_body = response.body.is_a?(String) ? response.body : JSON.generate(response.body)
|
|
132
|
+
Core::Error.from_response(StoredHttpResponse.new(response.status, response.response_headers), serialized_body)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def json_response?(response)
|
|
136
|
+
response.response_headers["Content-Type"].to_s.downcase.include?("json") || response.body.is_a?(Hash) || response.body.is_a?(Array)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def retry_after(response)
|
|
140
|
+
value = Float(response.response_headers["Retry-After"], exception: false)
|
|
141
|
+
value&.positive? ? value : 0
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def raise_task_failed(payload, response, message = nil)
|
|
145
|
+
raise Core::TaskFailedError.new(
|
|
146
|
+
message || hybrid_task_error(payload) || "Task failed",
|
|
147
|
+
details: payload,
|
|
148
|
+
response_headers: response.response_headers
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def hybrid_task_error(payload)
|
|
153
|
+
case payload
|
|
154
|
+
when Core::BaseModel
|
|
155
|
+
payload.error
|
|
156
|
+
when Hash
|
|
157
|
+
payload["error"] || payload[:error]
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def attach_response_headers(result, headers)
|
|
162
|
+
case result
|
|
163
|
+
when Core::BaseModel
|
|
164
|
+
result.with_response_headers(headers)
|
|
165
|
+
when Array
|
|
166
|
+
result.each { |item| attach_response_headers(item, headers) }
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require_relative "hybrid_lifecycle"
|
|
5
|
+
|
|
3
6
|
module RunApi
|
|
4
7
|
module Core
|
|
5
8
|
module ResourceHelpers
|
|
9
|
+
include HybridLifecycle
|
|
10
|
+
|
|
6
11
|
private
|
|
7
12
|
|
|
8
13
|
# Performs an HTTP request and coerces JSON responses into typed model objects.
|
|
@@ -274,15 +279,6 @@ module RunApi
|
|
|
274
279
|
completed.with_response_headers(response.response_headers) if response.is_a?(Core::BaseModel)
|
|
275
280
|
completed
|
|
276
281
|
end
|
|
277
|
-
|
|
278
|
-
def attach_response_headers(result, headers)
|
|
279
|
-
case result
|
|
280
|
-
when Core::BaseModel
|
|
281
|
-
result.with_response_headers(headers)
|
|
282
|
-
when Array
|
|
283
|
-
result.each { |item| attach_response_headers(item, headers) }
|
|
284
|
-
end
|
|
285
|
-
end
|
|
286
282
|
end
|
|
287
283
|
end
|
|
288
284
|
end
|
data/lib/runapi/core/response.rb
CHANGED
|
@@ -42,12 +42,13 @@ module RunApi
|
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
class Response < Hash
|
|
45
|
-
attr_reader :response_headers
|
|
45
|
+
attr_reader :response_headers, :status
|
|
46
46
|
|
|
47
|
-
def initialize(body:, headers: nil)
|
|
47
|
+
def initialize(body:, headers: nil, status: nil)
|
|
48
48
|
super()
|
|
49
49
|
@body = body unless body.is_a?(Hash)
|
|
50
50
|
@response_headers = headers.is_a?(ResponseHeaders) ? headers : ResponseHeaders.new(headers)
|
|
51
|
+
@status = status
|
|
51
52
|
update(body) if body.is_a?(Hash)
|
|
52
53
|
end
|
|
53
54
|
|
data/lib/runapi/core/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: runapi-core
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- RunAPI
|
|
@@ -53,6 +53,7 @@ files:
|
|
|
53
53
|
- lib/runapi/core/errors.rb
|
|
54
54
|
- lib/runapi/core/files.rb
|
|
55
55
|
- lib/runapi/core/http_client.rb
|
|
56
|
+
- lib/runapi/core/hybrid_lifecycle.rb
|
|
56
57
|
- lib/runapi/core/multipart_body.rb
|
|
57
58
|
- lib/runapi/core/polling.rb
|
|
58
59
|
- lib/runapi/core/pricing.rb
|
|
@@ -85,7 +86,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
85
86
|
- !ruby/object:Gem::Version
|
|
86
87
|
version: '0'
|
|
87
88
|
requirements: []
|
|
88
|
-
rubygems_version: 4.0.
|
|
89
|
+
rubygems_version: 4.0.17
|
|
89
90
|
specification_version: 4
|
|
90
91
|
summary: RunAPI Core Ruby SDK
|
|
91
92
|
test_files: []
|