Rails: Frontend-валидация в стиле DRY

в 18:45, , рубрики: DRY, ruby on rails, validation, метки: , ,

Когда я только начинал задумываться о том, чтобы приобщиться к миру веб-разработки, и выбирал язык, с которого начну, одна из википедий мне напела, что в основе философии Rails лежат 2 принципа: Convention over configuration (CoC) и Don’t Repeat Yourself (DRY). Что касается первого — я тогда вобще не понял о чём речь, а вот второй понял, принял и ожидал, что в недрах этого замечательного фреймворка, я отыщу нативный инструмент, позволяющий мне один раз написать правила валидации для атрибутов модели, и потом использовать эти правила как для front, так и для back проверок.

Как выяснилось позже — меня ждало разочарование. В рельсах из коробки подобной штуки нет, и всё, что удалось отыскать по теме в ходе обучения — это railscast про client_side_validations gem.

Я тогда о javascript знал только то, что он есть, поэтому пришлось молча прикрутить гем к рождающемуся блогу и отложить тему dry-валидаций до более близкого знакомства с js. И вот это время пришло: мне понадобился гибкий инструмент для проверки форм, и переписывать каждый validates_inclusion_of на js-манер я был не намерен. Да и гем тот больше не поддерживается.

Постановка задачи

Найти способ, который позволит:

  1. при валидации атрибутов использовать одну логику: как для бэка, так и для фронта
  2. быстро «вешать» проверки на разные формы и гибко их настраивать (как логику, так и визуал)

Решение материализовано в небольшой демке: http://sandbox.alexfedoseev.com/dry-validation/showoff

И пара поясняющих абзацев ниже.

Инструменты

Забыл упомянуть, что я в меру ленив, и писать собственный js-валидатор в мои планы изначально не входило. Из готовых решений мой выбор пал на jQuery Validation Plugin.

Его можно просто закинуть в js-ассеты или поставить как гем.
Больше ничего стороннего не потребуется.

Нести доброе светлое буду через пример. Допустим у нас есть список рассылки, в котором хранятся электронные адреса и настройка периодичности рассылки для каждого адреса (сколько раз в неделю пуляем письмо).

Переходим к сути

Соответственно есть модель — Email
И два её атрибута:

  • email — электронный адрес
  • frequency — периодичность, с которой рассылка будет уходить на данный адрес

Какие будут ограничения:

  • наличие email обязательно
  • email должен быть уникален
  • email должен быть email (с собакой и прочими рюшечками)
  • frequency не обязателен, но если есть, то должен быть в диапазоне от 1 до 7

Воплощаем:

app/models/email.rb

class Email < ActiveRecord::Base

  before_save { self.email = email.downcase }

  VALID_EMAIL_REGEX = /A[w+-.]+@[a-zd-.]+.[a-z]+z/i
  validates :email, presence: true,
            uniqueness: { case_sensitive: false },
            format: { with: VALID_EMAIL_REGEX }
  validates_inclusion_of :frequency, in: 1..7, allow_blank: true

end
В контроллере и представлении всё абсолютно стандартно

app/controllers/emails_controller.rb

class EmailsController < ApplicationController

  def new
    @email = Email.new
  end

  def create
    @email = Email.new(email_params)
    if @email.save
      flash[:success] = 'Email добавлен!'
      redirect_to emails_url
    else
      render :new
    end
  end

  private

    def email_params
      params.require(:email).permit(:email, :frequency)
    end

end

app/views/emails/new.html.haml

%h1 Новая почта
= form_for @email do |f|

  = render partial: 'shared/error_messages', locals: { object: f.object }

  %p= f.text_field :email, placeholder: 'Почта'
  %p= f.text_field :frequency, placeholder: 'Периодичность рассылки'
  %p= f.submit 'Добавить!'

Следующий шаг — повесить валидатор на форму и посмотреть что к чему.
Делается это просто: $('#form').validate();

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

Итак, вешаем:

app/assets/javascripts/emails.js.coffee

jQuery ->
  validate_url = '/emails/validate'

  $('#new_email, [id^=edit_email_]').validate(
    debug: true
    rules:
      'email[email]':
        required: true
        remote:
          url: validate_url
          type: 'post'
      'email[frequency]':
        remote:
          url: validate_url
          type: 'post'
  )

