Criando APIs com Phoenix 1.7

Nesse tutorial, vamos te ensinar como criar APIs com Phoenix 1.7. O Phoenix é um framework web escrito em Elixir que visa a produtividade e o desempenho.
Guilherme Ferreira | 29 de março de 2023

O Phoenix é um framework web escrito em Elixir que visa a produtividade e o desempenho. Com o lançamento da versão 1.7, o Phoenix recebeu melhorias significativas, tornando-se uma excelente opção para criação de APIs RESTful. Neste artigo, vamos criar um projeto de exemplo chamado “my_app”, que demonstrará como criar uma API RESTful com autenticação e autorização utilizando o Phoenix 1.7.

O projeto será criado utilizando o banco de dados PostgreSQL e configurado com o Docker. Serão desenvolvidos os CRUDs de categorias, artigos e comentários, que estarão relacionados entre si, e a autenticação será implementada para fornecer acesso protegido a determinados recursos da API. Dessa forma, apenas usuários autenticados terão acesso aos recursos que exigem autenticação, garantindo a segurança e a proteção dos dados da API. Com esse projeto de exemplo, esperamos que você possa aprender como criar uma API RESTful completa e segura com o Phoenix 1.7. Vamos começar!

Este artigo será dividido em duas partes. Na primeira parte, mostraremos como montar o sistema de autenticação para a API utilizando o Phoenix 1.7. Serão apresentados todos os passos necessários para implementar o sistema de autenticação com o uso de tokens JWT (JSON Web Tokens). Na segunda parte, daremos seguimento à construção dos demais CRUDs da API, que incluem categorias, artigos e comentários. Esses CRUDs estarão relacionados entre si e serão implementados seguindo as boas práticas de desenvolvimento de APIs RESTful. Vamos mergulhar nesse projeto e aprender a criar uma API RESTful completa e segura com o Phoenix 1.7.

Docker: o que é e como instalar

O Docker é uma plataforma para desenvolvimento, implantação e execução de aplicativos em contêineres. Esses contêineres fornecem uma maneira de empacotar aplicativos com todas as suas dependências e executá-los em qualquer lugar, independentemente do ambiente de hospedagem.

Para este projeto, vamos utilizar o Docker para configurar o nosso banco de dados PostgreSQL. O Docker é uma ferramenta que nos permite criar e gerenciar contêineres de maneira fácil e rápida. Com o Docker, podemos garantir que o nosso banco de dados esteja configurado de maneira consistente e reprodutível em qualquer ambiente.

Para instalar o Docker em seu sistema, acesse o site oficial do Docker e siga as instruções para instalação na sua plataforma. Certifique-se de que a instalação foi concluída com sucesso antes de prosseguir para o próximo passo do projeto.

Criando o banco de dados PostgreSQL no Docker

Para criar o nosso banco de dados PostgreSQL no Docker, vamos utilizar o comando abaixo:

docker run -p 5432:5432 --name db-postgres -e POSTGRES_PASSWORD=postgres -d postgres

Este comando irá baixar a imagem oficial do PostgreSQL do Docker Hub e executar um contêiner com as configurações padrão. Além disso, ele irá mapear a porta 5432 do contêiner para a porta 5432 do host, permitindo que possamos acessar o banco de dados a partir do nosso projeto.

O parâmetro -e POSTGRES_PASSWORD=postgres define a senha do usuário postgres como postgres. Certifique-se de escolher uma senha segura em produção.

Após a execução do comando acima, o nosso banco de dados PostgreSQL estará em execução no Docker. Podemos verificar isso usando o comando docker ps e verificando se o contêiner db-postgres está em execução. Com o banco de dados em execução, podemos prosseguir com a configuração do nosso projeto Phoenix para utilizá-lo.

Criando o projeto Phoenix para gerar a API

Para criar um novo projeto Phoenix chamado “MyApp”, primeiro precisamos ter o Elixir e o Phoenix instalados em nossa máquina. O Elixir é uma linguagem de programação funcional que roda na máquina virtual Erlang, e o Phoenix é um framework que usa o Elixir para criar aplicações web.

Para instalar o Elixir, basta seguir as instruções do site oficial: https://elixir-lang.org/install.html. Para instalar o Phoenix, use o seguinte comando no terminal:

