Codementor Events

Building a JSON API with Phoenix 1.3 and Elixir — lobo_tuerto's notes

Published Jun 13, 2018Last updated Dec 10, 2018
Building a JSON API with Phoenix 1.3 and Elixir — lobo_tuerto's notes

Original publication at: lobo_tuerto's notes

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 libreries 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-brunch --no-html
to mix phx.new.

This tutorial is about creating a small, but fully operational JSON API for
web applications.


To complement your API, I recommend Vue.js on the frontend:
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 module and hash its password ---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 through cookies.

If you are interested on doing auth with JWTs, check this other tutorial out.


Let me be clear about something...

I'm just starting with Elixir / Phoenix, if there are any omissions or bad practices,
bear with me, notify me and I'll fix them ASAP.

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... But I digress.


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
tested both of them and I can say liked neither ---at least on their Chrome app
incarnations--- as one didn't display cookie info, and the other didn't send
declared variables on POST requests. ¬¬

In any case if you want to try them out:

  • You can get Postman here.
  • You can get ARC here.

If you are using a web frontend library like Axios, then your
browser's developer tools should be enough:

If you go with Axios dont' forget to pass the configuration option withCredentials: true,
this will allow the client to send cookies along when doing CORS requests.

Or you can just use good ol' curl it works really well!
I'll show you some examples on how to test out your endpoints from the CLI.


Create a new API-only Phoenix application

Generate the app files

In your terminal:

mix phx.new my-app --app my_app --module MyApp --no-brunch --no-html

From the command above:

  • You'll see my-app as the name for the directory created for this application.

  • You'll see my_app used in files and directories inside my-app/lib e.g. my_app.ex.

  • You'll find MyApp used everywhere since it's the main module for your app.

    For example in my-app/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-app
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 don't like to see HTML pages when there is an error and instead want to receive
JSONs, set debug_errors to false in your config/dev.ex, and 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.


User schema

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

Contexts in Phoenix are cool, they serve as API boundaries that let you
organize your application code in a better way.

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.
  • After that comes 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.
defmodule MyApp.Repo.Migrations.CreateUsers do
  use Ecto.Migration

  def change do
    create table(:users) do
      add(:email, :string, null: false)
      add(:password_hash, :string)
      add(:is_active, :boolean, default: false, null: false)

      timestamps()
    end

    create(unique_index(:users, [:email]))
  end
end

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, "~> 1.0"}
    ]
  end

This is Bcrypt, we will use it to hash the user's password before saving it;
so we don't store it 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 during testing to speed up the process by decreasing security settings in that environment.

Let's add a virtual field in our User schema ---virtual, meaning it doesn't have a place in our DB.
Change lib/my_app/auth/user.ex to look like this:

defmodule MyApp.Auth.User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field(:email, :string)
    field(:is_active, :boolean, default: false)
    field(:password, :string, virtual: true)
    field(:password_hash, :string)

    timestamps()
  end

  @doc false
  def changeset(user, attrs) do
    user
    |> 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, password_hash: Bcrypt.hash_pwd_salt(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 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/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/auth_test.exs:33: (test)

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 is needed in test/my_app/auth/auth_test.exs:

defmodule MyApp.AuthTest do
  # ...
  describe "users" do
    # ...
    @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}
    # ...
  end
end

Let's try with mix test again:

  1) test users list_users/0 returns all users (MyApp.AuthTest)
     test/my_app/auth/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", password_hash: "$2b$12$PAh39aYQjWJznG/eCxtpIOXlBZV2aiFsH4twhe0HikbsYeAhUyBEe", id: 17, inserted_at: ~N[2018-05-26 21:58:01.891773], is_active: true, updated_at: ~N[2018-05-26 21:58:01.891790], password: nil}]
     right: [%MyApp.Auth.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">, email: "some email", password_hash: "$2b$12$PAh39aYQjWJznG/eCxtpIOXlBZV2aiFsH4twhe0HikbsYeAhUyBEe", id: 17, inserted_at: ~N[2018-05-26 21:58:01.891773], is_active: true, updated_at: ~N[2018-05-26 21:58:01.891790], password: "some password"}]
     stacktrace:
       test/my_app/auth/auth_test.exs:28: (test)



  2) test users update_user/2 with invalid data returns error changeset (MyApp.AuthTest)
     test/my_app/auth/auth_test.exs:54
     Assertion with == failed
     code:  assert user == Auth.get_user!(user.id())
     left:  %MyApp.Auth.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">, email: "some email", password_hash: "$2b$12$PBuI90ojvLiULOeUJLMzh.c5kAigasEfWA9CEy191zGiQ3FWtTVjq", id: 18, inserted_at: ~N[2018-05-26 21:58:02.137016], is_active: true, updated_at: ~N[2018-05-26 21:58:02.137034], password: "some password"}
     right: %MyApp.Auth.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">, email: "some email", password_hash: "$2b$12$PBuI90ojvLiULOeUJLMzh.c5kAigasEfWA9CEy191zGiQ3FWtTVjq", id: 18, inserted_at: ~N[2018-05-26 21:58:02.137016], is_active: true, updated_at: ~N[2018-05-26 21:58:02.137034], password: nil}
     stacktrace:
       test/my_app/auth/auth_test.exs:57: (test)

