💧🍔 Projeto Rockelivery: API para Pedidos em um Restaurante com Elixir e Phoenix (Parte 2)
Dev Maiqui 🇧🇷
Posted on June 7, 2021
Essa é a segunda parte do projeto Rockelivery. Esse projeto faz parte do Bootcamp da Rocketseat, ministrado pelo professor Rafael Camarda.
Conteúdo:
🕵🏻♂️ Conhecendo o Módulo Ecto.Repo
Para podermos inserir um usuário no banco de dados precisamos conhecer antes o módulo Ecto.Repo. O módulo Ecto.Repo define um repositório. No nosso caso será definido como Rockelivery.Repo
. Ele nos fornecerá a 'interface' com o nosso banco de dados, nos permitindo fazer a criação, alteração, deleção e consulta dos dados no banco.
Nosso repositório está definido em lib/rockelivery/repo.ex
. Nesse arquivo podemos ver também que foi usado o adapter do PostgreSQL:
defmodule Rockelivery.Repo do
use Ecto.Repo,
otp_app: :rockelivery,
adapter: Ecto.Adapters.Postgres
end
A configuração do Repo deve estar em seu ambiente de aplicativo, geralmente definido no arquivo config/config.exs
:
use Mix.Config
# configuração do Repo
config :rockelivery,
ecto_repos: [Rockelivery.Repo]
# você deve lembrar que fizemos uma configuração
# para definirmos o ID como UUID também nesse arquivo
config :rockelivery, Rockelivery.Repo,
migration_primary_key: [type: :binary_id],
migration_foreign_key: [type: :binary_id]
Essas definições você encontra na documentação oficial: https://hexdocs.pm/ecto/Ecto.html
Podemos ter mais de um repositório, o que significa que podemos nos conectar a mais de um banco de dados. Você pode conferir mais sobre isso nesse artigo: Meet Ecto, No-compromise Database Wrapper for Concurrent Elixir Apps
Este artigo em português também pode te ajudar a ter mais clareza sobre o repositório: https://elixirschool.com/pt/lessons/ecto/basics/
Um adaptador (adapter) é necessário para se comunicar com o banco de dados. O Ecto suporta diferentes banco de dados através do uso dos adaptadores. Alguns exemplos de adaptadores são:
- PostgreSQL
- MySQL
- SQLite
Acessando a documentação você pode encontrar várias funções chamadas de Callbacks, sendo funções implementadas pelo adaptador: https://hexdocs.pm/ecto/Ecto.Repo.html#callbacks
🧍 Inserindo o Usuário no Banco de Dados
Vamos agora inserir um usuário no banco de dados utilizando o Modo Interativo do Elixir chamado Interactive Elixir (iex). Execute o comando iex -S mix
no terminal.
Definindo os parâmetros do usuário:
iex> user_params = %{address: "Rua...", age: 28, cep: "12345678", cpf: "12345678910", email: "maiqui@teste.com", name: "Maiqui", password: "123456"}
%{
address: "Rua...",
age: 28,
cep: "12345678",
cpf: "12345678910",
email: "maiqui@teste.com",
name: "Maiqui",
password: "123456"
}
Inserindo o usuário no banco:
Para inserirmos um usuário no banco vamos utilizar o callback Rockelivery.Repo.insert/1
. Podemos ver um exemplo do que a função recebe como parâmetro e dos possíveis retornos:
case MyRepo.insert %Post{title: "Ecto is great"} do
{:ok, struct} -> # Inserted with success
{:error, changeset} -> # Something went wrong
end
Perceba que se der :ok
o retorno é uma struct
, senão o retorno é um changeset
.
Este exemplo e mais detalhes você pode conferir na documentação:
https://hexdocs.pm/ecto/Ecto.Repo.html#c:insert/2
Agora podemos então inserir o usuário no banco:
iex> user_params |> Rockelivery.User.changeset() |> Rockelivery.Repo.insert()
[debug] QUERY OK db=69.1ms decode=8.7ms queue=22.3ms idle=1924.0ms
INSERT INTO "users" ("address","age","cep","cpf","email","name","password_hash","inserted_at","updated_at","id") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ["Rua...", 28, "12345678", "12345678910", "maiqui@teste.com", "Maiqui", "$argon2id$v=19$m=131072,t=8,p=4$L3mKYnhn5ooDZRsyd7maaA$92xStD9oCV06HtJYnoYhGY4HBJK69bUvb6HpTv+IlPc", ~N[2021-06-05 14:45:28], ~N[2021-06-05 14:45:28], <<72, 47, 149, 167, 180, 71, 66, 233, 174, 103, 174, 247, 41, 84, 195, 240>>]
{:ok,
%Rockelivery.User{
__meta__: #Ecto.Schema.Metadata<:loaded, "users">,
address: "Rua...",
age: 28,
cep: "12345678",
cpf: "12345678910",
email: "maiqui@teste.com",
id: "482f95a7-b447-42e9-ae67-aef72954c3f0",
inserted_at: ~N[2021-06-05 14:45:28],
name: "Maiqui",
password: "123456",
password_hash: "$argon2id$v=19$m=131072,t=8,p=4$L3mKYnhn5ooDZRsyd7maaA$92xStD9oCV06HtJYnoYhGY4HBJK69bUvb6HpTv+IlPc",
updated_at: ~N[2021-06-05 14:45:28]
}}
🧍♀️ Buscando o Usuário no Banco de Dados
Para pesquisar todos os usuários no banco, vamos utilizar o callback Rockelivery.Repo.all/1
. A documentação desta função você encontra aqui: https://hexdocs.pm/ecto/Ecto.Repo.html#c:all/2
iex> Rockelivery.Repo.all(Rockelivery.User)
[debug] QUERY OK source="users" db=3.6ms queue=1.7ms idle=895.2ms
SELECT u0."id", u0."address", u0."age", u0."cep", u0."cpf", u0."email", u0."name", u0."password_hash", u0."inserted_at", u0."updated_at" FROM "users" AS u0 []
[
%Rockelivery.User{
__meta__: #Ecto.Schema.Metadata<:loaded, "users">,
address: "Rua...",
age: 28,
cep: "12345678",
cpf: "12345678910",
email: "maiqui@teste.com",
id: "482f95a7-b447-42e9-ae67-aef72954c3f0",
inserted_at: ~N[2021-06-05 14:45:28],
name: "Maiqui",
password: nil,
password_hash: "$argon2id$v=19$m=131072,t=8,p=4$L3mKYnhn5ooDZRsyd7maaA$92xStD9oCV06HtJYnoYhGY4HBJK69bUvb6HpTv+IlPc",
updated_at: ~N[2021-06-05 14:45:28]
}
]
Pesquisando no banco um usuário pelo seu ID:
Para pesquisar no banco o usuário pelo ID, vamos utilizar o callback Rockelivery.Repo.get/2
. Vamos usar o ID da última pesquisa. A documentação desta função você encontra aqui: https://hexdocs.pm/ecto/Ecto.Repo.html#c:get/3
iex> Rockelivery.Repo.get(Rockelivery.User, "482f95a7-b447-42e9-ae67-aef72954c3f0")
[debug] QUERY OK source="users" db=5.8ms queue=3.0ms idle=1422.9ms
SELECT u0."id", u0."address", u0."age", u0."cep", u0."cpf", u0."email", u0."name", u0."password_hash", u0."inserted_at", u0."updated_at" FROM "users" AS u0 WHERE (u0."id" = $1) [<<72, 47, 149, 167, 180, 71, 66, 233, 174, 103, 174, 247, 41, 84, 195, 240>>]
%Rockelivery.User{
__meta__: #Ecto.Schema.Metadata<:loaded, "users">,
address: "Rua...",
age: 28,
cep: "12345678",
cpf: "12345678910",
email: "maiqui@teste.com",
id: "482f95a7-b447-42e9-ae67-aef72954c3f0",
inserted_at: ~N[2021-06-05 14:45:28],
name: "Maiqui",
password: nil,
password_hash: "$argon2id$v=19$m=131072,t=8,p=4$L3mKYnhn5ooDZRsyd7maaA$92xStD9oCV06HtJYnoYhGY4HBJK69bUvb6HpTv+IlPc",
updated_at: ~N[2021-06-05 14:45:28]
}
🧍♂️ Módulo Users.Create
Agora precisamos inserir o usuário de um jeito mais fácil, até porque quem estiver usando a nossa API irá passar apenas os parâmetros esperando a inserção deles. Vamos criar um módulo onde terá uma função que receberá esses parâmetros, realizará as validações e depois, se todos os dados estiverem ok, fará a inserção no banco de dados.
Vamos criar o arquivo lib/rockelivery/users/create.ex
e o seu conteúdo terá o seguinte código:
defmodule Rockelivery.Users.Create do
alias Rockelivery.{User, Repo}
# O alias acima é a mesma coisa que:
# alias Rockelivery.User
# alias Rockelivery.Repo
def call(%{} = params) do
params
|> User.changeset()
|> Repo.insert()
end
def call(_anything), do: "Enter the data in a map format"
end
Como parâmetro estamos usando esse Pattern Matching %{} = params
estamos definindo que os dados devem ter o formato de map
. Caso a função receba qualquer outra coisa, será devolvido uma mensagem que está no segundo escopo da função.
iex> Rockelivery.Users.Create.call([])
"Enter the data in a map format"
A função sendo nomeada como call faz parte do Command Pattern. É um padrão que nesse caso significa que estamos chamando (call) a ação de criação (Create).
Vamos testar usando o iex. Se você não saiu do iex na última sessão não esqueça de usar o comando recompile
para a função existir para você. Vamos tentar criar um usuário com a mesma variável user_params
:
Estamos vendo que o usuário não foi inserido devido a um erro acusado por uma constraint. Simplismente o CPF já existe no banco. Mas, se alterarmos apenas o CPF, a constraint do email também irá reclamar que o email já existe. Aqui conseguimos perceber melhor como uma constraint funciona. Ela faz as validações no banco, diferente das validations que validam as informações antes de ir para o banco de dados.
Então, precisamos alterar pelo menos as informações do CPF e do EMAIL:
iex> user_params = %{address: "Rua...", age: 28, cep: "12345678", cpf: "12345678911", email: "mikeshinoda@teste.com"
, name: "Mike Shinoda", password: "123456"}
Agora você pode inserir um novo usuário usando a função que criamos:
iex> Rockelivery.Users.Create.call(user_params)
E, pode também, pesquisar os usuários para confirmar a nova inserção:
iex> Rockelivery.Repo.all(Rockelivery.User)
🌐 Criando a Parte Web
Agora podemos criar as nossas rotas, controllers e views. Criei um artigo explicando detalhadamente sobre isso e gostaria muito que você lesse antes de prosseguir nesse projeto: O Ciclo de Vida do Request no Phoenix
🛣️ Rotas
Vamos precisar do CRUD completo (Create, Read, Update, Delete), então vamos ao arquivo de rotas:
Em lib/rockelivery_web/router.ex
:
scope "/api", RockeliveryWeb do
pipe_through :api
get "/users/", UsersController, :index
post "/users/", UsersController, :create
put "/users/:id", UsersController, :update
get "/users/:id", UsersController, :show
delete "/users/:id", UsersController, :delete
end
Podemos criar o código acima de uma maneira mais fácil, usando o comando resources
:
scope "/api", RockeliveryWeb do
pipe_through :api
resources "/users", UsersController
end
Para podermos entender o resources
precisamos ter o controller criado.
Crie lib/rockelivery_web/controllers/users_controller.ex
:
defmodule RockeliveryWeb.UsersController do
use RockeliveryWeb, :controller
end
Vamos agora verificar todas rodas disponíveis da nossa aplicação digitando o comando no terminal:
$ mix phx.routes
Podemos ver que o resources
criou todas as rotas para nós.
Mas como não vamos fazer a criação e nem a alteração via front-end, não precisaremos das actions :edit
e :new
. Então podemos retirálas alterando o resources
:
scope "/api", RockeliveryWeb do
pipe_through :api
resources "/users", UsersController, except: [:edit, :new]
end
Você pode obter mais detalhes na documentação do Phoenix: https://hexdocs.pm/phoenix/routing.html#resources
🕹️ UsersController
Vamos criar agora a action do controller.
Em lib/rockelivery_web/controllers/users_controller.ex
:
defmodule RockeliveryWeb.UsersController do
use RockeliveryWeb, :controller
alias Rockelivery.Users.Create
def create(_conn, params) do
Create.call(params)
end
end
Facade Pattern
Existe uma maneira melhor para chamarmos a função Create.call/1
. Existe um padrão chamado Facade Pattern (Padrão da Fachada). O objetivo deste padrão é esconder a complexidade. Um exemplo disso, é o volante de um carro. Para virar à equerda você não precisa dizer: "gire a barra de ligação 20 graus no sentido anti-horário". Você apenas diz: "vire à esquerda aqui!" Há um artigo que fala mais sobre isso: Saner apps with the Facade Pattern
O nosso arquivo de fachada será lib/rockelivery
:
defmodule Rockelivery do
alias Rockelivery.Users.Create, as: UserCreate
defdelegate create_user(params), to: UserCreate, as: :call
end
Agora sempre que quisermos usar Rockelivery.Users.Create.call/1
devemos usar na verdade Rockelivery.create_user/1
. Então, precisamos refatorar o código do controller em lib/rockelivery_web/controllers/users_controller.ex
:
defmodule RockeliveryWeb.UsersController do
use RockeliveryWeb, :controller
def create(_conn, params) do
Rockelivery.create_user(params)
end
end
Estrutura de Controle With
Agora precisamos tratar o caso de sucesso e o caso de erro da nossa action. Para isso, podemos utilizar a estrutura de controle with. Ela é uma ótima opção para quando temos vários casos de sucesso, um após o outro.
Você pode encontrar exemplos do with
aqui: https://elixirschool.com/pt/lessons/basics/control-structures/#with
Ou visite a documentação: https://hexdocs.pm/elixir/master/Kernel.SpecialForms.html#with/1
Vamos agora alterar o nosso código em lib/rockelivery_web/controllers/users_controller.ex
:
defmodule RockeliveryWeb.UsersController do
use RockeliveryWeb, :controller
alias Rockelivery.User
def create(conn, params) do
with {:ok, %User{} = user} <- Rockelivery.create_user(params) do
conn
# Plug.Conn.put_status(conn, HTTP Status Codes) retorna conn
|> put_status(:created)
# CASO O NOME DA VIEW SEJA DIFERENTE DO CONTROLLER,
# VOCÊ DEVE USAR: Phoenix.Controller.put_view(NomeView)
# Phoenix.Controller.render(conn, template, assigns) retorna conn
|> render("create.json", user: user)
end
end
end
O assigns
está sendo passado como Keyword Argument. Em geral, quando um keyword list é o último argumento de uma função, os colchetes são opcionais.
Lembrando que o nome da view deve ter o mesmo nome do controller users, caso contrário, deveríamos ter que especificar qual a view seria usada para renderização usando a função Phoenix.Controller.put_view/2
e passar como argumento o nome do módulo da view. Você pode ver mais detalhes desta função na documentação: https://hexdocs.pm/phoenix/Phoenix.Controller.html#put_view/2
Se conn
, params
, render
, template
, assigns
não está tão claro pra você recomendo novamente que você leia o artigo: O Ciclo de Vida do Request no Phoenix
Confira todos os HTTP Status Codes aqui: https://httpstatuses.com/
Confira todos os atoms que represam os HTTP Status Codes aqui:
https://hexdocs.pm/plug/Plug.Conn.Status.html
😎 UsersView
Vamos criar agora o arquivo da view.
Em rockelivery_web/views/users_view.ex
:
defmodule RockeliveryWeb.UsersView do
use RockeliveryWeb, :view
alias Rockelivery.User
def render("create.json", %{user: %User{} = user}) do
%{
message: "User created!",
user: %{
id: user.id,
name: user.name
}
}
end
end
🟣 Usando o Insomnia
Como no navegador só conseguimos fazer requisição GET, para podermos testar a requisição POST precisamos utilizar um destes softwares: Insomnia, Postman, HTTPie e Curl. Aqui usaremos o Insomnia.
Por questão de organização, podemos criar uma Request Collection, assim todas as requests do projeto Rockelivery ficaram guardadas em um local separado de outros projetos.
Podemos também criar uma variável para a base de todas as nossas requests:
Vamos agora testar a criação de um usuário. Lembre de executar o servidor da aplicação com o comando mix phx.server
no terminal.
// crie um usuário com o formato json
{
"address": "Rua dos Monstros S.A.",
"age": 20,
"cep": "01020304",
"cpf": "12345678912",
"email": "mike_wazowski@monsterssa.com",
"name": "Mike Wazowski",
"password": "987654321"
}
Vamos refatorar a nossa view. Desta vez, vamos usar a lib que já vem com o Phoenix chamada jason
.
Em rockelivery_web/views/users_view
:
defmodule RockeliveryWeb.UsersView do
use RockeliveryWeb, :view
alias Rockelivery.User
def render("create.json", %{user: %User{} = user}) do
%{
message: "User created!",
# altere o código abaixo
user: user
}
end
end
Para que a struct %User{}
seja convertida em JSON precisamos alterar o nosso arquivo de schema.
Em lib/rockelivery/user.ex
adicione a linha abaixo:
@derive {Jason.Encoder, only: [:id, :name, :email]}
E mais um Mayk é adicionado com sucesso :)
❌ FallbackController
Quando um erro acontece em uma action e ele não é tratado por ninguém, esse erro passa a ser tratado pela action_fallback
. Você pode obter mais detalhes na documentação: https://hexdocs.pm/phoenix/Phoenix.Controller.html#action_fallback/1
Adicionando o FallbackController ao UsersController.
Em lib/rockelivery_web/controllers/users_controller.ex
:
defmodule RockeliveryWeb.UsersController do
use RockeliveryWeb, :controller
alias Rockelivery.User
# adicione
alias RockeliveryWeb.FallbackController
# adicione
action_fallback FallbackController
def create(conn, params) do
with {:ok, %User{} = user} <- Rockelivery.create_user(params) do
conn
|> put_status(:created)
|> render("create.json", user: user)
end
end
end
Criando o FallbackController
O FallbackController
sempre irá executar uma função chamada call/2
, então é ela que vamos precisar criar agora.
Crie o arquivo em lib/rockelivery_web/controllers/fallback_controller.ex
:
defmodule RockeliveryWeb.FallbackController do
use RockeliveryWeb, :controller
alias RockeliveryWeb.ErrorView
def call(conn, {:error, changeset}) do
conn
|> put_status(:bad_request)
|> put_view(ErrorView)
|> render("400.json", result: changeset)
end
end
Se tentássemos criar o mesmo usuário de antes, o erro sem o FallbackController
seria esse:
Após implementarmos o FallbackController
, o erro aparece assim:
Alterando a ErrorView
Em lib/rockelivery_web/views/error_view.ex
:
defmodule RockeliveryWeb.ErrorView do
use RockeliveryWeb, :view
def template_not_found(template, _assigns) do
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
end
# adicione
def render("400.json", _assigns) do
%{errors: %{message: "Error :("}}
end
end
Vamos agora ver a nova mensagem:
O que queremos mesmo é enviar os erros do changeset
. Então vamos alterar novamente o ErrorView
:
defmodule RockeliveryWeb.ErrorView do
use RockeliveryWeb, :view
import Ecto.Changeset, only: [traverse_errors: 2]
alias Ecto.Changeset
def template_not_found(template, _assigns) do
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
end
def render("400.json", %{result: %Changeset{} = changeset}) do
%{message: translate_errors(changeset)}
end
def translate_errors(%Changeset{} = changeset) do
traverse_errors(changeset, fn {msg, opts} ->
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
end)
end)
end
end
Entendendo de onde está vindo o changeset
Vamos enteder todo esse código. Primeiramente, você pode estar se perguntando da onde está vindo o changeset
. Se formos ao terminal no momento do erro, podemos ver o changeset:
Você se lembra desse exemplo da documentação que vimos quando estávamos falando sobre 🧍 Inserindo o Usuário no Banco de Dados?
case MyRepo.insert %Post{title: "Ecto is great"} do
{:ok, struct} -> # Inserted with success
{:error, changeset} -> # Something went wrong
end
Então, o Changeset
é retornado em uma tupla
quando acontece algum erro. Como esse erro não é tratado por ninguém, ele passa a ser responsabilidade do FallbackController
. Dentro do módulo de FallbackController
o changeset
é colocado dentro da váriavel result
em um Keyword Argument que é passado para o módulo ErrorView
:
Lembrando que quando um keyword list é o último argumento de uma função, os colchetes são opcionais.
Entendendo o traverse_errors
Primeiramente, você não precisa decorar esse código, você encontra ele na documentação: https://hexdocs.pm/ecto/Ecto.Changeset.html#traverse_errors/2
A traverse_errors/2
serve para capturar as mensagens de erro de um changeset
.
Se comentarmos a linha do import
vamos ver um erro no nosso código:
Nessa mesma linha do import
, estamos dizendo com o only
que queremos importat apenas a função traverse_errors
que tem aridade 2
, ou seja, a função traverse_errors
espera 2 parâmetros. E é obrigatório informar a aridade
.
Testando as mensagens de erro do changeset
Validações antes de enviar os dados para o banco, antes das Constraints:
Múltiplos Status de Erro
Em lib/rockelivery/users/create.ex
:
defmodule Rockelivery.Users.Create do
alias Rockelivery.{User, Repo}
def call(%{} = params) do
params
|> User.changeset()
|> Repo.insert()
# adicione
|> handle_insert()
end
def call(_anything), do: "Enter the data in a map format"
# adicione
defp handle_insert({:ok, %User{}} = result), do: result
# adicione
defp handle_insert({:error, changeset}) do
{:error, %{status: :bad_request, result: changeset}}
end
end
Em lib/rockelivery_web/controllers/fallback_controller.ex
:
defmodule RockeliveryWeb.FallbackController do
use RockeliveryWeb, :controller
alias RockeliveryWeb.ErrorView
# altere {:error, changeset} para {:error, %{status: status, result: changeset}}
def call(conn, {:error, %{status: status, result: changeset}}) do
conn
# altere :bad_request para status
|> put_status(status)
|> put_view(ErrorView)
# altere "400.json" para "error.json"
|> render("error.json", result: changeset)
end
end
Em lib/rockelivery_web/views/error_view.ex
:
defmodule RockeliveryWeb.ErrorView do
use RockeliveryWeb, :view
import Ecto.Changeset, only: [traverse_errors: 2]
alias Ecto.Changeset
def template_not_found(template, _assigns) do
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
end
# altere "400.json" para "error.json"
def render("error.json", %{result: %Changeset{} = changeset}) do
%{message: translate_errors(changeset)}
end
def translate_errors(%Changeset{} = changeset) do
traverse_errors(changeset, fn {msg, opts} ->
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
end)
end)
end
end
Testando As Mudanças
Vamos trocar :bad_request para :internal_server_error.
Em lib/rockelivery/users/create.ex
:
defmodule Rockelivery.Users.Create do
alias Rockelivery.{User, Repo}
def call(%{} = params) do
params
|> User.changeset()
|> Repo.insert()
# adicione
|> handle_insert()
end
def call(_anything), do: "Enter the data in a map format"
# adicione
defp handle_insert({:ok, %User{}} = result), do: result
# adicione
defp handle_insert({:error, changeset}) do
{:error, %{status: :internal_server_error, result: changeset}}
end
end
Agora você pode colocar devolta o :bad_request :)
Vamos tentar adicionar novamente um usuário para verificar se tudo está funcionando. Se você quiser pode usar os dados abaixo para adicionar outro Mike heheh :)
{
"address": "Rua dos Lutadores",
"age": 55,
"cep": "01020301",
"cpf": "12345678920",
"email": "mike_tyson@pugilista.com",
"name": "Mike Tyson",
"password": "987654321"
}
Agora você já pode ir para a parte 3 :)
Posted on June 7, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.