lib/flowmailer/token.ex

defmodule FlowMailer.Token do
  @moduledoc """
  Token manager for FlowMailer service.

  It is a GenServer that manages FlowMailer authorization tokens, supports multiple accounts.
  """
  use GenServer
  alias FlowMailer.Token

  import FlowMailer.Shared

  @type t :: %__MODULE__{
    updated_at: DateTime.t(),
    access_token: AccessToken.t()
  }

  defstruct updated_at: nil,
            access_token: nil

  defmodule AccessToken do
    @moduledoc """
    FlowMailer Access Token structure
    """
    @type t :: %__MODULE__{
            access_token: String.t(),
            token_type: String.t(),
            expires_in: non_neg_integer,
            scope: String.t()
          }
    defstruct [
      :access_token,
      :token_type,
      :expires_in,
      :scope
    ]
  end

  @client_id_key :flowmailer_client_id
  @account_id_key :flowmailer_account_id
  @client_secret_key :flowmailer_client_secret

  @auth_url "https://login.flowmailer.net/oauth/token"

  def get(config) do
    GenServer.call(__MODULE__, {:get_token, config})
  end

  def handle_config(config) do
    validate_params(config)
    GenServer.cast(__MODULE__, {:configure, config})
  end

  @impl true
  def init(_opts) do
    {:ok, %{}}
  end

  def start_link(_opts) do
    GenServer.start_link(__MODULE__, [], name: __MODULE__)
  end

  @impl true
  def handle_cast({:configure, %{flowmailer_client_id: client_id}}, state) do
    new_state = Map.put_new(state, client_id, %Token{})
    {:noreply, new_state}
  end

  @impl true
  def handle_call({:get_token, config}, _from, state) do
    client_id = get_config_value(config, @client_id_key)

    result =
      case Map.get(state, client_id) do
        nil ->
          add_new_token(client_id, state, config)

        %Token{updated_at: nil} ->
          do_refresh_token(client_id, state, config)

        %Token{} ->
          maybe_refresh_token(client_id, state, config)
      end

    case result do
      {:ok, state} ->
        %Token{access_token: access_token} = Map.get(state, client_id)
        %AccessToken{access_token: token_value} = access_token
        {:reply, {:ok, token_value}, state}

      {:error, error, state} ->
        {:reply, {:error, error}, state}
    end
  end

  defp add_new_token(client_id, state, config) do
    do_refresh_token(client_id, state, config)
  end

  defp maybe_refresh_token(client_id, state, config) do
    %Token{updated_at: updated_at, access_token: access_token = %AccessToken{}} =
      Map.get(state, client_id)

    expire_ts = DateTime.add(updated_at, access_token.expires_in - 2, :second)
    expires? = DateTime.diff(expire_ts, DateTime.utc_now()) < 0

    if expires? do
      {result, state} = do_refresh_token(client_id, state, config)
      {result, state}
    else
      {:ok, state}
    end
  end

  defp do_refresh_token(client_id, state, config) do
    case fetch_token(config) do
      {:ok, token_data} ->
        new_state =
          Map.put(state, client_id, %Token{
            updated_at: DateTime.utc_now(),
            access_token: token_to_struct(token_data)
          })

        {:ok, new_state}

      {:error, error} ->
        {:error, error, state}
    end
  end

  defp token_to_struct(data) do
    %AccessToken{
      access_token: data["access_token"],
      expires_in: data["expires_in"],
      scope: data["scope"],
      token_type: data["token_type"]
    }
  end

  defp fetch_token(config) do
    client_id = get_config_value(config, @client_id_key)
    client_secret = get_config_value(config, @client_secret_key)

    body =
      URI.encode_query(%{
        "client_id" => client_id,
        "client_secret" => client_secret,
        "grant_type" => "client_credentials",
        "scope" => "api"
      })

    headers = [
      {"content-type", "application/x-www-form-urlencoded"}
    ]

    case :hackney.post(@auth_url, headers, body, hackney_opts(config)) do
      {:ok, code, _headers, response} when code < 299 ->
        body = response |> Bamboo.json_library().decode!()
        {:ok, body}

      {:ok, code, _, response} ->
        {:error, %{code: code, response: response}}

      error ->
        {:error, error}
    end
  rescue
    r ->
      {:error, "Error on fetching FlowMailer token: #{inspect(r)}"}
  end

  defp validate_param(config, param) do
    _value = get_config_value(config, param)
    config
  end

  defp validate_params(config) do
    config
    |> validate_param(@client_id_key)
    |> validate_param(@account_id_key)
    |> validate_param(@client_secret_key)
  end
end