defmodule ApiBrasil.Core.Transport do @moduledoc """ Camada de transporte HTTP plugável. A implementação padrão é `ApiBrasil.Core.Transport.Httpc` (`:httpc`, do Erlang/OTP — sem dependências). Injete a sua para usar proxies corporativos, instrumentação, Finch/Req/Tesla ou mocks de teste. Um transporte pode ser: - um **módulo** que implementa este behaviour; - uma tupla **`{módulo, opções}`**, com as opções repassadas em cada chamada; - uma **função** de aridade 1, que recebe a `t:request/0` e devolve `{:ok, resposta}` ou `{:error, erro}` — o atalho para testes. ## Contrato Devolve `{:ok, %Response{}}` para **qualquer** status HTTP; devolve `{:error, %ApiBrasil.Core.Error{}}` (`:network` ou `:timeout`) apenas quando não houve resposta. defmodule MeuTransporte do @behaviour ApiBrasil.Core.Transport alias ApiBrasil.Core.Transport.Response @impl true def request(%ApiBrasil.Core.Transport.Request{} = request, _opts) do {:ok, Response.json(200, %{"ok" => true, "url" => request.url})} end end ApiBrasil.new(transport: MeuTransporte) """ alias ApiBrasil.Core.{Error, JSON} defmodule Request do @moduledoc "Requisição entregue à camada de transporte." alias ApiBrasil.Core.JSON @type t :: %__MODULE__{ method: ApiBrasil.Core.Transport.method(), url: String.t(), headers: %{String.t() => String.t()}, body: binary() | nil, timeout: non_neg_integer() | nil, response_type: :json | :binary } defstruct method: :get, url: "", headers: %{}, body: nil, timeout: nil, response_type: :json @doc "Devolve o body decodificado como JSON, quando houver." @spec json_body(t()) :: term() def json_body(%__MODULE__{body: nil}), do: nil def json_body(%__MODULE__{body: body}) do case JSON.decode(body) do {:ok, value} -> value {:error, _reason} -> nil end end end defmodule Response do @moduledoc "Resposta devolvida pela camada de transporte." alias ApiBrasil.Core.JSON @type t :: %__MODULE__{ status: pos_integer(), headers: %{String.t() => String.t()}, data: term(), body: binary() } defstruct status: 200, headers: %{}, data: nil, body: "" @doc "Monta uma resposta com status e corpo JSON — atalho para testes." @spec json(pos_integer(), term(), map()) :: t() def json(status, data, headers \\ %{}) do %__MODULE__{ status: status, headers: headers, data: data, body: JSON.encode!(data) } end @doc "Adiciona um header à resposta." @spec put_header(t(), String.t(), String.t()) :: t() def put_header(%__MODULE__{} = response, name, value) do %{response | headers: Map.put(response.headers, String.downcase(name), value)} end end @typedoc "Verbo HTTP usado pelo gateway." @type method :: :get | :post | :put | :patch | :delete @typedoc "Transporte aceito pela configuração." @type t :: module() | {module(), keyword()} | (Request.t() -> {:ok, Response.t()} | {:error, Error.t()}) @doc "Executa a requisição HTTP." @callback request(Request.t(), keyword()) :: {:ok, Response.t()} | {:error, Error.t()} @doc "Verbos HTTP suportados." @spec methods() :: [method()] def methods, do: [:get, :post, :put, :patch, :delete] @doc "Devolve o verbo em maiúsculas — `GET`, `POST`, `PUT`, `PATCH` ou `DELETE`." @spec method_to_string(method()) :: String.t() def method_to_string(method), do: method |> Atom.to_string() |> String.upcase() @doc """ Despacha a requisição para o transporte configurado — módulo, `{módulo, opções}` ou função de aridade 1. """ @spec call(t(), Request.t()) :: {:ok, Response.t()} | {:error, Error.t()} def call(transport, %Request{} = request) when is_function(transport, 1) do transport.(request) end def call({module, opts}, %Request{} = request) when is_atom(module) and is_list(opts) do module.request(request, opts) end def call(module, %Request{} = request) when is_atom(module) do module.request(request, []) end @doc """ Decodifica o corpo cru conforme o tipo de resposta: JSON vira mapa/lista, corpos não-JSON viram texto e `:binary` devolve os próprios bytes. """ @spec decode_body(binary(), :json | :binary) :: term() def decode_body(raw, :binary), do: raw def decode_body("", :json), do: nil def decode_body(raw, :json) do case JSON.decode(raw) do {:ok, value} -> value {:error, _reason} -> raw end end end