..

  3) test users get_user!/1 returns the user with given id (MyApp.AuthTest)
     test/my_app/auth/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", password_hash: "$2b$12$oAJUZ7CZl062xMlhhbQjjOI0CNyUZIjxeOOHe7PbdqlE.FaNJEupS", id: 21, inserted_at: ~N[2018-05-26 21:58:03.092546], is_active: true, updated_at: ~N[2018-05-26 21:58:03.092563], password: nil}
     right: %MyApp.Auth.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">, email: "some email", password_hash: "$2b$12$oAJUZ7CZl062xMlhhbQjjOI0CNyUZIjxeOOHe7PbdqlE.FaNJEupS", id: 21, inserted_at: ~N[2018-05-26 21:58:03.092546], is_active: true, updated_at: ~N[2018-05-26 21:58:03.092563], password: "some password"}
     stacktrace:
       test/my_app/auth/auth_test.exs:33: (test)

Here the problem is that when we get a user from the DB, password is going to be nil, since we are
only using that field to create or update a user.

To fix that, we will assign nil to user.password and while fixing those, let's take the opportunity
to test the password verification on create_user and update_user too:

defmodule MyApp.AuthTest do
  # ...
  describe "users" do
    # ...
    test "list_users/0 returns all users" do
      # ...changed
      assert Auth.list_users() == [%User{user | password: nil}]
    end

    test "get_user!/1 returns the user with given id" do
      # ...changed
      assert Auth.get_user!(user.id) == %User{user | password: nil}
    end

    test "create_user/1 with valid data creates a user" do
      # ...added
      assert Bcrypt.verify_pass("some password", user.password_hash)
    end
    # ...
    test "update_user/2 with valid data updates the user" do
      # ...added
      assert Bcrypt.verify_pass("some updated password", user.password_hash)
    end

    test "update_user/2 with invalid data returns error changeset" do
      # ...changed and added
      assert %User{user | password: nil} == Auth.get_user!(user.id)
      assert Bcrypt.verify_pass("some password", user.password_hash)
    end
  end
end

Now mix test should yield no errors.

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

Finished in 0.4 seconds
10 tests, 0 failures

Randomized with seed 508666

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-schema and --no-context options.

mix phx.gen.json Auth User users email:string password:string \
is_active:boolean --no-schema --no-context

Fix the tests

Now, if you try and run your tests you'll see this error:

== Compilation error in file lib/my_app_web/controllers/user_controller.ex ==
** (CompileError) lib/my_app_web/controllers/user_controller.ex:18: undefined function user_path/3
    (stdlib) lists.erl:1338: :lists.foreach/2
    (stdlib) erl_eval.erl:670: :erl_eval.do_apply/6

It's complaining about a missing user_path/3 function.

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]

Your lib/my_app_web/router.ex should look like this:

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 for the user:

defmodule MyAppWeb.UserView do
  # ...
  def render("user.json", %{user: user}) do
    %{id: user.id, email: user.email, is_active: user.is_active}
  end
  # ...
end

Tests should be fine now.

Create a couple of users

Using IEx

You can run your app inside IEx (Interactive Elixir) ---this is akin to rails console--- with:

iex -S mix phx.server

Then create a new user with:

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

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

defmodule MyAppWeb.Endpoint do
  # ...
  plug(
    Corsica,
    origins: "http://localhost:8080",
    log: [rejected: :error, invalid: :warn, accepted: :debug],
    allow_headers: ["content-type"],
    allow_credentials: true
  )

  plug(MyAppWeb.Router)
  # ...
end

You can pass a list to origins 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 for HTTP requests ---Vue.js development servers go up on port
8080 by default.


Simple authentication

Verify a user's password

Let's add some functions to the lib/my_app/auth/auth.ex file to verify a user's password:

defmodule MyApp.Auth do
  # ...
  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:

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:

defmodule MyAppWeb.UserController do
  # ...
  def sign_in(conn, %{"email" => email, "password" => password}) do
    case MyApp.Auth.authenticate_user(email, password) do
      {:ok, user} ->
        conn
        |> put_status(:ok)
        |> render(MyAppWeb.UserView, "sign_in.json", user: user)

      {:error, message} ->
        conn
        |> put_status(:unauthorized)
        |> render(MyAppWeb.ErrorView, "401.json", message: message)
    end
  end
end

Notice that we are rendering views inside MyAppWeb not inside MyApp.

Define sing_in.json and 401.json views

In lib/my_app_web/user_view.ex add this:

defmodule MyAppWeb.UserView do
  # ...
  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:

defmodule MyAppWeb.ErrorView do
  # ...
  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

