diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index bee5c78..2fca8df 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -3,18 +3,169 @@ on: branches: - main pull_request: - + +env: + MIX_ENV: test + jobs: + lint: + name: Lint + runs-on: ubuntu-20.04 + + env: + ELIXIR_VERSION: "1.15" + OTP_VERSION: "26.1" + + steps: + - name: Check out this repository + uses: actions/checkout@v4 + + - name: Install Erlang and Elixir + uses: erlef/setup-beam@v1 + with: + otp-version: ${{ env.OTP_VERSION }} + elixir-version: ${{ env.ELIXIR_VERSION}} + + - name: Cache Mix compiled stuff + uses: actions/cache@v3 + id: cache-mix + with: + path: | + _build + deps + key: | + mix-elixir${{ env.ELIXIR_VERSION }}-otp${{ env.OTP_VERSION }}-${{ hashFiles('mix.lock') }}-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: | + mix-elixir${{ env.ELIXIR_VERSION }}-otp${{ env.OTP_VERSION }}-${{ hashFiles('mix.lock') }}-${{ github.run_id }}- + mix-elixir${{ env.ELIXIR_VERSION }}-otp${{ env.OTP_VERSION }}-${{ hashFiles('mix.lock') }}- + + - name: Install dependencies + run: mix deps.get + + - name: Check for unused dependencies + run: mix deps.unlock --check-unused + + - name: Check for formatted code + run: mix format --check-formatted + + dialyzer: + name: Dialyze + runs-on: ubuntu-20.04 + + env: + ELIXIR_VERSION: "1.15" + OTP_VERSION: "26.1" + + steps: + - name: Check out this repository + uses: actions/checkout@v4 + + - name: Install Erlang and Elixir + uses: erlef/setup-beam@v1 + with: + otp-version: ${{ env.OTP_VERSION }} + elixir-version: ${{ env.ELIXIR_VERSION}} + + - name: Cache Mix compiled stuff + uses: actions/cache@v3 + id: cache-mix + with: + path: | + _build + deps + key: | + mix-elixir${{ env.ELIXIR_VERSION }}-otp${{ env.OTP_VERSION }}-${{ hashFiles('mix.lock') }}-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: | + mix-elixir${{ env.ELIXIR_VERSION }}-otp${{ env.OTP_VERSION }}-${{ hashFiles('mix.lock') }}-${{ github.run_id }}- + mix-elixir${{ env.ELIXIR_VERSION }}-otp${{ env.OTP_VERSION }}-${{ hashFiles('mix.lock') }}- + + # Don't cache PLTs based on mix.lock hash, as Dialyzer can incrementally update even old + # ones. Cache key based on Elixir and Erlang version (also useful when running in matrix). + - name: Cache Dialyzer's PLT + uses: actions/cache@v3 + id: cache-plt + with: + path: plts + key: | + plt-otp${{ env.OTP_VERSION }}-elixir${{ env.ELIXIR_VERSION }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + plt-otp${{ env.OTP_VERSION }}-elixir${{ env.ELIXIR_VERSION }}- + + - name: Install dependencies + run: mix deps.get + + # Create PLTs if no cache was found + - name: Create PLTs + if: steps.cache-plt.outputs.cache-hit != 'true' + run: | + mkdir -p plts + mix dialyzer --plt + + - name: Run Dialyzer + run: mix dialyzer --format github + test: + name: Test (Erlang ${{ matrix.otp }}, Elixir ${{ matrix.elixir }}, Toxiproxy ${{ matrix.toxiproxy }}) + runs-on: ubuntu-20.04 + + strategy: + matrix: + toxiproxy: + - "2.6.0" + - "2.5.0" + - "2.1.2" + elixir: + - "1.15" + otp: + - "26.1" + include: + # Oldest supported version pair. + - otp: "23.3" + elixir: "1.10.4-otp-23" + toxiproxy: "2.1.2" + + env: + TOXIPROXY_VERSION: ${{ matrix.toxiproxy }} + steps: - - uses: actions/checkout@v2 - - uses: erlef/setup-beam@v1 + - name: Check out this repository + uses: actions/checkout@v4 + + - name: Install Erlang and Elixir + uses: erlef/setup-beam@v1 with: - version-file: .tool-versions + otp-version: ${{ matrix.otp }} + elixir-version: ${{ matrix.elixir }} version-type: strict - - run: curl --silent -L https://github.com/Shopify/toxiproxy/releases/download/v2.1.2/toxiproxy-server-linux-amd64 -o ./toxiproxy-server - - run: chmod +x ./toxiproxy-server - - run: nohup bash -c "./toxiproxy-server > ./toxiproxy.log 2>&1 &" - - run: mix deps.get - - run: mix test + + - name: Install Toxiproxy + run: | + curl -v -L --fail https://github.com/Shopify/toxiproxy/releases/download/v${TOXIPROXY_VERSION}/toxiproxy-server-linux-amd64 -o ./toxiproxy-server + chmod +x ./toxiproxy-server + + - name: Start Toxiproxy + run: nohup bash -c "./toxiproxy-server > ./toxiproxy.log 2>&1 &" + + - name: Cache Mix compiled stuff + uses: actions/cache@v3 + id: cache-mix + with: + path: | + _build + deps + key: | + mix-elixir${{ matrix.elixir }}-otp${{ matrix.otp }}-${{ hashFiles('mix.lock') }}-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: | + mix-elixir${{ matrix.elixir }}-otp${{ matrix.otp }}-${{ hashFiles('mix.lock') }}-${{ github.run_id }}- + mix-elixir${{ matrix.elixir }}-otp${{ matrix.otp }}-${{ hashFiles('mix.lock') }}- + + - name: Install dependencies + run: mix deps.get + + - name: Run tests + run: mix test + + - name: Dump Toxiproxy logs on failure + if: failure() + run: cat ./toxiproxy.log diff --git a/.gitignore b/.gitignore index d3782ef..097d261 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ erl_crash.dump # Ignore package tarball (built via "mix hex.build"). toxiproxy_ex-*.tar +# Dialyzer PLTs. +/plts diff --git a/.tool-versions b/.tool-versions index 1928052..38d92fa 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -erlang 23.2 -elixir 1.11.3-otp-23 +erlang 26.0.2 +elixir 1.15.4-otp-26 diff --git a/lib/toxiproxy_ex.ex b/lib/toxiproxy_ex.ex index 0409fcc..dfec550 100644 --- a/lib/toxiproxy_ex.ex +++ b/lib/toxiproxy_ex.ex @@ -1,5 +1,5 @@ defmodule ToxiproxyEx do - alias ToxiproxyEx.{Proxy, Client, Toxic, ToxicCollection, ServerError} + alias ToxiproxyEx.{Proxy, Client, Toxic, ToxicCollection} @external_resource "README.md" @moduledoc "README.md" @@ -10,12 +10,12 @@ defmodule ToxiproxyEx do @typedoc """ A proxy that intercepts traffic to and from an upstream server. """ - @opaque proxy :: %Proxy{} + @opaque proxy :: Proxy.t() @typedoc """ A collection of proxies. """ - @opaque toxic_collection :: %ToxicCollection{} + @opaque toxic_collection :: ToxicCollection.t() @typedoc """ A hostname or IP address including a port number, e.g. `localhost:4539`. @@ -54,12 +54,7 @@ defmodule ToxiproxyEx do listen: host_with_port() | nil, enabled: true | false | nil ) :: proxy() - def create!(options) do - case Proxy.create(options) do - {:ok, proxy} -> proxy - :error -> raise ServerError, message: "Could not create proxy" - end - end + defdelegate create!(options), to: Proxy, as: :create @doc """ Deletes one or multiple proxies on the toxiproxy server. @@ -87,14 +82,7 @@ defmodule ToxiproxyEx do end def destroy!(%ToxicCollection{proxies: proxies}) do - Enum.each(proxies, fn proxy -> - case Proxy.destroy(proxy) do - :ok -> nil - :error -> raise ServerError, message: "Could not destroy proxy" - end - end) - - :ok + Enum.each(proxies, &Proxy.destroy/1) end @doc """ @@ -110,11 +98,9 @@ defmodule ToxiproxyEx do iex> ToxiproxyEx.get!(:test_mysql_master) """ @spec get!(atom() | String.t()) :: proxy() - def get!(name) when is_atom(name) do - get!(Atom.to_string(name)) - end + def get!(name) when is_atom(name) or is_binary(name) do + name = to_string(name) - def get!(name) do case Enum.find(all!().proxies, &(&1.name == name)) do nil -> raise ArgumentError, message: "Unknown proxy with name '#{name}'" proxy -> proxy @@ -136,7 +122,7 @@ defmodule ToxiproxyEx do iex> ToxiproxyEx.grep!(~r/master/) """ @spec grep!(Regex.t()) :: toxic_collection() - def grep!(pattern) do + 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}'" @@ -157,10 +143,8 @@ defmodule ToxiproxyEx do """ @spec all!() :: toxic_collection() def all!() do - case Client.list_proxies() do - {:ok, %{body: proxies}} -> Enum.map(proxies, &parse_proxy/1) - _ -> raise ServerError, message: "Could not fetch proxies." - end + Client.request!(:get, "/proxies") + |> Enum.map(&parse_proxy/1) |> ToxicCollection.new() end @@ -327,39 +311,29 @@ defmodule ToxiproxyEx do ...> nil ...> end) """ - @spec apply!(toxic_collection(), (() -> any())) :: :ok - def apply!(%ToxicCollection{toxics: toxics}, fun) do - dups = - Enum.group_by(toxics, fn t -> [t.name, t.proxy_name] end) - |> Enum.map(fn {_group, toxics} -> toxics end) - |> Enum.filter(fn toxics -> length(toxics) > 1 end) - - if Enum.empty?(dups) do - # Note: We probably don't care about the updated toxies here but we still use them rather than the one passed into the function. - toxics = - Enum.map(toxics, fn toxic -> - case Toxic.create(toxic) do - {:ok, toxic} -> toxic - :error -> raise ServerError, message: "Could not create toxic '#{toxic.name}'" - end - 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 (_)\ + """ + + {_name_and_proxy_name, [_toxic]} -> + :ok + end) - fun.() + # 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) - Enum.each(toxics, fn toxic -> - case Toxic.destroy(toxic) do - :ok -> nil - :error -> raise ServerError, message: "Could not destroy toxic '#{toxic.name}'" - end - end) - - :ok - else - raise ArgumentError, - message: - "There are multiple toxics with the name '#{hd(hd(dups)).name}' for proxy '#{ - hd(hd(dups)).proxy_name - }', please override the default name (_)" + try do + fun.() + after + Enum.each(toxics, &Toxic.destroy/1) end end @@ -386,29 +360,21 @@ defmodule ToxiproxyEx do ...> nil ...> end) """ - @spec down!(toxic_collection(), (() -> any())) :: :ok + @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) do - Enum.each(proxies, fn proxy -> - case Proxy.disable(proxy) do - :ok -> nil - :error -> raise ServerError, message: "Could not disable proxy '#{proxy.name}'" - end - end) + def down!(%ToxicCollection{proxies: proxies}, fun) when is_function(fun, 0) do + Enum.each(proxies, &Proxy.disable/1) - fun.() - - Enum.each(proxies, fn proxy -> - case Proxy.enable(proxy) do - :ok -> nil - :error -> raise ServerError, message: "Could not enable proxy '#{proxy.name}'" - end - end) - - :ok + try do + fun.() + after + Enum.each(proxies, &Proxy.enable/1) + end end @doc """ @@ -424,10 +390,8 @@ defmodule ToxiproxyEx do """ @spec reset!() :: :ok def reset!() do - case Client.reset() do - {:ok, _} -> :ok - _ -> raise ServerError, message: "Could not reset toxiproxy" - end + Client.request!(:post, "/reset", %{}) + :ok end @doc """ @@ -441,11 +405,11 @@ defmodule ToxiproxyEx do iex> ToxiproxyEx.version!() "2.1.2" """ - @spec version!() :: :ok + @spec version!() :: String.t() def version!() do - case Client.version() do - {:ok, %{body: res}} -> res - _ -> raise ServerError, message: "Could not fetch version" + case Client.request!(:get, "/version") do + %{"version" => version} -> version + version -> version end end diff --git a/lib/toxiproxy_ex/client.ex b/lib/toxiproxy_ex/client.ex index 355ad15..1ac856c 100644 --- a/lib/toxiproxy_ex/client.ex +++ b/lib/toxiproxy_ex/client.ex @@ -1,66 +1,60 @@ defmodule ToxiproxyEx.Client do @moduledoc false - def reset() do - client() - |> Tesla.post("/reset", %{}) - end - - def version() do - client() - |> Tesla.get("/version") - end - - def list_proxies() do - client() - |> Tesla.get("/proxies") - end - - def create_proxy(params) do - client() - |> Tesla.post("/proxies", params) - end - - def destroy_proxy(name) do - client() - |> Tesla.delete("/proxies/#{name}") - end - - def enable_proxy(name) do - client() - |> Tesla.post("/proxies/#{name}", %{enabled: true}) - end - - def disable_proxy(name) do - client() - |> Tesla.post("/proxies/#{name}", %{enabled: false}) - end - - def list_toxics(proxy_name) do - client() - |> Tesla.get("/proxies/#{proxy_name}/toxics") - end + alias Tesla.Env + alias ToxiproxyEx.ServerError + + # ToxiproxyEx 3.0 TODO: + # We can remove this once Toxiproxy will fix their stuff. See: + # https://github.com/Shopify/toxiproxy/pull/538 + defmodule BuggyToxiproxyVersionParserMiddleware do + @moduledoc false + + @behaviour Tesla.Middleware + + @impl true + def call(env, next, _options) do + with {:ok, env} <- Tesla.run(env, next) do + env = + if String.ends_with?(env.url, "/version") do + update_in(env.body, &String.trim_trailing(&1, "\\n")) + else + env + end + + {:ok, env} + end + end + end + + @spec request!(:get | :post | :delete, String.t(), map() | nil) :: response_body :: term() + def request!(method, path, params \\ nil) + when method in [:get, :post, :delete] and is_binary(path) and + (is_nil(params) or is_map(params)) do + middlewares = [ + {Tesla.Middleware.BaseUrl, Application.fetch_env!(:toxiproxy_ex, :host)}, + Tesla.Middleware.JSON, + Tesla.Middleware.KeepRequest, + BuggyToxiproxyVersionParserMiddleware + ] - def create_toxic(proxy_name, params) do - client() - |> Tesla.post("/proxies/#{proxy_name}/toxics", params) - end + client = Tesla.client(middlewares, {Tesla.Adapter.Mint, []}) - def destroy_toxic(proxy_name, toxic_name) do - client() - |> Tesla.delete("/proxies/#{proxy_name}/toxics/#{toxic_name}") - end + request_opts = [method: method, url: path] + request_opts = if params, do: Keyword.put(request_opts, :body, params), else: request_opts - defp client() do - url = Application.get_env(:toxiproxy_ex, :host, "http://127.0.0.1:8474") + case Tesla.request(client, request_opts) do + {:ok, %Env{status: status, body: body}} when status in 200..299 -> + body - middleware = [ - {Tesla.Middleware.BaseUrl, url}, - Tesla.Middleware.JSON - ] + {:ok, %Env{} = env} -> + raise ServerError, method: method, path: path, reason: {:status, env} - adapter = {Tesla.Adapter.Mint, []} + {:error, {Tesla.Middleware.JSON, :decode, %Jason.DecodeError{} = error}} -> + raise ServerError, method: method, path: path, reason: error - Tesla.client(middleware, adapter) + {:error, reason} -> + raise ServerError, method: method, path: path, reason: reason + end end end diff --git a/lib/toxiproxy_ex/proxy.ex b/lib/toxiproxy_ex/proxy.ex index f27ac8c..54eb53e 100644 --- a/lib/toxiproxy_ex/proxy.ex +++ b/lib/toxiproxy_ex/proxy.ex @@ -3,54 +3,58 @@ defmodule ToxiproxyEx.Proxy do alias ToxiproxyEx.{Client, Toxic} + @typedoc since: "1.2.0" + @type t() :: %__MODULE__{ + upstream: String.t(), + listen: String.t(), + name: String.t(), + enabled: boolean() + } + defstruct upstream: nil, listen: nil, name: nil, enabled: nil - def disable(proxy) do - case Client.disable_proxy(proxy.name) do - {:ok, _res} -> :ok - _ -> :error - end + @spec disable(t()) :: :ok + def disable(%__MODULE__{} = proxy) do + Client.request!(:post, "/proxies/#{proxy.name}", %{enabled: false}) + :ok end - def enable(proxy) do - case Client.enable_proxy(proxy.name) do - {:ok, _res} -> :ok - _ -> :error - end + @spec enable(t()) :: :ok + def enable(%__MODULE__{} = proxy) do + Client.request!(:post, "/proxies/#{proxy.name}", %{enabled: true}) + :ok end - def create(options) do + @spec create(keyword()) :: t() + def create(options) when is_list(options) do upstream = Keyword.get(options, :upstream) listen = Keyword.get(options, :listen, "localhost:0") name = Keyword.get(options, :name) enabled = Keyword.get(options, :enabled) - case Client.create_proxy(%{ - upstream: upstream, - name: name, - listen: listen, - enabled: enabled - }) do - {:ok, %{body: %{"listen" => listen, "enabled" => enabled, "name" => name}}} -> - {:ok, %__MODULE__{upstream: upstream, listen: listen, name: name, enabled: enabled}} + body = %{ + upstream: upstream, + name: name, + listen: listen, + enabled: enabled + } + + %{"listen" => listen, "enabled" => enabled, "name" => name} = + Client.request!(:post, "/proxies", body) - _ -> - :error - end + %__MODULE__{upstream: upstream, listen: listen, name: name, enabled: enabled} end - def destroy(proxy) do - case Client.destroy_proxy(proxy.name) do - {:ok, _res} -> :ok - _ -> :error - end + @spec destroy(t()) :: :ok + def destroy(%__MODULE__{} = proxy) do + Client.request!(:delete, "/proxies/#{proxy.name}") + :ok end - def toxics(proxy) do - case Client.list_toxics(proxy.name) do - {:ok, %{body: toxics}} -> {:ok, Enum.map(toxics, &parse_toxic(&1, proxy))} - _ -> :error - end + @spec toxics(t()) :: [Toxic.t()] + def toxics(%__MODULE__{} = proxy) do + toxics = Client.request!(:get, "/proxies/#{proxy.name}/toxics") + Enum.map(toxics, &parse_toxic(&1, proxy)) end defp parse_toxic( diff --git a/lib/toxiproxy_ex/server_error.ex b/lib/toxiproxy_ex/server_error.ex index 48c4675..50eef9c 100644 --- a/lib/toxiproxy_ex/server_error.ex +++ b/lib/toxiproxy_ex/server_error.ex @@ -1,6 +1,46 @@ defmodule ToxiproxyEx.ServerError do @moduledoc """ - Raised when communication with the toxiproxy server fails. + Raised when communication with the Toxiproxy server fails. """ + + @typedoc since: "1.2.0" + @type t() :: %__MODULE__{ + message: String.t() + } + defexception message: "Server Error" + + @impl true + def exception(options) when is_list(options) do + method = Keyword.fetch!(options, :method) + path = Keyword.fetch!(options, :path) + reason = Keyword.fetch!(options, :reason) + + string_reason = + case reason do + {:status, %Tesla.Env{status: status, headers: headers, body: body}} -> + """ + invalid status code #{status}. + + Headers: #{inspect(headers)} + Body: #{inspect(body)} + + """ + + %Jason.DecodeError{} = error -> + Exception.message(error) + + other -> + inspect(other) + end + + message = """ + Request to the Toxiproxy server failed. + + Request: #{method |> to_string() |> String.upcase()} #{path} + Failure reason: #{string_reason} + """ + + %__MODULE__{message: message} + end end diff --git a/lib/toxiproxy_ex/toxic.ex b/lib/toxiproxy_ex/toxic.ex index e0243f2..1fdca61 100644 --- a/lib/toxiproxy_ex/toxic.ex +++ b/lib/toxiproxy_ex/toxic.ex @@ -3,6 +3,9 @@ defmodule ToxiproxyEx.Toxic do alias ToxiproxyEx.Client + @typedoc since: "1.2.0" + @type t() :: %__MODULE__{} + defstruct [ :type, :name, @@ -12,7 +15,8 @@ defmodule ToxiproxyEx.Toxic do :toxicity ] - def new(fields) do + @spec new(keyword()) :: t() + def new(fields) when is_list(fields) do fields = fields |> maybe_put_default(:toxicity, 1.0) @@ -43,36 +47,34 @@ defmodule ToxiproxyEx.Toxic do Keyword.put(fields, field, default) end - def create(toxic) do - case Client.create_toxic(toxic.proxy_name, %{ - name: toxic.name, - type: toxic.type, - stream: toxic.stream, - toxicity: toxic.toxicity, - attributes: toxic.attributes - }) do - {:ok, %{body: %{"attributes" => attributes, "toxicity" => toxicity}}} -> - # Note: We update attributes and toxicity to ensure that our local representation is as close as possible to the data on the server. - # We do not make use of those fields after `create` has been called but we update them anyway. - {:ok, - new( - name: toxic.name, - type: toxic.type, - stream: toxic.stream, - toxicity: toxicity, - attributes: attributes, - proxy_name: toxic.proxy_name - )} + @spec create(t()) :: t() + def create(%__MODULE__{} = toxic) do + body = %{ + name: toxic.name, + type: toxic.type, + stream: toxic.stream, + toxicity: toxic.toxicity, + attributes: toxic.attributes + } - _res -> - :error - end + %{"attributes" => attributes, "toxicity" => toxicity} = + Client.request!(:post, "/proxies/#{toxic.proxy_name}/toxics", body) + + # Note: We update attributes and toxicity to ensure that our local representation is as close as possible to the data on the server. + # We do not make use of those fields after `create` has been called but we update them anyway. + new( + name: toxic.name, + type: toxic.type, + stream: toxic.stream, + toxicity: toxicity, + attributes: attributes, + proxy_name: toxic.proxy_name + ) end - def destroy(toxic) do - case Client.destroy_toxic(toxic.proxy_name, toxic.name) do - {:ok, _res} -> :ok - _ -> :error - end + @spec destroy(t()) :: :ok + def destroy(%__MODULE__{} = toxic) do + Client.request!(:delete, "/proxies/#{toxic.proxy_name}/toxics/#{toxic.name}") + :ok end end diff --git a/lib/toxiproxy_ex/toxic_collection.ex b/lib/toxiproxy_ex/toxic_collection.ex index 270df0e..14b39dc 100644 --- a/lib/toxiproxy_ex/toxic_collection.ex +++ b/lib/toxiproxy_ex/toxic_collection.ex @@ -1,14 +1,25 @@ defmodule ToxiproxyEx.ToxicCollection do @moduledoc false + alias ToxiproxyEx.{Proxy, Toxic} + + @typedoc since: "1.2.0" + @type t() :: %__MODULE__{ + proxies: [Proxy.t()], + toxics: [Toxic.t()] + } + defstruct proxies: [], toxics: [] + @spec new([Proxy.t()] | Proxy.t()) :: t() + def new(proxies_or_proxy) + def new(proxies) when is_list(proxies) do proxies = Enum.reject(proxies, &is_nil/1) %__MODULE__{proxies: proxies} end - def new(proxy) do + def new(%Proxy{} = proxy) do new([proxy]) end end diff --git a/mix.exs b/mix.exs index 0ab3fdb..943d14a 100644 --- a/mix.exs +++ b/mix.exs @@ -12,6 +12,13 @@ defmodule ToxiproxyEx.MixProject do start_permanent: Mix.env() == :prod, deps: deps(), + # Dialyzer + dialyzer: [ + plt_local_path: "plts", + plt_core_path: "plts", + plt_add_apps: [:ssl, :crypto, :mix, :ex_unit, :erts, :kernel, :stdlib] + ], + # Hex description: "Elixir Client for Toxiproxy", package: package(), @@ -23,7 +30,8 @@ defmodule ToxiproxyEx.MixProject do def application do [ - extra_applications: [:logger] + extra_applications: [], + env: [host: "http://127.0.0.1:8474"] ] end @@ -33,9 +41,13 @@ defmodule ToxiproxyEx.MixProject do {:jason, ">= 1.0.0"}, {:castore, "~> 1.0.3"}, {:mint, "~> 1.0"}, - {:ex_doc, "~> 0.23", only: :dev, runtime: false}, - {:dialyxir, "~> 1.0", only: [:dev], runtime: false} - ] + {:ex_doc, "~> 0.23", only: :dev, runtime: false} + ] ++ + if Version.match?(System.version(), "~> 1.12") do + [{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}] + else + [] + end end defp package do diff --git a/mix.lock b/mix.lock index 038bed0..a57f737 100644 --- a/mix.lock +++ b/mix.lock @@ -1,6 +1,6 @@ %{ "castore": {:hex, :castore, "1.0.3", "7130ba6d24c8424014194676d608cb989f62ef8039efd50ff4b3f33286d06db8", [:mix], [], "hexpm", "680ab01ef5d15b161ed6a95449fac5c6b8f60055677a8e79acf01b27baa4390b"}, - "dialyxir": {:hex, :dialyxir, "1.3.0", "fd1672f0922b7648ff9ce7b1b26fcf0ef56dda964a459892ad15f6b4410b5284", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "00b2a4bcd6aa8db9dcb0b38c1225b7277dca9bc370b6438715667071a304696f"}, + "dialyxir": {:hex, :dialyxir, "1.4.1", "a22ed1e7bd3a3e3f197b68d806ef66acb61ee8f57b3ac85fc5d57354c5482a93", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "84b795d6d7796297cca5a3118444b80c7d94f7ce247d49886e7c291e1ae49801"}, "earmark_parser": {:hex, :earmark_parser, "1.4.33", "3c3fd9673bb5dcc9edc28dd90f50c87ce506d1f71b70e3de69aa8154bc695d44", [:mix], [], "hexpm", "2d526833729b59b9fdb85785078697c72ac5e5066350663e5be6a1182da61b8f"}, "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "ex_doc": {:hex, :ex_doc, "0.30.4", "e8395c8e3c007321abb30a334f9f7c0858d80949af298302daf77553468c0c39", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "9a19f0c50ffaa02435668f5242f2b2a61d46b541ebf326884505dfd3dd7af5e4"}, diff --git a/test/support/proxy_assertions.ex b/test/support/proxy_assertions.ex index 22b64c0..b45cef8 100644 --- a/test/support/proxy_assertions.ex +++ b/test/support/proxy_assertions.ex @@ -1,19 +1,32 @@ defmodule ToxiproxyEx.ProxyAssertions do - defmacro assert_proxy_available(proxy) do - quote do - case connect_to_proxy(unquote(proxy)) do - {:ok, _socket} -> assert true - _ -> flunk("Proxy #{unquote(proxy).name} is not available but should be") - end + import ExUnit.Assertions + + alias ToxiproxyEx.Proxy + alias ToxiproxyEx.TestHelpers + + def assert_proxy_available(%Proxy{} = proxy) do + case TestHelpers.connect_to_proxy(proxy) do + {:ok, socket} -> + :gen_tcp.close(socket) + + {:error, reason} -> + flunk(""" + Proxy #{proxy.name} (shown below) should be available, but is not: \ + #{:inet.format_error(reason)} + + #{inspect(proxy)} + """) end end - defmacro assert_proxy_unavailable(proxy) do - quote do - case connect_to_proxy(unquote(proxy)) do - {:error, :econnrefused} -> assert true - _ -> flunk("Proxy #{unquote(proxy).name} is available but should not be") - end + def assert_proxy_unavailable(%Proxy{} = proxy) do + case TestHelpers.connect_to_proxy(proxy) do + {:error, :econnrefused} -> + :ok + + {:ok, socket} -> + :gen_tcp.close(socket) + flunk("Proxy #{proxy.name} is available but should not be") end end end diff --git a/test/support/test_helpers.ex b/test/support/test_helpers.ex index 212d583..a9e88d4 100644 --- a/test/support/test_helpers.ex +++ b/test/support/test_helpers.ex @@ -1,11 +1,13 @@ defmodule ToxiproxyEx.TestHelpers do + import ExUnit.Assertions + alias ToxiproxyEx.EchoServer def connect_to_proxy(proxy) do - [hostname, port] = String.split(proxy.listen, ":") + assert [hostname, port] = String.split(proxy.listen, ":") hostname = String.to_charlist(hostname) - {port, _rem} = Integer.parse(port) + assert {port, ""} = Integer.parse(port) :gen_tcp.connect(hostname, port, [ :binary, @@ -26,35 +28,34 @@ defmodule ToxiproxyEx.TestHelpers do {duration / 1_000, res} end - def with_tcpservers(count, fun) do - servers = - Enum.map(1..count, fn _i -> - EchoServer.create() - end) + def with_tcpservers(count, fun) when is_integer(count) do + parent_pid = self() + ref = make_ref() - server_pids = - Enum.map(servers, fn {socket, _port} -> - spawn(fn -> - EchoServer.start(socket) - end) - end) + tasks = + Enum.map(1..count, fn _i -> + task = + Task.async(fn -> + {socket, port} = EchoServer.create() + send(parent_pid, {:port, ref, port}) + EchoServer.start(socket) + end) - ports = Enum.map(servers, fn {_socket, port} -> port end) + assert_receive {:port, ^ref, port}, 100 - fun.(ports) + {task, port} + end) - Enum.each(server_pids, fn pid -> - Process.exit(pid, :kill) - end) + ports = Enum.map(tasks, fn {_task, port} -> port end) - Enum.each(servers, fn {socket, _port} -> - EchoServer.stop(socket) - end) + try do + fun.(ports) + after + Enum.each(tasks, fn {task, _port} -> Task.shutdown(task) end) + end end def with_tcpserver(fun) do - with_tcpservers(1, fn ports -> - fun.(hd(ports)) - end) + with_tcpservers(1, fn [port] -> fun.(port) end) end end diff --git a/test/toxiproxy_ex_test.exs b/test/toxiproxy_ex_test.exs index d581362..3f1a78f 100644 --- a/test/toxiproxy_ex_test.exs +++ b/test/toxiproxy_ex_test.exs @@ -65,7 +65,7 @@ defmodule ToxiproxyExTest do assert_proxy_available(proxy) # Use private API to retrieve toxics for the proxy. - {:ok, proxies} = Proxy.toxics(proxy) + proxies = Proxy.toxics(proxy) assert Enum.empty?(proxies) end) end @@ -208,9 +208,7 @@ defmodule ToxiproxyExTest do |> ToxiproxyEx.upstream(:latency, latency: 100, name: "my_upstream_toxic") |> ToxiproxyEx.apply!(fn -> # Use private API to retrieve toxics for the proxy. - {:ok, toxics} = Proxy.toxics(proxy) - - names = Enum.map(toxics, & &1.name) |> Enum.sort() + names = proxy |> Proxy.toxics() |> Enum.map(& &1.name) |> Enum.sort() assert names == Enum.sort(["latency_downstream", "latency_upstream", "my_upstream_toxic"]) end) end) @@ -289,6 +287,7 @@ defmodule ToxiproxyExTest do test "version" do version = ToxiproxyEx.version!() + assert is_binary(version) assert String.starts_with?(version, "2.") end @@ -321,13 +320,99 @@ defmodule ToxiproxyExTest do |> ToxiproxyEx.downstream(:latency, latency: 100) |> ToxiproxyEx.downstream(:latency, latency: 100) - assert_raise ArgumentError, - "There are multiple toxics with the name 'latency_downstream' for proxy 'test_echo_server', please override the default name (_)", - fn -> - ToxiproxyEx.apply!(collection, fn -> - nil - end) - end + message = """ + there are multiple toxics with the name "latency_downstream" for proxy \ + "test_echo_server", please override the default name (_)\ + """ + + assert_raise ArgumentError, message, fn -> + ToxiproxyEx.apply!(collection, fn -> + nil + end) + end end) end + + describe "apply!/2" do + test "destroys applied toxics if the passed function raises" do + with_tcpserver(fn port -> + proxy = ToxiproxyEx.create!(upstream: "localhost:#{port}", name: "test_echo_server") + + toxic_collection = + proxy + |> ToxiproxyEx.downstream(:latency, name: "apply_raise1_downstream", latency: 100) + |> ToxiproxyEx.downstream(:latency, name: "apply_raise2_downstream", timeout: 1000) + + assert_raise RuntimeError, "boom", fn -> + ToxiproxyEx.apply!(toxic_collection, fn -> raise "boom" end) + end + + assert ToxiproxyEx.Client.request!(:get, "/proxies/#{proxy.name}/toxics") == [] + end) + end + + test "destroys applied toxics if the passed function exits" do + with_tcpserver(fn port -> + proxy = ToxiproxyEx.create!(upstream: "localhost:#{port}", name: "test_echo_server") + + toxic_collection = + proxy + |> ToxiproxyEx.downstream(:latency, name: "apply_exit1_downstream", latency: 100) + |> ToxiproxyEx.downstream(:latency, name: "apply_exit2_downstream", timeout: 1000) + + catch_exit(ToxiproxyEx.apply!(toxic_collection, fn -> exit(:boom) end)) + + assert ToxiproxyEx.Client.request!(:get, "/proxies/#{proxy.name}/toxics") == [] + end) + end + + test "destroys applied toxics if the passed function throws" do + with_tcpserver(fn port -> + proxy = ToxiproxyEx.create!(upstream: "localhost:#{port}", name: "test_echo_server") + + toxic_collection = + proxy + |> ToxiproxyEx.downstream(:latency, name: "apply_throw1_downstream", latency: 100) + |> ToxiproxyEx.downstream(:latency, name: "apply_throw2_downstream", timeout: 1000) + + catch_throw(ToxiproxyEx.apply!(toxic_collection, fn -> throw(:boom) end)) + + assert ToxiproxyEx.Client.request!(:get, "/proxies/#{proxy.name}/toxics") == [] + end) + end + end + + describe "down!/2" do + test "destroys applied toxics if the passed function raises" do + with_tcpserver(fn port -> + proxy = ToxiproxyEx.create!(upstream: "localhost:#{port}", name: "proxy_which_is_down") + + assert_raise RuntimeError, "boom", fn -> + ToxiproxyEx.down!(proxy, fn -> raise "boom" end) + end + + assert %{"enabled" => true} = ToxiproxyEx.Client.request!(:get, "/proxies/#{proxy.name}") + end) + end + + test "destroys applied toxics if the passed function exits" do + with_tcpserver(fn port -> + proxy = ToxiproxyEx.create!(upstream: "localhost:#{port}", name: "proxy_which_is_down") + + catch_exit(ToxiproxyEx.down!(proxy, fn -> exit(:boom) end)) + + assert %{"enabled" => true} = ToxiproxyEx.Client.request!(:get, "/proxies/#{proxy.name}") + end) + end + + test "destroys applied toxics if the passed function throws" do + with_tcpserver(fn port -> + proxy = ToxiproxyEx.create!(upstream: "localhost:#{port}", name: "proxy_which_is_down") + + catch_throw(ToxiproxyEx.down!(proxy, fn -> throw(:boom) end)) + + assert %{"enabled" => true} = ToxiproxyEx.Client.request!(:get, "/proxies/#{proxy.name}") + end) + end + end end