Adding Uploadcare to Rails the Easy Way

Give your users the ability to create, read, and delete images

Coding by Matthew (WMF) is licensed under CC BY-SA 3.0

Uploadcare is a really handy service that provides a combination of a CDN and a convenient, configurable JavaScript upload dialog that lets your app’s users easily put images onto your CDN. Each Uploadcare image has a unique UUID, which means that if your Rails app is also using UUIDs as database identifiers, then Uploadcare will fit right in.

This post will show you how to easily integrate Uploadcare into an existing Rails app, in a way that gives your users the ability to create, read, and delete the images that they’ve uploaded to your account on the CDN. In this app, we’ll look specifically at the case of a user with a profile photo, but you can easily generalize this to any type of model with a user-uploaded image, or even an array of user-uploaded images.

Here’s the user table we’ll be working with:

create_table :user, id: :uuid do |t|
  t.string :name
  t.uuid   :profile_photo_id
end

This post assumes that you’re already familiar with how to set up a Rails app. We’ll be using Rails 4.2.5 in this example, but this post should work for earlier versions, as well. We’re also using the postgres UUID extension, so you’ll need that enabled before beginning.

Just use REST, and Bring Your Own Magic

We’re going to skip the uploadcare-rails gem, because it hasn’t been updated in a long time and I wasn’t really able to do anything useful with it (or with the similarly stale uploadcare-ruby gem). Fortunately, it’s really easy for us to do what we need to do without either of the Uploadcare gems.

Indeed, I often find that when I’m using an external service with a well-documented REST API, I can write less code with better performance and fewer headaches by skipping the official gem entirely. This is especially true in cases where the gem implements a half-baked ORM that attempts to map some subset of ActiveRecord’s functionality onto the REST API. By the time you’ve learned how that mapping works and which parts of ActiveRecord the gem has kind-of-sort-of implemented, you usually know enough about the REST API to put together your own half-baked ActiveRecord-like implementation that contains only the parts you need and works exactly like you want it to. So that’s what we’ll do in this case.

Once Uploadcare is set up in the app, we’ll create what is basically a fake ActiveRecord model using the virtus gem. Using this model, which contains all of the Uploadcare-related code and functionality that we need (and nothing that we don’t need), we can do the kinds of things that we tend to do with rails models in our controllers and views.

Setting up Uploadcare

To get started, create a free Uploadcare account and project, and then take note of location of the public and private keys for that project because we’ll need them in order to configure the app.

First, we’ll add some code to our app/views/layouts/application.html.erb file that will put the correct Uploadcare code in the <head> tags of our app:

  UPLOADCARE_LOCALE     = "en";
  UPLOADCARE_TABS       = "file url facebook gdrive box skydrive instagram evernote";
  UPLOADCARE_AUTOSTORE  = true;
  UPLOADCARE_PUBLIC_KEY = "<%= ENV['UPLOADCARE_PUBLIC_KEY'] %>";
  UPLOADCARE_CDN_BASE   = "https://ucarecdn.com/";
  UPLOADCARE_PATH_VALUE = false;

<%= javascript_include_tag "https://ucarecdn.com/widget/2.5.5/uploadcare/uploadcare-2.5.5.min.js" %>

You can set those ENV variables in your app’s environment, but we strongly recommend using the figaro gem and adding them to config/application.yml.

As for the bit of ERB below the script tags, this is where we include the minified version of the latest Uploadcare widget. Or, if you like you can place this in your vendor directory and bundle it with your app.

At this point, you should be able to create a Rails form object for a model that stores an Uploadcare UUID using the standard widget, and it should work. But we want to take it further than just adding an Uploadcare image UUID to a single model.

Creating an Image model

The virtus gem is super handy for dealing with REST APIs, and Uploadcare’s API is no exception. First, I’ll show you the code for my Image model, and then we’ll walk through what it does:

class Image
  include Virtus.model
  include ActiveModel::Model
  include HTTParty

  base_uri "api.uploadcare.com"
  debug_output $stdout unless Rails.env.production?

  class Info
    include Virtus.model

    attribute :height, Integer
    attribute :width, Integer
    attribute :geo_location
    attribute :datetime_original
    attribute :format, String
  end

  attribute :uuid, UUID
  attribute :mime_type, String
  attribute :is_ready, Boolean
  attribute :original_filename, String
  attribute :original_file_url, String
  attribute :datetime_uploaded, String
  attribute :size, Integer
  attribute :datetime_stored, String
  attribute :source, Hash
  attribute :image_info, Image::Info

  def ==(img)
    uuid = img.uuid
  end
  alias_method :eql?, :==

  def url
    return unless uuid
    "https://ucarecdn.com/#{uuid}/"
  end

  def formatted_url(url_operations_str=nil)
    return unless url.present?
    "#{url}#{url_operations_str}"
  end

  def destroy
    self.class.delete("/files/#{uuid}/", headers: self.class.headers)
  end

  def self.find(uuid)
    return unless uuid.present?
    response = self.get("/files/#{uuid}/", headers: headers)
    Image.new(
      JSON.parse(response.body).
        with_indifferent_access.
        slice(*Image.attributes.map(&:name))
    )
  end

  def self.uuid_from_url(url)
    return unless url.present?
    url.split("/")[3]
  end

  def self.formatted_url_from_uuid(uuid, url_operations_str=nil)
    self.new(uuid: uuid).formatted_url(url_operations_str)
  end

  def self.headers
    {
      "Accept" => "application/vnd.uploadcare-v0.4+json",
      "Authorization" => "Uploadcare.Simple #{ENV['UPLOADCARE_PUBLIC_KEY']}:#{ENV['UPLOADCARE_PRIVATE_KEY']}",
      "Date" => "#{Time.current.rfc2822}"
    }
  end
