以前は純Railsで開発を行うことが多かったんですが、最近はgraphql-ruby + SPAを採用することも増えています。
フロントの表現力がやっぱり違いますね。最近はリッチな要望をいただくことも多く、うまいこと対応するには都合が良いです。あと作ってて楽しい。
というわけで、今回はRailsをGraphQLサーバにするgem、graphql-rubyを使ったCRUDを紹介しようと思います。
Dockerに乗せたサンプルアプリを用意したので、ぜひ活用してください。(かなり面倒な)環境構築をスキップすることができます。
基本的にサンプルのコード前提で話を進めますのでその点ご了承ください。
やること
やらないこと
- 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です。こんなイメージです。
これをAuthorizationヘッダに入れておきます。一番下のやつ。
これを見ているのは 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 } }
結果
自動で振られる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に定義されていないので返すことができません。試しにリクエストを投げてみましょう。
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
ここまでの流れをおさらいすると、
- リクエスト
- graphql_controller
- query_typeのfieldを見る
- query_typeのdefで定義された処理を行う
- 値を返す
- その値を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の値になります。奇妙。
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から実行してみましょうか。
こんなものを投げてます。コピペで動くと思います。
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" } }
そしたら作った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を指定してください" }
良いですね。ばっちり取得できました。
おまけ: 関連編
関連の取得も楽々です。ユーザー情報と、ユーザーに紐づく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 } } }
結果
良きですね。また、例えば更新順にして返したい場合は、こんな感じで実装してあげれば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
こんな画面が開くと思います。
ここにクエリ文字列を書き、再生ボタンのようなものを押すとリクエストの結果を右側に表示してくれます。
諸々のファイルを見て入力補完してくれたり、画面右側には自動生成のドキュメントを置いておいてくれたりします。超すごい。便利。
認証回りの流れをふわっと見ていただきたかったのでpostmanを使っていましたが、気軽に触って遊ぶならこれで十分すぎる感じもあります。ぜひ活用してください。
まとめ
graphql-rubyでのcreateとreadを実装してみました。updateとdeleteも似たようなmutationを書けば良いので、実質CRUDをすべて解説しました(したことにしてください)。
環境構築からやるとなると、サーバもフロントも大変ですが…。その分快適な開発体験が得られます。良き。