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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE +21 -0
- data/README.md +96 -0
- data/lib/signingflow/allowlist.rb +13 -0
- data/lib/signingflow/client.rb +17 -0
- data/lib/signingflow/config.rb +82 -0
- data/lib/signingflow/errors.rb +39 -0
- data/lib/signingflow/jail.rb +46 -0
- data/lib/signingflow/object.rb +95 -0
- data/lib/signingflow/path.rb +110 -0
- data/lib/signingflow/redact.rb +31 -0
- data/lib/signingflow/requestor.rb +246 -0
- data/lib/signingflow/resources.rb +624 -0
- data/lib/signingflow/status.rb +82 -0
- data/lib/signingflow/version.rb +6 -0
- data/lib/signingflow/webhook.rb +46 -0
- data/lib/signingflow.rb +25 -0
- data/resources/allowlist.json +110 -0
- metadata +121 -0
|
@@ -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
|