defmodule TestServer do @external_resource "README.md" @moduledoc "README.md" |> File.read!() |> String.split("") |> Enum.fetch!(1) alias Plug.Conn alias TestServer.{Instance, InstanceManager} @doc """ Start a test server instance. The instance will be terminated when the test case finishes. ## Options * `:port` - integer of port number, defaults to random port that can be opened; * `:scheme` - an atom for the http scheme. Defaults to `:http`; * `:cowboy_options` - See [Cowboy docs](https://ninenines.eu/docs/en/cowboy/2.5/manual/cowboy_http/) """ @spec start(keyword()) :: {:ok, pid()} def start(options \\ []) do case ExUnit.fetch_test_supervisor() do {:ok, sup} -> start_with_ex_unit(options, sup) :error -> raise ArgumentError, "can only be called in a test process" end end defp start_with_ex_unit(options, _sup) do case InstanceManager.start_instance(options) do {:ok, instance} -> ExUnit.Callbacks.on_exit(fn -> stop(instance) end) {:ok, instance} {:error, error} -> raise_start_failure({:error, error}) end end defp raise_start_failure({:error, {{:EXIT, reason}, _spec}}) do raise_start_failure({:error, reason}) end defp raise_start_failure({:error, error}) do raise """ EXIT when starting #{__MODULE__.Instance}: #{Exception.format_exit(error)} """ end @doc """ Shuts down the current test server instance """ @spec stop() :: :ok | {:error, term()} def stop do case InstanceManager.get_by_caller(self()) do nil -> :ok instance -> stop(instance) end end @doc """ Shuts down a test server instance """ @spec stop(pid()) :: :ok | {:error, term()} def stop(instance) do case Process.alive?(instance) do true -> verify_routes!(instance) InstanceManager.stop_instance(instance) false -> :ok end end defp verify_routes!(instance) do case Instance.active_routes(instance) do [] -> :ok routes -> raise """ The test ended before the following #{inspect(__MODULE__)} route(s) received a request: #{Instance.routes_info(routes)} """ end end @spec url() :: binary() def url, do: url("") @spec url(binary() | keyword()) :: binary() def url(uri) when is_binary(uri), do: url(uri, []) def url(opts) when is_list(opts), do: url("", opts) @doc """ Produces a URL for the test server instance. ## Options * `:host` - binary host value, it'll be added to inet for IP 127.0.0.1, defaults to `"localhost"`; """ @spec url(binary(), keyword()) :: binary() def url(uri, opts) do unless is_nil(opts[:host]) or is_binary(opts[:host]), do: raise("Invalid host, got: #{inspect(opts[:host])}") {:ok, instance} = autostart() domain = maybe_enable_host(opts[:host]) options = Instance.get_options(instance) "#{Keyword.fetch!(options, :scheme)}://#{domain}:#{Keyword.fetch!(options, :port)}#{uri}" end defp autostart do case InstanceManager.get_by_caller(self()) do nil -> start([]) instance -> {:ok, instance} end end defp maybe_enable_host(nil), do: "localhost" defp maybe_enable_host(host) do :inet_db.set_lookup([:file, :dns]) :inet_db.add_host({127, 0, 0, 1}, [String.to_charlist(host)]) host end @doc """ Adds a route to the test server. ## Options * `:via` - matches the route against some specific HTTP method(s) specified as an atom, like `:get` or `:put`, or a list, like `[:get, :post]`. * `:match` - an anonymous function that will be called to see if a route matches, defaults to matching with arguments of uri and `:via` option. * `:to` - a Plug or anonymous function that will be called when the route matches. """ @spec add(binary(), keyword()) :: :ok | {:error, term()} def add(uri, options \\ []) when is_binary(uri) and is_list(options) do {:ok, instance} = autostart() {:current_stacktrace, [_process, _test_server | stacktrace]} = Process.info(self(), :current_stacktrace) options = Keyword.put_new(options, :to, &default_response_handler/1) Instance.register(instance, {uri, options, stacktrace}) end defp default_response_handler(conn) do Conn.send_resp(conn, 200, to_string(Conn.get_http_protocol(conn))) end @doc """ Fetches the generated x509 suite for the current test server instance. """ @spec x509_suite() :: term() def x509_suite do case InstanceManager.get_by_caller(self()) do nil -> raise "#{inspect(Instance)} is not running, did you start it?" instance -> x509_suite(instance) end end @doc """ Fetches the generated x509 suite for a test server instance. """ @spec x509_suite(pid()) :: term() def x509_suite(instance) do options = Instance.get_options(instance) cond do not (options[:scheme] == :https) -> raise "The #{inspect(Instance)} is not running with `[scheme: :https]` option" not Keyword.has_key?(options, :x509_suite) -> raise "The #{inspect(Instance)} is running with custom SSL" true -> options[:x509_suite] end end end