Let’s Code: Contact Form in Phoenix – The Schema
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 Phoenix applications. I encourage you to follow along, either in an existing Phoenix application or a new one.
The contact form will take the minimal number of fields from the user (email, subject, and body) and send that information to a support email address. In this post, I will be walking through the schema used to model the message from the user. Let’s get started.
Starting with a Type Specification
I like to start most new features with a type specification. This helps me organize and visualize the data structure(s) that I think I’ll need to build the feature. Once defined, I can take this type specification and turn it into an Elixir struct or an Ecto schema.
In the case of the contact form, a message from the user to the support team sounds like the only type specification I will need. I begin with a Message
module nestled under a Support
context. If you’re unfamiliar with the concept of contexts within Phoenix, I encourage you to read more about them in the Phoenix guides. For now, you can think of the Support
context as a boundary and public interface to all support team-related functionality for the application.
# lib/my_app/support/message.ex
defmodule MyApp.Support.Message do
@moduledoc false
@type t :: %__MODULE__{
email: String.t(),
subject: String.t(),
body: String.t()
}
end
Here, I define a Support.Message
module to model the user’s, you guessed it, support message. The structure of this module should contain the email, subject, and body fields. The Message
module is an internal module to be used by the Support
context only.
Stepping through the code, I start by specifying @moduledoc false
. This convention hides this internal module from our application’s documentation. I typically document modules that are part of the public interface of the application and hide the rest.
The @type t
convention documents the type this module models. In this case, it’s a struct of this module, denoted with the __MODULE__
convenience function, and containing email
, subject
, and body
attributes. Simple enough so far. Note, this module will not compile because a struct has not been defined. Next, I will add a schema using the Ecto library which will give us the struct and allow this module to be compiled.
Using Ecto’s Embedded Schemas
Since Ecto ships with most Phoenix applications, I use it here to structure my new Message
. Ecto typically deals with manipulating data that will eventually be persisted to a database. However, I won’t be storing the message in the database. The message will be validated and sent in an email to a support team. Ecto gives us a nice construct for working with data not intended for the database: embedded schemas. From the Ecto docs which describe schemas as data mappers:
We used
embedded_schema
because it is not our intent to persist it anywhere. With the schema in hand, we can use Ecto changesets and validations to process the data
I could use an Elixir struct here instead of an Ecto schema. However, Ecto provides changesets and validations to make change tracking and validating input easier than if I had written something from scratch. Even though I do not intend to persist this data to the database, I still want to validate the inputs and track changes to a message. I will add one to the Message
module:
# lib/my_app/support/message.ex
use Ecto.Schema
embedded_schema do
field(:email, :string)
field(:subject, :string)
field(:body, :string)
end
The use Ecto.Schema
line brings in the macro support for related functions. The function I use from this macro is embedded_schema
. Within that function, I add the fields from the typespec defined earlier: email
, subject
, and body
.
With this schema, the module compiles. Yay! So far, I’ve added the typespec for documentation and reference, and an embedded schema for the structure. Now it’s time to work with messages via Ecto changesets.
Defining a Changeset
Before getting straight into defining a changeset, I like to take this time to stub out desired functionality with some tests. I’ll define a test in test/my_app/support/message_test.exs
and begin with an outline of functionality that I would like tested.
# test/my_app/support/message_test.exs
defmodule MyApp.Support.MessageTest do
use MyApp.DataCase, async: true
alias MyApp.Support.Message
describe "changing a message" do
test "with valid fields"
test "missing required fields"
test "invalid email format"
end
end
This provides me with a good structure as I begin to fill in the implementation of the requirements. I have a describe
block for tracking changes to a new or existing message. Within that describe
block are three pending tests for each use-case that cover changing a message with valid and invalid inputs. I will begin with the first test:
# test/my_app/support/message_test.exs
test "with valid fields" do
fields = %{
email: "barry@bluejeans.test",
subject: "Halp Me",
body: "Need bluejean suggestions"
}
changeset = Message.changeset(%Message{}, fields)
assert changeset.valid?
end
I always like to start with the happy path, then follow that up with the failure cases. This ensures that I cover a majority of the usage. Running this test results in a failure because there isn’t a changeset/2
function on Message
. Let’s fix that.
# lib/my_app/support/message.ex
import Ecto.Changeset
@spec changeset(t(), map) :: Ecto.Changeset.t()
def changeset(%__MODULE__{} = message, fields) when is_map(fields) do
message
|> cast(fields, [:email, :subject, :body])
end
The import Ecto.Changeset
line imports all functions from that Ecto module. The function we are using in our changeset
function is cast
, which takes the fields
as changes for the message
based on the given set of permitted attributes: :email
, :subject
, and :body
. I will be using a couple more functions later on, which is why I’m importing them all.
The typespec, defined as @spec changeset(t(), map) :: Ecto.Changeset.t()
, documents that the changeset
function takes two arguments and returns an Ecto.Changeset
struct. The first argument uses the @type t
definition above, specifying that it should be a Message
struct. The second argument specifies it should be an Elixir map
.
Defining a simple changeset
function that casts the expected fields of the message is enough to get the first test to pass. Onto to the next test.
# test/my_app/support/message_test.exs
test "missing required fields" do
changeset = Message.changeset(%Message{}, %{})
refute changeset.valid?
errors = errors_on(changeset)
assert "can't be blank" in errors.email
assert "can't be blank" in errors.subject
assert "can't be blank" in errors.body
end
This test is asserting that all fields on a message will be required before submitting to the support team. One thing to note with this test that might not be well-known is the errors_on
function provided by a Phoenix Ecto template that’s included in your Phoenix project when using Ecto. This helper function uses Ecto’s traverse_errors
function to provide a map of errors for a given changeset. I use it here to assert the “can’t be blank” message is in each field’s set of errors.
Running this test results in a failure. The code to get this to pass will use Ecto’s validate_required
function like so:
# lib/my_app/support/message.ex
@spec changeset(t(), map) :: Ecto.Changeset.t()
def changeset(%__MODULE__{} = message, fields) when is_map(fields) do
message
|> cast(fields, [:email, :subject, :body])
# Added this line here
|> validate_required([:email, :subject, :body])
end
Re-running the test results in success. Fantastic! So far, I have covered casting the expected fields of the message and validating their presence. The last test covers validating the format of the message’s email address.
# test/my_app/support/message_test.exs
test "invalid email format" do
fields = %{
email: "barry@bluejeanstest",
subject: "Halp Me",
body: "Need bluejean suggestions"
}
cset = Message.changeset(%Message{}, fields)
refute cset.valid?
assert "has invalid format" in errors_on(cset).email
end
This tests that an invalid email (e.g. barry@bluejeanstest
) gets surfaced back to the user. Adding a simple format validation to the changeset should address this edge-case.
# lib/my_app/support/message.ex
def changeset(%__MODULE__{} = message, fields) when is_map(fields) do
message
|> cast(fields, [:email, :subject, :body])
|> validate_required([:email, :subject, :body])
# Added this line here
|> validate_format(:email, ~r/(.*?)\@\w+\.\w+/)
end
Running all the tests again results in success. Now the changes can be committed to version control, and we can celebrate the conclusion of the first step of adding a contact form to a Phoenix application. 🎉
The Final Product
# lib/my_app/support/message.ex
defmodule MyApp.Support.Message do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{
email: String.t(),
subject: String.t(),
body: String.t()
}
embedded_schema do
field(:email, :string)
field(:subject, :string)
field(:body, :string)
end
@spec changeset(t(), map) :: Ecto.Changeset.t()
def changeset(%__MODULE__{} = message, fields) when is_map(fields) do
message
|> cast(fields, [:email, :subject, :body])
|> validate_required([:email, :subject, :body])
|> validate_format(:email, ~r/(.*?)\@\w+\.\w+/)
end
end
# test/my_app/support/message_test.exs
defmodule MyApp.Support.MessageTest do
use MyApp.DataCase, async: true
alias MyApp.Support.Message
describe "changing a message" do
test "with valid fields" do
fields = %{
email: "barry@bluejeans.test",
subject: "Halp Me",
body: "Need bluejean suggestions"
}
changeset = Message.changeset(%Message{}, fields)
assert changeset.valid?
end
test "missing required fields" do
changeset = Message.changeset(%Message{}, %{})
refute changeset.valid?
errors = errors_on(changeset)
assert "can't be blank" in errors.email
assert "can't be blank" in errors.subject
assert "can't be blank" in errors.body
end
test "invalid email format" do
fields = %{
email: "barry@bluejeanstest",
subject: "Halp Me",
body: "Need bluejean suggestions"
}
cset = Message.changeset(%Message{}, fields)
refute cset.valid?
assert "has invalid format" in errors_on(cset).email
end
end
end
In the next post, I will be adding the module and functions for the Support
context within the application. This context will use the Message
and changeset
function created in this post to track and validate inputs from the user as well as send an email to the support team. If you have any questions or would like to discuss anything you saw here, feel free to comment over on Hacker News. Thanks for joining me! 👋
Acknowledgements
I would like to thank Troy Rosenberg and Kate Studwell for their feedback on earlier drafts of this post. 🙇♂️🙏