API-клиент c начинкой
О чём поговорим?
В предыдущей статье "Укутай" API-клиент мы говорили о том, как нам удалось решить задачу, обернув наш API-клиент в ещё одну абстракцию. Сегодня же поговорим о внедрении зависимости, которая сделает наш API-клиент расширяемым, что позволит дополнить его функционалом извне.
Глоссарий
СМЭВ - Система межведомственного электронного взаимодействия
Вид сведений (ВС) - контракт, валидирующий данные, передаваемые через СМЭВ
Введение
Опишем контекст происходящего, так как без него не понятно, почему мы пошли именно таким путём и почему для нашего случая это благо.
Есть задача транспорта заявок на оказание услуг, которые подаются через Госуслуги и МФЦ (возможно, в будущем добавится что-то ещё), в информационную систему заказчика.
Данные заявок попадают в СМЭВ (Система межведомственного электронного взаимодействия), откуда мы их извлекаем и через цепочку наших сервисов доставляем в ИС заказчика.
Сегодня мы будем рассматривать API-клиент, который используется в финальном сервисе этой цепочки. Этот сервис занимается интеграцией с ИС заказчика и созданием различных сущностей в ней.
Также нужно отметить, что данные, которые мы получаем, имеют определённую структуру, которую обеспечивает специальный контракт, который называется вид сведений. Этот контракт описывает набор поддерживаемых полей, их структуру и правила валидации.
Какие сложности у нас есть?
Получив данные в финальный сервис нашей цепочки, мы валидируем их и создаём payload, основываясь на сущностях API информационной системы заказчика.
И проблема состоит в том, что данные, которые СМЭВ и наш интеграционный сервис посчитали валидными, при попытке загрузить в ИС заказчика получают ошибку валидации.
Почему так происходит? Причин тут может быть множество, а одна из них кроется в виде сведений.
Дело в том, что ВС - это довольно большая структура и, проходя через итерации преобразований, может обзавестись ошибками. А помимо этого, ВС не умеет делать условную валидацию.
Поясню чуть подробнее. Предположим, у вас есть заявитель - физическое лицо. Это значит, что в ВС вы опишите тип, который назовёте к примеру individualApplicantType, в котором и опишите все нужные поля и валидацию к ним.
Если тип individualApplicantType имеет поля, которые обязательны в одном бизнес-процессе, а в другом ввод их пользователем не предусмотрен, то в схеме ВС эти поля придётся сделать опциональными. Что в свою очередь лишает нас 100% гарантии со стороны СМЭВ в том, что эти поля будут переданы там, где они обязательны по БП.
В подобных случаях валидация организуется на формах в тех сервисах, откуда подаётся заявка. Но так как мы потеряли гарантии общего контракта, по которому независимые системы обмениваются данными, мы не застрахованы от ошибок.
В совокупности, описанное выше может приводить к тому, что мы будет отправлять в ИС заказчика данные, которые она посчитает не валидными.
И сегодня мы поговорим о том, как удобно обрабатывать подобные ситуации, быстро определять их причину, выкатывать правки или делегировать их ответственной стороне.
Какую проблему мы НЕ решаем?
Когда нам нужно создать заявку в ИС заказчика, предварительно сформировав payload, мы делаем такой вызов на написанном нами API-клиенте:
response = api_client.leads_create(payload)
В случае, если наш запрос не завершится успешно, будет выброшено исключение.
Конечно же, у нас есть глобальный отлов всех исключений и логирование. При этом мы пишем не только стандартные логи, но и имеем специальную сущность БД, которая будет связана с входным сообщением (которое принесло нам данные в сервис) и хранить в себе тип ошибки, её текст и стек вызова. А для ошибок, связанных с HTTP взаимодействием, сообщение об ошибке будет хранить ещё и тело ответа сервера.
Всё это позволяет нам довольно легко определить причину провала того или иного входного сообщения.
# получаем проваленное сообщение msg = AggredatorMessage.find(status: :failed).last # Получаем последнюю ошибку связанную с обработкой сообщения msg.error_logs.last
Само собой, всё это можно вывести в админке и смотреть через интерфейс.
Поэтому проблему логирования мы НЕ решаем, оно у нас есть и оно классное :-)
Что же мы тогда хотим?
После того, как мы выяснили причину провала сообщения, мы сообщаем о ней заказчику. Предположим, что обнаруженная проблема требует изменений в ИС заказчика, но для полноценной отладки нас просят отправить payload запроса, который провалился.
Мы не предусмотрели такой функционал сразу, поэтому нам приходилось руками проходить процесс обработки заявки, делать все нужные запросы, формировать нужные данные, пока в итоге мы не получали нужный payload.
Этот процесс, скажу я вам, крайне не приятный. А точность его оставляет желать лучшего. Так, например, API-клиент умеет зачищать nil поля в payload (полезная вещь), и если забыть прогнать полученное тело потенциального запроса через обработчики API-клиента, то мы отдадим разработчикам заказчика совсем не тот payload, который реально отправили. И он может провалиться уже совсем по другой причине.
Поэтому мы хотели сохранять все отправляемые в ИС заказчика запросы, а так же получаемые ответы.
Требования
- должна сохраниться концепция, что API-клиент - это библиотека
- решение должно быть простым и не требовать много усилий для его использования
- сохраняемые данные запросов и ответов должны быть идентичны тем, что отправлены или получены от сервера
Первая версия решения
Первое решение заключалось в том, что каждый вызов метода API-клиента, оборачивался в примерно такой код:
ai = ApiInteraction.create(action: 'leads_create', request_payload: payload) begin response = api_client.leads_create(payload) rescue Api::HttpClientError => e ai.update(response_payload: e.body) raise e else ai.update(response_payload: response) end
Как можно видеть, это решение удовлетворяет лишь первому критерию.
Мы можем задать тип конкретного действия (поле action), а потом по нему удобно искать.
- оно многословно, из-за чего данные запросов и ответов сохранялись только в ключевых процессах
- сохраняемые данные не эквивалентны реально отправляемым на сервер, так как в API-клиенте есть свои преобразования данных (в частности очистка nil значений)
Из-за имеющихся недостатков решение было временным и долго не просуществовало.
Итоговое решение
Разрабатывая новое решение, нужно было не только избавиться от минусов предыдущего, но и сохранить его положительные качества.
Первое решение было хорошо тем, что не затрагивало код API-клиента и существовало от него совершенно независимо.
Подготовка к изменениям
Наш API-клиент уж совсем закостенел, а для того, чтобы отвечать новым требованиям, ему хоть и незначительно, но нужно обновиться.
Начнём раскрывать суть. Вот выдержки кода из старого класса Api::Client
module Api class Client def initializer( base_url, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT ) # some code end end private # как здорово что лень писать заголовки каждый раз породила этот мето :-) def send_request(request) request['Content-Type'] = 'application/json; charset=utf-8' resp = @http.request request handle_response(resp) end end
module Api class Client def initializer( base_url, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT, requester: Requester.new ) # some code end end private def send_request(request) request['Content-Type'] = 'application/json; charset=utf-8' resp = @requester.request(@http, request) handle_response(resp) end end
Люблю, когда маленькие правки приносят большие перемены.
Что же за зверь это Requester?
Дело в том, что для отправки HTTP запросов мы используем Net::HTTP. И мы можем создать любой запрос, а потом исполнить его вызовом метода request.
Это позволяет нам вынести этот вызов в абстракцию Requester, чтобы расширить наши возможности.
Как выглядит Requester? Да вот так:
module Api class Requester def request(http_agent, request) after_submit_result = before_submitting_request(request) response = http_agent.request(request) after_submitting_request(after_submit_result, response) response end private def after_submitting_request(after_submit_result, response); end def before_submitting_request(request); end end end
Всё очень просто. Наши изменения никак не повлияют на работу нашего API-клиента в базовом случае, но открывают возможности для кастомизации.
Давайте сохраним HTTP взаимодействие с сервером
Не хочется мучить вас долгими разговорами, поэтому постараюсь дать максимум кода и минимум текста.
Хотим где-то хранить информацию о HTTP взаимодействии с сервером, делаем таблицу и модель.
class CreateHttpInteractions < ActiveRecord::Migration[7.0] def change create_table :http_interactions do |t| t.string :method, null: false t.string :uri, null: false t.text :request_payload t.jsonb :request_headers t.string :response_code t.string :response_message t.text :response_payload t.jsonb :response_headers t.belongs_to :target, polymorphic: true, index: true, null: true t.timestamps end end end
class HttpInteraction < ApplicationRecord extend Enumerize enumerize :method, in: %w[GET POST PUT PATCH DELETE HEAD] belongs_to :target, polymorphic: true scope :admin_token, -> { where(method: 'POST').where("uri LIKE '%/v1/admin/token'") } scope :regions_list, -> { where(method: 'GET').where("uri LIKE '%/v1/admin/regions%'") } # and more end
Обращу ваше внимание на скоупы. Это то место, где текущее решение уступает изначальному. В изначальной версии мы всё делали руками, поэтому точно знали, какое действие мы логируем, и однозначно указывали его
ai = ApiInteraction.create(action: 'leads_create', request_payload: payload)
что позволяло очень легко и быстро искать взаимодействия нужного типа. Теперь это приходится делать при помощи запросов LIKE по URL запроса. Вот такая плата за универсальность.
Проблема решаемая, но как по мне, в этом моменте стало похуже :-)
Ну и наконец, сделаем реквестер, удовлетворяющий нашим потребностям.
class SaveInteractionToDbRequester < Api::Requester def initialize(message) super() @message = message end private def before_submitting_request(request) http_interaction = HttpInteraction.new http_interaction.target = @message http_interaction.method = request.method http_interaction.uri = request.uri http_interaction.request_payload = payload(request) http_interaction.request_headers = headers(request) http_interaction.save! http_interaction end def after_submitting_request(http_interaction, response) http_interaction.response_code = response.code http_interaction.response_message = response.msg http_interaction.response_payload = response.body.force_encoding('UTF-8') http_interaction.response_headers = headers(response) http_interaction.save! http_interaction end def payload(request) return URI.decode_www_form(request.uri.query).to_h if request.uri.query request.body end def headers(headers_owner) {}.tap do |h| headers_owner.each_header { |header, value| h[header] = value } end end end
Уже не так компактно, как раньше, но ничего сложного, отбросьте сборку модели и будет довольно минималистично :-)
Из плохого тут то, что изменена сигнатура конструктора относительно класса родителя. Да, использоваться дочерний класс может в тех же местах, где и родительский, но создаются они уже по-разному.
От этой проблемы можно уйти, сделав так:
class SaveInteractionToDbRequester < Api::Requester attr_accessor :message end
Но с точки зрения использования класса и риска ошибок, это не так здорово, так что выбираем из двух вариантов тот, что кажется лучшим для данного случая.
Как собираем API-клиент
У нас есть фабрика, которая создаёт API-клиент, сконфигурированный так, чтобы удовлетворять потребностям большинства задач.
class ApiClientFabricator def create(requester: ::Api::Requester.new) ::Api::ClientWrappers::AuthWrapper.wrap( ::Api::Client.new( Rails.application.config.x.api[:base_url], requester: requester ), Rails.application.config.x.api[:email], Rails.application.config.x.api[:password] ) end end
В неё просто добавился новый параметр requester.
api_client = api_client_fabricator.create(requester: ::SaveInteractionToDbRequester.new(request_message))
Опыт использования на практике
- сохранение всех взаимодействий с сервером, без искажений данных
- всё автоматически: сконфигурировал API-клиент и используешь ни о чем не думая
- обработка упавших запросов значительно облегчилась
Теперь когда к нам приходит запрос с просьбой отдать тело отправленного нами запроса по той или иной заявке, вместо кучи действий мы делаем так:
msg = AggredatorMessage.where(status: :failed).last # если у нас возникла проблема при создании пользователя msg.http_interactions.users_create.last.request_payload # если у нас возникла проблема при создании заявки msg.http_interactions.leads_create.last.request_payload
А впоследствии мы просто добавили себе кнопку в админку и можем скачать payload в виде JSON файла.
В общем, свои задачи выполняет, проблем не создаёт, а удовольствие приносит.
Искать по типу выполняемого бизнес-действия стало не так удобно. Но в целом эту проблему можно решить.
Итог
Разрабатывая наш сервис, мы и не предполагали, что столкнёмся с такой проблемой, думая, что наличие ограничений в виде сведений решит все проблемы.
Но столкнувшись с проблемой, как по мне, мы нашли решение, которое довольно быстро и безболезненно смогли интегрировать в имеющийся код.