Thanks for filling out our form!
Here's what happens next:

We'll write you back as soon as possible, right now we might be very busy saving the World. Give us a day or two, we'll contact you for sure.


In the meantime, perhaps you would like to take a look at one of our blog posts that explains

How do we work with our Clients
How we can help you with your business

In Touch,
Prograils Team

Prograils blog

Robert Kaczmarek on 13.04.18 in Ruby on Rails
Post rails 5.2 active storage

Rails 5.2 and Active Storage - the new approach to file uploads

Rails 5.2 and Active Storage - attach files, the modern way.

Rails 5.2 stable version has just been released. One of its new features is a new built-in way for uploading files in your applications - ActiveStorage. That means you don't have to use third-party libraries like CarrierWave. Let's check out what needs to be done when adapting your project to ActiveStorage

ADAPTING YOUR RAILS PROJECT TO ACTIVESTORAGE

If you are updating your existing Rails project, right after upgrading Rails to 5.2 you need to run command rails active_storage:install. This will create new migrations for ActiveStorage to use - more precisely, it will create active_storage_blobs and active_storage_attachments tables in your database.

# db/migrate/20180407102457_create_active_storage_tables.active_storage.rb
class CreateActiveStorageTables < ActiveRecord::Migration[5.2]
  def change
    create_table :active_storage_blobs do |t|
      t.string   :key,        null: false
      t.string   :filename,   null: false
      t.string   :content_type
      t.text     :metadata
      t.bigint   :byte_size,  null: false
      t.string   :checksum,   null: false
      t.datetime :created_at, null: false

      t.index [ :key ], unique: true
    end

    create_table :active_storage_attachments do |t|
      t.string     :name,     null: false
      t.references :record,   null: false, polymorphic: true, index: false
      t.references :blob,     null: false

      t.datetime :created_at, null: false

      t.index [ :record_type, :record_id, :name, :blob_id ], name: "index_active_storage_attachments_uniqueness", unique: true
    end
  end
end

So why do we need both of them anyway?

ActiveStorage will use these tables to keep track of your uploads via polymorphic associations. That's right - no more creating additional columns in your models for images and files. Blobs are records that contain the metadata of the file (such as content type, size, filename etc.) and an encoded key which points to the file location on the service (be it local or cloud). Attachments associates your models with blobs.

After migration, you need to add associations to attachments in your model:

# app/models/blog_post.rb
class BlogPost < ApplicationRecord
  has_one_attached :main_picture # one-to-one relationship
  has_many_attached :uploads # one-to-many relationship
end

Here I have added has_one_attached :main_picture so that my BlogPost model has one main image, and has_many_attached :uploads so I can upload many images / documents to it. The next step is to permit those attributes in controller:

# app/controllers/blog_posts_controller.rb
def blog_post_params
  params.require(:blog_post).permit(:title, :content, :main_picture, uploads: [])
end

For has_one association we permit a single parameter, and for has_many we permit an array. And that's pretty much it - with ActiveStorage it's so simple to add images to your existing models. Again, you don't need to create uploaders, mount them, add additional columns in your model and all that stuff.

CONFIG

When you install ActiveStorage, it will also create a config file storage.yml

# config/storage.yml
test:
  service: Disk
  root: <%= Rails.root.join("tmp/storage") %>

local:
  service: Disk
  root: <%= Rails.root.join("storage") %>

# Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
# amazon:
#   service: S3
#   access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
#   secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
#   region: us-east-1
#   bucket: your_own_bucket

# Remember not to checkin your GCS keyfile to a repository
# google:
#   service: GCS
#   project: your_project
#   credentials: <%= Rails.root.join("path/to/gcs.keyfile") %>
#   bucket: your_own_bucket

# Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key)
# microsoft:
#   service: AzureStorage
#   storage_account_name: your_account_name
#   storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %>
#   container: your_container_name

# mirror:
#   service: Mirror
#   primary: local
#   mirrors: [ amazon, google, microsoft ]

