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.

backend
phoenix
elixir
api
json

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:

  1. You’ll see my-phoenix-json-api as the name for the directory created for this application

  2. You’ll see my_app used in files and directories inside my-phoenix-json-api/lib e.g. my_app.ex

  3. 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.

Router error

Phoenix LiveDashboard

But visit http://localhost:4000/dashboard —new in version 1.5— and you’ll see this:

Live dashboard

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:

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 on UserController.
  • Make it RESTy: Extract sign_in and sign_out‘s functionality from UserController onto their own controller. Maybe call it SessionController.
    Where UserController.sign_in should be SessionController.create
    and UserController.sign_out should be SessionController.delete.
  • Implement DB support for sessions.
  • Implement an /api/me endpoint on a new MeController.
    This can serve as a kind of ping endpoint to check if the user is still logged in.
    Should return the current_user information.
  • Adjust and create tests for all this new functionality.

Useful links

i18n

curl

Learning

This was a long one, that’s it for now folks!