lib/plaid/client.ex

defmodule Plaid.Client do
  @moduledoc """
  Make API calls to plaid and convert the responses from JSON -> well typed elixir structs.

  To use a different HTTP client, create a new module like `MyApp.PlaidClient` which implements
  `post/3` and implements the `@behaviour Plaid.Client` behaviour. The success response of those functions must return a `:body` key with a JSON string value
  and a `:status_code` key with an integer HTTP status. For an example, see the `Plaid.Client.HTTPoison` module.

  > For network errors where you don't get a body or status code, you may return an error tuple
  > with any error value, but the error value is not currently utilized.
  """

  require Logger

  alias Plaid.Castable

  @doc """
  Callback to POST the data to the Plaid API.

  Will be called with the full URL, payload, and headers. Simply take these values
  execute the HTTP request.

  > `headers` passed in will be a list of two item tuples where the first item is the header key
  > and the second is the value. e.g. `[{"content-type", "application/json"}]`

  ## Examples

      iex> post("https://production.plaid.com/categories/get", ~s<{"thing": "stuff"}>, [{"content-type", "application/json"}])
      {:ok, %{body: ~s<{"foo": "bar"}>, status_code: 200}}

  """
  @callback post(url :: String.t(), payload :: String.t(), headers :: [{String.t(), String.t()}]) ::
              {:ok, %{body: String.t(), status_code: integer()}} | {:error, any()}

  @doc """
  Make a Plaid API call.

  Takes in everything needed to complete the request and
  return a well formed struct of the response.

  ## Examples

      call(
        "/categories/get",
        %{},
        Plaid.Categories.GetResponse,
        client_id: "123",
        secret: "abc"
      )
      {:ok, %Plaid.Categories.GetResponse{}}

  """
  @spec call(String.t(), map(), module(), Plaid.config()) ::
          {:ok, any()} | {:error, Plaid.Error.t()}
  def call(endpoint, payload \\ %{}, castable_module, config) do
    url = build_url(config, endpoint)

    payload =
      payload
      |> add_auth(config)
      |> Jason.encode!()

    headers = [{"content-type", "application/json"}]

    http_client = Keyword.get(config, :http_client, Plaid.Client.HTTPoison)

    case http_client.post(url, payload, headers) do
      {:ok, %{body: body, status_code: status_code}} when status_code in 200..299 ->
        {:ok, cast_body(body, castable_module)}

      {:ok, %{body: body}} ->
        {:error, cast_body(body, Plaid.Error)}

      {:error, _error} ->
        {:error, Castable.cast(Plaid.Error, %{})}
    end
  end

  @spec build_url(Plaid.config(), String.t()) :: String.t()
  defp build_url(config, endpoint) do
    test_api_host = Keyword.get(config, :test_api_host)

    if is_binary(test_api_host) do
      test_api_host <> endpoint
    else
      env = Keyword.get(config, :env, :sandbox)
      "https://#{env}.plaid.com#{endpoint}"
    end
  end

  @spec add_auth(map(), Plaid.config()) :: map()
  defp add_auth(payload, config) do
    auth =
      config
      |> Map.new()
      |> Map.take([:client_id, :secret])

    Map.merge(payload, auth)
  end

  @spec cast_body(String.t(), module() | :raw) :: String.t() | %{optional(any) => any}
  defp cast_body(body, :raw) do
    body
  end

  defp cast_body(json_body, castable_module) do
    case Jason.decode(json_body) do
      {:ok, generic_map} -> Castable.cast(castable_module, generic_map)
      _ -> Castable.cast(Plaid.Error, %{})
    end
  end
end