lobo_tuerto's notes
Building a JSON API in Elixir with Phoenix 1.4
A traditional JSON API tutorial, with cookies!
Published on
2018 / 04 / 06
Updated on
2019 / 10 / 12
Tags
tutorialbackendelixirphoenixcurljsonapideploymentgigalixirvps
Versions
phoenix:1.4elixir:1.9curl:7
v1.1.0

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.

Scoped, focused tutorials, should be used to explain techniques, like this one: Fluid SVGs with Vue.js.

But, full step-by-step tutorials should be used to teach about new tech stacks.

Going from zero to fully working prototype without skipping steps.
With best practices baked-in, presenting the best libraries available for a given task.
I really like tutorials that take this holistic approach.

So, this won’t just be 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 take a look at Vue.js as the frontend to complement your API.

Here is a Quickstart guide for a new Vue.js project.

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

NOTE:

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

§ Errors in JSON for 404s and 500s

If you’d rather don’t see HTML pages when there is an error and instead you’d like to receive JSON responses, then set debug_errors to false in your config/dev.ex, then restart your server:

config :my_app, MyAppWeb.Endpoint,
  # ...
  debug_errors: false,
  # ...

Now, visiting http://localhost:4000 yields:

{ "errors": { "detail": "Not Found" } }

To stop the development server hit CTRL+C twice.

§ Timestamps with microsecond resolution

If seconds are not enough for you and you want your timestamps to have more resolution, modify your config/config.exs file like this:

--- a/config/config.exs
+++ b/config/config.exs
@@ -11,6 +11,10 @@ config :my_app,
   ecto_repos: [MyApp.Repo],
   generators: [binary_id: true]

+# Add support for microseconds at the DB level
+# this avoids having to configure it on every migration file
+config :my_app, MyApp.Repo, migration_timestamps: [type: :utc_datetime_usec]
+
 # Configures the endpoint
 config :my_app, MyAppWeb.Endpoint,
   url: [host: "localhost"],

§ User schema

We’ll be generating a new User schema inside an Auth context.

Contexts are cool, they serve as API boundaries that allow —and encourage— better code organization in your application.

§ Generate the User schema and Auth context modules

mix phx.gen.context Auth User users email:string:unique \
is_active:boolean

From above:

  • Auth is the context’s module name
  • User is the schema’s module name
  • users is the DB table’s name
  • Then follow 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 those as plain text inside the database.

Fetch the new app dependencies with:

mix deps.get

Also, add the next line at the end of config/test.exs:

config :bcrypt_elixir, :log_rounds, 4

Don’t add that configuration option to config/dev.exs or config/prod.exs!
It’s only used for testing to speed up the process by decreasing a bit the security settings in that specific environment.

Let’s add a virtual field in our User schema —virtual, meaning it doesn’t have a corresponding field in our DB.

Add the following changes to lib/my_app/auth/user.ex:

--- a/lib/my_app/auth/user.ex
+++ b/lib/my_app/auth/user.ex
@@ -7,15 +7,30 @@ defmodule MyApp.Auth.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 language 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 definitions of put_password_hash/1.

What this does is run the changeset through that function, and if the changeset happens to be valid and have a password key, it’ll use Bcrypt to hash it.

Running Bcrypt.hash_pwd_salt("hola") would result in something like:

"$2b$12$sI3PE3UsOE0BPrUv7TwUt.i4BQ32kxgK.REDv.IHC8HlEVAkqmHky"

That strange looking string is what ends up being saved in the database instead of the plain text version.

§ 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.AuthTest)
     test/my_app/auth_test.exs:32
     match (=) failed
     code:  assert {:ok, %User{} = user} = Auth.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.Auth.User<>,
               valid?: false
             >}
     stacktrace:
       test/my_app/auth_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/auth_test.exs:

--- a/test/my_app/auth_test.exs
+++ b/test/my_app/auth_test.exs
@@ -6,9 +6,13 @@ defmodule MyApp.AuthTest do
   describe "users" do
     alias MyApp.Auth.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} =

