lib/toxiproxy_ex.ex

defmodule ToxiproxyEx do
  alias ToxiproxyEx.{Proxy, Client, Toxic, ToxicCollection}

  @external_resource "README.md"
  @moduledoc "README.md"
             |> File.read!()
             |> String.split("<!-- MDOC !-->")
             |> Enum.fetch!(1)

  @typedoc """
  A proxy that intercepts traffic to and from an upstream server.
  """
  @opaque proxy :: Proxy.t()

  @typedoc """
  A collection of proxies.
  """
  @opaque toxic_collection :: ToxicCollection.t()

  @typedoc """
  A hostname or IP address including a port number, e.g. `localhost:4539`.
  """
  @type host_with_port :: String.t()

  @typedoc """
  A map containing fields required to setup a proxy. Designed to be used with `ToxiproxyEx.populate!/1`.
  """
  @type proxy_map :: %{
          required(:name) => String.t(),
          required(:upstream) => host_with_port(),
          optional(:listen) => host_with_port(),
          optional(:enabled) => true | false
        }

  @doc """
  Creates a proxy on the toxiproxy server.

  Raises `ToxiproxyEx.ServerError` if the creation fails.

  ## Examples

  Create a new proxy:
      iex> ToxiproxyEx.create!(upstream: "localhost:3306", name: "test_mysql_master")

  Create a new proxy that listens on a specific port:
      iex> ToxiproxyEx.create!(upstream: "localhost:3306", listen: "localhost:5555", name: "test_mysql_master")

  Create a new proxy that is disabled by default:
      iex> ToxiproxyEx.create!(upstream: "localhost:3306", name: "test_mysql_master", enabled: false)
  """
  @spec create!(
          upstream: host_with_port(),
          name: String.t() | atom(),
          listen: host_with_port() | nil,
          enabled: true | false | nil
        ) :: proxy()
  defdelegate create!(options), to: Proxy, as: :create

  @doc """
  Deletes one or multiple proxies on the toxiproxy server.

  Raises `ToxiproxyEx.ServerError` if the deletion fails.

  ## Examples

  Destroy a single proxy:
      iex> ToxiproxyEx.create!(upstream: "localhost:3456", name: :test_mysql_master)
      iex> proxy = ToxiproxyEx.get!(:test_mysql_master)
      iex> ToxiproxyEx.destroy!(proxy)
      :ok

  Destroy all proxies:
      iex> ToxiproxyEx.create!(upstream: "localhost:3456", name: :test_mysql_master)
      iex> proxies = ToxiproxyEx.all!()
      iex> ToxiproxyEx.destroy!(proxies)
      :ok
  """

  @spec destroy!(proxy() | toxic_collection()) :: :ok
  def destroy!(%Proxy{} = proxy) do
    destroy!(ToxicCollection.new(proxy))
  end

  def destroy!(%ToxicCollection{proxies: proxies}) do
    Enum.each(proxies, &Proxy.destroy/1)
  end

  @doc """
  Retrieves a proxy from the toxiproxy server.

  Raises `ToxiproxyEx.ServerError` if the proxy could not be retrieved.
  Raises `ArgumentError` if the proxy does not exist.

  ## Examples

  Retrievs a proxy:
      iex> ToxiproxyEx.create!(upstream: "localhost:3306", name: "test_mysql_master")
      iex> ToxiproxyEx.get!(:test_mysql_master)
  """
  @spec get!(atom() | String.t()) :: proxy()
  def get!(name) when is_atom(name) or is_binary(name) do
    name = to_string(name)

    case Enum.find(all!().proxies, &(&1.name == name)) do
      nil -> raise ArgumentError, message: "Unknown proxy with name '#{name}'"
      proxy -> proxy
    end
  end

  @doc """
  Retrieves a list of proxies from the toxiproxy server where the name matches the specificed regex.

  Raises `ToxiproxyEx.ServerError` if the list of proxies could not be retrieved.
  Raises `ArgumentError` if no proxy matching the specified regex does exist.

  ## Examples

  Retrievs a proxy:
      iex> ToxiproxyEx.create!(upstream: "localhost:3306", name: "test_mysql_master")
      iex> ToxiproxyEx.create!(upstream: "localhost:3307", name: "test_mysql_follower")
      iex> ToxiproxyEx.create!(upstream: "localhost:3308", name: "test_redis_master")
      iex> ToxiproxyEx.grep!(~r/master/)
  """
  @spec grep!(Regex.t()) :: toxic_collection()
  def grep!(%Regex{} = pattern) do
    case Enum.filter(all!().proxies, &String.match?(&1.name, pattern)) do
      proxies = [_h | _t] -> ToxicCollection.new(proxies)
      [] -> raise ArgumentError, message: "No proxies found for regex '#{pattern}'"
    end
  end

  @doc """
  Retrieves a list of all proxies from the toxiproxy server.

  Raises `ToxiproxyEx.ServerError` if the list of proxies could not be retrieved.

  ## Examples

  Retrievs a proxy:
      iex> ToxiproxyEx.create!(upstream: "localhost:3306", name: "test_mysql_master")
      iex> ToxiproxyEx.create!(upstream: "localhost:3307", name: "test_redis_master")
      iex> ToxiproxyEx.all!()
  """
  @spec all!() :: toxic_collection()
  def all!() do
    Client.request!(:get, "/proxies")
    |> Enum.map(&parse_proxy/1)
    |> ToxicCollection.new()
  end

  defp parse_proxy(
         {_proxy_name,
          %{
            "upstream" => upstream,
            "listen" => listen,
            "name" => name,
            "enabled" => enabled
          }}
       ) do
    %Proxy{upstream: upstream, listen: listen, name: name, enabled: enabled}
  end

  @doc """
  Adds an upstream toxic to the proxy or list of proxies that will be enabled when passed to `ToxiproxyEx.apply!/2`.

  ## Examples

  Add an upstream toxic to a proxy:
      iex> proxy = ToxiproxyEx.create!(upstream: "localhost:3306", name: "test_mysql_master")
      iex> proxies = ToxiproxyEx.upstream(proxy, :latency, latency: 1000)
      iex> ToxiproxyEx.apply!(proxies, fn ->
      ...>  # Do some testing
      ...>  nil
      ...> end)

  Add an upstream toxic to a list of proxies:
      iex> ToxiproxyEx.create!(upstream: "localhost:3306", name: "test_mysql_master")
      iex> ToxiproxyEx.create!(upstream: "localhost:3307", name: "test_mysql_follower")
      iex> proxies = ToxiproxyEx.all!()
      iex> proxies = ToxiproxyEx.upstream(proxies, :latency, latency: 1000)
      iex> ToxiproxyEx.apply!(proxies, fn ->
      ...>  # Do some testing
      ...>  nil
      ...> end)
  """
  @spec upstream(proxy() | toxic_collection(), atom(), []) :: toxic_collection()
  def upstream(proxy_or_collection, type, attrs \\ [])

  def upstream(proxy = %Proxy{}, type, attrs) do
    upstream(ToxicCollection.new(proxy), type, attrs)
  end

  def upstream(%ToxicCollection{proxies: proxies, toxics: toxics}, type, attrs) do
    name = Keyword.get(attrs, :name)
    toxicity = Keyword.get(attrs, :toxicity)

    attrs =
      attrs
      |> Keyword.delete(:name)
      |> Keyword.delete(:toxicity)

    new_toxics =
      Enum.map(proxies, fn proxy ->
        Toxic.new(
          name: name,
          type: type,
          proxy_name: proxy.name,
          stream: :upstream,
          toxicity: toxicity,
          attributes: attrs
        )
      end)

    %ToxicCollection{proxies: proxies, toxics: toxics ++ new_toxics}
  end

  @doc """
  Alias for `ToxiproxyEx.downstream/3`.
  """
  @spec toxic(proxy() | toxic_collection(), atom(), []) :: toxic_collection()
  def toxic(proxy_or_collection, type, attrs \\ []) do
    downstream(proxy_or_collection, type, attrs)
  end

  @doc """
  Alias for `ToxiproxyEx.downstream/3`.
  """
  @spec toxicate(proxy() | toxic_collection(), atom(), []) :: toxic_collection()
  def toxicate(proxy_or_collection, type, attrs \\ []) do
    downstream(proxy_or_collection, type, attrs)
  end

  @doc """
  Adds an downstream toxic to the proxy or list of proxies that will be enabled when passed to `ToxiproxyEx.apply!/2`.

  ## Examples

  Add an downstream toxic to a proxy:
      iex> proxy = ToxiproxyEx.create!(upstream: "localhost:3306", name: "test_mysql_master")
      iex> proxies = ToxiproxyEx.downstream(proxy, :latency, latency: 1000)
      iex> ToxiproxyEx.apply!(proxies, fn ->
      ...>  # Do some testing
      ...>  nil
      ...> end)

  Add an downstream toxic to a list of proxies:
      iex> ToxiproxyEx.create!(upstream: "localhost:3306", name: "test_mysql_master")
      iex> ToxiproxyEx.create!(upstream: "localhost:3307", name: "test_mysql_follower")
      iex> proxies = ToxiproxyEx.all!()
      iex> proxies = ToxiproxyEx.downstream(proxies, :latency, latency: 1000)
      iex> ToxiproxyEx.apply!(proxies, fn ->
      ...>  # Do some testing
      ...>  nil
      ...> end)
  """
  @spec downstream(proxy() | toxic_collection(), atom(), []) :: toxic_collection()
  def downstream(proxy_or_collection, type, attrs \\ [])

  def downstream(proxy = %Proxy{}, type, attrs) do
    downstream(ToxicCollection.new(proxy), type, attrs)
  end

  def downstream(%ToxicCollection{proxies: proxies, toxics: toxics}, type, attrs) do
    name = Keyword.get(attrs, :name)
    toxicity = Keyword.get(attrs, :toxicity)

    attrs =
      attrs
      |> Keyword.delete(:name)
      |> Keyword.delete(:toxicity)

    new_toxics =
      Enum.map(proxies, fn proxy ->
        Toxic.new(
          name: name,
          type: type,
          proxy_name: proxy.name,
          stream: :downstream,
          toxicity: toxicity,
          attributes: attrs
        )
      end)

    %ToxicCollection{proxies: proxies, toxics: toxics ++ new_toxics}
  end

  @doc """
  Applies all toxics previously defined on the list of proxies during the duration of the given function.

  Raises `ToxiproxyEx.ServerError` if the toxics could not be enabled and disabled again on the server.

  ## Examples

  Add toxics and apply them toxic to a single proxy:
      iex> proxy = ToxiproxyEx.create!(upstream: "localhost:3306", name: "test_mysql_master")
      iex> proxies = ToxiproxyEx.downstream(proxy, :slow_close, delay: 100)
      iex> proxies = ToxiproxyEx.downstream(proxies, :latency, jitter: 300)
      iex> ToxiproxyEx.apply!(proxies, fn ->
      ...>  # All calls to mysql master are now slow at responding and closing.
      ...>  nil
      ...> end)

  Add toxics and apply them toxic to a list of proxies:
      iex> ToxiproxyEx.create!(upstream: "localhost:3306", name: "test_mysql_master")
      iex> ToxiproxyEx.create!(upstream: "localhost:3306", name: "test_mysql_follower")
      iex> proxies = ToxiproxyEx.all!()
      iex> proxies = ToxiproxyEx.downstream(proxies, :slow_close, delay: 100)
      iex> proxies = ToxiproxyEx.downstream(proxies, :latency, jitter: 300)
      iex> ToxiproxyEx.apply!(proxies, fn ->
      ...>  # All calls to mysql master and follower are now slow at responding and closing.
      ...>  nil
      ...> end)
  """
  @spec apply!(toxic_collection(), (-> result)) :: result when result: var
  def apply!(%ToxicCollection{toxics: toxics}, fun) when is_function(fun, 0) do
    toxics
    |> Enum.group_by(fn %Toxic{} = toxic -> {toxic.name, toxic.proxy_name} end)
    |> Enum.each(fn
      {_name_and_proxy_name, [toxic, _other_toxic | _rest]} ->
        raise ArgumentError, """
        there are multiple toxics with the name #{inspect(toxic.name)} for proxy \
        #{inspect(toxic.proxy_name)}, please override the default name (<type>_<direction>)\
        """

      {_name_and_proxy_name, [_toxic]} ->
        :ok
    end)

    # We probably don't care about the updated toxics here but we still use
    # rather than the one passed into the function.
    toxics = Enum.map(toxics, &Toxic.create/1)

    try do
      fun.()
    after
      Enum.each(toxics, &Toxic.destroy/1)
    end
  end

  @doc """
  Takes down the proxy or the list of proxies during the duration of the given function.

  Raises `ToxiproxyEx.ServerError` if the proxy or list of proxies could not have been disabled and enabled again on the server.

  ## Examples

  Take down a single proxy:
      iex> proxy = ToxiproxyEx.create!(upstream: "localhost:3306", name: "test_mysql_master")
      iex> ToxiproxyEx.down!(proxy, fn ->
      ...>  # Takes mysql master down.
      ...>  nil
      ...> end)

  Take down a list of proxies:
      iex> ToxiproxyEx.create!(upstream: "localhost:3306", name: "test_mysql_master")
      iex> ToxiproxyEx.create!(upstream: "localhost:3306", name: "test_mysql_follower")
      iex> proxies = ToxiproxyEx.all!()
      iex> ToxiproxyEx.down!(proxies, fn ->
      ...>  # Takes mysql master and follower down.
      ...>  nil
      ...> end)
  """
  @spec down!(toxic_collection() | proxy(), (-> result)) :: result when result: var
  def down!(proxy_or_collection, fun)

  def down!(proxy = %Proxy{}, fun) do
    down!(ToxicCollection.new(proxy), fun)
  end

  def down!(%ToxicCollection{proxies: proxies}, fun) when is_function(fun, 0) do
    Enum.each(proxies, &Proxy.disable/1)

    try do
      fun.()
    after
      Enum.each(proxies, &Proxy.enable/1)
    end
  end

  @doc """
  Re-enables are proxies and disables all toxics on toxiproxy.

  Raises `ToxiproxyEx.ServerError` if the server could not have been reset.

  ## Examples

  Reset toxiproxy:
      iex> ToxiproxyEx.reset!()
      :ok
  """
  @spec reset!() :: :ok
  def reset!() do
    Client.request!(:post, "/reset", %{})
    :ok
  end

  @doc """
  Gets the version of the running toxiproxy server.

  Raises `ToxiproxyEx.ServerError` if the version could not have been fetched from the server.

  ## Examples

  Get running toxiproxy version:
      iex> ToxiproxyEx.version!()
      "2.1.2"
  """
  @spec version!() :: String.t()
  def version!() do
    case Client.request!(:get, "/version") do
      %{"version" => version} -> version
      version -> version
    end
  end

  @doc """
  Creates proxies based on the passed data.
  This is usefull to quickly create multiple proxies based on hardcoded value or values read from external sources such as a config file.

  Nonexisting proxies will be created and existing ones will be updated to match the passed data.

  Raises `ToxiproxyEx.ServerError` if the proxies could not have been created on the server.

  ## Examples

  Creating proxies:
      iex> ToxiproxyEx.populate!([
      ...>  %{name: "test_mysql_master", upstream: "localhost:5765"},
      ...>  %{name: "test_mysql_follower", upstream: "localhost:5766", enabled: false}
      ...> ])
  """
  @spec populate!([proxy_map()]) :: toxic_collection()
  def populate!(proxies) when is_list(proxies) do
    Enum.map(proxies, fn proxy_attrs ->
      name = Map.get(proxy_attrs, :name)
      upstream = Map.get(proxy_attrs, :upstream)
      listen = Map.get(proxy_attrs, :upstream)

      existing = Enum.find(all!().proxies, &(&1.name == name))

      if existing do
        if existing.upstream == upstream && existing.listen == listen do
          existing
        else
          destroy!(existing)

          Keyword.new(proxy_attrs)
          |> create!()
        end
      else
        Keyword.new(proxy_attrs)
        |> create!()
      end
    end)
    |> ToxicCollection.new()
  end
end