A Generic 'Image' Wrapper for Active Storage in Rails 7
Jon Sully
6 Minutes
Staying DRY while storing descriptions in a not-model-specific way
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!
Comments? Thoughts?
Quentin Gaultier
great article ! Trying to make it work with has_many_attached now…
Jon Sully (Author)
Excellent! Let me know how it goes if you don’t mind 😁
Anonymous
Interested in has_many_attached
Peter Wd
Thanks, great article! Hi Quentin, were you able to get it working for has_many_attached?