Краткий этот туториал будет полезен, хотелось бы надеяться, как программистам, работающим с Ruby on Rails, так и тем из "племени младого незнакомого" веб-разработчиков, кто захочет освоить отличный инструмент, увидевший свет в Rails 5.2 ("извлечен из прода" Basecamp 3 усилиями George Claghorn и Javan Makhmali) - фреймворк Active Storage. Фреймворк делает простым и на редкость удобным загрузку файлов в облако (прямо сразу "из коробки" доступны Amazon S3, Google Cloud Storage и Microsoft Azure Storage), также - если речь об изображениях, видео или PDF-файлах - создание превью на лету.

Не буду сейчас ни сравнивать Active Storage с задолго до него существующими решениями CarrierWave, Paperclip или Shrine, ни рассуждать о полиморфизме таблицы active_storage_attachments; все это хорошо и многажды описано, без проблем способно быть, при желании и наличии интереса, найдено в доках. Остановлюсь всего только на нескольких практических моментах использования класса ActiveStorage::Variant, требующих, как известно, ImageMagick или libvips посредством джема image_processing:

# Gemfile
gem "image_processing", "~> 1.0"

Забегая вперед и чтобы не казаться голословным: живая демка, также ссылка на гитхаб здесь. "Тот, кто дойдет - увидит", так что вперед.

Генерация HTML галереи изображений не вызывает больших вопросов, более-менее в соответствии с докой итерируем массив и увязываем пути с классами (например) бутстрапа, создав для удобства простенький хелпер:

def path_to_file(x)
  Rails.application.routes.url_helpers.rails_blob_path(x, only_path: true)
end
<% @slideshow.images.each do |x| %>
	<%= link_to(
    	path_to_file(x), html_options = {
            "data-lightbox" => "photo", "class" => "col-sm-4"
		}) do %>
			<%= image_tag x.variant(@options), "class" => "img-fluid" %>
	<% end %>
<% end %>

Лайтбокс - любой из несть им числа. Какой хотите, без разницы.

Панель управления галереи на основе Active Admin выглядит вполне пристойно:

, что реализовано следующим образом:

ActiveAdmin.register Slideshow do
  permit_params :published_at, :name, :options, images: []
  remove_filter :images_attachments, :images_blobs, :options

  scope :all
  scope :published
  scope :unpublished

  action_item :publish, only: :show do
    link_to 'Publish', publish_admin_slideshow_path(slideshow), method: :put unless slideshow.published_at?
  end

  action_item :unpublish, only: :show do
    link_to 'Unpublish', unpublish_admin_slideshow_path(slideshow), method: :put if slideshow.published_at?
  end

  action_item :delete_images, only: :show do
    if slideshow.images.attached?
      link_to 'Delete Images', delete_images_admin_slideshow_path(slideshow), method: :delete
    end
  end

  member_action :publish, method: :put do
    slideshow = Slideshow.find(params[:id])
    slideshow.update(published_at: Time.zone.now)
    redirect_to admin_slideshow_path(slideshow)
  end

  member_action :unpublish, method: :put do
    slideshow = Slideshow.find(params[:id])
    slideshow.update(published_at: nil)
    redirect_to admin_slideshow_path(slideshow)
  end

  member_action :delete_images, method: :delete do
    slideshow = Slideshow.find(params[:id])
    # asset = ActiveStorage::Attachment.find_by(params[:attachment_id])
    slideshow.images.purge_later
    redirect_to admin_slideshow_path(slideshow)
  end

  member_action :delete_image, method: :delete do
    slideshow = Slideshow.find_by(params[:name])
    slideshow.images[params[:id].to_i].purge_later
    redirect_to admin_slideshow_path(slideshow)
  end

  form do |f|
    f.inputs 'Slideshow' do
      f.input :name
      f.input :options,
              input_html: { value: f.object.options || '{ "resize_to_limit": [300, 222], "kuwahara": "3%", "quality": 15 }' },
              label: 'Options. For example: { "resize_to_limit": [300, 222], "monochrome": true }'
      f.input :images, as: :file, input_html: { multiple: true }
    end
    f.actions
  end
  show do |t|
    attributes_table do
      if t.images.attached?
        t.images.each_with_index do |img, index|
          span do
            link_to delete_image_admin_slideshow_path(index), method: :delete do
              image_tag img.variant(resize_to_limit: [100, 100])
            end
          end
        end
      end
      row :name
      row :created_at
      row :updated_at
      row :published_at
    end
    para 'Click the preview to delete the image.'
  end