mix archive.install hex phx_new 1.7.1

Isso irá instalar a versão mais recente do Phoenix no momento em que este artigo foi escrito. Agora que temos tudo instalado, podemos criar o nosso projeto “MyApp”. Para isso, basta executar o seguinte comando no terminal:

mix phx.new my_app --no-html --binary-id

Este comando irá criar um novo projeto Phoenix chamado “my_app” em um diretório com o mesmo nome, sem as views e templates HTML. A novidade é que estamos adicionando a opção --binary-id, que configura o projeto para usar IDs binários em vez de IDs numéricos padrão do Elixir.

Os IDs binários são uma representação compacta e eficiente para identificadores, especialmente quando o armazenamento de dados é importante. Eles podem ser úteis em casos em que precisamos de IDs mais curtos ou quando estamos lidando com grande volume de dados.

Diferente dos IDs numéricos, que são geralmente implementados como auto incrementais pelo banco de dados, os IDs binários podem ser gerados pelo próprio aplicativo e não necessariamente em sequência. O uso de IDs binários também é comum em APIs RESTful, especialmente quando se trata de grandes volumes de dados. Ao trabalhar com APIs, o desempenho é fundamental e o uso de IDs binários pode ajudar a reduzir o tamanho das respostas de API, melhorando o tempo de resposta e a velocidade de carregamento dos aplicativos.

Além disso, a geração de IDs binários pode ser implementada no próprio aplicativo, sem depender do banco de dados, permitindo maior flexibilidade e controle sobre a geração de IDs.

No entanto, assim como em outros casos, o uso de IDs binários em APIs RESTful pode não ser adequado para todos os cenários. É importante avaliar as necessidades específicas do projeto e considerar os prós e contras de cada abordagem antes de decidir usar IDs binários ou IDs numéricos padrão do Elixir.

Após a criação do projeto, você pode executá-lo localmente com o seguinte comando:

cd my_app

Vamos criar a conexão com o banco de dados que geramos com o Docker anteriormente. Rode o comando:

mix ecto.create

Agora para rodar o servidor do Phoenix basta rodar:

mix phx.server

Isso iniciará o servidor Phoenix em http://localhost:4000. Você pode acessar esta URL no navegador para verificar se o servidor está respondendo.

O Phoenix 1.7 também traz outras melhorias, como uma nova forma de gerar controllers e views, aprimoramentos na forma como o código é compilado e uma nova API de configuração para tornar mais fácil a personalização de projetos Phoenix.

Em resumo, o Phoenix 1.7 é uma ótima opção para criar aplicações web escaláveis e de alta performance em Elixir. Com sua facilidade de uso e uma grande comunidade de desenvolvedores, criar uma aplicação para API como essa é uma tarefa rápida e simples.

Criando o Crud de usuários para a API

Agora que temos a estrutura básica de nossa aplicação de API criada, podemos começar a desenvolver nossos endpoints. Vamos criar um CRUD básico de usuários que permita a criação, leitura, atualização e exclusão de usuários em nossa aplicação.

Felizmente, o Phoenix nos fornece uma ferramenta muito útil para gerar rapidamente todo o código necessário para um CRUD: o mix phx.gen.json. Este comando irá gerar automaticamente todos os arquivos necessários para um CRUD JSON API.

Para criar nosso CRUD de usuários, execute o seguinte comando no terminal:

mix phx.gen.json Accounts User users email:string password_hash:string

Isso irá gerar uma migration para a tabela de usuários com as colunas de email e password_hash, bem como um model, um controller e views para realizar as operações de CRUD. Ainda não vamos rodar nossa migration porque vamos realizar algumas alterações no próximo tópico.

Em resumo, o mix phx.gen.json é uma ferramenta poderosa e útil para gerar rapidamente o código necessário para um CRUD JSON API em Phoenix. Ao usá-lo, podemos criar endpoints CRUD completos em apenas alguns minutos, economizando muito tempo e esforço no desenvolvimento de nossa aplicação de API. Isso nos permite focar no desenvolvimento de funcionalidades específicas e na implementação de lógica de negócios, em vez de perder tempo criando os arquivos básicos para um CRUD.

Criando o sistema de autenticação para a API

