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,624 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Signingflow
|
|
6
|
+
module Resources
|
|
7
|
+
UUID_BODY = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
|
|
8
|
+
UUID = /\A#{UUID_BODY}\z/i
|
|
9
|
+
ENVELOPE = /\Aenvelopes\/#{UUID_BODY}\z/i
|
|
10
|
+
TEMPLATE = /\Atemplates\/#{UUID_BODY}\z/i
|
|
11
|
+
WEBHOOK_URL = /\AwebhookUrls\/#{UUID_BODY}\z/i
|
|
12
|
+
DELIVERY = /\Awebhooks\/#{UUID_BODY}\z/i
|
|
13
|
+
EMAIL = /\A[^\s@]+@[^\s@]+\z/
|
|
14
|
+
MAX_UPLOAD_BYTES = 25 * 1024 * 1024
|
|
15
|
+
SEND_RECOVERY_WINDOW_MS = 120_000
|
|
16
|
+
SEND_RECOVERY_SKEW_MS = 60_000
|
|
17
|
+
MIME = {
|
|
18
|
+
"pdf" => "application/pdf",
|
|
19
|
+
"png" => "image/png",
|
|
20
|
+
"jpg" => "image/jpeg",
|
|
21
|
+
"jpeg" => "image/jpeg",
|
|
22
|
+
"txt" => "text/plain",
|
|
23
|
+
"json" => "application/json"
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
module PageSize
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
def of(value)
|
|
30
|
+
size = value.nil? ? 10 : value
|
|
31
|
+
unless size.is_a?(Integer) && size.between?(1, 1000)
|
|
32
|
+
raise InvalidRequestError, "pageSize must be a whole number from 1 to 1000."
|
|
33
|
+
end
|
|
34
|
+
size
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class Files
|
|
39
|
+
def initialize(requestor, config)
|
|
40
|
+
@requestor = requestor
|
|
41
|
+
@config = config
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def create(file: nil, path: nil, filename: nil, mime_type: nil, idempotency_key: nil)
|
|
45
|
+
loaded = load_file(file: file, path: path, filename: filename, mime_type: mime_type)
|
|
46
|
+
added = @requestor.call(
|
|
47
|
+
operation: Signingflow.operation("StorageService_BatchAddStorage"),
|
|
48
|
+
idempotency_key: idempotency_key,
|
|
49
|
+
body: {
|
|
50
|
+
"storages" => [{
|
|
51
|
+
"originalName" => loaded[:filename],
|
|
52
|
+
"displayName" => loaded[:filename],
|
|
53
|
+
"size" => loaded[:bytes].bytesize.to_s,
|
|
54
|
+
"extension" => loaded[:extension],
|
|
55
|
+
"mimeType" => loaded[:mime_type]
|
|
56
|
+
}]
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
storage = find_storage(added)
|
|
60
|
+
raise APIError, "Storage did not return an upload URL." unless storage
|
|
61
|
+
|
|
62
|
+
begin
|
|
63
|
+
upload_url = Path.allowed_upload_url(storage[:url], @config.upload_hosts)
|
|
64
|
+
rescue Error => e
|
|
65
|
+
raise InvalidRequestError, e.message
|
|
66
|
+
end
|
|
67
|
+
@requestor.put_bytes(upload_url, loaded[:bytes], loaded[:mime_type])
|
|
68
|
+
@requestor.call(
|
|
69
|
+
operation: Signingflow.operation("StorageService_BatchAcknowledgeUpload"),
|
|
70
|
+
body: { "names" => [storage[:name]] }
|
|
71
|
+
)
|
|
72
|
+
ResourceObject.new("name" => storage[:name], "id" => storage[:id])
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def load_file(file:, path:, filename:, mime_type:)
|
|
78
|
+
raise InvalidRequestError, "Pass file bytes or a path, not both." if !file.nil? && !path.nil?
|
|
79
|
+
|
|
80
|
+
bytes = if path
|
|
81
|
+
read_bytes(path)
|
|
82
|
+
elsif file
|
|
83
|
+
raise InvalidRequestError, "file must be the raw bytes." unless file.is_a?(String)
|
|
84
|
+
file.b
|
|
85
|
+
else
|
|
86
|
+
raise InvalidRequestError, "file is required."
|
|
87
|
+
end
|
|
88
|
+
raise InvalidRequestError, "The file is larger than 25 MB." if bytes.bytesize > MAX_UPLOAD_BYTES
|
|
89
|
+
|
|
90
|
+
name = filename.to_s.strip
|
|
91
|
+
name = File.basename(path.to_s) if name.empty? && path
|
|
92
|
+
raise InvalidRequestError, "filename is required when file is not a path." if name.empty?
|
|
93
|
+
|
|
94
|
+
extension = File.extname(name).sub(/\A\./, "").downcase
|
|
95
|
+
type = mime_type.to_s.strip
|
|
96
|
+
type = MIME[extension] || "application/octet-stream" if type.empty?
|
|
97
|
+
{ bytes: bytes, filename: name, extension: extension, mime_type: type }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def read_bytes(path)
|
|
101
|
+
raise InvalidRequestError, "A filesystem path requires upload_root." if @config.upload_root.nil?
|
|
102
|
+
|
|
103
|
+
begin
|
|
104
|
+
Jail.read(@config.upload_root, path, MAX_UPLOAD_BYTES)
|
|
105
|
+
rescue Error => e
|
|
106
|
+
case e.message
|
|
107
|
+
when "too large" then raise InvalidRequestError, "The file is larger than 25 MB."
|
|
108
|
+
when "outside root" then raise InvalidRequestError, "The path is outside upload_root."
|
|
109
|
+
when "hard link" then raise InvalidRequestError, "The file must not be a hard link."
|
|
110
|
+
else raise InvalidRequestError, "The file could not be read."
|
|
111
|
+
end
|
|
112
|
+
rescue StandardError
|
|
113
|
+
raise InvalidRequestError, "The file could not be read."
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def find_storage(body)
|
|
118
|
+
found = []
|
|
119
|
+
walk_storage(body, found)
|
|
120
|
+
found.first
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def walk_storage(value, found)
|
|
124
|
+
return unless value.is_a?(Hash) || value.is_a?(Array)
|
|
125
|
+
|
|
126
|
+
if value.is_a?(Array)
|
|
127
|
+
value.each { |item| walk_storage(item, found) }
|
|
128
|
+
return
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
url = text(value["pre_signed_url"]).empty? ? text(value["preSignedUrl"]) : text(value["pre_signed_url"])
|
|
132
|
+
name = text(value["name"])
|
|
133
|
+
id = name.split("/").last.to_s
|
|
134
|
+
found << { name: name, id: id, url: url } if name.start_with?("storages/") && UUID.match?(id) && !url.empty?
|
|
135
|
+
value.each_value { |child| walk_storage(child, found) }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def text(value)
|
|
139
|
+
value.is_a?(String) ? value : ""
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
class Templates
|
|
144
|
+
def initialize(requestor)
|
|
145
|
+
@requestor = requestor
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def use(name, idempotency_key: nil)
|
|
149
|
+
template = require_template(name)
|
|
150
|
+
used = @requestor.call(
|
|
151
|
+
operation: Signingflow.operation("TemplateService_UseTemplate"),
|
|
152
|
+
path_params: { "name" => template },
|
|
153
|
+
idempotency_key: idempotency_key,
|
|
154
|
+
body: {}
|
|
155
|
+
)
|
|
156
|
+
ResourceObject.new(created_envelope(used))
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
class Envelopes
|
|
161
|
+
def initialize(requestor, files, templates)
|
|
162
|
+
@requestor = requestor
|
|
163
|
+
@files = files
|
|
164
|
+
@templates = templates
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def create(display_name:, storage_id:, description: nil, idempotency_key: nil)
|
|
168
|
+
title = display_name.to_s.strip
|
|
169
|
+
raise InvalidRequestError, "Name the draft title." if title.empty?
|
|
170
|
+
raise InvalidRequestError, "storage_id is required." if storage_id.to_s.strip.empty?
|
|
171
|
+
|
|
172
|
+
created = @requestor.call(
|
|
173
|
+
operation: Signingflow.operation("EnvelopeService_AddEnvelope"),
|
|
174
|
+
idempotency_key: idempotency_key,
|
|
175
|
+
body: {
|
|
176
|
+
"envelope" => {
|
|
177
|
+
"display_name" => title,
|
|
178
|
+
"description" => description.to_s.strip,
|
|
179
|
+
"documents" => [{
|
|
180
|
+
"display_name" => title,
|
|
181
|
+
"attachments" => [{ "storage_id" => storage_id.to_s.strip }],
|
|
182
|
+
"supplemental" => false
|
|
183
|
+
}]
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
)
|
|
187
|
+
ResourceObject.new(created_envelope(created))
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def retrieve(name, read_mask: nil)
|
|
191
|
+
ResourceObject.new(retrieve_hash(name, read_mask: read_mask))
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def update(name, body)
|
|
195
|
+
envelope = require_envelope(name)
|
|
196
|
+
payload = stringify(body)
|
|
197
|
+
payload["name"] = text(payload["name"]).empty? ? envelope : text(payload["name"])
|
|
198
|
+
ResourceObject.new(unwrap_envelope(@requestor.call(
|
|
199
|
+
operation: Signingflow.operation("EnvelopeService_UpdateEnvelope"),
|
|
200
|
+
path_params: { "name" => envelope },
|
|
201
|
+
body: payload
|
|
202
|
+
)))
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def send(name, idempotency_key: nil)
|
|
206
|
+
envelope = require_envelope(name)
|
|
207
|
+
sent = unwrap_envelope(@requestor.call(
|
|
208
|
+
operation: Signingflow.operation("EnvelopeService_SendEnvelope"),
|
|
209
|
+
path_params: { "name" => envelope },
|
|
210
|
+
idempotency_key: idempotency_key,
|
|
211
|
+
body: {}
|
|
212
|
+
))
|
|
213
|
+
sent["name"] = envelope if text(sent["name"]).empty?
|
|
214
|
+
ResourceObject.new(sent)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def list(directory:, page_size: nil, page_token: nil, read_mask: nil)
|
|
218
|
+
folder = directory.to_s.strip
|
|
219
|
+
raise InvalidRequestError, "directory is required." if folder.empty?
|
|
220
|
+
|
|
221
|
+
size = PageSize.of(page_size)
|
|
222
|
+
loader = lambda do |token|
|
|
223
|
+
page = list_page(directory: folder, page_size: size, page_token: token, read_mask: read_mask)
|
|
224
|
+
List.new(data: page[:data], next_page_token: page[:next_page_token], loader: nil)
|
|
225
|
+
end
|
|
226
|
+
first = list_page(directory: folder, page_size: size, page_token: page_token, read_mask: read_mask)
|
|
227
|
+
List.new(data: first[:data], next_page_token: first[:next_page_token], loader: loader)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def remind(name)
|
|
231
|
+
envelope = require_envelope(name)
|
|
232
|
+
reminded = unwrap_envelope(@requestor.call(
|
|
233
|
+
operation: Signingflow.operation("EnvelopeService_RemindEnvelopeSigners"),
|
|
234
|
+
path_params: { "name" => envelope },
|
|
235
|
+
body: {}
|
|
236
|
+
))
|
|
237
|
+
reminded["name"] = envelope if text(reminded["name"]).empty?
|
|
238
|
+
ResourceObject.new(reminded)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def download(name)
|
|
242
|
+
envelope = require_envelope(name)
|
|
243
|
+
downloaded = @requestor.call(
|
|
244
|
+
operation: Signingflow.operation("EnvelopeService_DownloadEnvelope"),
|
|
245
|
+
path_params: { "name" => envelope }
|
|
246
|
+
)
|
|
247
|
+
ResourceObject.new(
|
|
248
|
+
"uri" => text(downloaded["uri"]),
|
|
249
|
+
"download_time" => text(downloaded["download_time"]).empty? ? text(downloaded["downloadTime"]) : text(downloaded["download_time"]),
|
|
250
|
+
"etag" => text(downloaded["etag"])
|
|
251
|
+
)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def prepare_draft(title:, file: nil, path: nil, message: nil, filename: nil)
|
|
255
|
+
heading = title.to_s.strip
|
|
256
|
+
raise InvalidRequestError, "Name the PDF and the draft title." if heading.empty?
|
|
257
|
+
|
|
258
|
+
stored = @files.create(file: file, path: path, filename: filename)
|
|
259
|
+
create(display_name: heading, description: message, storage_id: stored.id)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def send_document(title:, recipients:, file: nil, path: nil, message: nil, filename: nil)
|
|
263
|
+
heading = title.to_s.strip
|
|
264
|
+
raise InvalidRequestError, "Name the PDF, the title, and the recipients." if heading.empty?
|
|
265
|
+
|
|
266
|
+
people = clean_recipients(recipients)
|
|
267
|
+
stored = @files.create(file: file, path: path, filename: filename)
|
|
268
|
+
created = create(display_name: heading, description: message, storage_id: stored.id)
|
|
269
|
+
ResourceObject.new(finish_send(created.to_h, people, message, heading))
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def send_from_template(template:, recipients:, message: nil)
|
|
273
|
+
name = require_template(template)
|
|
274
|
+
people = clean_recipients(recipients)
|
|
275
|
+
created = @templates.use(name)
|
|
276
|
+
ResourceObject.new(finish_send(created.to_h, people, message, name))
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def download_completed(name)
|
|
280
|
+
envelope = require_envelope(name)
|
|
281
|
+
viewed = retrieve_hash(envelope)
|
|
282
|
+
unless envelope_state(viewed).strip.upcase == "COMPLETED"
|
|
283
|
+
raise InvalidRequestError.new("The signed file is not ready.", envelope_name: envelope)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
download(envelope)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
private
|
|
290
|
+
|
|
291
|
+
def retrieve_hash(name, read_mask: nil)
|
|
292
|
+
envelope = require_envelope(name)
|
|
293
|
+
viewed = unwrap_envelope(@requestor.call(
|
|
294
|
+
operation: Signingflow.operation("EnvelopeService_ViewEnvelope"),
|
|
295
|
+
path_params: { "name" => envelope },
|
|
296
|
+
query: { "readMask" => read_mask }
|
|
297
|
+
))
|
|
298
|
+
viewed["name"] = envelope if text(viewed["name"]).empty?
|
|
299
|
+
viewed
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def list_page(directory:, page_size:, page_token:, read_mask:)
|
|
303
|
+
body = @requestor.call(
|
|
304
|
+
operation: Signingflow.operation("EnvelopeService_ListEnvelopes"),
|
|
305
|
+
path_params: { "parent" => "envelopes" },
|
|
306
|
+
query: {
|
|
307
|
+
"directory" => directory,
|
|
308
|
+
"pageSize" => page_size.to_s,
|
|
309
|
+
"pageToken" => page_token,
|
|
310
|
+
"readMask" => read_mask
|
|
311
|
+
}
|
|
312
|
+
)
|
|
313
|
+
{ data: list_items(body, "envelopes"), next_page_token: next_token(body) }
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def finish_send(created, people, message, label)
|
|
317
|
+
name = text(created["name"])
|
|
318
|
+
raise APIError, "#{label} was saved without an envelope name." unless envelope_name?(name)
|
|
319
|
+
|
|
320
|
+
begin
|
|
321
|
+
viewed = retrieve_hash(name, read_mask: "documents,compliance")
|
|
322
|
+
patched = with_recipients(viewed, people, message)
|
|
323
|
+
raise InvalidRequestError.new("The draft has no document to address.", envelope_name: name) unless patched
|
|
324
|
+
|
|
325
|
+
update(name, patched)
|
|
326
|
+
send_or_view(name)
|
|
327
|
+
rescue StandardError => e
|
|
328
|
+
Error.attach_envelope(e, name)
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def send_or_view(name)
|
|
333
|
+
started = now_ms
|
|
334
|
+
send(name).to_h
|
|
335
|
+
rescue IdempotencyError => e
|
|
336
|
+
if e.resource_name == name
|
|
337
|
+
begin
|
|
338
|
+
return retrieve_hash(name)
|
|
339
|
+
rescue StandardError
|
|
340
|
+
Error.attach_envelope(e, name)
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
Error.attach_envelope(e, name)
|
|
344
|
+
rescue ConnectionError => e
|
|
345
|
+
begin
|
|
346
|
+
viewed = retrieve_hash(name)
|
|
347
|
+
sent = sent_millis(viewed)
|
|
348
|
+
return viewed if sent && sent_during?(sent, started, now_ms)
|
|
349
|
+
rescue StandardError
|
|
350
|
+
Error.attach_envelope(e, name)
|
|
351
|
+
end
|
|
352
|
+
Error.attach_envelope(e, name)
|
|
353
|
+
rescue Error => e
|
|
354
|
+
Error.attach_envelope(e, name)
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def with_recipients(envelope, people, message)
|
|
358
|
+
documents = envelope["documents"].is_a?(Array) ? envelope["documents"] : []
|
|
359
|
+
refs = documents.filter_map do |item|
|
|
360
|
+
next unless item.is_a?(Hash)
|
|
361
|
+
|
|
362
|
+
uid = text(item["uid"]).empty? ? text(item["id"]) : text(item["uid"])
|
|
363
|
+
next if uid.empty?
|
|
364
|
+
|
|
365
|
+
{ "uid" => uid, "display_name" => text(item["display_name"]).empty? ? text(item["displayName"]) : text(item["display_name"]) }
|
|
366
|
+
end
|
|
367
|
+
return nil if refs.empty?
|
|
368
|
+
|
|
369
|
+
participants = people.each_with_index.map do |person, index|
|
|
370
|
+
{
|
|
371
|
+
"uid" => SecureRandom.uuid,
|
|
372
|
+
"display_name" => person["name"],
|
|
373
|
+
"alternate_email" => person["email"],
|
|
374
|
+
"role" => "SIGNER",
|
|
375
|
+
"type" => "PERSONAL",
|
|
376
|
+
"action_order_type" => "SERIAL",
|
|
377
|
+
"action_order_number" => index + 1
|
|
378
|
+
}
|
|
379
|
+
end
|
|
380
|
+
compliance = envelope["compliance"].is_a?(Hash) ? envelope["compliance"] : nil
|
|
381
|
+
profile = compliance && !text(compliance["profile"]).empty? ? text(compliance["profile"]) : "STANDARD"
|
|
382
|
+
{
|
|
383
|
+
"name" => text(envelope["name"]),
|
|
384
|
+
"display_name" => text(envelope["display_name"]).empty? ? text(envelope["displayName"]) : text(envelope["display_name"]),
|
|
385
|
+
"description" => message.to_s.strip.empty? ? text(envelope["description"]) : message.to_s.strip,
|
|
386
|
+
"compliance" => { "profile" => profile },
|
|
387
|
+
"documents" => refs.each_with_index.map { |document, index|
|
|
388
|
+
row = { "uid" => document["uid"], "display_name" => document["display_name"] }
|
|
389
|
+
row["participants"] = participants if index.zero?
|
|
390
|
+
row
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def sent_during?(sent, started, now)
|
|
396
|
+
sent >= started && sent <= started + SEND_RECOVERY_WINDOW_MS && sent <= now + SEND_RECOVERY_SKEW_MS
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def now_ms
|
|
400
|
+
(Time.now.to_r * 1000).round
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
class WebhookUrls
|
|
405
|
+
def initialize(requestor)
|
|
406
|
+
@requestor = requestor
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def create(display_name:, http_request_uri:, events:, idempotency_key: nil, http_config: nil)
|
|
410
|
+
label = display_name.to_s.strip
|
|
411
|
+
raise InvalidRequestError, "displayName must be 1 to 100 characters." if label.empty? || label.length > 100
|
|
412
|
+
|
|
413
|
+
begin
|
|
414
|
+
uri = URI.parse(http_request_uri.to_s)
|
|
415
|
+
rescue URI::InvalidURIError
|
|
416
|
+
raise InvalidRequestError, "httpRequestUri must be an https URL."
|
|
417
|
+
end
|
|
418
|
+
raise InvalidRequestError, "httpRequestUri must be an https URL." unless uri.is_a?(URI::HTTPS)
|
|
419
|
+
raise InvalidRequestError, "events is required." if !events.is_a?(Array) || events.empty?
|
|
420
|
+
|
|
421
|
+
auth = http_config.is_a?(Hash) ? (http_config[:auth] || http_config["auth"]).to_s : ""
|
|
422
|
+
if auth.upcase == "NONE"
|
|
423
|
+
raise InvalidRequestError, "auth NONE has no signature. Leave http_config empty."
|
|
424
|
+
end
|
|
425
|
+
raise InvalidRequestError, "Leave http_config empty so the destination uses HMAC." if http_config
|
|
426
|
+
|
|
427
|
+
ResourceObject.new(@requestor.call(
|
|
428
|
+
operation: Signingflow.operation("WebhookUrlService_AddWebhookUrl"),
|
|
429
|
+
idempotency_key: idempotency_key,
|
|
430
|
+
body: {
|
|
431
|
+
"webhookUrl" => {
|
|
432
|
+
"displayName" => label,
|
|
433
|
+
"httpRequestUri" => http_request_uri,
|
|
434
|
+
"events" => events
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
))
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def retrieve(name)
|
|
441
|
+
destination = require_name(name, WEBHOOK_URL, "webhook url must look like webhookUrls/{id}.")
|
|
442
|
+
viewed = @requestor.call(
|
|
443
|
+
operation: Signingflow.operation("WebhookUrlService_ViewWebhookUrl"),
|
|
444
|
+
path_params: { "name" => destination }
|
|
445
|
+
)
|
|
446
|
+
record = viewed.is_a?(Hash) ? viewed.dup : {}
|
|
447
|
+
record.delete("signing_secret")
|
|
448
|
+
record.delete("signingSecret")
|
|
449
|
+
ResourceObject.new(record)
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def test(name)
|
|
453
|
+
destination = require_name(name, WEBHOOK_URL, "webhook url must look like webhookUrls/{id}.")
|
|
454
|
+
ResourceObject.new(@requestor.call(
|
|
455
|
+
operation: Signingflow.operation("WebhookUrlService_TestWebhookUrl"),
|
|
456
|
+
path_params: { "name" => destination },
|
|
457
|
+
body: {}
|
|
458
|
+
))
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
class WebhookDeliveries
|
|
463
|
+
def initialize(requestor)
|
|
464
|
+
@requestor = requestor
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def list(page_size: nil, page_token: nil)
|
|
468
|
+
size = PageSize.of(page_size)
|
|
469
|
+
loader = lambda do |token|
|
|
470
|
+
page = list_page(size, token)
|
|
471
|
+
List.new(data: page[:data], next_page_token: page[:next_page_token], loader: nil)
|
|
472
|
+
end
|
|
473
|
+
first = list_page(size, page_token)
|
|
474
|
+
List.new(data: first[:data], next_page_token: first[:next_page_token], loader: loader)
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def retrieve(name)
|
|
478
|
+
delivery = require_name(name, DELIVERY, "webhook delivery must look like webhooks/{id}.")
|
|
479
|
+
ResourceObject.new(@requestor.call(
|
|
480
|
+
operation: Signingflow.operation("WebhookService_ViewWebhook"),
|
|
481
|
+
path_params: { "name" => delivery }
|
|
482
|
+
))
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
private
|
|
486
|
+
|
|
487
|
+
def list_page(page_size, page_token)
|
|
488
|
+
body = @requestor.call(
|
|
489
|
+
operation: Signingflow.operation("WebhookService_ListWebhooks"),
|
|
490
|
+
path_params: { "parent" => "webhooks" },
|
|
491
|
+
query: { "pageSize" => page_size.to_s, "pageToken" => page_token }
|
|
492
|
+
)
|
|
493
|
+
{ data: list_items(body, "webhooks"), next_page_token: next_token(body) }
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
module_function
|
|
498
|
+
|
|
499
|
+
def require_envelope(name)
|
|
500
|
+
trimmed = name.to_s.strip
|
|
501
|
+
raise InvalidRequestError, "envelope must look like envelopes/{id}." unless ENVELOPE.match?(trimmed)
|
|
502
|
+
|
|
503
|
+
trimmed
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def require_template(name)
|
|
507
|
+
trimmed = name.to_s.strip
|
|
508
|
+
raise InvalidRequestError, "template must look like templates/{id}." unless TEMPLATE.match?(trimmed)
|
|
509
|
+
|
|
510
|
+
trimmed
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
def require_name(name, pattern, message)
|
|
514
|
+
trimmed = name.to_s.strip
|
|
515
|
+
raise InvalidRequestError, message unless pattern.match?(trimmed)
|
|
516
|
+
|
|
517
|
+
trimmed
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
def envelope_name?(name)
|
|
521
|
+
ENVELOPE.match?(name.to_s)
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def clean_recipients(recipients)
|
|
525
|
+
people = Array(recipients).map do |person|
|
|
526
|
+
source = person.is_a?(Hash) ? person : {}
|
|
527
|
+
{
|
|
528
|
+
"name" => (source["name"] || source[:name]).to_s.strip,
|
|
529
|
+
"email" => (source["email"] || source[:email]).to_s.strip
|
|
530
|
+
}
|
|
531
|
+
end
|
|
532
|
+
if people.empty? || people.any? { |person| person["name"].empty? || !EMAIL.match?(person["email"]) }
|
|
533
|
+
raise InvalidRequestError, "Each recipient needs a name and an email."
|
|
534
|
+
end
|
|
535
|
+
people
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def envelope_name_of(body)
|
|
539
|
+
record = body.is_a?(Hash) ? body : nil
|
|
540
|
+
return "" unless record
|
|
541
|
+
|
|
542
|
+
nested = record["envelope"].is_a?(Hash) ? record["envelope"] : nil
|
|
543
|
+
name = text(nested && nested["name"]).empty? ? text(record["name"]) : text(nested["name"])
|
|
544
|
+
return name if name.start_with?("envelopes/")
|
|
545
|
+
|
|
546
|
+
uid = text(nested && nested["uid"]).empty? ? text(record["uid"]) : text(nested["uid"])
|
|
547
|
+
uid.empty? ? "" : "envelopes/#{uid}"
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def unwrap_envelope(body)
|
|
551
|
+
return {} unless body.is_a?(Hash)
|
|
552
|
+
|
|
553
|
+
nested = body["envelope"]
|
|
554
|
+
(nested.is_a?(Hash) ? nested : body).dup
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def created_envelope(body)
|
|
558
|
+
unwrapped = unwrap_envelope(body)
|
|
559
|
+
name = envelope_name_of(body)
|
|
560
|
+
name = text(unwrapped["name"]) if name.empty?
|
|
561
|
+
unwrapped["name"] = name unless name.empty?
|
|
562
|
+
unwrapped
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def envelope_state(envelope)
|
|
566
|
+
progress = envelope["progress"].is_a?(Hash) ? envelope["progress"] : nil
|
|
567
|
+
from_progress = text(progress && progress["state"])
|
|
568
|
+
return from_progress unless from_progress.empty?
|
|
569
|
+
|
|
570
|
+
[envelope["progress_status"], envelope["progressStatus"], envelope["state"], envelope["condition"]].each do |value|
|
|
571
|
+
return value if value.is_a?(String) && !value.empty?
|
|
572
|
+
end
|
|
573
|
+
""
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
def sent_millis(envelope)
|
|
577
|
+
time_of(envelope["sent_time"]) || time_of(envelope["sentTime"])
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
def time_of(value)
|
|
581
|
+
return nil unless value.is_a?(String) && !value.strip.empty?
|
|
582
|
+
|
|
583
|
+
(Time.iso8601(value).to_r * 1000).round
|
|
584
|
+
rescue ArgumentError
|
|
585
|
+
nil
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
def text(value)
|
|
589
|
+
value.is_a?(String) ? value : ""
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def stringify(body)
|
|
593
|
+
return {} unless body.is_a?(Hash)
|
|
594
|
+
|
|
595
|
+
body.each_with_object({}) do |(key, value), copy|
|
|
596
|
+
copy[key.to_s] = value.is_a?(Hash) ? stringify(value) : value
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
def list_items(body, key)
|
|
601
|
+
list = body.is_a?(Hash) ? body[key] : nil
|
|
602
|
+
return [] unless list.is_a?(Array)
|
|
603
|
+
|
|
604
|
+
list.filter_map { |item| ResourceObject.new(item) if item.is_a?(Hash) }
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
def next_token(body)
|
|
608
|
+
return nil unless body.is_a?(Hash)
|
|
609
|
+
|
|
610
|
+
value = body["next_page_token"] || body["nextPageToken"]
|
|
611
|
+
value.is_a?(String) && !value.empty? ? value : nil
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
def signing_secret_of(body)
|
|
615
|
+
return "" unless body.is_a?(Hash) || body.is_a?(ResourceObject)
|
|
616
|
+
|
|
617
|
+
record = body.is_a?(ResourceObject) ? body.to_h : body
|
|
618
|
+
secret = text(record["signing_secret"])
|
|
619
|
+
secret.empty? ? text(record["signingSecret"]) : secret
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
[Files, Templates, Envelopes, WebhookUrls, WebhookDeliveries].each { |klass| klass.include self }
|
|
623
|
+
end
|
|
624
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Signingflow
|
|
4
|
+
module Status
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def grpc_code(body)
|
|
8
|
+
return nil unless body.is_a?(Hash)
|
|
9
|
+
|
|
10
|
+
code = body["code"]
|
|
11
|
+
code.is_a?(Numeric) ? code : nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def server_message(body)
|
|
15
|
+
return body if body.is_a?(String)
|
|
16
|
+
return "" unless body.is_a?(Hash)
|
|
17
|
+
|
|
18
|
+
message = body["message"]
|
|
19
|
+
message.is_a?(String) ? message : ""
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def trace_code(body)
|
|
23
|
+
found = find_meta(body, "trace_code")
|
|
24
|
+
found.empty? ? nil : found
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def resource_name(body)
|
|
28
|
+
url = find_meta(body, "resource_url")
|
|
29
|
+
return url unless url.empty?
|
|
30
|
+
|
|
31
|
+
id = find_meta(body, "resource_id")
|
|
32
|
+
id.empty? ? nil : id
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def field_violations(body)
|
|
36
|
+
found = []
|
|
37
|
+
walk(body) do |record|
|
|
38
|
+
list = record["fieldViolations"] || record["field_violations"]
|
|
39
|
+
next unless list.is_a?(Array)
|
|
40
|
+
|
|
41
|
+
list.each do |item|
|
|
42
|
+
next unless item.is_a?(Hash)
|
|
43
|
+
|
|
44
|
+
field = item["field"].is_a?(String) ? item["field"] : ""
|
|
45
|
+
description = item["description"].is_a?(String) ? item["description"] : ""
|
|
46
|
+
found << { "field" => field, "description" => description } if !field.empty? || !description.empty?
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
found.empty? ? nil : found
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def mentions_idempotency?(body)
|
|
53
|
+
JSON.generate(body.nil? ? "" : body).downcase.include?("idempotency")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def find_meta(body, key)
|
|
57
|
+
found = +""
|
|
58
|
+
walk(body) do |record|
|
|
59
|
+
next unless found.empty?
|
|
60
|
+
|
|
61
|
+
metadata = record["metadata"]
|
|
62
|
+
next unless metadata.is_a?(Hash)
|
|
63
|
+
|
|
64
|
+
value = metadata[key]
|
|
65
|
+
found = value if value.is_a?(String) && !value.empty?
|
|
66
|
+
end
|
|
67
|
+
found
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def walk(value, &visit)
|
|
71
|
+
return unless value.is_a?(Hash) || value.is_a?(Array)
|
|
72
|
+
|
|
73
|
+
if value.is_a?(Array)
|
|
74
|
+
value.each { |item| walk(item, &visit) }
|
|
75
|
+
return
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
visit.call(value)
|
|
79
|
+
value.each_value { |child| walk(child, &visit) }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|