Пройдемся по каждой строчке:

  • validate_url = '/emails/validate'
    адрес, на который будем отправлять ajax-запрос для проверки значений полей
  • $('#new_email, [id^=edit_email_]').validate
    вешаем валидатор как на форму нового email, так и на форму редактирования уже существующих адресов
  • debug: true
    метод отключает отправку формы, чтобы можно было развлекаться с настройками
  • rules:
    в методе прописываются правила проверки для полей, у плагина их много из коробки (см. доки), но нас интересуют пока только 2
  • 'email[email]':
    name–атрибут поля формы (простые имена указываются без кавычек, со специальными символами — берём в кавычки)

На следующих двух методах остановимся подробнее.

remote

remote:
  url: validate_url
  type: 'post'

Сначала поговорим о главном методе этого поста — remote. С его помощью мы можем отсылать ajax-запросы к серверу и обрабатывать возвращаемые данные.

Как оно работает: методу нужно скормить url запроса и его тип (в нашем случае отсылаем post-запрос). Этого достаточно, чтобы отправить значение поля на проверку серверу.

В ответ метод ожидает получить json:

  • ответ true — означает, что с полем всё ок
  • ответы false, undefined или null, а также любая другая string'а — расцениваются методом как сигнал провальной валидации
required

required: true

Метод «обязательных полей». Единственная проверка, которую нельзя (да и не нужно) выполнять через обращение к серверу, — это validates_presence_of (то есть валидацию наличия). Это связано с особенностями работы валидатора — он дёргает метод remote только в том случае, если в поле вводились какие-либо данные. «Запустить руками» данную проверку невозможно, поэтому валидации наличия прописываем непосредственно через данный метод. Кстати, он принимает в качестве аргумента функции, поэтому сложные логические проверки на наличие можно (и нужно) осуществлять через него.

Продолжаем

Валидатор повешен, ajax-запрос уходит к серверу, что дальше:

  • нужно создать метод в контроллере, который будет обрабатывать запрос
  • прописать роут к этому методу

app/controllers/emails_controller.rb

  def validate
    # пока пустой
  end

config/routes.rb

resources :emails
post 'emails/validate', to: 'emails#validate', as: :emails_validation

Отлично, теперь сервер может принимать post-запросы на адрес '/emails/validate'
Давате запустим сервер, откроем форму создания Email в браузере (lvh.me:3000/emails/new), наберём «что-нибудь» в поле формы и бегом в консоль — смотреть что же передаёт нам валидатор.

В общем-то, этого можно было ожидать:

Started POST "/emails/validate" for 127.0.0.1 at 2014-02-17 22:10:31 +0000
Processing by EmailsController#validate as JSON
  Parameters: {"email"=>{"frequency"=>"что-нибудь"}}

Теперь о стратегии: что мы будем делать с этим добром — как обрабатывать и что возвращать:

  • из прилетевшего в контроллер json, мы создадим в памяти новый объект Email
  • и дёрнем его валидацию через ActiveModel
  • в памяти нарисуется объект класса ActiveModel::Errors (доступный через метод errors), в котором будет хэш @messages — либо с ошибками (если атрибуты не прошли валидацию), либо пустой (если с объектом всё хорошо)
  • мы разберём этот хэш и, если он пустой — ответим браузеру true, а если в нём есть ошибки для проверяемого атрибута — ответим текстом этих ошибок, что будет расценено сервером как провальная валидация. И, более того, плагин использует полученную стрингу как текст сообщения об ошибке.

Шо-ко-лад! Мало того, что правила валидации прописываются один раз непосредственно в моделе, так ещё и сообщения об ошибках хранятся непосредственно в локале рельс.

Кстати, давайте их напишем.

config/locales/ru.yml

ru:
  activerecord:
    attributes:
      email:
        email: "Почта"
        frequency: "Периодичность"
    errors:
      models:
        email:
          attributes:
            email:
              blank: "обязательна"
              taken: "уже добавлена в список рассылки"
              invalid: "имеет странный формат"
            frequency:
              inclusion: "должна быть в диапазоне от 1 до 7 включительно"

О I18n читаем в гайдах Rails: http://guides.rubyonrails.org/i18n.html

Названия атрибутов и сообщения прописаны.
Теперь самое интересное — формируем ответ браузеру.

Сразу вываливаю работающий код, который будем разбирать по строчкам:

app/controllers/emails_controller.rb

def validate
  email = Email.new(email_params)
  email.valid?

  field = params[:email].first[0]
  @errors = email.errors[field]

  if @errors.empty?
    @errors = true
  else
    name = t("activerecord.attributes.email.#{field}")
    @errors.map! { |e| "#{name} #{e}<br />" }
  end

  respond_to do |format|
    format.json { render json: @errors }
  end
end

Поехали.

email = Email.new(email_params)
email.valid?

