〽️

Rails で GraphQL を使ってみる

2024/09/26に公開

概要

Ruby on Rails での GraphQL の導入方法、簡単な CRUD 操作、そもそも GraphQL とは?について書きました。RSpec まで書いてるので、これから GraphQL を使う人向けです。

※自分自身、初めて GraphQL に触れるので間違った解釈などあるかもしれません。そのときはご指摘いただけると嬉しいです。

環境

ruby 3.1.2
rails 7.0.8
graphiql-rails 1.10.1
graphql 2.3.16

準備

必要な gem をインストール

gem 'graphql'

必要なファイルを作成

rails generate graphql:install

作成と編集されたファイルは以下。

> rails generate graphql:install
[dotenv] Loaded .env
      create  app/graphql/types
      create  app/graphql/types/.keep
      create  app/graphql/myapp_schema.rb
      create  app/graphql/types/base_object.rb
      create  app/graphql/types/base_argument.rb
      create  app/graphql/types/base_field.rb
      create  app/graphql/types/base_enum.rb
      create  app/graphql/types/base_input_object.rb
      create  app/graphql/types/base_interface.rb
      create  app/graphql/types/base_scalar.rb
      create  app/graphql/types/base_union.rb
      create  app/graphql/resolvers/base_resolver.rb
      create  app/graphql/types/query_type.rb
add_root_type  query
      create  app/graphql/mutations
      create  app/graphql/mutations/.keep
      create  app/graphql/mutations/base_mutation.rb
      create  app/graphql/types/mutation_type.rb
add_root_type  mutation
      create  app/controllers/graphql_controller.rb
       route  post "/graphql", to: "graphql#execute"
Skipped graphiql, as this rails project is API only
  You may wish to use GraphiQL.app for development: https://github.com/skevy/graphiql-app
      create  app/graphql/types/node_type.rb
      insert  app/graphql/types/query_type.rb
      create  app/graphql/types/base_connection.rb
      create  app/graphql/types/base_edge.rb
      insert  app/graphql/types/base_object.rb
      insert  app/graphql/types/base_object.rb
      insert  app/graphql/types/base_union.rb
      insert  app/graphql/types/base_union.rb
      insert  app/graphql/types/base_interface.rb
      insert  app/graphql/types/base_interface.rb
      insert  app/graphql/myapp_schema.rb
      insert  config/application.rb

Query を実装

GraphQL の世界では、データの操作のことをオペレーション(Operation)と呼びます。その中でも、データの取得系の事を Query と呼びます。

一般的な「クエリ」は「問い合わせ」という意味なので、「Query」 と「クエリ」は分けて考えたほうが良いです。つまり、「Query の問い合わせ内容」のことを「Query のクエリ」と表現する事もあります。

クエリの型を作成

先ほど作成されてた app/graphql/types/query_type.rb に、この Query の型を定義していきます。
型というのは、「こんなリクエストをされたらこんな値を返しますよ」という決まり事のようなものです。今回は「記事一覧取得」と「記事詳細取得」の型を定義していきます。

module Types
  class QueryType < Types::BaseObject
    # 記事一覧の Query
    field :articles, resolver: Resolvers::Articles::GetArticles , null: false

    # 記事詳細取得の Query
    field :article, resolver: Resolvers::Articles::GetArticle, null: true
  end
end

field メソッド
GraphQL のフィールドを定義するために使用されます。このフィールドは、クライアントがデータをリクエストする際に指定するものです。

resolver オプション
クエリの実際の処理を Resolvers::Articles::GetArticles というリゾルバに委譲しています。リゾルバは、クエリを解決するためのロジックです。

null オプション
false のため、このフィールドは nullを返さない(必ず何かしらの結果を返す)ことを表しています。

個人的には REST のときのエンドポイントに近いなという印象でした。

リゾルバの実装

リゾルバは Query を解決するロジックを書くところ。
Resolvers::BaseResolverクラスを継承することにより、type, argument,resolve といったメソッドを使用できます。

# 記事一覧取得のリゾルバ
# app/graphql/resolvers/articles/get_articles.rb
module Resolvers
  module Articles
    class GetArticles < Resolvers::BaseResolver
      # 戻り値
      type [Types::ArticleType], null: false

      def resolve
        Article.all
      end
    end
  end
end

