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,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'json'
5
+
6
+ module Assinafy
7
+ module Support
8
+ # Defensive helper for verifying webhook deliveries when your gateway or
9
+ # proxy signs the body with an HMAC-SHA256 secret.
10
+ #
11
+ # The Assinafy v1 API itself does not currently document a
12
+ # request-signing scheme for webhook deliveries, so this class is opt-in:
13
+ # construct it with a secret only if you have one configured in front
14
+ # of your webhook receiver (e.g. via API Gateway / Cloudflare).
15
+ # HMAC verifies authenticity, not freshness; the receiver must separately
16
+ # reject replayed event IDs or enforce its gateway's timestamp policy.
17
+ #
18
+ # @example Verify and dispatch a webhook
19
+ # verifier = Assinafy::Support::WebhookVerifier.new(ENV['WEBHOOK_SECRET'])
20
+ # raw_body = request.body.read
21
+ # # NOTE: the header below is one YOUR gateway injects (e.g. Cloudflare /
22
+ # # API Gateway). Assinafy v1 does not send a signature header itself.
23
+ # if verifier.verify(raw_body, request.headers['X-Webhook-Signature'])
24
+ # event = verifier.extract_event(raw_body)
25
+ # verifier.event_type(event) # => "assignment_created"
26
+ # verifier.event_payload(event) # => { "user_name" => "John", ... } (or nil)
27
+ # verifier.event_object(event) # => { "id" => "doc2", "type" => "Document", ... }
28
+ # verifier.event_subject(event) # => { "id" => "...", "type" => "User", ... }
29
+ # end
30
+ class WebhookVerifier
31
+ # @param webhook_secret [String, nil] shared secret. When nil/empty,
32
+ # {#verify} always returns false (safe-by-default).
33
+ def initialize(webhook_secret = nil)
34
+ @webhook_secret = webhook_secret
35
+ end
36
+
37
+ # Constant-time compare the provided signature to the expected
38
+ # HMAC-SHA256 of the raw payload.
39
+ #
40
+ # @param payload [String] raw HTTP body
41
+ # @param signature [String] hex-encoded signature header value
42
+ # @return [Boolean]
43
+ def verify(payload, signature)
44
+ return false unless @webhook_secret && !@webhook_secret.empty?
45
+ return false unless signature && !signature.to_s.strip.empty?
46
+
47
+ body = payload.is_a?(String) ? payload : payload.to_s
48
+ expected = OpenSSL::HMAC.hexdigest('SHA256', @webhook_secret, body)
49
+ provided = signature.to_s.strip
50
+
51
+ OpenSSL.fixed_length_secure_compare(expected, provided)
52
+ rescue StandardError
53
+ false
54
+ end
55
+
56
+ # Parse a JSON webhook body into a Hash, returning nil on malformed or
57
+ # non-object payloads.
58
+ #
59
+ # @param payload [String]
60
+ # @return [Hash, nil]
61
+ def extract_event(payload)
62
+ text = payload.is_a?(String) ? payload : payload.to_s
63
+ parsed = JSON.parse(text)
64
+ parsed.is_a?(Hash) ? parsed : nil
65
+ rescue JSON::ParserError
66
+ nil
67
+ end
68
+
69
+ # Pull the event-type code from a parsed event Hash. The canonical key in
70
+ # the Assinafy v1 delivery envelope is `event` (e.g. `assignment_created`).
71
+ #
72
+ # @param event [Hash, nil]
73
+ # @return [String, nil]
74
+ # @example
75
+ # verifier.event_type({ 'event' => 'document_ready' }) # => "document_ready"
76
+ def event_type(event)
77
+ return nil unless event.is_a?(Hash)
78
+
79
+ event['event']
80
+ end
81
+
82
+ # The event-specific data snapshot (the documented top-level `payload`).
83
+ # May be `nil` for events that carry no extra params (e.g.
84
+ # `document_uploaded`).
85
+ #
86
+ # @param event [Hash, nil]
87
+ # @return [Hash, nil]
88
+ def event_payload(event)
89
+ return nil unless event.is_a?(Hash)
90
+
91
+ event['payload']
92
+ end
93
+
94
+ # The entity the event acted on (the documented top-level `object`),
95
+ # e.g. the Document. Includes a `type` discriminator.
96
+ #
97
+ # @param event [Hash, nil]
98
+ # @return [Hash]
99
+ def event_object(event)
100
+ return {} unless event.is_a?(Hash)
101
+
102
+ event['object'] || {}
103
+ end
104
+
105
+ # The actor that triggered the event (the documented top-level `subject`),
106
+ # e.g. the User. Includes a `type` discriminator.
107
+ #
108
+ # @param event [Hash, nil]
109
+ # @return [Hash]
110
+ def event_subject(event)
111
+ return {} unless event.is_a?(Hash)
112
+
113
+ event['subject'] || {}
114
+ end
115
+
116
+ # @deprecated Prefer {#event_payload} (event params) and {#event_object}
117
+ # (acted-on entity). The Assinafy envelope has no top-level `data` key;
118
+ # this returns `payload` and falls back to `object` for convenience.
119
+ #
120
+ # @param event [Hash, nil]
121
+ # @return [Hash]
122
+ def event_data(event)
123
+ return {} unless event.is_a?(Hash)
124
+
125
+ event['payload'] || event['object'] || {}
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Assinafy
4
+ # Small, stateless helpers shared across resources. Intentionally private
5
+ # by convention — callers should reach for these via the resource methods,
6
+ # not directly.
7
+ module Utils
8
+ MAX_NORMALIZATION_DEPTH = 100
9
+
10
+ class << self
11
+ # Unwrap an Assinafy envelope — a Hash with a numeric `status` and an
12
+ # optional `data` key. Returns `data` (or nil) for 2xx, raises {ApiError}
13
+ # otherwise, and passes through non-envelope bodies.
14
+ #
15
+ # @param body [Hash, Object]
16
+ # @return [Object]
17
+ def handle_assinafy_response(body)
18
+ return body unless body.is_a?(Hash)
19
+ return body unless body.key?('status')
20
+
21
+ status = Integer(body['status'], exception: false)
22
+ return body unless status
23
+
24
+ if status >= 200 && status < 300
25
+ body['data'] if body.key?('data')
26
+ else
27
+ raise ApiError.from_response(status, body)
28
+ end
29
+ end
30
+
31
+ # Drop nil values (but keep `false`).
32
+ #
33
+ # @param hash [Hash, nil]
34
+ # @return [Hash]
35
+ def clean_params(hash)
36
+ return {} if hash.nil?
37
+ raise ValidationError.new('Parameters must be a Hash') unless hash.is_a?(Hash)
38
+
39
+ hash.each_with_object({}) do |(key, value), result|
40
+ result[key] = value unless value.nil?
41
+ end
42
+ end
43
+
44
+ # Build a query-string Hash, translating the Ruby-friendly snake_case
45
+ # aliases in {query_key_map} to the hyphenated forms documented in the
46
+ # Assinafy API (e.g. `per_page` → `per-page`).
47
+ #
48
+ # @param hash [Hash, nil]
49
+ # @return [Hash{String=>Object}]
50
+ def query_params(hash)
51
+ normalize_keys(clean_params(hash), query_key_map)
52
+ end
53
+
54
+ # Build a body Hash, translating documented hyphenated body keys
55
+ # (`signer-access-code`, `verification-code`) while passing everything
56
+ # else through as-is.
57
+ #
58
+ # @param hash [Hash, nil]
59
+ # @return [Hash{String=>Object}]
60
+ def body_params(hash)
61
+ normalize_keys(clean_params(hash), body_key_map)
62
+ end
63
+
64
+ private
65
+
66
+ def normalize_keys(hash, key_map, seen = {}.compare_by_identity, depth = 0)
67
+ if depth > MAX_NORMALIZATION_DEPTH || seen.key?(hash)
68
+ raise ValidationError.new('Parameters are nested too deeply or contain a cycle')
69
+ end
70
+
71
+ seen[hash] = true
72
+ hash.each_with_object({}) do |(key, value), result|
73
+ result[normalize_key(key, key_map)] = normalize_value(value, key_map, seen, depth)
74
+ end
75
+ ensure
76
+ seen.delete(hash)
77
+ end
78
+
79
+ def normalize_value(value, key_map, seen, depth)
80
+ case value
81
+ when Hash
82
+ normalize_keys(value, key_map, seen, depth + 1)
83
+ when Array
84
+ normalize_array(value, key_map, seen, depth + 1)
85
+ else
86
+ value
87
+ end
88
+ end
89
+
90
+ def normalize_array(array, key_map, seen, depth)
91
+ if depth > MAX_NORMALIZATION_DEPTH || seen.key?(array)
92
+ raise ValidationError.new('Parameters are nested too deeply or contain a cycle')
93
+ end
94
+
95
+ seen[array] = true
96
+ array.map { |item| normalize_value(item, key_map, seen, depth) }
97
+ ensure
98
+ seen.delete(array)
99
+ end
100
+
101
+ def normalize_key(key, key_map)
102
+ raw = key.to_s
103
+ key_map.fetch(raw, raw)
104
+ end
105
+
106
+ def query_key_map
107
+ {
108
+ 'access_token' => 'access-token',
109
+ 'per_page' => 'per-page',
110
+ 'signer_access_code' => 'signer-access-code'
111
+ }
112
+ end
113
+
114
+ def body_key_map
115
+ {
116
+ 'signer_access_code' => 'signer-access-code',
117
+ 'verification_code' => 'verification-code'
118
+ }
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Assinafy
4
+ VERSION = '1.5.0'
5
+ end
data/lib/assinafy.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'faraday/multipart'
5
+ require 'stringio'
6
+
7
+ require_relative 'assinafy/version'
8
+ require_relative 'assinafy/errors'
9
+ require_relative 'assinafy/null_logger'
10
+ require_relative 'assinafy/configuration'
11
+ require_relative 'assinafy/utils'
12
+ require_relative 'assinafy/resources/base_resource'
13
+ require_relative 'assinafy/resources/auth_resource'
14
+ require_relative 'assinafy/resources/account_resource'
15
+ require_relative 'assinafy/resources/user_resource'
16
+ require_relative 'assinafy/resources/document_resource'
17
+ require_relative 'assinafy/resources/signer_resource'
18
+ require_relative 'assinafy/resources/signer_document_resource'
19
+ require_relative 'assinafy/resources/assignment_resource'
20
+ require_relative 'assinafy/resources/webhook_resource'
21
+ require_relative 'assinafy/resources/template_resource'
22
+ require_relative 'assinafy/resources/field_resource'
23
+ require_relative 'assinafy/resources/tag_resource'
24
+ require_relative 'assinafy/support/webhook_verifier'
25
+ require_relative 'assinafy/client'
metadata ADDED
@@ -0,0 +1,195 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: assinafy
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.5.0
5
+ platform: ruby
6
+ authors:
7
+ - Assinafy SDK Contributors
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: faraday
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 2.14.3
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '3.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: 2.14.3
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '3.0'
32
+ - !ruby/object:Gem::Dependency
33
+ name: faraday-multipart
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '1.0'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '2.0'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '1.0'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '2.0'
52
+ - !ruby/object:Gem::Dependency
53
+ name: bundler-audit
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - "~>"
57
+ - !ruby/object:Gem::Version
58
+ version: '0.9'
59
+ type: :development
60
+ prerelease: false
61
+ version_requirements: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - "~>"
64
+ - !ruby/object:Gem::Version
65
+ version: '0.9'
66
+ - !ruby/object:Gem::Dependency
67
+ name: rake
68
+ requirement: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - "~>"
71
+ - !ruby/object:Gem::Version
72
+ version: '13.0'
73
+ type: :development
74
+ prerelease: false
75
+ version_requirements: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - "~>"
78
+ - !ruby/object:Gem::Version
79
+ version: '13.0'
80
+ - !ruby/object:Gem::Dependency
81
+ name: rspec
82
+ requirement: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - "~>"
85
+ - !ruby/object:Gem::Version
86
+ version: '3.0'
87
+ type: :development
88
+ prerelease: false
89
+ version_requirements: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - "~>"
92
+ - !ruby/object:Gem::Version
93
+ version: '3.0'
94
+ - !ruby/object:Gem::Dependency
95
+ name: rubocop
96
+ requirement: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - "~>"
99
+ - !ruby/object:Gem::Version
100
+ version: '1.86'
101
+ type: :development
102
+ prerelease: false
103
+ version_requirements: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - "~>"
106
+ - !ruby/object:Gem::Version
107
+ version: '1.86'
108
+ - !ruby/object:Gem::Dependency
109
+ name: rubocop-rspec
110
+ requirement: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - "~>"
113
+ - !ruby/object:Gem::Version
114
+ version: '3.6'
115
+ type: :development
116
+ prerelease: false
117
+ version_requirements: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - "~>"
120
+ - !ruby/object:Gem::Version
121
+ version: '3.6'
122
+ - !ruby/object:Gem::Dependency
123
+ name: webmock
124
+ requirement: !ruby/object:Gem::Requirement
125
+ requirements:
126
+ - - "~>"
127
+ - !ruby/object:Gem::Version
128
+ version: '3.0'
129
+ type: :development
130
+ prerelease: false
131
+ version_requirements: !ruby/object:Gem::Requirement
132
+ requirements:
133
+ - - "~>"
134
+ - !ruby/object:Gem::Version
135
+ version: '3.0'
136
+ description: Ruby SDK for Assinafy. Covers the documented authentication, account,
137
+ user, document, signer, assignment, webhook, template, tag, and field APIs, plus
138
+ the high-level upload_and_request_signatures helper.
139
+ email:
140
+ - sdk@assinafy.com.br
141
+ executables: []
142
+ extensions: []
143
+ extra_rdoc_files: []
144
+ files:
145
+ - CHANGELOG.md
146
+ - LICENSE
147
+ - README.md
148
+ - lib/assinafy.rb
149
+ - lib/assinafy/client.rb
150
+ - lib/assinafy/configuration.rb
151
+ - lib/assinafy/errors.rb
152
+ - lib/assinafy/null_logger.rb
153
+ - lib/assinafy/resources/account_resource.rb
154
+ - lib/assinafy/resources/assignment_resource.rb
155
+ - lib/assinafy/resources/auth_resource.rb
156
+ - lib/assinafy/resources/base_resource.rb
157
+ - lib/assinafy/resources/document_resource.rb
158
+ - lib/assinafy/resources/field_resource.rb
159
+ - lib/assinafy/resources/signer_document_resource.rb
160
+ - lib/assinafy/resources/signer_resource.rb
161
+ - lib/assinafy/resources/tag_resource.rb
162
+ - lib/assinafy/resources/template_resource.rb
163
+ - lib/assinafy/resources/user_resource.rb
164
+ - lib/assinafy/resources/webhook_resource.rb
165
+ - lib/assinafy/support/webhook_verifier.rb
166
+ - lib/assinafy/utils.rb
167
+ - lib/assinafy/version.rb
168
+ homepage: https://github.com/assinafy/ruby-sdk
169
+ licenses:
170
+ - MIT
171
+ metadata:
172
+ source_code_uri: https://github.com/assinafy/ruby-sdk
173
+ bug_tracker_uri: https://github.com/assinafy/ruby-sdk/issues
174
+ changelog_uri: https://github.com/assinafy/ruby-sdk/blob/main/CHANGELOG.md
175
+ documentation_uri: https://api.assinafy.com.br/v1/docs
176
+ github_repo: ssh://github.com/assinafy/ruby-sdk
177
+ rubygems_mfa_required: 'true'
178
+ rdoc_options: []
179
+ require_paths:
180
+ - lib
181
+ required_ruby_version: !ruby/object:Gem::Requirement
182
+ requirements:
183
+ - - ">="
184
+ - !ruby/object:Gem::Version
185
+ version: '3.2'
186
+ required_rubygems_version: !ruby/object:Gem::Requirement
187
+ requirements:
188
+ - - ">="
189
+ - !ruby/object:Gem::Version
190
+ version: '0'
191
+ requirements: []
192
+ rubygems_version: 4.0.16
193
+ specification_version: 4
194
+ summary: Ruby SDK for the Assinafy digital signature API
195
+ test_files: []