end

Но тут вдруг возникает вопрос: каким образом передать в ActiveStorage::Variant параметры обработки изображений? - несложно указать напрямую в коде, но для удобства работы администратора сайта хотелось бы (несмотря на любую контраргументацию) записать их в базу данных, обеспечив возможность ввода и редактирования из админки. Но не evalом же их потом доставать, в самом-то деле! - небезопасно, согласитесь. Даже с учетом рубиновых уровней безопасности (с которыми, к слову, вообще непонятно что происходит):

@options = proc {
  $SAFE = 1
  eval(Slideshow.take.options)
}.call

Нет, так не пойдет. Плавно двигаясь эмпирическим (а как еще) путем познания - приходим к тому, что параметры вполне можно записывать и хранить как JSON:

class AddOptionsToSlideshow < ActiveRecord::Migration[6.1]
  def change
    add_column :slideshows, :options, :json
  end
end

Таким образом, совсем просто:

@options = Slideshow.take.options

Дальше - больше. Что мешает нам валидировать вводимый админом JSON, указав допустимые для тех или иных ключей (коих ImageMagick имеет великое множество, глаза разбегаются) типы значений? - ровно ничто не мешает. Инсталлируем:

gem 'activerecord_json_validator', '~> 2.0.0'

, добавляя в модель валидацию:

PROFILE_JSON_SCHEMA = Pathname.new(Rails.root.join('config', 'schemas', 'slideshow.json'))
validates :options, presence: true, json: {
  schema: PROFILE_JSON_SCHEMA,
  message: ->(errors) { errors }
}

, профайл же slideshow.json, на основе которого пойдет проверка, выглядит у нас, предположим (полностью зависит от чувства меры и художественного вкуса разработчика), следующим образом:

{
  "type": "object",
  "$schema": "http://json-schema.org/draft-04/schema#",
  "properties": {
    "resize_to_limit": { "type": "array" },
    "monochrome": { "type": "boolean" },
    "kuwahara": {"type": "string"},
    "sepia-tone": {"type": "string"},
    "quality": { "type": "integer" },
    "polaroid": { "type": "integer" }
  },
  "required": ["resize_to_limit"]
} 

Все уже давно работает, но ведь на рельсах аппетит приходит во время езды. Хм, а какой русский не любит быстрой езды... ок, пишем спек:

require 'rails_helper'

RSpec.describe Slideshow, type: :model do
  it { is_expected.to have_many_attached(:images) }

  describe 'validates data column' do
    subject { described_class.new(options: options) }
    let(:valid_data) do
      {
        "resize_to_limit": [1, 2],
        "kuwahara": 'string',
        "polaroid": 1
      }
    end

    describe 'valid data' do
      let(:options) { valid_data }
      it { is_expected.to be_valid }
    end

    describe 'value type is invalid (case 1)' do
      let(:options) { valid_data.merge quality: 'string' }
      it { is_expected.not_to be_valid }
    end

    describe 'value type is invalid (case 2)' do
      let(:options) { valid_data.merge monochrome: 'array' }
      it { is_expected.not_to be_valid }
    end

    describe 'value type is valid' do
      let(:options) { valid_data.merge monochrome: true }
      it { is_expected.to be_valid }
    end

    describe 'missing a optional element' do
      let(:options) { valid_data.except :kuwahara }
      it { is_expected.to be_valid }
    end

    describe 'missing a necessary element' do
      let(:options) { valid_data.except :resize_to_limit }
      it { is_expected.not_to be_valid }
    end
  end
end

Всего семь тестов: убеждаемся, что данные :valid_data адекватны, затем добавляем к ним один за другим пару элементов, где value принадлежат типу данных, противоречащих указанным в профайле, и еще один, где значение соответствует нужному; напоследок удаляем необязательный элемент и, наконец, обязательный. Все тесты должны пройти:

$ bundle exec rspec spec/models/slideshow_spec.rb 
.......

Finished in 0.33026 seconds (files took 10.42 seconds to load)
7 examples, 0 failures

Finished. Cool. "If you ain't drunk, then you're in the wrong club." (c)

Комментарии (0)