Let’s Code: Contact Form in Phoenix - The Public Interface
In this Let’s Code series, I will be walking through how I added a simple contact form to a Phoenix 1.4 web application. I will try to make this as general purpose as possible so that it’s easier to follow and apply to your own Phoenix applications. I encourage you to follow along, either in an existing Phoenix application or a new one.
In Part One of this series, I started with a Message
module to serve as the data object for the contact form. In that post, I described the contact form as taking the minimal number of fields from the user (email, subject, and body) and sending that information to a support email address. In this post, I will be walking through creating a module to send the feedback. Let’s get started.
The Support Interface
For the purposes of a contact form, I know I will need to allow an end user to modify and submit a message to the support team. With that, I can make out two functions: change_message
and send_message
. The change_message
function will be used to represent and validate the data on the form while the send_message
function will be used to send an email to the support team with the contents of the message. But where do these functions live?
As mentioned in the previous post, the Phoenix guides encourage defining context modules or public interfaces as a boundary around a specific domain of an application. The Support
context will be the boundary and public interface for the Support domain in this application. Functions on this public interface can be called from other interfaces such as a Phoenix controller or an IEx session.
In true Test-Driven Development (TDD) fashion, I can begin with a test file for this new module and functions:
# test/my_app/support_test.exs
defmodule MyApp.SupportTest do
# The `DataCase` module ships with a standard Phoenix app.
# See the docs for more info:
# https://hexdocs.pm/phoenix/testing_contexts.html#the-datacase
#
# The `async: true` option allows the tests in this file to run concurrently
# with other tests in the application. Tests in this file still run serially.
use MyApp.DataCase, async: true
alias MyApp.Support
test "changing a message" do
# `Support.change_message/1` is expected to return a Changeset for a Message.
#
# I'm using pattern-matching here to assert the returned data types as
# opposed to looking for specific values.
assert %Ecto.Changeset{data: %Support.Message{}} = Support.change_message()
end
end
Running this test results in the following failure:
** (UndefinedFunctionError) function MyApp.Support.change_message/0 is undefined (module MyApp.Support is not available)`
This is stating that not only are we missing the change_message
function, but the MyApp.Support
module doesn’t even exist. Let’s fix that by creating the Support
module and change_message
function:
# lib/my_app/support.ex
defmodule MyApp.Support do
@moduledoc """
The Support module is the interface to customer support for the application.
Look to this module when you want to provide support to application users.
"""
# Alias the `Support.Message` module for easy reference later in this module.
alias __MODULE__.Message
@doc """
Returns a changeset for a `Support.Message`.
"""
@spec change_message() :: Ecto.Changeset.t()
def change_message do
%Message{}
|> Message.changeset(%{})
end
end
This very simple module and change_message
function is enough to make the test pass. This function can be used to build a changeset for a new message. Now I will add tests for sending a message.
# test/my_app/support_test.exs
defmodule MyApp.SupportTest do
describe "sending a message" do
test "with valid fields" do
fields = %{
email: "barry@bluejeans.test",
subject: "Halp Me",
body: "I am having problems."
}
assert {:ok, %Support.Message{} = message} = Support.send_message(fields)
assert message.email == "barry@bluejeans.test"
assert message.subject == "Halp Me"
assert message.body == "I am having problems."
end
test "with invalid fields" do
assert {:error, changeset} = Support.send_message(%{})
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).email
end
end
end
Here, I added a new describe
block for testing the functionality of sending a message. This block has two tests: one for a successful message send and one for a failed message send. Successfully sending a message results in an :ok
tuple response with a sent message. Failing to send a message returns an :error
tuple with a changeset. This is a standard way of writing functions with error handling in Elixir.
Running the tests results in the following failure:
** (UndefinedFunctionError) function MyApp.Support.send_message/1 is undefined or private
I’m missing the send_message
function. I can open up the MyApp.Support
module again and add the following definition of the function to the bottom:
# lib/my_app/support.ex
defmodule MyApp.Support do
@doc """
Sends a message with given fields to the support team. Fields should include:
- `email`: Email for the user submitting the message for the support team to respond.
- `subject`: A short, general title as to the reason for support
- `body`: A longer description as to the reason for support and how the
support team might be able to help.
If the message is invalid, an `{:error, changeset}` is returned. Returns
`{:ok, message}` on success.
"""
@spec send_message(map) :: {:ok, Message.t()} | {:error, Ecto.Changeset.t()}
def send_message(fields) when is_map(fields) do
%Message{}
|> Message.changeset(fields)
|> Ecto.Changeset.apply_action(:insert)
end
end
The send_message
function takes a map
of fields and returns either an :ok
tuple with the Message
struct or an :error
tuple with a changeset. The is_map
guard clause used here prevents a programmer using this interface from passing anything other than a map
into this function. If something other than a map
is passed in, the programmer is greeted with this error:
iex(1)> MyApp.Support.send_message("oops")
** (FunctionClauseError) no function clause matching in MyApp.Support.send_message/1
The following arguments were given to MyApp.Support.send_message/1:
# 1
"oops"
Attempted function clauses (showing 1 out of 1):
def send_message(fields) when is_map(fields)
(my_app 0.1.0) lib/my_app/support.ex:29: MyApp.Support.send_message/1
This should give the programmer enough information to correct the error. I typically add guard clauses like this to my public interface modules to catch issues early in development before they creep out into production.
The meat of the send_message
function builds a new Message
changeset with the given fields
, then passes the changeset into Ecto.Changeset.apply_action/2
. I used apply_action
here because the data is not being persisted to a database. This function will simply apply the changes and return the :ok
tuple when the changes are valid. Otherwise, the function returns the :error
tuple when the changes are invalid. I am keeping the implementation simple for now. The actual email sending of the message to the support team will come later, because it’s a bit more involved. Re-running the tests result in success.
Refactoring
The eagle-eyed among you may have spotted an opportuntity for a refactor. The send_message
and change_message
functions both build a Message
changeset. I can clean that up by simply allowing the change_message
function to take an optional map
of fields as an argument. I’m only going to show the changes here:
# lib/my_app/support.ex
defmodule MyApp.Support do
# I changed this function to take a `map` as an argument and set the default
# to an empty map.
@spec change_message(map) :: Ecto.Changeset.t()
def change_message(fields \\ %{}) do
%Message{}
|> Message.changeset(fields)
end
# This function was updated to use the new `change_message` function.
def send_message(fields) when is_map(fields) do
fields
|> change_message()
|> Ecto.Changeset.apply_action(:insert)
end
end
Re-running the tests should result in success. Let’s take a step back now and review the final product.
The Final Product
# lib/my_app/support.ex
defmodule MyApp.Support do
@moduledoc """
The Support module is the interface to customer support for the application.
Look to this module when you want to provide support to application users.
"""
alias __MODULE__.Message
@doc """
Returns a changeset for a Support Message.
"""
@spec change_message(map) :: Ecto.Changeset.t()
def change_message(fields \\ %{}) do
%Message{}
|> Message.changeset(fields)
end
@doc """
Sends a message with given fields to the support team. Fields should include:
- `email`: Email for the user submitting the message for the support team to respond.
- `subject`: A short, general title as to the reason for support
- `body`: A longer description as to the reason for support and how the
support team might be able to help.
If the message is invalid, an `{:error, changeset}` is returned. Returns
`{:ok, message}` on success.
"""
@spec send_message(map) :: {:ok, Message.t()} | {:error, Ecto.Changeset.t()}
def send_message(fields) when is_map(fields) do
fields
|> change_message()
|> Ecto.Changeset.apply_action(:insert)
end
end
# test/my_app/support_test.exs
defmodule MyApp.SupportTest do
use MyApp.DataCase, async: true
alias MyApp.Support
test "changing a message" do
assert %Ecto.Changeset{data: %Support.Message{}} = Support.change_message()
end
describe "sending a message" do
test "with valid fields" do
fields = %{
email: "barry@bluejeans.test",
subject: "Halp Me",
body: "I am having problems."
}
assert {:ok, %Support.Message{} = message} = Support.send_message(fields)
assert message.email == "barry@bluejeans.test"
assert message.subject == "Halp Me"
assert message.body == "I am having problems."
end
test "with invalid fields" do
assert {:error, changeset} = Support.send_message(%{})
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).email
end
end
end
Wrap-Up
This post laid the foundation of the public interface for the Support
domain with functions to change and send a message. In this post, I covered:
- What kind of functions go on a public interface module.
- Setting up tests to run concurrently with other tests in the application.
- Following error handling conventions with
:ok
and:error
tuple responses. - Using guard clauses on a public interface to catch errors in development.
- Refactoring a tiny bit of duplication using function composition.
This post did not handle the actual sending of an email to the support team. This functionality is quite involved and will make for a good next installment of this Let’s Code series. Thanks for joining me! 👋
Acknowledgements
I would like to thank Troy Rosenberg and Kate Studwell for their feedback on earlier drafts of this post. 🙇♂️🙏