Let’s try with mix test again:

  1) test users list_users/0 returns all users (MyApp.AuthTest)
     test/my_app/auth_test.exs:26
     Assertion with == failed
     code:  assert Auth.list_users() == [user]
     left:  [%MyApp.Auth.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">, email: "some email", id: "c8f4f699-721b-4097-9067-2bbdcab118ba", inserted_at: ~U[2019-10-12 03:07:18.046009Z], is_active: true, password_hash: "$2b$04$DZHgGKqIDTW6b6BKeNhzZ.IT7qC1jDcpeuL1vT.kl8PNi0GdNPC0W", updated_at: ~U[2019-10-12 03:07:18.046009Z], password: nil}]
     right: [%MyApp.Auth.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">, email: "some email", id: "c8f4f699-721b-4097-9067-2bbdcab118ba", inserted_at: ~U[2019-10-12 03:07:18.046009Z], is_active: true, password_hash: "$2b$04$DZHgGKqIDTW6b6BKeNhzZ.IT7qC1jDcpeuL1vT.kl8PNi0GdNPC0W", updated_at: ~U[2019-10-12 03:07:18.046009Z], password: "some password"}]
     stacktrace:
       test/my_app/auth_test.exs:28: (test)


  2) test users get_user!/1 returns the user with given id (MyApp.AuthTest)
     test/my_app/auth_test.exs:31
     Assertion with == failed
     code:  assert Auth.get_user!(user.id()) == user
     left:  %MyApp.Auth.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">, email: "some email", id: "b97114f9-c6b1-40db-b95a-4f1de2c55d19", inserted_at: ~U[2019-10-12 03:07:18.052263Z], is_active: true, password_hash: "$2b$04$jwARrEuIH1pisEn9yY06xObFnAG4dEAaZWxIjkfEX/SWrd4sfIDnG", updated_at: ~U[2019-10-12 03:07:18.052263Z], password: nil}
     right: %MyApp.Auth.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">, email: "some email", id: "b97114f9-c6b1-40db-b95a-4f1de2c55d19", inserted_at: ~U[2019-10-12 03:07:18.052263Z], is_active: true, password_hash: "$2b$04$jwARrEuIH1pisEn9yY06xObFnAG4dEAaZWxIjkfEX/SWrd4sfIDnG", updated_at: ~U[2019-10-12 03:07:18.052263Z], password: "some password"}
     stacktrace:
       test/my_app/auth_test.exs:33: (test)


  3) test users update_user/2 with invalid data returns error changeset (MyApp.AuthTest)
     test/my_app/auth_test.exs:53
     Assertion with == failed
     code:  assert user == Auth.get_user!(user.id())
     left:  %MyApp.Auth.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">, email: "some email", id: "9a1bb156-0ea2-41a1-9162-6caf567cde7e", inserted_at: ~U[2019-10-12 03:07:18.056600Z], is_active: true, password_hash: "$2b$04$LxQ0rOv3LMbMO6yDMZLA..46/XinwMHFN21Kz1orS.YMa6JFkio4G", updated_at: ~U[2019-10-12 03:07:18.056600Z], password: "some password"}
     right: %MyApp.Auth.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">, email: "some email", id: "9a1bb156-0ea2-41a1-9162-6caf567cde7e", inserted_at: ~U[2019-10-12 03:07:18.056600Z], is_active: true, password_hash: "$2b$04$LxQ0rOv3LMbMO6yDMZLA..46/XinwMHFN21Kz1orS.YMa6JFkio4G", updated_at: ~U[2019-10-12 03:07:18.056600Z], password: nil}
     stacktrace:
       test/my_app/auth_test.exs:56: (test)

Here the problem is that when we get a user from the DB, password is going to be nil —since it’s a virtual field— and we are only using it when creating / updating a user.

To fix that, we will assign nil to user.password in the test fixture.

Also, let’s take the opportunity to test the password verification on create_user and update_user:

--- a/test/my_app/auth_test.exs
+++ b/test/my_app/auth_test.exs
@@ -20,7 +20,7 @@ defmodule MyApp.AuthTest do
         |> Enum.into(@valid_attrs)
         |> Auth.create_user()

-      user
+      %{user | password: nil}
     end

     test "list_users/0 returns all users" do
@@ -37,6 +37,7 @@ defmodule MyApp.AuthTest do
       assert {:ok, %User{} = user} = Auth.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
@@ -48,12 +49,14 @@ defmodule MyApp.AuthTest do
       assert {:ok, %User{} = user} = Auth.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
       user = user_fixture()
       assert {:error, %Ecto.Changeset{}} = Auth.update_user(user, @invalid_attrs)
       assert user == Auth.get_user!(user.id)
+      assert Bcrypt.verify_pass("some password", user.password_hash)
     end

     test "delete_user/1 deletes the user" do

Now mix test should yield no errors.

mix test
..........

Finished in 0.1 seconds
10 tests, 0 failures

Randomized with seed 161099

§ Users endpoint

§ Generate a new JSON endpoint

Let’s generate the users JSON endpoint, since we already have the Auth context and User schema available, we will pass the --no-context and --no-schema options.

mix phx.gen.json Auth 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.

You need to add the following line to lib/my_app_web/router.ex.
Declaring a resource in the router will make some helpers available to controllers —i.e. user_path/3:

resources "/users", UserController, except: [:new, :edit]

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

Nevertheless, tests will still complain.

To fix them we need to make a change in lib/my_app_web/views/user_view.ex.

We shouldn’t be sending a password attribute alongside the response.
Let’s remove that from the render function for “user.json” 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

