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