rails
March 5, 2023

"Укутай" API-клиент

О чём поговорим?

Сегодня мы поговорим о том, как сделать инструмент (API-клиент в нашем конкретном случае) удобным, при этом не изменяя его код.

Предыстория

Пишем мы сервис, который должен взаимодействовать с сервисом заказчика по HTTP API.

И так вышло, что API это не маленькое и постоянно развивается. В нём больше сотни различных методов (правды ради, лично нам нужны не все), и периодически появляются новые.

Для погружения вас в контекст скажу, что методы API выглядят примерно вот так:

  • POST /admin/token - метод для авторизации
  • GET /admin/users - список пользователей
  • POST /admin/users - создание пользователя
  • GET /admin/users/{user_id}/leads - список заявок пользователя
  • POST /admin/users/{user_id}/leads - создание заявки для пользователя
  • и т.д.

Само собой, слать запросы напрямую, просто используя библиотеку для отправки HTTP запросов, нам не хотелось (мало кому такого захочется!), и мы решили написать API-клиент (класс, из которого будет создан объект, при помощи которого мы будем общаться с API).

Как мы реализовали API-клиент?

Когда у меня мало времени на разработку и/или продукт изменчив, я стараюсь следовать следующей мудрости:

Не усложняй! Делай максимально просто, но сохраняя гибкость инструмента.

Отсюда следует вывод, что реализовывать API-клиент нужно так, чтобы он был зеркальным отражением самого API. Чтобы работа с API-клиентом была точно такой же, как будто вы взаимодействуете с API напрямую, просто это делать чуть легче, не нужно заботиться об URL, методах HTTP запроса и так далее.

Как итог, реализация выглядит как-то так:

module Api
  class Client
    # return [String]
    def admin_token(email, password)
       # some code
    end

    def users_list(token, params = {})
      # some code
    end

    def users_create(token, payload)
      # some code
    end

    def leads_list(token, user_id, params)
      # some code
    end

    def leads_create(token, user_id, payload)
      # some code
    end

    # and many more methods
  end
end
Обращу ваше внимание на то, что реализация методов нас не интересует. Говоря о реализации API-клиента, нас прежде всего интересует его интерфейс - вызываемые методы и их сигнатуры.

Как мы им пользуемся?

Если говорить о непосредственном использовании API-клиента, то выглядит это так:

api_client = Api::Client.new
token = api_client.admin_token(email, password)
api_client.users_list(token, params)
api_client_users_create(token, payload)
# и так далее

Но прежде чем попасть в сервис клиента через API, данные проходят длинный путь. Давайте рассмотрим этот процесс немного шире.

Приходит в наш сервис большая пачка данных одним запросом. Мы должны эти данные обработать и создать в сервисе заказчика различные сущности.

Например, когда мы создаём заявку, она привязывается к пользователю, которого, если не нашли в системе заказчика, мы предварительно должны создать.

И дело вот в чём. Логика создания пользователя, как и логика создания заявки, несколько сложнее, чем просто формирование запроса с данными и отправка их в нужный метод API, поэтому под каждую из задач в коде выделен отдельный класс.

Так как для взаимодействия с API нужно авторизоваться, получить token и передавать его в каждом методе, перед нами встал выбор:

  1. Один раз создать объект API-клиента, получить token и передавать их во все классы, которые в них нуждаются.
  2. Создавать API-клиент в каждом классе отдельно, и пусть каждый из них сам получает token.

Забегая вперёд скажем, что оба варианта нам не нравились.

Вариант 1 - нужно всюду передавать два параметра (объект API-клиента и token).

Вариант 2 - придётся делать больше запросов к API, чем в первом варианте, но лаконичности по итогу добавит не много, так как внутри каждого из классов нам тоже придётся таскать всюду объект API-клиента вместе с токеном, если у нас появится несколько методов, нуждающихся в них.

Так что выбираем первый вариант и продолжаем думать, как это улучшить.

В коде это выглядело примерно так:

api_client = Api::Client.new
token = api_client.admin_token(email, password)

user = User::Create.run(api_client, token, request_data)

lead = Lead::Create.run(api_client, token, user, request_data)

Подытожим наши боли

Часть 1

По сути нам не нравилось два момента:

  1. Вызывая метод API, нужно передавать token, а перед этим его получить.
  2. Когда нам нужно делить код на классы и методы, приходится таскать всюду не только объект API-клиента, но и token, если не хотим постоянно обращаться в API для его получения.

И тут бы нам сразу перейти к тому, как решить эту проблему, но дело в том, что решение порождает новые боли, поэтому в этом разделе давайте скажем и о них.

Часть 2

Чтобы решить боли из части 1, мы понимаем, что нам нужно сделать так, чтобы авторизация и хранение токена перестали быть нашей проблемой. Пусть ей займётся какой-нибудь код.

И в общем, решить эту задачу не сложно.

Можно сделать так:

