This post belongs to the Phoenix & Elm landing page series.
- Bootstrapping the project and the basic API functionality to save our first leads
- Building the landing page UI and the basic Elm subscription form
- Adding Google reCAPTCHA support to avoid spambots
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.
- Add tests covering the subscription process using mocks for the HTTP clients.
Now that we have detailed what we need let's get cracking!

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 createdNow 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.exsBefore 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
endNow 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.0sWe 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
endApart 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
endLet'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
endIt 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 665970As 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
endWe 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
# ...
endAdding 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
endWhen 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
# ...
endCalling 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 304229Awesome, 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!