Engineering

How to use Active Storage for uploading files in Rails

Active storage for uploading files in rails

Table of Contents

Share this article

In this blog post, we will discuss how to work with file uploads in a Ruby on Rails application using Active Storage.

Active Storage provides a simple way to upload files to cloud services like Amazon S3, Google Cloud Storage, etc. ActiveRecord models don’t need to be modified with additional columns to associate with files. Active Storage represents files that are attached to ActiveRecord objects via [.inline-code]ActiveStorage::Blob[.inline-code] and [.inline-code]ActiveStorage::Attachment[.inline-code].

[.inline-code]ActiveStorage::Blob[.inline-code] is a model of the metadata of an uploaded file, like the filename, content-type, and the URL of the actual file in the cloud service. It doesn’t contain the binary data of the uploaded file.

[.inline-code]ActiveStorage::Attachment[.inline-code] is a join model that links the blob to the actual ActiveRecord model like a User, or a Post, etc.

In the database, these two models are represented by the following tables:

active_storage_blobs
active_storage_attachments

By inspecting the table, we can see that [.inline-code]active_storage_attachments[.inline-code] has a foreign key that references the id of [.inline-code]active_storage_blobs[.inline-code].

Active Storage Field Management in ActiveRecord Models

In my Rails application, I have set up my Post ActiveRecord model to include an Active Storage field named photos :

# app/models/post.rb

class Post < ApplicationRecord
  belongs_to :user
  has_many_attached :photos
end

The [.inline-code]has_many_attached :photos[.inline-code] indicates that a single Post record can have multiple attached photos, each represented as an instance of [.inline-code]ActiveStorage::Attachment[.inline-code].

Handling File Uploads Directly with ActiveRecord model

Given a [.inline-code]Post[.inline-code] record which already exists in the database, we can easily add or remove photos from it by calling [.inline-code]post.photos.attach()[.inline-code] and [.inline-code]post.photos.purge()[.inline-code]. This is quite straight forward, and so I would just skip elaborating about this.

Handling File Uploads with ActiveStorage::Blob

But what if we want to allow file upload before the [.inline-code]Post[.inline-code] record exists in the database? This is useful in a scenario where we want users to be able to upload files before the creation of a new Post. In the following sections, I would like to share my sample code which handles file uploads both before and after a Post creation.

Firstly, here’s the Stimulus.js controller, which handles the photos upload and removal via HTTP request:

// app/javascript/controllers/upload_controller.js

import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static values = { postId: Number };

  static targets = [
    "filesInput",
    "fileItem",
    "filesContainer",
  ];

  uploadFile(event) {
    event.preventDefault();

    const filesInput = this.filesInputTarget;
    let files = Array.from(filesInput.files);

    let formData = new FormData();

    if (this.hasPostIdValue) {
      formData.set("post_id", this.postIdValue);
    }

    files.forEach((file) => {
      formData.append("photos[]", file);
    });

    fetch("/uploads", {
      method: "POST",
      body: formData,
      headers: {
        "X-CSRF-Token": document
          .querySelector('meta[name="csrf-token"]')
          ?.getAttribute("content"),
      },
    })
      .then((response) => {
        return response.json();
      })
      .then((data) => {
        if (data.result === "success") {
          this.filesContainerTarget.innerHTML += data.html;
          filesInput.value = "";
        }
      });
  }

  removeFile(event) {
    event.preventDefault();

    const signedId = button.dataset.signedId;

    fetch(`/uploads/${signedId}`, {
      method: "DELETE",
      headers: {
        "X-CSRF-Token": document
          .querySelector('meta[name="csrf-token"]')
          ?.getAttribute("content"),
      },
    })
      .then((response) => {
        return response.json();
      })
      .then((data) => {
        if (data.result === "success") {
          const targetToRemove = this.fileItemTargets.find(
            (t) => t.dataset.signedId === signedId
          );
          if (targetToRemove) {
            targetToRemove.remove();
          }
        }
      })
  }
}

In the Rails app, here’s the [.inline-code]UploadsController[.inline-code] which handles the file upload and removal:

# app/controllers/uploads_controller.rb