module Api
  class Client
    def initialize(email, password)
      @token = admin_token(email, password)
    end
  end
end

Лично я не люблю такие решения. Ну куда торопиться? Необходимость в токене ещё не возникла, а мы его получили. Вот так решит кто-то создать кучу экземпляров нашего класса и, сам того не ожидая, выполнит кучу запросов в API.

Лучше уж делать так:

module Api
  class Client
    def initialize(email, password)
      @email = email
      @password = password
    end
    
    private

    def token
      @token ||= admin_token(@email, @password)
    end     
  end
end

Так мы не будем делать запрос к API, пока нам в первый раз не понадобится token, и такой вариант потребует меньше правок. Ведь до этого наш код обращался к переменной token, а теперь точно так же будет обращаться к методу token (в Ruby можно вызвать метод без скобок).

Но в независимости от выбранного способа получения токена внутри класса API-клиента, набор беспокоящих нас проблем не меняется и выглядит так:

  1. API-клиент - довольно большой класс, и переписывать его - не самая приятная задача, хоть и посильная.
  2. Подобное изменение вызовет изменения уже устоявшегося в приложении интерфейса. То есть уже довольно много кода, где методы API-клиента вызываются с токеном в качестве первого параметра.
  3. Внесение дополнительной логики в API-клиент делает его уже не таким "тупым". Помните про KISS (Keep it simple, stupid).
  4. В будущем нам бы хотелось оформить API-клиент как ruby gem (библиотека/пакет). Как только он перестанет быть прямым отображением API, без лишней логики, он станет менее гибким.

Как решить все проблемы сразу?

Если мы не можем/не хотим менять интерфейс API-клиента и не хотим сильно переписывать его внутреннюю часть, то концептуально мы можем идти двумя путями:

  1. Встроить что-то в наш API-клиент, внедрив через конструктор.
  2. Обернуть API-клиент в другой класс.

Решение 1 может иметь место, если не нужно существенно изменять внутренний код API-клиента. Но это оказался не наш случай.

Поэтому мы пошли вторым путём и написали очень простую, но для решения наших задач весьма эффективную обёртку.

Наша обёртка

А в написанной нами обёртке ничего сложного. Она довольно проста и выглядит вот так:

module Api
  module ClientWrappers
    class AuthWrapper
      def self.wrap(client, email, password)
        new(client, email, password)
      end

      def initialize(client, email, password)
        @client = client
        @email = email
        @password = password
      end

      def method_missing(name, *args, &block)
        @client.send(name, token, *args, &block)
      end

      def respond_to_missing?(name, _include_private = false)
        @client.respond_to? name
      end

      private

      def token
        @token ||= @client.admin_token(@email, @password)
      end
    end
  end
end

Но этим она и прекрасна. Ведь при своей простоте она избавила нас от стольких проблем.

Теперь нам не нужно думать о передаче токена в методы, таскать его по классам и методам как параметр, наряду с API-клиентом. А главное, мы не вынуждены переписывать много кода.

Весь наш старый код продолжит работать с API-клиентом по старому интерфейсу (передавая token первым параметром).

Единственное что изменится, так это создание объекта API-клиента. Теперь это выглядит так:

api_client = Api::ClientWrappers::AuthWrapper.wrap(
  Api::Client.new,
  Rails.application.config.x.eog_api[:email],
  Rails.application.config.x.eog_api[:password]
)

Сложновато! Во всяком случае раньше было проще. Но ничего, немного терпения, мы это исправим.

Упрощаем создание "обёрнутого" API-клиента

Если вам плюс-минус часто приходится создавать что-то, и сложность этого процесса вас не устраивает, заведите под это дело специальный класс. Это мы и сделали.

class ApiClientFabricator
  def create
    ::Api::ClientWrappers::AuthWrapper.wrap(
      ::Api::Client.new
      Rails.application.config.x.eog_api[:email],
      Rails.application.config.x.eog_api[:password]
    )
  end
end

Минимум кода, максимум результата.

Ничего сложного или громоздкого. Зато теперь мы можем пользоваться нашим API-клиентом так:

fabricator = ApiClientFabricator.new
api_client = fabricator.create
api_client.users_list(params)
api_client_users_create(payload)
# и так далее

А если использовать dry-container и dry-auto_inject (мы как раз их используем), то будет ещё проще:

# Предварительно подключите зависимость с ключём "api_client_fabricator"
api_client = api_client_fabricator.create
api_client.users_list(params)

Итог

У нас было много написанного ранее кода, мы хотели сделать его использование удобным. И у нас это получилось.

При этом мы не сломали старый интерфейс, не нагрузили наш API-клиент дополнительной логикой, оставив его таким же простым и надёжным.

А самое главное, мы написали очень мало кода :-)

Когда мы дойдём до того, что наш API-клиент будет готов стать gem-ом, мы включим в него и нашу обёртку. И пусть пользователь нашего класса сам решит, в каком случае и как ему удобнее пользоваться нашим классом.