Cameron L BothnerSoftware Developer

How to Use ActiveStorage Outside of a Rails View

The promise of Rails is that by taking care of the problems common to all web apps, it lets you focus on what makes your application unique. File upload is the newest addition to this list of “solved problems” that Rails handles out of the box. ActiveStorage, added in Rails 5.2, lets you handle file attachments backed by cloud storage with only three lines of code.

# /app/models/user.rb
has_one_attached :image

# /app/controllers/user_controller.rb
params.require(:user).permit(:image)

# /app/views/users/edit.html.erb
<%= f.file_field :image, direct_upload: true %>

That kind of simple solution to a complex — but common — problem is why Rails has achieved the success it has had. It’s ergonomics like has_one_attached that make it the perfect choice of server framework if what you need is Create, Read, Update, and Delete.

But some CRUD problems demand a more dynamic user interface than simple HTML forms that navigate to a different page on submit. Even in that case, Rails makes a great JSON API. But if you’re not using the Rails view layer, you can’t lean on the form builder method file_field to do the heavy lifting for you.

In this post, I’ll explain how to upload a file and attach it to a model object outside of a Rails view.

  1. I’ll start with the easiest case, if you’re okay proxying the upload through your Rails application.
  2. Then I’ll explain how to use the activestorage JavaScript package to directly upload the user’s file from their browser to your cloud storage provider.
  3. (I’ve got a special treat for React.js users: I made a package that makes it super simple.)
  4. And lastly, for those whose client apps aren’t browser-based, I’ll detail the individual requests involved in a direct upload and attachment so you can perform an upload with any networking library.

Uploading and Attaching in One Request, Without Direct Upload

If you’re comfortable letting your Rails application proxy the upload to your storage service, you can upload and attach the file all in one request. Just submit a request using the multipart/form-data content type.

In JavaScript, in the browser or in ReactNative, you can use fetch with FormData like this

let data = new FormData()
data.append('user[image]', fileObject)
fetch('/users/1', {
  method: 'PUT',
  body: data
}).then(...)

With Alamofire, an HTTP library for iOS apps, it looks like this

Alamofire.upload(
  multipartFormData: { data in data.append(imageUrl, withName: "user[image]") },
  to: 'https://example.com/users/1',
  encodingCompletion: completionHandler
)

Chances are no matter what platform you’re on, you’ve got a decent option.

Upload Directly to Your Storage Provider

However, you may not want to proxy the upload through your Rails app. Uploads are notoriously slow, and hosting providers like Heroku limit the amount of time your app can spend on one request. Especially if your users will be uploading 12 megapixel iPhone photos on a spotty cell connection, there’s a very real chance those uploads will hit Heroku’s 30 second limit.

In a Javascript environment, like a single page app backed by a Rails API, you can use the npm package activestorage to perform a direct upload.

import { DirectUpload } from "activestorage";

Initialize a DirectUpload object with a file and your API’s direct upload URL — unless you’ve customized the direct uploads controller, this will be /rails/active_storage/direct_uploads. When you initiate the upload by calling create, you can pass a node-style callback to handle success or errors, based on the presence of the first argument. The second argument will be the blob attributes, if the upload succeeds.

interface Blob {
  byte_size: number
  checksum: string
  content_type: string
  filename: string
  signed_id: string
}

const upload = new DirectUpload(file, directUploadUrl)
upload.create((error: Error | null, blob: Blob | undefined) => {
  ...
})

To show the progress of the upload, you can listen for the events emitted by the direct upload.

After directly uploading the file, you’ll need to take the signed ID from the returned blob attributes and use it to attach the file to your model. Include it in the request in place of the file, and Rails will know what to do.

fetch("/users/1", { method: "PUT", body: { image: blob.signed_id } });

I Extracted this Behavior into a React Component

Since my specific non-Rails view situation is a React application, I extracted this direct upload behavior into a “headless” React component. I’ve released it open source on npm as react-activestorage-provider, in case others find it useful. Even if you’re not using React, it might inspire your own abstraction.