class UploadsController < ApplicationController
  def create
    photos = params.require(:photos)
    post_id = params[:post_id]
    blobs = []

    photos.each do |photo|
      blob =
        ActiveStorage::Blob.create_and_upload!(
          io: photo,
          filename: photo.original_filename,
          content_type: photo.content_type,
        )

      if post_id.present?
        post = Post.find(post_id)
        post.photos.attach(blob.signed_id)
      end

      blobs.push(blob)
    end

    html_content =
      render_to_string(
        partial: "posts/photos_list",
        locals: {
          blobs: blobs,
          hidden_input: true
        },
      )

    render json: { result: "success", html: html_content }
  end

  def destroy
    # Fetch the blob using the signed id
    blob = ActiveStorage::Blob.find_signed(params[:id])
    if blob
      if blob.attachments.any?
        # the blob is attached to post record
        blob.attachments.each { |attachment| attachment.purge }
      else
        blob.purge
      end

      render json: { result: "success" }
    else
      # blob not found
      head :unprocessable_entity
    end
  end

  private
end

Here’s the [.inline-code]PostsController[.inline-code] which handle the CRUD of the Post model:

class PostsController < ApplicationController
  before_action :authenticate_user!
  before_action :set_post, only: %i[ show edit update destroy ]

  # GET /posts or /posts.json
  def index
    @posts = current_user.posts.all
  end

  # GET /posts/1 or /posts/1.json
  def show
  end

  # GET /posts/new
  def new
    @post = current_user.posts.new
  end

  # GET /posts/1/edit
  def edit
  end

  # POST /posts or /posts.json
  def create
    file_signed_ids = params[:post].delete(:photo_signed_ids)

    @post = current_user.posts.new(post_params)

    respond_to do |format|
      if @post.save
        # for photos uploaded, attach the blob's signed_id to post record
        attach_files(file_signed_ids)

        format.html { redirect_to post_url(@post), notice: "Post was successfully created." }
        format.json { render :show, status: :created, location: @post }
      else
        format.html { render :new, status: :unprocessable_entity }
        format.json { render json: @post.errors, status: :unprocessable_entity }
      end
    end
  end

  # PATCH/PUT /posts/1 or /posts/1.json
  def update
    respond_to do |format|
      if @post.update(post_params)
        format.html { redirect_to post_url(@post), notice: "Post was successfully updated." }
        format.json { render :show, status: :ok, location: @post }
      else
        format.html { render :edit, status: :unprocessable_entity }
        format.json { render json: @post.errors, status: :unprocessable_entity }
      end
    end
  end

  # DELETE /posts/1 or /posts/1.json
  def destroy
    @post.destroy

    respond_to do |format|
      format.html { redirect_to posts_url, notice: "Post was successfully destroyed." }
      format.json { head :no_content }
    end
  end

  private
    # Use callbacks to share common setup or constraints between actions.
    def set_post
      @post = current_user.posts.find(params[:id])
    end

    # Only allow a list of trusted parameters through.
    def post_params
      # params.fetch(:post, {})

      params.require(:post).permit(:message, photos: [])
    end

    def attach_files(file_signed_ids)
      if file_signed_ids.present?
        file_signed_ids.each do |signed_id|
          @post.photos.attach(signed_id)
        end
      end
    end
end

Here’s the erb templates used for the form UI:

# app/views/posts/_form.html.erb

<%= form_with(model: post, class: "contents") do |form| %>
  <div class="mt-10">
    <%= form.label :message %>
    <%= form.text_field :message %>
  </div>

  <div
    class="my-10"
    data-controller="upload"
    <% if post.persisted? %>
      data-upload-post-id-value="<%= post.id %>"
    <% end %>
  >
    <div>
      <%= form.label :photos %>

      <div data-upload-target="filesContainer">
        <%
          photos = post.persisted? ? post.photos : []
          blobs = photos.map { |photo| photo.blob }
        %>

        <% if blobs.present? %>
          <%= render partial: "posts/photos_list",
          locals: {
            blobs: blobs,
            hidden_input: false
          } %>
        <% end %>
      </div>
    </div>

    <div class="w-full block">
      <%= file_field_tag "photos[]",
      multiple: true,
      id: "photos",
      data: {
        upload_target: "filesInput"
      } %>

      <button
        class="btn btn-primary py-1 px-2"
        data-action="click->upload#uploadFile"
      >
        <span class="button-span flex items-center justify-center">
          upload
        </span>
      </button>
    </div>
  </div>

  <div class="inline">
    <%= form.submit class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
  </div>
