apigratis-sdk-ruby 0.0.1

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.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +27 -0
  3. data/LICENSE +21 -0
  4. data/README.md +330 -0
  5. data/SECURITY.md +32 -0
  6. data/lib/api_brasil/client.rb +252 -0
  7. data/lib/api_brasil/core/env.rb +34 -0
  8. data/lib/api_brasil/core/errors.rb +167 -0
  9. data/lib/api_brasil/core/http_client.rb +342 -0
  10. data/lib/api_brasil/core/retry.rb +69 -0
  11. data/lib/api_brasil/core/transport/faraday_transport.rb +76 -0
  12. data/lib/api_brasil/core/transport/net_http_transport.rb +126 -0
  13. data/lib/api_brasil/core/transport.rb +114 -0
  14. data/lib/api_brasil/generated/catalog.rb +526 -0
  15. data/lib/api_brasil/legacy/service.rb +155 -0
  16. data/lib/api_brasil/services/base_service.rb +49 -0
  17. data/lib/api_brasil/services/data/bulk_service.rb +26 -0
  18. data/lib/api_brasil/services/data/cep_service.rb +58 -0
  19. data/lib/api_brasil/services/data/chip_virtual_service.rb +40 -0
  20. data/lib/api_brasil/services/data/consulta_service.rb +97 -0
  21. data/lib/api_brasil/services/data/correios_service.rb +23 -0
  22. data/lib/api_brasil/services/data/dados_service.rb +30 -0
  23. data/lib/api_brasil/services/data/database_ip_service.rb +19 -0
  24. data/lib/api_brasil/services/data/fipe_service.rb +61 -0
  25. data/lib/api_brasil/services/data/proxies.rb +146 -0
  26. data/lib/api_brasil/services/data/ura_service.rb +26 -0
  27. data/lib/api_brasil/services/data/vehicles_service.rb +46 -0
  28. data/lib/api_brasil/services/device_proxy_service.rb +46 -0
  29. data/lib/api_brasil/services/messaging/evolution_service.rb +38 -0
  30. data/lib/api_brasil/services/messaging/sms_service.rb +40 -0
  31. data/lib/api_brasil/services/messaging/whats_meow_service.rb +30 -0
  32. data/lib/api_brasil/services/messaging/whatsapp_service.rb +120 -0
  33. data/lib/api_brasil/services/platform/account_service.rb +138 -0
  34. data/lib/api_brasil/services/platform/auth_service.rb +204 -0
  35. data/lib/api_brasil/services/platform/bearer_rate_limit_service.rb +29 -0
  36. data/lib/api_brasil/services/platform/catalog_service.rb +111 -0
  37. data/lib/api_brasil/services/platform/devices_service.rb +73 -0
  38. data/lib/api_brasil/services/platform/ip_whitelist_service.rb +73 -0
  39. data/lib/api_brasil/services/platform/payments_service.rb +124 -0
  40. data/lib/api_brasil/services/platform/reports_service.rb +96 -0
  41. data/lib/api_brasil/version.rb +6 -0
  42. data/lib/api_brasil.rb +72 -0
  43. data/lib/apigratis-sdk-ruby.rb +5 -0
  44. data/lib/apigratis.rb +11 -0
  45. metadata +149 -0
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApiBrasil
4
+ module Core
5
+ # Leitura da configuracao a partir das variaveis de ambiente.
6
+ module Env
7
+ # Variaveis de ambiente reconhecidas pela SDK.
8
+ VARS = {
9
+ bearer_token: "APIBRASIL_BEARER_TOKEN",
10
+ device_token: "APIBRASIL_DEVICE_TOKEN",
11
+ secret_key: "APIBRASIL_SECRET_KEY",
12
+ base_url: "APIBRASIL_BASE_URL"
13
+ }.freeze
14
+
15
+ module_function
16
+
17
+ # Le a configuracao das variaveis de ambiente (quando disponiveis).
18
+ # Valores passados explicitamente no construtor sempre tem prioridade.
19
+ #
20
+ # @return [Hash{Symbol => String}]
21
+ def config
22
+ VARS.each_with_object({}) do |(key, name), acc|
23
+ value = read(name)
24
+ acc[key] = value unless value.nil? || value.empty?
25
+ end
26
+ end
27
+
28
+ # @return [String, nil]
29
+ def read(name)
30
+ ENV.fetch(name, nil)&.to_s
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module ApiBrasil
6
+ module Core
7
+ # Hierarquia de erros da SDK e a fabrica que mapeia status HTTP -> classe.
8
+ module Errors
9
+ # Erro base lancado pelo cliente `ApiBrasil::Client`. Subclasses
10
+ # especificas permitem tratar cada categoria com `rescue`:
11
+ #
12
+ # - `ValidationError` (400/422), `AuthenticationError` (401),
13
+ # `InsufficientBalanceError` (402), `PermissionError` (403),
14
+ # `NotFoundError` (404/410), `RateLimitError` (429), `ServerError` (5xx)
15
+ # - `NetworkError` / `TimeoutError` para falhas antes da resposta.
16
+ class ApiBrasilError < StandardError
17
+ # @return [Integer, nil] status HTTP retornado pela API (ex: 401, 402).
18
+ attr_reader :status
19
+
20
+ # @return [String, nil] codigo de erro retornado pela API (ex: `NOT_FOUND`).
21
+ attr_reader :error_code
22
+
23
+ # @return [Object, nil] corpo completo da resposta de erro.
24
+ attr_reader :response
25
+
26
+ # @return [Exception, nil] erro original, quando houver.
27
+ attr_reader :previous
28
+
29
+ def initialize(message, status: nil, code: nil, response: nil, previous: nil)
30
+ super(message)
31
+ @status = status
32
+ @error_code = code
33
+ @response = response
34
+ @previous = previous
35
+ end
36
+
37
+ # `true` quando a falha foi por saldo/creditos insuficientes (HTTP 402).
38
+ def insufficient_balance?
39
+ status == 402
40
+ end
41
+
42
+ # `true` quando a falha foi de autenticacao (HTTP 401).
43
+ def unauthorized?
44
+ status == 401
45
+ end
46
+
47
+ # Converte qualquer erro em um `ApiBrasilError`.
48
+ #
49
+ # @param error [Object]
50
+ # @return [ApiBrasilError]
51
+ def self.from(error)
52
+ return error if error.is_a?(ApiBrasilError)
53
+ return new(error.message, previous: error) if error.is_a?(Exception)
54
+
55
+ new(error.to_s)
56
+ end
57
+ end
58
+
59
+ # HTTP 400/422 - payload invalido.
60
+ class ValidationError < ApiBrasilError; end
61
+
62
+ # HTTP 401 - Bearer Token ausente, invalido ou expirado.
63
+ class AuthenticationError < ApiBrasilError; end
64
+
65
+ # HTTP 402 - saldo/creditos insuficientes.
66
+ class InsufficientBalanceError < ApiBrasilError; end
67
+
68
+ # HTTP 403 - sem permissao (ex: API exige conta PJ).
69
+ class PermissionError < ApiBrasilError; end
70
+
71
+ # HTTP 404/410 - recurso nao encontrado ou desativado.
72
+ class NotFoundError < ApiBrasilError; end
73
+
74
+ # HTTP 5xx - erro interno do gateway/provedor.
75
+ class ServerError < ApiBrasilError; end
76
+
77
+ # Falha de rede - a requisicao pode nao ter chegado ao servidor.
78
+ class NetworkError < ApiBrasilError; end
79
+
80
+ # Timeout - a requisicao pode ter sido processada; a SDK nao faz retry automatico.
81
+ class TimeoutError < NetworkError; end
82
+
83
+ # HTTP 429 - rate limit atingido.
84
+ class RateLimitError < ApiBrasilError
85
+ # @return [Integer, nil] espera sugerida pelo servidor (header `Retry-After`), em ms.
86
+ attr_reader :retry_after_ms
87
+
88
+ def initialize(message, retry_after_ms: nil, **options)
89
+ super(message, **options)
90
+ @retry_after_ms = retry_after_ms
91
+ end
92
+ end
93
+
94
+ # Mapeia um status HTTP + corpo de erro para a subclasse adequada de
95
+ # `ApiBrasilError` (equivalente ao `createApiError` da SDK JS).
96
+ module ErrorFactory
97
+ module_function
98
+
99
+ # @param status [Integer]
100
+ # @param data [Object] corpo da resposta ja decodificado
101
+ # @param headers [Hash{String => String}, nil]
102
+ # @param previous [Exception, nil]
103
+ # @return [ApiBrasilError]
104
+ def create(status, data, headers = nil, previous = nil)
105
+ message = extract_message(status, data)
106
+ options = {
107
+ status: status,
108
+ code: extract_code(data),
109
+ response: data,
110
+ previous: previous
111
+ }
112
+
113
+ case status
114
+ when 400, 422 then ValidationError.new(message, **options)
115
+ when 401 then AuthenticationError.new(message, **options)
116
+ when 402 then InsufficientBalanceError.new(message, **options)
117
+ when 403 then PermissionError.new(message, **options)
118
+ when 404, 410 then NotFoundError.new(message, **options)
119
+ when 429 then RateLimitError.new(message, retry_after_ms: parse_retry_after(headers), **options)
120
+ else
121
+ status >= 500 ? ServerError.new(message, **options) : ApiBrasilError.new(message, **options)
122
+ end
123
+ end
124
+
125
+ # @return [String]
126
+ def extract_message(status, data)
127
+ if data.is_a?(Hash)
128
+ message = data["message"] || data[:message]
129
+ return message if message.is_a?(String) && !message.empty?
130
+
131
+ error = data["error"] || data[:error]
132
+ return error if error.is_a?(String) && !error.empty?
133
+ end
134
+
135
+ "A API respondeu com HTTP #{status}."
136
+ end
137
+
138
+ # @return [String, nil]
139
+ def extract_code(data)
140
+ return nil unless data.is_a?(Hash)
141
+
142
+ code = data["code"] || data[:code]
143
+ code.is_a?(String) ? code : nil
144
+ end
145
+
146
+ # Le o header `Retry-After` (segundos ou data HTTP) e devolve ms.
147
+ #
148
+ # @return [Integer, nil]
149
+ def parse_retry_after(headers)
150
+ return nil unless headers.is_a?(Hash)
151
+
152
+ raw = headers["retry-after"] || headers["Retry-After"]
153
+ return nil if raw.nil? || raw.to_s.empty?
154
+
155
+ raw = raw.to_s
156
+ return [0, (raw.to_f * 1000).round].max if raw.match?(/\A\s*-?\d+(\.\d+)?\s*\z/)
157
+
158
+ begin
159
+ [0, ((Time.httpdate(raw) - Time.now) * 1000).round].max
160
+ rescue ArgumentError
161
+ nil
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,342 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ require_relative "../version"
7
+ require_relative "env"
8
+ require_relative "errors"
9
+ require_relative "retry"
10
+ require_relative "transport"
11
+
12
+ module ApiBrasil
13
+ module Core
14
+ # Cliente HTTP interno da SDK. Injeta os headers de autenticacao da
15
+ # plataforma (`Authorization: Bearer`, `DeviceToken`, `SecretKey`),
16
+ # aplica retry com backoff, dispara hooks de observabilidade e converte
17
+ # falhas em subclasses de `ApiBrasilError`.
18
+ #
19
+ # Chaves de configuracao aceitas (tambem aceitas em camelCase):
20
+ # - `bearer_token` (String): token JWT obtido no login
21
+ # - `device_token` (String): token do dispositivo (servicos device-based)
22
+ # - `secret_key` (String): SecretKey da API (usada ao criar devices)
23
+ # - `base_url` (String): base da API. Padrao: `https://gateway.apibrasil.io/api/v2`
24
+ # - `timeout` (Integer): timeout das requisicoes em **milissegundos**. Padrao: 30000
25
+ # - `headers` (Hash): headers adicionais em todas as requisicoes
26
+ # - `transport` (Transport::Base): transporte customizado
27
+ # - `retry` (Hash, false): politica de retry - veja `Retry::DEFAULT`
28
+ # - `hooks` (Hash): `on_request`, `on_response`, `on_retry` (callables)
29
+ #
30
+ # Opcoes por requisicao (`options`), que sobrescrevem a configuracao:
31
+ # `query`, `headers`, `bearer_token`, `device_token`, `secret_key`, `timeout`,
32
+ # `response_type` (`json` | `raw` | `stream`).
33
+ class HttpClient
34
+ DEFAULT_BASE_URL = "https://gateway.apibrasil.io/api/v2"
35
+ DEFAULT_TIMEOUT = 30_000
36
+ SDK_USER_AGENT = "APIBRASIL/SDK-RUBY #{ApiBrasil::VERSION}".freeze
37
+
38
+ # @return [Transport::Base]
39
+ attr_reader :transport
40
+
41
+ # @return [Hash, nil] politica de retry resolvida (`nil` quando desligada)
42
+ attr_reader :retry_config
43
+
44
+ # @return [Hash{Symbol => Proc}]
45
+ attr_reader :hooks
46
+
47
+ # @param config [Hash]
48
+ def initialize(config = {})
49
+ config = self.class.normalize_keys(config)
50
+ @config = Env.config.merge(config.compact)
51
+
52
+ transport = @config[:transport]
53
+ @transport = transport.respond_to?(:request) ? transport : Transport.default
54
+
55
+ @retry_config = Retry.resolve(@config.key?(:retry) ? @config[:retry] : nil)
56
+ @hooks = self.class.normalize_keys(@config[:hooks] || {})
57
+ end
58
+
59
+ # @return [String]
60
+ def base_url
61
+ value = @config[:base_url]
62
+ value.is_a?(String) && !value.empty? ? value : DEFAULT_BASE_URL
63
+ end
64
+
65
+ # @return [String, nil]
66
+ def bearer_token
67
+ value = @config[:bearer_token]
68
+ value.is_a?(String) ? value : nil
69
+ end
70
+
71
+ # @return [String, nil]
72
+ def device_token
73
+ value = @config[:device_token]
74
+ value.is_a?(String) ? value : nil
75
+ end
76
+
77
+ # @return [String, nil]
78
+ def secret_key
79
+ value = @config[:secret_key]
80
+ value.is_a?(String) ? value : nil
81
+ end
82
+
83
+ # Define/atualiza o Bearer Token (`nil` remove).
84
+ def bearer_token=(token)
85
+ if token.nil?
86
+ @config.delete(:bearer_token)
87
+ else
88
+ @config[:bearer_token] = token
89
+ end
90
+ end
91
+ alias set_bearer_token bearer_token=
92
+
93
+ # Define/atualiza o DeviceToken (`nil` remove).
94
+ def device_token=(token)
95
+ if token.nil?
96
+ @config.delete(:device_token)
97
+ else
98
+ @config[:device_token] = token
99
+ end
100
+ end
101
+ alias set_device_token device_token=
102
+
103
+ # Configuracao atual do cliente (util para clonar com outro device).
104
+ #
105
+ # @return [Hash]
106
+ def to_config
107
+ @config.merge(transport: @transport)
108
+ end
109
+
110
+ # Executa uma requisicao no gateway.
111
+ #
112
+ # @param method [String, Symbol] verbo HTTP
113
+ # @param path [String] caminho relativo a `base_url`
114
+ # @param body [Hash, Array, String, nil]
115
+ # @param options [Hash]
116
+ # @return [Object] corpo da resposta ja decodificado
117
+ def request(method, path, body = nil, options = {})
118
+ options = self.class.normalize_keys(options)
119
+ transport_request = build_transport_request(method, path, body, options)
120
+ attempt = 0
121
+
122
+ loop do
123
+ response = perform(transport_request, body, attempt)
124
+ return response.data if response.status < 400
125
+
126
+ error = Errors::ErrorFactory.create(response.status, response.data, response.headers)
127
+ raise error unless retry_status?(response.status, attempt)
128
+
129
+ attempt = wait_and_bump(attempt, transport_request, "HTTP #{response.status}",
130
+ suggested_delay(error, attempt))
131
+ rescue Errors::NetworkError => e
132
+ raise Errors::ApiBrasilError.from(e) unless retry_error?(e, attempt)
133
+
134
+ attempt = wait_and_bump(attempt, transport_request, e.message)
135
+ end
136
+ end
137
+
138
+ # @return [Object]
139
+ def get(path, options = {})
140
+ request("GET", path, nil, options)
141
+ end
142
+
143
+ # @return [Object]
144
+ def post(path, body = nil, options = {})
145
+ request("POST", path, body, options)
146
+ end
147
+
148
+ # @return [Object]
149
+ def put(path, body = nil, options = {})
150
+ request("PUT", path, body, options)
151
+ end
152
+
153
+ # @return [Object]
154
+ def patch(path, body = nil, options = {})
155
+ request("PATCH", path, body, options)
156
+ end
157
+
158
+ # @return [Object]
159
+ def delete(path, body = nil, options = {})
160
+ request("DELETE", path, body, options)
161
+ end
162
+
163
+ # Converte chaves camelCase/String para simbolos snake_case, mantendo
164
+ # a paridade com as SDKs PHP/Node/Dart (`baseURL`, `bearerToken`...).
165
+ #
166
+ # @param hash [Hash]
167
+ # @return [Hash{Symbol => Object}]
168
+ def self.normalize_keys(hash)
169
+ return {} if hash.nil?
170
+ return hash unless hash.is_a?(Hash)
171
+
172
+ hash.each_with_object({}) do |(key, value), acc|
173
+ acc[normalize_key(key)] = value
174
+ end
175
+ end
176
+
177
+ # @return [Symbol]
178
+ def self.normalize_key(key)
179
+ key.to_s.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase.to_sym
180
+ end
181
+
182
+ # Serializa o corpo em JSON. Hashes vazios viram `{}` (objeto), como
183
+ # nas demais SDKs, e nunca `[]`.
184
+ #
185
+ # @return [String]
186
+ def self.encode_body(body)
187
+ return body if body.is_a?(String)
188
+ return "{}" if body.respond_to?(:empty?) && body.empty?
189
+
190
+ JSON.generate(body)
191
+ end
192
+
193
+ # @param query [Hash, nil]
194
+ # @return [String]
195
+ def self.build_query_string(query)
196
+ return "" if query.nil? || !query.is_a?(Hash) || query.empty?
197
+
198
+ parts = query.filter_map do |key, value|
199
+ next if value.nil?
200
+
201
+ value = value.join(",") if value.is_a?(Array)
202
+ "#{URI.encode_www_form_component(key.to_s)}=#{URI.encode_www_form_component(value.to_s)}"
203
+ end
204
+
205
+ parts.empty? ? "" : "?#{parts.join("&")}"
206
+ end
207
+
208
+ # Escapa um valor usado como segmento de path (`%20`, nao `+`).
209
+ #
210
+ # @return [String]
211
+ def self.escape_path_segment(value)
212
+ URI.encode_www_form_component(value.to_s).gsub("+", "%20")
213
+ end
214
+
215
+ # @return [String]
216
+ def self.join_url(base_url, path)
217
+ base = base_url.to_s.sub(%r{/+\z}, "")
218
+ suffix = path.to_s.sub(%r{\A/+}, "")
219
+
220
+ suffix.empty? ? base : "#{base}/#{suffix}"
221
+ end
222
+
223
+ private
224
+
225
+ # Monta a requisicao entregue ao transporte (URL, headers e corpo ja
226
+ # prontos) - reaproveitada em todas as tentativas.
227
+ #
228
+ # @return [Transport::Request]
229
+ def build_transport_request(method, path, body, options)
230
+ Transport::Request.new(
231
+ method,
232
+ self.class.join_url(base_url, path) + self.class.build_query_string(options[:query]),
233
+ headers: build_headers(options),
234
+ body: body.nil? ? nil : self.class.encode_body(body),
235
+ timeout_ms: (options[:timeout] || @config[:timeout] || DEFAULT_TIMEOUT).to_i,
236
+ response_type: options[:response_type]
237
+ )
238
+ end
239
+
240
+ # Executa uma tentativa, disparando os hooks de request/response.
241
+ # Qualquer falha do transporte vira uma subclasse de `ApiBrasilError`.
242
+ #
243
+ # @return [Transport::Response]
244
+ def perform(transport_request, body, attempt)
245
+ fire_hook(:on_request, method: transport_request.method, url: transport_request.url,
246
+ headers: transport_request.headers, body: body, attempt: attempt)
247
+ started_at = monotonic_ms
248
+
249
+ response = @transport.request(transport_request)
250
+
251
+ fire_hook(:on_response, method: transport_request.method, url: transport_request.url,
252
+ status: response.status, duration_ms: (monotonic_ms - started_at).round,
253
+ attempt: attempt)
254
+
255
+ response
256
+ rescue StandardError => e
257
+ raise Errors::ApiBrasilError.from(e)
258
+ end
259
+
260
+ # @return [Hash{String => String}]
261
+ def build_headers(options)
262
+ headers = {
263
+ "Content-Type" => "application/json",
264
+ "Accept" => "application/json",
265
+ "User-Agent" => SDK_USER_AGENT
266
+ }
267
+
268
+ headers.merge!(stringify_headers(@config[:headers]))
269
+
270
+ token = options.key?(:bearer_token) ? options[:bearer_token] : bearer_token
271
+ headers["Authorization"] = "Bearer #{token}" if token.is_a?(String) && !token.empty?
272
+
273
+ device = options.key?(:device_token) ? options[:device_token] : device_token
274
+ headers["DeviceToken"] = device if device.is_a?(String) && !device.empty?
275
+
276
+ secret = options[:secret_key]
277
+ headers["SecretKey"] = secret if secret.is_a?(String) && !secret.empty?
278
+
279
+ headers.merge!(stringify_headers(options[:headers]))
280
+
281
+ headers
282
+ end
283
+
284
+ def stringify_headers(headers)
285
+ return {} unless headers.is_a?(Hash)
286
+
287
+ headers.each_with_object({}) { |(name, value), acc| acc[name.to_s] = value.to_s }
288
+ end
289
+
290
+ # Tentativas totais permitidas (original + retries).
291
+ def max_attempts
292
+ 1 + (@retry_config ? @retry_config[:retries].to_i : 0)
293
+ end
294
+
295
+ # Falhas de conexao sao refeitas; timeouts nunca, para nao duplicar
296
+ # cobrancas e envios ja processados pelo gateway.
297
+ def retry_error?(error, attempt)
298
+ return false unless error.is_a?(Errors::NetworkError) && !error.is_a?(Errors::TimeoutError)
299
+
300
+ !@retry_config.nil? && attempt + 1 < max_attempts
301
+ end
302
+
303
+ def retry_status?(status, attempt)
304
+ return false if @retry_config.nil?
305
+ return false unless Array(@retry_config[:retry_on_statuses]).include?(status)
306
+
307
+ attempt + 1 < max_attempts
308
+ end
309
+
310
+ def suggested_delay(error, attempt)
311
+ if error.is_a?(Errors::RateLimitError) && !error.retry_after_ms.nil?
312
+ error.retry_after_ms
313
+ else
314
+ Retry.backoff_delay_ms(attempt, @retry_config)
315
+ end
316
+ end
317
+
318
+ # Espera antes da proxima tentativa e devolve o numero dela.
319
+ #
320
+ # @return [Integer]
321
+ def wait_and_bump(attempt, transport_request, reason, delay_ms = nil)
322
+ delay_ms ||= Retry.backoff_delay_ms(attempt, @retry_config)
323
+ next_attempt = attempt + 1
324
+
325
+ fire_hook(:on_retry, method: transport_request.method, url: transport_request.url,
326
+ attempt: next_attempt, delay_ms: delay_ms, reason: reason)
327
+ Retry.sleep_ms(delay_ms.to_i)
328
+
329
+ next_attempt
330
+ end
331
+
332
+ def fire_hook(name, **info)
333
+ hook = @hooks[name]
334
+ hook.call(info) if hook.respond_to?(:call)
335
+ end
336
+
337
+ def monotonic_ms
338
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000
339
+ end
340
+ end
341
+ end
342
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApiBrasil
4
+ module Core
5
+ # Politica de retry do cliente. Por padrao a SDK tenta novamente apenas
6
+ # em HTTP 429 (rate limit) e em falhas de conexao - nunca em timeouts ou
7
+ # erros de negocio, para nao duplicar cobrancas/envios.
8
+ module Retry
9
+ # Configuracao padrao:
10
+ # - `retries`: novas tentativas alem da original
11
+ # - `min_delay_ms`: atraso base do backoff exponencial
12
+ # - `max_delay_ms`: teto do atraso entre tentativas
13
+ # - `retry_on_statuses`: status HTTP que disparam retry
14
+ DEFAULT = {
15
+ retries: 2,
16
+ min_delay_ms: 300,
17
+ max_delay_ms: 5000,
18
+ retry_on_statuses: [429].freeze
19
+ }.freeze
20
+
21
+ module_function
22
+
23
+ # Normaliza a configuracao de retry. `false` (ou `nil` explicito via
24
+ # `retry: false`) desativa completamente.
25
+ #
26
+ # @param config [Hash, false, nil]
27
+ # @return [Hash, nil]
28
+ def resolve(config)
29
+ return nil if config == false
30
+ return DEFAULT.dup unless config.is_a?(Hash)
31
+
32
+ normalized = config.each_with_object({}) do |(key, value), acc|
33
+ next if value.nil?
34
+
35
+ acc[normalize_key(key)] = value
36
+ end
37
+
38
+ DEFAULT.merge(normalized)
39
+ end
40
+
41
+ # Backoff exponencial com jitter: min * 2^attempt, limitado a max.
42
+ #
43
+ # @param attempt [Integer]
44
+ # @param retry_config [Hash]
45
+ # @return [Integer] atraso em milissegundos
46
+ def backoff_delay_ms(attempt, retry_config)
47
+ exponential = retry_config[:min_delay_ms] * (2**attempt)
48
+ jitter = 0.5 + (Kernel.rand * 0.5)
49
+
50
+ [retry_config[:max_delay_ms], (exponential * jitter).round].min
51
+ end
52
+
53
+ # Pausa a execucao por `ms` milissegundos.
54
+ def sleep_ms(milliseconds)
55
+ Kernel.sleep(milliseconds / 1000.0) if milliseconds.positive?
56
+ end
57
+
58
+ # Aceita `minDelayMs` (paridade com as outras SDKs) e `min_delay_ms`.
59
+ #
60
+ # @return [Symbol]
61
+ def normalize_key(key)
62
+ key.to_s
63
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
64
+ .downcase
65
+ .to_sym
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApiBrasil
4
+ module Core
5
+ module Transport
6
+ # Transporte opcional baseado no Faraday. Usado automaticamente quando o
7
+ # `faraday` ja esta carregado no processo; tambem pode ser injetado:
8
+ #
9
+ # require "faraday"
10
+ # ApiBrasil.new(transport: ApiBrasil::Core::Transport::FaradayTransport.new)
11
+ class FaradayTransport < Base
12
+ # @return [Boolean] `true` quando o Faraday esta disponivel.
13
+ def self.available?
14
+ defined?(::Faraday) ? true : false
15
+ end
16
+
17
+ # @param connection [Faraday::Connection, nil] conexao pronta (proxies, middlewares...)
18
+ def initialize(connection = nil)
19
+ super()
20
+
21
+ unless self.class.available?
22
+ raise Errors::NetworkError, "Faraday nao esta carregado. Adicione `require \"faraday\"` ou " \
23
+ "use ApiBrasil::Core::Transport::NetHttpTransport."
24
+ end
25
+
26
+ @connection = connection || ::Faraday.new
27
+ end
28
+
29
+ # @param request [Request]
30
+ # @return [Response]
31
+ def request(request)
32
+ response = @connection.run_request(
33
+ request.method.downcase.to_sym,
34
+ request.url,
35
+ request.body,
36
+ request.headers
37
+ ) do |req|
38
+ apply_timeout(req, request.timeout_ms)
39
+ end
40
+
41
+ Response.new(
42
+ response.status,
43
+ normalize_headers(response.headers),
44
+ Transport.decode_body(request, response.body)
45
+ )
46
+ rescue ::Faraday::TimeoutError => e
47
+ raise Errors::TimeoutError.new(
48
+ "Tempo limite de #{request.timeout_ms.to_i}ms excedido em #{request.method} #{request.url}.",
49
+ previous: e
50
+ )
51
+ rescue ::Faraday::Error => e
52
+ raise Errors::NetworkError.new(
53
+ "Falha de rede em #{request.method} #{request.url}: #{e.message}",
54
+ previous: e
55
+ )
56
+ end
57
+
58
+ private
59
+
60
+ def apply_timeout(req, timeout_ms)
61
+ return unless timeout_ms.to_i.positive?
62
+
63
+ seconds = timeout_ms.to_f / 1000
64
+ req.options.timeout = seconds
65
+ req.options.open_timeout = seconds
66
+ end
67
+
68
+ def normalize_headers(headers)
69
+ (headers || {}).each_with_object({}) do |(name, value), acc|
70
+ acc[name.to_s.downcase] = value.is_a?(Array) ? value.join(", ") : value.to_s
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end