One more change is needed in test/my_app_web/controllers/user_controller_test.exs.

Remove the "password" => "some password" and "password" => "some updated password" lines.

--- 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 left, 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

Using IEx

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.

Let’s create a new user using IEx:

MyApp.Auth.create_user(%{email: "asd@asd.com", password: "qwerty"})

Using curl

If you have curl available in your terminal, you can create a new user through your 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

Just make sure you have your server running in the background.

§ Simple authentication

§ Verify a user’s password

Let’s add some functions to lib/my_app/auth.ex so we can verify a user’s password:

--- a/lib/my_app/auth.ex
+++ b/lib/my_app/auth.ex
@@ -101,4 +101,23 @@ defmodule MyApp.Auth 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.Auth.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

Notice that the views we are rendering 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,8 @@ 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

You can try the sign_in endpoint now.

Try out your new endpoint with curl

mix phx.server

With our development server restarted, let’s send some POST requests to http://localhost:4000/api/users/sign_in.

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’ll 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’ll get a 401 with:

{ "errors": { "detail": "Wrong email or password" } }

§ 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,14 @@ defmodule MyAppWeb.UserController do
     case MyApp.Auth.authenticate_user(email, password) do
       {:ok, user} ->
         conn
+        |> put_session(:current_user_id, user.id)
         |> 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)

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
@@ -6,9 +6,32 @@ defmodule MyAppWeb.Router do
     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
+
+  # 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.

We want resources "/users" to be protected, so we moved it inside the new block that pipes through :api_auth.

It is 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.Auth
   alias MyApp.Auth.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} = Auth.create_user(@create_attrs)
     user
   end

+  def fixture(:current_user) do
+    {:ok, current_user} = Auth.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,12 @@ 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

Test the authenticate_user/2 function in test/my_app/auth_test.exs:

--- a/test/my_app/auth_test.exs
+++ b/test/my_app/auth_test.exs
@@ -69,5 +69,12 @@ defmodule MyApp.AuthTest do
       user = user_fixture()
       assert %Ecto.Changeset{} = Auth.change_user(user)
     end
+
+    test "authenticate_user/2 authenticates the user" do
+      user = user_fixture()
+      assert {:error, "Wrong email or password"} = Auth.authenticate_user("wrong email", "")
+      assert {:ok, authenticated_user} = Auth.authenticate_user(user.email, @valid_attrs.password)
+      assert %{user | password: nil} == authenticated_user
+    end
   end
 end

Test 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,33 @@ defmodule MyAppWeb.UserControllerTest do
     end
   end

+  describe "sign_in user" do
+    test "renders user when user credentials are good", %{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 "renders errors when user credentials are bad", %{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)
     {:ok, user: user}
mix test
................

Finished in 0.2 seconds
19 tests, 0 failures

Randomized with seed 609121

§ Endpoint testing with curl and cookies

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" } }

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": "f33f55a0-9b7f-4010-9025-973b8f7ab8b9", "is_active": false },
    { "email": "some@email.com", "id": "8606f145-1988-4c5b-9bb7-d0ef137b6c9d", "is_active": false }
  ]
}

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, here we have two options:

I’ll be using Corsica in this tutorial, as it have 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.0"}
    ]
  end

Fetch new dependencies with:

mix deps.get

Add plug Corsica to lib/my_app_web/endpoint.ex just above the router plug:

--- a/lib/my_app_web/endpoint.ex
+++ b/lib/my_app_web/endpoint.ex
@@ -40,5 +40,9 @@ defmodule MyAppWeb.Endpoint do
     key: "_my_app_key",
     signing_salt: "HzAEQkcO"

+  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 that 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 usually go up on port 8080 by default.

§ Deploying to Gigalixir

This is the easiest way to get your app up and running.
It’s basically a zero config approach. It just works.

I created a new account directly on their website, then switched to the CLI tool for everything else.

I recommend you do the same.

After registering, install the Gigalixir CLI tool:

sudo pip install gigalixir --ignore-installed six

Now login, so your API key gets saved to ~/.netrc:

gigalixir login

Get into your Phoenix project directory and create an app and db for it:

cd my-phoenix-json-api
gigalixir create
gigalixir pg:create --free

Get your app name with:

gigalixir apps
# "unique_name": "your-app-name"

Now open config/prod.exs and change the host URL to your app name plus .gigalixirapp.com at the end. Like this:

config :my_app, MyAppWeb.Endpoint,
  url: [host: "your-app-name.gigalixirapp.com", port: 80],
  cache_static_manifest: "priv/static/cache_manifest.json"

Then create a elixir_buildpack.config file in your project root directory and add the versions you used for your project.

I always use the latest ones —no Erlang 22.1 available though— so I put these in there:

elixir_version=1.9.1
erlang_version=22.0.7

