Phoenix & Elm landing page (pt.1)

This post belongs to the Phoenix & Elm landing page series.

  1. Bootstrapping the project and the basic API functionality to save our first leads
  2. Coming soon...
Source code

In these series, we are going to cover some common patterns and best practices related to using Phoenix and Elm to build a simple landing page with a subscription form. The primary goal is to achieve the following list of tasks:

  • Create a new Phoenix project.
  • Add a new Phoenix context for marketing leads.
  • Add an API endpoint to insert a lead's data into the database.
  • Build the landing page template using Phoenix and Bulma as our CSS framework of choice.
  • Add Elm to the project and build a subscription form that points to the API endpoint described previously.
  • Add Google's reCAPTCHA widget to the Elm subscription form, and how to render it and how to handle a visitor's reCAPTCHA response.
  • Build an HTTP client using HTTPoison to verify the token received by the reCAPTCHA widget against Google's reCAPTCHA API from our backend.
  • Build another HTTP client to subscribe leads to an email marketing service such as MailChimp.
  • Add tests covering the subscription process using mocks for the HTTP clients.

Now that we have detailed what we need let's get cracking!

Landing page

Creating the Phoenix project

Let's start by bootstrapping a new Phoenix project as we usually do:

$ mix phx.new landing_page
* creating landing_page/config/config.exs
* creating landing_page/config/dev.exs
* creating landing_page/config/prod.exs
...

After the task finishes, we can go to the generated project folder and create the database:

$ cd landing_page
$ mix ecto.create
The database for LandingPage.Repo has already been created

Now we are ready to start working on the backend.

The Marketing context and Lead schema

Before continuing, let's stop for a second and think about what is the primary goal of our future landing page. The principal goal is not only to be the temporally home site of our awesome new product that we are working on but to let potential leads subscribe so we can take any marketing or business decision that we might need, like for instance sending them the latest news and promotions via email campaigns. Having this in mind, we can identify a Marketing context and a leads table for the database, so let's create both of them using the new Phoenix context generator:

$ mix phx.gen.context Marketing Lead leads full_name:string email:string
* creating lib/landing_page/marketing/lead.ex
* creating priv/repo/migrations/20171202101203_create_leads.exs
* creating lib/landing_page/marketing/marketing.ex
* injecting lib/landing_page/marketing/marketing.ex
* creating test/landing_page/marketing/marketing_test.exs
* injecting test/landing_page/marketing/marketing_test.exs

Before running the migrations task, we need to tweak the migration file just created to add a unique index to the email column, because we do not want leads subscribing multiple times with the same email:

# priv/repo/migrations/20171201145808_create_leads.exs

defmodule LandingPage.Repo.Migrations.CreateLeads do
  use Ecto.Migration

  def change do
    create table(:leads) do
      add(:full_name, :string, null: false)
      add(:email, :string, null: false)

      timestamps()
    end

    create(unique_index(:leads, [:email]))
  end
end

Now we can run the migrations task to create the table:

mix ecto.migrate
[info] == Running LandingPage.Repo.Migrations.CreateLeads.change/0 forward
[info] create table leads
[info] create index leads_email_index
[info] == Migrated in 0.0s

We also have to add the necessary validation rules and constraints to the Lead schema module, so let's edit it:

# lib/landing_page/marketing/lead.ex

defmodule LandingPage.Marketing.Lead do
  use Ecto.Schema
  import Ecto.Changeset
  alias LandingPage.Marketing.Lead

  @derive {Poison.Encoder, only: [:full_name, :email]}

  schema "leads" do
    field(:email, :string)
    field(:full_name, :string)

    timestamps()
  end

  @doc false
  def changeset(%Lead{} = lead, attrs) do
    lead
    |> cast(attrs, [:full_name, :email])
    |> validate_required([:full_name, :email])
    |> unique_constraint(:email)
  end
end

Apart from adding the unique_constraint check function, we are also adding the @derive clause specifying the fields we want to return when a %Lead{} struct is automatically encoded by Poison, which is very convenient while developing JSON APIs, as we are going to see in a minute.

The API endpoint and saving leads

Now that our context and schema are ready to start saving leads, let's add the new route that we are going to use for this purpose:

# lib/landing_page_web/router.ex

defmodule LandingPageWeb.Router do
  use LandingPageWeb, :router

    # ...

  # Other scopes may use custom stacks.
  scope "/api", LandingPageWeb do
    pipe_through(:api)

    scope "/v1", V1 do
      post("/leads", LeadController, :create)
    end
  end
end

Let's continue with a more test-driven approach and create a new test file that covers how we expect the controller to work:

# test/landing_page_web/controllers/v1/lead_controller_test.exs

defmodule LandingPageWeb.V1.LeadControllerTest do
  use LandingPageWeb.ConnCase

  describe "POST /api/v1/leads" do
    test "returns error response with invalid parms", %{conn: conn} do
      conn = post(conn, lead_path(conn, :create), %{"lead" => %{}})

      assert json_response(conn, 422) == %{
               "full_name" => ["can't be blank"],
               "email" => ["can't be blank"]
             }
    end

    test "returns success response with valid params", %{conn: conn} do
      params = %{
        "lead" => %{"full_name" => "John", "email" => "foo@bar.com"}
      }

      conn = post(conn, lead_path(conn, :create), params)
      assert json_response(conn, 200) == params["lead"]
    end
  end
end

It is a very basic test, but it pretty much covers what we need at the moment. If the lead parameter is invalid, it should return a 422 response (unprocessable entity) along with the validation errors. On the other hand, if the sent parameters are correct, it will return a success response along with the inserted data. Let's run the mix test task and see what happens:

$ mix test test/landing_page_web/controllers/v1/lead_controller_test.exs


  1) test POST /api/v1/leads returns success response with valid params (LandingPageWeb.V1.LeadControllerTest)
     test/landing_page_web/controllers/v1/lead_controller_test.exs:14
     ** (UndefinedFunctionError) function LandingPageWeb.V1.LeadController.init/1 is undefined (module LandingPageWeb.V1.LeadController is not available)
     code: conn = post(conn, lead_path(conn, :create), params)
     stacktrace:
       LandingPageWeb.V1.LeadController.init(:create)
       (landing_page) lib/landing_page_web/router.ex:1: anonymous fn/1 in LandingPageWeb.Router.__match_route__/4
       (phoenix) lib/phoenix/router.ex:278: Phoenix.Router.__call__/1
       (landing_page) lib/landing_page_web/endpoint.ex:1: LandingPageWeb.Endpoint.plug_builder_call/2
       (landing_page) lib/landing_page_web/endpoint.ex:1: LandingPageWeb.Endpoint.call/2
       (phoenix) lib/phoenix/test/conn_test.ex:224: Phoenix.ConnTest.dispatch/5
       test/landing_page_web/controllers/v1/lead_controller_test.exs:19: (test)



  2) test POST /api/v1/leads returns error response with invalid parms (LandingPageWeb.V1.LeadControllerTest)
     test/landing_page_web/controllers/v1/lead_controller_test.exs:5
     ** (UndefinedFunctionError) function LandingPageWeb.V1.LeadController.init/1 is undefined (module LandingPageWeb.V1.LeadController is not available)
     code: conn = post(conn, lead_path(conn, :create), %{"lead" => %{}})
     stacktrace:
       LandingPageWeb.V1.LeadController.init(:create)
       (landing_page) lib/landing_page_web/router.ex:1: anonymous fn/1 in LandingPageWeb.Router.__match_route__/4
       (phoenix) lib/phoenix/router.ex:278: Phoenix.Router.__call__/1
       (landing_page) lib/landing_page_web/endpoint.ex:1: LandingPageWeb.Endpoint.plug_builder_call/2
       (landing_page) lib/landing_page_web/endpoint.ex:1: LandingPageWeb.Endpoint.call/2
       (phoenix) lib/phoenix/test/conn_test.ex:224: Phoenix.ConnTest.dispatch/5
       test/landing_page_web/controllers/v1/lead_controller_test.exs:6: (test)



Finished in 0.09 seconds
2 tests, 2 failures

Randomized with seed 665970

As expected, the test is failing because we have not created the controller module yet, so let's add it:

# lib/landing_page_web/controllers/v1/lead_controller.ex

defmodule LandingPageWeb.V1.LeadController do
  use LandingPageWeb, :controller

  alias LandingPage.Marketing

  plug(:scrub_params, "lead")

  def create(conn, %{"lead" => params}) do
    with {:ok, lead} <- Marketing.create_lead(params) do
      json(conn, lead)
    end
  end
end

We are using the scrub_params plug to check if the lead parameter is present and to convert any of its empty keys to nil values. To create the lead, we are using Marketing.create_lead, which we created before while generating the context. However, we are only pattern matching against the successful {:ok, lead} response, and there might be validation errors, throwing a runtime error due to the missing pattern matching against {:error, _}. So what is the reason for doing it like so? Simply because we want to introduce the new Phoenix.Controller.action_fallback/1 macro, which registers a plug to call as a fallback when an action doesn't return a valid %Plug.Conn{} structure. In our particular case, if there is any validation error, it returns a {:error, %Ecto.Changeset{}} that we need to handle, so let's setup the fallback controller:

# lib/landing_page_web.ex

defmodule LandingPageWeb do
# ...

  def controller do
    quote do
      use Phoenix.Controller, namespace: LandingPageWeb
      import Plug.Conn
      import LandingPageWeb.Router.Helpers
      import LandingPageWeb.Gettext

      action_fallback(LandingPageWeb.FallbackController)
    end
  end

  # ...
end

Adding action_fallback to the main LandingPageWeb module makes it available to all of the controllers, but we also have to create the FallbackController plug module itself, implementing the call/2 function:

# lib/landing_page_web/controllers/fallback_controller.ex

defmodule LandingPageWeb.FallbackController do
  use LandingPageWeb, :controller

  def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
    conn
    |> put_status(:unprocessable_entity)
    |> render(LandingPageWeb.ErrorView, "error.json", changeset: changeset)
  end
end

When it receives an error with a changeset, it sets the unprocessable_entity status to the connection and renders the error.json template from the LandingPageWeb.ErrorView module that we also need to implement in the existing module:

# lib/landing_page_web/views/error_view.ex

defmodule LandingPageWeb.ErrorView do
  use LandingPageWeb, :view

  import LandingPageWeb.ErrorHelpers

  # ...

  def render("error.json", %{changeset: changeset}) do
    Ecto.Changeset.traverse_errors(changeset, &translate_error/1)
  end

  # ...
end

Calling Ectos's traverse_errors using the translate_errors from the ErrorHelpers module, returns the list of changeset errors we have described in the controller's test. Let's rerun the test task to verify that we are good to go:

$ mix test test/landing_page_web/controllers/v1/lead_controller_test.exs
..
Finished in 0.1 seconds
2 tests, 0 failures

Randomized with seed 304229

Awesome, all test are passing, and the controller is working as we initially planned. In regards to the back-end we have everything that we need, for now, so in the next part we will focus on the front-end side, install all dependencies that we need such as Elm and Bulma, building the basic layout and the subscription form to start saving the first leads. In the meantime, you can check out the source code of what we have done so far here.

Happy coding!

comments powered by Disqus