DEV Community

Maiqui Tomé 🇧🇷
Maiqui Tomé 🇧🇷

Posted on • Updated on

Elixir: Consumindo dados de uma API externa

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Alterando o schema do user:

Remova city e uf apenas do validate_required.

Image description

Criando o banco de dados e rodando as migrations:

$ mix ecto.setup
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Você pode encontrar a versão mais recente do Tesla em: https://hex.pm/packages/tesla

Instalando as dependências:

$ mix deps.get
Enter fullscreen mode Exit fullscreen mode

Testando o Tesla:

$ iex -S mix
Enter fullscreen mode Exit fullscreen mode
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}
Enter fullscreen mode Exit fullscreen mode

Instalando o HTTPoison

Para instalar o HTTPoison, adicione ao mix.exs:

defp deps do
  [
    {:httpoison, "~> 1.8"}
  ]
end
Enter fullscreen mode Exit fullscreen mode

Você pode encontrar a versão mais recente do HTTPoison em: https://hex.pm/packages/httpoison

Instalando as dependências:

$ mix deps.get
Enter fullscreen mode Exit fullscreen mode

Testando o HTTPoison:

$ iex -S mix
Enter fullscreen mode Exit fullscreen mode
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}}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}}
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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}}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]
 }}
Enter fullscreen mode Exit fullscreen mode

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}}
Enter fullscreen mode Exit fullscreen mode

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
 >}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Image description

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
Enter fullscreen mode Exit fullscreen mode

Agora antes de fazer a chamada para o ViaCep estamos checando todas as validações do do changeset antes:

Image description

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:

Image description

Com o Tesla facilmente você pode usar o plug Tesla.Middleware.Headers:

Image description

No Httpoison:

iex> HTTPoison.get "https://api.github.com/users/maiquitome/repos", [{"User-Agent", "foobar"}]
Enter fullscreen mode Exit fullscreen mode

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.

Discussion (4)

Collapse
igorgbr profile image
Igor Giamoniano

Ótimo artigo, ajudou muito!

Collapse
maiquitome profile image
Maiqui Tomé 🇧🇷 Author

Que bom Igor!!! Obrigado pelo feedback :)

Collapse
wagnerdecarvalho profile image
Wagner Patrick de Carvalho

Explicação muito boa. Já usei bastante o HTTPoison, hoje uso mais o Tesla.

Collapse
maiquitome profile image
Maiqui Tomé 🇧🇷 Author

Massa!!! :)