Skip to main content

lib/wise/config.ex

defmodule Wise.Config do
  @moduledoc """
  Client configuration for the Wise Platform API.

  Build a config struct using `new/1` and pass it to every service call,
  or register it as a named config using `Wise.Application`.

  ## Required

  At least one of `:personal_token`, `:client_id`+`:client_secret`,
  or `:access_token` must be provided.

  ## Example

      config = Wise.Config.new!(
        personal_token: System.fetch_env!("WISE_API_TOKEN"),
        sandbox: true
      )

      {:ok, profiles} = Wise.Services.Profiles.list(config)
  """

  @production_url "https://api.wise.com"
  @sandbox_url "https://api.wise-sandbox.com"

  @type auth_mode :: :personal_token | :client_credentials | :user_token

  @type on_token_refresh ::
          (refresh_token :: String.t() ->
             {:ok,
              %{access_token: String.t(), refresh_token: String.t(), expires_at: DateTime.t()}}
             | {:error, term()})

  @type t :: %__MODULE__{
          base_url: String.t(),
          auth_mode: auth_mode(),
          personal_token: String.t() | nil,
          client_id: String.t() | nil,
          client_secret: String.t() | nil,
          access_token: String.t() | nil,
          refresh_token: String.t() | nil,
          token_expires_at: DateTime.t() | nil,
          on_token_refresh: on_token_refresh() | nil,
          timeout: pos_integer(),
          recv_timeout: pos_integer(),
          max_retries: non_neg_integer(),
          retry_base_delay: pos_integer(),
          retry_max_delay: pos_integer(),
          rate_limiter: GenServer.server() | nil,
          circuit_breaker: GenServer.server() | nil,
          request_hooks: [function()],
          response_hooks: [function()],
          user_agent: String.t()
        }

  defstruct [
    :base_url,
    :auth_mode,
    :personal_token,
    :client_id,
    :client_secret,
    :access_token,
    :refresh_token,
    :token_expires_at,
    :on_token_refresh,
    :rate_limiter,
    :circuit_breaker,
    timeout: 30_000,
    recv_timeout: 30_000,
    max_retries: 3,
    retry_base_delay: 500,
    retry_max_delay: 30_000,
    request_hooks: [],
    response_hooks: [],
    user_agent: "wise/1.0.0 (+https://github.com/iamkanishka/wise)"
  ]

  @doc """
  Creates a new config struct.

  Returns `{:ok, config}` or `{:error, reason}`.
  """
  @spec new(keyword()) :: {:ok, t()} | {:error, String.t()}
  def new(opts) do
    sandbox = Keyword.get(opts, :sandbox, false)
    custom_url = Keyword.get(opts, :base_url)

    base_url =
      cond do
        custom_url != nil -> String.trim_trailing(custom_url, "/")
        sandbox -> @sandbox_url
        true -> @production_url
      end

    config = struct!(__MODULE__, [base_url: base_url] ++ Keyword.delete(opts, :base_url))
    config = detect_auth_mode(config)

    with :ok <- validate(config) do
      {:ok, config}
    end
  end

  @doc "Like `new/1` but raises on invalid configuration."
  @spec new!(keyword()) :: t()
  def new!(opts) do
    case new(opts) do
      {:ok, config} -> config
      {:error, reason} -> raise ArgumentError, reason
    end
  end

  @spec detect_auth_mode(t()) :: t()
  defp detect_auth_mode(%{personal_token: tok} = cfg) when is_binary(tok) and tok != "" do
    %{cfg | auth_mode: :personal_token}
  end

  defp detect_auth_mode(%{client_id: id, client_secret: sec} = cfg)
       when is_binary(id) and is_binary(sec) do
    %{cfg | auth_mode: :client_credentials}
  end

  defp detect_auth_mode(%{access_token: tok} = cfg) when is_binary(tok) do
    %{cfg | auth_mode: :user_token}
  end

  defp detect_auth_mode(cfg), do: %{cfg | auth_mode: :personal_token}

  @spec validate(t()) :: :ok | {:error, String.t()}
  defp validate(%{auth_mode: :personal_token, personal_token: nil}) do
    {:error, "personal_token is required when using personal_token auth mode"}
  end

  defp validate(%{auth_mode: :client_credentials, client_id: nil}) do
    {:error, "client_id and client_secret are required for client_credentials auth"}
  end

  defp validate(%{auth_mode: :user_token, access_token: nil}) do
    {:error, "access_token is required for user_token auth mode"}
  end

  defp validate(_), do: :ok

  @doc "Returns the current Bearer token, refreshing if necessary."
  @spec current_token(t()) :: {:ok, String.t()} | {:error, Wise.Error.t()}
  def current_token(%{auth_mode: :personal_token, personal_token: tok}), do: {:ok, tok}

  def current_token(%{auth_mode: :user_token} = cfg) do
    if token_valid?(cfg.token_expires_at) do
      {:ok, cfg.access_token}
    else
      case cfg.on_token_refresh do
        nil ->
          {:ok, cfg.access_token}

        refresh_fn ->
          case refresh_fn.(cfg.refresh_token) do
            {:ok, %{access_token: tok}} -> {:ok, tok}
            {:error, _} = err -> err
          end
      end
    end
  end

  @spec current_token(t()) :: {:ok, String.t()} | {:error, atom()}
  def current_token(
        %{auth_mode: :client_credentials, access_token: tok, token_expires_at: exp} = _cfg
      )
      when is_binary(tok) do
    if token_valid?(exp), do: {:ok, tok}, else: {:error, :token_expired}
  end

  def current_token(_), do: {:error, :no_token}

  @spec token_valid?(DateTime.t() | nil) :: boolean()
  defp token_valid?(nil), do: false

  defp token_valid?(expires_at) do
    case DateTime.compare(DateTime.utc_now(), expires_at) do
      :lt -> true
      _ -> false
    end
  end
end