💎

Rails + GraphQLでAPI作成

2021/01/05に公開

各バージョン

ruby: 2.7.1
rails: 6.0.3.4
graphql-ruby: 1.11.6

GraphQL Ruby

公式ページ

RailsでGraphQLを扱う場合↑のgemを使ってAPIを実装していきます。

graphiql-rails

合わせて graphiql-rails gemを入れておくとブラウザ上で実装したGraphQLの
確認ができるIDEが使えるようになります :sparkles:
graphql-rubyのinstall時に graphiql-rails のgemをGemfileに追加してくれます

イメージ画像

環境構築

Gemfile
gem 'graphql'
gem 'graphiql-rails' # 今回は先に入れました

gemがインストールされたら rails generate graphql:install コマンドを実行し各ファイルを生成します。
生成されたファイルは以下の通り↓

$ rails generate graphql:install
      create  app/graphql/types
      create  app/graphql/types/.keep
      create  app/graphql/app_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/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"
     gemfile  graphiql-rails
       route  graphiql-rails

この時点での routes.rb は以下のようになっています。

Rails.application.routes.draw do

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

  post '/graphql', to: 'graphql#execute'
end

実装

Query 作成

まずは各テーブルに対応するTypeを定義しないといけないので、
例として以下の users テーブルに対応する user_type を作成してみたいと思います。

create_table :users do |t|
  t.string :name, null: false
  t.string :email
  t.timestamps
end

以下コマンドを実行すると user_type が作成されます。
(指定する型は ID がGraphQLで定義されているid用の型です(実態はString)
また語尾に ! が付いているものはnullを許容しない型となり、! が付いてないものはnull許容になります。)

$ bundle exec rails g graphql:object User id:ID! name:String! email:String

【補足】既にDBにテーブルが存在している場合はよろしくやってくれるっぽいので

$ bundle exec rails g graphql:object User

↑これでも大丈夫でした :sparkles:

生成されたファイル graphql/type/user_type.rb は以下のようになっていました。

module Types
  class UserType < Types::BaseObject
    field :id, ID, null: false
    field :name, String, null: false
    field :email, String, null: true
    field :created_at, GraphQL::Types::ISO8601DateTime, null: false
    field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
  end
end

既に生成されている graphql/type/query_type.rb に以下を追加します。

    field :users, [Types::UserType], null: false
    def users
      User.all
    end

http://localhost:3000/graphiql上で以下クエリを投げるとレスポンスが返ってくるかと思います。

{
  users {
    id
    name
    email
  }
}

Mutationsの作成

次にユーザーを作成するMutations CreateUser を作成してみたいと思います。

$ bundle exec rails g graphql:mutation CreateUser

graphql/mutations/create_user.rb が作成されるので、以下の様に修正します。

module Mutations
  class CreateUser < BaseMutation
    field :user, Types::UserType, null: true

    argument :name, String, required: true
    argument :email, String, required: false

    def resolve(**args)
      user = User.create!(args)
      {
        user: user
      }
    end
  end
end

既に生成されている graphql/types/mutation_type.rb に以下を追記します。

module Types
  class MutationType < Types::BaseObject
    field :createUser, mutation: Mutations::CreateUser # 追記
  end
end

http://localhost:3000/graphiql上で以下を実行するとUserが作成されます。

mutation {
  createUser(
    input:{
      name: "user"
      email: "user@email.com"
    }
  ){
    user {
      id
      name 
      email
    }
  }
}

Association

  • 1:1の関連テーブルの場合

例として PostLabel と1:1で関連付されている場合

label_type.rb
module Types
  class LabelType < Types::BaseObject
    field :id, ID, null: false
    field :name, String, null: false
    ...
  end
end
module Types
  class PostType < Types::BaseObject
    field :label, LabelType, null: true
  end
end

↑の様に labelLabelType として定義できます。
この場合の Query のイメージとしては

{
  posts {
    id
    label {
      id
      name
    }
  }
}

上記の様に labelLabelType として必要な値をQueryできます。

  • 1:Nの関連テーブルの場合

例として UserPost と1:Nの場合

module Types
  class PostType < Types::BaseObject
    field :id, ID, null: false
    field :label, LabelType, null: true
  end
end
module Types
  class UserType < Types::BaseObject
    field :posts, [PostType], null: false
  end
end

上記の様に posts[PostType] として定義でき、Queryとしては

{
  user(id: 1234) {
    id
    posts {
      id
      label {
        id
        name
      }
    }
  }
}

↑の様に呼び出す事ができます。

graphql-batch

