twitter facebook github google-plus xing linkedin instagram
Technologie

Using ActiveStorage in Rails API-apps

Using ActiveStorage in Rails API-apps

Rails 5.2 introduced ActiveStorage as one of its main features. Using it from Rails views is easy and well-documented - but how can you use it in a Rails API-app?

In this short tutorial, you will learn how to use and test ActiveStorage in a Rails API-only app. While the integration of ActiveStorage into Rails is mainly very good, there are some pitfalls to consider.

We will create a simple user model, consisting of a username and an attached avatar, and write a controller to create a user and download its avatar. We will use the command-line tool curl and the testing framework RSpec to test this controller. In the progress, we will discover two shortcomings of ActiveStorage and will show how to circumvent them.

About ActiveStorage

Many Web-Apps provide the ability to upload, store, create and download files through RESTful services. Persisting these files is not that trivial, as neither the database nor the local filesystem of the server are a good place for storing them.

ActiveStorage solves that problem by letting you add files to your ActiveRecord models while handling the low-level plumbing for you. You can easily configure a cloud-store like e.g. Amazon S3 or Google Cloud Storage for your production server, while using local disk storage for your development and test environments.

Set up the Rails app

Use the Rails generator to create a new API only app. We are excluding test-unit, as we will include RSpec later on.

rails new active-storage-api --api --skip-test

Change to the app directory:

cd active-storage-api

ActiveStorage is included in Rails by default, but you need to run its installer to be able to use it. Run the installer and create and migrate your development database, which will also update your schema.rb:

rails active_storage:install
bundle exec rake db:create db:migrate

This will create two tables, active_storage_attachments and active_storage_blobs. The blobs-table remembers where a file is saved and information about that file, like its content type or file size. The attachments-table connects the blobs table with your domain models, e.g. the users-table that we will create later on. This system allows for one-to-one as well as many-to-many relations.

Generate the user model

Our example user model shall have two attributes, a username and an avatar. While the username is a regular database column, the avatar is an attachment managed by ActiveStorage. To create the model and a migration for the database, run the Rails generator and execute the new migration:

rails generate model User username:string
bundle exec rake db:migrate

Change the generated user model to the following:

# app/models/user.rb
class User < ApplicationRecord
  has_one_attached :avatar
  validates :username, presence: true
end

The two added lines of code will validate that the username is present, and enable attaching an avatar to a user record.

Implement the users controller

With the help of our API, we want to create a user, show information about it, and download its avatar. We will implement these controller actions one at a time - but first, we have to add the new routes:

# config/routes.rb
Rails.application.routes.draw do
  namespace :api, defaults: { format: :json } do
    resources :users, only: %i[create show] do
      get :avatar, on: :member
    end
  end
end

Notice that we specified JSON as the default format for all API-routes, which leads to a default content type of application/json instead of text/html. If you start a Rails server with rails server and navigate to http://localhost:3000/rails/info/routes, you can check the generated routes:

Helper                HTTP Verb  Path                             Controller#Action
avatar_api_user_path  GET        /api/users/:id/avatar(.:format)  api/users#avatar {:format=>:json}
api_users_path        POST       /api/users(.:format)             api/users#create {:format=>:json}
api_user_path         GET        /api/users/:id(.:format)         api/users#show {:format=>:json}

Implement the create action

Create the users controller with the following code:

# app/controllers/api/users_controller.rb
class Api::UsersController < ApplicationController
  def create
    user = User.new(create_params)

    if user.save
      render json: success_json(user), status: :created
    else
      render json: error_json(user), status: :unprocessable_entity
    end
  end

  private

  def create_params
    params.require(:user).permit(:username, :avatar)
  end

  def success_json(user)
    {
      user: {
        id: user.id,
        username: user.username
      }
    }
  end

  def error_json(user)
    { errors: user.errors.full_messages }
  end
end

The implementation is quite simple: We build a new user with the permitted create_params, and then try to save it. If the saving is successful, we return status 201 (created), as well as a JSON response body containing information about the user. If any errors occured, we return status 422 (unprocessable entity), as well as a JSON response body containing meaningful error descriptions. We enabled uploading and saving an avatar just by permitting the avatar parameter in the create_params.

