After recently using elixir and phoenix to recreate the API for an old side project, I wanted to use Ecto’s embedded schemas, a schema that is not persisted to an underlying table, to validate the JSON coming into an API endpoint and get a workable domain object for the call before transforming it into the model persisted for the DB.
We’ll look at how I set up the sign-up and sign-in endpoints using the embeded schemas to validate the incoming JSON.
Sign-up Controller and Schema
First, I created a new file to hold the sign-up schema.
defmodule Knocker.Accounts.SignUp do
use Ecto.Schema
embedded_schema do
field(:email, :string)
field(:name, :string)
field(:phone_number, :string)
field(:password, :string, virtual: true)
field(:password_confirmation, :string, virtual: true)
field(:password_hash)
end
def changeset(%SignUp{} = sign_up, attrs) do
sign_up
|> cast(attrs, [:email, :name, :password, :password_confirmation, :phone_number])
|> validate_required([:email, :name, :password, :password_confirmation, :phone_number])
|> validate_confirmation(:password)
# I'll touch on this later
# |> put_pass_hash
end
end
The embedded schema holds all the form fields of a sign up form, including the
name, email, phone number, password and password confirmation fields. When
Knocker.Accounts.SignUp.changeset/2
is called, the changes provided by attrs
are cast to the SignUp
struct, same as if I was working with a regular schema.
This returns an Ecto.Changeset.t
object:
iex(1)> alias Knocker.Accounts.SignUp
Knocker.Accounts.SignUp
iex(2)> sign_up = %{email: "foo@bar.com", name: "Foo Bar", phone_number: "15555551234", password: "test", password_confirmation: "test"}
%{
email: "foo@bar.com",
name: "Foo Bar",
password: "test",
password_confirmation: "test",
phone_number: "15555551234"
}
iex(3)> SignUp.changeset(%SignUp{}, sign_up)
#Ecto.Changeset<
action: nil,
changes: %{
email: "foo@bar.com",
name: "Foo Bar",
password: "test",
password_confirmation: "test",
phone_number: "15555551234"
},
errors: [],
data: #Knocker.Accounts.SignUp<>,
valid?: true
>
Now, the changeset struct will tell me if the entered changes are valid, and
what the errors are if any. For instance, if I change the sign_up
map:
iex(4)> sign_up = %{sign_up | password_confirmation: "wrong_password"}
%{
email: "foo@bar.com",
name: "Foo Bar",
password: "test",
password_confirmation: "wrong_password",
phone_number: "15555551234"
}
iex(5)> SignUp.changeset(%SignUp{}, sign_up)
#Ecto.Changeset<
action: nil,
changes: %{
email: "foo@bar.com",
name: "Foo Bar",
password: "test",
password_confirmation: "wrong_password",
phone_number: "15555551234"
},
errors: [
password_confirmation: {"does not match confirmation",
[validation: :confirmation]}
],
data: #Knocker.Accounts.SignUp<>,
valid?: false
>
Now that the password confirmation field is wrong, the Ecto changeset tells me as such.
Once I have the changeset, I must now find out how to get a struct out of the
changeset without hitting the database, as this not a persisted object.
Fortunately, there is Ecto.Changeset.apply_action/2
, which takes a
changeset and an action and “applies”, returning either an ok-tuple or an
error-tuple, similar to Repo.insert/1
.
iex(6)> sign_up = %{ sign_up | password_confirmation: "test"}
%{
email: "foo@bar.com",
name: "Foo Bar",
password: "test",
password_confirmation: "test",
phone_number: "15555551234"
}
iex(7)> SignUp.changeset(%SignUp{}, sign_up) |>
...(7)> Ecto.Changeset.apply_action(:insert)
{:ok,
%Knocker.Accounts.SignUp{
email: "foo@bar.com",
id: nil,
name: "Foo Bar",
password: "test",
password_confirmation: "test",
password_hash: nil,
phone_number: "15555551234"
}}
But what of the password_hash
? I’m using the comeonin
libarary to
handle hashing the libaray, using a couple simple functions to add the hashed
password to the changeset:
defp put_pass_hash(%Ecto.Changeset{
valid?: true,
changes: %{password: password}
} = changeset) do
changeset
|> change(Bcrypt.add_hash(password))
|> change(%{password_confirmation: nil})
end
defp put_pass_hash(changeset), do: changeset
The put_pass_hash/1
function matches on a valid changeset that contains a
password in the list of changes, and uses [add_hash/1
][8] function to create
the hashed password and sets the password in the changeset to nil
. I must
manually nil
out the password_confirmation
field. If the changeset is
invalid or there is no password, then I return the changeset without
modification.
Once I add it to the module and changeset/2
, generating a valid changeset also
changes a given password and replaces it with a newly hashed password.
defmodule Knocker.Accounts.SignUp do
use Ecto.Schema
embedded_schema do
field(:email, :string)
field(:name, :string)
field(:phone_number, :string)
field(:password, :string, virtual: true)
field(:password_confirmation, :string, virtual: true)
field(:password_hash)
end
def changeset(%SignUp{} = sign_up, attrs) do
sign_up
|> cast(attrs, [:email, :name, :password, :password_confirmation, :phone_number])
|> validate_required([:email, :name, :password, :password_confirmation, :phone_number])
|> validate_confirmation(:password)
- # |> put_pass_hash
+ |> put_pass_hash
end
+ defp put_pass_hash(%Ecto.Changeset{
+ valid?: true,
+ changes: %{password: password}
+ } = changeset) do
+ changeset
+ |> change(Bcrypt.add_hash(password))
+ |> change(%{password_confirmation: nil})
+ end
+ defp put_pass_hash(changeset), do: changeset
end
And here the hash is generated!
iex(1)> SignUp.changeset(%SignUp{}, sign_up) |>
...(1)> Ecto.Changeset.apply_action(:insert)
{:ok,
%Knocker.Accounts.SignUp{
email: "foo@bar.com",
id: nil,
name: "Foo Bar",
password: nil,
password_confirmation: nil,
password_hash: "$2b$12$irnK/3/DjNRTSGXmZSaBKegNOTREALHASHJvQzcqUBCJhDmSTRyfu",
phone_number: "15555551234"
}}
Now we have a validated struct generated from our form inputs, which can now
be fed into the User module’s changeset/2
.
The sign-up controller, then, is simply
def create(conn, %{"user" => user_params}) do
with {:ok, %User{} = user} <- create_user(user_params) do
conn
|> put_status(:created)
|> put_resp_header("location", user_path(conn, :show, user))
|> render("show.json", user: user)
end
end
defp create_user(attrs \\ %{}) do
case create_sign_up(attrs) do
{:ok, user} ->
%User{}
|> User.changeset(Map.from_struct(user))
|> Repo.insert()
error ->
error
end
end
defp create_sign_up(attrs \\ %{}) do
%SignUp{}
|> SignUp.changeset(attrs)
|> Changeset.apply_action(:insert)
end
Provided our sign-up form is valid, a new user is recorded in the database. If not, an error is provided to the client.
Sign-In Controller and Schema
Another endpoint is the sign-in endpoint, where an e-mail and password is provided and a token is returned.
defmodule Knocker.Accounts.SignIn do
use Ecto.Schema
import Ecto.Changeset
alias Comeonin.Bcrypt
alias Knocker.Accounts.SignIn
embedded_schema do
field(:email, :string)
field(:password, :string)
end
def changeset(%SignIn{} = sign_in, attrs) do
sign_in
|> cast(attrs, [:email, :password])
|> validate_required([:email, :password])
end
end
This schema is simple, it only validates the presence of an email and a password. The controller, then, is:
def create(conn, %{"user" => user_params}) do
with {:ok, %SignIn{} = sign_in} <- create_sign_in(user_params),
%User{} = user <- get_user_by_email!(sign_in.email),
{:ok, _} <- sign_in_user(user, sign_in.password),
{:ok, jwt, _} <- Guardian.encode_and_sign(user) do
conn
|> Plug.sign_in(user)
|> put_status(:ok)
|> put_resp_header("authorization", "Bearer #{jwt}")
|> render("show.json", sign_in: jwt)
else
_ -> {:error, :unauthorized}
end
end
defp create_sign_in(attrs \\ %{}) do
%SignIn{}
|> SignIn.changeset(attrs)
|> Ecto.Changeset.apply_action(:insert)
end
defp get_user_by_email!(email) do
User
|> where([u], u.email == ^email)
|> select([u], u)
|> limit(1)
|> Repo.one!()
end
defp sign_in_user(user, password) do
SignIn.check_pw(user, password)
end
defp check_pw(user, password), do: Bcrypt.check_pass(user, password)
Once again, I use Ecto.Changeset.apply_action/2
to ensure the given sign in
data is valid, and then fetch a user by email and ensure the password matches.
If all is :ok
, I then generate and return a token. If not, I return
:unauthorized
.
Conclusion
The use of Ecto changesets to validate more than just persisted models helps in simplifying validation code of incoming data sets for API calls (and also forms), and, because I am using Ecto, I have access to the entire suite of model validations bundled into Ecto and write my own re-usable Ecto validation functions in the event that things get more complicated.