GraphQLが話題になってからしばらく経ってしまい、そろそろ言い訳もきかなくなってきたので軽く触ってみることにした。
今回は graphql-ruby を用いて、アクセス権限に応じたユーザー情報を fetch できるエンドポイントを実装してみる。
環境
- ruby 2.6.3
- Rails 6.0.0
- graphql 1.9.12
目標
- 普段RESTで下記のようなノリで実装しているエンドポイントをGraphQLで実現する。
app/controllers/users_controller.rb
class UsersController < ApplicationController
before_action :authenticate_user!, only: [:me]
def index
users = User.all
render json: { users: users.map(&:reponse) }
end
def show
user = User.find(params[:id])
render json: { user: user.detail }
end
def me
render json: { me: current_user.me }
end
end
app/models/user.rb
class User < ApplicationRecord
def response
{
id: id,
name: name
}
end
def detail
response.merge(
age: age
)
end
def me
detail.merge(
email: email,
birthday: birthday&.strftime("%Y-%m-%d")
)
end
end
要するに、下記を満たすエンドポイントを実装したい。
- ユーザー一覧では id, name にのみアクセスできる
- ユーザー詳細では id, name, age にアクセスできる
- 本人によるリクエストの場合のみ id, name, age に加えて email, birthday にアクセスできる
事前準備
- users テーブルを作っておく。
$ bundle exec rails db:create
$ bundle exec rails g model user name email birthday:date
$ bundle exec rails db:migrate
GraphQLの導入
Gemfile
gem 'graphql-ruby'
$ bundle install
$ bundle exec rails g graphql:install
色んなファイルが自動で生成されるが、ざっくり下記だけ抑えておけばOK。
app/controllers/graphql_controller.rb
class GraphqlController < ApplicationController
def execute
variables = ensure_hash(params[:variables])
query = params[:query]
operation_name = params[:operationName]
context = {
# Query context goes here, for example:
# 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
# === 以下省略 ===
end
- GraphQLのエンドポイントとなるコントローラ。
AppSchema.execute
でクエリを実行している。 - 後で具体的に説明するけど、
context
を利用して権限ごとのアクセス制限を実装する。
app/graphql/types/query_type.rb
module Types
class QueryType < Types::BaseObject
# Add root-level fields here.
# They will be entry points for queries on your schema.
# TODO: remove me
field :test_field, String, null: false,
description: "An example field added by the generator"
def test_field
"Hello World!"
end
end
end
- この
QueryType
に field を追加することで、エンドポイントからアクセスできるオブジェクトを追加していく。
Userオブジェクトの定義を追加
-
目標の
User#response
に相当する、一覧用Userオブジェクトの各フィールドを定義する。
app/graphql/types/user_types/base.rb
module Types
class UserTypes::Base < Types::BaseObject
field :id, ID, null: false
field :name, String, null: true
end
end
- これで「idとnameを持つUserTypes::Baseというオブジェクト」を定義できた。
- 次にこれをエンドポイントからアクセスできるように、 QueryType にフィールドを追加する。
app/graphql/types/query_type.rb
module Types
class QueryType < Types::BaseObject
field :users, [UserTypes::Base], null: true do
description "User List"
end
def users
User.all
end
end
end
- 元あった
test_field
みたいなのは要らないので消して、ユーザー一覧を取得できるフィールドを追加。 -
[UserTypes::Base]
が、さっき定義したUserオブジェクトを配列でレスポンスで返すよ、という意味。 - users というオブジェクトを要求した時に実際に呼び出される処理が
def users
に定義されている。今回は引数などは無しで単にUser.all
を呼び出すことにする。 - ここまで書くと、 rails console でクエリの実行結果を確認できる。
> User.create(name: "Taro", birthday: "1990-10-25", email: "xxx@gmail.com")
> AppSchema.execute("{ users() { id, name } }").to_h
# => {"data"=>{"users"=>[{"id"=>"1", "name"=>"Taro"}]}}
- 文字列で
{ users() { id, name } }
というのがGraphQLのクエリ。 - 無事にidとnameを持つオブジェクトを配列で取得できていることが分かる。
詳細表示用のオブジェクト定義を追加
- 同じ要領で、今度は
UserTypes::Detail
を追加する。
module Types
class UserTypes::Detail < Types::UserTypes::Base
field :age, Integer, null: true
end
end
- さっき作った
Types::UserTypes::Base
を継承することで id, name を重複して定義しなくて済んでいる - ところで、 users テーブルに age というカラムは無い。birthdayカラムから計算して出したいので、 User モデルに
User#age
メソッドを追加する。
app/models/user.rb
class User < ApplicationRecord
def age
birthday && (Date.today.strftime("%Y%m%d").to_i - birthday.strftime("%Y%m%d").to_i) / 10000
end
end
- こうするとGraphQLでもageフィールドを生やすことができる。
- あとは QueryType クラスに User 詳細を取得するフィールドを追加する。
module Types
class QueryType < Types::BaseObject
description "The query root of this schema"
field :users, [UserTypes::Base], null: false do
description "User List"
end
field :user, UserTypes::Detail, null: false do
description "User Detail"
argument :id, ID, required: true
end
def users
User.all
end
def user(id:)
User.find(id)
end
end
end
- user というフィールドを追加して、 UserTypes::Detail を型定義として指定している。
- ブロック内の
argument :id, ID, required: true
で、GraphQLのクエリで引数IDを必須で取ることを定義している。 -
def user
で引数idに応じたUserインスタンスを取得する処理を定義している。 - rails console でクエリの実行結果を確認してみよう。
> AppSchema.execute("{ user(id: 1) { id, name, age } }").to_h
# => {"data"=>{"user"=>{"id"=>"1", "name"=>"Taro", "age"=>28}}}
- 無事に age フィールドを取得できていることが分かる。
ユーザー認証を通ったユーザーにのみ特定フィールドのアクセスを許可する
- 最後に、誕生日とメールアドレスは本人のみアクセスできるようにする。
- ついでに誕生日は Date 型なので、レスポンス時は文字列の
YYYY-MM-DD
形式で返したい。
Date型を定義する
- GraphQLは最初 String, Integer, Boolean, ISO8601DateTime, JSONという5つの型しか用意されていない。 参考: Docs
- それ以外の型は自分で定義を用意する必要がある。
app/graphql/types/date_type.rb
class Types::DateType < Types::BaseScalar
RESPONSE_FORMAT = "%Y-%m-%d"
description "A date transported as a string"
class << self
def coerce_input(input, context)
Date.parse(input)
end
def coerce_result(value, context)
value.strftime(RESPONSE_FORMAT)
end
end
end
- 独自定義の型には coerce_input と coerce_result というクラスメソッドを追加する必要がある。
- 今回 mutation を使わないので coerce_input の方はどうでも良いが、 coerce_result はレスポンスの定義なので返したいフォーマットを指定しておく。
- 本当は同じ要領で email も独自定義の型を用意して、文字列がメールアドレスになってるかどうかバリデーションかけることもできるのだけど、今回は mutation を使わないので省略する。
UserTypes::Me オブジェクトを定義する
- 上記で追加した DateType を使って、 email と birthday を含む User オブジェクトの型を定義していく。
app/graphql/types/user_types/me.rb
module Types
class UserTypes::Me < Types::UserTypes::Detail
field :birthday, DateType, null: true
field :email, String, null: true
end
end
- 例によって
Types::UserTypes::Detail
を継承することで id, name, age を重複して定義しなくても済むようにしている - birthday フィールドの型定義に、さっき作った
DateType
を指定している。(本当はTypes::DateType
なのだけど、このUserTypes::Me
クラスもTypesモジュールのスコープ内にあるのでTypes::
を省略できる)
ユーザー認証の結果をGraphQLに反映する
- アクセス権限を判定するため、コントローラ側で current_user を取得して AppSchema に渡す
- 本来はユーザー認証ロジックを書くべきだが、それは本稿のスコープ外なので単に
User.first
を認証された current_user として渡すことにする。
app/controllers/graphql_controller.rb
class GraphqlController < ApplicationController
def execute
variables = ensure_hash(params[:variables])
query = params[:query]
operation_name = params[:operationName]
context = {
current_user: User.first,
}
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
# === 以下省略 ===
end
- こうすることで、 QueryTypes 等のクラス内で使える context というハッシュ経由で current_user にアクセスできるようになる。
QueryType に本人のみがアクセスできる User オブジェクトを定義する
app/graphql/types/query_type.rb
module Types
class QueryType < Types::BaseObject
description "The query root of this schema"
field :users, [UserTypes::Base], null: false do
description "User List"
end
field :user, UserTypes::Detail, null: false do
description "User Detail"
argument :id, ID, required: true
end
field :me, UserTypes::Me, null: false do
description "Self Detail"
end
def users
User.all
end
def user(id:)
User.where(id: id).first
end
def me
context[:current_user] || {}
end
end
end
-
field :me, UserTypes::Me
を追加した。 -
def me
内で context[:current_user] をクエリ対象のオブジェクトとして返している。 - これを
UserTypes::Me
型としてシリアライズするので、本人しかアクセスできない! - rails console でクエリ実行結果を見てみる。
> AppSchema.execute("{ me() { id, name, age, birthday, email } }", context: { current_user: User.first }).to_h
# => {"data"=>{"me"=>{"id"=>"1", "name"=>"Taro", "age"=>28, "birthday"=>"1990-10-25", "email"=>"xxx@gmail.com"}}}
- console で試す場合はキーワード引数
context
にハッシュで current_user を渡す。 - birthday が無事に
YYYY-MM-DD
形式の文字列で返ってきていることが分かる。
まとめ
- 以上の手順で「権限に応じてアクセスできるフィールドを変える」という実装が実現した。
-
Types::BaseObject
を継承したXxxType
クラスはシリアライザーみたいなものなんだなと理解した。 -
context
をうまく使えばユーザー認証に応じてレスポンスを出し分ける処理は書けそう。 - 今回は User に関わるフィールドだったので QueryType 内で制御するだけで済んだけど、実際に認証結果に応じたフィールドの制限とかは Authorization / Authentication みたいな方法があるらしいよ。
- この後は関連モデルのJOIN(GraphQLで言うところのconnection)とかwhere句の書き方とかN+1の解決方法とかを勉強してみたい。そこまでできれば、最低限のアプリケーションを作るための基礎は培えそうな気がする。
- client側の実装についてはまた今度(TypeScriptでApolloを使ってみたいとは思っている)。