clicksign-ruby-sdk 0.1.1 → 0.1.4

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: 7c3da29608ca505f7705b718fa579adb1d346bf8000e50fc87382616833e645f
4
- data.tar.gz: e92995a59c7939aac989d9a165956b9309523a505178a2f79f168e0b6b6578d5
3
+ metadata.gz: e2fa45c00d6994b4009e9888eb982da569f1657c692b7e83a5db76c69067ebc2
4
+ data.tar.gz: e74c86dab5a02752fd9bb3a388cf54c78ae90e9a376cdac163bf5dca090fd5e9
5
5
  SHA512:
6
- metadata.gz: f1ccd82b00a37e3f88cd0e8fc65b0279500750ad8c008c8e98fb50451d22770440809e7d0308d01753e6a66c78dab72f151e949fcd3cfa9249dc9f10e9a5c1dd
7
- data.tar.gz: c80bdcec1a9c6f7eb47b649cb17756c6cfde384d4fddfca8239b4efa2e26ccc415aa407aa7d83de852706b11571750ef94e1c8d2efa5e3265cb43efda3f9247d
6
+ metadata.gz: 86b4b41f6607d89ea58786225502c699902523d05ff72f60d983449fc9a318d794ab1c164ce1d475a073ea868d22ee65da53f7e36c61cb477e28e4cea7fa86f4
7
+ data.tar.gz: 484d16275cda5bf7e85413dcc3383776988574de052d6715a7d08e591d54701b13e3dd52a69c9a000977f8d51cc07a7590968050c6456b35cec48c914086a737
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,38 @@ 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
+ > **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.
99
+
74
100
  Para testar interativamente no console da gem:
75
101
 
