以前は純Railsで開発を行うことが多かったんですが、最近はgraphql-ruby + SPAを採用することも増えています。
フロントの表現力がやっぱり違いますね。最近はリッチな要望をいただくことも多く、うまいこと対応するには都合が良いです。あと作ってて楽しい。

というわけで、今回はRailsをGraphQLサーバにするgem、graphql-rubyを使ったCRUDを紹介しようと思います。

Dockerに乗せたサンプルアプリを用意したので、ぜひ活用してください。(かなり面倒な)環境構築をスキップすることができます。
基本的にサンプルのコード前提で話を進めますのでその点ご了承ください。

やること

  • graphql-rubyを使った簡単なCRUD実装

やらないこと

  • GraphQLとは?の説明や前提知識の解説
  • 環境構築の解説
  • フロントの実装(リクエストはpostmanから投げます)

実装を読んで理解する → 書いてみる という流れで解説します。

サンプルアプリの実装について

軽く概要紹介とセットアップを行います。

サンプルアプリ概要

  • AuthorizationヘッダにJWTを入れてリクエストを投げるといい感じにUserを作ってくれるようになっています。
  • モデルはUserのみ。idとemailとnameだけ持ってます。
  • user取得のqueryと、userのname変更のmutationが実装されています。
  • それに必要なquery_typeやmutation_typeの設定、user_typeの作成も済んでいます。
  • (雑にサンプル用に改修したのでいらないものが残ってたりしますがご了承ください)

setup

まずはこちらのプロジェクトをcloneしてください。なお、Dockerが必要になります。

https://github.com/yubachiri/graphql-ruby-sample-app

また、http://localhost:3000 にPOSTを投げられるようにしておいてください。記事内ではpostmanを利用します。

以下のコマンドでセットアップが完了し、サーバが起動するはずです。

$ cp .env.example .env
$ make setup
$ make up

早速動作確認をしたいのですが、リクエストのヘッダにJWTを入れる必要があるのでこれも作っておきましょう。
(もうちょっとセキュアな実装になっていたものを記事用に簡略化したアプリなので、ちょっとめんどくさい形になっちゃってます。)

こちら でJWTを作りましょう。AlgorithmはRS256、payloadは適当なemailのみ渡せばOKです。こんなイメージです。

create-jwt

これをAuthorizationヘッダに入れておきます。一番下のやつ。

postman-jwt

これを見ているのは app/controllers/concerns/secured.rb なので、興味がある方は見てみても良いかもしれません。
decodeしてemail取り出してUserをfind_or_createしてるだけですが。
真面目にやるときはここでちゃんとJWTを検証しましょう。

リクエストを受け取るのは app/controllers/grpahql_controller で、ここでsecuredをincludeしてます。routesは /graphql のpostのみ。

では、まずは既存の実装から概要を説明します。

取得処理: query

userに関する取得処理(query)は実装されているので、早速試してみましょう。JWTを読んで勝手にcurrent_userを作っているので、一度でもリクエストを投げていればuserは取得できるはずです。 bodyにこれを入れて、 http://localhost:3000/graphql にPOSTを投げてみましょう。

query users {
    users {
        id
        email
    }
}

結果

users

自動で振られるUUIDと、JWTに入れていたemailで作成されたuserが取得できました。
そしたら実装箇所を追ってみましょうか。

post '/graphql', to: 'graphql#execute' 
# graphql_controller.rb

class GraphqlController < ApplicationController
  include Secured
  before_action :authenticate_api_key!, only: :execute if Rails.env.production?
  protect_from_forgery with: :null_session

  def execute
    variables = ensure_hash(params[:variables])
    query = params[:query]
    operation_name = params[:operationName]
    context = {
      user_signed_in: user_signed_in?,
      current_user: current_user,
    }
    result = AppSchema.execute(query, variables: variables, context: context, operation_name: operation_name)
    render json: result
  rescue => e
    raise e unless Rails.env.development?
    handle_error_in_development e
  end

以下略