# 記事詳細取得のリゾルバ
# app/graphql/resolvers/articles/get_article.rb
module Resolvers
  module Articles
    class GetArticle < Resolvers::BaseResolver
      # 戻り値
      type Types::ArticleType, null: false

      # 引数
      argument :id, ID, required: true

      def resolve(id:)
        Article.find(id)
      end
    end
  end
end

type メソッド
リゾルバで返す値の型を指定している。今回の Types::ArticleType は以下のように別途定義しています。このように定義しておくことで、クライアント側から使用したいプロパティのみを指定して取得することができます。

# app/graphql/types/article_type.rb
module Types
  class ArticleType < Types::BaseObject
    # Article モデルのプロパティとその型を指定していく。
    field :id, ID, null: false
    field :title, String, null: false
    field :body, String, null: false
    field :is_published, Boolean, null: false
    field :custom_title, String, null: false

    # カスタムした値を定義することも可能。
    def custom_title
      "#{object.id}_#{object.title}"
    end
  end
end

記事一覧取得リゾルバで、

type [Types::ArticleType], null: false

となっていますが、これは Types::ArticleType を要素とするリスト型(配列)であるという意味です。また、null: falseとなっているので、レスポンスのこのフィールドは null にはならない事を示しています。GraphQL のスキーマ定義言語(SDL)で書くと、[Article!]!となります。

ちなみにリストの要素の null を許可する場合は [Article]!となり、Rails では以下のように書きます。

type [Types::ArticleType, null: true], null: false

argument メソッド
その名の通り引数とその型を定義しています。これを定義しておくことでクライアントから不正な値が渡されたときのチェックもしてくれるので便利です。

Mutation を実装

前述の Query に対し、GraphQL の世界ではデータの変更(作成、更新、削除)のことを Mutation と呼びます。Mutation でも「クエリの型を定義する」といった表現をするのでややこしいです。

クエリの型を定義

次は app/graphql/types/mutation_type.rbにミューテーションの型を定義していきます。
Query のときと同じく、Types::BaseObjectを継承しているので、Rails 的には Query と Mutation でクエリの定義は別ファイルにして欲しいようです。

# app/graphql/types/mutation_type.rb
module Types
  class MutationType < Types::BaseObject
    # 記事作成の Mutation
    field :create_article, Types::ArticleType, mutation: Mutations::Articles::CreateArticle, null: false

    # 記事更新の Mutation
    field :update_article, Types::ArticleType, mutation: Mutations::Articles::UpdateArticle, null: false

    # 記事削除の Mutation
    field :destroy_article, Types::ArticleType, mutation: Mutations::Articles::DestroyArticle, null: false
  end
end

Query のときと違うのは、resolverオプションではなく、mutationオプションで「リゾルバ」を指定していることです。mutation オプションを指定すると、Mutations::BaseMutationの機能を有している前提で処理されます。

データの変更をする場合はバリデーションなどといった、データの取得時にはしない複雑な処理が発生します。graphql-railsではそのような処理を分けるためにあえて、この2つのオプションでリゾルバを指定できるようになっていると考えられます。

GraphQL の世界では Query だろうと Mutation だろうとクエリを解決するロジックのことをどちらも「リゾルバ」と表現しているので、ここでも「リゾルバ」として進めます。

リゾルバの実装

データの取得時とルールは変わりません。

# 記事作成のリゾルバ
# app/graphql/mutations/articles/create_article.rb
module Mutations
  module Articles

    class CreateArticle < Mutations::BaseMutation
      # 戻り値の定義
      type Types::ArticleType

      # 引数の定義
      argument :title, String, required: true
      argument :body, String, required: true
      argument :is_published, Boolean, required: true

      def resolve(title:, body:, is_published:)
        Article.create!(title: title, body: body, is_published: is_published)
      end
    end
  end
end

# 記事更新のリゾルバ
# app/graphql/mutations/articles/update_article.rb
module Mutations
  module Articles

    class UpdateArticle < Mutations::BaseMutation
      # 戻り値の定義
      type Types::ArticleType

      # 引数の定義
      argument :id, ID, required: true
      argument :title, String, required: true
      argument :body, String, required: true
      argument :is_published, Boolean, required: true

      def resolve(id:, title:, body:, is_published:)
        article = Article.find(id)
        article.update!(title: title, body: body, is_published: is_published)
        article
      end
    end
  end
end