Agora vamos abordar como configurar o sistema de autenticação para nossa API do projeto MyApp. Para isso, utilizaremos duas bibliotecas importantes em Elixir: bcrypt_elixir e guardian.

O bcrypt_elixir é uma biblioteca de Elixir que fornece funções para criptografar e descriptografar senhas usando o algoritmo de hashing bcrypt. Já o guardian é uma biblioteca que oferece suporte para autenticação e autorização em aplicações Phoenix.

Configurando as dependências

Para começar, vamos adicionar as dependências necessárias em nosso arquivo mix.exs:

defp deps do
[
# outras dependências
{:bcrypt_elixir, "~> 2.0"},
{:guardian, "~> 2.3"}
]
end

Em seguida, vamos instalar as dependências executando o comando mix deps.get no terminal. Primeiramente, é necessário adicionar as configurações do Guardian ao nosso projeto.

Para gerar a secret_key, podemos utilizar o comando:

mix guardian.gen.secret

Esse comando irá gerar uma chave aleatória para a nossa aplicação. Em seguida, adicionamos a configuração do Guardian no nosso arquivo de configuração config/config.exs, como mostrado abaixo:

config :my_app, MyApp.Accounts.Guardian,
issuer: "my_app",
secret_key: "secret_key"

Aqui definimos que o issuer será “my_app” e a secret_key será a chave que geramos anteriormente. É importante lembrar que é necessário rodar novamente o servidor do Phoenix para que a configuração seja atualizada.

Com as configurações do Guardian adicionadas, podemos implementar a lógica de autenticação nos nossos controladores.

Lógica de autenticação parte 1

Para criar um CRUD de usuários com autenticação em nossa aplicação, precisamos fazer algumas alterações no schema de usuário e na migration correspondente.

Verifique na pasta priv/repo/migrations o arquivo da migration de usuários e realize uma pequena modificação para garantir que os emails não serjam duplicados. Vamos adicionar a linha create unique_index(:users, [:email]):

def change do
create table(:users) do
add :email, :string
add :password_hash, :string
timestamps()
end
create unique_index(:users, [:email])
end

Após realizar as alterações nos arquivos mencionados acima, podemos rodar o comando das migrations para aplicar as mudanças no banco de dados. Para isso, basta acessar o terminal, navegar até o diretório do projeto e executar o seguinte comando:

mix ecto.migrate

Esse comando irá executar todas as migrations pendentes no projeto, incluindo a modificação que adicionamos no arquivo de migration de usuários. Com isso, o banco de dados será atualizado com as mudanças necessárias para garantir a unicidade dos emails dos usuários.

Para utilizar o bcrypt_elixir, precisamos adicionar uma função de hash de senha em nosso schema de usuário. adequar nosso código em lib/my_app/accounts/user.ex para:

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

schema "users" do
field :email, :string
field :password_hash, :string
field :password, :string, virtual: true

timestamps()
end

@doc false
def changeset(user, attrs) do
user
|> cast(attrs, [:email, :password])
|> validate_required([:email, :password])
|> unique_constraint(:email)
|> password_hash()
end

@doc false
defp password_hash(changeset) do
case changeset do
%Ecto.Changeset{valid?: true, changes: %{password: password}} ->
put_change(changeset, :password_hash, Bcrypt.hash_pwd_salt(password))
%Ecto.Changeset{valid?: true, changes: _} ->
changeset
%Ecto.Changeset{valid?: false} ->
changeset
end
end
end

Primeiro, adicionamos um campo virtual para a senha, usando o código field :password, :string, virtual: true. Isso nos permitirá criar um campo temporário para a senha do usuário, que será usada para gerar o hash de senha e salvar no banco de dados.

Em seguida, adicionamos a restrição única para o campo de e-mail, usando o código unique_constraint(:email). Isso garantirá que não haja usuários com o mesmo endereço de e-mail em nosso sistema.

Por fim, incluímos uma função password_hash() no schema de usuário, que será responsável por gerar o hash da senha do usuário e salvá-lo no campo password_hash no banco de dados.

Agora precisamos corrigir alguns pontos nos testes unitários do CRUD de usuários. Vamos corrigir o arquivo test/support/fixtures/accounts_fixtures.ex deixando assim:

defmodule MyApp.AccountsFixtures do
@moduledoc """
This module defines test helpers for creating
entities via the `MyApp.Accounts` context.
"""

