"Укутай" 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 и передавать его в каждом методе, перед нами встал выбор:
- Один раз создать объект API-клиента, получить token и передавать их во все классы, которые в них нуждаются.
- Создавать 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
По сути нам не нравилось два момента:
- Вызывая метод API, нужно передавать token, а перед этим его получить.
- Когда нам нужно делить код на классы и методы, приходится таскать всюду не только объект 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-клиента, набор беспокоящих нас проблем не меняется и выглядит так:
- API-клиент - довольно большой класс, и переписывать его - не самая приятная задача, хоть и посильная.
- Подобное изменение вызовет изменения уже устоявшегося в приложении интерфейса. То есть уже довольно много кода, где методы API-клиента вызываются с токеном в качестве первого параметра.
- Внесение дополнительной логики в API-клиент делает его уже не таким "тупым". Помните про KISS (Keep it simple, stupid).
- В будущем нам бы хотелось оформить API-клиент как ruby gem (библиотека/пакет). Как только он перестанет быть прямым отображением API, без лишней логики, он станет менее гибким.
Как решить все проблемы сразу?
Если мы не можем/не хотим менять интерфейс 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-ом, мы включим в него и нашу обёртку. И пусть пользователь нашего класса сам решит, в каком случае и как ему удобнее пользоваться нашим классом.