runapi-core 0.4.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ba473ca16c3bf3642549224cc1e8e2ace81c302f91a767afc10b06b93cde95ce
4
- data.tar.gz: a01ba0a52c124fd881b45705ce8a7580e9b429ca776842693bc1ad262cf85804
3
+ metadata.gz: ea78f8bdda7cd96b63e4bdac55eac74d6c06bb4b4db80ab0cf98905309fd911c
4
+ data.tar.gz: d78ae1407c76cc538503619385d19862fcc6277fa5f3cbfc8afd637794bd1df5
5
5
  SHA512:
6
- metadata.gz: 908a114d22a7113898f4ec49595c40b2f414c02c4c528dba774b3262a871a429627d2337b6516a1923ae25662b38ee3f1352a9b30b31900d0d91e4ab5ad1863c
7
- data.tar.gz: 36a9539bb562174708f6d94354b2e4f2c4fe742773665d8603b8c77ff6ed6019e9c1c848ba0decf970e6cd96bec50def79d398057dc748a4bd8bc7ee97511d60
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.join(@options.base_url, path)
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
- @pool.with do |http|
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
- Constants::IDEMPOTENT_METHODS.include?(method.to_s.upcase) &&
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
@@ -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
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  module RunApi
4
4
  module Core
5
- VERSION = "0.4.1"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
 
8
8
  VERSION = Core::VERSION
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.1
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