Ruby on Rails, our beloved framework, provides a new standard for file upload. Please welcome ActiveStorage!
This article is a super quick, straight to the point guide to get started with ActiveStorage.
I’ve hosted a working example application on Github for you to try it out-of-the-box. It illustrates most of what this article presents. The link is at the end of the article.
Table of content:
How to add ActiveStorage to your Ruby on Rails 5.2+ project
How to choose where to store uploaded documents (on local disk, on Amazon S3, etc.)
How to make a model have one attachment (
has_one_attached
)How to make a model have many attachments (
has_many_attached
)How to check the presence of, link to or read the content of an attachment
How to destroy attachments
How to do basic manipulations on uploaded files (create variants, previews, read metadata, …)
How to attach a local file (useful in tests and seeds)
How to add validations on uploaded files
How to find records with attachments
1. How to add ActiveStorage to your Ruby on Rails 5.2+ project
There’s no gem to add to your Gemfile as Rails 5.2 comes with ActiveStorage built in. Simply run rails active_storage:install
which will generate a migration file, then run rake db:migrate
.
If you read this migration (always be curious!), you see that it adds two tables to your database:
active_storage_blobs
: this table records blobs which are file-related information (filename, metadata, size, etc.)active_storage_attachments
: this is a join table between your application's models and blobs.
So far you probably have been used to:
adding an attribute to your model/table to allow it to have a single attachment,
creating an associated table when you want your model to have multiple attachments.
ActiveStorage removes these two steps. You don’t have to generate any migration anymore to make your models have one or many attachments.
With ActiveStorage, all attachments of all models will be recorded in active_storage_blobs
and active_storage_attachments
(a polymorphic association) will be the link between your models and blobs. If it's still foggy for you don't worry, we'll get back to this point shortly, it's actually pretty easy to understand.
For now, let’s just focus on the configuration. We generated a migration and migrated the database, we now have to tell ActiveStorage where to store uploaded files.
2. How to choose where to store uploaded documents (on local disk, on Amazon S3, etc.)
First read config/storage.yml
. This file allows you to define several storage strategies. Each environment will be assigned a storage strategy.
Here is the default-generated 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 ]
Each storage strategy basically tells ActiveStorage two things:
Which service to use (chose between
Disk
,S3
,GCS
,AzureStorage
andMirror
)
How to configure the chosen service (what path, what credentials if any required, …)
The service list is quite simple to understand:
Disk
: Store files on your local disk
S3
: Use Amazon S3 (requirement: addgem 'aws-sdk-s3'
to your Gemfile)
GCS
: Use Google Cloud Storage (requirement: addgem 'google-cloud-storage', '~> 1.11'
to your Gemfile)
AzureStorage
: Use Microsoft Azure Storage (requirement: addgem 'azure-storage'
to your Gemfile)
Then there is Mirror
. Mirror
tells ActiveStorage to use both a primary storage strategy and a collection of other strategies to make copies of your uploaded documents. You wanted an easy way to build backups for uploaded documents? Mirror
is a nice one.
One more thing about the mirror service: though copies are made, all your queries and downloads will be performed on/from the primary strategy. This is a backup mechanism, not a load balancing one.
So back to config/storage.yml
and your storage strategies list.
As in the above example, you might choose to have:
a
test
strategy for when you run your rspec/minitest/whatever. In this strategy, you'll probably want to store uploaded files inRails.root.join("tmp/storage")
so that you can clean them up by runningrake tmp:clean
.
a
local
strategy for development environment. This would store uploaded files in a non-volatile storage, let's say inRails.root.join("storage")
for instance.
an
amazon
strategy for production environment. This would store uploaded files in an Amazon S3 bucket.
I’m not going to explain each service’s configuration specifics as it is pretty self-explanatory. Just read the examples above and you’re basically done. Oh and, obviously, don’t forget to configure your external services on their respective platforms beforehand (ex: for S3, create a bucket and set the right permissions).
Once you wrote your storage strategies (you can keep the default ones for now), you have to assign a strategy to each environment you run.
Concretely: in each config/environments/*.rb
file, set the attribute config.active_storage.service
to the strategy you want.
For instance, I usually have in config/environments/development.rb
the following line: config.active_storage.service = :local
.
3. How to make a model have one attachment (has_one_attached
)
Model-side:
Step 1: choose a name for your attachment. Let’s say you want to add an
avatar
image to aProfile
model.
Step 2: Add to your model the following:
has_one_attached :avatar
Reminder: you do not need to add a new column to your database table!
Now you can use some_profile.avatar.attached?
to check whether a file is present or not.
Controller-side:
To allow the upload of an avatar, add :avatar
to your permitted
params.require(:profile).permit(:some_attribute, :some_other_attribute, :avatar)
View-side:
<%= form.file_field :avatar %>
That’s it!
4. How to make a model have many attachments
(has_many_attached
)
Model-side:
Step 1: chose a name for your attachment. Let’s say you want to add
contracts
pdf files to aCustomer
model.
Step 2: Add to your model the following:
has_many_attached :contracts
Controller-side:
To allow the upload of new contracts, add contracts: []
to your permitted params:
params.require(:customer).permit(:some_attribute, :yet_another_attribute, contracts: [])
Now you can use some_customer.contracts.attached?
to check whether at least one file is present or not.
View-side:
<%= form.file_field :contracts, multiple: true %>
5. How to check the presence of, link to or read the content of an attachment
Check the presence of
some_profile.avatar.attached?
Link to
Since the file’s location is storage-strategy dependent, ActiveStorage provides a helper that creates a temporary redirection link to the file.
Create a redirection link that will last 5 minutes:
url_for(some_profile.avatar)
Create a download link using rails_blob_url
or rails_blob_path
:
rails_blob_path(some_profile.avatar, disposition: 'attachment')
Read file content
binary_data = some_profile.avatar.download
Be careful when doing so on big files stored on the cloud!
6. How to destroy attachments
You can destroy an attachment either:
synchronously:
some_profile.avatar.purge
asynchronously:
some_profile.avatar.purge_later
. This will schedule an ActiveJob to take care of it.
You might also want to permit a user to remove attachments. I can propose two solution:
One is to write your own controllers/actions/routes. The advantage is that you can easily add policies and allow/deny destruction of a document given you own constraints.
The other solution is to add
accept_nested_attributes_for
. Let me explain this one.
I assume you are accustomed to using accept_nested_attributes_for.
When you add has_many_attached :contracts
to a Customer
model, ActiveStorage injects has_many :contracts_attachments
in your model as well.
Read this to know what precisely happens behind the scene: https://github.com/rails/rails/blob/0f57f75008242d1739326fec38791c01852c9aa7/activestorage/lib/active_storage/attached/model.rb
Here’s how you would allow contracts
attachments destruction:
# Model
class Profile < ApplicationRecord
has_one_attached :avatar
accepts_nested_attributes_for :avatar_attachment, allow_destroy: true
end
# Controller
class ProfilesController < ApplicationController
before_action :set_profile, only: [:show, :edit, :update, :destroy]
# [...]
# PATCH/PUT /profiles/1
# PATCH/PUT /profiles/1.json
def update
respond_to do |format|
if @profile.update(profile_params)
format.html { redirect_to @profile, notice: 'Profile was successfully updated.' }
format.json { render :show, status: :ok, location: @profile }
else
format.html { render :edit }
format.json { render json: @profile.errors, status: :unprocessable_entity }
end
end
end
private
# Use callbacks to share common setup or constraints between actions.
def set_profile
@profile = Profile.find(params[:id])
end
# Never trust parameters from the scary internet, only allow the white list through.
def profile_params
params.require(:profile).permit(
:last_name, :first_name,
:avatar,
avatar_attachment_attributes: [:id, :_destroy]
)
end
end
# View
<p id="notice"><%= notice %></p>
<p>
<strong>Avatar:</strong><br />
<% if @profile.avatar.attached? %>
<%= form_for @profile do |f| %>
<%= f.fields_for :avatar_attachment_attributes do |avatar_form| %>
<%= avatar_form.hidden_field :id, value: @profile.avatar_attachment.id %>
<%= avatar_form.hidden_field :_destroy, value: true %>
<% end %>
<%= f.submit "Delete avatar" %>
<% end %>
<% end %>
</p>
7. How to do basic manipulations on uploaded files (create variants, previews, read metadata, …)
ActiveStorage comes with a couple of built-in helpers to assist you in doing basic operations such as extracting metadata or generating previews and variants on certain file formats. These helpers delegate the real work to specialized gems and/or system binaries. Therefore if you want to use them, you must meet the requirements first.
The documentation says:
Extracting previews requires third-party applications, FFmpeg for video and muPDF for PDFs, and on macOS also XQuartz and Poppler. These libraries are not provided by Rails. You must install them yourself to use the built-in previewers. Before you install and use third-party software, make sure you understand the licensing implications of doing so.
To generate variants from images, install MiniMagick (http://www.imagemagick.org) on your system then add
gem 'image_processing', '~> 1.2'
to your Gemfile.
To generate previews of pdfs, install either mupdf (https://mupdf.com/) or Poppler (https://poppler.freedesktop.org/) on your system.
To generate previews of videos, install FFmpeg (https://www.ffmpeg.org/) on your system.
Generate a variant of an image.
Here’s a basic example of how to generate a variant:
<%= image_tag profile.avatar.variant(resize_to_limit: [75, 75]) %>
Read https://github.com/janko/image_processing for a comprehensive list of possible transformations.
Also, if you wish to have a variant generated only once then stored and reused (for performance reasons), use the #processed
method:
<%= image_tag profile.avatar.variant(resize_to_limit: [75, 75]) %>
What that does: it first checks for the existence of the requested variant. If found, it is used, otherwise it is generated, stored then used.
Advice: use #variable?
first to make sure you can create a variant: some_profile.avatar.variable?
. Calling #variant
if MiniMagick is not installed or when the file format does not allow it will raise an error.
Generate a preview
When working with a PDF of a video, you can generate a preview:
<%= image_tag(customer.contract.preview(resize: '200x200') %>
Advice: use #previewable?
first to make sure you can create a preview: some_profile.avatar.previewable?
Let ActiveStorage decide if it is #variant
or #preview
that should be called
A nice wrapper takes care of this: #representation
:
<%= image_tag(record.attachment.representation(resize: '500x500') %>
Advice: use #representable?
first to make sure you can create either a variant or a preview: some_profile.avatar.representable?
Read the metadata, file size, etc.
Do you remember we started by creating two database tables? They were blobs (information about the attached file) and attachments (a join table between your models and blobs). If you need to retrieve information about an uploaded file, you know where to look.
A couple of examples:
For an
image
attachment width, you would readsome_record.image.metadata[:width]
.
For a
document
attachment content type, you would readsome_record.document.content_type
For a
video
attachment file size, you would readsome_record.video.byte_size
I advise you read the official documentation or -even better- the source code of ActiveStorage to find an exhaustive list.
Note that depending on how you attached a file (via upload or using #attach), the file might or might not have been analyzed. File analyze extracts informations like image dimensions and saves them in blob metadata.
8. How to attach a local file (useful in tests)
Back to the above :avatar
example. This is how to attach a local file to your record:
some_profile.avatar.attach(io: File.open('/path/to/file'), filename: 'avatar.png')
Doing so, we also need to explicitly ask ActiveStorage to analyze the file and populate the blob’s metadata attribute when need be:
some_profile.avatar.attach(io: File.open('/path/to/file'), filename: 'avatar.png')
9. How to add validations on uploaded files
ActiveStorage does not provide a validation mechanism as of now. To add your validations, you will simply write custom validations in your model. It would somehow look like this:
class MyModel < ActiveRecord::Base
has_one_attached :image
validate :image_size
private
def image_size
errors.add :image, 'too big' if image.blob.byte_size > 4096
end
end
10. How to find records with attachments
Let’s end this article on this final topic.
ActiveStorage adds a with_attached_<attribute>
scope that prevents N+1 queries when loading records and accessing their attachments. In the example of a Profile
model with has_one_attached :avatar
, the scope would be with_attached_avatar
.
You would use it this way: Profile.with_attached_avatar.find_each { ... }
This scope is great but we often face the situation where we want to list records that actually have attachments. George Claghorn answered this very question in the most simple and clearest way: https://github.com/rails/rails/issues/32295#issuecomment-374304126
Here is his snippet code:
# Assuming a model defined like so:
class Post < ApplicationRecord
has_one_attached :image
end
# ...you can join against :image_attachment to select posts having attached images:
Post.joins(:image_attachment).where('published_at >= ?', Time.now)
This is it for this article! As always, I advise you read the ActiveStorage source code to understand better how it works, maybe extend it and why not even contribute to it.
Github-hosted example application: https://github.com/yoones/demo_activestorage
Thanks for reading!