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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +27 -0
- data/LICENSE +21 -0
- data/README.md +330 -0
- data/SECURITY.md +32 -0
- data/lib/api_brasil/client.rb +252 -0
- data/lib/api_brasil/core/env.rb +34 -0
- data/lib/api_brasil/core/errors.rb +167 -0
- data/lib/api_brasil/core/http_client.rb +342 -0
- data/lib/api_brasil/core/retry.rb +69 -0
- data/lib/api_brasil/core/transport/faraday_transport.rb +76 -0
- data/lib/api_brasil/core/transport/net_http_transport.rb +126 -0
- data/lib/api_brasil/core/transport.rb +114 -0
- data/lib/api_brasil/generated/catalog.rb +526 -0
- data/lib/api_brasil/legacy/service.rb +155 -0
- data/lib/api_brasil/services/base_service.rb +49 -0
- data/lib/api_brasil/services/data/bulk_service.rb +26 -0
- data/lib/api_brasil/services/data/cep_service.rb +58 -0
- data/lib/api_brasil/services/data/chip_virtual_service.rb +40 -0
- data/lib/api_brasil/services/data/consulta_service.rb +97 -0
- data/lib/api_brasil/services/data/correios_service.rb +23 -0
- data/lib/api_brasil/services/data/dados_service.rb +30 -0
- data/lib/api_brasil/services/data/database_ip_service.rb +19 -0
- data/lib/api_brasil/services/data/fipe_service.rb +61 -0
- data/lib/api_brasil/services/data/proxies.rb +146 -0
- data/lib/api_brasil/services/data/ura_service.rb +26 -0
- data/lib/api_brasil/services/data/vehicles_service.rb +46 -0
- data/lib/api_brasil/services/device_proxy_service.rb +46 -0
- data/lib/api_brasil/services/messaging/evolution_service.rb +38 -0
- data/lib/api_brasil/services/messaging/sms_service.rb +40 -0
- data/lib/api_brasil/services/messaging/whats_meow_service.rb +30 -0
- data/lib/api_brasil/services/messaging/whatsapp_service.rb +120 -0
- data/lib/api_brasil/services/platform/account_service.rb +138 -0
- data/lib/api_brasil/services/platform/auth_service.rb +204 -0
- data/lib/api_brasil/services/platform/bearer_rate_limit_service.rb +29 -0
- data/lib/api_brasil/services/platform/catalog_service.rb +111 -0
- data/lib/api_brasil/services/platform/devices_service.rb +73 -0
- data/lib/api_brasil/services/platform/ip_whitelist_service.rb +73 -0
- data/lib/api_brasil/services/platform/payments_service.rb +124 -0
- data/lib/api_brasil/services/platform/reports_service.rb +96 -0
- data/lib/api_brasil/version.rb +6 -0
- data/lib/api_brasil.rb +72 -0
- data/lib/apigratis-sdk-ruby.rb +5 -0
- data/lib/apigratis.rb +11 -0
- 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
|