defmodule TestServer do
@external_resource "README.md"
@moduledoc "README.md"
|> File.read!()
|> String.split("<!-- MDOC !-->")
|> 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