Download Links: Sending files through a Rails 4 app

Creating download links to send files through a Rails app is fairly straightforward thanks to the built-in send_file method.

For this tutorial we’ll use Rail 4.0.4. Let’s say that you have generated a resource called Document with an attribute called document_path which is a string that contains the absolute path to this document, for example /var/www/myapp/downloadables/mydocument.pdf.

If you just need to add download functionality to one resource in your app there are just three steps to complete:

  1. Create a route for your new ‘download’ action
  2. Define the ‘download’ method in your controller
  3. Add a link to your download action in a view

Start by creating a route for the download action on that resource. For this example the download action is specific to a member of the Document resource rather than the collection of all Documents, which means we will refer to this member using its id. Add a route to routes.rb that looks like this:


resources :documents do
    get 'download', on: :member
end

This will define a route for use that maps a url like /documents/1/download to the download action we are about to define in our DocumentsController. That method is pretty simple, it’s just:


class DocumentsController < ApplicationController
before_action :set_document

  #All of your other actions (show, create, delete etc) go here  

  #Download
  def download
    send_file(@document.document_path)
  end

  private
      # Use callbacks to share common setup or constraints between actions.
      def set_document
        @document = Document.find(params[:id])
      end
end

With that change we can already point our browser at myapp/documents/1/download to get our pdf, but that’s not very intuitive or user friendly. Let’s add a link to one of our views to download this document. Let’s add it to the documents/show.html.erb view for this example. The code we want to add is:


<%= link_to "Download this Document!", download_document_path %>

Easy! But don’t get confused about download_document_path vs. @document.document_pathdownload_document_path is a convenience string that Rails generated for us when we declared the download route in Step 1.  @document.document_path is the string attribute stored in our database which describes the document’s location on disk. When we click on “Download this Document!” link Rails routes this URL to the download_document_path which calls the ‘download’ action in the DocumentsController. The ‘download’ method in turn calls send_file which then uses the @document.document_path string to locate the file you want to send on disk. The app then read this file and sends it to the user.

Performance Considerations

The way it is right now we are sending the file directly to the user with no streaming and blocking one of our application’s threads in the process.  This is a performance issue worthy of it’s own article but for now I’m just going to point you to some other resources where you can learn more about this topic:

send_file documentation

File Downloads Done Right

Adding more downloadable resources using Concerns

Now let’s say that in addition to allowing your users to download Documents, you also have Photos that your want your users to download. While you could easily just follow the above steps for the Photos resource, that wouldn’t be very DRY. Instead, consider moving your ‘download’ method to a ‘Downloadable’ module and then including that module into the controller for each resource that should be downloadable. In Rails parlance this module would be a called a ‘Concern’.

To get started let’s create a file called ‘downloadable.rb’ in our app/controllers/concerns folder. This will be a module that extends ActiveSupport::Concern and we want to copy our ‘download’ method code from the DocumentsController and paste it into this new module and rename it ‘send’ so that we do not confuse and override it with the ‘download’ method in the controller. We also want to pass the filepath as a parameter since we no longer can assume we know anything about the instance variable. We then include this module into the DocumentsController concern and the PhotosController and make the ‘download’ method call send. In the end our code will look like this:

#app/controllers/concerns/downloadable.rb
module Downloadable extend ActiveSupport::Concern

  def send_to_user(args={})
    send_file args[:filepath]
  end

end
#app/controllers/documents_controller.rb
class DocumentsController < ApplicationController
include Downloadable
before_action :set_document

  #All of your other actions (show, create, delete etc) go here  

  #Download
  def download
    send_to_user filepath: @document.document_full_path
  end

  private
      # Use callbacks to share common setup or constraints between actions.
      def set_document
        @document = Document.find(params[:id])
      end
end
#app/controllers/photos_controller.rb
class PhotosController < ApplicationController
include Downloadable
before_action :set_photo

  #All of your other actions (show, create, delete etc) go here  

  #Download
  def download
    send_to_user filepath: @photo.photo_full_path
  end

  private
      # Use callbacks to share common setup or constraints between actions.
      def set_photo
        @photo = Photo.find(params[:id])
      end
end

Now we’ve got to define our routes for Photo#download. Fortunately Rails also lets us use concerns in routes.rb to avoid duplicating code everytime we want to make something downloadable. We can route a concern as follows:

#routes.rb
MyApp::Application.routes.draw do

  concern :downloadable do
    get 'download', on: :member
  end

  resources :documents, concerns: :downloadable

  resources :photos, concerns: :downloadable

  root 'welcome#index'
end

Lastly, you want to create a link on your Photo’s show.html.erb page to grab this photo:


<%= link_to "Download this Photo!", download_photo_path %>

Final Thoughts

You probably noticed that our ‘send’ method is so short that it would actually be less code to just copy the ‘download’ method into PhotosController. Yes that’s true, but I wouldn’t do it. Duplicating code like that can be a maintenance nightmare. Our use of send_file is very basic and more than a little naive. We didn’t even check if the file exists and is readable before we tried to send it! Once we add validation and testing code the ‘send’ method will get a lot longer and we’ll start to see why we didn’t want to just copy and paste. If you’re curious how you might implement the file checking I’ve included a possibility for an expanded version of Downloadable in the addendum below.

If you have any comments or impovements to this code, please feel free to comment!

Addendum

Here’s an expanded version of the Downloadable module which adds some flash notifications and redirects the to page that made the last request in case the file is not readable:


module Downloadable extend ActiveSupport::Concern

  def send_to_user(args={})
  	file = args[:filepath]
  	if File.exists?(file) and File.readable?(file)
      send_file file
    else
      redirect_to :back, notice: 'Unfortunately the requested file is not readable or cannot be located.'
    end
  end

end