@doc """
Generate a user.
"""
def user_fixture(attrs \ %{}) do
{:ok, user} =
attrs
|> Enum.into(%{
email: "some email",
password: "some password_hash"
})
|> MyApp.Accounts.create_user()

user
end
end

E o arquivo test/my_app/accounts_test.exs ficou assim:

defmodule MyApp.AccountsTest do
  use MyApp.DataCase

  alias MyApp.Accounts

  describe "users" do
    alias MyApp.Accounts.User

    import MyApp.AccountsFixtures

    @invalid_attrs %{email: nil, password_hash: nil}

    test "list_users/0 returns all users" do
      user_fixture()
      assert [_user] = Accounts.list_users()
    end

    test "get_user!/1 returns the user with given id" do
      user = user_fixture()
      assert Accounts.get_user!(user.id).id == user.id
    end

    test "create_user/1 with valid data creates a user" do
      valid_attrs = %{email: "some email", password: "some password_hash"}

      assert {:ok, %User{} = user} = Accounts.create_user(valid_attrs)
      assert user.email == "some email"
      assert user.password == "some password_hash"
    end

    test "create_user/1 with invalid data returns error changeset" do
      assert {:error, %Ecto.Changeset{}} = Accounts.create_user(@invalid_attrs)
    end

    test "update_user/2 with valid data updates the user" do
      user = user_fixture()
      update_attrs = %{email: "some updated email", password: "some updated
 password_hash"}

      assert {:ok, %User{} = user} = Accounts.update_user(user, update_attrs)
      assert user.email == "some updated email"
      assert user.password == "some updated password_hash"
    end

    test "update_user/2 with invalid data returns error changeset" do
      user = user_fixture()
      assert {:error, %Ecto.Changeset{}} = Accounts.update_user(user, @invalid_attrs)
      assert user.id == Accounts.get_user!(user.id).id
    end

    test "delete_user/1 deletes the user" do
      user = user_fixture()
      assert {:ok, %User{}} = Accounts.delete_user(user)
      assert_raise Ecto.NoResultsError, fn -> Accounts.get_user!(user.id) end
    end

    test "change_user/1 returns a user changeset" do
      user = user_fixture()
      assert %Ecto.Changeset{} = Accounts.change_user(user, %{email: "some email", 
password: "some password_hash"})
    end
  end
end

Tive que fazer alguns ajustes para adequar com a lógica do password_hash. Nos testes do controle de usuários em test/my_app_web/controllers/user_controller_test.exs precisamos adequar com o sistema de autenticação. O código completo ficou assim:

defmodule MyAppWeb.UserControllerTest do
use MyAppWeb.ConnCase

import MyApp.AccountsFixtures

alias MyApp.Accounts.User
alias MyApp.Accounts.Guardian

@create_attrs %{
email: "some email valid",
password: "some password_hash"
}
@update_attrs %{
email: "some updated email",
password: "some updated password_hash"
}
@invalid_attrs %{email: nil, password: nil}

setup %{conn: conn} do
{:ok, conn: put_req_header(conn, "accept", "application/json")}
end

describe "index" do
setup [:create_session]

test "lists all users", %{conn: conn, user: user} do
conn = get(conn, ~p"/api/users")
assert json_response(conn, 200)["data"] |> Enum.at(0) |> Map.get("id") == user.id
end
end

describe "create user" do
setup [:create_session]

test "renders user when data is valid", %{conn: conn} do
conn = post(conn, ~p"/api/users", user: @create_attrs)
assert %{"id" => id} = json_response(conn, 201)["data"]

conn = get(conn, ~p"/api/users/#{id}")

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

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

describe "update user" do
setup [:create_session]

test "renders user when data is valid", %{conn: conn, user: %User{id: id} = user} do
conn = put(conn, ~p"/api/users/#{user}", user: @update_attrs)
assert %{"id" => ^id} = json_response(conn, 200)["data"]

conn = get(conn, ~p"/api/users/#{id}")

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

test "renders errors when data is invalid", %{conn: conn, user: user} do
conn = put(conn, ~p"/api/users/#{user}", user: @invalid_attrs)
assert json_response(conn, 422)["errors"] != %{}
end
end

describe "delete user" do
setup [:create_session]

test "deletes chosen user", %{conn: conn, user: user} do
conn = delete(conn, ~p"/api/users/#{user.id}")
assert response(conn, 204)
end
end

describe "login" do
setup [:create_user]

test "with valid credentials", %{conn: conn, user: user} do
conn =
post(conn, ~p"/api/login", %{
email: user.email,
password: "some password_hash"
})

assert json_response(conn, 200)["data"]["id"] == user.id
end

test "with invalid credentials", %{conn: conn, user: user} do
conn =
post(conn, "/api/login", %{
email: user.email,
password: "wrong_password"
})

assert json_response(conn, 401)["error"] =~ "Invalid credentials"
end
end

describe "user area" do
test "requires authentication", %{conn: conn} do
conn = get(conn, ~p"/api/users")
assert conn.resp_body =~ "unauthenticated"
end

test "lists all users when authenticated", %{conn: conn} do
user1 =
user_fixture(%{
email: "some email 1",
password: "some password_hash"
})

user2 =
user_fixture(%{
email: "some email2",
password: "some password_hash"
})

conn = Guardian.Plug.sign_in(conn, user1)
{:ok, token, _claims} = Guardian.encode_and_sign(user1)

conn = get(conn, ~p"/api/users", %{"Authorization" => "Bearer #{token}"})

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

defp create_user(_) do
user = user_fixture()
%{user: user}
end

defp create_session(%{conn: conn}) do
user = user_fixture()
conn = Guardian.Plug.sign_in(conn, user)
{:ok, token, _claims} = Guardian.encode_and_sign(user)

%{conn: put_req_header(conn, "authorization", "Bearer #{token}"), user: user}
end
end

O código apresentado é um exemplo de como ajustar os testes do controle de usuários para se adequar com um sistema de autenticação. Em específico, o sistema de autenticação utilizado é o Guardian, que fornece um token JWT que é passado no cabeçalho da requisição usando a chave “Authorization” => “Bearer #{token}”.

Esse token é necessário para acessar as rotas que precisam de autenticação, como a rota para listar todos os usuários, que é testada no método test “lists all users when authenticated”, %{conn: conn}. O uso do token é feito dentro do parâmetro params da chamada get(conn, ~p"/api/users", %{“Authorization” => “Bearer #{token}”}). Esse exemplo ilustra como é possível integrar sistemas de autenticação em testes, garantindo que as rotas protegidas estejam funcionando corretamente e que as rotas públicas estejam abertas para o acesso de todos.

Com essas alterações, estamos prontos para criar usuários e salvá-los em nosso banco de dados com senhas criptografadas. Quando um usuário fizer login, usaremos o bcrypt_elixir para comparar a senha fornecida com o hash de senha salvo em nosso banco de dados. Se a senha estiver correta, usaremos o guardian para gerar um token de autenticação para o usuário.

Lógica de autenticação parte 2

Para definir nosso sistema de autenticação, precisamos criar três arquivos: guardian.ex, pipeline.ex e error_handler.ex dentro da pasta lib/my_app/accounts.

Em guardian.ex, vamos adicionar o código abaixo. Primeiro, usamos o Guardian, definindo o otp_app como :my_app. Em seguida, fazemos um alias para o módulo MyApp.Accounts. Definimos a função subject_for_token/2 para retornar o id do usuário como uma string. E, finalmente, definimos a função resource_from_claims/1 para buscar o usuário no banco de dados com base no id fornecido nos claims.

defmodule MyApp.Accounts.Guardian do
use Guardian, otp_app: :my_app

alias MyApp.Accounts

def subject_for_token(user, _claims) do
{:ok, to_string(user.id)}
end

def resource_from_claims(%{"sub" => id}) do
user = Accounts.get_user!(id)
{:ok, user}
rescue
Ecto.NoResultsError -> {:error, :resource_not_found}
end
end

No pipeline.ex, vamos adicionar o seguinte código. Primeiro, usamos o Guardian.Plug.Pipeline e definimos o otp_app como :my_app. Em seguida, definimos o error_handler como MyApp.Accounts.ErrorHandler e o módulo Guardian como MyApp.Accounts.Guardian. Então, adicionamos os três plugs necessários para o processo de autenticação. Primeiro, o plug VerifySession para verificar se há um token de sessão. Em seguida, o plug VerifyHeader para verificar se há um token de autorização no header. Por fim, o plug LoadResource para carregar o usuário com base nos claims.

defmodule MyApp.Accounts.Pipeline do
use Guardian.Plug.Pipeline,
otp_app: :my_app,
error_handler: MyApp.Accounts.ErrorHandler,
module: MyApp.Accounts.Guardian

# If there is a session token, restrict it to an access token and validate it
plug Guardian.Plug.VerifySession, claims: %{"typ" => "access"}
# If there is an authorization header, restrict it to an access token and validate it
plug Guardian.Plug.VerifyHeader, claims: %{"typ" => "access"}
# Load the user if either of the verifications worked
plug Guardian.Plug.LoadResource, allow_blank: true
end

Em error_handler.ex, vamos adicionar o código abaixo. Definimos o módulo como MyApp.Accounts.ErrorHandler e definimos a função auth_error/3 para retornar uma mensagem de erro 401 quando a autenticação falha.

defmodule MyApp.Accounts.ErrorHandler do
import Plug.Conn

@behaviour Guardian.Plug.ErrorHandler

@impl Guardian.Plug.ErrorHandler
def auth_error(conn, {type, _reason}, _opts) do
body = to_string(type)
conn
|> put_resp_content_type("text/plain")
|> send_resp(401, body)
end
end

Vamos configurar mais um ponto em lib/my_app/accounts.ex para adicionar o método authenticate_user. Este método será responsável por autenticar o usuário através do email e da senha.

O código para o método authenticate_user é o seguinte:

def authenticate_user(email, plain_text_password) do
query = from(u in User, where: u.email == ^email)

case Repo.one(query) do
nil ->
{:error, :invalid_credentials}

user ->
if Bcrypt.verify_pass(plain_text_password, user.password_hash) do
{:ok, user}
else
{:error, :invalid_credentials}
end
end
end

O método authenticate_user recebe como parâmetros o email e a senha em texto plano. Ele faz uma consulta no banco de dados para encontrar um usuário com o email fornecido e verifica se a senha fornecida coincide com a senha criptografada no banco de dados. Se tudo estiver correto, retorna o usuário autenticado como um valor :ok. Se o email não for encontrado ou a senha estiver incorreta, retorna {:error, :invalid_credentials}.

##Controle de session e ajustes no controle de usuários

O próximo passo consiste em criar os arquivos lib/my_app_web/controllers/session_controller.ex e lib/my_app_web/controllers/session_json.ex, realizar um ajuste em lib/my_app_web/controllers/user_controller.ex e lib/my_app_web/controllers/user_json.ex.

No arquivo session_controller.ex, adicionaremos o seguinte código:

defmodule MyAppWeb.SessionController do
use MyAppWeb, :controller

action_fallback MyAppWeb.FallbackController

alias MyApp.{Accounts, Accounts.Guardian}

def login(conn, %{"email" => email, "password" => password}) do
case Accounts.authenticate_user(email, password) do
{:ok, user} ->
{:ok, token, _claims} = Guardian.encode_and_sign(user)
conn
|> put_status(:ok)
|> render(:user_token, user: user, token: token)
{:error, _reason} ->
conn
|> put_status(:unauthorized)
|> json(%{error: "Invalid credentials"})
end
end

def logout(conn, _) do
conn
|> Guardian.Plug.sign_out()
|> put_status(:ok)
|> json(%{msg: "Logged out"})
end
end

Explicando o código acima, temos:

  • defmodule MyAppWeb.SessionController: define o módulo SessionController dentro do contexto MyAppWeb.
  • use MyAppWeb, :controller: usa o módulo MyAppWeb como um controller, adicionando funcionalidades específicas.
  • action_fallback MyAppWeb.FallbackController: define a ação de fallback como MyAppWeb.FallbackController.
  • alias MyApp.{Accounts, Accounts.Guardian}: cria aliases para os módulos Accounts e Accounts.Guardian.
  • def login(conn, %{“email” => email, “password” => password}): define a função login que recebe a conexão conn e um mapa contendo os campos email e password.
  • case Accounts.authenticate_user(email, password) do: utiliza o módulo Accounts para autenticar o usuário com email e senha fornecidos.
  • {:ok, user} ->: se o usuário for autenticado com sucesso, é gerado um token utilizando o módulo Guardian.encode_and_sign(user).
  • conn |> put_status(:ok) |> render(:user_token, user: user, token: token): a resposta da requisição inclui um status 200, e é renderizada a view :user_token com os parâmetros user e token.
  • {:error, _reason} ->: se a autenticação falhar, a resposta da requisição inclui um status 401 (unauthorized) e uma mensagem de erro em formato json.
  • def logout(conn, _) do: define a função logout que recebe a conexão conn e ignora o segundo parâmetro.
  • conn |> Guardian.Plug.sign_out() |> put_status(:ok) |> json(%{msg: “Logged out”}): a função realiza o logout do usuário e retorna uma mensagem em formato json indicando que o usuário foi desconectado.

Em seguida, no arquivo session_json.ex temos:

defmodule MyAppWeb.SessionJSON do
alias MyApp.Accounts.User

@doc """
Renders a user and token.
"""
def user_token(%{user: user, token: token}) do
%{data: data(user, token)}
end

defp data(%User{} = user, token) do
%{
id: user.id,
email: user.email,
token: token
}
end
end

Esse módulo é responsável por renderizar os dados de usuário e token em formato JSON. Ele define duas funções: user_token: recebe um mapa com as chaves user e token e retorna um novo mapa com a chave data contendo os dados de usuário e token; data: recebe um objeto de usuário e um token e retorna um mapa contendo os dados de usuário (id e e-mail) e o token. Essas funções são usadas no controlador de sessão (session_controller.ex) para renderizar a resposta da API após o login do usuário.

No trecho de código apresentado, estamos definindo o método create no arquivo user_controller.ex da nossa aplicação. Esse método é responsável por criar um novo usuário na base de dados.

No parâmetro conn recebemos a requisição HTTP feita pelo cliente e no parâmetro %{“user” => user_params} recebemos os parâmetros necessários para criar um novo usuário.

Dentro do bloco with, fazemos uma chamada ao método Accounts.create_user(user_params) para criar o novo usuário na base de dados. Se essa chamada retornar {:ok, %User{} = user}, significa que a criação do usuário foi realizada com sucesso.

A seguir, utilizamos o módulo Guardian para gerar um token de autenticação para o usuário recém-criado. A linha {:ok, token, _claims} = Guardian.encode_and_sign(user) gera o token e retorna uma tupla com o token e outras informações que não são relevantes nesse momento.

Por fim, utilizamos a função render para retornar uma resposta HTTP com o status :created, indicando que o recurso foi criado com sucesso. Além disso, adicionamos um cabeçalho na resposta com o endereço do novo usuário criado e incluímos o token na resposta JSON que será enviada ao cliente.

O código completo do método create no arquivo user_controller.ex fica assim:

def create(conn, %{"user" => user_params}) do
  with {:ok, %User{} = user} <- Accounts.create_user(user_params) do
    {:ok, token, _claims} = Guardian.encode_and_sign(user)

    conn
    |> put_status(:created)
    |> put_resp_header("location", ~p"/api/users/#{user}")
    |> render(:create, user: user, token: token)
  end
end

Agora adicione o seguinte alias, para utilizar o Guardian, na parte de cima do controle:

alias MyApp.Accounts.Guardian

O arquivo user_json.ex é responsável por renderizar a resposta da API para as operações relacionadas ao usuário. Nesse arquivo, precisamos incluir o token na resposta da operação de criação de usuário.

Na função create/1, adicionamos o parâmetro token na chamada da função data/2, que é responsável por formatar os dados do usuário e token na resposta da API. Essa função recebe o objeto do usuário e o token e retorna um mapa com as informações necessárias.

Já na função data/2, que é uma função privada, adicionamos o campo token no mapa de retorno, para que ele possa ser incluído na resposta da API.

Código completo:

defmodule MyAppWeb.UserJSON do
  alias MyApp.Accounts.User

  @doc """
  Renders a list of users.
  """
  def index(%{users: users}) do
    %{data: for(user <- users, do: data(user))}
  end

  @doc """
  Renders a create user.
  """
  def create(%{user: user, token: token}) do
    %{data: data(user, token)}
  end

  @doc """
  Renders a single user.
  """
  def show(%{user: user}) do
    %{data: data(user)}
  end

  defp data(%User{} = user) do
    %{
      id: user.id,
      email: user.email
    }
  end

  defp data(%User{} = user, token) do
    %{
      id: user.id,
      email: user.email,
      token: token
    }
  end
end

Definindo as rotas de usuários e autenticação

Vamos adicionar rotas para usuários, autenticação e controle de acesso ao arquivo route.ex.

Primeiro, definimos dois pipelines: :auth e :ensure_auth. O pipeline :auth usa o plug MyApp.Accounts.Pipeline, que define como autenticar e autorizar usuários, enquanto o pipeline :ensure_auth usa o plug Guardian.Plug.EnsureAuthenticated, que verifica se o usuário está autenticado antes de permitir o acesso à rota.

Em seguida, adicionamos duas rotas sob o escopo “/api”, a primeira é a rota para criação de usuários, usando o método POST para chamar o método create do UserController. A segunda rota é para login de usuários, usando o método POST para chamar o método login do SessionController.

Finalmente, temos a rota para logout de usuários, usando o método GET para chamar o método logout do SessionController.

Na segunda parte do escopo, adicionamos a rota para os recursos de usuários. Aqui, usamos o método resources para definir as rotas padrão para o controle de usuários, exceto para as rotas :new e :edit.

Aqui está o código completo do arquivo route.ex com as alterações:

defmodule MyAppWeb.Router do
  use MyAppWeb, :router

  pipeline :api do
    plug :accepts, ["json"]
  end

  pipeline :auth do
    plug MyApp.Accounts.Pipeline
  end
  
  pipeline :ensure_auth do
    plug Guardian.Plug.EnsureAuthenticated
  end

  scope "/api", MyAppWeb do
    pipe_through [:api, :auth]

    post "/users", UserController, :create
    post "/login", SessionController, :login
    get "/logout", SessionController, :logout
  end

  scope "/api", MyAppWeb do
    pipe_through [:api, :auth, :ensure_auth]

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

O primeiro scope utiliza o pipeline :api, :auth, o que significa que todas as rotas dentro dele passarão pelos plugs definidos em ambos os pipelines. Já o segundo scope, além de utilizar o mesmo pipeline anterior, adiciona o pipeline :ensure_auth, que tem como objetivo garantir que somente usuários autenticados e com tokens válidos possam acessar as rotas dentro deste escopo. Dessa forma, o segundo scope é utilizado para adicionar rotas que precisam estar protegidas pelo sistema de autenticação, enquanto o primeiro é utilizado para rotas que não requerem autenticação ou que são protegidas apenas pelo pipeline :auth.

Agora que já criamos todas as rotas necessárias para o nosso sistema de autenticação, podemos testá-las usando ferramentas como o Postman ou o curl. Antes de testar, é necessário iniciar o servidor do Phoenix usando o comando “mix phx.server” no terminal.

Para testar a rota de criação de usuários, podemos usar o método POST no endpoint “/api/users”. Podemos enviar uma requisição com um corpo JSON contendo as informações do usuário, como email e senha, e o servidor irá criar um novo usuário e gerar um token JWT para o mesmo. Para testar a rota de login, podemos usar o método POST no endpoint “/api/login” enviando as credenciais do usuário, email e senha, e o servidor irá gerar um novo token JWT. Para testar a rota de logout, podemos usar o método GET no endpoint “/api/logout” e o servidor irá invalidar o token JWT atual.

Caso ocorram erros nas requisições, o servidor irá retornar um código HTTP de erro com uma mensagem explicando o problema. Com todas as rotas configuradas e testadas, temos um sistema de autenticação básico pronto para ser utilizado em nossa aplicação.

Conclusão

Vimos como montar o sistema de autenticação para a API utilizando o Phoenix 1.7. Com o uso de tokens JWT, implementamos a autenticação e autorização para garantir que apenas usuários autenticados tenham acesso aos recursos que exigem autenticação. Além disso, configuramos o banco de dados PostgreSQL e o Docker para garantir uma infraestrutura escalável e segura.

Guilherme Ferreira
Software Engineer formado em Ciência da Computação. Tutor de cursos e desenvolvedor de software. Curte explorar novas tecnologias e estudar inteligência artificial.