Repository: phoenixframework/phoenix
Pull request #4368 (2021-07-13 04:00:16)

Add mix phx.gen.notifier
This task generates email notifiers under a given context. It expects
that the app is configured with a "mailer" that is generated by `phx.new`.

PS: this feature was discussed before with José. It is related to https://github.com/phoenixframework/phoenix/pull/4355 

philss: ping @josevalim :)

Patch:
diff --git a/lib/mix/phoenix/context.ex b/lib/mix/phoenix/context.ex
index 97a5dbfe4..27fbc6562 100644
--- a/lib/mix/phoenix/context.ex
+++ b/lib/mix/phoenix/context.ex
@@ -22,6 +22,10 @@ defmodule Mix.Phoenix.Context do
     context =~ ~r/^[A-Z]\w*(\.[A-Z]\w*)*$/
   end
 
+  def new(context_name, opts) do
+    new(context_name, %Schema{}, opts)
+  end
+
   def new(context_name, %Schema{} = schema, opts) do
     ctx_app   = opts[:context_app] || Mix.Phoenix.context_app()
     base      = Module.concat([Mix.Phoenix.context_base(ctx_app)])
diff --git a/lib/mix/tasks/phx.gen.notifier.ex b/lib/mix/tasks/phx.gen.notifier.ex
new file mode 100644
index 000000000..3f68d735b
--- /dev/null
+++ b/lib/mix/tasks/phx.gen.notifier.ex
@@ -0,0 +1,167 @@
+defmodule Mix.Tasks.Phx.Gen.Notifier do
+  @shortdoc "Generates a notifier that delivers emails by default"
+
+  @moduledoc """
+  Generates a notifier that delivers emails by default.
+
+      mix phx.gen.notifier Accounts User welcome_user reset_password confirmation_instructions
+
+  This task expects a context module name, followed by a
+  notifier name and one or more message names. Messages
+  are the functions that will be created prefixed by "deliver",
+  so the message name should be "snake_case" without punctuation.
+
+  Note that this task expects that you have a mailer defined
+  as "YourApp.Mailer". Check the `mix phx.new` task for more details.
+  """
+
+  use Mix.Task
+
+  @switches [
+    context: :boolean,
+    context_app: :string,
+    prefix: :string
+  ]
+
+  @default_opts [context: true]
+
+  alias Mix.Phoenix.Context
+
+  @doc false
+  def run(args) do
+    if Mix.Project.umbrella?() do
+      Mix.raise(
+        "mix phx.gen.notifier must be invoked from within your *_web application root directory"
+      )
+    end
+
+    {context, notifier_module, messages} = build(args)
+
+    inflections = Mix.Phoenix.inflect(notifier_module)
+
+    binding = [
+      context: context,
+      inflections: inflections,
+      notifier_messages: messages
+    ]
+
+    paths = Mix.Phoenix.generator_paths()
+
+    prompt_for_conflicts(context)
+
+    copy_new_files(context, binding, paths)
+  end
+
+  @doc false
+  def build(args, help \\ __MODULE__) do
+    {opts, parsed, _} = parse_opts(args)
+    [context_name, notifier_name | notifier_messages] = validate_args!(parsed, help)
+
+    notifier_module = inspect(Module.concat(context_name, "#{notifier_name}Notifier"))
+    context = Context.new(notifier_module, opts)
+
+    {context, notifier_module, notifier_messages}
+  end
+
+  defp parse_opts(args) do
+    {opts, parsed, invalid} = OptionParser.parse(args, switches: @switches)
+
+    merged_opts =
+      @default_opts
+      |> Keyword.merge(opts)
+      |> put_context_app(opts[:context_app])
+
+    {merged_opts, parsed, invalid}
+  end
+
+  defp put_context_app(opts, nil), do: opts
+
+  defp put_context_app(opts, string) do
+    Keyword.put(opts, :context_app, String.to_atom(string))
+  end
+
+  defp validate_args!([context, notifier | messages] = args, help) do
+    cond do
+      not Context.valid?(context) ->
+        help.raise_with_help(
+          "Expected the context, #{inspect(context)}, to be a valid module name"
+        )
+
+      not valid_notifier?(notifier) ->
+        help.raise_with_help(
+          "Expected the notifier, #{inspect(notifier)}, to be a valid module name"
+        )
+
+      context == Mix.Phoenix.base() ->
+        help.raise_with_help(
+          "Cannot generate context #{context} because it has the same name as the application"
+        )
+
+      notifier == Mix.Phoenix.base() ->
+        help.raise_with_help(
+          "Cannot generate notifier #{notifier} because it has the same name as the application"
+        )
+
+      Enum.any?(messages, &(!valid_message?(&1))) ->
+        help.raise_with_help(
+          "Cannot generate notifier #{inspect(notifier)} because one of the messages is invalid: #{Enum.map_join(messages, ", ", &inspect/1)}"
+        )
+
+      true ->
+        args
+    end
+  end
+
+  defp validate_args!(_, help) do
+    help.raise_with_help("Invalid arguments")
+  end
+
+  defp valid_notifier?(notifier) do
+    notifier =~ ~r/^[A-Z]\w*(\.[A-Z]\w*)*$/
+  end
+
+  defp valid_message?(message_name) do
+    message_name =~ ~r/^[a-z]+(\_[a-z0-9]+)*$/
+  end
+
+  @doc false
+  @spec raise_with_help(String.t()) :: no_return()
+  def raise_with_help(msg) do
+    Mix.raise("""
+    #{msg}
+
+    mix phx.gen.notifier expects a context module name, followed by a
+    notifier name and one or more message names. Messages are the
+    functions that will be created prefixed by "deliver", so the message
+    name should be "snake_case" without punctuation.
+    For example:
+
+        mix phx.gen.notifier Accounts User welcome reset_password
+
+    In this example the notifier will be called `UserNotifier` inside
+    the Accounts context. The functions `deliver_welcome/1` and
+    `reset_password/1` will be created inside this notifier.
+    """)
+  end
+
+  defp copy_new_files(%Context{} = context, binding, paths) do
+    files = files_to_be_generated(context)
+
+    Mix.Phoenix.copy_from(paths, "priv/templates/phx.gen.notifier", binding, files)
+
+    context
+  end
+
+  defp files_to_be_generated(%Context{} = context) do
+    [
+      {:eex, "notifier.ex", context.file},
+      {:eex, "notifier_test.exs", context.test_file}
+    ]
+  end
+
+  defp prompt_for_conflicts(context) do
+    context
+    |> files_to_be_generated()
+    |> Mix.Phoenix.prompt_for_conflicts()
+  end
+end
diff --git a/priv/templates/phx.gen.notifier/notifier.ex b/priv/templates/phx.gen.notifier/notifier.ex
new file mode 100644
index 000000000..7a2cb2a52
--- /dev/null
+++ b/priv/templates/phx.gen.notifier/notifier.ex
@@ -0,0 +1,14 @@
+defmodule <%= inspect context.module %> do
+  import Swoosh.Email
+  alias <%= inspect context.base_module %>.Mailer<%= for message <- notifier_messages do %>
+
+  def deliver_<%= message %>(%{name: name, email: email}) do
+    new()
+    |> to({name, email})
+    |> from({"Phoenix Team", "team@example.com"})
+    |> subject("Welcome to Phoenix, #{name}!")
+    |> html_body("<h1>Hello, #{name}</h1>")
+    |> text_body("Hello, #{name}\n")
+    |> Mailer.deliver()
+  end<% end %>
+end


