Neste post vamos aprender a consumir dados de uma API externa. No momento da criação do usuário, vamos realizar uma chamada na API do ViaCep para validar o CEP dele, buscando e gravando as informações de cidade e UF desse usuário. Vamos aprender também como evitar fazer chamadas desnecessárias.
Criando o projeto
$ mix phx.new learning_external_api --app my_app
Link do projeto pronto: https://github.com/maiquitome/learning_external_api
Tabela users
Criando a migration e o schema:
$ mix phx.gen.schema User users first_name last_name email cep city uf
Alterando o schema do user:
Remova city
e uf
apenas do validate_required
.
Criando o banco de dados e rodando as migrations:
$ mix ecto.setup
Função para criar um usuário
Precisaremos de uma função para criar um usuário, pois vamos usar a API do ViaCEP para validar o CEP dele. Vamos adicionar essa validação posteriormente.
Em lib/my_app/users/create.ex
:
defmodule MyApp.Users.Create do
alias MyApp.{Repo, User}
@type user_params :: %{
first_name: String.t(),
last_name: integer,
cep: String.t(),
email: String.t()
}
@doc """
Inserts a user into the database.
## Examples
iex> alias MyApp.{User, Users}
...>
...> user_params = %{
...> first_name: "Mike",
...> last_name: "Wazowski",
...> cep: "95270000",
...> email: "mike_wazowski@monstros_sa.com"
...> }
...>
...> {:ok, %User{}} = Users.Create.call user_params
...>
iex> {:error, %Ecto.Changeset{}} = Users.Create.call %{}
"""
@spec call(user_params()) :: {:error, Ecto.Changeset.t()} | {:ok, Ecto.Schema.t()}
def call(params) do
%User{}
|> User.changeset(params)
|> Repo.insert()
end
end
Consumindo dados de uma API externa
Para conseguir consumir dados de uma API externa precisamos de um HTTP client. Podemos usar o Tesla ou o HTTPoison. A vantagem do Tesla é que ele possui middlewares prontos para usarmos. Neste post vamos usar os dois clients para compará-los.
Instalando o Tesla
Para instalar o Tesla, adicione ao mix.exs
:
defp deps do
[
{:tesla, "~> 1.4"},
# opcional, mas recomendado
{:hackney, "~> 1.17"}
# esse não precisa pois já vem com o phoenix
{:jason, ">= 1.0.0"}
]
end
Você pode encontrar a versão mais recente do Tesla em: https://hex.pm/packages/tesla
Instalando as dependências:
$ mix deps.get
Testando o Tesla:
$ iex -S mix
iex> Tesla.get "https://viacep.com.br/ws/01001000/json/"
{:ok, %Tesla.Env{}}
iex> Tesla.get ""
{:error, {:no_scheme}}
iex> Tesla.get "https://exemplo.com"
{:error, :econnrefused}
Instalando o HTTPoison
Para instalar o HTTPoison, adicione ao mix.exs
:
defp deps do
[
{:httpoison, "~> 1.8"}
]
end
Você pode encontrar a versão mais recente do HTTPoison em: https://hex.pm/packages/httpoison
Instalando as dependências:
$ mix deps.get
Testando o HTTPoison:
$ iex -S mix
iex> HTTPoison.get "https://viacep.com.br/ws/01001000/json/"
{:ok, %HTTPoison.Response{}}
iex> HTTPoison.get ""
** (CaseClauseError) no case clause matching: []
iex> HTTPoison.get "https://exemplo.com"
{:error, %HTTPoison.Error{id: nil, reason: :closed}}
HTTP Client com Tesla
Vamos colocar o nome do arquivo de tesla_client.ex
, pois faremos outro chamado httpoison_client.ex
e, desta forma, conseguiremos comparar os HTTP clients
de um jeito melhor.
Em lib/my_app/via_cep/tesla_client.ex
:
defmodule MyApp.ViaCep.TeslaClient do
# Ao invés de Tesla.get(), vc vai usar apenas get()
use Tesla
alias Tesla.Env
@base_url "https://viacep.com.br/ws/"
# codifica (encode) os parametros para json
# e descodifica (decode) a resposta para json automaticamente.
plug Tesla.Middleware.JSON
def get_cep_info(url \\ @base_url, cep) do
"#{url}#{cep}/json/"
|> get()
|> handle_get()
end
# casos abaixo de sucesso e erro
defp handle_get({:ok, %Env{status: 200, body: %{"erro" => "true"}}}) do
{:error, %{status: :not_found, result: "CEP not found!"}}
end
defp handle_get({:ok, %Env{status: 200, body: body}}) do
{:ok, body}
end
defp handle_get({:ok, %Env{status: 400, body: _body}}) do
{:error, %{status: :bad_request, result: "Invalid CEP!"}}
end
defp handle_get({:error, reason}) do
{:error, %{status: :bad_request, result: reason}}
end
end
Vamos fazer o teste:
iex(1)> MyApp.ViaCep.TeslaClient.get_cep_info "95270000"
{:ok,
%{
"bairro" => "",
"cep" => "95270-000",
"complemento" => "",
"ddd" => "54",
"gia" => "",
"ibge" => "4308201",
"localidade" => "Flores da Cunha",
"logradouro" => "",
"siafi" => "8661",
"uf" => "RS"
}}
iex(2)> MyApp.ViaCep.TeslaClient.get_cep_info "95270001"
{:error, %{result: "CEP not found!", status: :not_found}}
iex(3)> MyApp.ViaCep.TeslaClient.get_cep_info ""
{:error, %{result: "Invalid CEP!", status: :bad_request}}
HTTP Client com HTTPoison
No HTTPoison não temos um middleware pronto para transformar o json
em map
, então vamos precisar usar o Jason.decode()
que já vem instalado no Phoenix.
Em lib/my_app/via_cep/httpoison_client.ex
:
defmodule MyApp.ViaCep.HttpoisonClient do
alias HTTPoison.{Error, Response}
@base_url "https://viacep.com.br/ws/"
def get_cep_info(url \\ @base_url, cep) do
"#{url}#{cep}/json/"
|> HTTPoison.get()
|> json_to_map()
|> handle_get()
end
defp json_to_map({:ok, %Response{body: body} = response}) do
{_ok_or_error, body} = Jason.decode(body)
{:ok, Map.put(response, :body, body)}
end
defp json_to_map({:error, %Error{}} = error), do: error
defp handle_get({:ok, %Response{status_code: 200, body: %{"erro" => "true"}}}) do
{:error, %{status: :not_found, result: "CEP not found!"}}
end
defp handle_get({:ok, %Response{status_code: 200, body: body}}) do
{:ok, body}
end
defp handle_get({:ok, %Response{status_code: 400, body: _body}}) do
{:error, %{status: :bad_request, result: "Invalid CEP!"}}
end
defp handle_get({:error, reason}) do
{:error, %{status: :bad_request, result: reason}}
end
end
Vamos fazer o teste:
iex(1)> MyApp.ViaCep.HttpoisonClient.get_cep_info "95270000"
{:ok,
%{
"bairro" => "",
"cep" => "95270-000",
"complemento" => "",
"ddd" => "54",
"gia" => "",
"ibge" => "4308201",
"localidade" => "Flores da Cunha",
"logradouro" => "",
"siafi" => "8661",
"uf" => "RS"
}}
iex(2)> MyApp.ViaCep.HttpoisonClient.get_cep_info "95270001"
{:error, %{result: "CEP not found!", status: :not_found}}
iex(3)> MyApp.ViaCep.HttpoisonClient.get_cep_info ""
{:error, %{result: "Invalid CEP!", status: :bad_request}}
Validando o CEP na criação do usuário
Em lib/my_app/users/create.ex
, vamos alterar a função para passar a validar o CEP do usuário e, também, vamos pegar as informações da cidade e UF. Esta função apresenta um problema mas, vamos melhorar ela depois.
alias MyApp.ViaCep.HttpoisonClient, as: Client
...
@spec call(user_params()) :: {:error, Ecto.Changeset.t() | map()} | {:ok, Ecto.Schema.t()}
def call(params) do
cep = Map.get(params, :cep)
with {:ok, %{"localidade" => city, "uf" => uf}} <- Client.get_cep_info(cep),
params <- Map.merge(params, %{city: city, uf: uf}),
changeset <- User.changeset(%User{}, params),
{:ok, %User{}} = user <- Repo.insert(changeset) do
user
end
end
Criando um usuário:
iex(1)> user_params = %{
...(1)> first_name: "Mike",
...(1)> last_name: "Wazowski",
...(1)> cep: "95270000",
...(1)> email: "mike_wazowski@monstros_sa.com"
...(1)> }
%{
cep: "95270000",
email: "mike_wazowski@monstros_sa.com",
first_name: "Mike",
last_name: "Wazowski"
}
iex(2)> MyApp.Users.Create.call user_params
CEP: "95270000"
{:ok,
%MyApp.User{
__meta__: #Ecto.Schema.Metadata<:loaded, "users">,
cep: "95270000",
city: "Flores da Cunha",
email: "mike_wazowski@monstros_sa.com",
first_name: "Mike",
id: 1,
inserted_at: ~N[2022-05-15 21:38:14],
last_name: "Wazowski",
uf: "RS",
updated_at: ~N[2022-05-15 21:38:14]
}}
CEP Inválido
iex(1)> user_params = %{
...(1)> first_name: "Mike",
...(1)> last_name: "Wazowski",
...(1)> cep: "123",
...(1)> email: "mike_wazowski@monstros_sa.com"
...(1)> }
%{
cep: "123",
email: "mike_wazowski@monstros_sa.com",
first_name: "Mike",
last_name: "Wazowski"
}
iex(2)> MyApp.Users.Create.call user_params
CEP: "123"
{:error, %{result: "Invalid CEP!", status: :bad_request}}
Perceba que foi mostrada a mensagem Invalid CEP!
ao invés de mostrar a validação do changeset, alertando que o CEP devia possuir 8 caracteres, desta forma, teriamos a validação sem precisar fazer uma chamada desnecessária para a API externa.
Evitando chamadas desnecessarias para API Externa (apply_action)
Vamos agora validar todos os dados do changeset antes de fazer a chamada para a API do ViaCep.
Testando a validação do changeset sem usar o Repo.insert()
:
iex(1)> import Ecto.Changeset
iex(2)> user_params = %{
...(2)> first_name: "Mike",
...(2)> last_name: "Wazowski",
...(2)> cep: "123",
...(2)> email: "mike_wazowski@monstros_sa.com"
...(2)> }
%{
cep: "123",
email: "mike_wazowski@monstros_sa.com",
first_name: "Mike",
last_name: "Wazowski"
}
iex(3)> MyApp.User.changeset(%MyApp.User{}, user_params) |> apply_action(:create)
{:error,
#Ecto.Changeset<
action: :create,
changes: %{
cep: "123",
email: "mike_wazowski@monstros_sa.com",
first_name: "Mike",
last_name: "Wazowski"
},
errors: [
cep: {"should be %{count} character(s)",
[count: 8, validation: :length, kind: :is, type: :string]}
],
data: #MyApp.User<>,
valid?: false
>}
O apply_action
aplica a ação changeset somente se as alterações forem válidas.
Se as alterações forem válidas, todas as alterações serão aplicadas aos dados do changeset. Se as alterações forem inválidas, nenhuma alteração será aplicada e uma tupla de erro será retornada com o conjunto de alterações contendo a ação que tentou ser aplicada.
A ação pode ser qualquer átomo.
Vamos alterar o arquivo lib/my_app/user.ex
, adicionando a função validate_before_insert
:
def validate_before_insert(changeset), do: apply_action(changeset, :insert)
Vamos agora alterar o arquivo lib/my_app/users/create.ex
:
@spec call(user_params()) :: {:error, Ecto.Changeset.t() | map()} | {:ok, Ecto.Schema.t()}
def call(params) do
cep = Map.get(params, :cep)
changeset = User.changeset(%User{}, params)
with {:ok, %User{}} <- User.validate_before_insert(changeset),
{:ok, %{"localidade" => city, "uf" => uf}} <- Client.get_cep_info(cep),
params <- Map.merge(params, %{city: city, uf: uf}),
changeset <- User.changeset(%User{}, params),
{:ok, %User{}} = user <- Repo.insert(changeset) do
user
end
end
Agora antes de fazer a chamada para o ViaCep estamos checando todas as validações do do changeset
antes:
User-Agent header
Algumas API's externas podem solicitar algo a mais para realizar as chamadas, muitas podem pedir um token, no qual você consegue na documentação, ou no caso da API do github, um User-Agent header
:
Com o Tesla facilmente você pode usar o plug Tesla.Middleware.Headers
:
No Httpoison:
iex> HTTPoison.get "https://api.github.com/users/maiquitome/repos", [{"User-Agent", "foobar"}]
Leia na documentação do HTTPoison a parte de options.
Então, fique atento a documentação da API na qual você está realizando a chamada.
Conclusão
Em algum momento você vai precisar consumir dados de uma API externa; o que nos dias de hoje é bem normal. Ler a documentação da API externa é o primeiro passo para o sucesso. Em Elixir temos ótimas ferramentas de HTTP Client, a documentação dessas ferramentas também merece ser lida. Precisamos cuidar para não fazer chamadas desnecessárias em API's externas para não comprometer a performance da nossa aplicação. O próximo passo agora é saber como realizar testes automatizados nessas chamadas externas; assunto para um próximo post.
Top comments (6)
Ótimo artigo, ajudou muito!
Que bom Igor!!! Obrigado pelo feedback :)
Explicação muito boa. Já usei bastante o HTTPoison, hoje uso mais o Tesla.
Massa!!! :)
Muito bom o artigo, estou aprendendo elixir e buscando materiais, parabéns mesmo.
Show Wadson :)