signingflow 0.1.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.
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "securerandom"
6
+
7
+ module Signingflow
8
+ class Requestor
9
+ MAX_RESPONSE_BYTES = 8 * 1024 * 1024
10
+ UUID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i
11
+
12
+ def initialize(config)
13
+ @config = config
14
+ end
15
+
16
+ def call(operation:, path_params: nil, query: nil, body: nil, idempotency_key: nil)
17
+ key = operation["idempotent"] ? key_for(idempotency_key, body) : nil
18
+ payload = operation["http"] == "GET" ? nil : (body || {}).dup
19
+ payload["idempotencyKey"] = key if payload && key
20
+ begin
21
+ path = Path.fill(operation["path"], path_params || {})
22
+ uri = Path.resolve(@config.base_url, path, query)
23
+ rescue InvalidRequestError => e
24
+ raise InvalidRequestError.new(e.message, idempotency_key: key)
25
+ end
26
+ if Path.query_contains_secret(uri.query, @config.api_key)
27
+ raise InvalidRequestError.new("The API key must not be sent in the query string.", idempotency_key: key)
28
+ end
29
+
30
+ retries = operation["http"] == "GET" || operation["idempotent"] ? @config.max_network_retries : 0
31
+ attempt = 0
32
+ loop do
33
+ begin
34
+ response = once(uri, operation["http"], payload)
35
+ status = response.code.to_i
36
+ return parse_success(response) if status >= 200 && status < 300
37
+ if attempt < retries && retryable_status?(status)
38
+ attempt += 1
39
+ sleep_ms(Path.retry_delay_ms(response["rate-limit-retry-after"], response["rate-limit-reset-after"]))
40
+ next
41
+ end
42
+ raise failure(response, operation["capability"], key)
43
+ rescue Error
44
+ raise
45
+ rescue StandardError => e
46
+ if attempt < retries
47
+ attempt += 1
48
+ next
49
+ end
50
+ aborted = timeout_error?(e)
51
+ raise ConnectionError.new(
52
+ aborted ? "The API timed out." : "The API could not be reached.",
53
+ status_code: aborted ? 408 : 0,
54
+ idempotency_key: key
55
+ )
56
+ end
57
+ end
58
+ end
59
+
60
+ def put_bytes(uri, bytes, content_type)
61
+ retries = @config.max_network_retries
62
+ attempt = 0
63
+ loop do
64
+ begin
65
+ response = perform(uri, "PUT", bytes, {
66
+ "Content-Type" => content_type,
67
+ "x-goog-if-generation-match" => "0"
68
+ })
69
+ status = response.code.to_i
70
+ return if (status >= 200 && status < 300) || status == 412
71
+ if attempt < retries && status >= 500
72
+ attempt += 1
73
+ next
74
+ end
75
+ raise APIError.new("Uploading the file to storage failed.", status_code: status)
76
+ rescue Error
77
+ raise
78
+ rescue StandardError
79
+ if attempt < retries
80
+ attempt += 1
81
+ next
82
+ end
83
+ raise ConnectionError.new("The upload could not be reached.", status_code: 0)
84
+ end
85
+ end
86
+ end
87
+
88
+ def self.assemble(status:, content_length:, chunks:)
89
+ if content_length.is_a?(Numeric) && content_length > MAX_RESPONSE_BYTES
90
+ raise APIError.new("The API response is larger than 8 MB.", status_code: status)
91
+ end
92
+
93
+ consume(status: status, chunks: chunks)
94
+ end
95
+
96
+ def self.sleep_ms(ms)
97
+ return if ms <= 0
98
+
99
+ Kernel.sleep(ms / 1000.0)
100
+ end
101
+
102
+ private
103
+
104
+ def sleep_ms(ms)
105
+ self.class.sleep_ms(ms)
106
+ end
107
+
108
+ def key_for(supplied, body)
109
+ raw = supplied.to_s.strip
110
+ raw = body["idempotencyKey"].to_s.strip if raw.empty? && body.is_a?(Hash)
111
+ return SecureRandom.uuid if raw.empty?
112
+ raise InvalidRequestError, "idempotencyKey must be a UUID." unless UUID.match?(raw)
113
+
114
+ raw
115
+ end
116
+
117
+ def once(uri, method, payload)
118
+ headers = {
119
+ "Authorization" => "Bearer #{@config.api_key}",
120
+ "X-Request-Workspace-Id" => @config.workspace_id,
121
+ "X-Request-Env" => @config.env,
122
+ "X-Request-Source" => @config.source,
123
+ "Accept" => "application/json",
124
+ "User-Agent" => @config.source
125
+ }
126
+ headers["X-Request-Channel"] = @config.channel if @config.channel
127
+ headers["X-Request-Funnel"] = @config.funnel if @config.funnel
128
+ headers["Content-Type"] = "application/json" unless method == "GET"
129
+ body = method == "GET" ? nil : JSON.generate(payload || {})
130
+ perform(uri, method, body, headers)
131
+ end
132
+
133
+ def perform(uri, method, body, headers)
134
+ http = Net::HTTP.new(uri.host, uri.port)
135
+ http.use_ssl = uri.scheme == "https"
136
+ timeout = @config.timeout / 1000.0
137
+ http.open_timeout = timeout
138
+ http.read_timeout = timeout
139
+ http.write_timeout = timeout if http.respond_to?(:write_timeout=)
140
+ http.max_retries = 0
141
+ # Pass Accept-Encoding in the constructor so Net::HTTP leaves
142
+ # decode_content false and does not inflate gzip before the cap.
143
+ request = request_class(method).new(uri, headers.merge("Accept-Encoding" => "identity"))
144
+ request.body = body unless body.nil?
145
+ http.request(request) do |response|
146
+ response.body = read_capped(response)
147
+ end
148
+ end
149
+
150
+ def request_class(method)
151
+ case method
152
+ when "GET" then Net::HTTP::Get
153
+ when "POST" then Net::HTTP::Post
154
+ when "PATCH" then Net::HTTP::Patch
155
+ when "PUT" then Net::HTTP::Put
156
+ else
157
+ raise InvalidRequestError, "invalid path"
158
+ end
159
+ end
160
+
161
+ def parse_success(response)
162
+ type = response["content-type"].to_s
163
+ text = response.body.to_s
164
+ return {} if text.empty?
165
+ if !type.empty? && !type.include?("json") && !type.include?("text/")
166
+ raise APIError.new("The API returned a non-JSON body.", status_code: response.code.to_i)
167
+ end
168
+
169
+ JSON.parse(text)
170
+ rescue JSON::ParserError
171
+ raise APIError.new("The API returned invalid JSON.", status_code: response.code.to_i)
172
+ end
173
+
174
+ def failure(response, capability, idempotency_key)
175
+ text = response.body.to_s
176
+ parsed = text
177
+ if !text.empty?
178
+ begin
179
+ parsed = JSON.parse(text)
180
+ rescue JSON::ParserError
181
+ parsed = text
182
+ end
183
+ end
184
+ message = Redact.message(Status.server_message(parsed).empty? ? (text.empty? ? "Request failed with status #{response.code}." : text) : Status.server_message(parsed))
185
+ fields = {
186
+ status_code: response.code.to_i,
187
+ grpc_code: Status.grpc_code(parsed),
188
+ trace_code: Status.trace_code(parsed),
189
+ request_id: response["x-request-id"],
190
+ idempotency_key: idempotency_key,
191
+ capability: capability,
192
+ field_violations: Redact.violations(Status.field_violations(parsed)),
193
+ retry_after: response["rate-limit-retry-after"] || response["rate-limit-reset-after"]
194
+ }
195
+ status = response.code.to_i
196
+ return AuthenticationError.new(message, **fields) if status == 401
197
+ return PermissionError.new(message, **fields) if status == 403
198
+ if status == 409 && Status.mentions_idempotency?(parsed)
199
+ return IdempotencyError.new(message, **fields, resource_name: Status.resource_name(parsed))
200
+ end
201
+ return RateLimitError.new(message, **fields) if status == 429
202
+ return InvalidRequestError.new(message, **fields) if status >= 400 && status < 500
203
+
204
+ APIError.new(message, **fields)
205
+ end
206
+
207
+ def read_capped(response)
208
+ declared = response["content-length"]
209
+ length = declared.nil? || declared.to_s.empty? ? nil : Integer(declared, exception: false)
210
+ if length && length > MAX_RESPONSE_BYTES
211
+ raise APIError.new("The API response is larger than 8 MB.", status_code: response.code.to_i)
212
+ end
213
+
214
+ self.class.consume(status: response.code.to_i, chunks: body_chunks(response))
215
+ end
216
+
217
+ def body_chunks(response)
218
+ Enumerator.new do |yielder|
219
+ response.read_body { |chunk| yielder << chunk }
220
+ end
221
+ end
222
+
223
+ def self.consume(status:, chunks:)
224
+ body = +""
225
+ body.force_encoding(Encoding::ASCII_8BIT)
226
+ chunks.each do |chunk|
227
+ next if chunk.nil? || chunk.bytesize.zero?
228
+
229
+ if body.bytesize + chunk.bytesize > MAX_RESPONSE_BYTES
230
+ raise APIError.new("The API response is larger than 8 MB.", status_code: status)
231
+ end
232
+
233
+ body << chunk
234
+ end
235
+ body.force_encoding(Encoding::UTF_8)
236
+ end
237
+
238
+ def retryable_status?(status)
239
+ status == 408 || status == 429 || status >= 500
240
+ end
241
+
242
+ def timeout_error?(error)
243
+ error.is_a?(Net::OpenTimeout) || error.is_a?(Net::ReadTimeout) || error.is_a?(Timeout::Error)
244
+ end
245
+ end
246
+ end