defmodule Multiverses.Http do
@moduledoc """
Multiverses suite to enable isolated HTTP communications over multiverse shards
## How to Use
### In your Phoenix endpoint:
```
defmodule MyAppWeb.Endpoint do
#...
if Mix.env() == :test do
plug Multiverses.Plug
end
#...
end
```
> #### Warning {: .warning}
>
> If you anticipate needing to recompile the Endpoint module on the fly you will need a better way to
> store the build environment, as the Mix module may not be available to the running VM.
### In your test (assuming the `Multiverses.Req` adapter is used)
- declare that your test shards over the Http multiverses name domain.
- alias `Multiverses.Req` instead of using the `Req` module.
defmodule MyAppTest do
alias Multiverses.Req
@port #....
setup do
Multiverses.shard(Http)
end
test "some test" do
result = Req.get("localhost:\#{@port}")
assert result = #...
end
end
## How it works
The adapters for the http clients inject the `Multiverses.shard/1` id into
the http header `x-multiverse-id`. This is then intercepted by the
`Multiverses.Plug` module plug in your Endpoint, which then performs a
lookup to obtain the `:$callers` for the request; this `:$callers` chain
is then added to the request process dictionary for the duration of the
http Plug pipeline.
Because this exchange installs `:$callers` a side effect is that other
services that are `:$callers` aware, such as `Mox` or `Ecto` will be able
to see their respective checkouts.
## Cluster awareness
`Multiverses.Http` is tested to work over a BEAM cluster.
If you spin up a cluster as a part of your tests, you can issue an http
request to a node that is *not* the node running `ExUnit` and the `:$callers`
for the request will be remote pids (relative to the request handlers).
Before proceeding with clustered tests, check to see if the modules that
depend on this (e.g. `Mox`, `Ecto`) are able to correctly route their sandbox
checkouts to the correct node in the cluster.
## Other backends
Currently there is support only for the `Req` client library. If support for
other libraries is desired, please issue a PR. Compilation of library adapters
will be gated on the `:http_clients` application environment variable, which is
currently set to `[Req]`.
"""
use GenServer
@this {:global, __MODULE__}
def start_link(_) do
case GenServer.start_link(__MODULE__, [], name: @this) do
{:error, {:already_started, _}} -> :ignore
other -> other
end
end
def init(_) do
table = :ets.new(__MODULE__, [:named_table, :set, :protected, read_concurrency: true])
{:ok, table}
end
@doc """
obtains the `Multiverses` shard (`Http`) id and registers the current calling
process `:$callers` stack, if it has not already been registered.
Generally you would only want to call this function if you are implementing
an adapter for an HTTP or websocket client library.
"""
def registered_id do
Http
|> Multiverses.id()
|> get_registered()
end
defp get_registered(id) do
if node(:global.whereis_name(__MODULE__)) == node() do
get_registered(__MODULE__, id) || register(id)
else
GenServer.call(@this, {:get_registered, id, get_callers()})
end
end
defp get_registered(table, id) do
table
|> :ets.select([{{:"$1", :_}, [{:==, :"$1", {:const, id}}], [:"$1"]}])
|> List.first()
end
defp get_registered_impl(id, callers, _from, table) do
case :ets.select_count(table, [{{:"$1", :_}, [{:==, :"$1", {:const, id}}], [{{}}]}]) do
0 ->
:ets.insert(table, {id, callers})
_ ->
[]
end
{:reply, id, table}
end
@doc false
def register(id) do
GenServer.call(@this, {:register, id, get_callers()})
end
defp register_impl(id, callers, _from, table) do
:ets.insert(table, {id, callers})
{:reply, id, table}
end
defp get_callers do
[self() | List.wrap(Process.get(:"$callers"))]
end
defp get_callers(id) do
if node(:global.whereis_name(__MODULE__)) == node() do
get_callers(__MODULE__, id)
else
GenServer.call(@this, {:get_callers, id})
end
end
defp get_callers(table, id) do
table
|> :ets.select([{{:"$1", :"$2"}, [{:==, :"$1", {:const, id}}], [:"$2"]}])
|> List.first()
end
defp get_callers_impl(id, _from, table) do
{:reply, get_callers(table, id), table}
end
@doc """
finds the current process' `Multiverses` shard (`Http`) id and looks up the
associated `:$callers` chain. This chain is then imported into as the
current process' `:$callers` chain.
Generally you would only want to call this function if you are writing a non-
`Plug` http handler or a websocket server. In such a case you should call
this function immediately after associating the current process with the
shard using `Multiverses.allow/3` or `Multiverses.allow/2`.
"""
def adopt_callers(id) do
Process.put(:"$callers", get_callers(id))
end
def handle_call({:get_registered, id, callers}, from, table),
do: get_registered_impl(id, callers, from, table)
def handle_call({:register, id, callers}, from, table),
do: register_impl(id, callers, from, table)
def handle_call({:get_callers, id}, from, table), do: get_callers_impl(id, from, table)
end