Packages

SDK oficial Elixir da plataforma APIBrasil: WhatsApp, SMS, consultas de CPF/CNPJ, veiculos, CEP, correios, pagamentos PIX/boleto e mais.

Current section

Files

Jump to
apibrasil lib api_brasil core http.ex
Raw

lib/api_brasil/core/http.ex

defmodule ApiBrasil.Core.HTTP do
@moduledoc """
Pipeline HTTP interno da SDK.
Injeta os headers de autenticação da plataforma (`Authorization: Bearer`,
`DeviceToken`, `SecretKey`), aplica retry com backoff, dispara os hooks de
observabilidade e converte falhas em `ApiBrasil.Core.Error`.
## Opções por requisição
Todas as funções de serviço aceitam uma keyword list final com:
- `:query` — query string, um mapa ou keyword (valores `nil` são ignorados);
- `:headers` — headers extras desta requisição;
- `:bearer_token` — Bearer Token só desta requisição;
- `:device_token` — DeviceToken só desta requisição;
- `:secret_key` — SecretKey desta requisição (header `SecretKey`);
- `:timeout` — tempo limite desta requisição, em ms;
- `:response_type``:json` (padrão) ou `:binary` para baixar bytes crus.
"""
alias ApiBrasil.Client
alias ApiBrasil.Core.{Error, Hooks, JSON, Retry, Transport, Utils}
alias ApiBrasil.Core.Transport.Request
@user_agent "APIBRASIL/SDK-ELIXIR"
@doc "Identificação da SDK enviada em `User-Agent`."
@spec user_agent() :: String.t()
def user_agent, do: @user_agent
@doc "Monta a URL completa de um caminho."
@spec url(Client.t(), String.t()) :: String.t()
def url(%Client{base_url: base_url}, path), do: Utils.join_url(base_url, path)
@doc """
Executa uma requisição e devolve o corpo já decodificado, sem normalizar
em objeto JSON.
"""
@spec execute(Client.t(), Transport.method(), String.t(), term(), keyword()) ::
{:ok, term()} | {:error, Error.t()}
def execute(%Client{} = client, method, path, body \\ nil, opts \\ []) do
opts = merge_options(client.options, opts)
body = Utils.to_body(body)
with {:ok, payload} <- encode_body(body) do
request = %Request{
method: method,
url: url(client, path) <> Utils.build_query_string(opts[:query]),
headers: build_headers(client, opts),
body: payload,
timeout: opts[:timeout] || client.timeout,
response_type: opts[:response_type] || :json
}
run(client, request, body, 0)
end
end
@doc "Executa a requisição e devolve o corpo normalizado em um mapa JSON."
@spec request_json(Client.t(), Transport.method(), String.t(), term(), keyword()) ::
{:ok, map()} | {:error, Error.t()}
def request_json(%Client{} = client, method, path, body \\ nil, opts \\ []) do
with {:ok, data} <- execute(client, method, path, body, opts) do
{:ok, Utils.to_json_object(data)}
end
end
@doc "`GET path`."
@spec get(Client.t(), String.t(), keyword()) :: {:ok, map()} | {:error, Error.t()}
def get(client, path, opts \\ []), do: request_json(client, :get, path, nil, opts)
@doc "`POST path`."
@spec post(Client.t(), String.t(), term(), keyword()) :: {:ok, map()} | {:error, Error.t()}
def post(client, path, body \\ nil, opts \\ []),
do: request_json(client, :post, path, body, opts)
@doc "`PUT path`."
@spec put(Client.t(), String.t(), term(), keyword()) :: {:ok, map()} | {:error, Error.t()}
def put(client, path, body \\ nil, opts \\ []), do: request_json(client, :put, path, body, opts)
@doc "`PATCH path`."
@spec patch(Client.t(), String.t(), term(), keyword()) :: {:ok, map()} | {:error, Error.t()}
def patch(client, path, body \\ nil, opts \\ []),
do: request_json(client, :patch, path, body, opts)
@doc "`DELETE path`."
@spec delete(Client.t(), String.t(), term(), keyword()) :: {:ok, map()} | {:error, Error.t()}
def delete(client, path, body \\ nil, opts \\ []),
do: request_json(client, :delete, path, body, opts)
@doc "Baixa o corpo cru de uma rota (PDF de boleto, imagens...)."
@spec bytes(Client.t(), Transport.method(), String.t(), term(), keyword()) ::
{:ok, binary()} | {:error, Error.t()}
def bytes(client, method, path, body \\ nil, opts \\ []) do
with {:ok, data} <-
execute(client, method, path, body, Keyword.put(opts, :response_type, :binary)) do
{:ok, to_binary(data)}
end
end
@doc """
Mescla as opções padrão do cliente com as da chamada — headers e query são
mesclados, os demais campos são sobrescritos.
"""
@spec merge_options(keyword(), keyword()) :: keyword()
def merge_options(defaults, opts) do
defaults = defaults || []
opts = opts || []
headers =
Utils.merge_headers(
Utils.normalize_headers(defaults[:headers]),
Utils.normalize_headers(opts[:headers])
)
query = merge_query(defaults[:query], opts[:query])
defaults
|> Keyword.merge(opts)
|> Keyword.put(:headers, headers)
|> put_query(query)
end
defp put_query(opts, nil), do: Keyword.delete(opts, :query)
defp put_query(opts, query), do: Keyword.put(opts, :query, query)
defp merge_query(nil, nil), do: nil
defp merge_query(nil, query), do: normalize_query(query)
defp merge_query(query, nil), do: normalize_query(query)
defp merge_query(base, override) do
Map.merge(normalize_query(base), normalize_query(override))
end
defp normalize_query(query) when is_map(query), do: Utils.stringify_keys(query)
defp normalize_query(query) when is_list(query),
do: query |> Map.new() |> Utils.stringify_keys()
defp normalize_query(query), do: query
defp run(%Client{} = client, %Request{} = request, body, attempt) do
Hooks.notify(client.hooks, :request, %{
method: request.method,
url: request.url,
headers: request.headers,
body: body,
attempt: attempt
})
started_at = System.monotonic_time(:millisecond)
case Transport.call(client.transport, request) do
{:ok, response} ->
Hooks.notify(client.hooks, :response, %{
method: request.method,
url: request.url,
status: response.status,
duration: System.monotonic_time(:millisecond) - started_at,
attempt: attempt
})
handle_response(client, request, body, attempt, response)
{:error, %Error{} = error} ->
# Timeouts nunca são refeitos: a requisição pode ter sido processada
# e o retry duplicaria cobranças/envios.
if Error.network?(error) and not Error.timeout?(error) and retry?(client, attempt) do
retry(
client,
request,
body,
attempt,
Retry.backoff_delay(client.retry, attempt),
error.message
)
else
{:error, error}
end
end
end
defp handle_response(client, request, body, attempt, response) do
if response.status >= 400 do
error =
Error.from_api(
response.status,
Transport.decode_body(response.body, :json),
response.headers
)
if Retry.retries_status?(client.retry, response.status) and retry?(client, attempt) do
delay = error.retry_after || Retry.backoff_delay(client.retry, attempt)
retry(client, request, body, attempt, delay, "HTTP #{response.status}")
else
{:error, error}
end
else
{:ok, response.data}
end
end
defp retry?(%Client{retry: retry}, attempt), do: attempt + 1 < Retry.max_attempts(retry)
defp retry(client, request, body, attempt, delay, reason) do
next = attempt + 1
Hooks.notify(client.hooks, :retry, %{
method: request.method,
url: request.url,
attempt: next,
delay: delay,
reason: reason
})
Retry.sleep(delay)
run(client, request, body, next)
end
defp build_headers(%Client{} = client, opts) do
base = %{
"Content-Type" => "application/json",
"Accept" => "application/json",
"User-Agent" => @user_agent
}
auth =
%{}
|> maybe_put("Authorization", bearer(opts[:bearer_token] || client.bearer_token))
|> maybe_put("DeviceToken", opts[:device_token] || client.device_token)
|> maybe_put("SecretKey", opts[:secret_key])
base
|> Utils.merge_headers(client.headers)
|> Utils.merge_headers(auth)
|> Utils.merge_headers(Utils.normalize_headers(opts[:headers]))
end
defp bearer(nil), do: nil
defp bearer(""), do: nil
defp bearer(token), do: "Bearer " <> token
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, _key, ""), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
defp encode_body(nil), do: {:ok, nil}
defp encode_body(body) do
{:ok, JSON.encode!(body)}
rescue
error ->
{:error,
Error.validation(
"Não foi possível serializar o corpo da requisição: #{Exception.message(error)}",
reason: error
)}
end
defp to_binary(nil), do: ""
defp to_binary(data) when is_binary(data), do: data
defp to_binary(data), do: JSON.encode!(data)
end