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