defmodule Mix.Tasks.Preview.Rendering do
@moduledoc """
Previews notification rendering through the production library pipeline.
The canonical preview path is the `Chimeway.preview_rendering/3` API function.
This task is a data-only wrapper for CLI convenience.
For advanced executable Elixir preview experimentation, use `mix run -e` or `iex -S mix`
rather than passing executable inputs to this task.
Usage:
mix preview.rendering --notifier MyApp.Notifiers.CommentCreated \\
--params-json '{"id": 1}' \\
--recipient-json '{"id": "recipient-1"}' \\
--channel email
"""
use Mix.Task
@shortdoc "Preview one channel rendering without dispatching provider traffic"
@switches [
notifier: :string,
channel: :string,
params_json: :string,
params_file: :string,
param: :keep,
recipient_json: :string,
recipient_file: :string,
recipient_field: :keep,
help: :boolean
]
@impl Mix.Task
def run(argv) do
case OptionParser.parse(argv, strict: @switches) do
{[help: true], _, _} ->
Mix.shell().info(usage())
{opts, _, []} ->
with :ok <- validate_required_switches(opts),
{:ok, notifier} <- parse_notifier(Keyword.fetch!(opts, :notifier)),
{:ok, params} <- build_map_input(opts, :params_file, :params_json, :param),
{:ok, recipient} <-
build_map_input(opts, :recipient_file, :recipient_json, :recipient_field),
{:ok, preview} <-
Chimeway.preview_rendering(
notifier,
params,
recipient: recipient,
channel: Keyword.fetch!(opts, :channel)
) do
Mix.shell().info(format_preview(preview))
else
{:error, reason} ->
Mix.shell().error(format_error(reason))
exit({:shutdown, 1})
end
{_opts, _args, invalid} ->
Mix.shell().error("Unknown options: #{Enum.join(invalid, ", ")}\n\n#{usage()}")
exit({:shutdown, 1})
end
end
defp validate_required_switches(opts) do
has_notifier = Keyword.has_key?(opts, :notifier)
has_channel = Keyword.has_key?(opts, :channel)
has_params =
Keyword.has_key?(opts, :params_json) or Keyword.has_key?(opts, :params_file) or
Keyword.has_key?(opts, :param)
has_recipient =
Keyword.has_key?(opts, :recipient_json) or Keyword.has_key?(opts, :recipient_file) or
Keyword.has_key?(opts, :recipient_field)
if has_notifier and has_channel and has_params and has_recipient do
:ok
else
{:error, :missing_required_options}
end
end
defp parse_notifier(value) when is_binary(value) do
module_name =
if String.starts_with?(value, "Elixir.") do
value
else
"Elixir." <> value
end
try do
module = String.to_existing_atom(module_name)
case Code.ensure_loaded(module) do
{:module, ^module} -> {:ok, module}
_ -> {:error, {:invalid_notifier, value}}
end
rescue
ArgumentError -> {:error, {:invalid_notifier, value}}
end
end
defp build_map_input(opts, file_key, json_key, kv_key) do
with {:ok, from_file} <- parse_file_json(Keyword.get(opts, file_key)),
{:ok, from_json} <- parse_inline_json(Keyword.get(opts, json_key)),
{:ok, from_kv} <- parse_kv_pairs(Keyword.get_values(opts, kv_key)) do
merged =
from_file
|> Map.merge(from_json)
|> Map.merge(from_kv)
{:ok, merged}
end
end
defp parse_file_json(nil), do: {:ok, %{}}
defp parse_file_json(path) do
case File.read(path) do
{:ok, content} ->
case Jason.decode(content) do
{:ok, term} when is_map(term) -> {:ok, term}
{:ok, _other} -> {:error, {:invalid_json_map, :file, path}}
{:error, _reason} -> {:error, {:invalid_json, :file, path}}
end
{:error, reason} ->
{:error, {:file_read_error, path, reason}}
end
end
defp parse_inline_json(nil), do: {:ok, %{}}
defp parse_inline_json(content) do
case Jason.decode(content) do
{:ok, term} when is_map(term) -> {:ok, term}
{:ok, _other} -> {:error, {:invalid_json_map, :inline}}
{:error, _reason} -> {:error, {:invalid_json, :inline}}
end
end
defp parse_kv_pairs([]), do: {:ok, %{}}
defp parse_kv_pairs(pairs) do
Enum.reduce_while(pairs, {:ok, %{}}, fn pair, {:ok, acc} ->
case String.split(pair, "=", parts: 2) do
[key, value] -> {:cont, {:ok, Map.put(acc, key, value)}}
_ -> {:halt, {:error, {:invalid_kv_pair, pair}}}
end
end)
end
defp format_preview(preview) do
"""
Preview rendering
render_key: #{preview.render_key}
render_version: #{preview.render_version}
channel: #{preview.channel}
render_data: #{inspect(preview.render_data, pretty: true)}
"""
|> String.trim_trailing()
end
defp format_error(:missing_required_options) do
"Missing required options.\n\nYou must provide --notifier, --channel, and at least one source for params and recipient.\n\n#{usage()}"
end
defp format_error({:invalid_json_map, source}) do
"Preview rendering failed: JSON from #{source} must be a map/object"
end
defp format_error({:invalid_json_map, source, path}) do
"Preview rendering failed: JSON from #{source} (#{path}) must be a map/object"
end
defp format_error({:invalid_json, source, path}) do
"Preview rendering failed: Invalid JSON syntax in #{source} (#{path})"
end
defp format_error({:invalid_json, source}) do
"Preview rendering failed: Invalid JSON syntax in #{source}"
end
defp format_error(reason), do: "Preview rendering failed: #{inspect(reason)}"
defp usage do
"""
Usage: mix preview.rendering --notifier MODULE --channel CHANNEL [DATA FLAGS]
Required options:
--notifier notifier module name, for example MyApp.Notifiers.CommentCreated
--channel channel name to preview, for example email or in_app
Data flags (at least one for params, one for recipient):
--params-json Inline JSON string for params
--params-file Path to JSON file for params
--param Key=value pair (can be repeated)
--recipient-json Inline JSON string for recipient
--recipient-file Path to JSON file for recipient
--recipient-field Key=value pair (can be repeated)
"""
|> String.trim_trailing()
end
end