The “headless” pattern for UI components is something I first heard described by Merrick Christensen and it’s a brilliant way to isolate behavior and make it reusable no matter your design requirements. Instead of rendering any UI, the component calls a function passed to it as props, and renders the return value of that function. From the perspective of the consumer of a headless component, you’re simply given handler methods, status variables, and sometimes spreadable prop objects that you can use in whatever components you need for your UI.

This is what it looks like in practice:

<ActiveStorageProvider
  // -- How to attach the uploaded blob to your model
  endpoint={{
    path: '/profile',
    model: 'User',
    attribute: 'avatar',
    method: 'PUT',
  }}

  // -- What to do with your app’s response to the attachment
  onSubmit={user => this.setState({ avatar: user.avatar })}

  // -- Render your own UI based on the upload state
  render={({ handleUpload, uploads, ready }) => (
    <div>
      <input
        type="file"
        disabled={!ready}
        onChange={e => handleUpload(e.currentTarget.files)}
      />

      {uploads.map(upload => {
        switch (upload.state) {
          case 'waiting':
            return <p key={upload.id}>Waiting to upload {upload.file.name}</p>
          case 'uploading':
            return (
              <p key={upload.id}>
                Uploading {upload.file.name}: {upload.progress}%
              </p>
            )
          case 'error':
            return (
              <p key={upload.id}>
                Error uploading {upload.file.name}: {upload.error}
              </p>
            )
          case 'finished':
            return <p key={upload.id}>Finished uploading {upload.file.name}</p>
        }
      })}
    </div>
  )}
/>

The component ActiveStorageProvider makes it simple to add a quick “upload” button by taking care of both uploading and attaching your file, but it shouldn’t stand in your way if you’re doing something more interesting. If you want to handle the second step, attaching your Blob record to your model, yourself, you can use the lower level DirectUploadProvider. It creates the blob records and uploads the user’s files directly to your storage service, then calls you back with the signed ids of those blobs.

ActiveStorageProvider is implemented in terms of DirectUploadProvider. In fact, the latter was extracted when I found myself needing to create a record with attached files in one atomic action. In general this more granular option will be instrumental in reducing unnecessary requests.

For more information, check out the readme or the code itself on GitHub.

When You Have to Do It All Yourself

But if you’re not writing a web app and you still want direct upload, none of this will help. The npm package activestorage and anything that depends on it won’t work in a non-JavaScript environment, of course. But it even won’t work on React Native because it relies on DOM APIs (specifically FileReader.readAsArrayBuffer) that are not implemented. You just need to manually make all the requests that activestorage does.

Get the signed upload URL

POST /rails/active_storage/direct_uploads HTTP/1.1
Content-Type: application/json

{
  "blob": {
    "filename": "griffin.jpeg",
    "content_type": "image/jpeg",
    "byte_size": 1020753,
    "checksum": base_64_of_md5_hash(fileObject)
  }
}

Rails will create a blob record in your database and respond with a signed id and the information you’ll need to upload the file to the correct storage service.

HTTP/1.1 200 OK
Content-Type: application/json

{
  "signed_id":"******",
  "direct_upload": {
    "url": "https://***.s3.us-east-2.amazonaws.com/******",
    "headers": { ... }
  },
  ...
}

Upload the file

PUT the file to response.direct_upload.url with the headers from response.direct_upload.headers. There’s no other body but the file as a string of bytes.

Update your Rails model with the signed_id of the blob you just made

PUT /users/1.json HTTP/1.1
Content-Type: application/json

{
  "user": {
    "image": response.signed_id
  }
}

To recap, there are three requests involved whenever you upload and attach a file with ActiveStorage.

  1. Create a blob and receive a signed upload URL.
  2. Upload the file to that signed URL.
  3. Update the Rails model to assign your signed url to the attachment relation.
© 2024 Cameron Bothner