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,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