🌟

graphql-rubyにファイルアップロード機能を実装する

2022/11/08に公開1

現在開発中のプロダクトのバックエンドはRails + graphql-rubyを使っています。
今回、ファイルアップロードが必要な機能を実装しようと思ったのですが、graphql-rubyにはファイルアップロード機能が実装されていません。
そこで、GraphQLにファイルアップロードを実装するための方法を検討&実装したので紹介します。

どのように実現するか?

案1: ファイルアップロードはREST APIにする

REST APIでファイルアップロードさせる方法については検討の余地もなくすでに確立されています。
ただ、今回のプロダクトはGraphQLで実装しているので、できれば全てのエンドポイントをGraphQLに集約したいと考えています。
GraphQLで実装する手段が見つからない or 手段は見つかったが工数がかかりすぎるなど微妙な場合にこの案を採用することにします。

案2: Base64にエンコードして文字列としてアップロードする

GraphQLのパラメーターにはファイルをそのまま渡せないので、GraphQLが認識できるようにBase64にエンコードして文字列として渡す手法があるようです。

下記の記事を参考にさせていただきました。

https://blog.spacemarket.com/code/graphql-image/

追加実装する必要なく、GraphQLの1項目として扱えるので便利に感じますが、上記の記事にも書いてある通りBase64にエンコードすることでファイルサイズが30%ほど増加してしまうようです。
アップロードされるファイルのサイズが限定される場合は気にしなくても良いのかもしれませんが、今回実装する機能ではPDFやパワポなど文書データやそれを束ねたZIPファイルが想定されるので30%の増加は避けたいと考えました。

案3: GraphQLでマルチパート形式のままファイルアップロードできるようにする

GraphQLもREST APIのようにマルチパート形式でファイルアップロードできればベストです。
調査した結果、それが実現できそうなgem"apollo_upload_server-ruby"があったので検証することにしました。

https://github.com/jetruby/apollo_upload_server-ruby

READMEの1行目に書いてありますが、バックエンドはgraphql-ruby、フロントエンドはapollo-upload-clientを使うことを想定したミドルウェアのようです。
バックエンドでは冒頭に書いた通りgraphql-rubyを使っており、フロントエンドはApollo Clientを使っています。まさにうちにフィットするgemです。
ということで、3つの案の中では案3を最初に検証して、導入が難しい場合に案2や案1を検討することにしました。
そして、検証の結果、案3を採用することにしたので、以降ではapollo_upload_server-rubyの導入について記載していきます。

apollo_upload_server-rubyの導入

ここからはapollo_upload_server-rubyの導入、動作確認の方法、RSpecの書き方を紹介します。

今回使うサンプルコード

具体例があったほうがわかりやすいと思うので下記のサンプルコードを使って説明します。
各種バージョンは下記の通り

  • Ruby: 3.0.1
  • Rails: 6.1.3.2
  • graphql-ruby: 1.12.12
  • apollo_upload_server-ruby: 2.0.5

ファイルを保持する仕組みはActive Storageを使います。
Active Storageの説明は本題からずれるため省きます。Railsガイドなどを参照してください。

https://railsguides.jp/active_storage_overview.html

今回の検証ではBookモデルに画像を追加する機能を作ってみます。
まずはモデルを作成します。

# app/models/book.rb
class Book < ApplicationRecord
  has_one :book_image, dependent: :destroy
end

# app/models/book_image.rb
class BookImage < ApplicationRecord
  belongs_to :book

  has_one_attached :file

  def name
    file.filename.to_s
  end

  def path
    return '' unless file.attached?

    Rails.application.routes.url_helpers.rails_storage_proxy_path(file, only_path: true)
  end

  def content_type
    return '' unless file.attached?

    file.content_type
  end
end

BookImageにはファイル参照時に欲しくなるファイル名やパス、コンテントタイプを取得するメソッドをついでに実装しておきました。
今回の本題から外れますが、私はActiveStorageを使ってファイル添付できるようにする場合、Bookなどファイル添付させたい対象のモデル自身にはhas_one_attachedを定義せず、ファイル添付専用のモデル(BookImage)を別途作るようにしています。
このようにすることでファイルに関するデータ(AltやTitle、複数ファイルある場合の並び順など)を保持したくなったときにBookのスキーマを汚すことなく管理できて役割が明確になります。

導入

apollo_upload_server-rubyを追加してファイルアップロード用のMutationを作ります。(gemの追加などはREADMEに記載されているので省略します)

# app/graphql/mutations/upload_book_image.rb
module Mutations
  class UploadBookImage < BaseMutation
    argument :book_id, Int, required: true
    argument :image, ApolloUploadServer::Upload, required: true

    type Types::BookImageType

    def resolve(book_id:, image:)
      book = Book.find book_id
      BookImage.transaction do
        book.create_book_image! unless book.book_image
        # ApolloUploadServer::Uploadをそのまま渡せないのでioとfilenameを渡す
        book.book_image.file.attach(io: image.to_io, filename: image.original_filename)
        book.book_image
      end
    end
  end
