我想在graphql-ruby中限制根据权限访问的字段
由于GraphQL成为话题已经有一段时间了,我已经没有借口再不去尝试一下,所以我决定轻轻地涉猎一下。
这次我打算使用graphql-ruby来实现一个能根据访问权限获取用户信息的终端点。
环境
-
- ruby 2.6.3
-
- Rails 6.0.0
- graphql 1.9.12
要教育人们合理使用和管理时间的重要性。
- 普段RESTで下記のようなノリで実装しているエンドポイントをGraphQLで実現する。
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
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
gem 'graphql-ruby'
$ bundle install
$ bundle exec rails g graphql:install
有很多文件会自动生成,但只需大致掌握以下内容即可。
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 を利用して権限ごとのアクセス制限を実装する。
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#response に相当する、一覧用Userオブジェクトの各フィールドを定義する。
module Types
class UserTypes::Base < Types::BaseObject
field :id, ID, null: false
field :name, String, null: true
end
end
-
- これで「idとnameを持つUserTypes::Baseというオブジェクト」を定義できた。
- 次にこれをエンドポイントからアクセスできるように、 QueryType にフィールドを追加する。
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 メソッドを追加する。
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 形式で返したい。
定义日期型
-
- GraphQLは最初 String, Integer, Boolean, ISO8601DateTime, JSONという5つの型しか用意されていない。 参考: Docs
それ以外の型は自分で定義を用意する必要がある。
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 オブジェクトの型を定義していく。
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 として渡すことにする。
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。
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を使ってみたいとは思っている)。