76
102
  ```bash
@@ -79,6 +105,126 @@ CLICKSIGN_API_KEY=seu-token bundle exec ruby bin/console
79
105
 
80
106
  ---
81
107
 
108
+ ## Multi-conta e cliente instanciável
109
+
110
+ 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.
111
+
112
+ ### `Clicksign::Services` (recomendado para resources)
113
+
114
+ Encapsula um `Clicksign::Client` e roteia todas as chamadas de `Clicksign::Resources::*` dentro do bloco `use`:
115
+
116
+ ```ruby
117
+ conta_a = Clicksign::Services.new(
118
+ api_key: ENV['CLICKSIGN_TOKEN_CONTA_A'],
119
+ environment: :production,
120
+ max_retries: 2
121
+ )
122
+
123
+ conta_b = Clicksign::Services.new(
124
+ api_key: ENV['CLICKSIGN_TOKEN_CONTA_B'],
125
+ environment: :sandbox
126
+ )
127
+
128
+ conta_a.use do
129
+ Clicksign::Resources::Notarial::Envelope.create(name: 'Contrato A')
130
+ end
131
+
132
+ conta_b.use do
133
+ Clicksign::Resources::Notarial::Envelope.filter(status: 'draft').to_a
134
+ end
135
+ ```
136
+
137
+ 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).
138
+
139
+ Em Rails, um padrão comum é resolver o service no controller e executar a lógica dentro de `use`:
140
+
141
+ ```ruby
142
+ class EnvelopesController < ApplicationController
143
+ def create
144
+ current_tenant.clicksign_service.use do
145
+ envelope = Clicksign::Resources::Notarial::Envelope.create(envelope_params)
146
+ render json: { id: envelope.id }
147
+ end
148
+ end
149
+ end
150
+ ```
151
+
152
+ ### `Clicksign::Client` (HTTP direto)
153
+
154
+ Para chamadas JSON:API de baixo nível sem passar pelos resources:
155
+
156
+ ```ruby
157
+ client = Clicksign::Client.new(
158
+ api_key: ENV['CLICKSIGN_API_KEY'],
159
+ base_url: 'https://sandbox.clicksign.com/api/v3',
160
+ open_timeout: 2,
161
+ read_timeout: 30,
162
+ max_retries: 3
163
+ )
164
+
165
+ response = client.get('/envelopes', params: { 'filter[status]' => 'draft' })
166
+ client.post('/envelopes', body: { data: { type: 'envelopes', attributes: { name: 'Novo' } } })
167
+ ```
168
+
169
+ | Abordagem | Quando usar |
170
+ |-----------|-------------|
171
+ | `Clicksign.configure` | App single-tenant; initializer único |
172
+ | `Clicksign::Services#use` | Multi-conta; token por request/job |
173
+ | `Clicksign::Client.new` | Controle fino do HTTP ou integração customizada |
174
+
175
+ ---
176
+
177
+ ## Timeouts, retry e instrumentação
178
+
179
+ ### Timeouts
180
+
181
+ Configuráveis globalmente (`Clicksign.configure`), por `Services` ou diretamente em `Client.new`. Timeouts de rede disparam `Clicksign::TimeoutError` (retryable quando `max_retries > 0`).
182
+
183
+ ### Retry automático
184
+
185
+ Com `max_retries > 0`, o client reexecuta a requisição em erros **transitórios**:
186
+
187
+ - `Clicksign::TimeoutError`
188
+ - `Clicksign::RateLimitError`
189
+ - `Clicksign::ServerError` (5xx)
190
+
191
+ Backoff exponencial com **full jitter** (espera aleatória entre `0` e o teto da tentativa: `0,5s`, `1s`, `2s`… até **30s**), para evitar thundering herd quando muitos clientes falham ao mesmo tempo. Após esgotar as retentativas, a exceção original é relançada.
192
+
193
+ ```ruby
194
+ Clicksign.configure do |c|
195
+ c.api_key = ENV['CLICKSIGN_API_KEY']
196
+ c.environment = :production
197
+ c.max_retries = 3
198
+ end
199
+ ```
200
+
201
+ 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).
202
+
203
+ ### Instrumentação
204
+
205
+ 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.
206
+
207
+ ```ruby
208
+ Clicksign.on_request do |event|
209
+ # event: :method, :path, :status, :duration_ms, :attempt
210
+ Rails.logger.info "[Clicksign] #{event[:method]} #{event[:path]} → #{event[:status]} (#{event[:duration_ms]}ms)"
211
+ end
212
+
213
+ Clicksign.on_retry do |event|
214
+ # event: :method, :path, :attempt, :max_retries, :error, :wait_ms
215
+ Rails.logger.warn "[Clicksign] retry #{event[:attempt]}/#{event[:max_retries]} em #{event[:wait_ms]}ms"
216
+ end
217
+
218
+ Clicksign.on_error do |event|
219
+ # event: :method, :path, :error, :status, :duration_ms
220
+ Sentry.capture_exception(event[:error])
221
+ end
222
+ ```
223
+
224
+ Eventos publicados: `:request` (toda tentativa, sucesso ou erro HTTP), `:retry` (antes de cada retentativa), `:error` (quando uma exceção é lançada).
225
+
226
+ ---
227
+
82
228
  ## Início rápido
83
229
 
84
230
  Listar envelopes em rascunho e criar um novo:
@@ -329,23 +475,40 @@ Event.create_for_document(
329
475
 
330
476
  ## Filtros, ordenação e paginação
331
477
 
332
- A API de listagem é chainable:
478
+ ### `list` vs `filter`
479
+
480
+ | Método | Retorno | Uso |
481
+ |--------|---------|-----|
482
+ | `Resource.list` | `Array` | Primeira página da collection, **sem** filtros na chain |
483
+ | `Resource.filter(...)` | `QueryProxy` | Filtros, ordenação, paginação, includes — termine com `.to_a`, `.first`, `.auto_paging_each`, etc. |
484
+
485
+ `list` **não** aceita argumentos. Para filtrar: `Envelope.filter(status: 'draft').to_a` (não `Envelope.list(status: 'draft')`).
486
+
487
+ Guia completo: [`docs/cookbook/07-list-and-filter.md`](docs/cookbook/07-list-and-filter.md).
488
+
489
+ ```ruby
490
+ # Sem filtros — retorna Array imediatamente
491
+ Webhook.list
492
+
493
+ # Com filtros ou chain — começa em filter
494
+ Envelope.filter(status: 'draft').to_a
495
+ ```
496
+
497
+ ### Chain de consulta
333
498
 
334
499
  ```ruby
335
500
  Envelope
336
501
  .filter(status: 'running', name: 'Contrato')
502
+ .with_includes('folder') # sideload JSON:API (.include('folder') também funciona)
337
503
  .order('-created')
338
504
  .page(1)
339
505
  .per(20)
340
506
  .to_a
341
507
 
342
- # Atalho quando há poucos filtros
343
508
  Template.filter(name: 'NDA padrão').first
344
509
 
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
510
+ Envelope.order('-created').first
511
+ Envelope.filter(status: 'draft').count
349
512
  ```
350
513
 
351
514
  Atributos dos objetos retornados são acessados como métodos ou por chave string:
@@ -364,6 +527,8 @@ Envelope.filter(status: 'running').map(&:name)
364
527
  Envelope.order('-created').select { |e| e.auto_close }
365
528
  ```
366
529
 
530
+ 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]`.
531
+
367
532
  ---
368
533
 
369
534
  ## Outros recursos
@@ -372,8 +537,8 @@ Envelope.order('-created').select { |e| e.auto_close }
372
537
  |---------|--------|---------|
373
538
  | Webhook | `Clicksign::Resources::Webhook` | `Webhook.create(endpoint: 'https://...', events: ['envelope.completed'], status: 'active')` |
374
539
  | 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: '...')` |
540
+ | Template | `Clicksign::Resources::Template` | `Template.list` · `Template.filter(name: '...')` · `Template.list_template_fields(id)` |
541
+ | Usuário | `Clicksign::Resources::User` | `User.me` · `User.list` · `User.filter(email: '...')` |
377
542
  | Membership | `Clicksign::Resources::Membership` | `Membership.create(role: 'member', user_id: user.id)` |
378
543
  | Grupo | `Clicksign::Resources::Group` | `Group.add_users(group_id, [user.id])` |
379
544
  | ACL pasta/grupo | `Clicksign::Resources::AccessControlList` | `AccessControlList.create(folder_id:, group_id:)` |
@@ -453,15 +618,22 @@ AccessControlList.destroy(folder_id: folder.id, group_id: group.id)
453
618
 
454
619
  Erros HTTP são convertidos em exceções antes de chegar ao seu código:
455
620
 
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` |
621
+ | HTTP | Exceção | `retryable?` |
622
+ |------|---------|--------------|
623
+ | 401, 403 | `Clicksign::AuthenticationError` | não |
624
+ | 404 | `Clicksign::NotFoundError` | não |
625
+ | 400, 422 | `Clicksign::ValidationError` | não |
626
+ | 409 | `Clicksign::ConflictError` | não |
627
+ | 429 | `Clicksign::RateLimitError` | sim |
628
+ | 5xx | `Clicksign::ServerError` | sim |
629
+ | Timeout / conexão | `Clicksign::TimeoutError` | sim |
630
+
631
+ Todas herdam de `Clicksign::Error` e expõem metadados úteis para debug:
632
+
633
+ - `status_code` — código HTTP da resposta
634
+ - `request_id` — quando enviado pela API
635
+ - `response_body` — corpo JSON da resposta de erro
636
+ - `response_headers` — headers da resposta (`RateLimitError` também expõe `rate_limit_remaining` e `rate_limit_reset`)
465
637
 
466
638
  Exemplo:
467
639
 
@@ -472,6 +644,9 @@ rescue Clicksign::NotFoundError
472
644
  puts 'Envelope não encontrado'
473
645
  rescue Clicksign::ValidationError => e