You can set your root directory for local and test services, as well as set your keys to Amazon S3, Google Cloud Service, Microsoft Azure. You still need gems for those services to work. Keep in mind that in Rails 5.2 we use Credentials instead of well known secrets. Also ActiveStorage provides mirror service - it keeps your uploads in sync across all mirrors, so you don't need to worry about downtime on one of the services.

Remember to set your service in environment file for the option you choose, for example:

# config/environments/development.rb
config.active_storage.service = :amazon

TIPS AND TRICKS

In forms, we use normal file_field for our uploads. Add multiple: true when you use has_many_attached!

-# app/views/blog_posts/_form.haml
.field
  = form.label :main_picture
  = form.file_field :main_picture
.field
  = form.label :uploads
  = form.file_field :uploads, multiple: true # for multiple uploads

It gets a little trickier when showing the uploads to user. ActiveStorage allows to upload any type of files - at a time of writing this, there are no built-in validations - but you can still create one yourself!:

# app/models/blog_post.rb
validate :main_picture_format

private

def main_picture_format
  return unless main_picture.attached?
  return if main_picture.blob.content_type.start_with? 'image/'
  main_picture.purge_later
  errors.add(:main_picture, 'needs to be an image')
end

When showing uploads, we have few possibilities:

-# app/views/blog_posts/show.haml
%p
  %strong Title:
  = @blog_post.title
- if @blog_post.main_picture.attached?
  %p
    %strong Image:
    = image_tag @blog_post.main_picture.variant(resize: '700x700') # on-demand processing
    = image_tag @blog_post.main_picture.variant(resize: '700x700').processed.service_url # process then fetch the image
%p
  %strong Content:
  = @blog_post.content
- if @blog_post.uploads.attached?
  %p
    %strong Uploads:
    - @blog_post.uploads.each do |upload|
      - if upload.representable?
        = link_to image_tag(upload.representation(resize: '500x500')), upload # opens in new browser tab
        = link_to image_tag(upload.representation(resize: '500x500')), rails_blob_path(upload, disposition: :attachment) # downloads file
      - elsif upload.image?
        = link_to image_tag(upload, width: 500), upload
      - else
        = link_to upload.filename, upload

Let's talk about the options:

1) if the upload can be changed / transformed (it's an image), we can use MiniMagic to process it (ActiveStorage only supports MiniMagic as of now). To check if upload can be processed, we can call upload.variable?. To use MiniMagic's magic :), we must call upload.variant method with transformation which should be applied (resize, trim, flip etc.). So it looks like this upload.variant(resize: '500x500')

One thing to note - .svg files are images, but they are not variable images - MiniMagick cannot transform them, so when you try to apply variant to them, you will get ActiveStorage::InvariableError! To apply some configuration, you can call image_tag with parameters like: image_tag(@blog_post.svg_image, width: 200)

