rails
June 18, 2023

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 файла.

В общем, свои задачи выполняет, проблем не создаёт, а удовольствие приносит.

Минусы:

Искать по типу выполняемого бизнес-действия стало не так удобно. Но в целом эту проблему можно решить.

Итог

Разрабатывая наш сервис, мы и не предполагали, что столкнёмся с такой проблемой, думая, что наличие ограничений в виде сведений решит все проблемы.

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