Develop

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

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

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

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

Вступление

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

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

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

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

before_action :check_agreement, :redirect_account_verification
הההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההה
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

Хм, 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
הההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההה
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
И так, разберем входные данные, очевидно, что нам нужен пользователь т.к. все проверки по большей части связаны именно с ним. Так же нам нужен текущий request по нему мы будем определять, нужно ли нам выполнять данную проверку, к примеру, исключать AJAX запросы,  view_context нужен нам для работы с методами контроллера.

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

module Services
module App
class 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
end
end
הההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההה
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

Следующий шаг, мы должны определиться как будем проверять необхомость в редиректе, логично, что данный класс не должен знать правил перехода, а лишь перебирать варинты и возвращать результат. Для этого определим константу 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
הההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההה
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

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

module Services
module App
class Conductor
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
end
end
הההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההה
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

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

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

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

А  в нашем классе модифицируем метод 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
הההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההה
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

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

module Services
module App
class 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
end
end
הההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההה
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

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

На этом все.