We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
2018 / 04 / 06
2022 / 08 / 28
Build a JSON API with Phoenix
Let's build a JSON API that uses cookies. Sorry, not sorry JWT.
This guide is for the now outdated Phoenix 1.5 version
— lt
Do you have a Rails background?
Are you tired of looking at outdated or incomplete tutorials on how to build a JSON API using Elixir and Phoenix?
Then, read on my friend!
Introduction
I have found there are mainly two types of tutorials one should write:
- Scoped, focused tutorials.
- Full step-by-step tutorials.
With full step-by-step tutorials being used to teach about new tech stacks.
Going from zero to fully working prototype without skipping steps.
With best practices baked-in, and presenting the best libraries available
for a given task.
I enjoy very much tutorials that take this holistic approach.
So, here is mine. :v:
This won’t be just about how to generate a new Phoenix API only app.
That’s easy enough, you just need to pass the --no-webpack --no-html
to mix phx.new
.
This tutorial is about creating a small, but fully operational JSON API for web applications.
I recommend you to take a look at Vue.js as the frontend tech
to complement your API.
Here is a Build a Vue 3 + TypeScript dev environment with Vite
What we’ll do:
- Create a new API-only Phoenix application —skip HTML and JS stuff.
- Create a User schema with hashed passwords —because storing plain text passwords in the database is just wrong.
- Create a Users endpoint —so you can get a list of, create or delete users!
- CORS configuration —so you can use that frontend of yours that runs on another port / domain.
- Create a Sign in endpoint —using session based authentication with cookies.
If you are interested on doing auth with JWTs, check this other tutorial out.
Basically, this is the tutorial I wish I had available when I was trying to learn about how to implement a JSON API with Elixir / Phoenix.
Prerequisites
Install Elixir
We will start by installing Erlang and Elixir using the asdf version manager —using version managers is a best practice in development environments.
Install PostgreSQL
PostgreSQL is the default database for new Phoenix apps, and with good reason: It’s a solid, realiable, and well engineered relational DB.
About REST clients
You might need to get a REST client so you can try out your API endpoints.
The two most popular ones seem to be Postman and Advanced Rest Client I tried both of them and I can’t say I liked any —at least on their Chrome app incarnations. One didn’t display cookie info, and the other didn’t send declared variables on POST requests.
As of today, both have been deprecated as Google Chrome extensions.
If you are using a web frontend library like Axios, then your browser’s developer tools should be enough:
If you go with Axios don’t forget to pass the configuration
option withCredentials: true
as this will allow the client to send
cookies along when doing CORS requests.
Or, you could just use good ol’ curl
.
It works really well.
I’ll show you how to test your endpoints from the CLI with it.
Create a new API-only Phoenix application
Generate the app files
In your terminal:
mix phx.new my-phoenix-json-api --app my_app --module MyApp \
--no-html --no-webpack --binary-id
From the command above:
-
You’ll see my-phoenix-json-api as the name for the directory created for this application
-
You’ll see my_app used in files and directories inside my-phoenix-json-api/lib e.g. my_app.ex
-
You’ll find MyApp used everywhere as the main module for your app
For example in
lib/my_app.ex
:defmodule MyApp do @moduledoc """ MyApp keeps the contexts that define your domain and business logic. Contexts are also responsible for managing your data, regardless if it comes from the database, an external API or others. """ end
Create the development database
If you created a new DB user when installing PostgreSQL, add its credentials to
config/dev.exs
and config/test.exs
.
Then execute:
cd my-phoenix-json-api
mix ecto.create
You can drop the database for the dev environment with:
mix ecto.drop
If you’d like to drop the database for the test environment, you’d need to:
MIX_ENV=test mix ecto.drop
Start the development server
From your terminal:
mix phx.server
Visit http://localhost:4000
and bask in the glory of a beautifully formatted error page. :)
Don’t worry though, we’ll be adding a JSON endpoint soon enough.
Phoenix LiveDashboard
But visit http://localhost:4000/dashboard
—new in version 1.5— and you’ll see this:
Awesome! :D
JSON errors for 404s and 500s
If you’d rather not see HTML pages whenever there is an error,
and instead receive JSON responses, then set debug_errors
to false in your config/dev.exs
and restart your server:
config :my_app, MyAppWeb.Endpoint,
# ...
debug_errors: false,
# ...
Now, visiting http://localhost:4000
yields:
{ "errors": { "detail": "Not Found" } }
This will force yourself into JSON API development mode
—you’ll need to think more about how to deal with JSON
errors from the start.
If you need more info, or want to see the HTML error page,
you can change the debug_error
setting back to true
at any time.
To stop the development server hit CTRL+C
twice.
UTC timestamps
If seconds are not enough for you and you want your timestamps to
have more resolution, modify your config/config.exs
file like this:
# Configure timestamp type for migrations
config :my_app, MyApp.Repo, migration_timestamps: [type: :utc_datetime]
Microsecond resolution
If you need more resolution for your timestamps you can do this instead:
# Add support for microseconds at the database level
# avoid having to configure it on every migration file
config :my_app, MyApp.Repo, migration_timestamps: [type: :utc_datetime_usec]
User schema
We’ll be generating a new User schema inside an Account context.
Contexts are cool, they serve as API boundaries that allow —and encourage— better code organization in your application.
Generate modules for the Account context and User schema
mix phx.gen.context Account User users email:string:unique \
is_active:boolean
From above:
- Account is the context’s module name
- User is the schema’s module name
- users is the DB table’s name
- Then what follows are some field definitions
Open the migration file generated from the previous command in
priv/repo/migrations/<some time stamp>_create_users.exs
and let’s make some changes to it:
- email can’t be null.
- Add a password_hash string field.
--- a/priv/repo/migrations/20191012021055_create_users.exs
+++ b/priv/repo/migrations/20191012021055_create_users.exs
@@ -4,7 +4,8 @@ defmodule MyApp.Repo.Migrations.CreateUsers do
def change do
create table(:users, primary_key: false) do
add :id, :binary_id, primary_key: true
- add :email, :string
+ add :email, :string, null: false
+ add :password_hash, :string
add :is_active, :boolean, default: false, null: false
timestamps()
Run the new migration:
mix ecto.migrate
If you want to read some info about this generator, execute:
mix help phx.gen.context
Hash a user’s password on saving
Add a new dependency to mix.exs
:
defp deps do
[
# ...
{:bcrypt_elixir, "~> 2.0"}
]
end
This is Bcrypt, we will use it to hash user passwords before saving them —need to do that so we don’t store them as plain text inside the database.
Fetch the new app dependencies with:
mix deps.get
Then, add the next line at the end of config/test.exs
:
# Relax crypto strength a bit during testing
config :bcrypt_elixir, :log_rounds, 4
Don’t add that configuration option to config/dev.exs
or config/prod.exs
!
It’s only used on testing to speed up the process by
decreasing the security strength in that specific
environment.
Let’s add a virtual field to our User schema —virtual,
meaning it doesn’t have a corresponding field in the database.
Add the following changes to lib/my_app/account/user.ex:
--- a/lib/my_app/account/user.ex
+++ b/lib/my_app/account/user.ex
@@ -7,15 +7,30 @@ defmodule MyApp.Account.User do
schema "users" do
field :email, :string
field :is_active, :boolean, default: false
+ field :password, :string, virtual: true
+ field :password_hash, :string
- timestamps()
+ # Add support for microseconds at the app level
+ # for this specific schema
+ timestamps(type: :utc_datetime_usec)
end
@doc false
def changeset(user, attrs) do
user
- |> cast(attrs, [:email, :is_active])
- |> validate_required([:email, :is_active])
+ |> cast(attrs, [:email, :is_active, :password])
+ |> validate_required([:email, :is_active, :password])
|> unique_constraint(:email)
+ |> put_password_hash()
+ end
+
+ defp put_password_hash(
+ %Ecto.Changeset{valid?: true, changes: %{password: password}} = changeset
+ ) do
+ change(changeset, Bcrypt.add_hash(password))
+ end
+
+ defp put_password_hash(changeset) do
+ changeset
end
end
Notice the call and definition of put_password_hash/1
.
When the changeset runs through this function, if the changeset
happens to be valid and have a password key, it’ll hash it
using Bcrypt.
Otherwise, it’ll just pass the changeset through.
Running Bcrypt.hash_pwd_salt("hola")
in iex -S mix
would
result in something like:
"$2b$12$6aJaxdg2tbR91rC208/u7usloGdRq.16iuBcExjHqOK5MxhkJtYJq"
This strange looking string is what ends up being saved in the database instead of a plain text password.
Fix the tests
Run the tests for your project with:
mix test
Right now they will fail with:
1) test users create_user/1 with valid data creates a user (MyApp.AccountTest)
test/my_app/account_test.exs:32
match (=) failed
code: assert {:ok, %User{} = user} = Account.create_user(@valid_attrs)
right: {:error,
#Ecto.Changeset<
action: :insert,
changes: %{email: "some email", is_active: true},
errors: [password: {"can't be blank", [validation: :required]}],
data: #MyApp.Account.User<>,
valid?: false
>}
stacktrace:
test/my_app/account_test.exs:33: (test)
# 6 more errors...
That’s because of the changes we just made to the user schema.
But this is easily fixed by adding the password attribute where it’s needed in test/my_app/account_test.exs:
--- a/test/my_app/account_test.exs
+++ b/test/my_app/account_test.exs
@@ -6,9 +6,13 @@ defmodule MyApp.AccountTest do
describe "users" do
alias MyApp.Account.User
- @valid_attrs %{email: "some email", is_active: true}
- @update_attrs %{email: "some updated email", is_active: false}
- @invalid_attrs %{email: nil, is_active: nil}
+ @valid_attrs %{email: "some email", is_active: true, password: "some password"}
+ @update_attrs %{
+ email: "some updated email",
+ is_active: false,
+ password: "some updated password"
+ }
+ @invalid_attrs %{email: nil, is_active: nil, password: nil}
def user_fixture(attrs \\ %{}) do
{:ok, user} =
@@ -19,13 +23,17 @@ defmodule MyApp.AccountTest do
user
end
+ def user_without_password(attrs \\ %{}) do
+ %{user_fixture(attrs) | password: nil}
+ end
+
test "list_users/0 returns all users" do
- user = user_fixture()
+ user = user_without_password()
assert Account.list_users() == [user]
end
test "get_user!/1 returns the user with given id" do
- user = user_fixture()
+ user = user_without_password()
assert Account.get_user!(user.id) == user
end
@@ -47,7 +55,7 @@ defmodule MyApp.AccountTest do
end
test "update_user/2 with invalid data returns error changeset" do
- user = user_fixture()
+ user = user_without_password()
assert {:error, %Ecto.Changeset{}} = Account.update_user(user, @invalid_attrs)
assert user == Account.get_user!(user.id)
end
Let’s take the opportunity to verify the password on create_user and update_user:
--- a/test/my_app/account_test.exs
+++ b/test/my_app/account_test.exs
@@ -41,6 +41,7 @@ defmodule MyApp.AccountTest do
assert {:ok, %User{} = user} = Account.create_user(@valid_attrs)
assert user.email == "some email"
assert user.is_active == true
+ assert Bcrypt.verify_pass("some password", user.password_hash)
end
test "create_user/1 with invalid data returns error changeset" do
@@ -52,6 +53,7 @@ defmodule MyApp.AccountTest do
assert {:ok, %User{} = user} = Account.update_user(user, @update_attrs)
assert user.email == "some updated email"
assert user.is_active == false
+ assert Bcrypt.verify_pass("some updated password", user.password_hash)
end
test "update_user/2 with invalid data returns error changeset" do
Now mix test
should yield no errors.
mix test
..........
Finished in 0.2 seconds
10 tests, 0 failures
Randomized with seed 12964
If you commented out this config line in config/test.exs:
# Relax crypto strength a bit during testing
# config :bcrypt_elixir, :log_rounds, 4
Tests would run like this:
mix test
..........
Finished in 2.1 seconds
10 tests, 0 failures
Randomized with seed 852319
And they would end taking 10x more time! D:
Users endpoint
Generate a new JSON endpoint
Let’s generate a JSON endpoint for the users.
Since we already have an Account context and a
User schema available, we will pass the --no-context
and --no-schema
options:
mix phx.gen.json Account User users email:string password:string \
is_active:boolean --no-context --no-schema
Fix the tests
Now, if you try and run your tests you’ll see this error:
1) test index lists all users (MyAppWeb.UserControllerTest)
test/my_app_web/controllers/user_controller_test.exs:29
** (UndefinedFunctionError) function MyAppWeb.Router.Helpers.user_path/2 is undefined or private
code: conn = get(conn, Routes.user_path(conn, :index))
# ...
4) test update user renders user when data is valid (MyAppWeb.UserControllerTest)
test/my_app_web/controllers/user_controller_test.exs:59
** (UndefinedFunctionError) function MyAppWeb.Router.Helpers.user_path/3 is undefined or private
code: conn = put(conn, Routes.user_path(conn, :update, user), user: @update_attrs)
# ...
It’s complaining about missing user_path/2
and user_path/3
functions.
Add the following line to lib/my_app_web/router.ex
:
resources "/users", UserController, except: [:new, :edit]
Declaring a resource in the router will make some helpers
available to controllers —i.e. user_path/3
.
On your lib/my_app_web/router.ex
:
--- a/lib/my_app_web/router.ex
+++ b/lib/my_app_web/router.ex
@@ -7,5 +7,6 @@ defmodule MyAppWeb.Router do
scope "/api", MyAppWeb do
pipe_through :api
+ resources "/users", UserController, except: [:new, :edit]
end
end
Still, tests will complain.
To fix them we need to change something in lib/my_app_web/views/user_view.ex
:
--- a/lib/my_app_web/views/user_view.ex
+++ b/lib/my_app_web/views/user_view.ex
@@ -11,9 +11,6 @@ defmodule MyAppWeb.UserView do
end
def render("user.json", %{user: user}) do
- %{id: user.id,
- email: user.email,
- password: user.password,
- is_active: user.is_active}
+ %{id: user.id, email: user.email, is_active: user.is_active}
end
end
We shouldn’t be sending a password attribute inside the response.
Since we aren’t sending a password from the endpoint we need
to remove the "password" => "some password"
and "password" => "some updated password"
lines in
test/my_app_web/controllers/user_controller_test.exs
:
--- a/test/my_app_web/controllers/user_controller_test.exs
+++ b/test/my_app_web/controllers/user_controller_test.exs
@@ -42,8 +42,7 @@ defmodule MyAppWeb.UserControllerTest do
assert %{
"id" => id,
"email" => "some email",
- "is_active" => true,
- "password" => "some password"
+ "is_active" => true
} = json_response(conn, 200)["data"]
end
@@ -65,8 +64,7 @@ defmodule MyAppWeb.UserControllerTest do
assert %{
"id" => id,
"email" => "some updated email",
- "is_active" => false,
- "password" => "some updated password"
+ "is_active" => false
} = json_response(conn, 200)["data"]
end
For the last couple of errors, let’s add a new call/2
clause to
lib/my_app_web/controllers/fallback_controller.ex
like this:
--- a/lib/my_app_web/controllers/fallback_controller.ex
+++ b/lib/my_app_web/controllers/fallback_controller.ex
@@ -12,4 +12,11 @@ defmodule MyAppWeb.FallbackController do
|> put_view(MyAppWeb.ErrorView)
|> render(:"404")
end
+
+ def call(conn, {:error, %Ecto.Changeset{}}) do
+ conn
+ |> put_status(:unprocessable_entity)
+ |> put_view(MyAppWeb.ErrorView)
+ |> render(:"422")
+ end
end
Tests should be fine now.
mix test
................
Finished in 0.2 seconds
16 tests, 0 failures
Randomized with seed 615417
Create a couple users
You can launch IEx with your app environment available within it with:
iex -S mix
This is akin to the well known rails console
.
There is a way to have a REPL with your app environment and launch a dev server at the same time:
iex -S mix phx.server
This is just for convenience.
Using IEx
Start IEx and type:
MyApp.Account.create_user(%{email: "asd@asd.com", password: "qwerty"})
Using curl
If you have curl
available on your terminal, you can create a new user through your
API endpoint using something like:
curl -H "Content-Type: application/json" -X POST \
-d '{"user":{"email":"some@email.com","password":"some password"}}' \
http://localhost:4000/api/users
Remember to restart your server since you made some changes to the router.
Adding seed data
Sometimes you want to have some predefined data available in your application. That’s where seed data comes in.
Phoenix already defined a file for that in priv/repo/seeds.exs
,
let’s add a couple of user records in it:
IO.puts("Adding a couple of users...")
MyApp.Account.create_user(%{email: "user1@email.com", password: "qwerty"})
MyApp.Account.create_user(%{email: "user2@email.com", password: "asdfgh"})
Then execute the file with:
mix run priv/repo/seeds.exs
Now we have four users available in our local database:
- asd@asd.com
- some@email.com
- user1@email.com
- user2@email.com
Simple authentication
Verify a user’s password
Let’s add some functions to lib/my_app/account.ex
to verify
a user’s password:
--- a/lib/my_app/account.ex
+++ b/lib/my_app/account.ex
@@ -101,4 +101,23 @@ defmodule MyApp.Account do
def change_user(%User{} = user) do
User.changeset(user, %{})
end
+
+ def authenticate_user(email, password) do
+ query = from(u in User, where: u.email == ^email)
+ query |> Repo.one() |> verify_password(password)
+ end
+
+ defp verify_password(nil, _) do
+ # Perform a dummy check to make user enumeration more difficult
+ Bcrypt.no_user_verify()
+ {:error, "Wrong email or password"}
+ end
+
+ defp verify_password(user, password) do
+ if Bcrypt.verify_pass(password, user.password_hash) do
+ {:ok, user}
+ else
+ {:error, "Wrong email or password"}
+ end
+ end
end
sign_in endpoint
Then add a new sign_in endpoint to lib/my_app_web/router.ex
:
--- a/lib/my_app_web/router.ex
+++ b/lib/my_app_web/router.ex
@@ -8,5 +8,6 @@ defmodule MyAppWeb.Router do
scope "/api", MyAppWeb do
pipe_through :api
resources "/users", UserController, except: [:new, :edit]
+ post "/users/sign_in", UserController, :sign_in
end
end
sign_in controller function
Finally add the sign_in function to
lib/my_app_web/controllers/user_controller.ex
:
--- a/lib/my_app_web/controllers/user_controller.ex
+++ b/lib/my_app_web/controllers/user_controller.ex
@@ -40,4 +40,20 @@ defmodule MyAppWeb.UserController do
send_resp(conn, :no_content, "")
end
end
+
+ def sign_in(conn, %{"email" => email, "password" => password}) do
+ case MyApp.Account.authenticate_user(email, password) do
+ {:ok, user} ->
+ conn
+ |> put_status(:ok)
+ |> put_view(MyAppWeb.UserView)
+ |> render("sign_in.json", user: user)
+
+ {:error, message} ->
+ conn
+ |> put_status(:unauthorized)
+ |> put_view(MyAppWeb.ErrorView)
+ |> render("401.json", message: message)
+ end
+ end
end
Please do note that the view modules are inside the MyAppWeb
module, not inside MyApp
. :|
Define sign_in.json and 401.json views
In lib/my_app_web/user_view.ex
add this:
--- a/lib/my_app_web/views/user_view.ex
+++ b/lib/my_app_web/views/user_view.ex
@@ -13,4 +13,15 @@ defmodule MyAppWeb.UserView do
def render("user.json", %{user: user}) do
%{id: user.id, email: user.email, is_active: user.is_active}
end
+
+ def render("sign_in.json", %{user: user}) do
+ %{
+ data: %{
+ user: %{
+ id: user.id,
+ email: user.email
+ }
+ }
+ }
+ end
end
In lib/my_app_web/error_view.ex
add this:
--- a/lib/my_app_web/views/error_view.ex
+++ b/lib/my_app_web/views/error_view.ex
@@ -13,4 +13,12 @@ defmodule MyAppWeb.ErrorView do
def template_not_found(template, _assigns) do
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
end
+
+ def render("401.json", %{message: message}) do
+ %{
+ errors: %{
+ detail: message
+ }
+ }
+ end
end
We can try our brand new sign_in
endpoint now.
Test the sign_in endpoint with curl
Start your server:
mix phx.server
With the development server (re)started, let’s send some requests to it.
Good credentials
curl -H "Content-Type: application/json" -X POST \
-d '{"email":"asd@asd.com","password":"qwerty"}' \
http://localhost:4000/api/users/sign_in -i
You should receive a 200 with:
{
"data": {
"user": { "email": "asd@asd.com", "id": "f33f55a0-9b7f-4010-9025-973b8f7ab8b9" }
}
}
Bad credentials
curl -H "Content-Type: application/json" -X POST \
-d '{"email":"asd@asd.com","password":"not the right password"}' \
http://localhost:4000/api/users/sign_in -i
You should get a 401 with:
{ "errors": { "detail": "Wrong email or password" } }
Awesome! Let’s move on.
Sessions
Add plug :fetch_session
to your :api
pipeline in lib/my_app_web/router.ex
:
--- a/lib/my_app_web/router.ex
+++ b/lib/my_app_web/router.ex
@@ -3,6 +3,7 @@ defmodule MyAppWeb.Router do
pipeline :api do
plug :accepts, ["json"]
+ plug :fetch_session
end
scope "/api", MyAppWeb do
Save authentication status
Now let’s modify our sign_in
function in
lib/my_app_web/controllers/user_controller.ex
:
--- a/lib/my_app_web/controllers/user_controller.ex
+++ b/lib/my_app_web/controllers/user_controller.ex
@@ -45,12 +45,15 @@ defmodule MyAppWeb.UserController do
case MyApp.Account.authenticate_user(email, password) do
{:ok, user} ->
conn
+ |> put_session(:current_user_id, user.id)
+ |> configure_session(renew: true)
|> put_status(:ok)
|> put_view(MyAppWeb.UserView)
|> render("sign_in.json", user: user)
{:error, message} ->
conn
+ |> delete_session(:current_user_id)
|> put_status(:unauthorized)
|> put_view(MyAppWeb.ErrorView)
|> render("401.json", message: message)
Session renewal
It’s good practice to renew the session whenever you upgrade it from a guest state to an authenticated state.
This can help in preventing a session fixation attack.
+ |> put_session(:current_user_id, user.id)
+ |> configure_session(renew: true)
It seems like Phoenix already does this
—changing the session ID— even if you don’t explicitly
call configure_session(renew: true)
.
Protect a resource with authentication
Modify your lib/my_app_web/router.ex
:
--- a/lib/my_app_web/router.ex
+++ b/lib/my_app_web/router.ex
@@ -3,14 +3,23 @@ defmodule MyAppWeb.Router do
pipeline :api do
plug :accepts, ["json"]
+ plug :fetch_session
+ end
+
+ pipeline :api_auth do
+ plug :ensure_authenticated
end
scope "/api", MyAppWeb do
pipe_through :api
- resources "/users", UserController, except: [:new, :edit]
post "/users/sign_in", UserController, :sign_in
end
+ scope "/api", MyAppWeb do
+ pipe_through [:api, :api_auth]
+ resources "/users", UserController, except: [:new, :edit]
+ end
+
# Enables LiveDashboard only for development
#
# If you want to use the LiveDashboard in production, you should put
@@ -26,4 +35,19 @@ defmodule MyAppWeb.Router do
live_dashboard "/dashboard", metrics: MyAppWeb.Telemetry
end
end
+
+ # Plug function
+ defp ensure_authenticated(conn, _opts) do
+ current_user_id = get_session(conn, :current_user_id)
+
+ if current_user_id do
+ conn
+ else
+ conn
+ |> put_status(:unauthorized)
+ |> put_view(MyAppWeb.ErrorView)
+ |> render("401.json", message: "Unauthenticated user")
+ |> halt()
+ end
+ end
end
As you can see we added a new pipeline called :api_auth
that’ll run
requests through a new :ensure_authenticated
plug function.
We also created a new scope "/api"
block that will pipe its requests
through :api
then through :api_auth
in order to protect the endpoint
resources "/users"
.
It’s truly amazing the way you can define this stuff in Phoenix, composability FTW!
Fix the tests
Obviously, all our MyAppWeb.UserController
tests are broken now because of the
authentication we just implemented.
Here are the modifications needed in
test/my_app_web/controllers/user_controller_test.exs
:
--- a/test/my_app_web/controllers/user_controller_test.exs
+++ b/test/my_app_web/controllers/user_controller_test.exs
@@ -3,6 +3,7 @@ defmodule MyAppWeb.UserControllerTest do
alias MyApp.Account
alias MyApp.Account.User
+ alias Plug.Test
@create_attrs %{
email: "some email",
@@ -15,20 +16,38 @@ defmodule MyAppWeb.UserControllerTest do
password: "some updated password"
}
@invalid_attrs %{email: nil, is_active: nil, password: nil}
+ @current_user_attrs %{
+ email: "some current user email",
+ is_active: true,
+ password: "some current user password"
+ }
def fixture(:user) do
{:ok, user} = Account.create_user(@create_attrs)
user
end
+ def fixture(:current_user) do
+ {:ok, current_user} = Account.create_user(@current_user_attrs)
+ current_user
+ end
+
setup %{conn: conn} do
- {:ok, conn: put_req_header(conn, "accept", "application/json")}
+ {:ok, conn: conn, current_user: current_user} = setup_current_user(conn)
+ {:ok, conn: put_req_header(conn, "accept", "application/json"), current_user: current_user}
end
describe "index" do
- test "lists all users", %{conn: conn} do
+ test "lists all users", %{conn: conn, current_user: current_user} do
conn = get(conn, Routes.user_path(conn, :index))
- assert json_response(conn, 200)["data"] == []
+
+ assert json_response(conn, 200)["data"] == [
+ %{
+ "id" => current_user.id,
+ "email" => current_user.email,
+ "is_active" => current_user.is_active
+ }
+ ]
end
end
@@ -91,4 +110,14 @@ defmodule MyAppWeb.UserControllerTest do
user = fixture(:user)
{:ok, user: user}
end
+
+ defp setup_current_user(conn) do
+ current_user = fixture(:current_user)
+
+ {
+ :ok,
+ conn: Test.init_test_session(conn, current_user_id: current_user.id),
+ current_user: current_user
+ }
+ end
end
mix test
................
Finished in 0.2 seconds
16 tests, 0 failures
Randomized with seed 573739
Add missing tests
Let’s add tests for the authenticate_user/2
function, open
test/my_app/account_test.exs
:
--- a/test/my_app/account_test.exs
+++ b/test/my_app/account_test.exs
@@ -72,5 +72,13 @@ defmodule MyApp.AccountTest do
user = user_fixture()
assert %Ecto.Changeset{} = Account.change_user(user)
end
+
+ test "authenticate_user/2 authenticates the user" do
+ user = user_without_password()
+
+ assert {:error, "Wrong email or password"} = Account.authenticate_user("wrong email", "")
+ assert {:ok, authenticated_user} = Account.authenticate_user(user.email, @valid_attrs.password)
+ assert user == authenticated_user
+ end
end
end
Now, let’s add tests for the sign_in
endpoint in
test/my_app_web/controllers/user_controller_test.exs
:
--- a/test/my_app_web/controllers/user_controller_test.exs
+++ b/test/my_app_web/controllers/user_controller_test.exs
@@ -106,6 +106,41 @@ defmodule MyAppWeb.UserControllerTest do
end
end
+ describe "sign in user" do
+ test "returns the user with good credentials", %{conn: conn, current_user: current_user} do
+ conn =
+ post(
+ conn,
+ Routes.user_path(conn, :sign_in, %{
+ email: current_user.email,
+ password: @current_user_attrs.password
+ })
+ )
+
+ assert json_response(conn, 200)["data"] == %{
+ "user" => %{
+ "id" => current_user.id,
+ "email" => current_user.email
+ }
+ }
+ end
+
+ test "returns errors with bad credentials", %{conn: conn} do
+ conn =
+ post(
+ conn,
+ Routes.user_path(conn, :sign_in, %{
+ email: "non-existent email",
+ password: ""
+ })
+ )
+
+ assert json_response(conn, 401)["errors"] == %{
+ "detail" => "Wrong email or password"
+ }
+ end
+ end
+
defp create_user(_) do
user = fixture(:user)
%{user: user}
mix test
...................
Finished in 0.3 seconds
19 tests, 0 failures
Randomized with seed 53857
Endpoint testing with curl and cookies
Restart your server and then try to request a protected
resource, like /api/users
with:
curl -H "Content-Type: application/json" -X GET \
http://localhost:4000/api/users \
-c cookies.txt -b cookies.txt -i
You’ll get:
{ "errors": { "detail": "Unauthenticated user" } }
Pay attention to the extra -c cookies.txt -b cookies.txt
params.
That is how we enable cookies with curl.
Which in turn, give us session support!
Let’s login with:
curl -H "Content-Type: application/json" -X POST \
-d '{"email":"asd@asd.com","password":"qwerty"}' \
http://localhost:4000/api/users/sign_in \
-c cookies.txt -b cookies.txt -i
You’ll get:
{
"data": {
"user":{ "email": "asd@asd.com", "id": "f33f55a0-9b7f-4010-9025-973b8f7ab8b9" }
}
}
Now, try requesting that protected resource again:
curl -H "Content-Type: application/json" -X GET \
http://localhost:4000/api/users \
-c cookies.txt -b cookies.txt -i
You’ll see:
{
"data": [
{
"email": "asd@asd.com",
"id": "53ad5ed3-8957-42fc-8a51-836c5dea2974",
"is_active": false
},
{
"email": "some@email.com",
"id": "c46cb95d-6302-4160-82e1-c7e390f13eb9",
"is_active": false
},
{
"email": "user1@email.com",
"id": "74c8d07c-5859-422d-926c-bcaf96115b9c",
"is_active": false
},
{
"email": "user2@email.com",
"id": "34e81519-0901-48f1-a14f-de6d80c42a9a",
"is_active": false
}
]
}
Great success!
CORS configuration
You’ll need to configure this if you plan on having your
API and frontend on different domains.
If you don’t know what CORS is, have a look at this:
Cross-Origin Resource Sharing (CORS)
That said, we have two options:
I prefer Corsica as it has more features for configuring CORS requests —if you want a less strict libray, you can try CorsPlug out.
Add this dependency to mix.exs
:
defp deps do
[
# ...
{:corsica, "~> 1.2"}
]
end
Fetch new dependencies with:
mix deps.get
Let’s add Corsica to lib/my_app_web/endpoint.ex
just above plug MyAppWeb.Router
:
plug Corsica,
origins: "http://localhost:8080",
# allow_credentials: true,
# allow_headers: ["Content-Type"],
# log: [rejected: :error, invalid: :warn, accepted: :debug]
plug MyAppWeb.Router
end
We used a single string, but origins
can accept a list.
This list can be composed of strings and/or regular expressions.
In my case, the rule above will accept CORS requests from a Vue.js frontend that uses Axios to do HTTP requests —Vue.js development servers go up on port 8080 by default.
DevOps notes
Since this guide was already too long, I extracted the DevOps
related sections into their own guides.
You can find them here:
- Elixir Continuous Integration with GitHub Actions
- Deploying an Elixir Phoenix application to Gigalixir
- Deploying an Elixir Phoenix application to a VPS <!– Elixir Continuous Deployment with GitHub Actions (future) –>
Bonus section
GitHub repository
You can checkout the code for this tutorial here.
Customize your 404s and 500s JSON responses
In lib/my_app_web/views/error_view.ex
:
defmodule MyAppWeb.ErrorView do
# ...
def render("404.json", _assigns) do
%{errors: %{detail: "Endpoint not found!"}}
end
def render("500.json", _assigns) do
%{errors: %{detail: "Internal server error :("}}
end
# ...
end
Format your project files with the Elixir built-in code formatter
There is a .formatter.exs
file in your project’s root directory, with this content:
[
import_deps: [:ecto, :phoenix],
inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"],
subdirectories: ["priv/*/migrations"]
]
You can invoke mix format
to format your whole project according to the default Elixir formatting rules.
Use a different port for the Phoenix server
By default, running mix phx.server
will serve your application on port 4000
, let’s make the port configurable for
our development environment. In config/dev.exs
modify the line for http: [port: 4000],
to:
config :my_app, MyAppWeb.Endpoint,
http: [port: System.get_env("PORT") || 4000],
# ...
Now, to bind your app to a different port when starting it —let’s say 5000
— do:
PORT=5000 mix phx.server
# OR
PORT=5000 iex -S mix phx.server
Visual Studio Code extension for Elixir
I recommend going with ElixirLS, it’s pretty up to date and has many advanced features, check it out here.
Exercises for the reader
Here are some tasks you could try your hand at:
-
Take into account a user’s
is_active
attribute when trying to log-in. -
Implement an
/api/users/sign_out
endpoint onUserController
. -
Make it RESTy: Extract
sign_in
andsign_out
‘s functionality fromUserController
onto their own controller. Maybe call itSessionController
.
WhereUserController.sign_in
should beSessionController.create
andUserController.sign_out
should beSessionController.delete
. - Implement DB support for sessions.
-
Implement an
/api/me
endpoint on a newMeController
.
This can serve as a kind of ping endpoint to check if the user is still logged in.
Should return thecurrent_user
information. - Adjust and create tests for all this new functionality.
Useful links
- A community driven style guide for Elixir
- User Authentication from Scratch in Elixir and Phoenix
- Tip for Phoenix 1.3 Fallback Controller error
- Debugging Phoenix with IEx.pry
i18n
curl
Learning
- Elixir School: Lessons about the Elixir programming language
- What’s new in Ecto 2.1 FREE ebook
- Learning Regex with Elixir: Tips, Tricks & Caveats
- From Models to Contexts in Phoenix 1.3.0
This was a long one, that’s it for now folks!