assinafy 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Assinafy
4
+ # Top-level entry point for the Assinafy Ruby SDK.
5
+ #
6
+ # A Client owns a single Faraday connection (with shared auth headers,
7
+ # timeouts, and User-Agent) and exposes one resource accessor per
8
+ # documented API surface.
9
+ #
10
+ # @example Construct from positional args
11
+ # client = Assinafy::Client.create(ENV['ASSINAFY_API_KEY'], ENV['ASSINAFY_ACCOUNT_ID'])
12
+ #
13
+ # @example Construct from a config Hash (e.g. parsed YAML/JSON)
14
+ # client = Assinafy::Client.from_config(api_key: '...', account_id: '...')
15
+ #
16
+ # @see https://api.assinafy.com.br/v1/docs
17
+ class Client
18
+ # @return [Resources::AuthResource]
19
+ attr_reader :auth
20
+ # @return [Resources::AccountResource]
21
+ attr_reader :accounts
22
+ # @return [Resources::UserResource]
23
+ attr_reader :users
24
+ # @return [Resources::DocumentResource]
25
+ attr_reader :documents
26
+ # @return [Resources::SignerResource]
27
+ attr_reader :signers
28
+ # @return [Resources::SignerDocumentResource]
29
+ attr_reader :signer_documents
30
+ # @return [Resources::AssignmentResource]
31
+ attr_reader :assignments
32
+ # @return [Resources::WebhookResource]
33
+ attr_reader :webhooks
34
+ # @return [Resources::TemplateResource]
35
+ attr_reader :templates
36
+ # @return [Resources::FieldResource]
37
+ attr_reader :fields
38
+ # @return [Resources::TagResource]
39
+ attr_reader :tags
40
+ # @return [Support::WebhookVerifier]
41
+ attr_reader :webhook_verifier
42
+
43
+ # @param api_key [String, nil] sent as `X-Api-Key`
44
+ # @param token [String, nil] legacy session token; sent as
45
+ # `Authorization: Bearer ...` when no `api_key` is given
46
+ # @param account_id [String, nil] default workspace ID for account-scoped
47
+ # resources; those methods document their supported per-call overrides
48
+ # @param base_url [String]
49
+ # @param webhook_secret [String, nil] secret for {Support::WebhookVerifier}
50
+ # @param timeout [Integer] Faraday read/open timeout in seconds
51
+ # @param logger [Logger, nil] receives info-level lifecycle messages
52
+ #
53
+ # @example Build a client and reach a resource accessor (no network call)
54
+ # client = Assinafy::Client.new(api_key: 'example_api_key', account_id: 'account_example')
55
+ # client.documents #=> #<Assinafy::Resources::DocumentResource ...>
56
+ def initialize(api_key: nil, token: nil, account_id: nil,
57
+ base_url: Configuration::DEFAULT_BASE_URL,
58
+ webhook_secret: nil,
59
+ timeout: Configuration::DEFAULT_TIMEOUT,
60
+ logger: nil)
61
+ config = Configuration.new(
62
+ api_key: api_key, token: token, account_id: account_id,
63
+ base_url: base_url, webhook_secret: webhook_secret,
64
+ timeout: timeout, logger: logger
65
+ )
66
+
67
+ @connection = build_connection(config)
68
+ @logger = config.logger || NullLogger.new
69
+
70
+ @auth = Resources::AuthResource.new(@connection, nil, @logger)
71
+ @accounts = Resources::AccountResource.new(@connection, account_id, @logger)
72
+ @users = Resources::UserResource.new(@connection, nil, @logger)
73
+ @documents = Resources::DocumentResource.new(@connection, account_id, @logger)
74
+ @signers = Resources::SignerResource.new(@connection, account_id, @logger)
75
+ @signer_documents = Resources::SignerDocumentResource.new(@connection, nil, @logger)
76
+ @assignments = Resources::AssignmentResource.new(@connection, account_id, @logger)
77
+ @webhooks = Resources::WebhookResource.new(@connection, account_id, @logger)
78
+ @templates = Resources::TemplateResource.new(@connection, account_id, @logger)
79
+ @fields = Resources::FieldResource.new(@connection, account_id, @logger)
80
+ @tags = Resources::TagResource.new(@connection, account_id, @logger)
81
+ @webhook_verifier = Support::WebhookVerifier.new(webhook_secret)
82
+ end
83
+
84
+ # Convenience constructor with positional `api_key`/`account_id`.
85
+ #
86
+ # @param api_key [String]
87
+ # @param account_id [String]
88
+ # @param options [Hash] forwarded to {#initialize}
89
+ # @return [Client]
90
+ #
91
+ # @example Construct with positional credentials and an optional webhook secret
92
+ # client = Assinafy::Client.create('example_api_key', 'account_example', webhook_secret: 'gateway_secret')
93
+ # client #=> #<Assinafy::Client ...>
94
+ def self.create(api_key, account_id, **options)
95
+ new(api_key: api_key, account_id: account_id, **options)
96
+ end
97
+
98
+ # Build a Client from a Hash (string or symbol keys).
99
+ # Useful for credentials loaded from YAML/JSON.
100
+ #
101
+ # @param config [Hash]
102
+ # @return [Client]
103
+ #
104
+ # @example Build from a credentials Hash loaded from YAML/JSON (string or symbol keys both work)
105
+ # creds = YAML.load_file('config/assinafy.yml') # { 'api_key' => '...', 'account_id' => '...' }
106
+ # client = Assinafy::Client.from_config(creds)
107
+ # client #=> #<Assinafy::Client ...>
108
+ def self.from_config(config)
109
+ from_hash(config)
110
+ end
111
+
112
+ # Alias of {.from_config} for symmetry with {Configuration.from_hash}.
113
+ #
114
+ # @param config [Hash]
115
+ # @return [Client]
116
+ #
117
+ # @example Build from a symbol-keyed Hash
118
+ # client = Assinafy::Client.from_hash(api_key: 'example_api_key', account_id: 'account_example')
119
+ # client #=> #<Assinafy::Client ...>
120
+ def self.from_hash(config)
121
+ cfg = Configuration.from_hash(config)
122
+ new(
123
+ api_key: cfg.api_key,
124
+ token: cfg.token,
125
+ account_id: cfg.account_id,
126
+ base_url: cfg.base_url,
127
+ webhook_secret: cfg.webhook_secret,
128
+ timeout: cfg.timeout,
129
+ logger: cfg.logger
130
+ )
131
+ end
132
+
133
+ # High-level helper that bundles the most common workflow:
134
+ # upload PDF → (optionally wait for metadata) → create signers → create a
135
+ # virtual assignment for them.
136
+ #
137
+ # @param source [String, Hash] see {Resources::DocumentResource#upload}
138
+ # @param signers [Array<Hash>] see {Resources::SignerResource#create}
139
+ # @param message [String, nil]
140
+ # @param wait_for_ready [Boolean] poll until the document is metadata-ready (default true)
141
+ # @param expires_at [String, nil] ISO 8601 expiration for the assignment
142
+ # @param copy_receivers [Array<String>, nil] signer IDs that only receive copies
143
+ # @param account_id [String, nil] override the client default
144
+ # @return [Hash{Symbol=>Object}] `{ document: {Hash}, assignment: {Hash}, signer_ids: [String, ...] }`
145
+ # where `document` is the (unwrapped) document payload, `assignment` is the (unwrapped) virtual
146
+ # assignment, and `signer_ids` lists the IDs of the signers created during the workflow.
147
+ # @note This helper is not transactional. If a later API call fails, an uploaded
148
+ # document or newly created signer may remain and should be cleaned up by the caller.
149
+ #
150
+ # @example Upload a PDF and request a virtual signature from one signer
151
+ # result = client.upload_and_request_signatures(
152
+ # source: '/path/to/contract.pdf',
153
+ # signers: [{ full_name: 'Example Signer', email: 'signer@example.com' }],
154
+ # message: 'Please review and sign'
155
+ # )
156
+ #
157
+ # # Under the hood the SDK uploads the file, then POSTs this JSON body to
158
+ # # POST /documents/{document_id}/assignments (nil optional fields are dropped):
159
+ # # {
160
+ # # "method": "virtual",
161
+ # # "signers": [{ "id": "19e6b92e7895332ed9708535d8c" }],
162
+ # # "message": "Please review and sign"
163
+ # # }
164
+ #
165
+ # # Returned (unwrapped) Hash:
166
+ # result
167
+ # #=> {
168
+ # # document: {
169
+ # # "resource" => "document", "id" => "1032009d72b364f377ff270405cc",
170
+ # # "account_id" => "account_example", "name" => "contract.pdf",
171
+ # # "status" => "metadata_ready",
172
+ # # "artifacts" => { "original" => "https://.../download/original", "thumbnail" => "https://..." },
173
+ # # "tags" => [], "pages" => [{ "id" => "...", "number" => 1, "height" => 1651, "width" => 1275 }]
174
+ # # # ... (see docs for full shape)
175
+ # # },
176
+ # # assignment: {
177
+ # # "resource" => "assignment", "id" => "19e99aa0633e32ac13f845c08db",
178
+ # # "sender_email" => "sender@example.com", "method" => "virtual",
179
+ # # "expires_at" => nil, "message" => "Please review and sign",
180
+ # # "signers" => [{ "id" => "19e6b92e7895332ed9708535d8c", "full_name" => "Example Signer",
181
+ # # "email" => "signer@example.com", "completed" => false, "step" => 1 }],
182
+ # # "copy_receivers" => [], "items" => [{ "id" => "103200a43e372db16f48a6f0f2d4", "completed" => false }],
183
+ # # "summary" => { "signer_count" => 1, "completed_count" => 0 },
184
+ # # "signing_urls" => [{ "signer_id" => "19e6b92e7895332ed9708535d8c", "url" => "https://.../sign/..." }]
185
+ # # # ... (see docs for full shape)
186
+ # # },
187
+ # # signer_ids: ["19e6b92e7895332ed9708535d8c"]
188
+ # # }
189
+ def upload_and_request_signatures(source:, signers:, message: nil,
190
+ wait_for_ready: true, expires_at: nil,
191
+ copy_receivers: nil, account_id: nil)
192
+ unless signers.is_a?(Array) && !signers.empty? && signers.all?(Hash)
193
+ raise ValidationError.new('Signers must be a non-empty Array of Hashes')
194
+ end
195
+
196
+ @logger.info("Starting upload and signature workflow for #{signers.length} signer(s)")
197
+
198
+ upload_opts = account_id.nil? ? {} : { account_id: account_id }
199
+ document = @documents.upload(source, upload_opts)
200
+ document = @documents.wait_until_ready(document['id']) if wait_for_ready
201
+
202
+ signer_ids = signers.map do |signer|
203
+ created = @signers.create(signer, account_id)
204
+ created['id'] || raise(ApiError.new('Signer created but the API returned no ID', 502, created))
205
+ end
206
+
207
+ assignment_payload = { method: 'virtual', signers: signer_ids,
208
+ message: message, expires_at: expires_at,
209
+ copy_receivers: copy_receivers }
210
+ assignment = @assignments.create(document['id'], assignment_payload)
211
+
212
+ @logger.info("Upload and signature workflow completed for document #{document['id']}")
213
+
214
+ { document: document, assignment: assignment, signer_ids: signer_ids }
215
+ end
216
+
217
+ # Expose the underlying Faraday connection (for advanced use cases,
218
+ # such as adding middleware or inspecting headers in tests).
219
+ #
220
+ # @return [Faraday::Connection]
221
+ #
222
+ # @example Inspect the auth header the SDK sends
223
+ # client = Assinafy::Client.new(api_key: 'example_api_key', account_id: 'account_example')
224
+ # client.faraday_connection.headers['X-Api-Key'] #=> "example_api_key"
225
+ def faraday_connection
226
+ @connection
227
+ end
228
+
229
+ private
230
+
231
+ def build_connection(config)
232
+ Faraday.new(url: config.base_url) do |f|
233
+ f.request :multipart
234
+ f.request :json
235
+ f.response :json, content_type: /\bjson/
236
+ f.options.timeout = config.timeout
237
+ f.options.open_timeout = config.timeout
238
+ f.headers.merge!(config.auth_headers)
239
+ f.headers['Accept'] = 'application/json'
240
+ f.headers['User-Agent'] = "assinafy-ruby-sdk/#{VERSION}"
241
+ f.adapter Faraday.default_adapter
242
+ end
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+
5
+ module Assinafy
6
+ # SDK configuration values. {Client} snapshots these values when it builds
7
+ # its connection; construct a new client after changing a configuration.
8
+ #
9
+ # @example Build from a YAML-style hash (e.g. loaded from config/assinafy.yml)
10
+ # raw = YAML.load_file('config/assinafy.yml') # => string-keyed Hash
11
+ # # raw => {
12
+ # # "api_key" => "example_api_key",
13
+ # # "account_id" => "account_example",
14
+ # # "base_url" => "https://api.assinafy.com.br/v1",
15
+ # # "webhook_secret" => "gateway_secret",
16
+ # # "timeout" => 30
17
+ # # }
18
+ # config = Assinafy::Configuration.from_hash(raw)
19
+ # config.api_key # => "example_api_key"
20
+ # config.account_id # => "account_example"
21
+ # config.auth_headers # => { "X-Api-Key" => "example_api_key" }
22
+ class Configuration
23
+ # Default base URL (production v1 API).
24
+ DEFAULT_BASE_URL = 'https://api.assinafy.com.br/v1'
25
+ # Default Faraday open/read timeout, in seconds.
26
+ DEFAULT_TIMEOUT = 30
27
+
28
+ # @!attribute [rw] api_key
29
+ # @return [String, nil] sent as `X-Api-Key`
30
+ # @!attribute [rw] token
31
+ # @return [String, nil] legacy bearer token (used when `api_key` is nil)
32
+ # @!attribute [rw] account_id
33
+ # @return [String, nil] default workspace ID
34
+ # @!attribute [rw] base_url
35
+ # @return [String] API base URL (trailing slash stripped)
36
+ # @!attribute [rw] webhook_secret
37
+ # @return [String, nil] secret for {Support::WebhookVerifier}
38
+ # @!attribute [rw] timeout
39
+ # @return [Integer] Faraday timeout in seconds
40
+ # @!attribute [rw] logger
41
+ # @return [Logger, nil]
42
+ attr_accessor :api_key, :token, :account_id, :base_url, :webhook_secret, :timeout, :logger
43
+
44
+ # Build a configuration directly from keyword arguments. Prefer passing
45
+ # `api_key` (the documented `X-Api-Key` mechanism); `token` is the legacy
46
+ # bearer fallback. `base_url` has its trailing slash stripped on assignment.
47
+ #
48
+ # @param api_key [String, nil] sent as the `X-Api-Key` header
49
+ # @param token [String, nil] legacy bearer token (used only when `api_key` is nil)
50
+ # @param account_id [String, nil] default workspace account ID
51
+ # @param base_url [String] API base URL (trailing slash is stripped)
52
+ # @param webhook_secret [String, nil] secret for {Support::WebhookVerifier}
53
+ # @param timeout [Integer] Faraday open/read timeout in seconds
54
+ # @param logger [Logger, nil] optional logger for Faraday
55
+ #
56
+ # @example Construct with an API key (omits the default base_url)
57
+ # config = Assinafy::Configuration.new(
58
+ # api_key: 'example_api_key',
59
+ # account_id: 'account_example'
60
+ # )
61
+ # config.base_url # => "https://api.assinafy.com.br/v1"
62
+ # config.timeout # => 30
63
+ # config.auth_headers # => { "X-Api-Key" => "example_api_key" }
64
+ #
65
+ # @example Trailing slash on base_url is stripped
66
+ # Assinafy::Configuration.new(base_url: 'https://api.assinafy.com.br/v1/').base_url
67
+ # # => "https://api.assinafy.com.br/v1"
68
+ def initialize(api_key: nil, token: nil, account_id: nil,
69
+ base_url: DEFAULT_BASE_URL, webhook_secret: nil,
70
+ timeout: DEFAULT_TIMEOUT, logger: nil)
71
+ @api_key = api_key
72
+ @token = token
73
+ @account_id = account_id
74
+ raise ValidationError.new('Base URL is required') unless base_url.is_a?(String)
75
+
76
+ @base_url = base_url.strip.sub(%r{/+\z}, '')
77
+ raise ValidationError.new('Base URL is required') if @base_url.empty?
78
+
79
+ @webhook_secret = webhook_secret
80
+ @timeout = normalize_timeout(timeout)
81
+ @logger = logger
82
+ end
83
+
84
+ # Build a {Configuration} from a Hash with string or symbol keys.
85
+ # Accepts both `'token'` and `'access_token'` for backwards compatibility.
86
+ # Missing keys fall back to defaults (`base_url` => {DEFAULT_BASE_URL},
87
+ # `timeout` => {DEFAULT_TIMEOUT}); numeric strings are accepted, while
88
+ # invalid and non-positive values raise {ValidationError}.
89
+ #
90
+ # @param hash [Hash{String,Symbol=>Object}]
91
+ # @return [Configuration]
92
+ #
93
+ # @example Symbol-keyed hash with the legacy access_token alias
94
+ # config = Assinafy::Configuration.from_hash(
95
+ # access_token: 'legacy-bearer-abc123',
96
+ # account_id: 'account_example',
97
+ # timeout: '45'
98
+ # )
99
+ # config.token # => "legacy-bearer-abc123"
100
+ # config.api_key # => nil
101
+ # config.timeout # => 45
102
+ # config.base_url # => "https://api.assinafy.com.br/v1"
103
+ # config.auth_headers # => { "Authorization" => "Bearer legacy-bearer-abc123" }
104
+ def self.from_hash(hash)
105
+ raise ValidationError.new('Configuration must be a Hash') unless hash.is_a?(Hash)
106
+
107
+ h = hash.transform_keys(&:to_s)
108
+ new(
109
+ api_key: h['api_key'],
110
+ token: h['token'] || h['access_token'],
111
+ account_id: h['account_id'],
112
+ base_url: h.key?('base_url') ? h['base_url'] : DEFAULT_BASE_URL,
113
+ webhook_secret: h['webhook_secret'],
114
+ timeout: h.key?('timeout') ? h['timeout'] : DEFAULT_TIMEOUT,
115
+ logger: h['logger']
116
+ )
117
+ end
118
+
119
+ # Return the HTTP headers used to authenticate requests, preferring
120
+ # `X-Api-Key` (the documented mechanism) over a bearer token. When `api_key`
121
+ # is set it wins; otherwise a non-nil `token` produces an `Authorization:
122
+ # Bearer` header; with neither credential set an empty Hash is returned.
123
+ #
124
+ # @return [Hash{String=>String}] one of `{ "X-Api-Key" => ... }`,
125
+ # `{ "Authorization" => "Bearer ..." }`, or `{}`
126
+ #
127
+ # @example api_key takes precedence over token
128
+ # Assinafy::Configuration.new(api_key: 'k', token: 't').auth_headers
129
+ # # => { "X-Api-Key" => "k" }
130
+ #
131
+ # @example Bearer fallback when only a token is present
132
+ # Assinafy::Configuration.new(token: 't').auth_headers
133
+ # # => { "Authorization" => "Bearer t" }
134
+ #
135
+ # @example No credentials configured
136
+ # Assinafy::Configuration.new.auth_headers
137
+ # # => {}
138
+ def auth_headers
139
+ key = api_key.to_s.strip
140
+ bearer = token.to_s.strip
141
+ return { 'X-Api-Key' => key } unless key.empty?
142
+ return { 'Authorization' => "Bearer #{bearer}" } unless bearer.empty?
143
+
144
+ {}
145
+ end
146
+
147
+ private
148
+
149
+ def normalize_timeout(value)
150
+ raw = value.nil? ? DEFAULT_TIMEOUT : value
151
+ seconds = Integer(raw, exception: false) if raw.is_a?(Integer) || raw.is_a?(String)
152
+ return seconds if seconds&.positive?
153
+
154
+ raise ValidationError.new('Timeout must be a positive integer')
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Assinafy
4
+ # Base class for all errors raised by the SDK. Carries an optional context
5
+ # Hash that may contain useful debugging details (e.g. response data, IDs).
6
+ class Error < StandardError
7
+ # @return [Hash] arbitrary metadata about the error
8
+ attr_reader :context
9
+
10
+ def initialize(message = nil, context = {})
11
+ super(message)
12
+ @context = context || {}
13
+ end
14
+ end
15
+
16
+ # Raised when the API responds with a non-2xx status, or with a 2xx
17
+ # response envelope whose embedded status code indicates failure.
18
+ #
19
+ # The Assinafy v1 API returns two distinct error-body shapes, both handled
20
+ # here:
21
+ #
22
+ # - Framework errors: `{"name":"Not Found","message":"Página não encontrada.","code":0,"status":404}`
23
+ # - Application envelopes: `{"status":404,"data":null,"message":"Template não encontrado."}`
24
+ #
25
+ # @example Rescue an API error
26
+ # begin
27
+ # client.documents.details('missing-id')
28
+ # rescue Assinafy::ApiError => e
29
+ # e.status_code # => 404
30
+ # e.message # => "Documento não encontrado."
31
+ # e.error_name # => "Not Found" (nil when the body omits it)
32
+ # e.error_code # => 0 (nil when the body omits it)
33
+ # e.response_data # => the raw parsed body Hash
34
+ # end
35
+ class ApiError < Error
36
+ # @return [Integer] HTTP-style status code reported by the API
37
+ attr_reader :status_code
38
+ # @return [Hash, String, nil] raw response body
39
+ attr_reader :response_data
40
+ # @return [String, nil] the API's `name` field (framework errors only)
41
+ attr_reader :error_name
42
+ # @return [Integer, String, nil] the API's `code` field, when present
43
+ attr_reader :error_code
44
+
45
+ def initialize(message, status_code, response_data = nil)
46
+ super(message, { status_code: status_code, response_data: response_data })
47
+ @status_code = status_code
48
+ @response_data = response_data
49
+ return unless response_data.is_a?(Hash)
50
+
51
+ @error_name = response_data['name']
52
+ @error_code = response_data['code']
53
+ end
54
+
55
+ # Build an {ApiError} from an HTTP status and parsed body. Reads the human
56
+ # message from `message`, `error`, or `name` (in that order).
57
+ #
58
+ # @param status_code [Integer]
59
+ # @param response_data [Hash, Object]
60
+ # @return [ApiError]
61
+ def self.from_response(status_code, response_data)
62
+ data = response_data.is_a?(Hash) ? response_data : {}
63
+ message = data['message'] || data['error'] || data['name'] || 'API request failed'
64
+ new(message.to_s, status_code, response_data)
65
+ end
66
+ end
67
+
68
+ # Raised before a network request is made when the caller's input is
69
+ # invalid (missing IDs, wrong shape, etc.).
70
+ class ValidationError < Error
71
+ # @return [Hash] field-keyed validation details
72
+ attr_reader :errors
73
+
74
+ def initialize(message = 'Validation failed', errors = {})
75
+ super(message, { errors: errors })
76
+ @errors = errors || {}
77
+ end
78
+ end
79
+
80
+ # Raised when Faraday reports a connection, timeout, or TLS error. The
81
+ # original exception's message is included.
82
+ class NetworkError < Error; end
83
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Assinafy
4
+ class NullLogger
5
+ %i[debug info warn error fatal unknown].each do |level|
6
+ define_method(level) { |*, **| nil }
7
+ end
8
+ end
9
+ end