Создаём в памяти объект из прилетевших от формы параметров и дёргаем проверку на валидность, чтобы в памяти появился объект ActiveModel::Errors. В хэше @messages с ошибками, помимо нужных нам для проверяемого атрибута, будут лежать и сообщения для всех остальных атрибутов (т.к. значения всех остальных — nil, прилетело же только значение проверяемого атрибута).

Давайте посмотрим как выглядит объект, чтобы понять как его разобрать:

(rdb:938) email.errors
#=> #<ActiveModel::Errors:0x007fbbe378dfb0 @base=#<Email id: nil, email: nil, frequency: "что-нибудь", created_at: nil, updated_at: nil>, @messages={:email=>["обязательна", "имеет странный формат"], :frequency=>["должна быть в диапазоне от 1 до 7 включительно"]}>

Мы видим хэш с сообщениями об ошибках, и доки нам подсказывают как их достать:

(rdb:938) email.errors['frequency']
#=> ["должна быть в диапазоне от 1 до 7 включительно"]

То есть для того, чтобы достать ошибки для атрибута, нам прежде всего нужно достать имя этого атрибута.
Это мы вытянем из хэша params:

# так выглядит хэш
(rdb:938) params
#=> {"email"=>{"frequency"=>"что-нибудь"}, "controller"=>"emails", "action"=>"validate"}

# нам известно название модели, поэтому достаём атрибуты
(rdb:938) params[:email]
#=> {"frequency"=>"что-нибудь"}

# поскольку за запрос улетает всегда один атрибут, то забираем первый
(rdb:938) params[:email].first
#=> ["frequency", "что-нибудь"]

# на первом месте всегда будет ключ, то есть имя атрибута -> забираем
(rdb:938) params[:email].first[0]
#=> "frequency"

Возвращаемся к функции валидации в контроллере:

field = params[:email].first[0]
@errors = email.errors[field]

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

После этого сформируем ответ браузеру:

if @errors.empty?
  @errors = true
else
  name = t("activerecord.attributes.email.#{field}")
  @errors.map! { |e| "#{name} #{e}<br />" }
end

Если массив с ошибками пуст, то переменная @errors — это true (именно этот ответ ожидает плагин, если ошибок нет).

Если же в массиве есть ошибки, то:

  • если мы отдадим его как просто @errors, то получим сообщение «должна быть в диапазоне от 1 до 7 включительно» (а если их будет несколько, то они вобще «слипнутся» при выводе)

Поэтому мы:

  • вытаскиваем имя атрибута модели из файла локализации рельс:
    name = t("activerecord.attributes.email.#{field}")
  • и в каждый элемент массива с ошибками добавляем это имя в начало с пробелом:
    @errors.map! { |e| "#{name} #{e}<br />" }
  • можно ещё и br в конце каждой ошибки прилепить, зависит от вёрстки, короче добавлять по вкусу

Получаем в итоге массив с сообщениями в формате:
«Периодичность должна быть в диапазоне от 1 до 7 включительно».

И последний штрих — пакуем всё это в json:

respond_to do |format|
  format.json { render json: @errors }
end

Rails отдаёт ответ браузеру.

Рефакторим

Для одной модели это работает, но у нас в приложении будет много моделей. Для того, чтобы не-повторять-себя, можно переписать роутинг и метод валидации в контроллере.

Роутинг

config/routes.rb

# было
post 'emails/validate', to: 'emails#validate', as: :emails_validation

# чтобы не переписывать маршрут для каждого контроллера
post ':controller/validate', action: 'validate', as: :validate_form
Метод валидации

Вынесем логику валидации в application_controller.rb, чтобы его могли использовать любые контроллеры приложения.

app/controllers/application_controller.rb

def validator(object)
  object.valid?
  model = object.class.name.underscore.to_sym
  field = params[model].first[0]
  @errors = object.errors[field]

  if @errors.empty?
    @errors = true
  else
    name = t("activerecord.attributes.#{model}.#{field}")
    @errors.map! { |e| "#{name} #{e}<br />" }
  end
end

app/controllers/emails_controller.rb

def validate
  email = Email.new(email_params)
  validator(email)
  respond_to do |format|
    format.json { render json: @errors }
  end
end

P.S. Чтобы не дёргать сервер при каждом введённом пользователем символе в полях формы, установите значение метода onkeyup: false

jQuery Validation Plugin:
http://jqueryvalidation.org

Демо с бантиками:
http://sandbox.alexfedoseev.com/dry-validation/showoff

Автор: alexfedoseev

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js