Test patch:
diff --git a/priv/templates/phx.gen.notifier/notifier_test.exs b/priv/templates/phx.gen.notifier/notifier_test.exs
new file mode 100644
index 000000000..14a76a4b3
--- /dev/null
+++ b/priv/templates/phx.gen.notifier/notifier_test.exs
@@ -0,0 +1,18 @@
+defmodule <%= inspect context.module %>Test do
+  use ExUnit.Case, async: true
+  import Swoosh.TestAssertions
+
+  alias <%= inspect context.module %>.<%= inflections[:alias] %>Notifier<%= for message <- notifier_messages do %>
+
+  test "deliver_<%= message %>/1" do
+    user = %{name: "Alice", email: "alice@example.com"}
+
+    <%= inflections[:alias] %>Notifier.deliver_<%= message %>(user)
+
+    assert_email_sent(
+      subject: "Welcome to Phoenix, Alice!",
+      to: {"Alice", "alice@example.com"},
+      text_body: ~r/Hello, Alice/
+    )
+  end<% end %>
+end
diff --git a/test/mix/tasks/phx.gen.notifier_test.exs b/test/mix/tasks/phx.gen.notifier_test.exs
new file mode 100644
index 000000000..a0fa1ddbf
--- /dev/null
+++ b/test/mix/tasks/phx.gen.notifier_test.exs
@@ -0,0 +1,129 @@
+Code.require_file("../../../installer/test/mix_helper.exs", __DIR__)
+
+defmodule Mix.Tasks.Phx.Gen.NotifierTest do
+  use ExUnit.Case
+  import MixHelper
+  alias Mix.Tasks.Phx.Gen
+
+  setup do
+    Mix.Task.clear()
+    :ok
+  end
+
+  test "new notifier", config do
+    in_tmp_project(config.test, fn ->
+      Gen.Notifier.run(~w(Accounts User welcome_user reset_password))
+
+      assert_file("lib/phoenix/accounts/user_notifier.ex", fn file ->
+        assert file =~ ~S|defmodule Phoenix.Accounts.UserNotifier do|
+        assert file =~ ~S|import Swoosh.Email|
+        assert file =~ ~S|alias Phoenix.Mailer|
+
+        assert file =~ ~S|def deliver_welcome_user(%{name: name, email: email}) do|
+        assert file =~ ~S|from({"Phoenix Team", "team@example.com"})|
+
+        assert file =~ ~S|def deliver_reset_password(%{name: name, email: email}) do|
+        assert file =~ ~S|from({"Phoenix Team", "team@example.com"})|
+
+        assert file =~ ~S|Mailer.deliver()|
+      end)
+
+      assert_file("test/phoenix/accounts/user_notifier_test.exs", fn file ->
+        assert file =~ ~S|defmodule Phoenix.Accounts.UserNotifierTest do|
+      end)
+
+      send(self(), {:mix_shell_input, :yes?, true})
+      send(self(), {:mix_shell_input, :yes?, true})
+      send(self(), {:mix_shell_input, :yes?, true})
+
+      Gen.Notifier.run(~w(Accounts User account_confirmation))
+
+      assert_received {:mix_shell, :info,
+                       ["The following files conflict with new files to be generated:" <> notice]}
+
+      assert notice =~ "user_notifier.ex"
+      assert notice =~ "user_notifier_test.exs"
+
+      assert_received {:mix_shell, :yes?, [question]}
+
+      assert question =~ "Proceed with interactive overwrite?"
+
+      assert_file("lib/phoenix/accounts/user_notifier.ex", fn file ->
+        assert file =~ ~S|defmodule Phoenix.Accounts.UserNotifier do|
+        assert file =~ ~S|import Swoosh.Email|
+        assert file =~ ~S|alias Phoenix.Mailer|
+
+        assert file =~ ~S|def deliver_account_confirmation(%{name: name, email: email}) do|
+        assert file =~ ~S|from({"Phoenix Team", "team@example.com"})|
+
+        refute file =~ ~S|def deliver_welcome_user(%{name: name, email: email}) do|
+        refute file =~ ~S|def deliver_reset_password(%{name: name, email: email}) do|
+      end)
+    end)
+  end
+
+  test "generates nested notifier", config do
+    in_tmp_project(config.test, fn ->
+      Gen.Notifier.run(~w(Admin.Accounts User welcome_user reset_password))
+
+      assert_file("lib/phoenix/admin/accounts/user_notifier.ex", fn file ->
+        assert file =~ ~S|defmodule Phoenix.Admin.Accounts.UserNotifier do|
+        assert file =~ ~S|import Swoosh.Email|
+        assert file =~ ~S|alias Phoenix.Mailer|
+
+        assert file =~ ~S|def deliver_welcome_user(%{name: name, email: email}) do|
+        assert file =~ ~S|from({"Phoenix Team", "team@example.com"})|
+
+        assert file =~ ~S|def deliver_reset_password(%{name: name, email: email}) do|
+        assert file =~ ~S|from({"Phoenix Team", "team@example.com"})|
+
+        assert file =~ ~S|Mailer.deliver()|
+      end)
+    end)
+  end
+
+  test "in an umbrella with a context_app, generates the notifier", config do
+    in_tmp_umbrella_project(config.test, fn ->
+      Application.put_env(:phoenix, :generators, context_app: {:another_app, "another_app"})
+      Gen.Notifier.run(~w(Accounts User welcome_user reset_password))
+
+      assert_file("another_app/lib/another_app/accounts/user_notifier.ex", fn file ->
+        assert file =~ ~S|defmodule AnotherApp.Accounts.UserNotifier do|
+        assert file =~ ~S|import Swoosh.Email|
+        assert file =~ ~S|alias AnotherApp.Mailer|
+
+        assert file =~ ~S|Mailer.deliver()|
+      end)
+    end)
+  end
+
+  test "invalid mix arguments", config do
+    in_tmp_project(config.test, fn ->
+      assert_raise Mix.Error, ~r/Expected the context, "blog", to be a valid module name/, fn ->
+        Gen.Notifier.run(~w(blog Post new_post))
+      end
+
+      assert_raise Mix.Error, ~r/Expected the notifier, "posts", to be a valid module name/, fn ->
+        Gen.Notifier.run(~w(Post posts new_post))
+      end
+
+      assert_raise Mix.Error,
+                   ~r/Cannot generate context Phoenix because it has the same name as the application/,
+                   fn ->
+                     Gen.Notifier.run(~w(Phoenix Post new_blog_post))
+                   end
+
+      assert_raise Mix.Error,
+                   ~r/Cannot generate notifier Phoenix because it has the same name as the application/,
+                   fn ->
+                     Gen.Notifier.run(~w(Blog Phoenix new_blog_post))
+                   end
+
+      assert_raise Mix.Error,
+                   ~r/Cannot generate notifier "Post" because one of the messages is invalid: "NewPost"/,
+                   fn ->
+                     Gen.Notifier.run(~w(Blog Post NewPost))
+                   end
+    end)
+  end
+end