You may, at some point, want to give your users the ability to upload files - images or videos or pdfs or some other file type. As we've seen, storing data for a record requires a column in the database and a matching form field. For example, if we want to save a user's name, we need a name
column in the users
table and a matching text_field "name"
in the user form.
Storing files, however, isn't as simple. We can't just store a file in the database - that's not what database software is built for and it would take up too much space. Instead, files get uploaded to a publicly available computer and the file's location on that computer is what we store in the database. Sending file data over the internet is a complicated bit of technology, but as with many aspects of our software development journey, we don't need to solve a problem that's been already solved - we just need to know when and how to apply the solution.
For the following examples, we'll use this repository - a final version of our Tacogram application.
ActiveStorage
File attachments are so common that the rails framework comes built with a library to handle it called ActiveStorage. If you're curious about the library, the code lives here in the rails codebase. There's also a thorough description of it in the Rails Guides here. However, we'll walk through all the steps below (spoiler alert, there aren't that many), so the documentation is only useful/relevant when you want to dig in or do something a little out of the ordinary.
Installation & Setup
The first step is to generate a few new tables that the library will need - just to reiterate, the files don't get stored in the database, but the location of the files do. Stop your server if it was running. Then, from the terminal prompt, run the following command:
rails active_storage:install
The output will show a migration file copied over from the library into your db/migrate
directory. The file name has the usual migration timestamp followed by _create_active_storage_tables.active_storage.rb
. This migration file will create a few tables, but, as with all migration files, they need to be executed to take effect. So the next step is:
rails db:migrate
Now if you check the schema, you'll see 3 new tables:
active_storage_attachments
active_storage_blobs
active_storage_variant_records
We won't really think much about these tables - the library will be responsible for using them - but it's good to know they exist.
Next, open the file config/storage.yml
. This is a file we provided, but it never really differs from this, so you can copy it into any future applications. A .yml
file is a list of key-value pairs that is most often used for application configuration details. Here we have a :local
storage configuration which will store files into the server's file system.
local:
service: Disk
root: <%= Rails.root.join("storage") %>
The last setup step is to use this storage configuration in each environment running our application. In the config/environments
directory, open the file development.rb
.
Development is the name of the "environment" used when we're building an application. It's our Gitpod workspace or your personal computer. It's where we "develop". Production is the name of the "environment" where our application lives on the internet so that users can use it. It's the "live" version of our application. This development.rb
file includes the configuration decisions for your application when it runs in the development environment.
At the bottom of the file, just before the closing end
, add this line of code to indicate that we'll use the :local
storage option in development.
config.active_storage.service = :local
Open up the production.rb
configuration file and add the same code just before the closing end
.
The ActiveStorage setup is done. Configuration files load once when your server starts, which is why we haven't started the server yet. You can start your rails server now and run the application. Next we need to use the library in our code.
File Attachments
In the Tacogram application, we've been storing a post's image as a url in the image
column of the posts
table. Instead, we want to upload an image file, so we need to augment the Post
model with the ability to do so (using the ActiveStorage library). Open up the models/post.rb
file and add this code:
class Post < ApplicationRecord
has_one_attached :uploaded_image
end
The has_one_attached
method is given to us by ActiveStorage as a way of adding this upload ability. We can use any name we want to reference the attachment (similar to the name of a column in the table) - uploaded_image
is descriptive, but name it whatever you want as long as it doesn't conflict with any column names in the posts
table (e.g. "image" is already used as a column name).
Now that we've given a post
the ability to store an uploaded_image
file, we need to modify the form so that a user can do so. In views/posts/new.html.erb
, first make sure the <form>
tag can support file uploading - update it with enctype="multipart/form-data"
so that it looks like this, if it doesn't already:
<form action="/posts" method="post" enctype="multipart/form-data">
Then, add a new label and input for the uploaded_image
:
<div class="mb-3">
<label for="uploaded_image_input" class="form-label">Upload Image</label>
<input type="file" name="uploaded_image" id="uploaded_image_input" class="form-control">
</div>
Note that the label and input name match the name we used in the model, uploaded_image
. Also note that the input type is not text
, but instead file
which tells the browser to display this form input differently. Refresh your browser to see the new input.
If you click on "Choose File", you'll be given the opportunity to browse and select a file on your device to upload. You may notice, however, that you can choose any file. For this application, we only want the user to upload images. We can narrow down the user's visible options by adding an option to the form field.
<div class="mb-3">
<label for="uploaded_image_input" class="form-label">Upload Image</label>
<input type="file" name="uploaded_image" id="uploaded_image_input" class="form-control" accept="image/png, image/jpg, image/jpeg, image/gif">
</div>
The accept
attribute has a string value with the file types the user can select. A clever user can modify the html (see the frontend CSS lesson) so this doesn't protect our application very well, but it does improve the user's experience so that they don't unintentionally make a mistake. Here we've accepted several image file types. If you're looking for others, read the MDN documentation or google for "list of mime types".
Now when you submit a new post and look at the server log, you'll see new params related to this uploaded_image
field. Our create
action in the posts controller isn't currently handling these params. Open up the controllers/posts_controller.rb
file so that we can modify the code. Instead of the code that assigns the url to the image
column, use this code to attach the file
@post.uploaded_image.attach(params["uploaded_image"])
The full create
action now looks like this:
def create
@user = User.find_by({ "id" => session["user_id"] })
if @user != nil
@post = Post.new
@post["body"] = params["body"]
@post.uploaded_image.attach(params["uploaded_image"])
@post["user_id"] = @user["id"]
@post.save
else
flash["notice"] = "Login first."
end
redirect_to "/posts"
end
The @post.uploaded_image.attach()
code is functionality we enabled when we added has_one_attached
to the Post
model. The argument we're passing into the parentheses is the params
data from the form which includes the file selected by the user.
Go back to the new post form and submit another post with an image file. You'll notice the index view of the posts does not show the uploaded image. That's because it's still trying to read the url from the image
column in the table. So this is the last bit of code we need to change.
Open the views/posts/index.html.erb
file. The code currently displaying images is an <img>
html element with an src
attribute value of the post's image
column.
<img src="<%= post["image"] %>" class="img-fluid">
That's the old functionality. We want to display images using the new functionality. We could just modify this code, however, that won't account for any existing data that was created using the old way. Instead, the code should handle data created the old way (aka "legacy data") and data created the new way. Sounds like conditional logic.
The condition we'll check for is if the post has an attached uploaded_image
:
<% if post.uploaded_image.attached? %>
The attached?
method will return true or false and is more functionality enabled by that has_one_attached
change we made to the model.
If it's attached, display it, otherwise, display the image
column.
<% if post.uploaded_image.attached? %>
...display the attached image file
<% else %>
<img src="<%= post["image"] %>" class="img-fluid">
<% end %>
It would be nice if it was as simple as post["uploaded_image"]
, but again remember that we're not reading a column from a table. Instead, we need to find where this file exists on the server's computer (or anywhere on the internet for that matter) and then use that url. The code is url_for(post.uploaded_image)
. And the final code looks like this:
<% if post.uploaded_image.attached? %>
<img src="<%= url_for(post.uploaded_image) %>" class="img-fluid">
<% else %>
<img src="<%= post["image"] %>" class="img-fluid">
<% end %>
Refresh the browser and you should now see the uploaded image file. If you inspect the html for each post, you'll notice that the src
value for the old posts are the urls submitted via the form, but the src
value for new posts are all uniform.
/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBCZz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--0bb9db65c8a23883995f028cdb4bea3a68fde563/your_image.jpeg
They're stored on the Gitpod server's machine within a subdirectory of our rails app.
Technical Debt
This conditional logic is what we call "technical debt" - code that solves a problem but should be cleaned up eventually. Since we're no longer supporting the feature of simple image urls for posts, it would be best to migrate the legacy data so that all records use the new behavior. This will help with maintainability over time and avoid fragility in our code. But modifying the legacy data is not a simple task and is not without risk. We could deal with it now, but that would block this new feature. So instead we decide to deal with it later, or "pay off that technical debt" later and use a simple fix for now.
Technical debt is a reality of any application as it evolves - it's not a bad thing, just a reality that we need to be aware of and make time for when possible.