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:
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 insidemy-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.
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 onUserController
. - Make it RESTy: Extract
sign_in
andsign_out
's functionality from
UserController
onto their own controller.
Maybe call itSessionController
, whereUserController.sign_in
should be
SessionController.create
andUserController.sign_out
should beSessionController.delete
. - Implement DB support for sessions.
- Implement an
/api/me
endpoint on a newMeController
.
This can serve as a ping endpoint to verify the user is still logged in. Should return
thecurrent_user
information. - Adjust and create tests for all this new functionality.
Useful links
- A community driven style guide for Elixir
- User Authentication from Scratch in Elixir and Phoenix
- Tip for Phoenix 1.3 Fallback Controller error
- Debugging Phoenix with IEx.pry
i18n
About curl
Learning
- What's new in Ecto 2.1 FREE ebook
- Learning Regex with Elixir: Tips, Tricks & Caveats
- From Models to Contexts in Phoenix 1.3.0
- Elixir School: Lessons about the Elixir programming language
This was a long one, that's it for now folks!
— lt