DEV Community

Cover image for Active Storage meets GraphQL Pt. 2: Exposing attachment URLs
Vladimir Dementyev for Evil Martians

Posted on • Originally published at evilmartians.com

Active Storage meets GraphQL Pt. 2: Exposing attachment URLs

In the previous post I shared some tips on adding Active Storage's direct uploads to Rails+GraphQL applications.

So, now we know how to upload files. What's next? Let's move to the next step: exposing attachments URLs via GraphQL API.

Seems like an easy task, right? Not exactly.

These are the challenges we faced when doing it in our own application:

  • Dealing with N+1 queries
  • Making it possible for clients to request a specific image variant.

N+1 problem: batch loading to the rescue

Let's first try to add the avatarUrl field to our User type in a naïve way:

module Types
  class User < GraphQL::Schema::Object
    field :id, ID, null: false
    field :name, String, null: false
    field :avatar_url, String, null: true

    def avatar_url
      # That's an official way for generating
      # Active Storage blobs URLs outside of controllers 😕
      Rails.application.routes.url_helpers
           .rails_blob_url(user.avatar)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Assume that we have an endpoint which returns all the users, e.g., { users { name avatarUrl } }. If you run this query in development and take a look at the Rails server logs in your console, you will see something like this:

D, [2019-04-15T22:46:45.916467 #2500] DEBUG -- :   User Load (0.9ms)  SELECT users".* FROM "users"
D, [2019-04-15T22:46:45.919362 #2500] DEBUG -- :   ActiveStorage::Attachment Load (0.9ms)  SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_type" = $1 AND "active_storage_attachments"."name" = $2 AND "active_storage_attachments"."record_id" = $3 [["record_type", "User"], ["name", "avatar"], ["record_id", 12]]
D, [2019-04-15T22:46:45.922420 #2500] DEBUG -- :   ActiveStorage::Blob Load (1.0ms)  SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = $1  [["id", 9]]
D, [2019-04-15T22:46:45.919362 #2500] DEBUG -- :   ActiveStorage::Attachment Load (0.9ms)  SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_type" = $1 AND "active_storage_attachments"."name" = $2 AND "active_storage_attachments"."record_id" = $3 [["record_type", "User"], ["name", "avatar"], ["record_id", 13]]
D, [2019-04-15T22:46:45.922420 #2500] DEBUG -- :   ActiveStorage::Blob Load (1.0ms)  SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = $1  [["id", 10]]
D, [2019-04-15T22:46:45.919362 #2500] DEBUG -- :   ActiveStorage::Attachment Load (0.9ms)  SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_type" = $1 AND "active_storage_attachments"."name" = $2 AND "active_storage_attachments"."record_id" = $3 [["record_type", "User"], ["name", "avatar"], ["record_id", 14]]
D, [2019-04-15T22:46:45.922420 #2500] DEBUG -- :   ActiveStorage::Blob Load (1.0ms)  SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = $1  [["id", 15]]
Enter fullscreen mode Exit fullscreen mode

For each user we load an ActiveStorage::Attachment and an ActiveStorage::Blob record: 2*N + 1 records (where N is the number of users).

We already discussed this problem in the "Rails 5.2: Active Storage and beyond" post, so, I'm not going to repeat the technical details here.

tl;dr For classic Rails apps we have a built-in scope for preloading attachments (e.g. User.with_attached_avatar) or can generate the scope ourselves knowing the way Active Storage names internal associations.

GraphQL makes preloading data a little bit trickier–we don't know beforehand which data is needed by the client and cannot just add with_attached_<smth> to every Active Record collection ('cause that would add an additional overhead when we don't need this data).

That's why classic preloading approaches (includes, eager_load, etc.) are not very helpful for building GraphQL APIs. Instead, most of the applications use the batch loading technique.

One of the ways to do that in a Ruby app is to add the graphql-batch gem by Shopify. It provides a core API for writing batch loaders with a Promise-like interface.

Although no batch loaders included into the gem by default, there is an association_loader example which we can use for our task (more precisely, we use this enhanced version which supports scopes and nested associations).

Let's use it to solve our N+1 issue:

def avatar_url
  AssociationLoader.for(
    object.class,
    # We should provide the same arguments as
    # the `preload` or `includes` call when do a classic preloading
    avatar_attachment: :blob
  ).load(object).then do |avatar|        
    next if avatar.nil?        
    Rails.application.routes.url_helpers.rails_blob_url(avatar)        
  end
end
Enter fullscreen mode Exit fullscreen mode

NOTE: the then method we use above is not a #yield_self alias, it's an API provided by the promise.rb gem.

The code looks a little bit overloaded, but it works and makes only 3 queries independently on the number of users. Keep on reading to see how we can transform this into a human-friendly API.

Dealing with variants

We want to leverage the power of GraphQL and allow clients to specify the desired image variants (e.g., thumbs, covers, etc.):

API example

From the code perspective we want to do the following:

user.avatar.variant(:thumb) # == user.avatar.variant(resize_to_fill: [64, 64])
Enter fullscreen mode Exit fullscreen mode

Unfortunately, Active Storage doesn't have a concept of variants (predefined, named transformations) yet. That will likely be included in Rails 6.x (where x > 0) when this PR (or its variation) gets merged.

We decided not to wait and implement this functionality ourselves: this small patch by @bibendi adds the ability to define named variants in a YAML file:

# config/transformations.yml

thumb:
  convert: jpg
  resize_to_fill: [64, 64]

medium:
  convert: jpg
  resize_to_fill: [200, 200]
Enter fullscreen mode Exit fullscreen mode

Since we have the same transformation settings for all the attachments in the app, this global configuration works for us well.

Now we need to integrate this functionality into our API.

First, we add an enum type to our schema representing a particular variant from the transformations.yml:

class ImageVariant < GraphQL::Schema::Enum
  description <<~DESC
    Image variant generated with libvips via the image_processing gem.
    Read more about options here https://github.com/janko/image_processing/blob/master/doc/vips.md#methods
  DESC

  ActiveStorage.transformations.each do |key, options|
    value key.to_s, options.map { |k, v| "#{k}: #{v}" }.join("\n"), value: key
  end
end
Enter fullscreen mode Exit fullscreen mode

Thanks to Ruby's metaprogramming nature we can define our type dynamically using the configuration object–our transfromations.yml and the ImageVariant enum will always be in sync!

ImageVariant type

Finally, let's update our field definition to support variants:

module Types
  class User < GraphQL::Schema::Object
    field :avatar_url, String, null: true do
      argument :variant, ImageVariant, required: false
    end

    def avatar_url(variant: nil)
      AssociationLoader.for(
        object.class,
        avatar_attachment: :blob
      ).load(object).then do |avatar|
        next if avatar.nil?
        avatar = avatar.variant(variant) if variant
        Rails.application.routes.url_helpers.url_for(avatar)
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Bonus: adding a field extension

Adding this amount of code every time we want to add an attachment url field to a type doesn't seem to be an elegant solution, does it?

While looking for a better option, I found a Field Extensions API for graphql-ruby. "Looks like exactly what I was looking for!", I thought.

Let me first show you the final field definition:

field :avatar_url, String, null: true, extensions: [ImageUrlField]
Enter fullscreen mode Exit fullscreen mode

That's it! No more argument-s and loaders. Adding the extension makes everything work the way we want!

And here is the annotated code for the extension:

class ImageUrlField < GraphQL::Schema::FieldExtension
  attr_reader :attachment_assoc

  def apply
    # Here we try to define the attachment name:
    #  - it could be set explicitly via extension options
    #  - or we imply that is the same as the field name w/o "_url"
    # suffix (e.g., "avatar_url" => "avatar") 
    attachment = options&.[](:attachment) ||
                  field.original_name.to_s.sub(/_url$/, "")

    # that's the name of the Active Record association
    @attachment_assoc = "#{attachment}_attachment"

    # Defining an argument for the field
    field.argument(
      :variant,
      ImageVariant,
      required: false
    )
  end

  # This method resolves (as it states) the field itself
  # (it's the same as defining a method within a type)
  def resolve(object:, arguments:, **rest)
    AssociationLoader.for(
      object.class,
      # that's where we use our association name
      attachment_assoc => :blob
    )
  end

  # This method is called if the result of the `resolve`
  # is a lazy value (e.g., a Promise – like in our case)
  def after_resolve(value:, arguments:, object:, **rest)
    return if value.nil?

    variant = arguments.fetch(:variant, :medium)
    value = value.variant(variant) if variant

    Rails.application.routes.url_helpers.url_for(value)
  end
end
Enter fullscreen mode Exit fullscreen mode

Happy graphQLing and active storing!


Read more dev articles on https://evilmartians.com/chronicles!

Top comments (4)

Collapse
 
such profile image
Adrien Montfort

Hey thanks a lot for this article! Very instructive.

I have an issue when I try using the FieldExtension. The class of the object I'm getting is the GraphqlObject Type but I'm expecting my ActiveRecord type. I've tried in a regular resolver and it works correctly. Any idea why by any chance?

Collapse
 
such profile image
Adrien Montfort

Apparently you need to use object.object to get the ActiveRecord object! (and object.object.class for its class).

Collapse
 
palkan_tula profile image
Vladimir Dementyev

Which version of graphql gem it is? Probably, that's the reason. The code I posted works fine for us in 1.9.4.

Thread Thread
 
such profile image
Adrien Montfort • Edited

I tried with 1.9.3 and 1.9.6...