Test the create action with curl

To test the create action, we first need an avatar image which we want to upload. Put a file named avatar.png in the root folder of your application (active-storage-api), and spin up Rails with rails server. We will use the command-line tool curl to test our implementation by issuing a multipart-form-data request:

curl --include --request POST http://localhost:3000/api/users --form "user[username]=Test User" --form "user[avatar]=@avatar.png"

Running this command should return a status of 201 and a JSON response body containing the ID and the username of the newly generated user. The uploaded avatar is stored in the /storage folder, which can be configured in config/storage.yml.

We also want to test the error case. We can do so by leaving out the username from the request, thus only uploading the avatar:

curl --include --request POST http://localhost:3000/api/users --form "user[avatar]=@avatar.png"

This should return a status of 422 as well as a JSON response body containing the error message Username can't be blank.

Implement and test the show action

The show action is pretty straight forward. Just add the following lines to your user controller, below the create action:

# app/controllers/api/users_controller.rb
def show
  user = User.find_by(id: params[:id])

  if user.present?
    render json: success_json(user), status: :ok
  else
    head :not_found
  end
end

If the user is found, this will return the same JSON response body as the create action did (though with a status 200), or else an empty response with a status 404 (not found). You can test the action by either visiting http://localhost:3000/api/users/:id in your browser, or using curl:

curl --include http://localhost:3000/api/users/:id

Make sure to replace the :id with the value from the response of our create-request, and also try specifying a non-existent id to check the error case.

Implement and test the avatar action

In the avatar action, we want to redirect to the stored file, but only if it exists:

# app/controllers/api/users_controller.rb
def avatar
  user = User.find_by(id: params[:id])

  if user&.avatar&.attached?
    redirect_to rails_blob_url(user.avatar)
  else
    head :not_found
  end
end

We check the existence of the avatar with user&.avatar&.attached?, using the safe navigation operator in case the user was not found. If either the user or the avatar does not exist, we will return a 404 (not found). Again, you can either test the action with your browser by visiting http://localhost:3000/api/users/:id/avatar, or using curl. We can first check if the resource was found or not by only requesting the headers with --head:

curl --head http://localhost:3000/api/users/:id/avatar

If we want to actually download the avatar, we have to follow redirects with --location and store the response to a file:

curl --location http://localhost:3000/api/users/:id/avatar > downloaded_avatar.png

After the request is finished, the downloaded_avatar.png should equal the original avatar.png that you previously uploaded.

Test your app with RSpec

Set up RSpec

We will use RSpec for testing our API. Add the following to your Gemfile:

group :development, :test do
  gem 'rspec-rails'
end

Run Bundler and the RSpec installer to setup RSpec:

bundle install
rails generate rspec:install

Add specs for a successful create action

We are going to show how to implement tests for our create action, as this will be the biggest challenge. Create request specs for the users controller with the following content:

# spec/requests/api/users_controller_spec.rb
require 'rails_helper'

RSpec.describe Api::UsersController do
  describe 'POST /api/users' do
    subject { post '/api/users', params: params }

    let(:params) { { user: { username: username, avatar: avatar } } }
    let(:username) { 'Test User' }
    let(:avatar) { fixture_file_upload('avatar.png') }

    context 'valid request' do
      it 'returns status created' do
        subject
        expect(response).to have_http_status :created
      end

      it 'returns a JSON response' do
        subject
        expect(JSON.parse(response.body)).to eql(
          'user' => {
            'id' => User.last.id,
            'username' => 'Test User'
          }
        )
      end

      it 'creates a user' do
        expect { subject }.to change { User.count }.from(0).to(1)
      end

      it 'creates a blob ' do
        expect { subject }.to change { ActiveStorage::Blob.count }.from(0).to(1)
      end
    end
  end
end