end

別途、mutation_typeへ追加したりBookImageTypeを実装したりする必要はありますが特別な実装は無いので省略します。ファイルアップロードに関連する実装はこれで完了です。
早速動作確認してみましょう。

動作確認の方法

graphql-rubyを使っている方はgraphiql-railsを使って動作検証している方が多いのではないでしょうか?
このプロダクト開発でもgraphiql-railsを導入しています。
ただ、graphiqlはファイルアップロードの検証ができません。
そこで別途ファイルアップロードの検証可能なツールを導入することにしました。
今回使ったのは"Altair GraphQL Client"です。Chromeの拡張機能もあるのでそれを使いました。

https://chrome.google.com/webstore/detail/altair-graphql-client/flnheeellpciglgpaodhkhmapeljopja

早速使ってみます。下記の画像に沿ってリクエストのやり方を説明します。

  1. GraphQLのエンドポイントを指定します。
  2. クエリーを指定します。
  3. Variablesを指定します。imageのところは""にします。
  4. ファイル項目(image)までのvariablesのJSON keyをドットでつなげた値を指定します。今回はinput.imageです。
  5. ファイルダイアログが表示されるので、添付ファイルを指定します。
  6. Send Request!!!!

これでリクエストが送られます。レスポンスが正常に返却されたのでデータを確認します。
今回はRailsコンソールで確認しました。
下記の通り、データが取得でき、ファイルが添付されていることも確認できました。

 % x api rails c
irb(main):001:0> book_image = BookImage.find 2
irb(main):002:0> book_image.file.attached?
=> true
irb(main):003:0> book_image.path
=> "/attachments/blobs/proxy/xxxxxxxxxxxxxxx/visits.png"
irb(main):004:0> book_image.name
=> "visits.png"
irb(main):005:0> book_image.content_type
=> "image/png"

上記の通り、ファイルアップロードを含むクエリーの場合、variablesの他にファイル項目までのルートを指定するなど、通常と書き方が異なるので注意が必要です。
リクエストの定義は下記に記載されているので詳細はご確認ください。

https://github.com/jaydenseric/graphql-multipart-request-spec/tree/v2.0.0-alpha.2

RSpecの書き方

手動では動作確認できたので、最後にテストコード(RSpec)を書きます。
GraphQLはリクエストスペックを書くようにしています。
動作確認のところにも記載した通り、リクエストパラメーターのフォーマットが通常と異なるのでひと手間必要でした。
下記が、正常系のテストを1つ定義したリクエストスペックです。

require 'rails_helper'

RSpec.describe Mutations::UploadBookImage, :request do
  describe 'uploadBookImage' do
    subject { post graphql_path, params: params }

    let(:params) do
      {
        operations: {
          query: query,
          variables: variables
        }.to_json,
        map: { '0' => [file_map] }.to_json,
        '0' => upload_file
      }
    end

    let(:query) do
      <<~"GQL"
        mutation UploadBookImage($input: UploadBookImageInput!) {
          uploadBookImage(input: $input) {
            path
            name
            contentType
          }
        }
      GQL
    end

    let(:variables) do
      {
        input: {
          bookId: book.id,
          image: ''
        }
      }
    end
    let(:book) { create :book }

    let(:file_map) { 'variables.input.image' }
    let(:upload_file) { fixture_file_upload(upload_file_name, content_type) }
    let(:upload_file_name) { 'book.png' }
    let(:content_type) { 'image/png' }

    it 'ファイルがアップロードされること' do
      subject
      # データの確認
      book_image = BookImage.find_by(book: book)
      expect(book_image.file.attached?).to be_truthy

      # レスポンスの確認
      response_body = JSON.parse(response.body)
      response_data = response_body.dig('data', 'uploadBookImage')
      expect(response_data['name']).to eq upload_file_name
      expect(response_data['contentType']).to eq content_type
      expect(response_data['path']).to start_with '/attachments/blobs/proxy/'
    end
  end
end

paramsのところがかなり特殊です。
通常のクエリーであれば下記のように書けるのですが、queryとvariablesはoperations配下に定義する必要があります。

let(:params) { { query: query, variables: variables.to_json } }

operationsと同列にmapを定義します。
こちらは動作検証で指定したファイル項目までのkeyをドットでつなげたものを指定すればよいのですが、Altairのときと違い先頭に"variables."が必要になります。
最後に'0'という項目です。こちらにアップロードするファイルを指定します。このkey名はアップロードするファイル数によって1ずつインクリメントしていきます。今回は1ファイルなので'0'固定です。
(動作検証のところにも掲載しましたが)リクエストの定義は下記に記載されているので詳細はご確認ください。

https://github.com/jaydenseric/graphql-multipart-request-spec/tree/v2.0.0-alpha.2

まとめ

今回はバックエンドだけの観点で記載しましたが、パラメーターが少し変わってしまうものの、ほとんど手間なくファイルアップロードができるようにできました。
GraphQLのファイルアップロードで迷っている方は是非apollo_upload_server-rubyを試してみてください。

Discussion