<% end %>


# app/views/posts/_photos_list.html.erb

<% blobs.each do |blob| %>
  <% signed_id = blob.signed_id
  display_name = blob.filename.to_s
  blob_path = rails_blob_path(blob, disposition: "attachment") %>

  <div
    id="<%= display_name %>"
    class="my-2"
    data-signed-id="<%= signed_id %>"
    data-upload-target="fileItem"
  >
    <%= link_to display_name,
    blob_path,
    title: display_name,
    class: "no-underline hover:underline" %>

    <% if hidden_input %>
      <input
        type="hidden"
        name="post[photo_signed_ids][]"
        value="<%= signed_id %>"
      >
    <% end %>

    <button
      id="remove-button"
      class="btn btn-primary py-1 px-2 ml-5"
      data-action="click->upload#removeFile"
      data-signed-id="<%= signed_id %>"
    >
      <span class="button-span w-5 h-5 flex items-center justify-center">X</span>
    </button>
  </div>
<% end %>

Handling Photos Upload and Removal before the creation of a new Post

In the new Post creation form, user is able to upload and remove photos. Here’s some screenshots of the new Post creation form:

When the form is freshly loaded:

New post form

When user has filled in the message and uploaded some photos:

New post form with images uploaded

After user clicking the [.inline-code]Create Post[.inline-code] button, the new Post is created:

New post created

Uploading photos before the creation of a new Post

Let’s start with handling files upload.

Before creating the Post, user can select multiple photos for upload. When the upload button is clicked, this action is handled by [.inline-code]upload_controller.js[.inline-code] in [.inline-code]uploadFile()[.inline-code]:

// app/javascript/controllers/upload_controller.js

export default class extends Controller {
  ...

  uploadFile(event) {
    ...
    const filesInput = this.filesInputTarget;
    let files = Array.from(filesInput.files);

    let formData = new FormData();
    ...

    files.forEach((file) => {
      formData.append("photos[]", file);
    });

    fetch("/uploads", {
      method: "POST",
      body: formData,
      ...
    })
      .then((response) => {
        return response.json();
      })
      .then((data) => {
        if (data.result === "success") {
          this.filesContainerTarget.innerHTML += data.html;
          filesInput.value = "";
        }
      });
  }

It makes a HTTP POST request to the [.inline-code]/uploads[.inline-code] endpoint, where the selected photos are sent as [.inline-code]photos[][.inline-code] param.

In the Rails app, the above HTTP request is handled by create action in the [.inline-code]UploadsController[.inline-code]:

# app/controllers/uploads_controller.rb

class UploadsController < ApplicationController
  def create
    photos = params.require(:photos)
    ...
    blobs = []

    photos.each do |photo|
      blob =
        ActiveStorage::Blob.create_and_upload!(
          io: photo,
          filename: photo.original_filename,
          content_type: photo.content_type,
        )
      ...

      blobs.push(blob)
    end
        
    html_content =
      render_to_string(
        partial: "posts/photos_list",
        locals: {
          blobs: blobs,
          hidden_input: true
        },
      )

    render json: { result: "success", html: html_content }
  end
  
  ...
end

It gets an array of photos from the photos param, and uploads each of them via [.inline-code]ActiveStorage::Blob.create_and_upload!()[.inline-code].

The uploaded blobs are stored in the blobs array, which is passed into the [.inline-code]app/views/posts/_photos_list.html.erb[.inline-code] partial. For each of the blob, the partial produces a html snippet similar to the below:

<div ... data-signed-id="eyJfcmFpb..." data-upload-target="fileItem">
    <a title="cat-2.jpg" ... href="/rails/active_storage/blobs/redirect/eyJfcmFpb.../cat-2.jpg?disposition=attachment">cat-2.jpg</a>
    <input type="hidden" name="post[photo_signed_ids][]" value="eyJfcmFpb...">
    <button ... data-action="click->upload#removeFile" data-signed-id="eyJfcmFpb...">
      <span ...>X</span>
    </button>
  </div>

These html contents are returned to the caller, which is [.inline-code]upload_controller.js[.inline-code]. The contents are appended into the [.inline-code]filesContainer[.inline-code] section of the form. So in the form, we can see that these UI elements are shown for each of the uploaded file:

