Engineering
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.
Change to the app directory:
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:
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:
Change the generated user model to the following:
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:
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:
Implement the create action
Create the users controller with the following code:
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:
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:
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:
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:
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:
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:
If we want to actually download the avatar, we have to follow redirects with --location and store the response to a file:
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:
Run Bundler and the RSpec installer to setup RSpec:
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:
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:
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:
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:
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:
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:
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:
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.