File uploads are generally a tricky area in web development. In this tutorial, we will learn how to use Dragonfly, a powerful Ruby gem that makes it easy and efficient to add any kind of upload functionality to a Rails project.
What We're Going to Build
Our sample application will display a list of users, and for each one of them, we will be able to upload an avatar and have it stored. Additionally, Dragonfly will allow us to:
- Dynamically manipulate images without saving additional copies
- Leverage HTTP caching to optimize our application load
In this lesson, we will follow a BDD [Behavior Driven Development] approach, using Cucumber and RSpec.
Prerequisites
You'll need to have Imagemagick installed: you can refer to this page for the binaries to install. As I am based on a Mac platform, use Homebrew, I can simply type brew install imagemagick
.
You will also need to clone a basic Rails application that we will use as a starting point.
Setup
We will begin by cloning the starting repository and setting up our dependencies:
git clone http://[email protected]/cloud8421/tutorial_dragonfly_template.git cd tutorial_dragonfly_template
This application requires at least Ruby 1.9.2 to run, however, I encourage you to use 1.9.3. The Rails version is 3.2.1. The project does not include a .rvmrc
or a .rbenv
file.
Next, we run:
bundle install bundle exec rake db:setup db:test:prepare db:seed
This will take care of the gem dependencies and database setup (we will be using sqlite, so no need to worry about database config).
To test that everything is working as expected, we can run:
bundle exec rspec bundle exec cucumber
You should find that all tests have passed. Let's review Cucumber's output:
Feature: managing user profile As a user In order to manage my data I want to access my user profile page Background: Given a user exists with email "[email protected]" Scenario: viewing my profile Given I am on the home page When I follow "Profile" for "[email protected]" Then I should be on the profile page for "[email protected]" Scenario: editing my profile Given I am on the profile page for "[email protected]" When I follow "Edit" And I change my email with "[email protected]" And I click "Save" Then I should be on the profile page for "[email protected]" And I should see "User updated" 2 scenarios (2 passed) 11 steps (11 passed) 0m0.710s
As you can see, these features describe a typical user workflow: we open a user page from a list, press "Edit" to edit the user data, change the email, and save.
Now, try running the app:
rails s
If you open http:://localhost:3000
in the browser, you will find a list of users (we pre-populated the database with 40 random records thanks to the Faker gem).
For now, each one of the users will have a small, 16x16px avatar and a big placeholder avatar in their profile page. If you edit the user, you will be able to change its details (first name, last name and password), but if you try to upload an avatar, it will not be saved.
Feel free to browse the codebase: the application uses Simple Form to generate form views and Twitter Bootstrap for CSS and layout, as they integrate perfectly and help a lot in speeding up the prototyping process.
Features for Avatar Upload
We will start by adding a new scenario to features/managing_profile.feature
:
... Scenario: adding an avatar Given I am on the profile page for "[email protected]" When I follow "Edit" And I upload the mustache avatar And I click "Save" Then I should be on the profile page for "[email protected]" And the profile should show "the mustache avatar"
This feature is fairly self-explanatory, but it requires a few additional steps to add to features/step_definitions/user_steps.rb
:
... When /^I upload the mustache avatar$/ do attach_file 'user[avatar_image]', Rails.root + 'spec/fixtures/mustache_avatar.jpg' end Then /^the profile should show "([^"]*)"$/ do |image| pattern = case image when 'the mustache avatar' /mustache_avatar/ end n = Nokogiri::HTML(page.body) n.xpath(".//img[@class='thumbnail']").first['src'].should =~ pattern end
This step assumes that you have an image, called mustache_avatar.jpg
inside spec/fixtures
. As you might guess, this is just an example; it can be anything you want.
The first step uses Capybara to find the user[avatar_image]
file field and upload the file. Note that we're assuming that we will have a an avatar_image
attribute on the User
model.
The second step uses Nokogiri (a powerful HTML/XML parsing library) and XPath to parse the content of the resulting profile page and search for the first img
tag with a thumbnail
class and test that the src
attribute contains mustache_avatar
.
If you run cucumber
now, this scenario will trigger an error, as there is no file field with the name we specified. It's now time to focus on the User
model.
Adding Dragonfly Support to the User Model
Before integrating Dragonfly with the User
model, let's add a couple of specs to user_spec.rb
.
We can append a new block right after the attributes
context:
context "avatar attributes" do it { should respond_to(:avatar_image) } it { should allow_mass_assignment_of(:avatar_image) } end
We test that the User has a avatar_image
attribute and, as we will be updating this attribute through a form, it needs to be accessible (second spec).
Now we can install Dragonfly: by doing that, we will get these specs to go green.
Let's add the following lines to the Gemfile:
gem 'rack-cache', require: 'rack/cache' gem 'dragonfly', '~>0.9.10'
Next, we can run bundle install
. Rack-cache is needed in development, as it's the simplest option to have HTTP caching. It can be used in production as well, even if more robust solutions (like Varnish or Squid) would be better.
We also need to add the Dragonfly initializer. Let's create the config/initializers/dragonfly.rb
file and add the following:
require 'dragonfly' app = Dragonfly[:images] app.configure_with(:imagemagick) app.configure_with(:rails) app.define_macro(ActiveRecord::Base, :image_accessor)
This is the vanilla Dragonfly configuration: it sets up a Dragonfly application and configures it with the needed module. It also adds a new macro to ActiveRecord
that we will be able to use to extend our User
model.
We need to update config/application.rb
, and add a new directive to the configuration (right before the config.generators
block):
config.middleware.insert 0, 'Rack::Cache', { verbose: true, metastore: URI.encode("file:#{Rails.root}/tmp/dragonfly/cache/meta"), entitystore: URI.encode("file:#{Rails.root}/tmp/dragonfly/cache/body") } unless Rails.env.production? config.middleware.insert_after 'Rack::Cache', 'Dragonfly::Middleware', :images
Without going into much detail, we are setting up Rack::Cache
(except for production, where it's enabled by default), and setting up Dragonfly to use it.
We will store our images on disk, however, we need a way to track the association with a user. The simplest option is to add two columns to the user table with a migration:
rails g migration add_avatar_to_users avatar_image_uid:string avatar_image_name:string bundle exec rake db:migrate db:test:prepare
Once again, this is straight from Dragonfly's documentation: we need to have a avatar_image_uid
column to uniquely identify the avatar file and a avatar_image_name
to store its original filename (the latter column is not strictly needed, but it enables the generation of image urls that end with the original filename).
Finally, we can update the User
model:
class User < ActiveRecord::Base image_accessor :avatar_image attr_accessible :email, :first_name, :last_name, :avatar_image ...
The image_accessor
method is made available by the Dragonfly initializer, and it requires just an attribute name. We also make the same attribute accessible in the line below.
Running rspec
now should show all specs green.
Uploading and Displaying the Avatar
To test the upload function, we can add a context to users_controller_spec.rb
in the PUT update
block:
context "avatar image" do let!(:image_file) { fixture_file_upload('/mustache_avatar.jpg', 'image/jpg') } context "uploading an avatar" do before do put :update, id: user.id, user: { avatar_image: image_file } end it "should effectively save the image record on the user" do user.reload user.avatar_image_name.should =~ /mustache_avatar/ end end end
We will reuse the same fixture and create a mock for the upload with fixture_file_upload
.
As this functionality leverages on Dragonfly, we don't need to write code to get it passing.
We now have to update our views to show the avatar. Let's start from the user show page and open app/views/users/show.html.erb
and update it with the following content:
<div class="row"> <div class="span4"> <% if @user.avatar_image.present? %> <%= image_tag @user.avatar_image.url, class: 'thumbnail' %> <% else %> <img src="http://placehold.it/400x400&text=Super+cool+avatar" alt="Super cool avatar" class="thumbnail"> <% end %> </div> <div class="span8"> <hr /> <h2><%= @user.name %></h2> <h4><%= @user.email %></h4> <hr /> <%= link_to 'Edit', edit_user_path(@user), class: "btn" %> </div> </div>
Next, we can update app/views/users/edit.html.erb
:
<%= simple_form_for @user, multipart: true do |f| %> <div class="row"> <div class="span4"> <div class="thumbnail-wrapper"> <% if @user.avatar_image.present? %> <%= image_tag @user.avatar_image.url, class: 'thumbnail' %> <% else %> <img src="http://placehold.it/400x400&text=Super+cool+avatar" alt="Super cool avatar" class="thumbnail"> <% end %> <div class="caption form-inline"> <%= f.input :avatar_image, as: :file %> </div> </div> </div> <div class="span8"> <div class="well"> <%= f.input :first_name %> <%= f.input :last_name %> <%= f.input :email %> </div> <div class="form-actions"> <%= f.submit 'Save', class: "btn btn-primary" %> </div> </div> </div> <% end %>
We can show the user avatar with a simple call to @user.avatar_image.url
. This will return a url to a non-modified version of the avatar uploaded by the user.
If you run cucumber
now, you'll see the green feature. Feel free to try it out in the browser too!
We're implicitly relying on CSS to resize the image if it's too big for its container. It's a shaky approach: our user could upload non-square avatars, or a very small image. In addition, we're always serving the same image, without too much concern for page size or bandwidth.
We need to work on two different areas: adding some validation rules to the avatar upload and specifying image size and ratio with Dragonfly.
Upload Validations
We will start by opening the user_spec.rb
file and adding a new spec block:
context "avatar attributes" do %w(avatar_image retained_avatar_image remove_avatar_image).each do |attr| it { should respond_to(attr.to_sym) } end %w(avatar_image retained_avatar_image remove_avatar_image).each do |attr| it { should allow_mass_assignment_of(attr.to_sym) } end it "should validate the file size of the avatar" do user.avatar_image = Rails.root + 'spec/fixtures/huge_size_avatar.jpg' user.should_not be_valid # size is > 100 KB end it "should validate the format of the avatar" do user.avatar_image = Rails.root + 'spec/fixtures/dummy.txt' user.should_not be_valid end end
We are testing for presence and are allowing "mass assignment" for additional attributes that we will use to enhance the user form (:retained_avatar_image
and :remove_avatar_image
).
In addition, we are also testing that our user model will not accept big uploads (more than 200 KB) and files that are not images. For both cases, we need to add two fixture files (an image with the specified name and whose size is more than 200 KB and a text file with any content).
As usual, running these specs will not get us to green. Let's update the user model to add those validation rules:
... attr_accessible :email, :first_name, :last_name, :avatar_image, :retained_avatar_image, :remove_avatar_image ... validates_size_of :avatar_image, maximum: 100.kilobytes validates_property :format, of: :avatar_image, in: [:jpeg, :png, :gif, :jpg] validates_property :mime_type, of: :avatar_image, in: ['image/jpg', 'image/jpeg', 'image/png', 'image/gif'], case_sensitive: false
These rules are fairly effective: note that, in addition to checking the format, we also check the mime type for better safety. Being an image, we allow jpg, png and gif files.
Our specs should be passing now, so it's time to update the views to optimize the image load.
Dynamic Image Processing
By default, Dragonfly uses ImageMagick to dynamically process images when requested. Assuming we have a user
instance in one of our views, we could then:
user.avatar_image.thumb('100x100').url user.avatar_image.process(:greyscale).url
These methods will create a processed version of this image with a unique hash and thanks to our caching layer, ImageMagick will be called just once per image. After that, the image will be served directly from cache.
You can use many built-in methods or simply build your own, Dragonfly's documentation has got plenty of examples.
Let's revisit our user edit
page and update the view code:
... <% if @user.avatar_image.present? %> <%= image_tag @user.avatar_image.thumb('400x400#').url, class: 'thumbnail' %> <% else %> ...
We'll do the same for the user show
page:
... <% if @user.avatar_image.present? %> <%= image_tag @user.avatar_image.thumb('400x400#').url, class: 'thumbnail' %> <% else %> ...
We're forcing the image size to 400 x 400 pixels. The #
parameter also instructs ImageMagick to crop the image keeping a central gravity. You can see that we have the same code in two places, so let's refactor this into a partial called views/users/_avatar_image.html.erb
<% if @user.avatar_image.present? %> <%= image_tag @user.avatar_image.thumb('400x400#').url, class: 'thumbnail' %> <% else %> <img src="http://placehold.it/400x400&text=Super+cool+avatar" alt="Super cool avatar" class="thumbnail"> <% end %>
Then we can replace the content of the .thumbnail
container with a simple call to:
<div class="thumbnail"> <%= render 'avatar_image' %> </div>
We can do even better by moving the argument of thumb
out of the partial. Let's update _avatar_image.html.erb
:
<% if user.avatar_image.present? %> <%= image_tag user.avatar_image.thumb(args).url %> <% else %> <img src="http://placehold.it/<%= args.gsub(/\W/, '') %>&text=Super+cool+avatar" alt="Super cool avatar"> <% end %>
We can now call our partial with two arguments: one for the desired aspect and one for the user:
<%= render 'avatar_image', args: '400x400#', user: @user %>
We can use the snippet above in edit
and show
views, while we can call it in the following way inside views/users/_user_table.html.erb
, where we are showing the small thumbnails.
... <td><%= link_to 'Profile', user_path(user) %></td> <td> <%= render 'avatar_image', args: '16x16#', user: user %> </td> <td><%= user.first_name %></td> ...
In both cases, we also perform a Regex on the aspect to extract a string compatible with the placehold.it service (i.e. removing non alphanumerical characters).
Removing the Avatar
Dragonfly creates two additional attributes that we can use in a form:
-
retained_avatar_image
: this stores the uploaded image between reloads. If validations for another form field (say email) fail and the page is reloaded, the uploaded image is still available without need to reupload it. We will use it directly in the form. -
remove_avatar_image
: when it's true, the current avatar image will be deleted both from the user record and disk.
We can test the avatar removal by adding an additional spec to users_controller_spec.rb
, in the avatar image
block:
... context "removing an avatar" do before do user.avatar_image = Rails.root + 'spec/fixtures/mustache_avatar.jpg' user.save end it "should remove the avatar from the user" do put :update, id: user.id, user: { remove_avatar_image: "1" } user.reload user.avatar_image_name.should be_nil end end ...
Once again, Dragonfly will get this spec to pass automatically as we already have the remove_avatar_image
attribute available for the user instance.
Let's then add another feature to managing_profile.feature
:
Scenario: removing an avatar Given the user with email "[email protected]" has the mustache avatar And I am on the profile page for "[email protected]" When I follow "Edit" And I check "Remove avatar image" And I click "Save" Then I should be on the profile page for "[email protected]" And the profile should show "the placeholder avatar"
As usual, we need to add some steps to user_steps.rb
and update one to add a Regex for the placeholder avatar:
Given /^the user with email "([^"]*)" has the mustache avatar$/ do |email| u = User.find_by_email(email) u.avatar_image = Rails.root + 'spec/fixtures/mustache_avatar.jpg' u.save end When /^I check "([^"]*)"$/ do |checkbox| check checkbox end Then /^the profile should show "([^"]*)"$/ do |image| pattern = case image when 'the placeholder avatar' /placehold.it/ when 'the mustache avatar' /mustache_avatar/ end n = Nokogiri::HTML(page.body) n.xpath(".//img[@class='thumbnail']").first['src'].should =~ pattern end
We need also to add two additional fields to the edit
form:
... <div class="caption form-inline"> <%= f.input :retained_avatar_image, as: :hidden %> <%= f.input :avatar_image, as: :file, label: false %> <%= f.input :remove_avatar_image, as: :boolean %> </div> ...
This will get our feature to pass.
Refactoring Features
To avoid having a large and too detailed feature, we can test the same functionality in a request spec.
Let's create a new file called spec/requests/user_flow_spec.rb
and add this content to it:
require 'spec_helper' describe "User flow" do let!(:user) { Factory(:user, email: "[email protected]") } describe "viewing the profile" do it "should show the profile for the user" do visit '/' page.find('tr', text: user.email).click_link("Profile") current_path = URI.parse(current_url).path current_path.should == user_path(user) end end describe "updating profile data" do it "should save the changes" do visit '/' page.find('tr', text: user.email).click_link("Profile") click_link 'Edit' fill_in :email, with: "[email protected]" click_button 'Save' current_path.should == user_path(user) page.should have_content "User updated" end end describe "managing the avatar" do it "should save the uploaded avatar" do user.avatar_image = Rails.root + 'spec/fixtures/mustache_avatar.jpg' user.save visit user_path(user) click_link 'Edit' attach_file 'user[avatar_image]', Rails.root + 'spec/fixtures/mustache_avatar.jpg' click_button 'Save' current_path.should == user_path(user) page.should have_content "User updated" n = Nokogiri::HTML(page.body) n.xpath(".//img[@class='thumbnail']").first['src'].should =~ /mustache_avatar/ end it "should remove the avatar if requested" do user.avatar_image = Rails.root + 'spec/fixtures/mustache_avatar.jpg' user.save visit user_path(user) click_link 'Edit' check "Remove avatar image" click_button 'Save' current_path.should == user_path(user) page.should have_content "User updated" n = Nokogiri::HTML(page.body) n.xpath(".//img[@class='thumbnail']").first['src'].should =~ /placehold.it/ end end end
The spec encapsulates all of the steps we used to define our main feature. It thoroughly tests the markup and the flow, so we can make sure that everything works properly at a granular level.
Now we can shorten the managing_profile.feature
:
Feature: managing user profile As a user In order to manage my data I want to access my user profile page Background: Given a user exists with email "[email protected]" Scenario: editing my profile Given I change the email with "[email protected]" for "[email protected]" Then I should see "User updated" Scenario: adding an avatar Given I upload the mustache avatar for "[email protected]" Then the profile should show "the mustache avatar" Scenario: removing an avatar Given the user "[email protected]" has the mustache avatar and I remove it Then the user "[email protected]" should have "the placeholder avatar"
Updated user_steps.rb
:
Given /^a user exists with email "([^"]*)"$/ do |email| Factory(:user, email: email) end Given /^the user with email "([^"]*)" has the mustache avatar$/ do |email| u = User.find_by_email(email) u.avatar_image = Rails.root + 'spec/fixtures/mustache_avatar.jpg' u.save end Then /^I should see "([^"]*)"$/ do |content| page.should have_content(content) end Then /^the profile should show "([^"]*)"$/ do |image| n = Nokogiri::HTML(page.body) n.xpath(".//img[@class='thumbnail']").first['src'].should =~ pattern_for(image) end Given /^I change the email with "([^"]*)" for "([^"]*)"$/ do |new_email, old_email| u = User.find_by_email(old_email) visit edit_user_path(u) fill_in :email, with: new_email click_button 'Save' end Given /^I upload the mustache avatar for "([^"]*)"$/ do |email| u = User.find_by_email(email) visit edit_user_path(u) attach_file 'user[avatar_image]', Rails.root + 'spec/fixtures/mustache_avatar.jpg' click_button 'Save' end Given /^the user "([^"]*)" has the mustache avatar and I remove it$/ do |email| u = User.find_by_email(email) u.avatar_image = Rails.root + 'spec/fixtures/mustache_avatar.jpg' u.save visit edit_user_path(u) check "Remove avatar image" click_button 'Save' end Then /^the user "([^"]*)" should have "([^"]*)"$/ do |email, image| u = User.find_by_email(email) visit user_path(u) n = Nokogiri::HTML(page.body) n.xpath(".//img[@class='thumbnail']").first['src'].should =~ pattern_for(image) end def pattern_for(image_name) case image_name when 'the placeholder avatar' /placehold.it/ when 'the mustache avatar' /mustache_avatar/ end end
Adding S3 Support
As a last step, we can easily add S3 support to store the avatar files. Let's reopen config/initializers/dragonfly.rb
and update the configuration block:
Dragonfly::App[:images].configure do |c| c.datastore = Dragonfly::DataStorage::S3DataStore.new c.datastore.configure do |d| d.bucket_name = 'dragonfly_tutorial' d.access_key_id = 'some_access_key_id' d.secret_access_key = 'some_secret_access_key' end end unless %(development test cucumber).include? Rails.env
This will work out of the box, and will only affect production (or any other environment that is not specified in the file). Dragonfly will default to file system storage for all other cases.
Conclusion
I hope you found this tutorial interesting, and managed to pick up a few interesting tidbits of information.
I encourage you to refer to the Dragonfly GitHub page for extensive documentation and other examples of use cases - even outside of a Rails application.
Comments