2) if the upload can be previewed (it's a PDF or a video), simply call upload.previewable?. The preview will be the first page of the document or the first frame of the video. Keep in mind that to use previews, you need to install third-party libraries as ActiveStorage does not provide any, but depends on them - for PDFs it's Poppler or mupdf, and for videos it's ffmpeg.

To make it more compact, ActiveStorage has combined these two modules into one called ActiveStorage::Representable, so you can simply call upload.representation(resize: '500x500') and it will return ActiveStorage::Variant for variable blob, and ActiveStorage::Preview for previewable one.

One thing with variants is that we have 2 choices with them: we can make variants on-demand / lazy transform by calling upload.variant(resize: '500x500x') - this will issue transformation when the image is requested by browser or calling upload.variant(resize: '500x500x').processed.service_url to force transformation - just keep in mind that the entire file needs to be downloaded, processed, uploaded to the service and then return itself again But don't worry - ActiveStorage first checks if variant exists on the service, and if so, returns it instead of transforming again.

To allow users to download file, we use Rails new helper rails_blob_path with disposition: :attachment - it will start downloading file after clicking, instead of opening it in new tab: = link_to image_tag(upload.representation(resize: '500x500')), rails_blob_path(upload, disposition: :attachment)

If you want to attach file in custom controller method or somewhere else in your code, you can call @blog_post.main_picture.attach(params[:main_picture]).

Having multiple files attached to your model you will get N+1 queries accessing them - referring one attached items calls your model controllers, ActiveStorage::Attachment to load polymorphic association and finally ActiveStorage::Blob to load the file itself. To avoid that, we can use built-in scope "with_attached_#{attachment_name}" which will include attached blobs in our query, so instead of BlogPost.all, we use BlogPost.with_attached_uploads.

To delete blob, you can use @blog_post.main_picture.purge which will synchronously delete attachment and resource files or @blog_post.main_picture.purge_later to delete attachment in the background via ActiveJob. You also do not need to specify dependant: :destroy with association - it will automatically delete attachment via purge_later method when the parent model is destroyed. If you want to make many records-one blob relationship, you need to specify has_one/many_attached :attachment, dependant: false to prevent destroying blob when parent model is destroyed

VARIANTS AND OPTIMIZATION

As of now, ActiveStorage does not provide any built-in methods for image processing despite using MiniMagick for transformations. So to have the same functionality, as for example CarrierWave offers, you need to do some work. I created lib called Uploads where I keep all my code for various resizes based on CarrierWave and optimization based on Google's PageSpeed documentation for image optimization.

# lib/uploads.rb
class Uploads
  class << self
    def jpeg?(blob)
      blob.content_type.include? 'jpeg'
    end

    def optimize
      {
        strip: true
      }
    end

    def optimize_jpeg
      {
        strip: true,
        'sampling-factor': '4:2:0',
        quality: '85',
        interlace: 'JPEG',
        colorspace: 'sRGB'
      }
    end

    def optimize_hash(blob)
      return optimize_jpeg if jpeg? blob
      optimize
    end

    def resize_to_limit(width:, height:, blob:)
      {
        resize: "#{width}x#{height}>"
      }.merge(optimize_hash(blob))
    end

    def resize_to_fit(width:, height:, blob:)
      {
        resize: "#{width}x#{height}"
      }.merge(optimize_hash(blob))
    end

    def resize_to_fill(width:, height:, blob:, gravity: 'Center')
      blob.analyze unless blob.analyzed?

      cols = blob.metadata[:width].to_f
      rows = blob.metadata[:height].to_f
      if width != cols || height != rows
        scale_x = width / cols
        scale_y = height / rows
        if scale_x >= scale_y
          cols = (scale_x * (cols + 0.5)).round
          resize = cols.to_s
        else
          rows = (scale_y * (rows + 0.5)).round
          resize = "x#{rows}"
        end
      end

      {
        resize: resize,
        gravity: gravity,
        background: 'rgba(255,255,255,0.0)',
        extent: cols != width || rows != height ? "#{width}x#{height}" : ''
      }.merge(optimize_hash(blob))
    end

    def resize_and_pad(width:, height:, blob:, background: :transparent, gravity: 'Center')
      {
        thumbnail: "#{width}x#{height}>",
        background: background == :transparent ? 'rgba(255, 255, 255, 0.0)' : background,
        gravity: gravity,
        extent: "#{width}x#{height}"
      }.merge(optimize_hash(blob))
    end
  end
end

After that, what is left to do is to make new instance method for model. I made one for my BlogPost model like this:

# app/models/blog_post.rb
def main_picture_header_variant
  variation =
    ActiveStorage::Variation.new(Uploads.resize_to_fit(width: 500, height: 200, blob: main_picture.blob))
  ActiveStorage::Variant.new(main_picture.blob, variation)
end

If you want to do something similar, do not forget to require your lib in your model by adding require 'uploads' before class definition.

# app/models/blog_post.rb
require 'uploads'

class BlogPost < ApplicationRecord

  ...
end

This method allows me to just simply call @blog_post.main_picture_header_variant to fetch that particular variant.

Photo from: Gratisography.com

Share on
comments powered by Disqus