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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2b00cbdd7f7cbfb7b8eb0d97e2a89b5578bbfa93474e81a021498dd8bd4d65bf
4
- data.tar.gz: '049906ba475373a2d7862fa27872ae58037c5f056149ae95d8065d3f36928bd0'
3
+ metadata.gz: a45728b201a9703e1d70cb6de98056f85da9a524d9d0c122a1ed951d6f98ef5b
4
+ data.tar.gz: 7a2aea99029254b4849b27090f40927d4cbf66d44cb516f121ea14c65544f141
5
5
  SHA512:
6
- metadata.gz: f488769e30736ddee12363827d8f73bfb5671db3dacbff62d1c83ed20f25aafc1ed4a9255a658170d9ad190fcb2d85d9b12843866bac3979e540cce1a8f0a599
7
- data.tar.gz: 35e43651dbca0ddb24831c2d85a93a948765854af07c6544bb78aaef7509f3d7e92fe7847f2c0fea0e4bf53d84edb291786aacc171563ccab19cbcf76b2fe616
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 da API:** [Sandbox](https://sandbox.clicksign.com/api/v3) · [Produção](https://app.clicksign.com/api/v3) · Referência interna: [`docs/SPEC.md`](docs/SPEC.md)
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
- Configure a chave de API e a URL base **uma vez** no boot da aplicação (initializer, `config/initializers/clicksign.rb`, script, etc.):
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.base_url = ENV.fetch('CLICKSIGN_API_BASE_URL', 'https://sandbox.clicksign.com/api/v3')
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 customizado no documento
320
- Event.create_for_document(
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: { description: 'Etapa interna concluída' }
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
- A API de listagem é chainable:
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
- # Navegação no resultado
346
- Envelope.order('-created').first # mais recente
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 da gem em `Clicksign::Configuration` é produção (`app.clicksign.com`). Para desenvolvimento, defina explicitamente o sandbox:
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 = ENV['CLICKSIGN_API_KEY']
496
- c.base_url = 'https://sandbox.clicksign.com/api/v3'
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 principal usa **WebMock** e não exige rede. Alguns specs antigos ainda podem usar **VCR** com gravação no sandbox.
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
- client.rb # HTTP (GET, POST, PATCH, DELETE)
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
  ---
@@ -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
- write_timeout: 10, max_retries: 0)
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 = backoff_delay(attempts)
72
- Instrumentation.publish(:retry, {
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 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
87
- context = { method: request.method.downcase.to_sym, path: resource_path(uri),
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
- use_ssl: uri.scheme == 'https',
98
- open_timeout: @open_timeout,
99
- read_timeout: @read_timeout,
100
- write_timeout: @write_timeout,
101
- &proc { |http| http.request(request) })
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 = elapsed_ms(start)
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
- publish_event(:request, context, status: status, duration_ms: duration)
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
- :write_timeout, :max_retries
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
- "Unknown environment: #{env}. Valid: #{ENVIRONMENTS.keys.join(', ')}"
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
@@ -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
- response_body: nil, response_headers: {})
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].each do |cb|
20
+ callbacks = @mutex.synchronize { @callbacks[event].dup }
21
+ callbacks.each do |cb|
20
22
  cb.call(payload)
21
- rescue StandardError
22
- # Callbacks must not affect the request — errors are silently ignored.
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
- write_timeout: 10, max_retries: 0)
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
- safe_http_post(request, uri)
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
- sleep([0.5 * (2**(attempts - 1)), 30].min)
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 safe_http_post(request, uri)
63
- http_post(request, uri)
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
- raise TimeoutError, e.message, e.backtrace
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
- use_ssl: uri.scheme == 'https',
71
- open_timeout: @open_timeout,
72
- read_timeout: @read_timeout,
73
- write_timeout: @write_timeout) do |http|
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
- nil
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
- kind: nil)
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
- { data: data, included: included }
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)
@@ -13,7 +13,8 @@ module Clicksign
13
13
  end
14
14
 
15
15
  def include(*types)
16
- @params['include'] = types.join(',')
16
+ existing = @params['include']&.split(',') || []
17
+ @params['include'] = (existing + types.map(&:to_s)).uniq.join(',')
17
18
  self
18
19
  end
19
20
 
@@ -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
@@ -15,11 +15,15 @@ module Clicksign
15
15
  self
16
16
  end
17
17
 
18
- def include(*types)
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
- def list(**filters)
94
- return fetch_list({}) if filters.empty?
95
-
96
- filter(**filters).to_a
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
- def include(*types)
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 = client.get(endpoint,
182
- params: base.merge('page[number]' => page,
183
- 'page[size]' => per))
184
- items = JsonApi::Parser.parse(raw)[:data].map { |item| build_instance(item) }
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 if items.size < per
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
- load_data(parsed[:data].first, parent_id: @_parent_id)
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
- load_data(parsed[:data].first, parent_id: @_parent_id)
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
- if @_attributes&.key?(key)
243
- name.to_s.end_with?('=') ? @_attributes[key] = args.first : @_attributes[key]
244
- else
245
- super
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
- params: filter_params(**filters))
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
- params: filter_params(**filters))
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
- params: filter_params(**filters))
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
- as: SignatureWatcher,
46
- params: filter_params(**filters))
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
- def self.create_for_document(envelope_id:, document_id:, **attributes)
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
@@ -27,7 +27,7 @@ module Clicksign
27
27
 
28
28
  def update(**)
29
29
  raise NotImplementedError,
30
- 'SignatureWatcher does not support update (route: except: [:update])'
30
+ 'SignatureWatcher does not support update (route: except: [:update])'
31
31
  end
32
32
 
33
33
  def envelope_id
@@ -40,7 +40,7 @@ module Clicksign
40
40
 
41
41
  def update(**)
42
42
  raise NotImplementedError,
43
- 'Signer does not support update (route: except: [:update])'
43
+ 'Signer does not support update (route: except: [:update])'
44
44
  end
45
45
 
46
46
  def base_path
@@ -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
@@ -3,7 +3,7 @@
3
3
  module Clicksign
4
4
  class Services
5
5
  def initialize(api_key:, environment: :production, base_url: nil,
6
- open_timeout: 2, read_timeout: 10, write_timeout: 10, max_retries: 0)
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,
@@ -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.3
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: 4.0.11
80
+ rubygems_version: 3.6.9
79
81
  specification_version: 4
80
82
  summary: Ruby SDK for the Clicksign API
81
83
  test_files: []