A Generic 'Image' Wrapper for Active Storage in Rails 7

I was recently faced with the problem of needing to store description text (to hydrate an alt="" attribute) for an Active Storage image attachment in a Rails 7 project, particularly in a has_one relationship case.

Let’s setup our example. It’s a model with an Active Storage attachment using Rails 7’s predefined variants:

class Article < ApplicationRecord
   # ...
   has_one_attached :hero_image do |image|
     image.variant :large, resize_to_fill: [3800, 950]
     image.variant :medium, resize_to_fill: [1900, 475]
     image.variant :thumb, resize_to_fill: [100, 100]
   end
   # ...
end

In this setup there’s no ‘second model’ or wrapper, the Article has an Active Storage attachment directly applied to it. This is sort of the default setup with Active Storage and yields instance APIs such as:

@article.hero_image.attached?
@article.hero_image.variant(:thumb)
<%= image_tag @article.hero_image %>
<%= image_tag @article.hero_image.variant :large %>

Which are super clean and nice, but what about storing extra data about an image? As noted above, the goal here is that we want to be able to declare, store, and render alt text on the html image element for a given image. George Claghorn, previously a member of the Rails core team, answered the question for how to accomplish “storing extra data on each image” on StackOverflow a few years ago for a has_many setup without pre-defined variants, but both of those constraints add challenges I need to overcome here.

At the end of the day, I want an API like this:

@article.hero_image.attached?
@article.hero_image.variant(:thumb)
@article.hero_image.alt_text
@article.hero_image.caption
# etc.
<%= image_tag @article.hero_image, alt: @article.hero_image.alt_text %>

Basically an API that retains the methods given by Active Storage when declaring an attachment… plus the data we store for a given image. Here’s how we can do it.

1. Create an Image model

We’re going to start with an Image model / migration. This isn’t an image specific to Article, it can work with / for any model in your project and will store extra data for all images. In this blog post we’re going to assume that the only extra fields we need on our images are alt_text and caption but you can add any other fields you need for your images in the migration below. Could be geographic data, could be the photographer info, etc.

class CreateImagesTable < ActiveRecord::Migration[7.0]
  def change
    create_table :images do |t|
      t.integer :imageable_id, null: false
      t.string :imageable_type, null: false
      t.string :name
      
      t.text :alt_text
      t.text :caption
      # any other attributes you want for your project's images

      t.timestamps
    end
  end
end
class Image < ApplicationRecord
  belongs_to :imageable, polymorphic: true

  delegate :persisted?, to: :imageable
  delegate :changed?, to: :imageable
  delegate_missing_to :file

  def file
    public_send name
  end

  def file=(attrs)
    file.attach attrs unless attrs.blank?
  end
end

This is going to get a little tricky but the idea here is that the Image model will ultimately be what has_one_attached, resulting in ActiveStorage polymorphically referencing the Image, while the image polymorphically references the root record (the Article, in our case), passing the pre-defined variants down to Active Storage along the way.

2. The Magic’s in the Concern ✨
module Imageable
  extend ActiveSupport::Concern

  class_methods do
    def has_image(method, **kwargs, &block)
      image_attachment = :"#{name.downcase}_#{method}"
      relation_method = :"#{method}_image"
      hidden_relation_method = :"_#{relation_method}"

      has_one hidden_relation_method, as: :imageable, class_name: :Image

      Image.has_one_attached image_attachment, **kwargs, &block

      class_eval <<-CODE, __FILE__, __LINE__ + 1
        def #{relation_method}
          #{hidden_relation_method} || build_#{hidden_relation_method}(name: :#{image_attachment})
        end

        def #{relation_method}=(attrs)
          #{relation_method}.update! attrs
        end
      CODE
    end
  end
end

If you’re just here for the code-and-go, you’re all set! With that model and concern in place, you can use Image as follows. If you want to understand how this works, continue reading after the following section.

3. Usage API

We just need to change two parts of Article.

class Article < ApplicationRecord
+  include Imageable
   # ...
-  has_one_attached :hero_image do |image|
+  has_image :hero do |image|
     image.variant :large, resize_to_fill: [3800, 950]
     image.variant :medium, resize_to_fill: [1900, 475]
     image.variant :thumb, resize_to_fill: [100, 100]
   end
   # ...
end

# NOTE: don't add _image to the key given to has_image, it will add it for you

Boom! Now our desired API works.

@article.hero_image.attached? #=> true
@article.hero_image.variant :thumb #=> <ActiveStorage::Attachment::Variant...etc>
@article.hero_image.alt_text #=> "My text here!"
<%= image_tag @article.hero_image.variant(:thumb), alt: @article.hero_image.alt_text %>
<p>
  <%= @article.hero_image.caption %>
</p>

Here’s what the form side would look like (views/articles/_form.html.erb):

<%= form.fields_for :hero_image, @article.hero_image do |hero_image_fields| %>
  <div>
   <%= hero_image_fields.label :preview %>
   <br>
   <%= image_tag(hero_image_fields.object.variant(:thumb)) if hero_image_fields.object.attached? %>
  </div>

  <div>
    <%= hero_image_fields.file_field :file, direct_upload: true %>
  </div>

  <div>
    <%= hero_image_fields.label :image_alt_text %><br>
    <%= hero_image_fields.text_field :alt_text, required: true %>
  </div>  

  <div>
    <%= hero_image_fields.label :image_caption %><br>
    <%= hero_image_fields.text_field :caption, required: true %>
  </div>  