↑の説明の様に 1:1や1:Nの関連テーブルのデータも取ってくる事ができますが
今のままだとDBへの問い合わせが大量に発生してしまう場合があります。
UserPost と1:Nの場合の例で Post が100件ある場合、それぞれ100回問い合わせが発生してしまいます。

そこで解決方法の一つである複数問い合わせをまとめやってくれる graphql-batch を導入してみます。

gem 'graphql-batch'

Gemをインストールしたら、loader を作成していきます。
loader は「複数問い合わせをまとめる」部分の実装になります。

graphql/loaders/record_loader.rb
module Loaders
  class RecordLoader < GraphQL::Batch::Loader
    def initialize(model)
      @model = model
    end

    def perform(ids)
      @model.where(id: ids).each { |record| fulfill(record.id, record) }
      ids.each { |id| fulfill(id, nil) unless fulfilled?(id) }
    end
  end
end

これを先程の PostLabel と1:1で関連付されている場合に適用すると

module Types
  class PostType < Types::BaseObject
    field :label, LabelType, null: true
    def label
      Loaders::RecordLoader.for(Label).load(object.label_id)
    end
  end
end

こんな感じで書けます。
UserPost と1:Nの場合には別途loaderを作成します。

graphql/loaders/association_loader.rb
module Loaders
  class AssociationLoader < GraphQL::Batch::Loader
    def self.validate(model, association_name)
      new(model, association_name)
      nil
    end

    def initialize(model, association_name)
      @model = model
      @association_name = association_name
      validate
    end

    def load(record)
      raise TypeError, "#{@model} loader can't load association for #{record.class}" unless record.is_a?(@model)
      return Promise.resolve(read_association(record)) if association_loaded?(record)
      super
    end

    # We want to load the associations on all records, even if they have the same id
    def cache_key(record)
      record.object_id
    end

    def perform(records)
      preload_association(records)
      records.each { |record| fulfill(record, read_association(record)) }
    end

    private

    def validate
      unless @model.reflect_on_association(@association_name)
        raise ArgumentError, "No association #{@association_name} on #{@model}"
      end
    end

    def preload_association(records)
      ::ActiveRecord::Associations::Preloader.new.preload(records, @association_name)
    end

    def read_association(record)
      record.public_send(@association_name)
    end

    def association_loaded?(record)
      record.association(@association_name).loaded?
    end
  end
end

※ loaderはgraphql-batchのリポジトリにサンプルがあるので、そちらを参考にして実装すると良さそうです

以下の様に書くと、まとめて問い合わせしてくれるようになります。

module Types
  class UserType < Types::BaseObject
    field :posts, [PostType], null: false
    def posts
      Loaders::AssociationLoader.for(User, :posts).load(object)
    end
  end
end

スキーマファイルからドキュメント生成

最後に定義したスキーマファイルから良い感じのドキュメントを自動で生成するようにしてみたいと思います。

routes.rb にマウントできてデプロイ毎に自動でgraphdocが更新される
便利なgemを探していたらgraphdoc-rubyというgemがあったので試してみます。

Gemfile に以下を追加

gem 'graphdoc-ruby'

また、npmパッケージの@2fd/graphdocも必要なので
予めDockerイメージ内でインストールしておきます。(Docker使用してない場合はローカル環境にインストールすれば良いかと思います)

例)

RUN set -ex \
    && wget -qO- https://deb.nodesource.com/setup_10.x | bash - \
    && apt-get update \
    && apt-get install -y \
                 ...
                 --no-install-recommends \
    && rm -rf /var/lib/apt/lists/* \
    && npm install -g yarn \
    && npm install -g @2fd/graphdoc # インストールしとく

config/routes.rb に以下を追記

config/routes.rb
Rails.application.routes.draw do
  mount GraphdocRuby::Application, at: 'graphdoc'
end

※ エンドポイントを変更している場合、config/initializers/graphdoc.rb を修正する

例)

GraphdocRuby.configure do |config|
  config.endpoint = 'http://0.0.0.0:3000/api/v1/graphql'
end

Railsを再起動して、http://localhost:3000/graphdoc でドキュメントが生成されればOKです :sparkles:

バッドノウハウ

  • http://localhost:3000/graphiql アクセス時に以下エラーが発生する場合

    Sprockets::Rails::Helper::AssetNotPrecompiled in GraphiQL::Rails::Editors#show
    
  • graphiqlの画面にTypeError: Cannot read property 'types' of undefined が表示される
    -> 手元の環境だとRails再起動で治りました

  • graphiqlの画面にSyntaxError: Unexpected token < in JSON at position 0 が表示される
    -> エラーが発生してる可能性がるのでログを見て修正する

参考になったURL

Discussion