defmodule Circlex.Emulator.State do
use GenServer
require Logger
alias Circlex.Emulator.State.{
BankAccountState,
PaymentState,
PayoutState,
RecipientState,
SubscriptionState,
TransferState,
WalletState
}
def start_link(opts \\ []) do
name = Keyword.get(opts, :name, __MODULE__)
next = Keyword.get(opts, :next, %{})
signer_proc = Keyword.get(opts, :signer_proc, nil)
{initial_state, persistor} =
case Keyword.get(opts, :initial_state, nil) do
{:file, file} ->
{read_file(file), nil}
{:persist, file} ->
{read_file(file), {:file, file}}
els ->
{els, nil}
end
Logger.info("Starting Circlex.Emulator.State #{name}...")
GenServer.start_link(
__MODULE__,
%{
st: initial_state,
idempotency_keys: [],
signer_proc: signer_proc,
next: next,
persistor: persistor
},
name: name
)
end
@impl true
def init(state = %{st: st, signer_proc: signer_proc}) do
if not is_nil(signer_proc), do: Process.put(:signer_proc, signer_proc)
initial_st =
WalletState.initial_state()
|> Map.merge(BankAccountState.initial_state())
|> Map.merge(TransferState.initial_state())
|> Map.merge(PaymentState.initial_state())
|> Map.merge(PayoutState.initial_state())
|> Map.merge(RecipientState.initial_state())
|> Map.merge(SubscriptionState.initial_state())
|> Map.merge(do_restore_st(st))
{:ok, Map.put(state, :st, initial_st)}
end
defp get_pid() do
case Process.get(:state_pid) do
pid when is_pid(pid) ->
pid
name when is_atom(name) and not is_nil(name) ->
Process.whereis(name)
nil ->
__MODULE__
end
end
def next(type) do
GenServer.call(get_pid(), {:next, type})
end
def check_idempotency_key(idempotency_key) do
GenServer.call(get_pid(), {:check_idempotency_key, idempotency_key})
end
def restore_state(json) do
GenServer.cast(get_pid(), {:restore_state, json})
end
def serialize_state() do
GenServer.call(get_pid(), :serialize_state)
end
def get_st(mfa_or_fn, keys \\ [], filter_fn \\ nil) do
GenServer.call(get_pid(), {:get_st, mfa_or_fn, keys, filter_fn})
end
def update_st(mfa_or_fn, keys \\ [], filter_fn \\ nil) do
GenServer.cast(get_pid(), {:update_st, mfa_or_fn, keys, filter_fn})
end
def get_in(keys, default \\ nil) do
case get_st(fn x -> x end, keys) do
nil ->
default
els ->
els
end
end
def put_in(keys, val) do
update_st(fn _ -> val end, keys)
end
def persist(persistor) do
GenServer.cast(get_pid(), {:persist, persistor})
end
@impl true
def handle_cast(
{:update_st, {mod, fun, args}, keys, filter_fn},
state = %{st: st, persistor: persistor}
) do
case apply(mod, fun, [get_val(st, keys, filter_fn) | args]) do
{:ok, res} ->
new_st = do_put_in(st, keys, res)
do_persist(persistor, new_st)
{:noreply, Map.put(state, :st, new_st)}
end
end
def handle_cast({:update_st, f, keys, filter_fn}, state = %{st: st, persistor: persistor})
when is_function(f) do
case f.(get_val(st, keys, filter_fn)) do
{:ok, res} ->
new_st = do_put_in(st, keys, res)
do_persist(persistor, new_st)
{:noreply, Map.put(state, :st, new_st)}
end
end
def handle_cast({:restore_state, new_st}, state) do
{:noreply, Map.put(state, :st, do_restore_st(new_st))}
end
def handle_cast({:persist, persistor}, state = %{st: st}) do
do_persist(st, persistor)
{:noreply, state}
end
@impl true
def handle_call({:get_st, {mod, fun, args}, keys, filter_fn}, _from, state = %{st: st}) do
{:reply, apply(mod, fun, [get_val(st, keys, filter_fn) | args]), state}
end
def handle_call({:get_st, f, keys, filter_fn}, _from, state = %{st: st}) when is_function(f) do
{:reply, f.(get_val(st, keys, filter_fn)), state}
end
def handle_call(
{:check_idempotency_key, idempotency_key},
_from,
state = %{idempotency_keys: idempotency_keys}
) do
if Enum.member?(idempotency_keys, idempotency_key) do
{:reply, :reused_key, state}
else
{:reply, :ok, %{state | idempotency_keys: [idempotency_key | idempotency_keys]}}
end
end
# TODO: We could simplify this down to just transform all
# fn calls to be `{fn, acc}`-style, but this is easier
# for now, since it's less burden on the caller.
def handle_call({:next, type}, _from, state = %{next: next}) do
case next[type] do
nil ->
{:reply, generate_type(type), state}
[] ->
{:reply, generate_type(type), state}
f when is_function(f) ->
{:reply, f.(), state}
{f, acc} when is_function(f) ->
{v, new_acc} = f.(acc)
{:reply, v, %{state | next: Map.put(next, type, {f, new_acc})}}
[v | rest] ->
{:reply, v, %{state | next: Map.put(next, type, rest)}}
end
end
def handle_call(:serialize_state, _from, state = %{st: st}) do
{:reply, serialize_st(st), state}
end
defp get_val(st, keys, filter_fn) do
st
|> do_get_in(keys)
|> apply_filter(filter_fn)
end
defp apply_filter(val, nil), do: val
defp apply_filter(val, f), do: f.(val)
defp do_get_in(st, keys, default \\ nil)
defp do_get_in(nil, _, default), do: default
defp do_get_in(st, [], _), do: st
defp do_get_in(st, [k | rest], default) when is_map(st) do
do_get_in(st[k], rest, default)
end
defp do_put_in(_, [], val), do: val
defp do_put_in(st, [key], val) when is_map(st), do: Map.put(st, key, val)
defp do_put_in(st, [key | rest], val) when is_map(st),
do: Map.put(st, key, do_put_in(Map.get(st, key, %{}), rest, val))
defp do_restore_st(nil), do: %{}
defp do_restore_st(st) do
st
|> WalletState.deserialize()
|> BankAccountState.deserialize()
|> TransferState.deserialize()
|> PaymentState.deserialize()
|> PayoutState.deserialize()
|> RecipientState.deserialize()
|> SubscriptionState.deserialize()
end
defp generate_type(:uuid), do: UUID.uuid1()
defp generate_type(:wallet_id), do: Enum.random(1_000_000_000..1_001_000_000) |> to_string()
defp generate_type(:date), do: DateTime.to_iso8601(DateTime.utc_now())
defp generate_type(:eth_keypair), do: Signet.Keys.generate_keypair()
defp generate_type(:tracking_ref),
do: "CIR3KXLL" <> to_string(System.unique_integer([:positive]))
defp generate_type(:external_ref),
do: "EXTREF" <> to_string(System.unique_integer([:positive]))
defp generate_type(:virtual_account_number),
do: Enum.random(100_000_000_000..999_999_999_999) |> to_string()
defp serialize_st(st) do
st
|> WalletState.serialize()
|> BankAccountState.serialize()
|> TransferState.serialize()
|> PaymentState.serialize()
|> PayoutState.serialize()
|> RecipientState.serialize()
|> SubscriptionState.serialize()
end
# TODO: We could probably move this out of this process and make it best effort.
defp do_persist({:file, file}, st) do
st_enc =
st
|> serialize_st()
|> Jason.encode!(pretty: true)
File.write!(file, st_enc)
end
defp do_persist(nil, _st), do: :ok
defp read_file(file) do
file
|> File.read!()
|> Jason.decode!(keys: :atoms)
end
end