<% end %>

(Direct upload not required, that’s a stock file_field helper method, but the file_field must specify the key :file)

And there you have it! Fully featured Active Storage APIs baked into a generic Image model polymorphically accessible from any other model with a has_image hook that propagates the predefined variants given. 🎉

4. How it works (Optional reading)

There’s quite a bit of careful, not-so-secret sauce going on here. Let’s walk through the concern first. Let’s also continue working under the example of an Article model that gives the :hero key to has_image.

At first we just setup some names dynamically based on the model consuming has_image and the key passed in:

module Imageable
  extend ActiveSupport::Concern

  class_methods do
    def has_image(method, **kwargs, &block)
>     image_attachment = :"#{name.downcase}_#{method}" #=> :article_hero
>     relation_method = :"#{method}_image" #=> :hero_image
>     hidden_relation_method = :"_#{relation_method}" #=> :_hero_image

      has_one hidden_relation_method, as: :imageable, class_name: :Image

      Image.has_one_attached image_attachment, **kwargs, &block

      class_eval <<-CODE, __FILE__, __LINE__ + 1
        def #{relation_method}
          #{hidden_relation_method} || build_#{hidden_relation_method}(name: :#{image_attachment})
        end

        def #{relation_method}=(attrs)
          #{relation_method}.update! attrs
        end
      CODE
    end
  end
end

Then we setup a ‘hidden’ one-to-one relation between the model (Article) and Image. We’re doing this as a ‘hidden’ relationship because we do need to setup a relationship between the two models, we just need to augment the interactions on both the ‘getter’ and ‘setter’ sides a little bit. This will make more sense further down.

module Imageable
  extend ActiveSupport::Concern

  class_methods do
    def has_image(method, **kwargs, &block)
      image_attachment = :"#{name.downcase}_#{method}"
      relation_method = :"#{method}_image"
      hidden_relation_method = :"_#{relation_method}"

>     has_one hidden_relation_method, as: :imageable, class_name: :Image

      Image.has_one_attached image_attachment, **kwargs, &block

      class_eval <<-CODE, __FILE__, __LINE__ + 1
        def #{relation_method}
          #{hidden_relation_method} || build_#{hidden_relation_method}(name: :#{image_attachment})
        end

        def #{relation_method}=(attrs)
          #{relation_method}.update! attrs
        end
      CODE
    end
  end
end

Next we dynamically declare a has_one_attached hook in Image directly from this concern. Neat, right? Note that we’re specifically naming the attachment on the Image side :article_hero and passing along the predefined variants block. It’s worth expanding on this one — we’re adding a new relation to the class Image with the name article_hero so that other images (on this same class or others) e.g. article_footer or user_profile_picture won’t override any other association on Image. Specifically, we don’t want any associations to override another associations pre-defined variants. That could happen if we weren’t specific to both the consuming class and the has_image key.

module Imageable
  extend ActiveSupport::Concern

  class_methods do
    def has_image(method, **kwargs, &block)
      image_attachment = :"#{name.downcase}_#{method}"
      relation_method = :"#{method}_image"
      hidden_relation_method = :"_#{relation_method}"

      has_one hidden_relation_method, as: :imageable, class_name: :Image

>     Image.has_one_attached image_attachment, **kwargs, &block

      class_eval <<-CODE, __FILE__, __LINE__ + 1
        def #{relation_method}
          #{hidden_relation_method} || build_#{hidden_relation_method}(name: :#{image_attachment})
        end

        def #{relation_method}=(attrs)
          #{relation_method}.update! attrs
        end
      CODE
    end
  end
end

And finally we setup a couple of methods on the consuming class to spoof the association using the dynamic names we defined above. The first essentially sets up @article.hero_image as a getter for the real association, using build_ if it doesn’t currently exist, while the second sets up the setter to save the data rather than purely set attributes (which mimics Active Storage):

module Imageable
  extend ActiveSupport::Concern

  class_methods do
    def has_image(method, **kwargs, &block)
      image_attachment = :"#{name.downcase}_#{method}"
      relation_method = :"#{method}_image"
      hidden_relation_method = :"_#{relation_method}"

      has_one hidden_relation_method, as: :imageable, class_name: :Image

      Image.has_one_attached image_attachment, **kwargs, &block

      class_eval <<-CODE, __FILE__, __LINE__ + 1
>       def #{relation_method}
>         #{hidden_relation_method} || build_#{hidden_relation_method}(name: :#{image_attachment})
>       end
>
>       def #{relation_method}=(attrs)
>         #{relation_method}.update! attrs
>       end
      CODE
    end
  end
end

And that’s generally the puzzle! There’s more happening here that you can explore if you’d like, but this recipe should give you the ability to add data to your images while implementing mostly the same API as direct Active Storage, including the pre-defined variants. I will likely configure a has_many version in the future but I’m pretty satisfied with this for now. Being able to use the has_image hook across all different models (and multiple times per model as needed) is just the right flexibility I need!

Join the Conversation

Latest Blog Posts