clicksign-ruby-sdk 0.1.3 → 0.1.5
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 +4 -4
- data/README.md +275 -32
- data/lib/clicksign/client.rb +14 -46
- data/lib/clicksign/configuration.rb +2 -2
- data/lib/clicksign/error_handler.rb +2 -1
- data/lib/clicksign/errors.rb +1 -1
- data/lib/clicksign/instrumentation.rb +10 -5
- data/lib/clicksign/json_api/bulk_operations_client.rb +36 -26
- data/lib/clicksign/json_api/operations/bulk_requirement.rb +1 -1
- data/lib/clicksign/json_api/parser.rb +3 -1
- data/lib/clicksign/json_api/query_builder.rb +2 -1
- data/lib/clicksign/request_instrumentation.rb +57 -0
- data/lib/clicksign/resource.rb +73 -18
- data/lib/clicksign/resources/notarial/envelope.rb +5 -5
- data/lib/clicksign/resources/notarial/event.rb +47 -1
- data/lib/clicksign/resources/notarial/signature_watcher.rb +1 -1
- data/lib/clicksign/resources/notarial/signer.rb +1 -1
- data/lib/clicksign/retry_backoff.rb +22 -0
- data/lib/clicksign/services.rb +1 -1
- data/lib/clicksign/webhook.rb +3 -1
- data/lib/clicksign.rb +2 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a45728b201a9703e1d70cb6de98056f85da9a524d9d0c122a1ed951d6f98ef5b
|
|
4
|
+
data.tar.gz: 7a2aea99029254b4849b27090f40927d4cbf66d44cb516f121ea14c65544f141
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e9cb3bdaff221f8a8e58a9ef4ca47363962e19b7dcb4f22eb129cb1f5f3b60812550db76301ee8a36473be11490293932895d83c91ef60d10928206d090cd162
|
|
7
|
+
data.tar.gz: 8c3cd41e15ba5c21136fc3ee4b53fcb96c231620735c17401acbd046a032262b31a9a1617f07ec704e9a9593dedb8023dd82019afe156cee782356c78c297199
|
data/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Cliente Ruby oficial para a [API v3 da Clicksign](https://developers.clicksign.c
|
|
|
9
9
|
|
|
10
10
|
**Requisitos:** Ruby >= 3.0 · dependências de runtime: apenas biblioteca padrão (`net/http`, `json`).
|
|
11
11
|
|
|
12
|
-
**Documentação
|
|
12
|
+
**Documentação:** [índice `docs/`](docs/) · [Workflow](docs/WORKFLOW.md) · [Cookbook](docs/cookbook/) · [Troubleshooting](docs/TROUBLESHOOTING.md) · [Arquitetura](docs/ARCHITECTURE.md) · [Observabilidade](docs/OBSERVABILITY.md) · [SPEC](docs/SPEC.md) · API: [Sandbox](https://sandbox.clicksign.com/api/v3) · [Produção](https://app.clicksign.com/api/v3)
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
@@ -17,17 +17,26 @@ Cliente Ruby oficial para a [API v3 da Clicksign](https://developers.clicksign.c
|
|
|
17
17
|
|
|
18
18
|
- [Instalação](#instalação)
|
|
19
19
|
- [Configuração](#configuração)
|
|
20
|
+
- [Multi-conta e cliente instanciável](#multi-conta-e-cliente-instantiável)
|
|
21
|
+
- [Timeouts, retry e instrumentação](#timeouts-retry-e-instrumentação)
|
|
20
22
|
- [Início rápido](#início-rápido)
|
|
21
23
|
- [Fluxo de assinatura (notarial)](#fluxo-de-assinatura-notarial)
|
|
22
24
|
- [Filtros, ordenação e paginação](#filtros-ordenação-e-paginação)
|
|
23
25
|
- [Outros recursos](#outros-recursos)
|
|
24
26
|
- [Tratamento de erros](#tratamento-de-erros)
|
|
25
27
|
- [Ambientes](#ambientes)
|
|
28
|
+
- [Limitações e produção](#limitações-e-produção)
|
|
26
29
|
- [Desenvolvimento](#desenvolvimento)
|
|
27
30
|
- [Licença](#licença)
|
|
28
31
|
|
|
29
32
|
> **Exemplo passo a passo:** [`docs/WORKFLOW.md`](docs/WORKFLOW.md) — fluxo completo de envelope → documento → signatário → requisitos → ativação → notificação.
|
|
30
33
|
|
|
34
|
+
> **Cookbook (receitas por cenário):** [`docs/cookbook/`](docs/cookbook/) — [retries](docs/cookbook/01-retries.md), [bulk requirements](docs/cookbook/02-bulk-requirements.md), [webhooks](docs/cookbook/03-webhooks.md), [vários clientes](docs/cookbook/04-multi-client.md), [list vs filter](docs/cookbook/07-list-and-filter.md), [limitações de produção](docs/cookbook/08-production-limitations.md).
|
|
35
|
+
|
|
36
|
+
> **Troubleshooting:** [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md) — sintoma → causa → correção (erros HTTP, multi-tenant, bulk parcial, webhooks).
|
|
37
|
+
|
|
38
|
+
> **Arquitetura e observabilidade:** [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) · [`docs/OBSERVABILITY.md`](docs/OBSERVABILITY.md)
|
|
39
|
+
|
|
31
40
|
---
|
|
32
41
|
|
|
33
42
|
## Instalação
|
|
@@ -56,21 +65,40 @@ gem install clicksign-ruby-sdk
|
|
|
56
65
|
|
|
57
66
|
## Configuração
|
|
58
67
|
|
|
59
|
-
|
|
68
|
+
A forma mais simples é a configuração global no boot da aplicação (initializer, `config/initializers/clicksign.rb`, script, etc.):
|
|
60
69
|
|
|
61
70
|
```ruby
|
|
62
71
|
require 'clicksign'
|
|
63
72
|
|
|
64
73
|
Clicksign.configure do |c|
|
|
65
74
|
c.api_key = ENV.fetch('CLICKSIGN_API_KEY')
|
|
66
|
-
c.
|
|
75
|
+
c.environment = :sandbox # ou :production — define base_url automaticamente
|
|
76
|
+
# c.base_url = '...' # opcional: sobrescreve o URL do ambiente
|
|
77
|
+
c.open_timeout = 2 # segundos (padrão)
|
|
78
|
+
c.read_timeout = 10
|
|
79
|
+
c.write_timeout = 10
|
|
80
|
+
c.max_retries = 0 # retentativas automáticas (ver seção abaixo)
|
|
67
81
|
end
|
|
68
82
|
```
|
|
69
83
|
|
|
84
|
+
| Opção | Padrão | Descrição |
|
|
85
|
+
|-------|--------|-----------|
|
|
86
|
+
| `api_key` | — | Token da API (obrigatório) |
|
|
87
|
+
| `environment` | — | `:sandbox` ou `:production` (atalho para `base_url`) |
|
|
88
|
+
| `base_url` | produção | URL completa da API v3 |
|
|
89
|
+
| `open_timeout` | `2` | Timeout de conexão (s) |
|
|
90
|
+
| `read_timeout` | `10` | Timeout de leitura (s) |
|
|
91
|
+
| `write_timeout` | `10` | Timeout de escrita (s) |
|
|
92
|
+
| `max_retries` | `0` | Retentativas em erros transitórios |
|
|
93
|
+
|
|
70
94
|
A API usa o header `Authorization: <seu-token>` **sem** o prefixo `Bearer`.
|
|
71
95
|
|
|
72
96
|
> **Segurança:** não commite tokens no código. Use variáveis de ambiente ou cofre de secrets (Rails credentials, etc.).
|
|
73
97
|
|
|
98
|
+
> **`api_key` é obrigatório em runtime:** o SDK não valida a presença do token no boot. Se `api_key` for `nil`, nenhum erro é levantado no `configure` — o `AuthenticationError` só aparece na primeira request HTTP. Use `ENV.fetch('CLICKSIGN_API_KEY')` (em vez de `ENV[]`) para detectar a ausência da variável no startup da aplicação.
|
|
99
|
+
|
|
100
|
+
> **Multi-conta / multi-tenant:** se cada requisição pode usar credenciais diferentes (SaaS, workers por cliente), prefira [`Clicksign::Services`](#multi-conta-e-cliente-instantiável) em vez da config global.
|
|
101
|
+
|
|
74
102
|
Para testar interativamente no console da gem:
|
|
75
103
|
|
|
76
104
|
```bash
|
|
@@ -79,6 +107,126 @@ CLICKSIGN_API_KEY=seu-token bundle exec ruby bin/console
|
|
|
79
107
|
|
|
80
108
|
---
|
|
81
109
|
|
|
110
|
+
## Multi-conta e cliente instanciável
|
|
111
|
+
|
|
112
|
+
Além da config global, a gem oferece clientes isolados por contexto — útil em apps multi-tenant, jobs Sidekiq com token por conta e testes paralelos.
|
|
113
|
+
|
|
114
|
+
### `Clicksign::Services` (recomendado para resources)
|
|
115
|
+
|
|
116
|
+
Encapsula um `Clicksign::Client` e roteia todas as chamadas de `Clicksign::Resources::*` dentro do bloco `use`:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
conta_a = Clicksign::Services.new(
|
|
120
|
+
api_key: ENV['CLICKSIGN_TOKEN_CONTA_A'],
|
|
121
|
+
environment: :production,
|
|
122
|
+
max_retries: 2
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
conta_b = Clicksign::Services.new(
|
|
126
|
+
api_key: ENV['CLICKSIGN_TOKEN_CONTA_B'],
|
|
127
|
+
environment: :sandbox
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
conta_a.use do
|
|
131
|
+
Clicksign::Resources::Notarial::Envelope.create(name: 'Contrato A')
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
conta_b.use do
|
|
135
|
+
Clicksign::Resources::Notarial::Envelope.filter(status: 'draft').to_a
|
|
136
|
+
end
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
O client ativo fica em `Thread.current` durante o bloco; blocos aninhados restauram o client externo ao sair. Fora de `use`, os resources voltam a usar `Clicksign.client` (config global).
|
|
140
|
+
|
|
141
|
+
Em Rails, um padrão comum é resolver o service no controller e executar a lógica dentro de `use`:
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
class EnvelopesController < ApplicationController
|
|
145
|
+
def create
|
|
146
|
+
current_tenant.clicksign_service.use do
|
|
147
|
+
envelope = Clicksign::Resources::Notarial::Envelope.create(envelope_params)
|
|
148
|
+
render json: { id: envelope.id }
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### `Clicksign::Client` (HTTP direto)
|
|
155
|
+
|
|
156
|
+
Para chamadas JSON:API de baixo nível sem passar pelos resources:
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
client = Clicksign::Client.new(
|
|
160
|
+
api_key: ENV['CLICKSIGN_API_KEY'],
|
|
161
|
+
base_url: 'https://sandbox.clicksign.com/api/v3',
|
|
162
|
+
open_timeout: 2,
|
|
163
|
+
read_timeout: 30,
|
|
164
|
+
max_retries: 3
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
response = client.get('/envelopes', params: { 'filter[status]' => 'draft' })
|
|
168
|
+
client.post('/envelopes', body: { data: { type: 'envelopes', attributes: { name: 'Novo' } } })
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
| Abordagem | Quando usar |
|
|
172
|
+
|-----------|-------------|
|
|
173
|
+
| `Clicksign.configure` | App single-tenant; initializer único |
|
|
174
|
+
| `Clicksign::Services#use` | Multi-conta; token por request/job |
|
|
175
|
+
| `Clicksign::Client.new` | Controle fino do HTTP ou integração customizada |
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## Timeouts, retry e instrumentação
|
|
180
|
+
|
|
181
|
+
### Timeouts
|
|
182
|
+
|
|
183
|
+
Configuráveis globalmente (`Clicksign.configure`), por `Services` ou diretamente em `Client.new`. Timeouts de rede disparam `Clicksign::TimeoutError` (retryable quando `max_retries > 0`).
|
|
184
|
+
|
|
185
|
+
### Retry automático
|
|
186
|
+
|
|
187
|
+
Com `max_retries > 0`, o client reexecuta a requisição em erros **transitórios**:
|
|
188
|
+
|
|
189
|
+
- `Clicksign::TimeoutError`
|
|
190
|
+
- `Clicksign::RateLimitError`
|
|
191
|
+
- `Clicksign::ServerError` (5xx)
|
|
192
|
+
|
|
193
|
+
Backoff exponencial com **full jitter**: espera aleatória uniforme em `[0, teto)` onde o teto cresce como `0.5s × 2^(tentativa-1)` (0,5s → 1s → 2s…) com cap de **30s**. O zero é possível — o jitter distribui a espera para evitar thundering herd. Após esgotar as retentativas, a exceção original é relançada.
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
Clicksign.configure do |c|
|
|
197
|
+
c.api_key = ENV['CLICKSIGN_API_KEY']
|
|
198
|
+
c.environment = :production
|
|
199
|
+
c.max_retries = 3
|
|
200
|
+
end
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Operações em lote (`BulkRequirement`) usam o mesmo `max_retries` e os mesmos hooks de instrumentação via `Clicksign.bulk_operations_client` (retry automático só em timeout).
|
|
204
|
+
|
|
205
|
+
### Instrumentação
|
|
206
|
+
|
|
207
|
+
Registre callbacks para observabilidade (logs, métricas, APM). Callbacks não propagam exceções — falhas internas são ignoradas para não afetar a requisição.
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
Clicksign.on_request do |event|
|
|
211
|
+
# event: :method, :path, :status, :duration_ms, :attempt
|
|
212
|
+
Rails.logger.info "[Clicksign] #{event[:method]} #{event[:path]} → #{event[:status]} (#{event[:duration_ms]}ms)"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
Clicksign.on_retry do |event|
|
|
216
|
+
# event: :method, :path, :attempt, :max_retries, :error, :wait_ms
|
|
217
|
+
Rails.logger.warn "[Clicksign] retry #{event[:attempt]}/#{event[:max_retries]} em #{event[:wait_ms]}ms"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
Clicksign.on_error do |event|
|
|
221
|
+
# event: :method, :path, :error, :status, :duration_ms
|
|
222
|
+
Sentry.capture_exception(event[:error])
|
|
223
|
+
end
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Eventos publicados: `:request` (toda tentativa, sucesso ou erro HTTP), `:retry` (antes de cada retentativa), `:error` (quando uma exceção é lançada).
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
82
230
|
## Início rápido
|
|
83
231
|
|
|
84
232
|
Listar envelopes em rascunho e criar um novo:
|
|
@@ -316,12 +464,33 @@ Envelope.list_events(envelope.id)
|
|
|
316
464
|
# Eventos de um documento
|
|
317
465
|
Document.list_events(document.id, envelope_id: envelope.id)
|
|
318
466
|
|
|
319
|
-
# Criar evento
|
|
320
|
-
Event.
|
|
467
|
+
# Criar evento de imagem no documento (comprovante JPEG)
|
|
468
|
+
Event.create_add_image(
|
|
469
|
+
envelope_id: envelope.id,
|
|
470
|
+
document_id: document.id,
|
|
471
|
+
title: 'Comprovante de identidade',
|
|
472
|
+
occurred_at: Time.now.iso8601,
|
|
473
|
+
content_base64: 'data:image/jpeg;base64,...'
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
# Criar evento customizado — token_email ou token_sms
|
|
477
|
+
Event.create_custom(
|
|
478
|
+
envelope_id: envelope.id,
|
|
479
|
+
document_id: document.id,
|
|
480
|
+
kind: 'token_email', # ou 'token_sms'
|
|
481
|
+
occurred_at: Time.now.iso8601,
|
|
482
|
+
signer_name: 'Maria Silva',
|
|
483
|
+
signer_email: 'maria@empresa.com',
|
|
484
|
+
# signer_phone_number: '11988887777' # obrigatório quando kind: 'token_sms'
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
# API de baixo nível — qualquer name customizado
|
|
488
|
+
Event.create(
|
|
321
489
|
envelope_id: envelope.id,
|
|
322
490
|
document_id: document.id,
|
|
323
491
|
name: 'custom',
|
|
324
|
-
data: {
|
|
492
|
+
data: { kind: 'token_email', signer_name: 'Maria Silva',
|
|
493
|
+
signer_email: 'maria@empresa.com', occurred_at: Time.now.iso8601 }
|
|
325
494
|
)
|
|
326
495
|
```
|
|
327
496
|
|
|
@@ -329,23 +498,40 @@ Event.create_for_document(
|
|
|
329
498
|
|
|
330
499
|
## Filtros, ordenação e paginação
|
|
331
500
|
|
|
332
|
-
|
|
501
|
+
### `list` vs `filter`
|
|
502
|
+
|
|
503
|
+
| Método | Retorno | Uso |
|
|
504
|
+
|--------|---------|-----|
|
|
505
|
+
| `Resource.list` | `Array` | Primeira página da collection, **sem** filtros na chain |
|
|
506
|
+
| `Resource.filter(...)` | `QueryProxy` | Filtros, ordenação, paginação, includes — termine com `.to_a`, `.first`, `.auto_paging_each`, etc. |
|
|
507
|
+
|
|
508
|
+
`list` **não** aceita argumentos. Para filtrar: `Envelope.filter(status: 'draft').to_a` (não `Envelope.list(status: 'draft')`).
|
|
509
|
+
|
|
510
|
+
Guia completo: [`docs/cookbook/07-list-and-filter.md`](docs/cookbook/07-list-and-filter.md).
|
|
511
|
+
|
|
512
|
+
```ruby
|
|
513
|
+
# Sem filtros — retorna Array imediatamente
|
|
514
|
+
Webhook.list
|
|
515
|
+
|
|
516
|
+
# Com filtros ou chain — começa em filter
|
|
517
|
+
Envelope.filter(status: 'draft').to_a
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
### Chain de consulta
|
|
333
521
|
|
|
334
522
|
```ruby
|
|
335
523
|
Envelope
|
|
336
524
|
.filter(status: 'running', name: 'Contrato')
|
|
525
|
+
.with_includes('folder') # sideload JSON:API (.include('folder') também funciona)
|
|
337
526
|
.order('-created')
|
|
338
527
|
.page(1)
|
|
339
528
|
.per(20)
|
|
340
529
|
.to_a
|
|
341
530
|
|
|
342
|
-
# Atalho quando há poucos filtros
|
|
343
531
|
Template.filter(name: 'NDA padrão').first
|
|
344
532
|
|
|
345
|
-
|
|
346
|
-
Envelope.
|
|
347
|
-
Envelope.order('-created').last # mais antigo
|
|
348
|
-
Envelope.filter(status: 'draft').count # total de rascunhos
|
|
533
|
+
Envelope.order('-created').first
|
|
534
|
+
Envelope.filter(status: 'draft').count
|
|
349
535
|
```
|
|
350
536
|
|
|
351
537
|
Atributos dos objetos retornados são acessados como métodos ou por chave string:
|
|
@@ -364,6 +550,8 @@ Envelope.filter(status: 'running').map(&:name)
|
|
|
364
550
|
Envelope.order('-created').select { |e| e.auto_close }
|
|
365
551
|
```
|
|
366
552
|
|
|
553
|
+
Percorrer **todas** as páginas automaticamente (`auto_paging_each`, `each_page`, `auto_paging`): a gem segue `links.next` da JSON:API quando presente; se a API não enviar `links`, usa heurística por `page[size]`.
|
|
554
|
+
|
|
367
555
|
---
|
|
368
556
|
|
|
369
557
|
## Outros recursos
|
|
@@ -372,8 +560,8 @@ Envelope.order('-created').select { |e| e.auto_close }
|
|
|
372
560
|
|---------|--------|---------|
|
|
373
561
|
| Webhook | `Clicksign::Resources::Webhook` | `Webhook.create(endpoint: 'https://...', events: ['envelope.completed'], status: 'active')` |
|
|
374
562
|
| Pasta | `Clicksign::Resources::Folder` | `Folder.create(name: 'Contratos 2026', folder_id: pai&.id)` |
|
|
375
|
-
| Template | `Clicksign::Resources::Template` | `Template.list` · `Template.list_template_fields(id)` |
|
|
376
|
-
| Usuário | `Clicksign::Resources::User` | `User.me` · `User.filter(email: '...')` |
|
|
563
|
+
| Template | `Clicksign::Resources::Template` | `Template.list` · `Template.filter(name: '...')` · `Template.list_template_fields(id)` |
|
|
564
|
+
| Usuário | `Clicksign::Resources::User` | `User.me` · `User.list` · `User.filter(email: '...')` |
|
|
377
565
|
| Membership | `Clicksign::Resources::Membership` | `Membership.create(role: 'member', user_id: user.id)` |
|
|
378
566
|
| Grupo | `Clicksign::Resources::Group` | `Group.add_users(group_id, [user.id])` |
|
|
379
567
|
| ACL pasta/grupo | `Clicksign::Resources::AccessControlList` | `AccessControlList.create(folder_id:, group_id:)` |
|
|
@@ -453,15 +641,22 @@ AccessControlList.destroy(folder_id: folder.id, group_id: group.id)
|
|
|
453
641
|
|
|
454
642
|
Erros HTTP são convertidos em exceções antes de chegar ao seu código:
|
|
455
643
|
|
|
456
|
-
| HTTP | Exceção |
|
|
457
|
-
|
|
458
|
-
| 401, 403 | `Clicksign::AuthenticationError` |
|
|
459
|
-
| 404 | `Clicksign::NotFoundError` |
|
|
460
|
-
| 400, 422 | `Clicksign::ValidationError` |
|
|
461
|
-
| 409 | `Clicksign::ConflictError` |
|
|
462
|
-
| 429 | `Clicksign::RateLimitError` |
|
|
463
|
-
| 5xx | `Clicksign::ServerError` |
|
|
464
|
-
| Timeout / conexão | `Clicksign::TimeoutError` |
|
|
644
|
+
| HTTP | Exceção | `retryable?` |
|
|
645
|
+
|------|---------|--------------|
|
|
646
|
+
| 401, 403 | `Clicksign::AuthenticationError` | não |
|
|
647
|
+
| 404 | `Clicksign::NotFoundError` | não |
|
|
648
|
+
| 400, 422 | `Clicksign::ValidationError` | não |
|
|
649
|
+
| 409 | `Clicksign::ConflictError` | não |
|
|
650
|
+
| 429 | `Clicksign::RateLimitError` | sim |
|
|
651
|
+
| 5xx | `Clicksign::ServerError` | sim |
|
|
652
|
+
| Timeout / conexão | `Clicksign::TimeoutError` | sim |
|
|
653
|
+
|
|
654
|
+
Todas herdam de `Clicksign::Error` e expõem metadados úteis para debug:
|
|
655
|
+
|
|
656
|
+
- `status_code` — código HTTP da resposta
|
|
657
|
+
- `request_id` — quando enviado pela API
|
|
658
|
+
- `response_body` — corpo JSON da resposta de erro
|
|
659
|
+
- `response_headers` — headers da resposta (`RateLimitError` também expõe `rate_limit_remaining` e `rate_limit_reset`)
|
|
465
660
|
|
|
466
661
|
Exemplo:
|
|
467
662
|
|
|
@@ -472,6 +667,9 @@ rescue Clicksign::NotFoundError
|
|
|
472
667
|
puts 'Envelope não encontrado'
|
|
473
668
|
rescue Clicksign::ValidationError => e
|
|
474
669
|
puts "Dados inválidos: #{e.message}"
|
|
670
|
+
puts e.response_body
|
|
671
|
+
rescue Clicksign::RateLimitError => e
|
|
672
|
+
puts "Aguarde reset em #{e.rate_limit_reset}" if e.retryable?
|
|
475
673
|
rescue Clicksign::AuthenticationError
|
|
476
674
|
puts 'Verifique CLICKSIGN_API_KEY'
|
|
477
675
|
end
|
|
@@ -479,28 +677,62 @@ end
|
|
|
479
677
|
|
|
480
678
|
Operações em lote (`BulkRequirement`) podem retornar falhas **por slot** em `response.failures` sem lançar exceção, quando a API responde com `atomic:results` parcial.
|
|
481
679
|
|
|
680
|
+
Guia detalhado: [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md).
|
|
681
|
+
|
|
482
682
|
---
|
|
483
683
|
|
|
484
684
|
## Ambientes
|
|
485
685
|
|
|
486
|
-
| Ambiente | `base_url` |
|
|
487
|
-
|
|
488
|
-
| Sandbox | `https://sandbox.clicksign.com/api/v3` |
|
|
489
|
-
| Produção | `https://app.clicksign.com/api/v3` |
|
|
686
|
+
| Ambiente | Símbolo | `base_url` |
|
|
687
|
+
|----------|---------|------------|
|
|
688
|
+
| Sandbox | `:sandbox` | `https://sandbox.clicksign.com/api/v3` |
|
|
689
|
+
| Produção | `:production` | `https://app.clicksign.com/api/v3` |
|
|
490
690
|
|
|
491
|
-
O padrão
|
|
691
|
+
O padrão em `Clicksign::Configuration` é **produção**. Para desenvolvimento, use o atalho `environment` (equivalente a definir `base_url`):
|
|
492
692
|
|
|
493
693
|
```ruby
|
|
494
694
|
Clicksign.configure do |c|
|
|
495
|
-
c.api_key
|
|
496
|
-
c.
|
|
695
|
+
c.api_key = ENV['CLICKSIGN_API_KEY']
|
|
696
|
+
c.environment = :sandbox
|
|
497
697
|
end
|
|
698
|
+
|
|
699
|
+
# Ou em multi-conta:
|
|
700
|
+
service = Clicksign::Services.new(
|
|
701
|
+
api_key: ENV['CLICKSIGN_API_KEY'],
|
|
702
|
+
environment: :sandbox
|
|
703
|
+
)
|
|
498
704
|
```
|
|
499
705
|
|
|
706
|
+
Também é possível passar `base_url` manualmente quando precisar de um endpoint customizado (proxy, mock, etc.).
|
|
707
|
+
|
|
500
708
|
Gere tokens de API no painel da Clicksign do ambiente correspondente.
|
|
501
709
|
|
|
502
710
|
---
|
|
503
711
|
|
|
712
|
+
## Limitações e produção
|
|
713
|
+
|
|
714
|
+
Design **stdlib-only** (`net/http`) — sem dependências de runtime extras. Duas limitações importantes em alta carga ou runtimes modernos:
|
|
715
|
+
|
|
716
|
+
### Sem connection pool
|
|
717
|
+
|
|
718
|
+
Cada request abre e fecha uma conexão TCP (via `Net::HTTP.start`). Não há reutilização persistente entre chamadas.
|
|
719
|
+
|
|
720
|
+
- **OK** para jobs sequenciais, integrações moderadas e a maioria dos apps Rails.
|
|
721
|
+
- **Atenção** em Puma com muitas threads e várias chamadas Clicksign por request: overhead de handshake/TLS pode virar gargalo antes do rate limit da API.
|
|
722
|
+
|
|
723
|
+
Mitigações: menos round-trips (`BulkRequirement`, batch na app), filas (Sidekiq), cache de leitura. Detalhes: [`docs/cookbook/08-production-limitations.md`](docs/cookbook/08-production-limitations.md).
|
|
724
|
+
|
|
725
|
+
### `Thread.current` e Fibers
|
|
726
|
+
|
|
727
|
+
`Clicksign::Services#use` armazena o client em `Thread.current[:clicksign_client]`. Resources usam esse client dentro do bloco.
|
|
728
|
+
|
|
729
|
+
- **Compatível:** Puma (thread por request), Sidekiq, scripts.
|
|
730
|
+
- **Incompatível** com propagar contexto em **Fibers** (Falcon, async-ruby): o client do `use` pode não estar visível no Fiber que chama `Envelope.create`.
|
|
731
|
+
|
|
732
|
+
Mitigações: `Clicksign.configure` por processo (single-tenant), `Clicksign::Client.new` explícito no seu contexto async, ou evitar `Services` em stacks fiberizadas.
|
|
733
|
+
|
|
734
|
+
---
|
|
735
|
+
|
|
504
736
|
## Desenvolvimento
|
|
505
737
|
|
|
506
738
|
Clone o repositório e instale dependências de desenvolvimento:
|
|
@@ -515,17 +747,28 @@ bundle exec rspec
|
|
|
515
747
|
| `CLICKSIGN_API_KEY` | Token para testes contra sandbox (opcional) |
|
|
516
748
|
| `CLICKSIGN_API_BASE_URL` | URL da API (padrão sandbox nos specs de integração legados) |
|
|
517
749
|
|
|
518
|
-
A suíte
|
|
750
|
+
A suíte usa **WebMock** e não exige rede.
|
|
519
751
|
|
|
520
752
|
Estrutura relevante:
|
|
521
753
|
|
|
522
754
|
```
|
|
523
755
|
lib/clicksign/
|
|
524
|
-
|
|
756
|
+
retry_backoff.rb # Exponential backoff com full jitter
|
|
757
|
+
client.rb # HTTP (GET, POST, PATCH, DELETE), retry, timeouts
|
|
758
|
+
services.rb # Cliente por contexto (multi-conta via #use)
|
|
759
|
+
configuration.rb # Config global, environment, timeouts, retry
|
|
760
|
+
request_instrumentation.rb # Hooks compartilhados Client + BulkOperationsClient
|
|
761
|
+
instrumentation.rb # Eventos :request, :retry, :error
|
|
525
762
|
resource.rb # CRUD base, filtros, nested lists
|
|
526
763
|
resources/notarial/ # Envelope, Document, Signer, Requirement, ...
|
|
527
764
|
json_api/ # Serializer, Parser, bulk operations
|
|
528
765
|
docs/SPEC.md # mapa completo de resources e rotas
|
|
766
|
+
docs/WORKFLOW.md # fluxo notarial ponta a ponta
|
|
767
|
+
docs/README.md # índice da documentação
|
|
768
|
+
docs/cookbook/ # receitas: retries, bulk, webhooks, multi-cliente
|
|
769
|
+
docs/TROUBLESHOOTING.md # diagnóstico e erros comuns
|
|
770
|
+
docs/ARCHITECTURE.md # diagramas e camadas
|
|
771
|
+
docs/OBSERVABILITY.md # logs, métricas, OpenTelemetry
|
|
529
772
|
```
|
|
530
773
|
|
|
531
774
|
---
|
data/lib/clicksign/client.rb
CHANGED
|
@@ -6,13 +6,15 @@ require 'json'
|
|
|
6
6
|
|
|
7
7
|
module Clicksign
|
|
8
8
|
class Client
|
|
9
|
+
include RequestInstrumentation
|
|
10
|
+
|
|
9
11
|
HEADERS = {
|
|
10
12
|
'Content-Type' => 'application/vnd.api+json',
|
|
11
13
|
'Accept' => 'application/vnd.api+json',
|
|
12
14
|
}.freeze
|
|
13
15
|
|
|
14
16
|
def initialize(api_key:, base_url:, open_timeout: 2, read_timeout: 10,
|
|
15
|
-
|
|
17
|
+
write_timeout: 10, max_retries: 0)
|
|
16
18
|
@api_key = api_key
|
|
17
19
|
@base_url = base_url
|
|
18
20
|
@open_timeout = open_timeout
|
|
@@ -68,24 +70,16 @@ module Clicksign
|
|
|
68
70
|
Clicksign::ServerError => e
|
|
69
71
|
raise unless e.retryable? && attempts <= @max_retries
|
|
70
72
|
|
|
71
|
-
delay =
|
|
72
|
-
|
|
73
|
-
method: request.method.downcase.to_sym,
|
|
74
|
-
path: resource_path(uri),
|
|
75
|
-
attempt: attempts,
|
|
76
|
-
max_retries: @max_retries,
|
|
77
|
-
error: e,
|
|
78
|
-
wait_ms: (delay * 1000).round,
|
|
79
|
-
})
|
|
73
|
+
delay = RetryBackoff.delay(attempts)
|
|
74
|
+
publish_retry(request, uri, attempts, e, delay)
|
|
80
75
|
sleep(delay)
|
|
81
76
|
retry
|
|
82
77
|
end
|
|
83
78
|
end
|
|
84
79
|
|
|
85
80
|
def execute_once(request, uri, attempt: 1)
|
|
86
|
-
start
|
|
87
|
-
context =
|
|
88
|
-
attempt: attempt }
|
|
81
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
82
|
+
context = request_context(request, uri, attempt)
|
|
89
83
|
response = http_request(request, uri)
|
|
90
84
|
handle_response(response, context, start)
|
|
91
85
|
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED => e
|
|
@@ -94,50 +88,24 @@ module Clicksign
|
|
|
94
88
|
|
|
95
89
|
def http_request(request, uri)
|
|
96
90
|
Net::HTTP.start(uri.host, uri.port,
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
91
|
+
use_ssl: uri.scheme == 'https',
|
|
92
|
+
open_timeout: @open_timeout,
|
|
93
|
+
read_timeout: @read_timeout,
|
|
94
|
+
write_timeout: @write_timeout,
|
|
95
|
+
&proc { |http| http.request(request) })
|
|
102
96
|
end
|
|
103
97
|
|
|
104
98
|
def handle_response(response, context, start)
|
|
105
|
-
duration =
|
|
106
|
-
status = response.code.to_i
|
|
99
|
+
_response, status, duration = publish_http_outcome(response, context, start)
|
|
107
100
|
begin
|
|
108
101
|
ErrorHandler.call(response)
|
|
109
102
|
rescue Error => e
|
|
110
|
-
|
|
111
|
-
publish_event(:error, context, error: e, status: status, duration_ms: duration)
|
|
103
|
+
publish_http_error(context, e, status, duration)
|
|
112
104
|
raise
|
|
113
105
|
end
|
|
114
|
-
publish_event(:request, context, status: status, duration_ms: duration)
|
|
115
106
|
return nil if response.body.nil? || response.body.empty?
|
|
116
107
|
|
|
117
108
|
JSON.parse(response.body)
|
|
118
109
|
end
|
|
119
|
-
|
|
120
|
-
def handle_network_error(error, context, duration)
|
|
121
|
-
err = TimeoutError.new(error.message)
|
|
122
|
-
publish_event(:error, context, error: err, status: nil, duration_ms: duration)
|
|
123
|
-
raise err, error.message, error.backtrace
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def publish_event(type, context, extra)
|
|
127
|
-
Instrumentation.publish(type, context.merge(extra))
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
def elapsed_ms(start)
|
|
131
|
-
((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(1)
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
def resource_path(uri)
|
|
135
|
-
base = URI.parse(@base_url).path
|
|
136
|
-
uri.path.delete_prefix(base)
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
def backoff_delay(attempt)
|
|
140
|
-
[0.5 * (2**(attempt - 1)), 30].min
|
|
141
|
-
end
|
|
142
110
|
end
|
|
143
111
|
end
|
|
@@ -8,7 +8,7 @@ module Clicksign
|
|
|
8
8
|
}.freeze
|
|
9
9
|
|
|
10
10
|
attr_accessor :api_key, :base_url, :open_timeout, :read_timeout,
|
|
11
|
-
|
|
11
|
+
:write_timeout, :max_retries, :logger
|
|
12
12
|
|
|
13
13
|
def initialize
|
|
14
14
|
@base_url = 'https://app.clicksign.com/api/v3'
|
|
@@ -21,7 +21,7 @@ module Clicksign
|
|
|
21
21
|
def environment=(env)
|
|
22
22
|
url = ENVIRONMENTS.fetch(env.to_sym) do
|
|
23
23
|
raise ArgumentError,
|
|
24
|
-
|
|
24
|
+
"Unknown environment: #{env}. Valid: #{ENVIRONMENTS.keys.join(', ')}"
|
|
25
25
|
end
|
|
26
26
|
self.base_url = url
|
|
27
27
|
end
|
|
@@ -50,7 +50,8 @@ module Clicksign
|
|
|
50
50
|
errors = body['errors']
|
|
51
51
|
return response.message unless errors.is_a?(Array)
|
|
52
52
|
|
|
53
|
-
errors.filter_map { |e| e['detail'] || e['title'] }.join(', ')
|
|
53
|
+
result = errors.filter_map { |e| e['detail'] || e['title'] }.join(', ')
|
|
54
|
+
result.empty? ? response.message : result
|
|
54
55
|
end
|
|
55
56
|
end
|
|
56
57
|
end
|
data/lib/clicksign/errors.rb
CHANGED
|
@@ -5,7 +5,7 @@ module Clicksign
|
|
|
5
5
|
attr_reader :status_code, :request_id, :response_body, :response_headers
|
|
6
6
|
|
|
7
7
|
def initialize(message = nil, status_code: nil, request_id: nil,
|
|
8
|
-
|
|
8
|
+
response_body: nil, response_headers: {})
|
|
9
9
|
super(message)
|
|
10
10
|
@status_code = status_code
|
|
11
11
|
@request_id = request_id
|
|
@@ -5,6 +5,7 @@ module Clicksign
|
|
|
5
5
|
EVENTS = %i[request retry error].freeze
|
|
6
6
|
|
|
7
7
|
@callbacks = Hash.new { |h, k| h[k] = [] }
|
|
8
|
+
@mutex = Mutex.new
|
|
8
9
|
|
|
9
10
|
class << self
|
|
10
11
|
def on(event, &block)
|
|
@@ -12,20 +13,24 @@ module Clicksign
|
|
|
12
13
|
raise ArgumentError, "Unknown event: #{event}. Valid: #{EVENTS.join(', ')}"
|
|
13
14
|
end
|
|
14
15
|
|
|
15
|
-
@callbacks[event] << block
|
|
16
|
+
@mutex.synchronize { @callbacks[event] << block }
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
def publish(event, payload)
|
|
19
|
-
@callbacks[event].
|
|
20
|
+
callbacks = @mutex.synchronize { @callbacks[event].dup }
|
|
21
|
+
callbacks.each do |cb|
|
|
20
22
|
cb.call(payload)
|
|
21
|
-
rescue StandardError
|
|
22
|
-
|
|
23
|
+
rescue StandardError => e
|
|
24
|
+
Clicksign.configuration.logger&.warn(
|
|
25
|
+
"[Clicksign] instrumentation callback error (#{event}): " \
|
|
26
|
+
"#{e.class}: #{e.message}",
|
|
27
|
+
)
|
|
23
28
|
end
|
|
24
29
|
end
|
|
25
30
|
|
|
26
31
|
# Removes all registered callbacks — intended for test teardown.
|
|
27
32
|
def clear
|
|
28
|
-
@callbacks = Hash.new { |h, k| h[k] = [] }
|
|
33
|
+
@mutex.synchronize { @callbacks = Hash.new { |h, k| h[k] = [] } }
|
|
29
34
|
end
|
|
30
35
|
end
|
|
31
36
|
end
|
|
@@ -7,13 +7,15 @@ require 'json'
|
|
|
7
7
|
module Clicksign
|
|
8
8
|
module JsonApi
|
|
9
9
|
class BulkOperationsClient
|
|
10
|
+
include RequestInstrumentation
|
|
11
|
+
|
|
10
12
|
HEADERS = {
|
|
11
13
|
'Content-Type' => 'application/vnd.api+json',
|
|
12
14
|
'Accept' => 'application/vnd.api+json',
|
|
13
15
|
}.freeze
|
|
14
16
|
|
|
15
17
|
def initialize(api_key:, base_url:, open_timeout: 2, read_timeout: 10,
|
|
16
|
-
|
|
18
|
+
write_timeout: 10, max_retries: 0)
|
|
17
19
|
@api_key = api_key
|
|
18
20
|
@base_url = base_url
|
|
19
21
|
@open_timeout = open_timeout
|
|
@@ -23,23 +25,13 @@ module Clicksign
|
|
|
23
25
|
end
|
|
24
26
|
|
|
25
27
|
def post(path, body:)
|
|
26
|
-
response = perform_post(path, body)
|
|
27
|
-
parsed = parse_response_body(response) || {}
|
|
28
|
-
|
|
29
|
-
return parsed if parsed.key?('atomic:results')
|
|
30
|
-
|
|
31
|
-
ErrorHandler.call(response)
|
|
32
|
-
parsed
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
private
|
|
36
|
-
|
|
37
|
-
def perform_post(path, body)
|
|
38
28
|
uri = build_uri(path)
|
|
39
29
|
request = build_request(uri, body)
|
|
40
30
|
execute_with_retry(request, uri)
|
|
41
31
|
end
|
|
42
32
|
|
|
33
|
+
private
|
|
34
|
+
|
|
43
35
|
def build_request(uri, body)
|
|
44
36
|
request = Net::HTTP::Post.new(uri, headers)
|
|
45
37
|
request.body = body.to_json
|
|
@@ -50,29 +42,47 @@ module Clicksign
|
|
|
50
42
|
attempts = 0
|
|
51
43
|
begin
|
|
52
44
|
attempts += 1
|
|
53
|
-
|
|
45
|
+
execute_once(request, uri, attempt: attempts)
|
|
54
46
|
rescue Clicksign::TimeoutError => e
|
|
55
47
|
raise unless e.retryable? && attempts <= @max_retries
|
|
56
48
|
|
|
57
|
-
|
|
49
|
+
delay = RetryBackoff.delay(attempts)
|
|
50
|
+
publish_retry(request, uri, attempts, e, delay)
|
|
51
|
+
sleep(delay)
|
|
58
52
|
retry
|
|
59
53
|
end
|
|
60
54
|
end
|
|
61
55
|
|
|
62
|
-
def
|
|
63
|
-
|
|
56
|
+
def execute_once(request, uri, attempt: 1)
|
|
57
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
58
|
+
context = request_context(request, uri, attempt)
|
|
59
|
+
response = http_post(request, uri)
|
|
60
|
+
_response, status, duration = publish_http_outcome(response, context, start)
|
|
61
|
+
handle_bulk_body(response, context, status, duration)
|
|
64
62
|
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED => e
|
|
65
|
-
|
|
63
|
+
handle_network_error(e, context, start)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def handle_bulk_body(response, context, status, duration)
|
|
67
|
+
parsed = parse_response_body(response) || {}
|
|
68
|
+
return parsed if parsed.key?('atomic:results')
|
|
69
|
+
|
|
70
|
+
begin
|
|
71
|
+
ErrorHandler.call(response)
|
|
72
|
+
rescue Error => e
|
|
73
|
+
publish_http_error(context, e, status, duration)
|
|
74
|
+
raise
|
|
75
|
+
end
|
|
76
|
+
parsed
|
|
66
77
|
end
|
|
67
78
|
|
|
68
79
|
def http_post(request, uri)
|
|
69
80
|
Net::HTTP.start(uri.host, uri.port,
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
http.request(request)
|
|
75
|
-
end
|
|
81
|
+
use_ssl: uri.scheme == 'https',
|
|
82
|
+
open_timeout: @open_timeout,
|
|
83
|
+
read_timeout: @read_timeout,
|
|
84
|
+
write_timeout: @write_timeout,
|
|
85
|
+
&proc { |http| http.request(request) })
|
|
76
86
|
end
|
|
77
87
|
|
|
78
88
|
def headers
|
|
@@ -87,8 +97,8 @@ module Clicksign
|
|
|
87
97
|
return nil if response.body.nil? || response.body.empty?
|
|
88
98
|
|
|
89
99
|
JSON.parse(response.body)
|
|
90
|
-
rescue JSON::ParserError
|
|
91
|
-
|
|
100
|
+
rescue JSON::ParserError => e
|
|
101
|
+
raise Error, "Invalid JSON response from bulk operations endpoint: #{e.message}"
|
|
92
102
|
end
|
|
93
103
|
end
|
|
94
104
|
end
|
|
@@ -33,7 +33,7 @@ module Clicksign
|
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
def add_rubricate(signer_id:, document_id:, pages: nil, rubric_field: nil,
|
|
36
|
-
|
|
36
|
+
kind: nil)
|
|
37
37
|
validate_ids!(signer_id, document_id)
|
|
38
38
|
if pages.nil? && rubric_field.nil?
|
|
39
39
|
raise ArgumentError, 'pages or rubric_field is required'
|
|
@@ -15,7 +15,9 @@ module Clicksign
|
|
|
15
15
|
.select { |item| item.is_a?(Hash) && item['type'] }
|
|
16
16
|
.map { |item| build(item) }
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
links = raw['links'] if raw.key?('links')
|
|
19
|
+
|
|
20
|
+
{ data: data, included: included, links: links }
|
|
19
21
|
end
|
|
20
22
|
|
|
21
23
|
def self.build(item)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clicksign
|
|
4
|
+
module RequestInstrumentation
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def publish_event(type, context, extra)
|
|
8
|
+
Instrumentation.publish(type, context.merge(extra))
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def elapsed_ms(start)
|
|
12
|
+
((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(1)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def resource_path(uri)
|
|
16
|
+
base = URI.parse(@base_url).path
|
|
17
|
+
uri.path.delete_prefix(base)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def request_context(request, uri, attempt)
|
|
21
|
+
{
|
|
22
|
+
method: request.method.downcase.to_sym,
|
|
23
|
+
path: resource_path(uri),
|
|
24
|
+
attempt: attempt,
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def publish_retry(request, uri, attempt, error, delay)
|
|
29
|
+
Instrumentation.publish(:retry, {
|
|
30
|
+
method: request.method.downcase.to_sym,
|
|
31
|
+
path: resource_path(uri),
|
|
32
|
+
attempt: attempt,
|
|
33
|
+
max_retries: @max_retries,
|
|
34
|
+
error: error,
|
|
35
|
+
wait_ms: (delay * 1000).round,
|
|
36
|
+
})
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def handle_network_error(error, context, start)
|
|
40
|
+
duration = elapsed_ms(start)
|
|
41
|
+
err = TimeoutError.new(error.message)
|
|
42
|
+
publish_event(:error, context, error: err, status: nil, duration_ms: duration)
|
|
43
|
+
raise err, error.message, error.backtrace
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def publish_http_outcome(response, context, start)
|
|
47
|
+
duration = elapsed_ms(start)
|
|
48
|
+
status = response.code.to_i
|
|
49
|
+
publish_event(:request, context, status: status, duration_ms: duration)
|
|
50
|
+
[response, status, duration]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def publish_http_error(context, error, status, duration)
|
|
54
|
+
publish_event(:error, context, error: error, status: status, duration_ms: duration)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
data/lib/clicksign/resource.rb
CHANGED
|
@@ -15,11 +15,15 @@ module Clicksign
|
|
|
15
15
|
self
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
def
|
|
18
|
+
def with_includes(*types)
|
|
19
19
|
@builder.include(*types)
|
|
20
20
|
self
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
def include(*types)
|
|
24
|
+
with_includes(*types)
|
|
25
|
+
end
|
|
26
|
+
|
|
23
27
|
def order(field)
|
|
24
28
|
@builder.order(field)
|
|
25
29
|
self
|
|
@@ -90,10 +94,10 @@ module Clicksign
|
|
|
90
94
|
@endpoint || "/#{resource_type}"
|
|
91
95
|
end
|
|
92
96
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
+
# Returns the first page with no query chain.
|
|
98
|
+
# Use +filter+ for filters, sort, pagination.
|
|
99
|
+
def list
|
|
100
|
+
fetch_list({})
|
|
97
101
|
end
|
|
98
102
|
|
|
99
103
|
def retrieve(id)
|
|
@@ -118,10 +122,28 @@ module Clicksign
|
|
|
118
122
|
QueryProxy.new(self, JsonApi::QueryBuilder.new.filter(**params))
|
|
119
123
|
end
|
|
120
124
|
|
|
121
|
-
|
|
125
|
+
# JSON:API sideload — use +with_includes+;
|
|
126
|
+
# +include+ also accepts +Module+ for Ruby mixins.
|
|
127
|
+
def with_includes(*types)
|
|
128
|
+
validate_jsonapi_include_types!(types)
|
|
122
129
|
QueryProxy.new(self, JsonApi::QueryBuilder.new.include(*types))
|
|
123
130
|
end
|
|
124
131
|
|
|
132
|
+
def include(*types)
|
|
133
|
+
modules, jsonapi = types.partition { |t| t.is_a?(Module) }
|
|
134
|
+
if modules.any? && jsonapi.any?
|
|
135
|
+
raise ArgumentError,
|
|
136
|
+
'cannot mix Module with JSON:API ' \
|
|
137
|
+
'include types — use with_includes for sideload'
|
|
138
|
+
end
|
|
139
|
+
if modules.any?
|
|
140
|
+
modules.each { |mod| super(mod) }
|
|
141
|
+
return self
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
with_includes(*types)
|
|
145
|
+
end
|
|
146
|
+
|
|
125
147
|
def order(field)
|
|
126
148
|
QueryProxy.new(self, JsonApi::QueryBuilder.new.order(field))
|
|
127
149
|
end
|
|
@@ -164,6 +186,17 @@ module Clicksign
|
|
|
164
186
|
Thread.current[:clicksign_client] || Clicksign.client
|
|
165
187
|
end
|
|
166
188
|
|
|
189
|
+
def validate_jsonapi_include_types!(types)
|
|
190
|
+
raise ArgumentError, 'at least one include type is required' if types.empty?
|
|
191
|
+
|
|
192
|
+
invalid = types.reject { |t| t.is_a?(String) || t.is_a?(Symbol) }
|
|
193
|
+
return if invalid.empty?
|
|
194
|
+
|
|
195
|
+
raise ArgumentError,
|
|
196
|
+
'JSON:API include types must be String or Symbol, ' \
|
|
197
|
+
"got: #{invalid.map(&:class).uniq.join(', ')}"
|
|
198
|
+
end
|
|
199
|
+
|
|
167
200
|
private
|
|
168
201
|
|
|
169
202
|
def fetch_list(params)
|
|
@@ -178,24 +211,41 @@ module Clicksign
|
|
|
178
211
|
page = 1
|
|
179
212
|
|
|
180
213
|
loop do
|
|
181
|
-
raw
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
214
|
+
raw = client.get(endpoint,
|
|
215
|
+
params: base.merge('page[number]' => page,
|
|
216
|
+
'page[size]' => per))
|
|
217
|
+
parsed = JsonApi::Parser.parse(raw)
|
|
218
|
+
items = parsed[:data].map { |item| build_instance(item) }
|
|
185
219
|
yield items
|
|
186
|
-
break
|
|
220
|
+
break unless more_pages?(parsed, items, per)
|
|
187
221
|
|
|
188
222
|
page += 1
|
|
189
223
|
end
|
|
190
224
|
end
|
|
191
225
|
|
|
226
|
+
def more_pages?(parsed, items, per)
|
|
227
|
+
links = parsed[:links]
|
|
228
|
+
unless links.nil?
|
|
229
|
+
next_link = links['next']
|
|
230
|
+
return false if next_link.nil? || next_link.to_s.empty?
|
|
231
|
+
|
|
232
|
+
return true
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
items.size >= per
|
|
236
|
+
end
|
|
237
|
+
|
|
192
238
|
def build_instance(data, parent_id: nil)
|
|
239
|
+
raise NotFoundError, 'API returned null data' if data.nil?
|
|
240
|
+
|
|
193
241
|
instance = allocate
|
|
194
242
|
instance.send(:load_data, data, parent_id: parent_id)
|
|
195
243
|
instance
|
|
196
244
|
end
|
|
197
245
|
|
|
198
246
|
def infer_resource_type
|
|
247
|
+
return 'resources' if name.nil? || name.empty?
|
|
248
|
+
|
|
199
249
|
"#{name.split('::').last
|
|
200
250
|
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
201
251
|
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
@@ -213,7 +263,10 @@ module Clicksign
|
|
|
213
263
|
),
|
|
214
264
|
)
|
|
215
265
|
parsed = JsonApi::Parser.parse(raw)
|
|
216
|
-
|
|
266
|
+
data = parsed[:data].first
|
|
267
|
+
raise NotFoundError, 'API returned null data' if data.nil?
|
|
268
|
+
|
|
269
|
+
load_data(data, parent_id: @_parent_id)
|
|
217
270
|
self
|
|
218
271
|
end
|
|
219
272
|
|
|
@@ -225,7 +278,10 @@ module Clicksign
|
|
|
225
278
|
def reload
|
|
226
279
|
raw = self.class.client.get("#{base_path}/#{@id}")
|
|
227
280
|
parsed = JsonApi::Parser.parse(raw)
|
|
228
|
-
|
|
281
|
+
data = parsed[:data].first
|
|
282
|
+
raise NotFoundError, 'API returned null data' if data.nil?
|
|
283
|
+
|
|
284
|
+
load_data(data, parent_id: @_parent_id)
|
|
229
285
|
self
|
|
230
286
|
end
|
|
231
287
|
|
|
@@ -239,11 +295,10 @@ module Clicksign
|
|
|
239
295
|
|
|
240
296
|
def method_missing(name, *args, &block)
|
|
241
297
|
key = name.to_s.delete_suffix('=')
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
end
|
|
298
|
+
return super unless @_attributes&.key?(key)
|
|
299
|
+
|
|
300
|
+
@_attributes[key] = args.first if name.to_s.end_with?('=')
|
|
301
|
+
@_attributes[key]
|
|
247
302
|
end
|
|
248
303
|
|
|
249
304
|
def respond_to_missing?(name, include_private = false)
|
|
@@ -27,23 +27,23 @@ module Clicksign
|
|
|
27
27
|
|
|
28
28
|
def self.list_events(envelope_id, **filters)
|
|
29
29
|
nested_list(envelope_id, nested_type: 'events', as: Event,
|
|
30
|
-
|
|
30
|
+
params: filter_params(**filters))
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def self.list_documents(envelope_id, **filters)
|
|
34
34
|
nested_list(envelope_id, nested_type: 'documents', as: Document,
|
|
35
|
-
|
|
35
|
+
params: filter_params(**filters))
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
def self.list_signers(envelope_id, **filters)
|
|
39
39
|
nested_list(envelope_id, nested_type: 'signers', as: Signer,
|
|
40
|
-
|
|
40
|
+
params: filter_params(**filters))
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
def self.list_signature_watchers(envelope_id, **filters)
|
|
44
44
|
nested_list(envelope_id, nested_type: 'signature_watchers',
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
as: SignatureWatcher,
|
|
46
|
+
params: filter_params(**filters))
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
def self.list_requirements(envelope_id, **filters)
|
|
@@ -6,7 +6,9 @@ module Clicksign
|
|
|
6
6
|
class Event < Clicksign::Resource
|
|
7
7
|
self.resource_type = 'events'
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
CUSTOM_KINDS = %w[token_email token_sms].freeze
|
|
10
|
+
|
|
11
|
+
def self.create(envelope_id:, document_id:, **attributes)
|
|
10
12
|
raw = client.post(
|
|
11
13
|
"/envelopes/#{envelope_id}/documents/#{document_id}/events",
|
|
12
14
|
body: JsonApi::Serializer.dump(type: resource_type, attributes: attributes),
|
|
@@ -14,6 +16,50 @@ module Clicksign
|
|
|
14
16
|
parsed = JsonApi::Parser.parse(raw)
|
|
15
17
|
build_instance(parsed[:data].first)
|
|
16
18
|
end
|
|
19
|
+
|
|
20
|
+
def self.create_add_image(envelope_id:, document_id:, title:, occurred_at:,
|
|
21
|
+
content_base64:)
|
|
22
|
+
create(
|
|
23
|
+
envelope_id: envelope_id,
|
|
24
|
+
document_id: document_id,
|
|
25
|
+
name: 'add_image',
|
|
26
|
+
content_base64: content_base64,
|
|
27
|
+
data: { title: title, occurred_at: occurred_at },
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.create_custom(envelope_id:, document_id:, kind:, occurred_at:,
|
|
32
|
+
signer_name:, signer_email: nil, signer_phone_number: nil)
|
|
33
|
+
unless CUSTOM_KINDS.include?(kind.to_s)
|
|
34
|
+
raise ArgumentError, "kind must be one of: #{CUSTOM_KINDS.join(', ')}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
create(
|
|
38
|
+
envelope_id: envelope_id,
|
|
39
|
+
document_id: document_id,
|
|
40
|
+
name: 'custom',
|
|
41
|
+
data: {
|
|
42
|
+
kind: kind,
|
|
43
|
+
occurred_at: occurred_at,
|
|
44
|
+
signer_name: signer_name,
|
|
45
|
+
signer_email: signer_email,
|
|
46
|
+
signer_phone_number: signer_phone_number,
|
|
47
|
+
}.compact,
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# API only exposes GET (list) and POST (create) for events — no singleton routes.
|
|
52
|
+
def update(**)
|
|
53
|
+
raise NotImplementedError, 'Event does not support update'
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def delete
|
|
57
|
+
raise NotImplementedError, 'Event does not support delete'
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def reload
|
|
61
|
+
raise NotImplementedError, 'Event does not support reload'
|
|
62
|
+
end
|
|
17
63
|
end
|
|
18
64
|
end
|
|
19
65
|
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clicksign
|
|
4
|
+
module RetryBackoff
|
|
5
|
+
BASE_SECONDS = 0.5
|
|
6
|
+
CAP_SECONDS = 30.0
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def ceiling(attempt)
|
|
11
|
+
[BASE_SECONDS * (2**(attempt - 1)), CAP_SECONDS].min.to_f
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Full jitter: uniform in [0, ceiling) to spread retries and avoid thundering herd.
|
|
15
|
+
def delay(attempt, rng: Random)
|
|
16
|
+
max = ceiling(attempt)
|
|
17
|
+
return 0.0 if max <= 0
|
|
18
|
+
|
|
19
|
+
rng.rand(max)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
data/lib/clicksign/services.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Clicksign
|
|
4
4
|
class Services
|
|
5
5
|
def initialize(api_key:, environment: :production, base_url: nil,
|
|
6
|
-
|
|
6
|
+
open_timeout: 2, read_timeout: 10, write_timeout: 10, max_retries: 0)
|
|
7
7
|
resolved_url = base_url || resolve_environment(environment)
|
|
8
8
|
@client = Client.new(
|
|
9
9
|
api_key: api_key,
|
data/lib/clicksign/webhook.rb
CHANGED
|
@@ -30,8 +30,10 @@ module Clicksign
|
|
|
30
30
|
|
|
31
31
|
# Constant-time comparison to prevent timing attacks.
|
|
32
32
|
def self.secure_compare?(expected, actual)
|
|
33
|
+
return false if actual.nil? || actual.to_s.empty?
|
|
34
|
+
|
|
33
35
|
digest_a = OpenSSL::Digest::SHA256.hexdigest(expected)
|
|
34
|
-
digest_b = OpenSSL::Digest::SHA256.hexdigest(actual)
|
|
36
|
+
digest_b = OpenSSL::Digest::SHA256.hexdigest(actual.to_s)
|
|
35
37
|
result = 0
|
|
36
38
|
digest_a.bytes.zip(digest_b.bytes) { |x, y| result |= x ^ y }
|
|
37
39
|
result.zero?
|
data/lib/clicksign.rb
CHANGED
|
@@ -9,6 +9,8 @@ require_relative 'clicksign/configuration'
|
|
|
9
9
|
require_relative 'clicksign/errors'
|
|
10
10
|
require_relative 'clicksign/error_handler'
|
|
11
11
|
require_relative 'clicksign/instrumentation'
|
|
12
|
+
require_relative 'clicksign/request_instrumentation'
|
|
13
|
+
require_relative 'clicksign/retry_backoff'
|
|
12
14
|
require_relative 'clicksign/webhook'
|
|
13
15
|
require_relative 'clicksign/json_api/query_builder'
|
|
14
16
|
require_relative 'clicksign/json_api/serializer'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: clicksign-ruby-sdk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Clicksign
|
|
@@ -30,6 +30,7 @@ files:
|
|
|
30
30
|
- lib/clicksign/json_api/parser.rb
|
|
31
31
|
- lib/clicksign/json_api/query_builder.rb
|
|
32
32
|
- lib/clicksign/json_api/serializer.rb
|
|
33
|
+
- lib/clicksign/request_instrumentation.rb
|
|
33
34
|
- lib/clicksign/resource.rb
|
|
34
35
|
- lib/clicksign/resources/acceptance_term/whatsapp.rb
|
|
35
36
|
- lib/clicksign/resources/access_control_list.rb
|
|
@@ -49,6 +50,7 @@ files:
|
|
|
49
50
|
- lib/clicksign/resources/template_field.rb
|
|
50
51
|
- lib/clicksign/resources/user.rb
|
|
51
52
|
- lib/clicksign/resources/webhook.rb
|
|
53
|
+
- lib/clicksign/retry_backoff.rb
|
|
52
54
|
- lib/clicksign/services.rb
|
|
53
55
|
- lib/clicksign/version.rb
|
|
54
56
|
- lib/clicksign/webhook.rb
|
|
@@ -75,7 +77,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
75
77
|
- !ruby/object:Gem::Version
|
|
76
78
|
version: '0'
|
|
77
79
|
requirements: []
|
|
78
|
-
rubygems_version:
|
|
80
|
+
rubygems_version: 3.6.9
|
|
79
81
|
specification_version: 4
|
|
80
82
|
summary: Ruby SDK for the Clicksign API
|
|
81
83
|
test_files: []
|