Advertisement

How to Upload Files with Ease Using DragonFly

by
Student iconAre you a student? Get a yearly Tuts+ subscription for $45 →

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://cloud8421@github.com/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@example.com" 
 
  Scenario: viewing my profile 
    Given I am on the home page 
    When I follow "Profile" for "email@example.com" 
    Then I should be on the profile page for "email@example.com" 
 
  Scenario: editing my profile 
    Given I am on the profile page for "email@example.com" 
    When I follow "Edit" 
    And I change my email with "new_email@example.com" 
    And I click "Save" 
    Then I should be on the profile page for "email@example.com" 
    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@example.com" 
  When I follow "Edit" 
  And I upload the mustache avatar 
  And I click "Save" 
  Then I should be on the profile page for "email@example.com" 
  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&amp;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&amp;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&amp;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/, '') %>&amp;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@example.com" has the mustache avatar 
  And I am on the profile page for "email@example.com" 
  When I follow "Edit" 
  And I check "Remove avatar image" 
  And I click "Save" 
  Then I should be on the profile page for "email@example.com" 
  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@example.com") } 
 
  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: "new_email@example.com" 
      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@example.com" 
 
  Scenario: editing my profile 
    Given I change the email with "new_mail@example.com" for "email@example.com" 
    Then I should see "User updated" 
 
  Scenario: adding an avatar 
    Given I upload the mustache avatar for "email@example.com" 
    Then the profile should show "the mustache avatar" 
 
  Scenario: removing an avatar 
    Given the user "email@example.com" has the mustache avatar and I remove it 
    Then the user "email@example.com" 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.

Advertisement