💧🔗Эликсир: Потребление данных из внешнего API

В этом посте мы узнаем, как потреблять данные из внешнего API. Во время создания пользователя мы сделаем вызов ViaCep API для проверки его почтового индекса, получим и сохраним информацию о городе и UF этого пользователя. Мы также узнаем, как избежать ненужных звонков.

  • Создание проекта
    • Таблица пользователей
    • Функция для создания пользователя
  • Потребление данных из внешнего API
    • Установка Tesla
    • Установка HTTPoison
    • HTTP-клиент с Tesla
    • HTTP-клиент с HTTPoison
    • Проверка почтового индекса при создании пользователя
    • Избегание ненужных обращений к внешнему API (apply_action)
    • Заголовок User-Agent
  • Заключение

Создание проекта

$ mix phx.new learning_external_api --app my_app
Войдите в полноэкранный режим Выход из полноэкранного режима

Ссылка на готовый проект: https://github.com/maiquitome/learning_external_api

Пользователи столов

Создание миграции и схемы:

$ mix phx.gen.schema User users first_name last_name email cep city uf
Войдите в полноэкранный режим Выход из полноэкранного режима

Изменение схемы пользователя:

Удалите city и uf только из validate_required.

Создание базы данных и запуск миграций:

$ mix ecto.setup
Войдите в полноэкранный режим Выход из полноэкранного режима

Функция для создания пользователя

Нам понадобится функция для создания пользователя, поскольку мы будем использовать ViaCEP API для проверки почтового индекса пользователя. Мы добавим эту проверку позже.

В 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
Войдите в полноэкранный режим Выход из полноэкранного режима

Потребление данных из внешнего API

Чтобы иметь возможность потреблять данные из внешнего API, нам нужен HTTP-клиент. Мы можем использовать либо Tesla, либо HTTPoison. Преимущество Tesla в том, что у нее есть готовое промежуточное программное обеспечение, которое мы можем использовать. В этом посте мы будем использовать оба клиента для их сравнения.

Установка Tesla

Чтобы установить Tesla, добавьте в 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
Войдите в полноэкранный режим Выход из полноэкранного режима

Последнюю версию Tesla можно найти по адресу: https://hex.pm/packages/tesla.

Установка зависимостей:

$ mix deps.get
Войдите в полноэкранный режим Выход из полноэкранного режима

Испытание 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}
Войдите в полноэкранный режим Выход из полноэкранного режима

Установка HTTPoison

Чтобы установить HTTPoison, добавьте в mix.exs:

defp deps do
  [
    {:httpoison, "~> 1.8"}
  ]
end
Войдите в полноэкранный режим Выход из полноэкранного режима

Вы можете найти последнюю версию HTTPoison по адресу: https://hex.pm/packages/httpoison.

Установка зависимостей:

$ mix deps.get
Войдите в полноэкранный режим Выход из полноэкранного режима

Тестирование 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-клиент с Tesla

Давайте переименуем файл tesla_client.ex, потому что мы создадим другой файл под названием httpoison_client.ex, и таким образом мы сможем сравнивать HTTP-клиенты лучшим образом.

В 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
Войдите в полноэкранный режим Выход из полноэкранного режима

Давайте запустим тест:

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-клиент с HTTPoison

В HTTPoison у нас нет готового промежуточного ПО для преобразования json в map, поэтому нам придется использовать Jason.decode(), который уже установлен в Phoenix.

В 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

Войдите в полноэкранный режим Выход из полноэкранного режима

Давайте запустим тест:

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}}
Войдите в полноэкранный режим Выход из полноэкранного режима

Проверка почтового индекса при создании пользователя

В lib/my_app/users/create.ex изменим функцию для проверки почтового индекса пользователя, а также для получения информации о городе и штате. У этой функции есть проблема, но мы улучшим ее позже.

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
Войдите в полноэкранный режим Выход из полноэкранного режима

Создание пользователя:

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]
 }}
Войдите в полноэкранный режим Выход из полноэкранного режима

Неверный почтовый индекс

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}}
Войдите в полноэкранный режим Выход из полноэкранного режима

Обратите внимание, что сообщение Invalid ZIP Code! было показано вместо того, чтобы показать валидацию набора изменений, предупреждая, что ZIP Code должен содержать 8 символов, таким образом, мы получили бы валидацию без лишнего вызова внешнего API.

Избегание ненужных обращений к внешнему API (apply_action)

Теперь давайте проверим все данные в наборе изменений перед вызовом ViaCep API.

Тестирование валидации набора изменений без использования 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
 >}
Войдите в полноэкранный режим Выход из полноэкранного режима

apply_action применяет действие набора изменений, только если изменения действительны.

Если изменения действительны, все изменения будут применены к данным набора изменений. Если изменения недействительны, изменения не будут применены, и будет возвращен кортеж ошибок с набором изменений, содержащим действие, которое пыталось быть применено.

Действие может быть любым атомом.

Давайте изменим файл lib/my_app/user.ex, добавив функцию validate_before_insert:

def validate_before_insert(changeset), do: apply_action(changeset, :insert)
Войдите в полноэкранный режим Выход из полноэкранного режима

Теперь давайте изменим файл 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
Войдите в полноэкранный режим Выход из полноэкранного режима

Теперь перед вызовом ViaCep мы проверяем все валидации changeset:

Заголовок User-Agent

Некоторые внешние API могут запросить что-то дополнительное для выполнения вызовов, многие могут запросить токен, который вы можете получить из документации, или, в случае API github, заголовок User-Agent:

В Tesla вы можете легко использовать plug Tesla.Middleware.Headers:

Нет Httpoison:

iex> HTTPoison.get "https://api.github.com/users/maiquitome/repos", [{"User-Agent", "foobar"}]
Войдите в полноэкранный режим Выход из полноэкранного режима

Прочитайте в документации HTTPoison часть, посвященную опциям.

Затем изучите документацию API, к которому вы обращаетесь.

Заключение

В какой-то момент вам понадобится потреблять данные из внешнего API; в наше время это вполне нормальное явление. Чтение документации по внешнему API — первый шаг к успеху. В Elixir у нас есть отличные инструменты HTTP-клиента, документацию по которым также стоит прочитать. Мы должны быть осторожны и не делать лишних вызовов внешних API, чтобы не снизить производительность нашего приложения. Следующий шаг — узнать, как проводить автоматизированные тесты этих внешних вызовов; это тема для будущего поста.

Оцените статью
Procodings.ru
Добавить комментарий