Add changes and commit, now to deploy just:

gigalixir push gigalixir master

Done. Wait a min, then visit your app’s URL:

https://your-app-name.gigalixirapp.com

That’s it! :D

§ Basic VPS deployment

If you want to get your hands dirty —or happen to have a VPS available— then read along.

For simplicity I’ll illustrate what steps I’d follow to have my app available at: http://lobotuerto.com:4000.

Let’s say we have a VPS on Linode or DigitalOcean and we want to deploy our app on that machine.

With Ubuntu as the OS, we have a domain configured on the machine —e.g. lobotuerto.com— and we just want to be able to access a production build on port 4000.

So, when we try something like this, it should work:

curl -H "Content-Type: application/json" -X POST \
-d '{"email":"asd@asd.com","password":"qwerty"}' \
http://lobotuerto.com:4000/api/users/sign_in \
-c cookies.txt -b cookies.txt -i

Let’s get to it! As this guide is already bigger than I expected. ;)

OK, SSH into your VPS then follow the steps below:

§ Install PostgreSQL on the VPS

Follow this guide: How to install PostgreSQL in Manjaro Linux.

Make sure to create a DB user for your app (it’s on the guide).
For this example the DB user will be called deployer and will have a password eldeployerloco.

§ Install Erlang and Elixir dependencies

sudo apt install build-essential autoconf m4 libncurses5-dev libwxgtk3.0-dev \
libgl1-mesa-dev libglu1-mesa-dev libpng-dev libssh-dev unixodbc-dev

§ Create a deployment user

For example:

sudo adduser deployer

§ Install Erlang and Elixir as the deployment user

Impersonate the user with:

sudo su deployer -l

This is done in order for the deployer user to have access to Elixir and stuff.

Follow this guide, it’s the same for Ubuntu: How to install Elixir in Manjaro Linux.

§ Clone your app repo

Or transfer the code using scp.

git clone https://github.com/lobo-tuerto/my-phoenix-json-api

§ Install the app dependencies

cd my-phoenix-json-api
mix deps.get

§ Adjust config files

Comment out this code in config/prod.exs:

-config :my_app, MyAppWeb.Endpoint,
-  url: [host: "example.com", port: 80],
-  cache_static_manifest: "priv/static/cache_manifest.json"
+
+# config :my_app, MyAppWeb.Endpoint,
+#   url: [host: "example.com", port: 80],
+#   cache_static_manifest: "priv/static/cache_manifest.json"

Uncomment this code from config/prod.secret.exs:

-#     config :my_app, MyAppWeb.Endpoint, server: true
+config :my_app, MyAppWeb.Endpoint, server: true

§ Setup the DB

SECRET_KEY_BASE="`mix phx.gen.secret`" \
DATABASE_URL="ecto://deployer:eldeployerloco@localhost/myapp_prod" \
MIX_ENV=prod mix ecto.create

SECRET_KEY_BASE="`mix phx.gen.secret`" \
DATABASE_URL="ecto://deployer:eldeployerloco@localhost/myapp_prod" \
MIX_ENV=prod mix ecto.migrate

SECRET_KEY_BASE="`mix phx.gen.secret`" \
DATABASE_URL="ecto://deployer:eldeployerloco@localhost/myapp_prod" \
MIX_ENV=prod mix release

Let’s create a couple of users to try it out —this could have been done using the priv/repo/seeds.exs file too, but I want to illustrate how you can access an IEx session on the production app.

_build/prod/rel/my_app/bin/my_app start_iex
MyApp.Auth.create_user(%{email: "asd@asd.com", password: "qwerty"})
MyApp.Auth.create_user(%{email: "some@email.com", password: "some password"})

Now, without exiting that IEx session, go ahead and try this again:

curl -H "Content-Type: application/json" -X POST \
-d '{"email":"asd@asd.com","password":"qwerty"}' \
http://lobotuerto.com:4000/api/users/sign_in \
-c cookies.txt -b cookies.txt -i

You’ll see:

HTTP/1.1 200 OK
cache-control: max-age=0, private, must-revalidate
content-length: 48
content-type: application/json; charset=utf-8
date: Mon, 29 Jul 2019 22:15:13 GMT
server: Cowboy
x-request-id: FbYAie3B0hrQ5-gAAAPF
set-cookie: _my_app_key=SFMyNTY.RgcVe-7fJ-NqSO5DQL; path=/; HttpOnly

{"data":{"user":{"email":"asd@asd.com","id":1}}}

Awesome!

§ Further reading on releases and deployments

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

§ Specify the Phoenix server port

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 ping endpoint to verify the user is still logged in. Should return the current_user information.
  • Adjust and create tests for all this new functionality.

§ Useful links

§ i18n

§ About curl

§ Learning

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

FINIS
Got comments or feedback?
Follow me on twitter
v1.1.0