Develop

Управляем редиректами в одном месте

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

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

Для меня в этот раз стал наш App::BaseController.

Вступление

Что есть App::BaseController в разрабатываем приложение? Тут все просто, базовый контроллер для всех контроллеров пользовательской части. Еще недавно, был весьма компактный класс, всего 53 строки.

Окей, что не так?

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

Открываем контроллер и видим

before_action :check_agreement, :redirect_account_verification

Хм, 2 проверки которые выполняют уже похожую логику, но по более простой схеме. И как обычно бывает - ладно, еще один фильтр с редиректом картину не изменит, ведь класс то и так небольшой, что плохого в еще в 1 методе на несколько строк кода? Окей, реализовываем логику, добавляем новый before_action все работает, тесты работают, клиент доволен, вроде все хорошо. Но осадочек остался...

Хьюстон, у нас копипаст

Спустя не очен продолжительный период, возникает необходимость, добавить еще одну проверку, на этот раз это работа 2-х факторной аутентификации с своим чемоданом различных настроек и проверок. И вот тут уже глаз начинает резать по полной программе. Опять писать новый метод с редиректом по условию? Нет, так дело не пойдет.

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

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

Ближе к делу

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

before_action :inspect_route

def inspect_route
  response = Services::App::Conductor.call(
    request: request,
    user: current_user,
    view_context: view_context
  )
  return if response.blank?

  respond_to do |format|
    format.html { redirect_to response.url }
    format.json do 
      render json: (response.notice.present? ? { message: response.notice } : {})
    end
  end
end
И так, разберем входные данные, очевидно, что нам нужен пользователь т.к. все проверки по большей части связаны именно с ним. Так же нам нужен текущий request по нему мы будем определять, нужно ли нам выполнять данную проверку, к примеру, исключать AJAX запросы,  view_context нужен нам для работы с методами контроллера.

Теперь определим структуру нашего сервисного класса, она будет выглядять примерно так

class Services::App::Conductor
  attr_reader :user, :request, :view_context

  def initialize(user:, request: nil, view_context: nil)
    @user = user
    @request = request
    @view_context = view_context
  end

  def self.call(user:, request: nil, view_context: nil)
    new(
      user: user,
      request: request,
      view_context: view_context
    ).call
  end

  def call; end
end

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

ROUTES = [
  Services::App::Routes::Agreement,
  Services::App::Routes::AccountVerification
].freeze

def call
  return if destination.blank?
  destination.new(user, view_context).info
end

private

def destination
  @_destination ||= ROUTES.find do |route|
    route.available?(
      user: user, 
      request: request, 
      view_context: view_context
    )
  end
end

Из этого кода видно, что класс для проверки роута должен реализовывать 2 метода, available? и info, первый описывает все необходимые проверки, а второй возвращаемые результаты - ссылку для перехода и сообщение.

class Services::App::Routes::Agreement
  attr_reader :user, :view_context

  def initialize(user, view_context = nil)
    @user = user
    @view_context = view_context
  end

  def self.available?(options)
    !options[:user].agreed?
  end

  def info
    OpenStruct.new(
      url: view_context.app_terms_path,
      notice: I18n.t('notifications.agreement_required')
    )
  end
end

Окей, базовая логика написана, что теперь? Теперь нам нужно корректировать работу этого класса, просто так мы не можем отключить before_action :inspect_route т.к. он должн работать для каждого контроллера и отключаться только если доходит до шага, который выполняет редирект на самого себя. В текущей реализации у нас произойдет зацикливание, чтобы исправить это добавим метод в контроллере который указывает на какой проверке мы должны остановится в нашем классе проводнике.

В контроллере добавляем private метод

def current_route
  Services::App::Routes::Agreement
end

А  в нашем классе модифицируем метод destination следующим образом

def destination
  @_destination ||= ROUTES.find do |route|
    return if already_selected?(route)

    route.available?(
      user: user, 
      request: request, 
      view_context: view_context
    )
  end
end

def already_selected?(route)
  current_route == route
end

def current_route
  return unless view_context&.controller.respond_to?(:current_route, true)
  view_context&.controller&.send(:current_route)
end

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

class Services::App::Conductor
  attr_reader :user, :request, :view_context

  ROUTES = [
    Services::App::Routes::Agreement,
    Services::App::Routes::AccountVerification
  ].freeze

  def initialize(user:, request: nil, view_context: nil)
    @user = user
    @request = request
    @view_context = view_context
  end

  def self.call(user:, request: nil, view_context: nil)
    new(
      user: user,
      request: request,
      view_context: view_context
    ).call
  end

  def call
    return if invalid_client? || destination.blank?
    destination.new(user, view_context).info
  end

  private

  def invalid_client?
    request.xhr?
  end

  def destination
    @_destination ||= ROUTES.find do |route|
      return if already_selected?(route)

      route.available?(
        user: user, 
        request: request, 
        view_context: view_context
      )
    end
  end

  def already_selected?(route)
    current_route == route
  end

  def current_route
    return unless view_context&.controller.respond_to?(:current_route, true)
    view_context&.controller&.send(:current_route)
  end
end

При необходимости его можно модифицировать под дополнительные условия.

На этом все.