# 記事削除のリゾルバ
# app/graphql/mutations/articles/destroy_article.rb
module Mutations
  module Articles
    class DestroyArticle < Mutations::BaseMutation
      # 戻り値の定義
      type Types::ArticleType

      # 引数の定義
      argument :id, ID, required: true

      def resolve(id:)
        article = Article.find(id)
        article.destroy
        article
      end
    end
  end
end

RSpec

RSpec に関しては一般的な記述と変わりません。クエリの部分を直接指定して書いています。
今回は代表して記事詳細取得と記事作成のみ記載します。

# 記事詳細取得の RSpec
# spec/requests/graphql/articles/article_spec.rb
require 'rails_helper'

RSpec.describe 'Resolvers::Articles::GetArticle', type: :request do
  subject(:exec_get_article) { post '/graphql', params: params }

  let(:params) { { query: query } }
  let(:query) do
    <<-GRAPHQL
      query {#{' '}
        article(id: "#{article.id}"){
            id
            title
            body
            isPublished
          }
      }
    GRAPHQL
  end

  let!(:article) { create(:article) }

  context '正常系' do
    it 'レスポンスが正しいこと' do
      exec_get_article

      json = JSON.parse(response.body)
      target_data = json['data']['article']

      expect(target_data['id']).to eq(article.id.to_s)
      expect(target_data['title']).to eq(article.title)
      expect(target_data['body']).to eq(article.body)
      expect(target_data['isPublished']).to eq(article.is_published)
    end
  end
end

# 記事作成の RSpec
# spec/requests/graphql/articles/create_article_spec.rb
require 'rails_helper'

RSpec.describe 'Mutations::Articles::CreateArticle', type: :request do
  subject(:exec_create_article) { post '/graphql', params: params }

  let(:params) { { query: query } }
  let(:query) do
    <<-GRAPHQL
      mutation {#{' '}
        createArticle(input: { title: "#{title}", body: "#{body}", isPublished: #{is_published}}){
            id
            title
            body
            isPublished
          }
      }
    GRAPHQL
  end

  let(:title) { 'title' }
  let(:body) { 'body' }
  let(:is_published) { false }

  context '正常系' do

    it 'レスポンスが正しいこと' do
      exec_create_article

      article = Article.order(:created_at).last
      json = JSON.parse(response.body)
      target_data = json['data']['createArticle']
      expect(target_data['id']).to eq(article.id.to_s)
      expect(target_data['title']).to eq(title)
      expect(target_data['body']).to eq(body)
      expect(target_data['isPublished']).to eq(is_published)
    end

    it '記事が登録されること' do
      expect { exec_create_article }.to change(Article, :count).by(1)
    end
  end
end

テスト用のクライアントツールで動作確認する

graphiql というブラウザで利用できる IDE を使っていきます。rails では graphiql-railsという gem が提供されているので、そちらを使っていきます。
https://github.com/rmosolgo/graphiql-rails
Gemfile に以下を追加して bundle install

gem 'graphiql-rails'

API モードだったので表示用に sprocketsを追加し、bundle install

gem 'sprockets-rails' 

公式ドキュメントどおりに以下の設定を追加。

# config/application.rb
require "sprockets/railtie"

# app/assets/config/manifest.js
//= link graphiql/rails/application.css
//= link graphiql/rails/application.js

Session, Cookie の設定を有効にする必要があるので以下を追加。

# config/application.rb
config.middleware.use ActionDispatch::Cookies
config.middleware.use ActionDispatch::Session::CookieStore
config.api_only = false

routes.rb に以下を追加

if Rails.env.development?
  mount GraphiQL::Rails::Engine, at: "/graphiql", graphql_path: "/graphql"
end

/graphiql にアクセス。
以下のような画面が表示され、作成したスキーマの動作確認をすることができます。

まとめと感想

今回は Ruby on Rails での CURD 操作を実装してみました。
REST に比べると、フロントエンド側は使用したいフィールドのみを取得できるので、オーバーフェッチング、アンダーフェッチングにならず、API の呼び出し回数の削減に繋がりますね。

バックエンド側はクライアント側に特化した API を作らなくても良くなりますね。よく画面ごとに必要な API を作ることが多いですが、メンテナンス性も悪く、開発を続けると一貫性が無くなってきます。

まだ細かい部分の理解はできてないので、引き続きキャッチアップしていきます。

Discussion