Current section
Files
Jump to
Current section
Files
lib/api_brasil/core/transport/httpc.ex
defmodule ApiBrasil.Core.Transport.Httpc do
@moduledoc """
Transporte padrão da SDK, sobre o `:httpc` do Erlang/OTP — sem
dependências externas.
TLS é verificado de verdade: `verify_peer` com as CAs do sistema
(`:public_key.cacerts_get/0`, OTP 25+) e checagem de hostname. Se o
sistema não expuser um repositório de CAs, a SDK usa o
[CAStore](https://hex.pm/packages/castore) quando ele estiver no projeto.
## Opções
ApiBrasil.new(
transport: {ApiBrasil.Core.Transport.Httpc,
profile: :default,
ssl: [verify: :verify_peer, cacertfile: ~c"/etc/ssl/certs/ca-certificates.crt"],
http_options: [autoredirect: false],
connect_timeout: 10_000
}
)
"""
@behaviour ApiBrasil.Core.Transport
alias ApiBrasil.Core.{Error, Transport}
alias ApiBrasil.Core.Transport.{Request, Response}
@default_connect_timeout 15_000
@impl true
def request(%Request{} = request, opts) do
with :ok <- ensure_started() do
profile = Keyword.get(opts, :profile, :default)
url = String.to_charlist(request.url)
headers = httpc_headers(request.headers)
httpc_request = build_request(request, url, headers)
http_options = http_options(request, opts)
request_options = [body_format: :binary]
case :httpc.request(request.method, httpc_request, http_options, request_options, profile) do
{:ok, {{_version, status, _reason}, response_headers, body}} ->
{:ok, response(status, response_headers, body, request.response_type)}
{:ok, {status, body}} when is_integer(status) ->
{:ok, response(status, [], body, request.response_type)}
{:error, reason} ->
{:error, transport_error(reason, request)}
end
end
end
defp build_request(%Request{method: method} = request, url, headers)
when method in [:post, :put, :patch, :delete] do
{content_type, headers} = pop_content_type(headers)
{url, headers, content_type, request.body || ""}
end
defp build_request(_request, url, headers), do: {url, headers}
defp http_options(%Request{} = request, opts) do
extra = Keyword.get(opts, :http_options, [])
base = [
timeout: request.timeout || :infinity,
connect_timeout: Keyword.get(opts, :connect_timeout, @default_connect_timeout)
]
base =
if String.starts_with?(request.url, "https") do
Keyword.put(base, :ssl, Keyword.get(opts, :ssl) || ssl_options(request.url))
else
base
end
Keyword.merge(base, extra)
end
defp response(status, headers, body, response_type) do
body = IO.iodata_to_binary(body)
%Response{
status: status,
headers: response_headers(headers),
data: Transport.decode_body(body, response_type),
body: body
}
end
defp response_headers(headers) do
Map.new(headers, fn {name, value} ->
{name |> to_string() |> String.downcase(), to_string(value)}
end)
end
defp httpc_headers(headers) do
Enum.map(headers, fn {name, value} ->
{String.to_charlist(name), String.to_charlist(value)}
end)
end
defp pop_content_type(headers) do
case Enum.split_with(headers, fn {name, _value} ->
name |> to_string() |> String.downcase() == "content-type"
end) do
{[{_name, value} | _rest], others} -> {value, others}
{[], others} -> {~c"application/json", others}
end
end
defp transport_error(:timeout, request), do: timeout_error(request, :timeout)
defp transport_error({:failed_connect, details} = reason, request) do
if timeout_details?(details) do
timeout_error(request, reason)
else
network_error(request, reason)
end
end
defp transport_error(reason, request), do: network_error(request, reason)
defp timeout_details?(details) when is_list(details) do
Enum.any?(details, fn
{:inet, _families, :timeout} -> true
{_key, :timeout} -> true
_ -> false
end)
end
defp timeout_details?(_details), do: false
defp timeout_error(request, reason) do
Error.timeout("Tempo limite excedido em #{target(request)}: #{inspect(reason)}",
reason: reason
)
end
defp network_error(request, reason) do
Error.network("Falha de rede em #{target(request)}: #{inspect(reason)}", reason: reason)
end
defp target(%Request{method: method, url: url}) do
"#{Transport.method_to_string(method)} #{url}"
end
defp ensure_started do
case Application.ensure_all_started(:inets) do
{:ok, _apps} ->
case Application.ensure_all_started(:ssl) do
{:ok, _apps} ->
:ok
{:error, reason} ->
{:error, Error.network("Não foi possível iniciar o :ssl: #{inspect(reason)}")}
end
{:error, reason} ->
{:error, Error.network("Não foi possível iniciar o :inets: #{inspect(reason)}")}
end
end
@doc """
Opções TLS usadas por padrão: `verify_peer`, CAs do sistema e verificação
de hostname.
"""
@spec ssl_options(String.t()) :: keyword()
def ssl_options(url) do
host = url |> URI.parse() |> Map.get(:host) |> to_string() |> String.to_charlist()
[
verify: :verify_peer,
depth: 3,
server_name_indication: host,
customize_hostname_check: [
match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
]
] ++ cacerts()
end
defp cacerts do
case :persistent_term.get({__MODULE__, :cacerts}, nil) do
nil ->
resolved = resolve_cacerts()
:persistent_term.put({__MODULE__, :cacerts}, resolved)
resolved
resolved ->
resolved
end
end
defp resolve_cacerts do
[cacerts: :public_key.cacerts_get()]
rescue
_error -> castore_or_raise()
catch
_kind, _reason -> castore_or_raise()
end
defp castore_or_raise do
if Code.ensure_loaded?(CAStore) do
[cacertfile: String.to_charlist(apply(CAStore, :file_path, []))]
else
raise ArgumentError, """
Não foi possível carregar as CAs do sistema para validar o TLS.
Adicione o CAStore ao seu projeto:
{:castore, "~> 1.0"}
ou informe as CAs manualmente:
ApiBrasil.new(
transport: {ApiBrasil.Core.Transport.Httpc,
ssl: [verify: :verify_peer, cacertfile: ~c"/caminho/ca.pem"]}
)
"""
end
end
end