We are first defining our subject (make a POST request to /api/users) and the request parameters (username and avatar). The fixture_file_upload is a convenient helper which searches for a file with the given name in spec/fixtures/ and sets its filename and content type for the upload. Move your avatar.png to the spec/fixtures folder in order for the tests to be successful.

In the specs, we test for the desired response status and JSON response body. We also check that the created user and an ActiveStorage-blob get persisted to our database. Migrate the test-database and run the specs, which should all be successful:

RAILS_ENV=test bundle exec rake db:migrate
bundle exec rspec

Test an invalid request

We should also test the case in which the request is malformed, e.g. when the username-parameter is missing. Add the following inside the describe and below the other context-block:

# spec/requests/api/users_controller_spec.rb
context 'missing username' do
  let(:username) { nil }

  it 'returns status unprocessable entity' do
    subject
    expect(response).to have_http_status :unprocessable_entity
  end

  it 'returns a JSON response' do
    subject
    expect(JSON.parse(response.body)).to eql(
      'errors' => ['Username can\'t be blank']
    )
  end

  it 'does not create a user' do
    expect { subject }.not_to change { User.count }.from(0)
  end

  it 'does not create a blob' do
    expect { subject }.not_to change { ActiveStorage::Blob.count }.from(0)
  end
end

Again, we check for the response status and JSON response body, and this time that no records are inserted into our database. However, when running the specs again, we are presented with a surprise:

Failures:

  1) Api::UsersController POST /api/users missing username does not create a blob
    Failure/Error: expect { subject }.not_to change { ActiveStorage::Blob.count }.from(0)
      expected `ActiveStorage::Blob.count` not to have changed, but did change from 0 to 1

Even though no user was created, the uploaded file was stored and a database blob was created! This is due to a known bug which will be fixed in Rails 6.0. While still using Rails 5.2, we can fix this bug by manually deleting the uploaded avatar in the error case. Modify the user create action to the following:

# app/controllers/api/users_controller.rb
def create
  user = User.new(create_params)

  if user.save
    render json: success_json(user), status: :created
  else
    user.avatar.purge # TODO: Remove in Rails 6.0
    render json: error_json(user), status: :unprocessable_entity
  end
end

The user.avatar.purge will delete the avatar if it exists, and will also work if no avatar was uploaded at all. The tests should now all pass.

Require a user to upload an avatar

What if we want to make it mandatory that a user has an avatar? Let's implement this behaviour by first adding specs which test for the desired behaviour:

# spec/requests/api/users_controller_spec.rb
context 'missing avatar' do
  let(:avatar) { nil }

  it 'returns status unprocessable entity' do
    subject
    expect(response).to have_http_status :unprocessable_entity
  end

  it 'returns a JSON response' do
    subject
    expect(JSON.parse(response.body)).to eql(
      'errors' => ['Avatar can\'t be blank']
    )
  end

  it 'does not create a user' do
    expect { subject }.not_to change { User.count }.from(0)
  end

  it 'does not create a blob' do
    expect { subject }.not_to change { ActiveStorage::Blob.count }.from(0)
  end
end

Those look very similar to the ones checking for the missing username. Of those four new tests, only the last one will pass at first, since we do not upload an avatar. Let's make the others pass by changing our model to include a validation for the presence of the avatar:

# app/models/user.rb
class User < ApplicationRecord
  has_one_attached :avatar
  validates :username, presence: true
  validate :avatar_present?

  private

  def avatar_present?
    errors.add(:avatar, :blank) unless avatar.attached?
  end
end

The avatar_present?-validation will add an error similar to the one generated by the validates :username, presence: true. We can check that this works by running our specs again, which should now pass. Rails unfortunately does not yet offer built-in validators for attachments, which will hopefully change in future version.

Summary

We implemented a Rails API-app working together with ActiveStorage attachments, and tested its behaviour with curl, the browser, and RSpec. There were some minor nuisances which we had to workaround manually, which will however be treated in upcoming Rails versions. What we didn't cover is how to deploy your app and make it work with a cloud storage provider. This is however fairly easy and can be configured in the storage.yml config.

You are looking for the right partner for successful projects?
We'd love to help – let's get in touch!
Contact us