File Upload with Elm and ActiveStorage

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.