474
646
  puts "Dados inválidos: #{e.message}"
647
+ puts e.response_body
648
+ rescue Clicksign::RateLimitError => e
649
+ puts "Aguarde reset em #{e.rate_limit_reset}" if e.retryable?
475
650
  rescue Clicksign::AuthenticationError
476
651
  puts 'Verifique CLICKSIGN_API_KEY'
477
652
  end
@@ -479,28 +654,62 @@ end
479
654
 
480
655
  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
656
 
657
+ Guia detalhado: [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md).
658
+
482
659
  ---
483
660
 
484
661
  ## Ambientes
485
662
 
486
- | Ambiente | `base_url` |
487
- |----------|------------|
488
- | Sandbox | `https://sandbox.clicksign.com/api/v3` |
489
- | Produção | `https://app.clicksign.com/api/v3` |
663
+ | Ambiente | Símbolo | `base_url` |
664
+ |----------|---------|------------|
665
+ | Sandbox | `:sandbox` | `https://sandbox.clicksign.com/api/v3` |
666
+ | Produção | `:production` | `https://app.clicksign.com/api/v3` |
490
667
 
491
- O padrão da gem em `Clicksign::Configuration` é produção (`app.clicksign.com`). Para desenvolvimento, defina explicitamente o sandbox:
668
+ O padrão em `Clicksign::Configuration` é **produção**. Para desenvolvimento, use o atalho `environment` (equivalente a definir `base_url`):
492
669
 
493
670
  ```ruby
494
671
  Clicksign.configure do |c|
495
- c.api_key = ENV['CLICKSIGN_API_KEY']
496
- c.base_url = 'https://sandbox.clicksign.com/api/v3'
672
+ c.api_key = ENV['CLICKSIGN_API_KEY']
673
+ c.environment = :sandbox
497
674
  end
675
+
676
+ # Ou em multi-conta:
677
+ service = Clicksign::Services.new(
678
+ api_key: ENV['CLICKSIGN_API_KEY'],
679
+ environment: :sandbox
680
+ )
498
681
  ```
499
682
 
683
+ Também é possível passar `base_url` manualmente quando precisar de um endpoint customizado (proxy, mock, etc.).
684
+
500
685
  Gere tokens de API no painel da Clicksign do ambiente correspondente.
501
686
 
502
687
  ---
503
688
 
689
+ ## Limitações e produção
690
+
691
+ Design **stdlib-only** (`net/http`) — sem dependências de runtime extras. Duas limitações importantes em alta carga ou runtimes modernos:
692
+
693
+ ### Sem connection pool
694
+
695
+ Cada request abre e fecha uma conexão TCP (via `Net::HTTP.start`). Não há reutilização persistente entre chamadas.
696
+
697
+ - **OK** para jobs sequenciais, integrações moderadas e a maioria dos apps Rails.
698
+ - **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.
699
+
700
+ 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).
701
+
702
+ ### `Thread.current` e Fibers
703
+
704
+ `Clicksign::Services#use` armazena o client em `Thread.current[:clicksign_client]`. Resources usam esse client dentro do bloco.
705
+
706
+ - **Compatível:** Puma (thread por request), Sidekiq, scripts.
707
+ - **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`.
708
+
709
+ Mitigações: `Clicksign.configure` por processo (single-tenant), `Clicksign::Client.new` explícito no seu contexto async, ou evitar `Services` em stacks fiberizadas.
710
+
711
+ ---
712
+
504
713
  ## Desenvolvimento
505
714
 
506
715
  Clone o repositório e instale dependências de desenvolvimento:
@@ -515,17 +724,28 @@ bundle exec rspec
515
724
  | `CLICKSIGN_API_KEY` | Token para testes contra sandbox (opcional) |
516
725
  | `CLICKSIGN_API_BASE_URL` | URL da API (padrão sandbox nos specs de integração legados) |
517
726
 
518
- A suíte principal usa **WebMock** e não exige rede. Alguns specs antigos ainda podem usar **VCR** com gravação no sandbox.
727
+ A suíte usa **WebMock** e não exige rede.
519
728
 
520
729
  Estrutura relevante:
521
730
 
522
731
  ```
