Elixir API and Elm SPA - Part 1
Part 1: Elixir App creation
I am going to create a demo app with an Elixir API on the backend and a separated Elm SPA on the frontend. The app will be a simple CRUD app with what I have found to be the best practices so far. The app will be a simple market app where users can post announcements for things to sell and other users can see the offers and buy them. The name of the app will be Toltec.
Series
- Part 1 - Elixir App creation
- Part 2 - Adds Guardian Authentication
- Part 3: Elm App creation and Routing setup
- Part 4: Adding Login and Register pages
- Part 5: Persisting session data to localStorage
Assumptions
I assume you are using:
- Elixir 1.6.5
- Erlang 20
- Elm 0.18
- PostgreSQL 10.4
Create the App
Let's create a basic app for the API. We don't need html or brunch as we don't render html at all. We'll just expose a JSON REST API:
mix phx.new toltec-api --app toltec --no-brunch --no-html --binary-id
Configure auto formatting
Before adding any code let's configure code auto formatting. Add a file in the root of the project called .formatter.exs:
# .formatter.exs
[
inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
And the run the format command:
mix format
This should format all the code in your project.
Now you need to configure your editor to automatically run this command when you save a file. For VS Code, for example, you can use the vscode-elixir and vscode-elixir-formatter extensions.
Create Accounts
We are going to use the comeonin package to handle the password hashing in our app. Add this dependency in mix.exs
# mix.exs
defp deps do
[
{:phoenix, "~> 1.3.0"},
{:phoenix_pubsub, "~> 1.0"},
{:phoenix_ecto, "~> 3.2"},
{:postgrex, ">= 0.0.0"},
{:gettext, "~> 0.11"},
{:cowboy, "~> 1.0"},
{:comeonin, "~> 4.0"},
{:argon2_elixir, "~> 1.2"}
]
end
And get the dependencies
mix deps.get
Now let's add the User schema and an Accounts context to hold all this logic
mix phx.gen.context Accounts User users name:string email:string:unique password_hash:string
This will create a user migration, a user schema and an accounts context. For the users I won't use UUID primary keys, so I am going to remove the binary_id config from the users migration, but you're free to keep it if you want. Change the migration to:
# priv/repo/migrations/20180612062911_create_users.exs
def change do
execute("CREATE EXTENSION citext;")
create table(:users) do
add(:name, :string, null: false)
add(:email, :citext, null: false)
add(:password_hash, :string, null: false)
timestamps()
end
create(unique_index(:users, [:email]))
end
And lets add some basic methods to the user schema:
# lib/toltec/accounts/user.ex
defmodule Toltec.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
alias Toltec.Accounts.User
schema "users" do
field(:email, :string)
field(:name, :string)
field(:password, :string, virtual: true)
field(:password_hash, :string)
timestamps()
end
def changeset(%User{} = user, attrs) do
user
|> cast(attrs, [:name, :email])
|> validate_required([:name, :email])
|> validate_length(:name, min: 2, max: 255)
|> validate_length(:email, min: 5, max: 255)
|> unique_constraint(:email)
|> validate_format(:email, ~r/@/)
end
def registration_changeset(%User{} = user, attrs) do
user
|> changeset(attrs)
|> cast(attrs, [:password])
|> validate_required([:password])
|> validate_length(:password, min: 8, max: 100)
|> put_password_hash()
end
defp put_password_hash(changeset) do
case changeset do
%Ecto.Changeset{valid?: true, changes: %{password: password}} ->
put_change(changeset, :password_hash, Comeonin.Argon2.hashpwsalt(password))
_ ->
changeset
end
end
end
I have added a password virtual field, to temporarily hold the clear-text password. This will never be saved to the DB. I also added two changesets, one for changing the password and one for the other fields. The put_password_hash() function uses the comeonin library to get a hash of the password and put that on the password_hash field that will be stored in DB.
Now modify the Accounts context that holds all the logic for accounts to look like this:
# lib/toltec/accounts/accounts.ex
defmodule Toltec.Accounts do
import Ecto.Query, warn: false
alias Toltec.Repo
alias Toltec.Accounts.User
def list_users do
Repo.all(User)
end
def get_user!(id), do: Repo.get!(User, id)
def create_user(attrs \\ %{}) do
result =
%User{}
|> User.registration_changeset(attrs)
|> Repo.insert()
case result do
{:ok, user} -> {:ok, %User{user | password: nil}}
_ -> result
end
end
def update_user(%User{} = user, attrs) do
user
|> User.changeset(attrs)
|> Repo.update()
end
def delete_user(%User{} = user) do
Repo.delete(user)
end
def change_user(%User{} = user) do
User.changeset(user, %{})
end
end
Next, let's add some seed data to the DB to make our app usable.
# priv/repo/seeds.exs
# users
user =
Toltec.Accounts.User.registration_changeset(%Toltec.Accounts.User{}, %{
name: "some user",
email: "user@toltec",
password: "user@toltec"
})
Toltec.Repo.insert!(user)
Now is time to build the DB, create tables and insert the seed data.
mix ecto.reset
This command will drop the DB if exists, create it again, run the migrations in order and finally run the seeds.exs file to insert initial data. You should see no errors in the output, and should be similar to this.
mix ecto.reset
The database for Toltec.Repo has already been dropped
The database for Toltec.Repo has been created
[info] == Running Toltec.Repo.Migrations.CreateUsers.change/0 forward
[info] execute "CREATE EXTENSION citext;"
[info] create table users
[info] create index users_email_index
[info] == Migrated in 0.0s
[debug] QUERY OK db=2.8ms
INSERT INTO "users" ("email","name","password_hash","inserted_at","updated_at") VALUES ($1,$2,$3,$4,$5) RETURNING "id" ["user@toltec", "some user", "$argon2i$v=19$m=65536,t=6,p=1$jdZHJ4HdGSF34hP6iTiPeQ$+PWBp77RP8wxMasVj0wv1wWS33pponixKSEG109V/u8", {{2018, 6, 12}, {22, 35, 55, 411334}}, {{2018, 6, 12}, {22, 35, 55, 411345}}]
You can find the source code, with tests, in the repo here in the branch part-01.
After cloning it, run the tests and verify that everything is alright:
mix test
...................
Finished in 0.3 seconds
19 tests, 0 failures
So far we have created the app, the users schema, and some initial user to play with our app. That's it for now.