defmodule Mix.Tasks.PhoenixMicro.Gen.Saga do
use Mix.Task
@shortdoc "Generates a PhoenixMicro saga module"
@moduledoc """
Generates a PhoenixMicro saga module with stub steps and compensations.
## Usage
mix phoenix_micro.gen.saga MyApp.PlaceOrderSaga reserve_inventory charge_payment
## Options
--no-compensate Generate steps without compensation stubs
## Example
mix phoenix_micro.gen.saga MyApp.Checkout.PlaceOrderSaga \\
reserve_inventory \\
charge_payment \\
create_shipment \\
notify_customer
Generates `lib/my_app/checkout/place_order_saga.ex` and a matching test file.
"""
@switches [no_compensate: :boolean]
@spec run([String.t()]) :: any()
@impl Mix.Task
def run(argv) do
{opts, args, _bad_opts} = OptionParser.parse(argv, switches: @switches)
case args do
[module_name | [_first | _rest] = steps] ->
generate(module_name, steps, opts)
[_module_name] ->
IO.puts(
:stderr,
"Provide at least one step name.\n\n" <>
"Usage: mix phoenix_micro.gen.saga <Module> <step1> [step2 ...]"
)
exit({:shutdown, 1})
[] ->
IO.puts(:stderr, "Usage: mix phoenix_micro.gen.saga <Module> <step1> [step2 ...]")
exit({:shutdown, 1})
end
end
# ---------------------------------------------------------------------------
# Private
# ---------------------------------------------------------------------------
defp generate(module_name, steps, opts) do
no_compensate = Keyword.get(opts, :no_compensate, false)
file_path = module_to_path(module_name)
test_path = module_to_test_path(module_name)
create_file(file_path, render_saga(module_name, steps, no_compensate))
create_file(test_path, render_saga_test(module_name, steps))
IO.puts("""
Saga generated successfully!
Files created:
#{file_path}
#{test_path}
Run the saga:
{:ok, ctx} = PhoenixMicro.Saga.run(#{module_name}, %{your: "context"})
""")
end
defp render_saga(module_name, steps, no_compensate) do
step_blocks =
Enum.map_join(steps, "\n\n", fn step_name ->
build_step_block(step_name, no_compensate)
end)
steps_list = Enum.map_join(steps, "\n", fn s -> " #{s}" end)
IO.iodata_to_binary([
"defmodule ",
module_name,
" do\n",
" @moduledoc \"\"\"\n",
" Saga: ",
module_name,
"\n\n",
" Generated by `mix phoenix_micro.gen.saga`.\n\n",
" Steps (in order):\n",
steps_list,
"\n\n",
" If any step fails, completed steps are compensated in reverse order.\n",
" \"\"\"\n\n",
" use PhoenixMicro.Saga\n\n",
step_blocks,
"\n",
"end\n"
])
end
defp build_step_block(step_name, no_compensate) do
compensate_part =
if no_compensate do
""
else
IO.iodata_to_binary([
",\n",
" compensate: fn context ->\n",
" # TODO: undo ",
step_name,
"\n",
" _ = context\n",
" :ok\n",
" end"
])
end
IO.iodata_to_binary([
" step :",
step_name,
",\n",
" execute: fn context ->\n",
" # TODO: implement ",
step_name,
"\n",
" # Return {:ok, updated_context} or {:error, reason}\n",
" _ = context\n",
" {:ok, context}\n",
" end",
compensate_part,
",\n",
" timeout: 30_000\n"
])
end
defp render_saga_test(module_name, steps) do
step_atoms = Enum.map_join(steps, ", ", fn s -> ":" <> s end)
IO.iodata_to_binary([
"defmodule ",
module_name,
"Test do\n",
" use ExUnit.Case, async: false\n\n",
" alias PhoenixMicro.Saga\n\n",
" @saga ",
module_name,
"\n\n",
" setup do\n",
" start_supervised!({Registry, keys: :unique, name: PhoenixMicro.Registry})\n",
" start_supervised!(PhoenixMicro.Saga.Supervisor)\n",
" :ok\n",
" end\n\n",
" describe \"__saga_steps__/0\" do\n",
" test \"defines expected steps in order\" do\n",
" steps = @saga.__saga_steps__()\n",
" names = Enum.map(steps, & &1.name)\n",
" assert names == [",
step_atoms,
"]\n",
" end\n",
" end\n\n",
" describe \"Saga.run/3\" do\n",
" test \"completes successfully with stub implementations\" do\n",
" result =\n",
" Task.async(fn -> Saga.run(@saga, %{}, timeout: 10_000) end)\n",
" |> Task.await(15_000)\n\n",
" assert {:ok, _context} = result\n",
" end\n",
" end\n",
"end\n"
])
end
defp module_to_path(module_name) do
path =
module_name
|> String.replace(".", "/")
|> Macro.underscore()
"lib/#{path}.ex"
end
defp module_to_test_path(module_name) do
path =
module_name
|> String.replace(".", "/")
|> Macro.underscore()
"test/#{path}_test.exs"
end
defp create_file(path, content) do
File.mkdir_p!(Path.dirname(path))
if File.exists?(path) do
if String.downcase(IO.gets("#{path} already exists. Overwrite? [y/N] ")) =~ ~r/^y/i do
File.write!(path, content)
IO.puts(" * overwrite #{path}")
end
else
File.write!(path, content)
IO.puts(" * create #{path}")
end
end
end