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,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Assinafy
4
+ module Resources
5
+ # Authentication and API key management.
6
+ #
7
+ # See https://api.assinafy.com.br/v1/docs#authentication for the full
8
+ # documentation of these endpoints.
9
+ class AuthResource < BaseResource
10
+ # Authenticate with email and password.
11
+ #
12
+ # The returned `access_token` is a JWT that typically expires in one hour. For long-lived
13
+ # back-end integrations, prefer an API key (see #create_api_key) over the access token.
14
+ #
15
+ # @param email [String]
16
+ # @param password [String]
17
+ # @return [Hash] unwrapped payload: { "access_token" => String, "user" => Hash, "accounts" => Array<Hash> }
18
+ # @raise [Assinafy::ApiError] on a non-2xx response
19
+ #
20
+ # @see POST /login
21
+ #
22
+ # @example Request and response
23
+ # resource.login(email: 'user@example.com', password: 'secret')
24
+ # # Request body sent by the SDK:
25
+ # # { "email": "user@example.com", "password": "secret" }
26
+ # #
27
+ # # Returns the unwrapped data payload (envelope { status, message, data } stripped):
28
+ # # {
29
+ # # "access_token" => "access-token-placeholder",
30
+ # # "user" => {
31
+ # # "id" => "user-id", "name" => "Example User",
32
+ # # "email" => "user@example.com", "telephone" => "+15555550100",
33
+ # # "government_id" => "00000000000", "is_email_verified" => false,
34
+ # # "has_accepted_terms" => true, "created_at" => "2023-03-03T11:51:34Z",
35
+ # # "to_be_deleted_at" => nil
36
+ # # },
37
+ # # "accounts" => [
38
+ # # { "id" => "account-id", "name" => "Example Workspace", "roles" => ["owner"],
39
+ # # "is_delete_allowed" => true, "created_at" => "2023-03-03T11:51:34Z" }
40
+ # # ]
41
+ # # }
42
+ def login(email:, password:)
43
+ call('Failed to login') do
44
+ @connection.post('login', body_params(email: email, password: password))
45
+ end
46
+ end
47
+
48
+ # Authenticate with a third-party identity provider token.
49
+ #
50
+ # Currently the only supported provider is `google`. Returns the same shape as #login.
51
+ #
52
+ # @param provider [String] the provider type; currently only `google`
53
+ # @param token [String] provider-issued OAuth/OIDC access or ID token
54
+ # @param has_accepted_terms [Boolean]
55
+ # @return [Hash] unwrapped payload: { "access_token" => String, "user" => Hash, "accounts" => Array<Hash> }
56
+ #
57
+ # @see POST /authentication/social-login
58
+ #
59
+ # @example Request and response
60
+ # resource.social_login(provider: 'google', token: 'provider-token', has_accepted_terms: true)
61
+ # # Request body sent by the SDK:
62
+ # # { "provider": "google", "token": "provider-token", "has_accepted_terms": true }
63
+ # #
64
+ # # Returns the unwrapped data payload (envelope stripped); same shape as #login:
65
+ # # {
66
+ # # "access_token" => "access-token-placeholder",
67
+ # # "user" => { "id" => "user-id", "name" => "Example User", ... },
68
+ # # "accounts" => [
69
+ # # { "id" => "account-id", "name" => "Example Workspace", "roles" => ["owner"],
70
+ # # "is_delete_allowed" => true, "created_at" => "2023-03-03T11:51:34Z" }
71
+ # # ]
72
+ # # }
73
+ def social_login(provider:, token:, has_accepted_terms:)
74
+ call('Failed to login with social provider') do
75
+ @connection.post(
76
+ 'authentication/social-login',
77
+ body_params(
78
+ provider: provider,
79
+ token: token,
80
+ has_accepted_terms: has_accepted_terms
81
+ )
82
+ )
83
+ end
84
+ end
85
+
86
+ # Link a third-party identity provider to the authenticated user's account.
87
+ #
88
+ # @param provider [String] the provider type; currently only `google`
89
+ # @param token [String] provider-issued OAuth/OIDC token
90
+ # @return [nil] the documented success envelope has no `data` payload
91
+ # @see POST /auth/link-social-login
92
+ # @example Link a Google account
93
+ # client.auth.link_social_login(provider: 'google', token: 'provider-token')
94
+ # # Request body sent by the SDK:
95
+ # # { "provider": "google", "token": "provider-token" }
96
+ # # Response: { "status": 200, "message": "Provider linked" }
97
+ # # => nil
98
+ def link_social_login(provider:, token:)
99
+ call('Failed to link social login') do
100
+ @connection.post('auth/link-social-login', body_params(provider: provider, token: token))
101
+ end
102
+ end
103
+
104
+ # Generate a new API key for the authenticated user.
105
+ #
106
+ # The returned key is shown in full only once, here; afterwards #get_api_key returns a masked
107
+ # version. IMPORTANT: generating a new key deletes (invalidates) the previous one. Send the key
108
+ # via the `X-Api-Key` header and never expose it in a front-end application.
109
+ #
110
+ # @param password [String] the user's current password
111
+ # @return [Hash] unwrapped payload: { "api_key" => String } (the new key, in full)
112
+ #
113
+ # @see POST /users/api-keys
114
+ #
115
+ # @example Request and response
116
+ # resource.create_api_key(password: 'secret')
117
+ # # Request body sent by the SDK:
118
+ # # { "password": "secret" }
119
+ # #
120
+ # # Returns the unwrapped data payload (envelope stripped):
121
+ # # { "api_key" => "api-key-created-once" }
122
+ def create_api_key(password:)
123
+ call('Failed to create API key') do
124
+ @connection.post('users/api-keys', body_params(password: password))
125
+ end
126
+ end
127
+
128
+ # Retrieve the active API key for the authenticated user.
129
+ #
130
+ # For security the key is returned MASKED (only the last 4 characters are visible); the full key
131
+ # is only available once, at #create_api_key time. Returns `nil` if no key has been generated yet.
132
+ # This endpoint works with `X-Api-Key` authentication (verified live), not only a Bearer token.
133
+ #
134
+ # @return [Hash, nil] unwrapped payload: { "api_key" => String } (masked), or nil if no key exists yet
135
+ # @see GET /users/api-keys
136
+ #
137
+ # @example Request and response (key exists)
138
+ # resource.get_api_key
139
+ # # No request body (GET).
140
+ # #
141
+ # # Returns the unwrapped data payload (envelope stripped):
142
+ # # { "api_key" => "************************************************************9Jdr" }
143
+ #
144
+ # @example Response when no key has been generated yet
145
+ # resource.get_api_key # => nil
146
+ def get_api_key
147
+ call('Failed to get API key') do
148
+ @connection.get('users/api-keys')
149
+ end
150
+ end
151
+
152
+ alias api_key get_api_key
153
+
154
+ # Delete the API key of the authenticated user.
155
+ #
156
+ # The SDK ignores the response body and always returns `nil` on success. (The API itself
157
+ # responds with an empty `data` payload.)
158
+ #
159
+ # @return [nil]
160
+ # @see DELETE /users/api-keys
161
+ #
162
+ # @example Request and response
163
+ # resource.delete_api_key
164
+ # # No request body (DELETE).
165
+ # #
166
+ # # Returns nil on success (the API's empty `data` payload is discarded).
167
+ # # => nil
168
+ def delete_api_key
169
+ call_void('Failed to delete API key') do
170
+ @connection.delete('users/api-keys')
171
+ end
172
+ end
173
+
174
+ # Change the authenticated user's password.
175
+ #
176
+ # @param email [String]
177
+ # @param password [String] current password
178
+ # @param new_password [String] the new password to set
179
+ # @return [Hash] unwrapped payload: { "email" => String }
180
+ #
181
+ # @see PUT /authentication/change-password
182
+ #
183
+ # @example Request and response
184
+ # resource.change_password(email: 'user@example.com', password: 'current-password',
185
+ # new_password: 'new-password')
186
+ # # Request body sent by the SDK:
187
+ # # { "email": "user@example.com", "password": "current-password",
188
+ # # "new_password": "new-password" }
189
+ # #
190
+ # # Returns the unwrapped data payload (envelope stripped):
191
+ # # { "email" => "user@example.com" }
192
+ def change_password(email:, password:, new_password:)
193
+ call('Failed to change password') do
194
+ @connection.put(
195
+ 'authentication/change-password',
196
+ body_params(email: email, password: password, new_password: new_password)
197
+ )
198
+ end
199
+ end
200
+
201
+ # Trigger a password-reset email for the given account.
202
+ #
203
+ # Used when the user forgot their password or has not set one yet. An email with a reset token
204
+ # is sent; pass that token to #reset_password to complete the flow.
205
+ #
206
+ # @param email [String]
207
+ # @return [Hash] unwrapped payload: { "email" => String }
208
+ #
209
+ # @see PUT /authentication/request-password-reset
210
+ #
211
+ # @example Request and response
212
+ # resource.request_password_reset(email: 'user@example.com')
213
+ # # Request body sent by the SDK:
214
+ # # { "email": "user@example.com" }
215
+ # #
216
+ # # Returns the unwrapped data payload (envelope stripped):
217
+ # # { "email" => "user@example.com" }
218
+ def request_password_reset(email:)
219
+ call('Failed to request password reset') do
220
+ @connection.put('authentication/request-password-reset', body_params(email: email))
221
+ end
222
+ end
223
+
224
+ # Reset the password using the token sent via #request_password_reset.
225
+ #
226
+ # @param email [String]
227
+ # @param new_password [String] the new password to set
228
+ # @param token [String, nil] reset token from the email; omitted from the body when nil
229
+ # @return [Hash] unwrapped payload: { "email" => String }
230
+ #
231
+ # @see PUT /authentication/reset-password
232
+ #
233
+ # @example Request and response
234
+ # resource.reset_password(email: 'user@example.com', new_password: 'new-password',
235
+ # token: 'reset-token')
236
+ # # Request body sent by the SDK (nil token would be omitted by body_params):
237
+ # # { "email": "user@example.com", "token": "reset-token", "new_password": "new-password" }
238
+ # #
239
+ # # Returns the unwrapped data payload (envelope stripped):
240
+ # # { "email" => "user@example.com" }
241
+ def reset_password(email:, new_password:, token: nil)
242
+ call('Failed to reset password') do
243
+ @connection.put(
244
+ 'authentication/reset-password',
245
+ body_params(email: email, token: token, new_password: new_password)
246
+ )
247
+ end
248
+ end
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Assinafy
4
+ module Resources
5
+ # Shared plumbing for every resource: a Faraday connection, an optional
6
+ # default account ID, parameter normalisation, response-envelope handling,
7
+ # pagination header parsing, and a single `request`/`call` pipeline that
8
+ # wraps Faraday and Assinafy errors into the SDK's own error hierarchy.
9
+ class BaseResource
10
+ PATH_SEGMENT = /\A[A-Za-z0-9._~-]+\z/
11
+ PAGINATION_HEADERS = {
12
+ current_page: 'x-pagination-current-page',
13
+ per_page: 'x-pagination-per-page',
14
+ total: 'x-pagination-total-count',
15
+ last_page: 'x-pagination-page-count'
16
+ }.freeze
17
+
18
+ # @param connection [Faraday::Connection]
19
+ # @param default_account_id [String, nil] used by `account_id` when no override is passed
20
+ # @param logger [Logger, nil]
21
+ def initialize(connection, default_account_id = nil, logger = nil)
22
+ @connection = connection
23
+ @default_account_id = default_account_id
24
+ @logger = logger || NullLogger.new
25
+ end
26
+
27
+ protected
28
+
29
+ def account_id(explicit = nil)
30
+ require_id(explicit.nil? ? @default_account_id : explicit, 'Account ID')
31
+ end
32
+
33
+ # Ensure a required scalar argument is present (not nil/blank).
34
+ # `require_id` is kept as an intention-revealing alias for path IDs.
35
+ def require_present(value, name)
36
+ return value unless value.nil? || value.to_s.strip.empty?
37
+
38
+ raise ValidationError.new("#{name} is required")
39
+ end
40
+
41
+ def require_id(value, name)
42
+ id = require_present(value, name)
43
+ raise ValidationError.new("#{name} must be a String") unless id.is_a?(String)
44
+
45
+ return id if PATH_SEGMENT.match?(id) && id != '.' && id != '..'
46
+
47
+ raise ValidationError.new("#{name} contains invalid characters")
48
+ end
49
+
50
+ def require_boolean(value, name)
51
+ return value if [true, false].include?(value)
52
+
53
+ raise ValidationError.new("#{name} must be true or false")
54
+ end
55
+
56
+ def require_payload(payload, name = 'Payload')
57
+ raise ValidationError.new("#{name} must be a Hash") unless payload.is_a?(Hash)
58
+
59
+ payload
60
+ end
61
+
62
+ def require_array(value, name)
63
+ return value if value.is_a?(Array) && !value.empty?
64
+
65
+ raise ValidationError.new("#{name} must be a non-empty Array")
66
+ end
67
+
68
+ def query_params(params)
69
+ Utils.query_params(params)
70
+ end
71
+
72
+ def body_params(params)
73
+ Utils.body_params(params)
74
+ end
75
+
76
+ # Normalise an upload source into `[buffer, file_name]`. Shared by every
77
+ # multipart upload endpoint (document upload, template create, account
78
+ # logo). Accepts a path String, or a Hash with `:file_path` (path) **or**
79
+ # `:buffer` + `:file_name` (raw bytes).
80
+ #
81
+ # @param source [String, Hash]
82
+ # @param max_bytes [Integer, nil] read at most one byte beyond this limit
83
+ # @return [Array(String, String)] `[buffer, file_name]`
84
+ # @raise [ValidationError] on an unusable source
85
+ def read_source(source, max_bytes: nil)
86
+ case source
87
+ when String
88
+ [read_file(source, max_bytes), File.basename(source)]
89
+ when Hash
90
+ if source[:buffer]
91
+ raise ValidationError.new('file_name is required when uploading a buffer') unless source[:file_name]
92
+
93
+ [source[:buffer], source[:file_name]]
94
+ elsif source[:file_path]
95
+ [read_file(source[:file_path], max_bytes), source[:file_name] || File.basename(source[:file_path])]
96
+ else
97
+ raise ValidationError.new('Invalid upload source: provide :file_path or :buffer')
98
+ end
99
+ else
100
+ raise ValidationError.new('Invalid upload source: provide a path String or a Hash with :file_path/:buffer')
101
+ end
102
+ rescue SystemCallError, ArgumentError, TypeError => e
103
+ raise ValidationError.new("Unable to read upload source: #{e.message}", { cause: e.class.name })
104
+ end
105
+
106
+ def read_file(path, max_bytes)
107
+ return File.binread(path) unless max_bytes
108
+
109
+ File.binread(path, max_bytes + 1)
110
+ end
111
+
112
+ # Wrap raw bytes in a Faraday::FilePart for a multipart request body.
113
+ #
114
+ # @param buffer [String] raw file bytes
115
+ # @param file_name [String]
116
+ # @param content_type [String] MIME type (defaults to sniffing the extension)
117
+ # @return [Faraday::FilePart]
118
+ def file_part(buffer, file_name, content_type = nil)
119
+ Faraday::FilePart.new(StringIO.new(buffer), content_type || mime_type_for(file_name), file_name)
120
+ end
121
+
122
+ def mime_type_for(file_name)
123
+ case File.extname(file_name.to_s).downcase
124
+ when '.pdf' then 'application/pdf'
125
+ when '.png' then 'image/png'
126
+ when '.jpg', '.jpeg' then 'image/jpeg'
127
+ when '.gif' then 'image/gif'
128
+ when '.webp' then 'image/webp'
129
+ when '.svg' then 'image/svg+xml'
130
+ else 'application/octet-stream'
131
+ end
132
+ end
133
+
134
+ def http_get(path, params = {})
135
+ @connection.get(path, query_params(params))
136
+ end
137
+
138
+ def http_post(path, body = nil, params = {})
139
+ @connection.post(path) do |request|
140
+ request.params.update(query_params(params))
141
+ request.body = body unless body.nil?
142
+ end
143
+ end
144
+
145
+ def http_put(path, body = nil, params = {})
146
+ @connection.put(path) do |request|
147
+ request.params.update(query_params(params))
148
+ request.body = body unless body.nil?
149
+ end
150
+ end
151
+
152
+ def http_patch(path, body = nil, params = {})
153
+ @connection.patch(path) do |request|
154
+ request.params.update(query_params(params))
155
+ request.body = body unless body.nil?
156
+ end
157
+ end
158
+
159
+ def http_delete(path, params = {}, body: nil)
160
+ @connection.delete(path) do |request|
161
+ request.params.update(query_params(params))
162
+ request.body = body unless body.nil?
163
+ end
164
+ end
165
+
166
+ def call(label)
167
+ Utils.handle_assinafy_response(request(label) { yield }.body)
168
+ end
169
+
170
+ def call_optional(label)
171
+ call(label) { yield }
172
+ rescue ApiError => e
173
+ raise unless e.status_code == 404
174
+
175
+ nil
176
+ end
177
+
178
+ def call_void(label)
179
+ call(label) { yield }
180
+ nil
181
+ end
182
+
183
+ def call_binary(label)
184
+ body = request(label) { yield }.body
185
+ body = Utils.handle_assinafy_response(body) if body.is_a?(Hash)
186
+ return ''.b if body.nil?
187
+ return body.b if body.is_a?(String)
188
+
189
+ raise Assinafy::Error.new("#{label}: expected a binary response", { body_class: body.class.name })
190
+ end
191
+
192
+ def call_list(label)
193
+ response = request(label) { yield }
194
+ body = Utils.handle_assinafy_response(response.body)
195
+ result = { data: extract_list_data(body) }
196
+ meta = parse_pagination_meta(response.headers)
197
+ result[:meta] = meta if meta
198
+ result
199
+ end
200
+
201
+ private
202
+
203
+ def request(label)
204
+ response = yield
205
+ check_status!(response, label)
206
+ response
207
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError, Faraday::SSLError => e
208
+ raise NetworkError.new("#{label}: #{e.message}", { cause: e.class.name })
209
+ rescue Assinafy::Error
210
+ raise
211
+ rescue StandardError => e
212
+ # Preserve the original class for debugging; Ruby keeps the original
213
+ # exception accessible via #cause since we re-raise inside the rescue.
214
+ raise Assinafy::Error.new("#{label}: #{e.message}", { cause: e.class.name })
215
+ end
216
+
217
+ def check_status!(response, _label)
218
+ return if (200..299).cover?(response.status)
219
+
220
+ raise ApiError.from_response(response.status, response.body)
221
+ end
222
+
223
+ def extract_list_data(body)
224
+ case body
225
+ when Array then body
226
+ when Hash then body['data'] || []
227
+ else []
228
+ end
229
+ end
230
+
231
+ def parse_pagination_meta(headers)
232
+ return nil unless headers
233
+
234
+ meta = PAGINATION_HEADERS.each_with_object({}) do |(key, header), acc|
235
+ value = to_int(headers[header])
236
+ acc[key] = value if value
237
+ end
238
+ meta.empty? ? nil : meta
239
+ end
240
+
241
+ def to_int(value)
242
+ return nil if value.nil?
243
+
244
+ Integer(value)
245
+ rescue ArgumentError, TypeError
246
+ nil
247
+ end
248
+ end
249
+ end
250
+ end