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