Blog

Update: Rails and Active Storage. The new approach to file uploads

Since its first shipping, Active Storage has revolutionized attaching files to Ruby on Rails applications. Learn what it is, how to set it up and use it, as well what's new in Rails Active Storage in 2021!

Table of Content

  1. What is Active Storage in Ruby on Rails?
  2. Adapting your Rails project to Active Storage
  3. Config
  4. Active Storage Tips and Tricks
  5. Variants and optimization
  6. Rails Active Storage in 2021: Image processing and others

What is Active Storage in Ruby on Rails?

ActiveStorage, a built-in way for uploading files in web applications was among the features that came with Rails 5.2. The files are uploaded to cloud storage services like Amazon S3, Google Cloud Storage or Microsoft Azure Storage and then attached to Active Record objects in the app.

This means Rails developers no longer have to use third-party libraries like CarrierWave for example. Let's check out what needs to be done when adapting your project to ActiveStorage

.

Adapting your Rails project to Active Storage

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 the 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.

ActiveStorage also provides the 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

Active Storage 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 the user.

ActiveStorage allows to upload any type of files. At the original time of writing this, there were 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 a 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 the 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 helperrails_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 a 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 an 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 a 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.

Rails Active Storage in 2021: Image processing and others

4 years after the first version of ActiveStorage had been released, lots of smaller and bigger refinements were shipped.

One of the largest ones was the support for image_processing gem, which simplifies a lot of the logic related to variants and optimization, so let’s check how to leverage this.

First of all, image_processing gem relies on the imagemagic or libvips binary for all image manipulations, so be sure that it is installed on your development machine and servers.

The usage is very similar to the solution I proposed earlier in this blog post. So in order to display image that is resized to limit, simply use:

@blog_post.main_picture.variant(resize_to_limit: [700, 700])

and that's all. Very easy and intuitive.

There are lots of popular ImageMagic manipulations available. You can find all the supported methods in the ImageMagic section in image_processing docs.

But there is no stopping here. ActiveStorage is constantly evolving and there are many things to look forward to.

One, for example, is the long-awaited ability to create versions for attachments (like you can do with CarrierWave). It is coming soon and right now it is available on the edge version of Rails if you want to test it out.

To use this functionality, all you have to do is to make one change to your model:

class BlogPost < ApplicationRecord
  has_one_attached :main_picture do |attachable|
    attachable.variant :header, resize_to_limit: [700, 700]
  end
end

and then you will be able to simply use this like this:

@blog_post.main_picture.variant :header

With those two additions, I think that ActiveStorage will be an out-of-box solution that you can use in place of CarrierWave. It will be simple to use, will have all the popular image transformations and Rails magic that you will need in your application and will speed up your work. So, yeah, it sounds very exciting!

Photo from: Gratisography.com

Recommended reads

Check our latest product - it's based on our experience of managing over 50-people strong company. The tool we're missing as a small company and not an enterprise.

humadroid.io is an employee and performance management software. It's an unique tool allowing everyone to be in the loop - by having up to date info about co-workers, time-off, benefits, assets, helping with one-on-ones, being a go-to place for company-wide announcements.

Check out humadroid.io
Top

Contact us

* Required fields

The controller of your personal data provided via this contact form is Prograils sp. z o.o., with a registered seat at Sczanieckiej 9A/10, 60-215 Poznań. Your personal data will be processed in order to respond to your inquiries and for our marketing purposes (e.g. when you ask us for our post-development, maintenance or ad hoc engagements for your app). You have the rights to: access your personal data, rectify or erase your personal data, restrict the processing of your personal data, data portability and to object to the processing of your personal data. Learn more.

Notice

We do not track you online. We use only session cookies and anonymous identifiers for the purposes specified in the cookie policy. No third-party trackers.

I understand
Elo Mordo!Elo Mordo!