There are many file uploading gems out there like CarrierWave, Paperclip, and Dragonfly, to name a few. They all have their specifics, and probably you've already used at least one of these gems.
Today, however, I want to introduce a relatively new, but very cool solution called Shrine, created by Janko Marohnić. In contrast to some other similar gems, it has a modular approach, meaning that every feature is packed as a module (or plugin in Shrine's terminology). Want to support validations? Add a plugin. Wish to do some file processing? Add a plugin! I really love this approach as it allows you to easily control which features will be available for which model.
In this article I am going to show you how to:
- integrate Shrine into a Rails application
- configure it (globally and per-model)
- add the ability to upload files
- process files
- add validation rules
- store additional metadata and employ file cloud storage with Amazon S3
The source code for this article is available on GitHub.
The working demo can be found here.
Integrating Shrine
To start off, create a new Rails application without the default testing suite:
rails new FileGuru -T
I will be using Rails 5 for this demo, but most of the concepts apply to versions 3 and 4 as well.
Drop the Shrine gem into your Gemfile:
gem "shrine"
Then run:
bundle install
Now we will require a model that I am going to call Photo
. Shrine stores all file-related information in a special text column ending with a _data
suffix. Create and apply the corresponding migration:
rails g model Photo title:string image_data:text rails db:migrate
Note that for older versions of Rails, the latter command should be:
rake db:migrate
Configuration options for Shrine can be set both globally and per-model. Global settings are done, of course, inside the initializer file. There I am going to hook up the necessary files and plugins. Plugins are used in Shrine to extract pieces of functionality into separate modules, giving you full control of all the available features. For example, there are plugins for validation, image processing, caching attachments, and more.
For now, let's add two plugins: one to support ActiveRecord and another one to set up logging. They are going to be included globally. Also, set up file system storage:
config/initializers/shrine.rb
require "shrine" require "shrine/storage/file_system" Shrine.plugin :activerecord Shrine.plugin :logging, logger: Rails.logger Shrine.storages = { cache: Shrine::Storage::FileSystem.new("public", prefix: "uploads/cache"), store: Shrine::Storage::FileSystem.new("public", prefix: "uploads/store"), }
Logger will simply output some debugging information inside the console for you saying how much time was spent to process a file. This can come in handy.
2015-10-09T20:06:06.676Z #25602: STORE[cache] ImageUploader[:avatar] User[29543] 1 file (0.1s) 2015-10-09T20:06:06.854Z #25602: PROCESS[store]: ImageUploader[:avatar] User[29543] 1-3 files (0.22s) 2015-10-09T20:06:07.133Z #25602: DELETE[destroyed]: ImageUploader[:avatar] User[29543] 3 files (0.07s)
All uploaded files will be stored inside the public/uploads directory. I don't want to track these files in Git, so exclude this folder:
.gitignore
public/uploads
Now create a special "uploader" class that is going to host model-specific settings. For now, this class is going to be empty:
models/image_uploader.rb
class ImageUploader < Shrine end
Lastly, include this class inside the Photo
model:
models/photo.rb
include ImageUploader[:image]
[:image]
adds a virtual attribute that will be used when constructing a form. The above line can be rewritten as:
include ImageUploader.attachment(:image) # or include ImageUploader::Attachment.new(:image)
Nice! Now the model is equipped with Shrine's functionality, and we can proceed to the next step.
Controller, Views, and Routes
For the purposes of this demo, we'll need only one controller to manage photos. The index
page will serve as the root:
pages_controller.rb
class PhotosController < ApplicationController def index @photos = Photo.all end end
The view:
views/photos/index.html.erb
<h1>Photos</h1> <%= link_to 'Add Photo', new_photo_path %> <%= render @photos %>
In order to render the @photos
array, a partial is required:
views/photos/_photo.html.erb
<div> <% if photo.image_data? %> <%= image_tag photo.image_url %> <% end %> <p><%= photo.title %> | <%= link_to 'Edit', edit_photo_path(photo) %></p> </div>
image_data?
is a method presented by Shrine that checks whether a record has an image.
image_url
is yet another Shrine method that simply returns a path to the original image. Of course, it is much better to display a small thumbnail instead, but we will take care of that later.
Add all the necessary routes:
config/routes.rb
resources :photos, only: [:new, :create, :index, :edit, :update] root 'photos#index'
This is it—the groundwork is done, and we can proceed to the interesting part!
Uploading Files
In this section I will show you how to add the functionality to actually upload files. The controller actions are very simple:
photos_controller.rb
def new @photo = Photo.new end def create @photo = Photo.new(photo_params) if @photo.save flash[:success] = 'Photo added!' redirect_to photos_path else render 'new' end end
The only gotcha is that for strong parameters you have to permit the image
virtual attribute, not the image_data
.
photos_controller.rb
private def photo_params params.require(:photo).permit(:title, :image) end
Create the new
view:
views/photos/new.html.erb
<h1>Add photo</h1> <%= render 'form' %>
The form's partial is also trivial:
views/photos/_form.html.erb
<%= form_for @photo do |f| %> <%= render "shared/errors", object: @photo %> <%= f.label :title %> <%= f.text_field :title %> <%= f.label :image %> <%= f.file_field :image %> <%= f.submit %> <% end %>
Once again, note that we are using the image
attribute, not the image_data
.
Lastly, add another partial to display errors:
views/shared/_errors.html.erb
<% if object.errors.any? %> <h3>The following errors were found:</h3> <ul> <% object.errors.full_messages.each do |message| %> <li><%= message %></li> <% end %> </ul> <% end %>
This is pretty much all—you can start uploading images right now.
Validations
Of course, much more work has to be done in order to complete the demo app. The main problem is that the users may upload absolutely any type of file with any size, which is not particularly great. Therefore, add another plugin to support validations:
config/inititalizers/shrine.rb
Shrine.plugin :validation_helpers
Set up the validation logic for the ImageUploader
:
models/image_uploader.rb
Attacher.validate do validate_max_size 1.megabyte, message: "is too large (max is 1 MB)" validate_mime_type_inclusion ['image/jpg', 'image/jpeg', 'image/png'] end
I am permitting only JPG and PNG images less than 1MB to be uploaded. Tweak these rules as you see fit.
MIME Types
Another important thing to note is that, by default, Shrine will determine a file's MIME type using the Content-Type HTTP header. This header is passed by the browser and set only based on the file's extension, which is not always desirable.
If you wish to determine the MIME type based on the file's contents, then use a plugin called determine_mime_type. I will include it inside the uploader class, as other models may not require this functionality:
models/image_uploader.rb
plugin :determine_mime_type
This plugin is going to use Linux's file utility by default.
Caching Attached Images
Currently, when a user sends a form with incorrect data, the form will be displayed again with errors rendered above. The problem, however, is that the attached image will be lost, and the user will need to select it once again. This is very easy to fix using yet another plugin called cached_attachment_data:
models/image_uploader.rb
plugin :cached_attachment_data
Now simply add a hidden field into your form.
views/photos/_form.html.erb
<%= f.hidden_field :image, value: @photo.cached_image_data %> <%= f.label :image %> <%= f.file_field :image %>
Editing a Photo
Now images can be uploaded, but there is no way to edit them, so let's fix it right away. The corresponding controller's actions are somewhat trivial:
photos_controller.rb
def edit @photo = Photo.find(params[:id]) end def update @photo = Photo.find(params[:id]) if @photo.update_attributes(photo_params) flash[:success] = 'Photo edited!' redirect_to photos_path else render 'edit' end end
The same _form
partial will be utilized:
views/photos/edit.html.erb
<h1>Edit Photo</h1> <%= render 'form' %>
Nice, but not enough: users still can't remove an uploaded image. In order to allow this, we'll need—guess what—another plugin:
models/image_uploader.rb
plugin :remove_attachment
It uses a virtual attribute called :remove_image
, so permit it inside the controller:
photos_controller.rb
def photo_params params.require(:photo).permit(:title, :image, :remove_image) end
Now just display a checkbox to remove an image if a record has an attachment in place:
views/photos/_form.html.erb
<% if @photo.image_data? %> Remove attachment: <%= f.check_box :remove_image %> <% end %>
Generating a Thumbnail Image
Currently we display original images, which is not the best approach for previews: photos may be large and occupy too much space. Of course, you could simply employ the CSS width
and height
attributes, but that's a bad idea as well. You see, even if the image is set to be small using styles, the user will still need to download the original file, which might be pretty big.
Therefore, it is much better to generate a small preview image on the server side during the initial upload. This involves two plugins and two additional gems. Firstly, drop in the gems:
gem "image_processing" gem "mini_magick", ">= 4.3.5"
Image_processing is a special gem created by the author of Shrine. It presents some high-level helper methods to manipulate images. This gem, in turn, relies on mini_magick, a Ruby wrapper for ImageMagick. As you've guessed, you'll need ImageMagick on your system in order to run this demo.
Install these new gems:
bundle install
Now include the plugins along with their dependencies:
models/image_uploader.rb
require "image_processing/mini_magick" class ImageUploader < Shrine include ImageProcessing::MiniMagick plugin :processing plugin :versions # other code... end
Processing is the plugin to actually manipulate an image (for example, shrink it, rotate, convert to another format, etc.). Versions, in turn, allows us to have an image in different variants. For this demo, two versions will be stored: "original" and "thumb" (resized to 300x300
).
Here is the code to process an image and store its two versions:
models/image_uploader.rb
class ImageUploader < Shrine process(:store) do |io, context| { original: io, thumb: resize_to_limit!(io.download, 300, 300) } end end
resize_to_limit!
is a method provided by the image_processing gem. It simply shrinks an image down to 300x300
if it is larger and does nothing if it's smaller. Moreover, it keeps the original aspect ratio.
Now when displaying the image, you just need to provide either the :original
or :thumb
argument to the image_url
method:
views/photos/_photo.html.erb
<div> <% if photo.image_data? %> <%= image_tag photo.image_url(:thumb) %> <% end %> <p><%= photo.title %> | <%= link_to 'Edit', edit_photo_path(photo) %></p> </div>
The same can be done inside the form:
views/photos/_form.html.erb
<% if @photo.image_data? %> <%= image_tag @photo.image_url(:thumb) %> Remove attachment: <%= f.check_box :remove_image %> <% end %>
To automatically delete the processed files after uploading is complete, you may add a plugin called delete_raw:
models/image_uploader.rb
plugin :delete_raw
Image's Metadata
Apart from actually rendering an image, you may also fetch its metadata. Let's, for example, display the original photo's size and MIME type:
views/photos/_photo.html.erb
<div> <% if photo.image_data? %> <%= image_tag photo.image_url(:thumb) %> <p> Size <%= photo.image[:original].size %> bytes<br> MIME type <%= photo.image[:original].mime_type %><br> </p> <% end %> <p><%= photo.title %> | <%= link_to 'Edit', edit_photo_path(photo) %></p> </div>
What about its dimensions? Unfortunately, they are not stored by default, but this is possible with a plugin called store_dimensions.
Image's Dimensions
The store_dimensions plugin relies on the fastimage gem, so hook it up now:
gem 'fastimage'
Don't forget to run:
bundle install
Now just include the plugin:
models/image_uploader.rb
plugin :store_dimensions
And display the dimensions using the width
and height
methods:
views/photos/_photo.html.erb
<div> <% if photo.image_data? %> <%= image_tag photo.image_url(:thumb) %> <p> Size <%= photo.image[:original].size %> bytes<br> MIME type <%= photo.image[:original].mime_type %><br> Dimensions <%= "#{photo.image[:original].width}x#{photo.image[:original].height}" %> </p> <% end %> <p><%= photo.title %> | <%= link_to 'Edit', edit_photo_path(photo) %></p> </div>
Also, there is a dimensions
method available that returns an array containing width and height (for example, [500, 750]
).
Moving to the Cloud
Developers often choose cloud services to host uploaded files, and Shrine does present such a possibility. In this section, I will show you how to upload files to Amazon S3.
As the first step, include two more gems into the Gemfile:
gem "aws-sdk", "~> 2.1" group :development do gem 'dotenv-rails' end
aws-sdk is required to work with S3's SDK, whereas dotenv-rails will be used to manage environment variables in development.
bundle install
Before proceeding, you should obtain a key pair to access S3 via API. To get it, sign in (or sign up) to Amazon Web Services Console and navigate to Security Credentials > Users. Create a user with permissions to manipulate files on S3. Here is the simple policy presenting full access to S3:
{ "Version": "2016-11-14", "Statement": [ { "Effect": "Allow", "Action": "s3:*", "Resource": "*" } ] }
Download the created user's key pair. Alternatively, you might use root access keys, but I strongly discourage you from doing that as it's very insecure.
Next, create an S3 bucket to host your files and add a file into the project's root to host your configuration:
.env
S3_KEY=YOUR_KEY S3_SECRET=YOUR_SECRET S3_BUCKET=YOUR_BUCKET S3_REGION=YOUR_REGION
Never ever expose this file to the public, and make sure you exclude it from Git:
.gitignore
.env
Now modify Shrine's global configuration and introduce a new storage:
config/initializers/shrine.rb
require "shrine" require "shrine/storage/s3" s3_options = { access_key_id: ENV['S3_KEY'], secret_access_key: ENV['S3_SECRET'], region: ENV['S3_REGION'], bucket: ENV['S3_BUCKET'], } Shrine.storages = { cache: Shrine::Storage::S3.new(prefix: "cache", **s3_options), store: Shrine::Storage::S3.new(prefix: "store", **s3_options), }
That's it! No changes have to be made to the other parts of the app, and you can test this new storage right away. If you are receiving errors from S3 related to incorrect keys, make sure you accurately copied the key and secret, without any trailing spaces and invisible special symbols.
Conclusion
We've come to the end of this article. Hopefully, by now you feel much confident in using Shrine and are eager to employ it in one of your projects. We have discussed many of this gem's features, but there are even more, like the ability to store additional context along with files and the direct upload mechanism.
Therefore, do browse Shrine's documentation and its official website, which thoroughly describes all available plugins. If you have other questions left about this gem, don't hesitate to post them. I thank you for staying with me, and I'll see you soon!
No comments:
Post a Comment