Let's restart our development server and 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": { "id": 1,  "email": "asd@asd.com" }
  }
}

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:

defmodule MyAppWeb.Router do
  # ...
  pipeline :api do
    plug(:accepts, ["json"])
    plug(:fetch_session)
  end
  # ...
end

Save authentication status

Now let's modify our sign_in function in lib/my_app_web/controllers/user_controller.ex:

defmodule MyAppWeb.UserController do
  # ...
  def sign_in(conn, %{"email" => email, "password" => password}) do
    case MyApp.Auth.authenticate_user(email, password) do
      {:ok, user} ->
        conn
        |> put_session(:current_user_id, user.id)
        |> put_status(:ok)
        |> render(MyAppWeb.UserView, "sign_in.json", user: user)

      {:error, message} ->
        conn
        |> delete_session(:current_user_id)
        |> put_status(:unauthorized)
        |> render(MyAppWeb.ErrorView, "401.json", message: message)
    end
  end
end

Protect a resource with authentication

Modify your lib/my_app_web/router.ex to look like this:

defmodule MyAppWeb.Router do
  use MyAppWeb, :router

  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)
    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)
      |> render(MyAppWeb.ErrorView, "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 pipes its requests through :api
then through :api_auth and moved resources "/users" inside.


Isn't it 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
requirement to be logged in. I'll leave here how the fixed
test/my_app_web/controllers/user_controller_test.exs file looks like:

defmodule MyAppWeb.UserControllerTest do
  use MyAppWeb.ConnCase

  alias MyApp.Auth
  alias MyApp.Auth.User
  alias Plug.Test

  @create_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}
  @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: 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, current_user: current_user} do
      conn = get(conn, user_path(conn, :index))

      assert json_response(conn, 200)["data"] == [
               %{
                 "id" => current_user.id,
                 "email" => current_user.email,
                 "is_active" => current_user.is_active
               }
             ]
    end
  end

  describe "create user" do
    test "renders user when data is valid", %{conn: conn} do
      conn = post(conn, user_path(conn, :create), user: @create_attrs)
      assert %{"id" => id} = json_response(conn, 201)["data"]

      conn = get(conn, user_path(conn, :show, id))

      assert json_response(conn, 200)["data"] == %{
               "id" => id,
               "email" => "some email",
               "is_active" => true
             }
    end

    test "renders errors when data is invalid", %{conn: conn} do
      conn = post(conn, user_path(conn, :create), user: @invalid_attrs)
      assert json_response(conn, 422)["errors"] != %{}
    end
  end

  describe "update user" do
    setup [:create_user]

    test "renders user when data is valid", %{conn: conn, user: %User{id: id} = user} do
      conn = put(conn, user_path(conn, :update, user), user: @update_attrs)
      assert %{"id" => ^id} = json_response(conn, 200)["data"]

      conn = get(conn, user_path(conn, :show, id))

      assert json_response(conn, 200)["data"] == %{
               "id" => id,
               "email" => "some updated email",
               "is_active" => false
             }
    end

    test "renders errors when data is invalid", %{conn: conn, user: user} do
      conn = put(conn, user_path(conn, :update, user), user: @invalid_attrs)
      assert json_response(conn, 422)["errors"] != %{}
    end
  end

  describe "delete user" do
    setup [:create_user]

    test "deletes chosen user", %{conn: conn, user: user} do
      conn = delete(conn, user_path(conn, :delete, user))
      assert response(conn, 204)

      assert_error_sent(404, fn ->
        get(conn, user_path(conn, :show, user))
      end)
    end
  end

  defp create_user(_) 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

Add missing tests

In test/my_app/auth/auth_test.exs, test the authenticate_user/2 function:

defmodule MyApp.AuthTest do
  # ...
  describe "users" do
    # ...
    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{user | password: nil} == authenticated_user
    end
  end
end

In test/my_app_web/controllers/user_controller_test.exs, test the sign_in endpoint:

defmodule MyAppWeb.UserControllerTest do
  # ...
  describe "sign_in user" do
    test "renders user when user credentials are good", %{conn: conn, current_user: current_user} do
      conn =
        post(
          conn,
          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, user_path(conn, :sign_in, %{email: "nonexistent email", password: ""}))
      assert json_response(conn, 401)["errors"] == %{"detail" => "Wrong email or password"}
    end
  end
  # ...
end

Endpoint testing with curl

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": { "id": 1, "email": "asd@asd.com" }
  }
}

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": [
    { "is_active": false, "id": 1, "email": "asd@asd.com" },
    { "is_active": false, "id": 2, "email": "some@email.com" }
  ]
}

Success!


Bonus section

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's files with Elixir's built-in code formatter

Create a new .formatter.exs file in your project's root directory, with this content:

[
  inputs: ["mix.exs", "{config,lib,priv,test}/**/*.{ex,exs}"]
]

Now, 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!

— lt

Discover and read more posts from Víctor Adrián de la Cruz Serrano
get started
post commentsBe the first to share your opinion
Show more replies