Joseph Kain bio photo

Joseph Kain

Professional Software Engineer learning Elixir.

Twitter LinkedIn Github

Dear readers, I haven’t posted on Learning Elixir in almost a month. Please know that I’m working to get back onto a weekly posting schedule. I have an ongoing project using Elixir and Phoenix which is keeping mey busy, but it should also provide a number of interesting topics for this blog. Speaking of which…

In this project, I needed to upload images and store them on Amazon S3. The Arc library has great support for doing this.

When setting up my image support I had written an Image module that contained the Ecto Schema for my images. It also contained a few useful functions for working with those images.

defmodule MyApp.Image do
  use MyApp.Web, :model

  alias MyApp.Uploaders

  Schema "images" do
    field :name, :string
    field :upload, :any, virtual: true
    belongs_to :rental, MyApp.Rental

    timestamps
  end

  @required_fields ~w(name)
  @optional_fields ~w()

  def changeset(model, params \\ %{}) do
    model
    |> cast(params, ~w(upload), [])
    |> put_name
    |> cast(params, @required_fields, @optional_fields)
  end

  def put_name(changeset) do
    case changeset do
      %Ecto.Changeset{
        valid?: true,
        changes: %{upload: %Plug.Upload{content_type: "image/" <> _, filename: name}}
      } ->
        put_change(changeset, :name, name)
      _ ->
        changeset
    end
  end

  def store(%Plug.Upload{} = upload, image) do
    Uploaders.RentalImage.store({upload, image})
  end

  def url(image, version) do
    Uploaders.RentalImage.url({image.name, image}, version)
  end
end

This Image module has all the basic model stuff like the schema and changeset/2 function. It also includes functions store/2 and url/2 which can be used to store a new image to S3 and to retreive the url for a previously stored image, respectively. Both functions rely on Arc functionality through Uploaders.RentalImage. They also hide the Arc from users of the Image module. Those users only need to interact with the Image module and can ignore the Uploaders.RentalImage module altogether.

The Uploaders.RentalImage module follows the the Arc documentation, and configures the way we want to use Arc.

defmodule MyApp.Uploaders.RentalImage do
  use Arc.Definition

  @acl :public_read
  @versions [:original, :show, :thumb]

  @heights %{
    show: 315,
    thumb: 30
  }

  def validate({file, _}) do
    ~w(.jpg .jpeg .gif .png) |> Enum.member?(Path.extname(file.file_name))
  end

  def transform(:thumb, _file) do
    {:convert, "-thumbnail x#{@heights[:thumb]} -gravity center -format jpg"}
  end
  def transform(:show, _file) do
    {:convert, "-strip -resize x#{@heights[:show]} -gravity center -format png"}
  end

  def storage_dir(version, {_, image}) do
    "uploads/rentals/#{image.rental_id}/images/#{image.id}/#{version}"
  end

  def filename(_version, {file, _}) do
    Path.rootname(file.file_name)
  end
end

This module provides a number of functions that Arc expects to be able to call to determine how we want Arc to behave. For example, storage_dir/2 is called to determine the directory in which to store a given Image.

I’m also following Arc’s use case of having the S3 resource attached to my Image model. Hence the tuple format for the parameters to validate/1 and filename/2.

Confusing?

When I showed this to my coworker he was a little confused about the role of Uploaders.RentalImage. While he might be going against Arc’s perscription, I think he had a valid point: why does the uploader need to be a separate module?

When we started on a large refactoring of our application I decided to see what it would be like to combine Image and Uploaders.RentalImage in a single module.

My plan was to integrate all the code into a single module and then see what could be simplified or improved.

A Single Module with Model and Arc Uploader

I did as a planned and put everything together. I ended up with this:

defmodule MyApp.Rental.Image do
  use MyApp.Web, :model

  alias MyApp.Rental

  schema "images" do
    field :name, :string
    field :upload, :any, virtual: true
    belongs_to :rental, MyApp.Rental

    timestamps
  end

  @required_fields ~w(name)
  @optional_fields ~w()

  def changeset(model, params \\ %{}) do
    model
    |> cast(params, ~w(upload), [])
    |> put_name
    |> cast(params, @required_fields, @optional_fields)
  end

  defp put_name(changeset) do
    case changeset do
      %Ecto.Changeset{
        valid?: true,
        changes: %{
          upload: %Plug.Upload{content_type: "image/" <> _, filename: name}
        }
      } ->
        put_change(changeset, :name, name)
      _ ->
        changeset
    end
  end

  def store(%Plug.Upload{} = upload, image), do: store({upload, image})
  def url(%Rental.Image{} = image, version) do
    url({image.name, image}, version)
  end

  ########################################################################
  # The rest of this file is the Arc definition for uploading
  ########################################################################

  use Arc.Definition

  @acl :public_read
  @versions [:original, :slider, :card]
  @heights %{
    card: 230,
    slider: 358,
  }

  def validate({file, _}) do
    ~w(.jpg .jpeg .gif .png) |> Enum.member?(Path.extname(file.file_name))
  end

  def transform(:original, _file), do: :noaction
  def transform(:slider, _file) do
    {:convert, "-strip -resize 612x358 -extent 612x358 -background black -gravity center -format jpg", :jpg}
  end
  def transform(tag, _file) do
    {:convert, "-strip -resize x#{@heights[tag]} -gravity center -format jpg", :jpg}
  end

  def storage_dir(version, {_, image}) do
    "uploads/rentals/#{image.rental_id}/images/#{image.id}/#{version}"
  end

  def filename(_version, {file, _}), do: Path.rootname(file.file_name)
end

Note, that there are a few differences beyond just combining the two files. For example, I am now supporting a different set of image versions and transforms (:card and :slider instead of thumb and :show).

I drew a small separation between the parts using a comment. Should I do more here?

There wasn’t much in terms of simplification to do with this combination aside from being able to drop Uploaders.RentalImage from calls to store/1 and url/2.

I still maintained the custom store/2 and url/2 functions, wrappers around the Arc functions. I find their interfaces simpler than Arc’s tuple format.

Here’s an example of how I use the url/2 function from the view to put the :slider version of an image into an image slider:

<img src="<%= Image.url(image, :slider) %>"
     alt=""  data-bgposition="center bottom" data-bgfit="cover"
     data-bgrepeat="no-repeat" data-bgparallax="10"
     class="rev-slidebg" data-no-retina>

I like just being able to pass image instead of having to build a tuple like {image.name, image}. The tuple has redundnacy so I prefere to write out the tuple once within my url/2 function and avoid it in all other uses.

Conclusion

Overall I’m pretty happy with this. On one hand using a separate uploader module separates distinct concerns. But on the other hand the code involved isn’t that large and keeping it all in one module helps keep all my Image related code together in one place.

The Arc uploader concerns itself mostly with naming files and given that the model needs to be aware of some of these naming conventions in order to store the name in the database I think it makes sense to keep these things together.

If this functionality grows over time then splitting this into multiple modules might make sense. But I’m pretty happy with what I have right now.