securedでcurrent_userをうまいことしてからexecuteに入ります。
このうち AppSchema.execute が実処理になります。先ほどbodyに入れた文字列を見て、うまいこと処理を行います。これです。

query users {
    users {
        id
        email
    }
}

queryなので、 app/graphql/types/query_type.rb を見にいきます。
実際に行われる処理は上記文字列の二行目の users { というところを見ます。なので query_type のうち、これが呼ばれます。

field :users, [Types::Objects::UserType], null: false

def users
  User.all
end

内容を解説します。

まず一行目について。

field :フィールド名, 戻り値の型, return nullを許容するか: boolean という宣言になっています。

フィールド名と同名のメソッドが実処理です。User.allを返していますね。
このとき、メソッドの戻り値とfieldでの型定義に注意しましょう。型で定義されている値しか返すことができません。
現状のUserTypeにはidとemailのみ定義されています。

class Types::Objects::UserType < Types::BaseObject
  field :id, ID, null: false
  field :email, String, null: false
end

userモデルはnameも持っているのですが、typeに定義されていないので返すことができません。試しにリクエストを投げてみましょう。

name-error

UserTypeにnameは定義されていないという旨のエラーが返ってきました。ここにnameを足すと、正常に取得できるようになります。

class Types::Objects::UserType < Types::BaseObject
  field :id, ID, null: false
  field :email, String, null: true
  field :name, String, null: true
end

name-null

ここまでの流れをおさらいすると、

  1. リクエスト
  2. graphql_controller
  3. query_typeのfieldを見る
  4. query_typeのdefで定義された処理を行う
  5. 値を返す
  6. その値をtypeで検証する

6の時点では、returnされたオブジェクトをobjectから触ることができます。
試しに触ってみましょう。

class Types::Objects::UserType < Types::BaseObject
  field :id, ID, null: false
  field :email, String, null: false
  field :name, String, null: true

  def email
    object.id + object.email
  end
end

これで、emailで返されるのがid+emailの値になります。奇妙。

email-plus-id

nameがnullのままなのが気になるので、次はuserにnameを登録する処理を見てみます。

更新処理: mutation

GraphQLでは、追加・更新・削除はmutationが担当しますね。
すでにupdate_userが実装されているので、こちらを見ていきましょう。下記の2ファイルを見ればOKです。

# app/graphql/types/mutation_type

module Types
  class MutationType < Types::BaseObject
    field :update_user, mutation: Mutations::UpdateUser
  end
end

mutation_typeにはfieldが一つ定義されていますね。これは先ほどのquery_typeと同じように、クエリ文字列から探しにくるものです。
mutation updateUser { ... で呼ぶことができます。
呼ぶ際には引数を渡すことができますが、それはのちほど解説しますね。

で、実装の方はこうなっています。

# app/graphql/mutations/update_user

class Mutations::UpdateUser < Mutations::BaseMutation
  null false

  argument :name, String, required: true

  field :user, Types::Objects::UserType, null: false

  def resolve(name:)
    context[:current_user].update!(name: name)
    { user: context[:current_user] }
  end
end

上から順に解説します。

  • null false

mutationの戻り値にnullを許容するかという設定です。
null falseなので、後述のresolveがnilを返すとエラーになります。
return nilを許容するならtrueにすれば良いですし、デフォルトはtrueになっているので特に何も書かなくてもOKです。
参考: https://graphql-ruby.org/mutations/mutation_classes.html

  • argumentは読んだままで、受け取る引数の定義です。

複数受け取りたいときは同じような並べればOKです。
argument :名前, 型, 必須設定という構成ですね。
この3つが必須になっていて、required: trueも省略はできません。
試してエラーメッセージを確認してみるとよいでしょう。

  • fieldは、このmutationが返す値の設定です。

こちらも引数同様、複数並べることができます。
field :名前, 型, nullを許容するかです。
ここではuserを指定しているので、 { user: value } のようにuserをkeyにしたjsonを返すようにしないと怒られます。

  • resolveは実処理です。

ここにロジックを書きます。
うまいこと処理を行い、定義した型に沿った値を返せばOKです。

では、postmanから実行してみましょうか。

execute-update

こんなものを投げてます。コピペで動くと思います。

mutation updateUser($input: UpdateUserInput!) {
    updateUser(input: $input) {
        user {
            id
            email
            name
        }
    }
}
{
    "input": {
        "name": "hoge"
    }
}

気を付けるのは、引数の定義の部分ですね。 形としては、

mutation 好きな名前($input: アッパーキャメルmutation名 + Input!) {
  mutation名(input: $input) {
    戻り値

のような構造になります。GraphQLの仕様についてはここでは解説しないので、うまいことググってください。
引数はinputで受け取らなくてはならず、name: Stringのように受け取ることはできないというのがミソです。他の書き方も見かけますが、リクエストから引数を渡すにはこんな感じの書き方をする必要があるんじゃないかと思います。
少なくとも勝手にinputで囲われるのは確かです。 (追記) と思ってたんですが、勘違いかもしれません。普通に $id: ID! とかでも受け取れるみたいです。

これを実行すると、inputに応じたnameに更新されたuserが返される実装になっています。

また、inputをvalidationに引っかかる値にして例外を発生させると、graphql-rubyがGraphQLの仕様に準じたレスポンスを返してくれます。
たとえばこんなinputにしてみると…、

{
    "input": {
        "name": "this is too long name"
    }
}

こんなレスポンスになります。

{
    "data": null,
    "errors": [
        {
            "message": "Name is too long (maximum is 10 characters)",
            "locations": [
                {
                    "line": 3,
                    "column": 5
                }
            ],
            "path": [
                "updateUser"
            ],
            "extensions": {
                "code": "RECORD_INVALID",
                "record": {
                    "model": "User",
                    "id": "ba84fc51-6302-48c0-8c7b-8ca7f61f6e92",
                    "errors": {
                        "name": [
                            "is too long (maximum is 10 characters)"
                        ]
                    },
                    "messages": [
                        "Name is too long (maximum is 10 characters)"
                    ]
                }
            }
        }
    ]
}

と、以上がmutationの解説になります。
ここからは実際に手を動かして機能を実装してみましょう。

実践編

おなじみ、やることリストの登録を題材にやっていきます。といっても追加と読み出しだけなのでとても軽いです。
user has_many todos のような単純な構成とします。todoが持つのはtitleのみ。

準備

ということでモデルを作成しましょうか。

$ docker-compose run --rm app rails g model todo title:string user:references

userのidがuuidなので、migrationファイルをちょっと編集します。

class CreateTodos < ActiveRecord::Migration[6.0]
  def change
    create_table :todos, id: :uuid do |t|
      t.string :title
      t.references :user, type: :uuid, null: false, foreign_key: true

      t.timestamps
    end
  end
end

userの関連を定義するところで、type: :uuid を追記しておきましょう。でmigrateしてください。
したらuser.rbに has_many :todos も書いておいてください。

mutation編

さて、準備ができたらまずはcreateのmutationを実装してみましょう。railsコマンドでファイル作成ができます。

$ docker-compose run --rm app rails g graphql:mutation create_todo

mutation_typeへの追記と、実装を行うファイルの作成が行われます。

# mutation_type

module Types
  class MutationType < Types::BaseObject
    field :create_todo, mutation: Mutations::CreateTodo # これが追記されているはず
    field :update_user, mutation: Mutations::UpdateUser
  end
end

作成されるmutationファイル

# app/graphql/mutations/create_todo.rb

module Mutations
  class CreateTodo < BaseMutation
    # TODO: define return fields
    # field :post, Types::PostType, null: false

    # TODO: define arguments
    # argument :name, String, required: true

    # TODO: define resolve method
    # def resolve(name:)
    #   { post: ... }
    # end
  end
end

これを下記の内容に変えてください。

module Mutations
  class CreateTodo < BaseMutation
    field :todo, Types::Objects::TodoType, null: false

    argument :title, String, required: true

    def resolve(title:)
      todo = context[:current_user].todos.create!(title: title)
      { todo: todo }
    end
  end
end

このままだとTodoTypeがなくて怒られるので、そっちも作りましょう。こちらもコマンドがあります。

$ docker-compose run --rm app rails g graphql:object objects/todo

作成される app/graphql/types/objects/todo_type.rb を編集します。

module Types
  module Objects
    class TodoType < Types::BaseObject
      field :id, ID, null: false
      field :title, String, null: false
    end
  end
end

ここまでできたら動くはずなので、試してみましょう。

mutation createTodo($input: CreateTodoInput!) {
    createTodo(input: $input) {
        todo {
            id
            title
        }
    }
}
{
    "input": {
        "title": "first todo"
    }
}

create-mutate

そしたら作ったtodoを取得する、query編です。

query編

query_typeの編集

module Types
  class QueryType < Types::BaseObject

    ~略~

    field :users, [Types::Objects::UserType], null: false

    def users(page: nil, items: nil)
      User.all
    end

    # 以下追記

    field :todos, [Types::Objects::TodoType], null: false

    def todos
      context[:current_user].todos.all
    end

    field :todo, Types::Objects::TodoType, null: true do
      argument :id, ID, required: true
    end

    def todo(id:)
      context[:current_user].todos.find(id)
    end
  end
end

戻り値は[]で囲むことで、複数返ることを表現できます。
todosは複数、todoはひとつ返るような定義ですね。

なお取得時はN+1問題を回避するためgraphql-batchを使うようにすると素敵です。
もともと書いてある、userで使っているような感じです。

https://github.com/Shopify/graphql-batch

queryの実装はこれだけなので、実際に取得してみましょう。

自分の全todoの取得(あらかじめcreateTodoで作成しておいてください)

query todos {
    todos {
        id
        title
    }
}

idを指定して取得する

query todo($id: ID!) {
    todo(id: $id) {
        id
        title
    }
}
{
    "id": "自分のtodoのidを指定してください"
}

by-id

良いですね。ばっちり取得できました。

おまけ: 関連編

関連の取得も楽々です。ユーザー情報と、ユーザーに紐づくtodoを一気に取得してみましょう。

# user_type

class Types::Objects::UserType < Types::BaseObject
  field :id, ID, null: false
  field :email, String, null: true
  field :name, String, null: true
  # 追記
  field :todos, [Types::Objects::TodoType], null: true

end

queryを投げてみる

query userWithTodos {
    currentUser {
        id
        email
        name
        todos {
            id
            title
        }
    }
}

結果

with-todo

良きですね。また、例えば更新順にして返したい場合は、こんな感じで実装してあげればOKです。

class Types::Objects::UserType < Types::BaseObject

  ~略~

  field :todos, [Types::Objects::TodoType], null: true

  def todos
    object.todos.order(updated_at: :desc)
  end
end

おまけ: graphiql編

今回はpostmanからリクエストを送っていましたが、もっと簡単な方法があります。それが、 routes.rbで/graphql の他に定義されているアレです。使ってみましょう。

http://localhost:3000/graphiql

graphiql

こんな画面が開くと思います。

ここにクエリ文字列を書き、再生ボタンのようなものを押すとリクエストの結果を右側に表示してくれます。

suggest

docs

諸々のファイルを見て入力補完してくれたり、画面右側には自動生成のドキュメントを置いておいてくれたりします。超すごい。便利。

認証回りの流れをふわっと見ていただきたかったのでpostmanを使っていましたが、気軽に触って遊ぶならこれで十分すぎる感じもあります。ぜひ活用してください。

まとめ

graphql-rubyでのcreateとreadを実装してみました。updateとdeleteも似たようなmutationを書けば良いので、実質CRUDをすべて解説しました(したことにしてください)。

環境構築からやるとなると、サーバもフロントも大変ですが…。その分快適な開発体験が得られます。良き。