Engineering

How to use Active Storage for uploading files in Rails

Active storage for uploading files in rails

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 ActiveStorage::Blob and ActiveStorage::Attachment.

ActiveStorage::Blob 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.

ActiveStorage::Attachment 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 active_storage_attachments has a foreign key that references the id of active_storage_blobs.

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 has_many_attached :photos indicates that a single Post record can have multiple attached photos, each represented as an instance of ActiveStorage::Attachment.

Handling File Uploads Directly with ActiveRecord model

Given a Post record which already exists in the database, we can easily add or remove photos from it by calling post.photos.attach() and post.photos.purge(). 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 Post 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 UploadsController 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 PostsController 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:

64d1d5eb445c8af250969cb5 new-upload-form

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

64d1d62ecc43eb160dc0959a upload-form-with-user-data

After user clicking the Create Post button, the new Post is created:

64d1d696047cd4c6f3f694a8 upload-form-submitted-successfully

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 upload_controller.js in uploadFile():

// 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 /uploads endpoint, where the selected photos are sent as photos[] param.

In the Rails app, the above HTTP request is handled by create action in the UploadsController:

# 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 ActiveStorage::Blob.create_and_upload!().

The uploaded blobs are stored in the blobs array, which is passed into the app/views/posts/_photos_list.html.erb 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 upload_controller.js. The contents are appended into the filesContainer 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 Create Post 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 photo_signed_ids params. These are the hidden input fields which represent the uploaded blobs's signed id.

After creating the new post via @post.save, it calls attach_files(file_signed_ids) 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 upload_controller.js in removeFile():

// 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 /uploads endpoint, passing in the blob's signed id.

In the Rails app, the above HTTP request is handled by destroy action in the UploadsController:

# 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, blob.attachments.any? returns false, as the blob is not attached to any Post yet. It then calls blob.purge and remove the blob.

When the success response is received in upload_controller.js, 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:

64d1d79e0de142d026a0fc41 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, post.persisted? returns true. It then adds a data attribute data-upload-post-id-value="<%= post.id %>" 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 blobs = photos.map { |photo| photo.blob }. The blobs array is passed into the posts/photos_list 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 upload_controller.js in uploadFile():

// 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 formData.set("post_id", this.postIdValue).

In Rails, this request is then handled by UploadsController 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 post_id.present?. 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 post.photos.attach(blob.signed_id).

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 UploadsController in destroy action. Since the blob is now attached to a Post, blob.attachments.any? returns true. It calls blob.attachments.each { |attachment| attachment.purge }, 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 ActiveStorage::Blob, 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!

Need help building your product?

Reach out to us by filling out the form on our contact page. If you need an NDA, just let us know, and we’ll gladly provide one!

Top software development company Malaysia awards
Loading...