File upload in Rails has gotten easier with the introduction of ActiveStorage. The official guide is a great place to get started when building a vanilla Rails app, but what about integrating ActiveStorage with an Elm app consuming a Rails JSON API?
In this post we’ll go over one way to accomplish this with examples from Plantiful, the breakable toy I’ve been working on since my thoughtbot apprenticeship. Plantiful is an Elm SPA backed by a Rails API.
Setting up ActiveStorage
On the Rails side of things, ActiveStorage setup is quite straightforward. Its included in Rails 5 and 6. Your first step is to get the required tables into the database:
rails active_storage:install
This will generate a migration for you, CreateActiveStorageTables
. This
creates a few tables to connect files to their associated records. So run that
migration:
rails db:migrate
Excellent! Take a look at the ActiveStorage Guide to learn how to configure ActiveStorage for different file-storage solutions.
Now to set up the model. This part is easy: it’s just a single line. In Plantiful, I started with a single image, an avatar for the plant. The model looks something like this.
class Plant < ApplicationRecord
belongs_to :user
has_many :check_ins
has_one_attached :photo
end
That last line has_one_attached :photo
is where the magic happens. This line
gives us some new methods we can call on a Plant instance. The method we’re
interested in right now is for attaching a photo, which we’ll use in the
controller:
class Api::PlantsController < Api::BaseController
def photo
@plant = current_user.plants.find(params[:id])
@plant.photo.attach(plant_params[:photo])
render json: serialized_plant, status: :ok
end
private
def plant_params
params.require(:plant).permit(:name, :photo)
end
def serialized_plant
{
id: @plant.id
name: @plant.name,
photo: url_for(@plant.photo)
}
end
end
And, of course, the route for that endpoint:
namespace :api do
resources :plants do
post :photo, on: :member
end
end
All the above steps should give us what we need on the back-end, so let’s move on to the front-end.
File Upload with Elm
On the Elm side there are a couple of things we need to handle:
- Create an HTML tag on the page that triggers the file select dialog
- Create an HTTP request that sends the file to the server
To accomplish this, we need to handle a few different messages
- User clicks on file input
- User selects a file
- Elm client receives the server response
Let’s start with the HTML tag. On the page where we want to add the photo upload feature, there’s an image tag pointing to a plants avatar or a placeholder image. When the user clicks the image, we want the file select dialog to show.
We’ll add a click handler to the image tag. Here’s what that might look like:
Import File.Select as Select
type Msg
= UserSelectedNewPhotoUpload
| UserSelectedImage File.File
view : Model -> Html Msg
view model =
div []
[ img [ class "avatar"
, src model.plant.photoUrl
, onClick UserSelectedNewImageUpload
] []
]
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
UserSelectedNewImageUpload ->
(model, Select.file ["image/*"] UserSelectedImage)
UserSelectedImage _ ->
(model, Cmd.none)
When the user clicks the photo, the UserSelectedNewImageUpload
message fires
off. When we receive that message, we call Select.file
.
Select.file
needs two things: the type of file the user can select and a
message to send off once a file is selected. In our case, we’re accepting all
image types and we’ll call our message UserSelectedImage
. We need to handle
that new message, so let’s update our Msg
type and update
function:
type Msg
= UserSelectedNewPhotoUpload
| UserSelectedImage File.File
| ReceivedUploadResponse (Result Http.Error Plant)
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
UserSelectedNewPhotoUpload ->
(model, Select.file ["image/*"] UserSelectedImage)
UserSelectedImage image ->
(model, uploadImage image model.plant.id ReceivedUploadResponse)
ReceivedUploadResponse _ ->
(model, Cmd.none)
Once a user selects an image, we’ll receive the UserSelectedImage
message
along with the image that was selected. We’ll then pass those into uploadPhoto
to make HTTP request. Let’s see what that looks like:
uploadPhoto :
File.File
-> Int
-> (Result Http.Error Plant.Plant -> Msg)
-> Cmd Msg
uploadPhoto image plantId msg =
let
url = "/api/plants/" ++ String.fromInt plantId ++ "/photo"
in
Http.request
{ method = "POST"
, url = url
, body = Http.multipartBody [ Http.filePart "plant[photo]" file ]
, expect = Http.expectJson msg plantDecoder
, headers = []
, timeout = Nothing
, tracker = Nothing
}
So, in uploadPhoto
, we’re making a POST request to the endpoint URL we defined
in our routes in the first part. For a plant with ID 2, we’re POSTing to
/api/plants/2/photo
. Once we receive a response from the server, we’ll fire
off the message we passed in, ReceivedUploadResponse
and get the plant with
its updated photoUrl
.
We’ll also need a way to decode the JSON we receive back from the server. JSON decoding is a deep topic in Elm and is outside the scope of this blog post. If you’re interested in diving into it, check out the official guide and this great blog post by Joël.
The body
of the POST request is worth highlighting because it’s a bit
different than the JSON encoded request body you’ll often use in Elm. For our
use case with ActiveStorage, we don’t want to encode request as JSON. Instead,
we want to send a request whose content-type is multipart/form-data. We want to
set the name of the Http.filePart
in a way that matches up with Rails’
parameter conventions. If you’re curious about how that works, take a look here:
What Are Rails Parameters & How to Use Them Correctly.
In our case, we’re expecting parameters nested like so:
irb(main):002:0> plant_params
=> { plant: { photo: photo_file_here } }
And that’s exactly what “plant[photo]”
gives us. When Rails decodes the
request, the photo key will have an ActionDispatch::Http::UploadedFile
that
represents our image.
Great! So the final step here is handling ReceivedUploadResponse
. We’ll update
our Msg
type and update
function:
type Msg
= UserSelectedNewPhotoUpload
| UserSelectedImage
| ReceivedUploadResponse (Result Http.Error Plant)
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
UserSelectedNewPhotoUpload ->
(model, Select.file ["image/*"] UserSelectedImage)
UserSelectedImage image ->
(model, uploadImage image model.plant.id ReceivedUploadResponse)
ReceivedUploadResponse (OK plant) ->
( { model | plant = plant }, Cmd.none
ReceivedUploadResponse (Err _) ->
(model, Cmd.none)
That should do it! Users can now upload beautiful images of their plant to Plantiful. If you’re curious to learn more about Elm, take a look at the intro guide. For ActiveStorage, the official docs cover everything you need to get started.