end

Let’s go through the above code from top to bottom.

First, I include ActiveModel::Model so that I get model_name and the other methods that my controllers and views look for by default.

Next, you’ll notice that I’m using the httparty gem. I do this because in the specific app that I built this model for I have other gems that depend on httparty, so I went ahead and used it here instead of introducing a new dependency. Feel free to substitute your gem of choice.

In the next two lines, I set HTTParty’s base_uri to the domain of the Uploadcare REST API, and I configure the model to send debug output to stdout so that I can watch what it’s doing in dev and testing.

Let’s skip the inner class declaration for the moment and look at the Image model’s attribute declarations. The attributes interface is one of my favorite aspects of virtus, because it handles coercion and therefore makes deserializing JSON objects a breeze.

I can get a bit of JSON from the Uploadcare REST API, convert it to a hash with JSON.parse, and then use that hash to initialize an Image object with a call to Image.new; thanks to the built-in type coercion, the attributes will each be the proper type.

Now let’s take a quick look at that inner class, Image::Info. Uploadcare’s JSON response has an image_info field that contains some sub-fields, and with the Image::Info class and Virtus’s automagic coercion I can easily turn that into an object.

Moving down the file, I went ahead and defined == for the models so that I can check for equality in my specs.

I also go ahead and derive the url every time from the UUID, instead of using what Uploadcare gives me back via the REST API. I do this because I often want to call Image#url on persisted image objects where I have the UUID stored in my database but I don’t need to make an API call to fill out the entire object. Two examples will clarify what I mean.

First, consider the case of the User#profile_photo method, which is basically a fake ActiveRecord belongs_to :profile_photo association as I’ve set it up below:

def profile_photo
   Image.find(uuid: profile_photo_id) }
end

The above code will make a REST API call for every profile_photo method call, which in some cases may be exactly what we want. But for cases where we don’t need that, one option would be to memoize it with an instance variable:

def profile_photo
   @profile_photo ||= Image.find(uuid: profile_photo_id) }
end

But what if all we really need to do is display the user’s profile photo in a view, and we don’t need any of the metadata (size, dimensions, date uploaded, etc.) about the image from Uploadcare. In this case, we can avoid an API call entirely by just doing:

def profile_photo
   Image.new(uuid: profile_photo_id) }
end

Now we’ve got an image object, and a call to url or formatted_url will give us the correct URL for the image on the CDN.

This small optimization works even better when we’re dealing with multiple images. Let’s say I want my users to have multiple profile photos, so I have my users table configured as follows:

create_table :user do |t|
  t.string :name
  t.uuid   :profile_photo_ids, array: true
end

Now let’s say that in my users/show view I want to show all of the user’s profile photos with a bit of code like this:

<% @user.profile_photos.each do |photo| %>
  <%= image_tag photo.url %>
<% end %>

If I only need to show the full-sized image and not any of the other data associated with the image, then I can define User#profile_photos as follows and have the Image objects I need without making any REST API calls:

def profile_photos
  profile_photo_ids.map { |uuid| Image.new(uuid: uuid) }
end

Moving on, Image#formatted_url provides an easy way for me to take advantage of Uploadcare’s image resize options, and Image#destroy lets me delete the image from the service.

Finally, there are the three class methods: find is for retrieving a fully populated Image from the service, uuid_from_url provides the inverse of Image.new(uuid: my_uuid).url for use in tests and other situations where I want take in a URL and derive a UUID, and headers is a convenience method that populates handles the authorization, API versioning, and timestamp requirements for the REST API.

Wiring up the controller and view

At this point, we’ve got what amounts to a fake ActiveRecord belongs_to :profile_photo association on our User model, so we can actually let users CRUD their images using the standard rails machinery of views and controllers. Let’s take a look at how that might work on a simple users/edit view.

<%= f.label :profile_photo_id, "Profile Photo:" %>
<%= f.hidden_field :profile_photo_id, role: "uploadcare-uploader" %>

Assuming that your controller’s strong parameters are configured to permit(:profile_photo_id), the above bit of code will launch the Uploadcare widget and let you save the resulting UUID to your User model.

If you want to remove an image from the CDN when it’s no longer associated with a User, then you have a number of options. I suggest a before_save hook in the User model, like the following:

before_save :remove_orphaned_profile_photo

def remove_orphaned_profile_photo
  return unless profile_photo_id.changed?
  image = Image.new(uuid: profile_photo_id_was)
  image.destroy
end

So there you have it: with very little code you can get Uploadcare working in your app, and you don’t need a bunch of stale dependencies. And don’t get me wrong: faking bits of ActiveRecord functionality in order to provide a clean interface between your rails app and a REST API is a strategy that I heartily endorse, but only if you’re implementing the functionality yourself. Doing it yourself means that all of the knowledge that your app needs to talk to the API lives in your repo under your control, and any newcomer to the app can read that code and related specs and know everything they need to know about the interface.

Photo of Jon Stokes

Jon is a founder of Ars Technica, and a former Wired editor. When he’s not developing code for Collective Idea clients, he still keeps his foot in the content world via freelancing and the occasional op-ed.

Comments

  1. vizvamitra
    January 26, 2017 at 15:52 PM

    I’d like to read your article but something is wrong with it’s markup. Can I ask you to fix it?

  2. jason.roelofs@collectiveidea.com
    Jason Roelofs
    January 26, 2017 at 17:10 PM

    @vizvamitra Sorry about that, we’ve cleaned up the post and it should be much more readable again!