  • The file name with a downloadable link
  • A X remove button, which allows user to remove the photo
  • The photo’s signed id as a hidden input field

Upon clicking the [.inline-code]Create Post[.inline-code] button, the Post creation is handled here:

class PostsController < ApplicationController
  ...

  def create
    file_signed_ids = params[:post].delete(:photo_signed_ids)

    @post = current_user.posts.new(post_params)

    respond_to do |format|
      if @post.save
        # for photos uploaded, attach the blob's signed_id to post record
        attach_files(file_signed_ids)

        format.html { redirect_to post_url(@post), notice: "Post was successfully created." }
        ...
      else
        format.html { render :new, status: :unprocessable_entity }
        ...
        end
    end
  end

  private
    ...

    def attach_files(file_signed_ids)
      if file_signed_ids.present?
        file_signed_ids.each do |signed_id|
          @post.photos.attach(signed_id)
        end
      end
    end
end

It gets an array of blob’s signed ids from the [.inline-code]photo_signed_ids[.inline-code] params. These are the hidden input fields which represent the uploaded blobs’s signed id.

After creating the new post via [.inline-code]@post.save[.inline-code], it calls [.inline-code]attach_files(file_signed_ids)[.inline-code] to attach each of the signed ids to the Post.

So we have covered how to support photos upload before a Post creation, and then attach the uploaded blobs to the Post after the model creation.

Removing photos before the creation of a new Post

Next let’s dwell into the file removal.

In the form, user is able to click on the X button to remove a particular photo they have uploaded. This action is handled by [.inline-code]upload_controller.js[.inline-code] in [.inline-code]removeFile()[.inline-code]:

// app/javascript/controllers/upload_controller.js

export default class extends Controller {
  ...

  removeFile(event) {
    ...
    const signedId = button.dataset.signedId;

    fetch(`/uploads/${signedId}`, {
      method: "DELETE",
      headers: {
        "X-CSRF-Token": document
          .querySelector('meta[name="csrf-token"]')
          ?.getAttribute("content"),
      },
    })
      .then((response) => {
        return response.json();
      })
      .then((data) => {
        if (data.result === "success") {
          const targetToRemove = this.fileItemTargets.find(
            (t) => t.dataset.signedId === signedId
          );
          if (targetToRemove) {
            targetToRemove.remove();
          }
        }
      })
  }
}

It makes a HTTP DELETE request to the [.inline-code]/uploads[.inline-code] endpoint, passing in the blob’s signed id.

In the Rails app, the above HTTP request is handled by destroy action in the [.inline-code]UploadsController[.inline-code]:

# app/controllers/uploads_controller.rb

class UploadsController < ApplicationController
  ...

  def destroy
    # Fetch the blob using the signed id
    blob = ActiveStorage::Blob.find_signed(params[:id])
    if blob
      if blob.attachments.any?
        # the blob is attached to post record
        blob.attachments.each { |attachment| attachment.purge }
      else
        blob.purge
      end

      render json: { result: "success" }
    else
      # blob not found
      head :unprocessable_entity
    end
  end

  private
end

In this context, [.inline-code]blob.attachments.any?[.inline-code] returns false, as the blob is not attached to any Post yet. It then calls [.inline-code]blob.purge[.inline-code] and remove the blob.

When the success response is received in [.inline-code]upload_controller.js[.inline-code], it removes the photo’s html element from the form UI:

if (data.result === "success") {
    const targetToRemove = this.fileItemTargets.find(
      (t) => t.dataset.signedId === signedId
    );
    if (targetToRemove) {
      targetToRemove.remove();
    }
  }

Handling Photos Upload and Removal for existing Post

For existing Post, in the edit form, user is also able to upload and remove photos. Here’s a screenshot of the edit Post form:

Edit post form

Uploading photos for existing Post

To support this scenario, we would need to make some modification to the file upload mechanism described above. In the erb template for the form UI:

# app/views/posts/_form.html.erb

<%= form_with(model: post, class: "contents") do |form| %>
  ...

  <div
    ...
    data-controller="upload"

