lib/plaid/client.ex

defmodule Plaid.Client do
  @moduledoc """
  Functions to build a Tesla client for handling HTTP requests.
  """

  defmodule Request do
    @moduledoc """
    Data structure for an HTTP request with convenience functions.
    """

    defstruct body: %{}, endpoint: nil, method: nil, opts: %{}
    @type t :: %__MODULE__{body: map, endpoint: String.t(), method: atom, opts: map}

    @doc """
    Convert `Request` to `options` format passed to `Tesla.request/2`.
    """
    @spec to_options(Request.t()) :: keyword
    def to_options(%Request{body: b, endpoint: e, method: m, opts: o}) do
      [method: m, url: e, body: b, opts: Map.to_list(o)]
    end

    @doc """
    Add telemetry metadata to `Request`.

    Calling without the second argument adds default metadata. Custom metadata
    is added by passing a map with a key `telemetry_metadata`.

    Example
    ```
    Request.add_metadata(request, %{telemetry_metadata: %{ins_id: "ins_1"}})
    ```
    """
    @spec add_metadata(Request.t()) :: Request.t()
    @spec add_metadata(Request.t(), map) :: Request.t()
    def add_metadata(%Request{endpoint: e, method: m, opts: o} = request, config \\ %{}) do
      metadata =
        Map.new()
        |> Map.put(:method, m)
        |> Map.put(:path, e)
        |> Map.put(:u, :native)
        |> Map.merge(config[:telemetry_metadata] || %{})

      %{request | opts: Map.put(o, :metadata, metadata)}
    end
  end

  @doc """
  Creates a new Tesla client.

  Optional `config` argument sets the following values at runtime:
  - base url (aka root uri)
  - client_id
  - secret
  - `Tesla.Adapter`
  - `Tesla.Middleware`
  - HTTP options for selected `Tesla.Adapter`

  Example
  ```
  config = %{
    root_uri: "https://sandbox.plaid.com/",
    client_id: "my-client-id",
    secret: "shhhh",
    adapter: Tesla.Adapter.Httpc,
    middleware: [Tesla.Middleware.Logger],
    http_options: [recv_timeout: 10_000]
  }

  client = Client.new(config)
  ```
  """
  @spec new(map) :: Tesla.Client.t()
  def new(config \\ %{}) do
    middleware =
      [
        {Tesla.Middleware.BaseUrl, get_base_url(config)},
        {Tesla.Middleware.Headers,
         [
           {"Content-Type", "application/json"},
           {"user-agent", "Elixir-SDK"},
           {"Plaid-Version", "2020-09-14"},
           {"PLAID-CLIENT-ID", get_client_id(config)},
           {"PLAID-SECRET", get_secret(config)}
         ]},
        Tesla.Middleware.JSON,
        {Tesla.Middleware.Telemetry, get_metadata(config)}
      ] ++ get_middleware(config)

    adapter = {get_adapter(config), get_http_options(config)}

    Tesla.client(middleware, adapter)
  end

  defp get_base_url(config) do
    case config[:root_uri] || Application.get_env(:plaid, :root_uri) do
      nil ->
        raise Plaid.MissingRootUriError

      root_uri ->
        root_uri
    end
  end

  defp get_client_id(config) do
    case config[:client_id] || Application.get_env(:plaid, :client_id) do
      nil ->
        raise Plaid.MissingClientIdError

      client_id ->
        client_id
    end
  end

  defp get_secret(config) do
    case config[:secret] || Application.get_env(:plaid, :secret) do
      nil ->
        raise Plaid.MissingSecretError

      secret ->
        secret
    end
  end

  defp get_middleware(config) do
    case config[:middleware] || Application.get_env(:plaid, :middleware) || [] do
      middleware when is_list(middleware) ->
        middleware

      m ->
        [m]
    end
  end

  defp get_metadata(config) do
    default_metadata = %{
      u: :native,
      service: :plaid
    }

    metadata = Map.merge(default_metadata, config[:telemetry_metadata] || %{})

    %{metadata: metadata}
  end

  defp get_adapter(config) do
    config[:adapter] || Application.get_env(:plaid, :adapter) || Tesla.Adapter.Hackney
  end

  defp get_http_options(config) do
    Keyword.merge(
      Application.get_env(:plaid, :http_options, []),
      config[:http_options] || []
    )
  end
end