523
732
  lib/clicksign/
524
- client.rb # HTTP (GET, POST, PATCH, DELETE)
733
+ retry_backoff.rb # Exponential backoff com full jitter
734
+ client.rb # HTTP (GET, POST, PATCH, DELETE), retry, timeouts
735
+ services.rb # Cliente por contexto (multi-conta via #use)
736
+ configuration.rb # Config global, environment, timeouts, retry
737
+ request_instrumentation.rb # Hooks compartilhados Client + BulkOperationsClient
738
+ instrumentation.rb # Eventos :request, :retry, :error
525
739
  resource.rb # CRUD base, filtros, nested lists
526
740
  resources/notarial/ # Envelope, Document, Signer, Requirement, ...
527
741
  json_api/ # Serializer, Parser, bulk operations
528
742
  docs/SPEC.md # mapa completo de resources e rotas
743
+ docs/WORKFLOW.md # fluxo notarial ponta a ponta
744
+ docs/README.md # índice da documentação
745
+ docs/cookbook/ # receitas: retries, bulk, webhooks, multi-cliente
746
+ docs/TROUBLESHOOTING.md # diagnóstico e erros comuns
747
+ docs/ARCHITECTURE.md # diagramas e camadas
748
+ docs/OBSERVABILITY.md # logs, métricas, OpenTelemetry
529
749
  ```
530
750
 
531
751
  ---
@@ -6,6 +6,8 @@ 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',
@@ -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
@@ -102,42 +96,16 @@ module Clicksign
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'
@@ -18,8 +18,11 @@ module Clicksign
18
18
  def publish(event, payload)
19
19
  @callbacks[event].each do |cb|
20
20
  cb.call(payload)
21
- rescue StandardError
22
- # Callbacks must not affect the request — errors are silently ignored.
21
+ rescue StandardError => e
22
+ Clicksign.configuration.logger&.warn(
23
+ "[Clicksign] instrumentation callback error (#{event}): " \
24
+ "#{e.class}: #{e.message}",
25
+ )
23
26
  end
24
27
  end
25
28
 
@@ -7,6 +7,8 @@ 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',
@@ -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,19 +42,38 @@ 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)
@@ -70,9 +81,8 @@ module Clicksign
70
81
  use_ssl: uri.scheme == 'https',
71
82
  open_timeout: @open_timeout,
72
83
  read_timeout: @read_timeout,
73
- write_timeout: @write_timeout) do |http|
74
- http.request(request)
75
- end
84
+ write_timeout: @write_timeout,
85
+ &proc { |http| http.request(request) })
76
86
  end
77
87
 
78
88
  def headers
@@ -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)
@@ -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,17 +211,30 @@ 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)
193
239
  instance = allocate
194
240
  instance.send(:load_data, data, parent_id: parent_id)
@@ -196,6 +242,8 @@ module Clicksign
196
242
  end
197
243
 
198
244
  def infer_resource_type
245
+ return 'resources' if name.nil? || name.empty?
246
+
199
247
  "#{name.split('::').last
200
248
  .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
201
249
  .gsub(/([a-z\d])([A-Z])/, '\1_\2')
@@ -239,11 +287,10 @@ module Clicksign
239
287
 
240
288
  def method_missing(name, *args, &block)
241
289
  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
290
+ return super unless @_attributes&.key?(key)
291
+
292
+ @_attributes[key] = args.first if name.to_s.end_with?('=')
293
+ @_attributes[key]
247
294
  end
248
295
 
249
296
  def respond_to_missing?(name, include_private = false)
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clicksign
4
- VERSION = '0.1.1'
4
+ VERSION = File.read(File.expand_path('../../REVISION', __dir__)).strip
5
5
  end
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.1
4
+ version: 0.1.4
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