    <% if post.persisted? %>
      data-upload-post-id-value="<%= post.id %>"
    <% end %>
  >
    <div>
      <%= form.label :photos %>

      <div data-upload-target="filesContainer">
        <%
          photos = post.persisted? ? post.photos : []
          blobs = photos.map { |photo| photo.blob }
        %>

        <% if blobs.present? %>
          <%= render partial: "posts/photos_list",
          locals: {
            blobs: blobs,
            hidden_input: false
          } %>
        <% end %>
      </div>
    </div>

    ...
  </div>

  <div ...>
    <%= form.submit ... %>
  </div>
<% end %>

For existing Post, [.inline-code]post.persisted?[.inline-code] returns true. It then adds a data attribute [.inline-code]data-upload-post-id-value="<%= post.id %>"[.inline-code] to the form, which represents the Post record’s id.

The div filesContainer is used to list the existing photos of a Post. In here, it gets the photos list by calling post.photos and turns that into a blobs array by calling [.inline-code]blobs = photos.map { |photo| photo.blob }[.inline-code]. The blobs array is passed into the [.inline-code]posts/photos_list[.inline-code] partial to list each of the photo in the form UI.

In the edit Post form, when user adds more photos and click on the upload button, the action is again handled by [.inline-code]upload_controller.js[.inline-code] in [.inline-code]uploadFile()[.inline-code]:

// app/javascript/controllers/upload_controller.js

export default class extends Controller {
  static values = { postId: Number };
  ...

  uploadFile(event) {
    ...
    let formData = new FormData();

    if (this.hasPostIdValue) {
      formData.set("post_id", this.postIdValue);
    }

    files.forEach((file) => {
      formData.append("photos[]", file);
    });

    fetch("/uploads", {
      ...
    })
    ...
  }

  ...
}

Now the Post’s id is set in the formData via [.inline-code]formData.set("post_id", this.postIdValue)[.inline-code].

In Rails, this request is then handled by [.inline-code]UploadsController[.inline-code] in create:

# app/controllers/uploads_controller.rb

class UploadsController < ApplicationController
  def create
    ...
    post_id = params[:post_id]
    ...

    photos.each do |photo|
      blob = ActiveStorage::Blob.create_and_upload!(...)

      if post_id.present?
        post = Post.find(post_id)
        post.photos.attach(blob.signed_id)
      end

      ...
    end

    ...
  end

  ...
end

After the photo is uploaded as blob, it checks whether [.inline-code]post_id.present?[.inline-code]. This returns true now, because for an existing Post, the Post’s id is set as a data attribute in the previous erb template. It then find the Post by id and attach the blob to it by calling [.inline-code]post.photos.attach(blob.signed_id)[.inline-code].

So now the photo blobs are successfully uploaded and attached to the Post.

Removing photos for existing Post

Next we would look into how to handle photos removal for an existing Post.

# app/controllers/uploads_controller.rb

class UploadsController < ApplicationController
  ...

  def destroy
    # Fetch the blob using the signed id
    blob = ActiveStorage::Blob.find_signed(params[:id])

    if blob
      if blob.attachments.any?
        # the blob is attached to post record
        blob.attachments.each { |attachment| attachment.purge }
      else
        ...
      end

      render json: { result: "success" }
    else
      # blob not found
      head :unprocessable_entity
    end
  end

  ...
end

The HTTP delete request is handled by [.inline-code]UploadsController[.inline-code] in [.inline-code]destroy[.inline-code] action. Since the blob is now attached to a Post, [.inline-code]blob.attachments.any?[.inline-code] returns true. It calls [.inline-code]blob.attachments.each { |attachment| attachment.purge }[.inline-code], which removes the attachment from the Post, and then the blob itself is purged automatically.

So now the photo is successfully removed from the Post and also deleted from the cloud service.

Conclusion

In conclusion, we have looked into how to handle files upload and removal via [.inline-code]ActiveStorage::Blob[.inline-code], and it covers scenarios both before and after a model creation. I hope this walkthrough has been helpful for understanding the ins and outs of file uploads with Active Storage. Happy coding!

Your vision deserves a great dev team.

We're not about tech jargon or over-promising. Instead, we focus on clear communication, transparency in our process, and delivering results that speak for themselves.

Awards won by Hivekind